Files
shopdb-flask/deploy/windows/web.config
cproudlock 44237b5cbd 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'.
2026-08-03 11:17:58 -04:00

128 lines
5.7 KiB
XML

<?xml version="1.0" encoding="UTF-8"?>
<!--
IIS site config for shopdb-flask via HttpPlatformHandler.
IIS launches waitress (a Windows-friendly WSGI server; gunicorn does NOT run
on Windows) and forwards requests to it on a private loopback port that IIS
assigns via %HTTP_PLATFORM_PORT%. One process serves both /api and the built
Vue SPA (frontend/dist), so no separate static site is needed.
Prerequisites on the box:
- HttpPlatformHandler IIS module installed
(https://www.iis.net/downloads/microsoft/httpplatformhandler)
- URL Rewrite module installed (only for the optional X-Forwarded-For rule)
- Python 3.14 + a venv at APP_ROOT\venv with requirements.txt + waitress
- Secrets live in APP_ROOT\.env (wsgi.py load_dotenv() reads it). Keep them
OUT of this file. Lock .env ACLs to the app pool identity + admins.
Replace APP_ROOT (C:\shopdb-flask below) with the real deploy path. The IIS
site's physical path MUST be APP_ROOT (where wsgi.py lives).
-->
<configuration>
<system.webServer>
<handlers>
<add name="httpplatformhandler" path="*" verb="*"
modules="httpPlatformHandler" resourceType="Unspecified" />
</handlers>
<httpPlatform
processPath="C:\shopdb-flask\venv\Scripts\waitress-serve.exe"
arguments="--port=%HTTP_PLATFORM_PORT% --host=127.0.0.1 --threads=8 --trusted-proxy=127.0.0.1 --trusted-proxy-headers=x-forwarded-for wsgi:app"
stdoutLogEnabled="true"
stdoutLogFile="C:\shopdb-flask\logs\httpplatform"
startupTimeLimit="120"
startupRetryCount="3">
<environmentVariables>
<!-- FLASK_ENV MUST be production here or wsgi.py defaults to the dev
config (SQL echo, debug, wrong DB URL). Real secrets go in .env. -->
<environmentVariable name="FLASK_ENV" value="production" />
<environmentVariable name="PYTHONPATH" value="C:\shopdb-flask" />
<!-- Subpath method only: when this web.config sits in an IIS
Application (e.g. /ops) under an existing site instead of its own
site, tell the app its mount path. Must match the alias the
Application was created with AND the VITE_BASE_PATH the frontend
was built with ('/ops/'). Omit for the own-site method.
<environmentVariable name="MOUNT_PATH" value="/ops" />
-->
</environmentVariables>
</httpPlatform>
<!--
Forward the real client IP, so the audit log, the kiosk visitor-location
feature (IP -> business unit), the GE-Enforce IP allowlist and per-host
login rate limiting all see the caller rather than the loopback address
HttpPlatformHandler connects from.
IIS does not set X-Forwarded-For on its own. Without the rule below there
is no such header at all, and every client looks like 127.0.0.1 - so the
allowlist and the visitor-location lookup silently stop working.
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>
<allowedServerVariables>
<add name="HTTP_X_FORWARDED_FOR" />
</allowedServerVariables>
<rules>
<rule name="Set X-Forwarded-For" stopProcessing="false">
<match url=".*" />
<serverVariables>
<set name="HTTP_X_FORWARDED_FOR" value="{REMOTE_ADDR}" />
</serverVariables>
<action type="None" />
</rule>
</rules>
</rewrite>
SHOPDB-CLIENTIP-END -->
</system.webServer>
<!--
Installer downloads: serve /installers/* as IIS static files instead of
forwarding them to Flask. The handler above is path="*", so without this a
request for /installers/Foo.exe goes to waitress, which has no such route
(SPA fallback), and large binaries would stream through a Python thread.
This <location> clears the httpPlatformHandler for that one subpath and puts
the static file handler back, so IIS serves the bytes directly (kernel-mode,
range/resume, no Python thread held).
Requires a physical folder at APP_ROOT\installers (the site's physical path
is APP_ROOT). Drop the installer binaries there, e.g. robocopy them from the
classic wwwroot\installers. The stored installpath 'installers/Foo.exe' then
resolves to <mount>/installers/Foo.exe (e.g. /shopdb/installers/Foo.exe).
.exe/.msi are given an explicit MIME map; if the parent site has a Request
Filtering rule that denies executable extensions, also allow them there.
-->
<location path="installers">
<system.webServer>
<handlers>
<clear />
<add name="StaticFile" path="*" verb="*"
modules="StaticFileModule" resourceType="File"
requireAccess="Read" />
</handlers>
<staticContent>
<remove fileExtension=".exe" />
<mimeMap fileExtension=".exe" mimeType="application/octet-stream" />
<remove fileExtension=".msi" />
<mimeMap fileExtension=".msi" mimeType="application/octet-stream" />
</staticContent>
</system.webServer>
</location>
</configuration>