feat(installer): bundle URL Rewrite, ask where client IPs come from, verify installs

IIS does not set X-Forwarded-For on its own and HttpPlatformHandler connects
from loopback, so without a rewrite rule every client reads as 127.0.0.1. The
GE-Enforce IP allowlist, the dashboard visitor-location lookup and per-host
login rate limiting all stop working, silently. The rule needed URL Rewrite,
which the installer told operators to download - from an air-gapped server.

URL Rewrite now ships in the bundle, and the wizard asks which case applies,
because the two answers are mutually exclusive. Directly exposed: install it and
set X-Forwarded-For from REMOTE_ADDR, which is what stops a client spoofing its
own. Behind a proxy: leave the rule off, since REMOTE_ADDR is the proxy and
applying it would discard the real client IP.

The rule is enabled by deleting two explicit marker lines rather than by a regex
over the surrounding comment, so editing that prose cannot silently disable it.

An existing web.config is no longer overwritten. It is the one file on a server
that legitimately carries hand-edits, and replacing it reverted them without a
word - on a server where the X-Forwarded-For rule had been enabled by hand, that
alone would have turned the GE-Enforce IP allowlist off. The installer reports
what it found instead.

pip now runs with --require-hashes and --only-binary=:all:. Hash-checking is
requested explicitly rather than inferred from the lockfile, so shipping an
unhashed requirements.txt fails loudly instead of quietly dropping the check.

shopdb-admin.ps1 gains a verify command: which bundle this server was installed
from, and whether the installed packages still match what shipped.

The .iss states its compiler floor. WizardStyle uses the built-in windows11
custom style, which needs Inno Setup 6.6.0; older compilers now fail with that
sentence rather than 'WizardStyle is invalid'.
This commit is contained in:
cproudlock
2026-08-03 11:17:58 -04:00
parent 88af7fd9ce
commit 44237b5cbd
5 changed files with 286 additions and 19 deletions

View File

