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

@@ -22,7 +22,7 @@
[CmdletBinding()]
param(
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
'check','sessions','plugins','add-plugin','uninstall')]
'check','sessions','plugins','add-plugin','verify','uninstall')]
[string] $Command = 'menu',
[string] $Path = '',
[string] $AppRoot = 'C:\shopdb-flask',
@@ -473,6 +473,69 @@ function Add-Plugin {
Say (" {0} added" -f $Name) 'Green'
}
function Invoke-Verify {
<#
Prove the installed dependencies are still the ones that shipped.
Two independent records, both written at install time:
requirements.txt a sha256 for every wheel, checked by pip on install
bundle-lock.json the exact third-party payload of the bundle
This re-checks what can still be checked on a live server. It reports; it
changes nothing.
#>
Head 'Verify'
$lock = Join-Path $AppRoot 'bundle-lock.json'
if (Test-Path $lock) {
try {
$l = Get-Content $lock -Raw | ConvertFrom-Json
Say (" installed from : {0} bundle ({1}, {2})" -f $l.generated, $l.pythontag, $l.platform)
} catch { Say ' bundle-lock.json is present but unreadable' 'Yellow' }
} else {
Say ' no bundle-lock.json recorded (installed before payload locking, or by hand)' 'Yellow'
}
# pip's own audit. It re-reads the metadata of what is actually installed and
# reports anything missing or version-inconsistent, which is the part that
# can still drift after install - a hand-run `pip install` on the server.
$py = Join-Path $AppRoot 'venv\Scripts\python.exe'
if (-not (Test-Path $py)) { Say ' no venv - application not installed' 'Red'; return }
Say ''
Say ' checking installed packages against requirements.txt...'
$req = Join-Path $AppRoot 'requirements.txt'
if (-not (Test-Path $req)) { Say ' requirements.txt is missing from the install' 'Red'; return }
$wanted = @{}
foreach ($line in (Get-Content $req)) {
if ($line -match '^([A-Za-z0-9._-]+)==([^ \\]+)') { $wanted[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
}
$frozen = @{}
foreach ($line in (& $py -m pip freeze 2>$null)) {
if ($line -match '^([A-Za-z0-9._-]+)==(.+)$') { $frozen[$Matches[1].ToLower().Replace('_','-')] = $Matches[2] }
}
$bad = 0
foreach ($name in ($wanted.Keys | Sort-Object)) {
if (-not $frozen.ContainsKey($name)) {
Say (" MISSING {0} {1}" -f $name, $wanted[$name]) 'Red'; $bad++
} elseif ($frozen[$name] -ne $wanted[$name]) {
Say (" DIFFERS {0} {1} installed, {2} expected" -f $name, $frozen[$name], $wanted[$name]) 'Red'; $bad++
}
}
if ($bad -eq 0) {
Say (" all {0} packages match" -f $wanted.Count) 'Green'
} else {
Say ''
Say (" {0} package(s) do not match what shipped." -f $bad) 'Red'
Say ' Something was installed or upgraded on this server by hand. Re-run the' 'Yellow'
Say ' installer to put the shipped set back.' 'Yellow'
}
& $py -m pip check 2>&1 | ForEach-Object { Say " $_" 'DarkGray' }
}
function Show-Uninstall {
Head 'Uninstall'
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
@@ -492,13 +555,15 @@ function Show-Menu {
Write-Host ' 2 Stop the application 7 Worker processes' -ForegroundColor White
Write-Host ' 3 Start the application 8 Open in browser' -ForegroundColor White
Write-Host ' 4 View recent logs 9 Plugins' -ForegroundColor White
Write-Host ' 5 Health check 0 Exit' -ForegroundColor White
Write-Host ' 5 Health check V Verify this install' -ForegroundColor White
Write-Host ' 0 Exit' -ForegroundColor White
Write-Host ''
$c = Read-Host ' Choose'
switch ($c) {
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
'7' { Show-Sessions } '8' { Open-Site }
'v' { Invoke-Verify } 'V' { Invoke-Verify }
'9' { Show-Plugins
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
if ($add) { Add-Plugin $add } }
@@ -524,6 +589,7 @@ switch ($Command) {
'sessions' { Show-Sessions }
'plugins' { Show-Plugins }
'add-plugin'{ Add-Plugin $Path }
'verify' { Invoke-Verify }
'uninstall' { Show-Uninstall }
default { Show-Menu }
}