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:
@@ -9,6 +9,14 @@
|
|||||||
; that nobody tests. This file only collects operator input, runs the stages in
|
; that nobody tests. This file only collects operator input, runs the stages in
|
||||||
; order, and reports which one failed.
|
; order, and reports which one failed.
|
||||||
|
|
||||||
|
; Inno Setup 6.6.0 is the floor: WizardStyle below uses the built-in 'windows11'
|
||||||
|
; custom style, which 6.5 and earlier reject. Stated here so a build box on an
|
||||||
|
; older compiler fails with this sentence rather than with a bare
|
||||||
|
; "Value of [Setup] section directive WizardStyle is invalid".
|
||||||
|
#if VER < EncodeVer(6,6,0)
|
||||||
|
#error This script needs Inno Setup 6.6.0 or newer (WizardStyle=... windows11). Download it from https://jrsoftware.org/isdl.php
|
||||||
|
#endif
|
||||||
|
|
||||||
#define AppName "ShopDB-Flask"
|
#define AppName "ShopDB-Flask"
|
||||||
; Pre-release. This has not shipped, so it is 0.x by definition. It becomes
|
; Pre-release. This has not shipped, so it is 0.x by definition. It becomes
|
||||||
; 1.0.0 when a real site installs from it successfully - not before.
|
; 1.0.0 when a real site installs from it successfully - not before.
|
||||||
@@ -173,6 +181,7 @@ var
|
|||||||
DbPageReady: Boolean;
|
DbPageReady: Boolean;
|
||||||
DeployPage: TInputOptionWizardPage;
|
DeployPage: TInputOptionWizardPage;
|
||||||
DeployPageReady: Boolean;
|
DeployPageReady: Boolean;
|
||||||
|
ClientIpPage: TInputOptionWizardPage;
|
||||||
// Set from the streamed stage output so a failure can name its cause. Without
|
// Set from the streamed stage output so a failure can name its cause. Without
|
||||||
// this the wizard could only report "exit 1", which points at nothing.
|
// this the wizard could only report "exit 1", which points at nothing.
|
||||||
FailCause: String;
|
FailCause: String;
|
||||||
@@ -388,6 +397,35 @@ begin
|
|||||||
else
|
else
|
||||||
SitePage.Values[0] := GetComputerNameString;
|
SitePage.Values[0] := GetComputerNameString;
|
||||||
SitePage.Values[1] := '8090';
|
SitePage.Values[1] := '8090';
|
||||||
|
|
||||||
|
// Where the real client IP comes from. Not cosmetic: IIS sends no
|
||||||
|
// X-Forwarded-For of its own, so with neither option applied every request
|
||||||
|
// reads as 127.0.0.1 and the GE-Enforce IP allowlist, the dashboard's
|
||||||
|
// visitor-location lookup and per-host login rate limiting all fail silently.
|
||||||
|
//
|
||||||
|
// The two answers are mutually exclusive, and picking the wrong one is worse
|
||||||
|
// than picking neither: the rule OVERWRITES the header with REMOTE_ADDR, which
|
||||||
|
// is exactly right when IIS faces clients (it defeats spoofing) and exactly
|
||||||
|
// wrong behind a proxy (REMOTE_ADDR is the proxy, so the real client IP is
|
||||||
|
// discarded). Hence a question rather than a default.
|
||||||
|
ClientIpPage := CreateInputOptionPage(SitePage.ID,
|
||||||
|
'Client addresses', 'How does this server see who is connecting?',
|
||||||
|
'ShopDB records the address of every request, and some features decide what '
|
||||||
|
+ 'to show based on it. Choose whichever describes this server.',
|
||||||
|
True, False);
|
||||||
|
ClientIpPage.Add('Clients connect to this server directly (installs URL Rewrite)');
|
||||||
|
ClientIpPage.Add('A proxy or load balancer sits in front of this server');
|
||||||
|
ClientIpPage.SelectedValueIndex := 0;
|
||||||
|
end;
|
||||||
|
|
||||||
|
// 'direct' installs URL Rewrite from the bundle and enables the X-Forwarded-For
|
||||||
|
// rule; 'proxy' leaves both alone because the proxy already sets the header.
|
||||||
|
function ClientIpSourceArg: String;
|
||||||
|
begin
|
||||||
|
if ClientIpPage.SelectedValueIndex = 1 then
|
||||||
|
Result := 'proxy'
|
||||||
|
else
|
||||||
|
Result := 'direct';
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Full path to the 64-bit PowerShell.
|
// Full path to the 64-bit PowerShell.
|
||||||
@@ -900,6 +938,7 @@ begin
|
|||||||
' -SiteHost "' + SitePage.Values[0] + '"' +
|
' -SiteHost "' + SitePage.Values[0] + '"' +
|
||||||
' -SitePort ' + SitePage.Values[1] +
|
' -SitePort ' + SitePage.Values[1] +
|
||||||
' -OnFailure never' +
|
' -OnFailure never' +
|
||||||
|
' -ClientIpSource ' + ClientIpSourceArg +
|
||||||
' -SitePlugins "' + SelectedPlugins + '"';
|
' -SitePlugins "' + SelectedPlugins + '"';
|
||||||
|
|
||||||
// Subpath deployment: an IIS Application under the existing site instead of a
|
// Subpath deployment: an IIS Application under the existing site instead of a
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
[CmdletBinding()]
|
[CmdletBinding()]
|
||||||
param(
|
param(
|
||||||
[ValidateSet('menu','status','start','stop','restart','logs','open','backup',
|
[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] $Command = 'menu',
|
||||||
[string] $Path = '',
|
[string] $Path = '',
|
||||||
[string] $AppRoot = 'C:\shopdb-flask',
|
[string] $AppRoot = 'C:\shopdb-flask',
|
||||||
@@ -473,6 +473,69 @@ function Add-Plugin {
|
|||||||
Say (" {0} added" -f $Name) 'Green'
|
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 {
|
function Show-Uninstall {
|
||||||
Head 'Uninstall'
|
Head 'Uninstall'
|
||||||
Say ' Use Settings > Apps > ShopDB-Flask, or Add/Remove Programs.'
|
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 ' 2 Stop the application 7 Worker processes' -ForegroundColor White
|
||||||
Write-Host ' 3 Start the application 8 Open in browser' -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 ' 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 ''
|
Write-Host ''
|
||||||
$c = Read-Host ' Choose'
|
$c = Read-Host ' Choose'
|
||||||
switch ($c) {
|
switch ($c) {
|
||||||
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
|
'1' { Restart-App } '2' { Stop-App } '3' { Start-App }
|
||||||
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
|
'4' { Show-Logs } '5' { Invoke-Check } '6' { Backup-Db $Path }
|
||||||
'7' { Show-Sessions } '8' { Open-Site }
|
'7' { Show-Sessions } '8' { Open-Site }
|
||||||
|
'v' { Invoke-Verify } 'V' { Invoke-Verify }
|
||||||
'9' { Show-Plugins
|
'9' { Show-Plugins
|
||||||
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
|
$add = Read-Host ' Name of a shipped plugin to add (Enter to skip)'
|
||||||
if ($add) { Add-Plugin $add } }
|
if ($add) { Add-Plugin $add } }
|
||||||
@@ -524,6 +589,7 @@ switch ($Command) {
|
|||||||
'sessions' { Show-Sessions }
|
'sessions' { Show-Sessions }
|
||||||
'plugins' { Show-Plugins }
|
'plugins' { Show-Plugins }
|
||||||
'add-plugin'{ Add-Plugin $Path }
|
'add-plugin'{ Add-Plugin $Path }
|
||||||
|
'verify' { Invoke-Verify }
|
||||||
'uninstall' { Show-Uninstall }
|
'uninstall' { Show-Uninstall }
|
||||||
default { Show-Menu }
|
default { Show-Menu }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,13 @@
|
|||||||
wheels\*.whl
|
wheels\*.whl
|
||||||
app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...)
|
app\ (release tree: wsgi.py, shopdb\, plugins\, frontend\dist\, ...)
|
||||||
httpplatformhandler\httpPlatformHandler_amd64.msi
|
httpplatformhandler\httpPlatformHandler_amd64.msi
|
||||||
|
urlrewrite\ (optional, -ClientIpSource direct)
|
||||||
mysql\ (optional, bundled-database option)
|
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
|
.PARAMETER DbHost
|
||||||
Existing-MySQL option: the server hostname. The PASSWORD IS NEVER A PARAMETER -
|
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.
|
# installer checks that and refuses if they disagree.
|
||||||
[string] $MountAlias = '',
|
[string] $MountAlias = '',
|
||||||
[string] $ParentSite = 'Default Web Site',
|
[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,
|
[switch] $WhatIfOnly,
|
||||||
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
|
# Unattended runs cannot answer a prompt. Choose the failure behaviour up front.
|
||||||
[ValidateSet('ask','always','never')] [string] $OnFailure = 'ask'
|
[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;
|
# - the database is backed up BEFORE migrations touch it;
|
||||||
# - a failed migration puts the backup back rather than leaving a half-state.
|
# - 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 {
|
function Get-BundleVersion {
|
||||||
# The version being installed, read from the payload itself so it can never
|
# The version being installed, read from the payload itself so it can never
|
||||||
# disagree with the code that is about to be copied.
|
# 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'
|
Write-Log 'STAGE 2: runtime and configuration' 'STEP'
|
||||||
|
|
||||||
if (-not (Test-Path $BundleRoot)) { Fail "bundle not found: $BundleRoot" }
|
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" }
|
if (-not (Test-Path $AppSource)) { Fail "application payload not found: $AppSource" }
|
||||||
|
Assert-BundleIntegrity
|
||||||
|
|
||||||
# --- Python, all users --------------------------------------------------
|
# --- Python, all users --------------------------------------------------
|
||||||
# A per-user install lands in %LOCALAPPDATA%, which the IIS app-pool identity
|
# 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') `
|
Set-Content -Path (Join-Path $AppRoot '.installed-version') `
|
||||||
-Value $script:BundleVersion -Encoding ASCII
|
-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')) {
|
foreach ($sub in @('logs','instance')) {
|
||||||
$p = Join-Path $AppRoot $sub
|
$p = Join-Path $AppRoot $sub
|
||||||
if (-not (Test-Path $p)) { New-Item -ItemType Directory -Path $p -Force | Out-Null }
|
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 ----------------------------------------
|
# --- offline dependency install ----------------------------------------
|
||||||
# PIP_NO_INDEX makes a network attempt impossible rather than merely
|
# PIP_NO_INDEX makes a network attempt impossible rather than merely
|
||||||
# unnecessary. On an air-gapped box pip otherwise hangs on DNS timeouts.
|
# 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) {
|
if (-not $WhatIfOnly) {
|
||||||
$env:PIP_NO_INDEX = '1'
|
$env:PIP_NO_INDEX = '1'
|
||||||
$env:PIP_FIND_LINKS = $WheelDir
|
$env:PIP_FIND_LINKS = $WheelDir
|
||||||
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
$env:PIP_DISABLE_PIP_VERSION_CHECK = '1'
|
||||||
try {
|
try {
|
||||||
Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir",
|
Invoke-Native $Py @('-m','pip','install','--no-index',"--find-links=$WheelDir",
|
||||||
|
'--require-hashes','--only-binary=:all:',
|
||||||
'-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install'
|
'-r',(Join-Path $AppRoot 'requirements.txt')) 'dependency install'
|
||||||
} finally {
|
} finally {
|
||||||
Remove-Item Env:\PIP_NO_INDEX, Env:\PIP_FIND_LINKS -ErrorAction SilentlyContinue
|
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'
|
$srcCfg = Join-Path $AppRoot 'deploy\windows\web.config'
|
||||||
$dstCfg = Join-Path $AppRoot 'web.config'
|
$dstCfg = Join-Path $AppRoot 'web.config'
|
||||||
if (Test-Path $srcCfg) {
|
if (Test-Path $dstCfg) {
|
||||||
Write-Log 'installing web.config'
|
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) {
|
if (-not $WhatIfOnly) {
|
||||||
$cfg = Get-Content $srcCfg -Raw
|
$cfg = Get-Content $srcCfg -Raw
|
||||||
$cfg = $cfg.Replace('C:\shopdb-flask', $AppRoot)
|
$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
|
Set-Content -Path $dstCfg -Value $cfg -Encoding UTF8
|
||||||
Track 'file' $dstCfg
|
Track 'file' $dstCfg
|
||||||
}
|
}
|
||||||
} elseif (-not (Test-Path $dstCfg)) {
|
} else {
|
||||||
Fail "web.config not found at $srcCfg or $dstCfg"
|
Fail "web.config not found at $srcCfg or $dstCfg"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -306,10 +306,15 @@ Invoke-Check 'IIS' 'URL Rewrite module' {
|
|||||||
}
|
}
|
||||||
$modules = & $appcmd list module 2>$null
|
$modules = & $appcmd list module 2>$null
|
||||||
if ($modules -match 'RewriteModule') {
|
if ($modules -match 'RewriteModule') {
|
||||||
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (X-Forwarded-For rule can be enabled)'
|
Add-Result 'IIS' 'URL Rewrite module' 'PASS' 'installed (the X-Forwarded-For rule can be enabled)'
|
||||||
} else {
|
} else {
|
||||||
|
# Not a FAIL: it is only needed for -ClientIpSource direct, and the
|
||||||
|
# installer carries the MSI and installs it itself. Worth reporting
|
||||||
|
# because without the rule IIS sends no X-Forwarded-For at all, so every
|
||||||
|
# client reads as 127.0.0.1 and the GE-Enforce IP allowlist, the
|
||||||
|
# visitor-location lookup and per-host login rate limiting go quiet.
|
||||||
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
Add-Result 'IIS' 'URL Rewrite module' 'INFO' 'not installed' `
|
||||||
'Optional. Leave the <rewrite> block in web.config commented out, or IIS returns 500.19.'
|
'The installer installs it from the bundle when -ClientIpSource is direct. Behind a reverse proxy that already sets X-Forwarded-For, use -ClientIpSource proxy and leave it out.'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,16 +49,29 @@
|
|||||||
</httpPlatform>
|
</httpPlatform>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
OPTIONAL: forward the real client IP so audit logs and the kiosk
|
Forward the real client IP, so the audit log, the kiosk visitor-location
|
||||||
visitor-location feature (IP -> business unit) see the caller, not the
|
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
|
||||||
loopback that HttpPlatformHandler connects from.
|
login rate limiting all see the caller rather than the loopback address
|
||||||
|
HttpPlatformHandler connects from.
|
||||||
|
|
||||||
This block is COMMENTED OUT by default because it needs the URL Rewrite
|
IIS does not set X-Forwarded-For on its own. Without the rule below there
|
||||||
module; with it uncommented but URL Rewrite not installed, IIS returns
|
is no such header at all, and every client looks like 127.0.0.1 - so the
|
||||||
HTTP 500.19 ("configuration section not well-formed / cannot be read").
|
allowlist and the visitor-location lookup silently stop working.
|
||||||
Install URL Rewrite (https://www.iis.net/downloads/microsoft/url-rewrite)
|
|
||||||
and then uncomment the <rewrite> block below to enable it.
|
|
||||||
|
|
||||||
|
ONLY CORRECT WHEN IIS IS DIRECTLY EXPOSED. It overwrites the header with
|
||||||
|
REMOTE_ADDR, which is what stops a client spoofing its own X-Forwarded-For.
|
||||||
|
Behind a reverse proxy (ARR, a load balancer) REMOTE_ADDR is the PROXY, so
|
||||||
|
this rule would destroy the real client IP - there, leave it disabled and
|
||||||
|
let the proxy set the header.
|
||||||
|
|
||||||
|
It ships DISABLED because it needs the URL Rewrite module; enabled without
|
||||||
|
it, IIS returns HTTP 500.19 ("configuration section not well-formed").
|
||||||
|
|
||||||
|
The installer handles both: -ClientIpSource direct installs URL Rewrite
|
||||||
|
from the bundle and enables this; -ClientIpSource proxy leaves it alone.
|
||||||
|
By hand: install URL Rewrite, then delete the two marker lines below.
|
||||||
|
-->
|
||||||
|
<!-- SHOPDB-CLIENTIP-BEGIN
|
||||||
<rewrite>
|
<rewrite>
|
||||||
<allowedServerVariables>
|
<allowedServerVariables>
|
||||||
<add name="HTTP_X_FORWARDED_FOR" />
|
<add name="HTTP_X_FORWARDED_FOR" />
|
||||||
@@ -73,7 +86,7 @@
|
|||||||
</rule>
|
</rule>
|
||||||
</rules>
|
</rules>
|
||||||
</rewrite>
|
</rewrite>
|
||||||
-->
|
SHOPDB-CLIENTIP-END -->
|
||||||
|
|
||||||
</system.webServer>
|
</system.webServer>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user