@@ -23,7 +23,13 @@
wheels\*.whl
app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...)
httpplatformhandler\httpPlatformHandler_amd64.msi
urlrewrite\ (optional, -ClientIpSource direct)
mysql\ (optional, bundled-database option)
bundle-lock.json + bundle-lock.ps1
The payload is checked against bundle-lock.json before anything runs. A
bundle whose wheels or MSIs do not match the lock exactly - missing, extra or
altered - is refused, because every one of them executes as SYSTEM here.
.PARAMETER DbHost
Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER -
@@ -102,6 +108,19 @@ param(
# installer checks that and refuses if they disagree.
[string] $MountAlias = '',
[string] $ParentSite = 'Default Web Site',
# How this server learns a request's real client IP.
#
# direct IIS is exposed to clients. Install URL Rewrite from the bundle
# and set X-Forwarded-For from REMOTE_ADDR. Overwriting the header
# is what stops a client spoofing it.
# proxy A reverse proxy (ARR, load balancer) sits in front and already
# sets X-Forwarded-For. Leave the rule off - REMOTE_ADDR would be
# the proxy, so applying it would DESTROY the real client IP.
#
# Without one or the other, IIS sends no X-Forwarded-For at all and every
# client reads as 127.0.0.1: the GE-Enforce IP allowlist, the dashboard
# visitor-location lookup and per-host login rate limiting all break quietly.
[ValidateSet('direct','proxy')] [string] $ClientIpSource = 'direct',
[switch] $WhatIfOnly,
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
[ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
@@ -236,6 +255,45 @@ $AppSource = Join-Path $BundleRoot 'app'
# - the database is backed up BEFORE migrations touch it;
# - a failed migration puts the backup back rather than leaving a half-state.
function Assert-BundleIntegrity {
<#
Refuse to run against a payload that is not exactly what was built and
reviewed.
This runs BEFORE anything is installed, because everything downstream is an
executable that runs as SYSTEM on this server: the Python installer, the
HttpPlatformHandler and URL Rewrite MSIs, and ~40 wheels. requirements.txt
hashes cover the wheels once pip gets to them; nothing covered the rest,
and nothing noticed a stale extra wheel sitting in the wheelhouse.
There is deliberately no override. A bundle that fails here was altered
after it was built, and the fix is to get a correct bundle rather than to
wave this one through on a server nobody can see.
#>
$checker = Join-Path $BundleRoot 'bundle-lock.ps1'
$lockFile = Join-Path $BundleRoot 'bundle-lock.json'
if (-not (Test-Path $checker)) {
Fail 'bundle-lock.ps1 is missing from the bundle' `
'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
}
if (-not (Test-Path $lockFile)) {
Fail 'bundle-lock.json is missing from the bundle' `
'This bundle was not assembled by build-installer.ps1/.sh. Rebuild it.'
}
. $checker
$lock = Read-BundleLock $lockFile
$problems = Test-BundleLock -BundleRoot $BundleRoot -Lock $lock
if ($problems.Count -gt 0) {
foreach ($p in $problems) { Write-Log " $p" 'FAIL' }
Fail ("the bundle does not match bundle-lock.json ({0} problem(s))" -f $problems.Count) @'
The payload was changed after this installer was built. Do not install from it.
Obtain a bundle whose payload matches its lock, or rebuild one and re-compile.
'@
}
Write-Log ("bundle payload verified against bundle-lock.json ({0}, {1})" -f `
(Get-JsonProperty $lock 'pythontag' 'unknown'), (Get-JsonProperty $lock 'platform' 'unknown')) 'OK'
}
function Get-BundleVersion {
# The version being installed, read from the payload itself so it can never
# disagree with the code that is about to be copied.
@@ -642,8 +700,9 @@ function Invoke-Stage2 {
Write-Log 'STAGE 2: runtime and configuration' 'STEP'
if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" }
if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Build it on Windows with the matching Python. See build-offline-bundle-native.ps1.' }
if (-not (Test-Path $WheelDir)) { Fail "wheelhouse not found: $WheelDir" 'Rebuild the bundle with deploy\windows\installer\build-installer.ps1 (or .sh); it refuses to produce a bundle without one.' }
if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" }
Assert-BundleIntegrity
# --- Python, all users --------------------------------------------------
# A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
@@ -765,6 +824,12 @@ a page that cannot load its own assets. Rebuild with scripts/build-site.sh
Set-Content -Path (Join-Path $AppRoot '.installed-version') `
-Value $script:BundleVersion -Encoding ASCII
}
# Keep the lock with the install so `shopdb-admin.ps1 verify` can say WHICH
# bundle this server was built from, months later and offline.
if (-not $WhatIfOnly) {
$bundledLock = Join-Path $BundleRoot 'bundle-lock.json'
if (Test-Path $bundledLock) { Copy-Item $bundledLock $AppRoot -Force }
}
foreach ($sub in @('logs','instance')) {
$p = Join-Path $AppRoot $sub
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
@@ -784,13 +849,27 @@ a page that cannot load its own assets. Rebuild with scripts/build-site.sh
# --- offline dependency install ----------------------------------------
# PIP_NO_INDEX makes a network attempt impossible rather than merely
# unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts.
Write-Log 'installing dependencies from the wheelhouse (offline)'
#
# --require-hashes puts pip in hash-checking mode: every wheel must match a
# sha256 listed in requirements.txt or the install ABORTS. Without it pip
# took whatever file in the wheelhouse satisfied the version pin, so a
# swapped or hand-dropped wheel installed silently. The flag is passed
# explicitly rather than relying on pip inferring it from the presence of
# hashes, so shipping an unhashed requirements.txt fails loudly here instead
# of quietly dropping the check.
#
# --only-binary=:all: refuses to fall back to building from an sdist. On a
# server with no compiler and no network that fallback cannot succeed; it
# just turns a clear "no wheel for this platform" into a confusing build
# error deep in someone's setup.py.
Write-Log 'installing dependencies from the wheelhouse (offline, hash-checked)'
if (-not $WhatIfOnly) {
$env:PIP_NO_INDEX = '1'
$env:PIP_FIND_LINKS = $WheelDir
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
try {
Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir",
'--require-hashes','--only-binary=:all:',
'-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install'
} finally {
Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue
@@ -1230,18 +1309,83 @@ the module by hand, then re-run stage 4.
}
}
# web.config ships in the repo; only its paths need correcting.
# --- client IP -----------------------------------------------------------
# URL Rewrite is what lets the XFF rule below exist. Install it BEFORE
# writing a web.config that references <rewrite>, or IIS answers every
# request with 500.19 until someone works out which module is missing.
if ($ClientIpSource -eq 'direct') {
$rewriteDll = Join-Path $env:windir 'system32\inetsrv\rewrite.dll'
if (Test-Path $rewriteDll) {
Write-Log 'URL Rewrite already installed' 'OK'
} else {
$rwMsi = Get-ChildItem (Join-Path $BundleRoot 'urlrewrite') -Filter '*.msi' -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $rwMsi) {
Fail 'URL Rewrite is not installed and no MSI is in the bundle' @'
-ClientIpSource direct needs the IIS URL Rewrite module to set X-Forwarded-For.
Add urlrewrite\rewrite_amd64.msi to the bundle and rebuild, or re-run with
-ClientIpSource proxy if something in front of IIS already sets the header.
Installing it later by hand also works: this server has no network, so the MSI
has to come from the bundle either way.
'@
}
Write-Log "installing $($rwMsi.Name)"
if (-not $WhatIfOnly) {
Invoke-Native 'msiexec.exe' @('/i', $rwMsi.FullName, '/quiet', '/norestart') `
'URL Rewrite MSI' -TimeoutSec 600
if (-not (Test-Path $rewriteDll)) {
Fail 'the URL Rewrite MSI reported success but the module is missing' `
'An installer exit code of 0 does not prove it did what was asked.'
}
Write-Log 'URL Rewrite installed' 'OK'
}
}
} else {
Write-Log 'client IP comes from an upstream proxy; not installing URL Rewrite' 'OK'
}
# web.config ships in the repo; its paths need correcting and the client-IP
# rule needs enabling or leaving off.
#
# An EXISTING web.config is never overwritten. It is the one file on the
# server that legitimately carries hand-edits - a nested application for
# /installers, bindings, a proxy-specific rule - and overwriting it reverts
# them silently. On a server where the XFF rule was enabled by hand, that
# alone would turn the GE-Enforce IP allowlist off without a word in any log.
$srcCfg = Join-Path $AppRoot 'deploy\windows\web.config'
$dstCfg = Join-Path $AppRoot 'web.config'
if (Test-Path $srcCfg) {
Write-Log 'installing web.config'
if (Test-Path $dstCfg) {
Write-Log 'web.config already exists; leaving it alone' 'OK'
$existing = Get-Content $dstCfg -Raw
$hasRule = $existing -match '<rewrite>' -and $existing -notmatch 'SHOPDB-CLIENTIP-BEGIN'
if ($ClientIpSource -eq 'direct' -and -not $hasRule) {
Write-Log 'this web.config does NOT set X-Forwarded-For; client IPs will read as 127.0.0.1' 'WARN'
Write-Log ' enable the SHOPDB-CLIENTIP block by hand, or delete web.config and re-run stage 4' 'WARN'
}
if ($ClientIpSource -eq 'proxy' -and $hasRule) {
Write-Log 'this web.config OVERWRITES X-Forwarded-For, which discards the real client IP behind a proxy' 'WARN'
Write-Log ' remove the <rewrite> block by hand if a proxy in front already sets the header' 'WARN'
}
} elseif (Test-Path $srcCfg) {
Write-Log "installing web.config (client IP: $ClientIpSource)"
if (-not $WhatIfOnly) {
$cfg = Get-Content $srcCfg -Raw
$cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot)
if ($ClientIpSource -eq 'direct') {
# Uncomment by deleting the two marker lines. A string operation
# on explicit markers, not a regex over the surrounding prose:
# editing that prose must never silently disable the rule.
if ($cfg -notmatch 'SHOPDB-CLIENTIP-BEGIN') {
Fail 'the shipped web.config has no SHOPDB-CLIENTIP block' `
'It was edited. Restore deploy\windows\web.config from the repository.'
}
$cfg = $cfg.Replace('<!-- SHOPDB-CLIENTIP-BEGIN', '').Replace('SHOPDB-CLIENTIP-END -->', '')
Write-Log 'X-Forwarded-For rule enabled' 'OK'
}
Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8
Track 'file' $dstCfg
}
} elseif (-not (Test-Path $dstCfg)) {
} else {
Fail "web.config not found at $srcCfg or $dstCfg"
}