CLIENT IP / SPOOFABILITY. docs/geenforce-api-cutover.md claimed that removing the IIS rewrite rule made the allowlist fail closed and that it does NOT become spoofable. The opposite is true. IIS never sets X-Forwarded-For on its own; the rule is the only thing that does. Remove it and IIS still forwards whatever X-Forwarded-For the CALLER sent, waitress trusts it because it arrives from 127.0.0.1, and remote_addr becomes attacker-controlled - so a token-less caller can fetch manifests from anywhere on the network. The document and the _trusted_client_ip docstring now say so, waitress runs with --trusted-proxy-count=1, and stage 5 checks the rule is actually live rather than assuming it. The wizard question is rephrased to something an operator can verify with their network team instead of guessing at. NON-ASCII. The style gate only ever checked .py/.vue/.js/.ts, so documentation accumulated em-dashes, arrows and box-drawing characters against this repo's own convention - including in files added this week. Cleaned, and the gate now uses INCLUDES_ALL so Markdown, JSON and YAML are covered. PLUGIN DEFAULTS. The wizard pre-ticked measuringtools and printedparts, both of which ship default_enabled=false, so every site taking the defaults installed and enabled them against their manifests. Inno has no JSON parser so the list must be hardcoded, but tests/test_installer_defaults.py now fails when it drifts. UPGRADES. The payload copy merges, so a plugin dropped from a site's profile kept its code forever - which defeats a lean build and leaves core's optional-import guards succeeding for a plugin the site no longer has. Stale plugin directories are now deregistered and removed before the copy. add-plugin used 'plugin install', which for the five default_enabled=false plugins left them installed but DISABLED - and printed a green success line anyway. It now goes through apply-profile, and the success line is gated on the exit code. Invoke-Flask records its own exit status, because $LASTEXITCODE keeps a stale value when flask.exe is missing and no native command runs. CHARSET. The utf8mb4 compiler hook lived inline in migrations/env.py, so it covered the CORE chain only: plugin baselines inherited the server default, which on a latin1 server means two charsets in one database. It is now shopdb/utils/mysql_charset.py, imported by both, and preflight reports the database's default charset. BACKUP HONESTY. The dump was described as 'all of your asset data'. Uploaded branding and floor-map images live in instance\ on disk, not in the database, so a restore from the .sql alone comes back with no map. backup now archives instance\ alongside it and says both are needed. VERSIONING. AppVersion was hardcoded at 0.9.0 while the product, the frontend and the newest tag said 0.7.0 - and 0.9.0 collides with a retired contract version. Both builders now generate version.iss from shopdb/__init__.py. Smaller: rollback overwrites .env before deleting it, as uninstall already did; appcmd unlocks are scoped to this site's location rather than server-wide, with the wide unlock as a fallback; DEVELOPMENT-SETUP says Python 3.14; the README plugin list gains printedparts; prune-schema --force is documented as first-provisioning-only; HTTPS is documented as not-the-default with the steps to add it; the DBA SQL is on the wizard's database page; the features page says unticking does not remove an installed feature; and the installer README states that bundle-lock cannot vouch for the exe itself - that needs signing or an out-of-band hash, neither of which is wired up.
246 lines
11 KiB
Markdown
246 lines
11 KiB
Markdown
# Windows installer
|
|
|
|
Builds a single self-contained `.exe` that installs ShopDB-Flask on an
|
|
**air-gapped** Windows Server. Nothing here ever touches the network at install
|
|
time: Python, the wheels, the SPA and (optionally) MySQL all ship inside it.
|
|
|
|
Lives with the application on purpose. The installer depends on app internals -
|
|
`flask plugin` verbs, `site-profile.json`, `MOUNT_PATH`, the plugin registry -
|
|
so a separate repo would drift out of step with the thing it installs.
|
|
|
|
## Files
|
|
|
|
| File | What it is |
|
|
|---|---|
|
|
| `shopdb-preflight.ps1` | Stage 1. Read-only. Changes nothing, reports what this server is missing. |
|
|
| `shopdb-install.ps1` | Stages 0 and 2-5 plus `uninstall`. All the actual work. |
|
|
| `shopdb-admin.ps1` | Operator console installed alongside the app: status, restart, logs, backup, plugins. |
|
|
| `ShopDBFlask.iss` | Inno Setup wizard. A thin wrapper - it collects input and runs the stages. |
|
|
| `build-installer.sh` | Stages the bundle from a site profile. |
|
|
| `make-branding.py` | Generates wizard artwork and the icon from `frontend/public/*.svg`. |
|
|
| `*.bmp`, `shopdb.ico` | Generated artwork, committed so a Windows build box needs no Python. |
|
|
| `build-installer.ps1` | The same build, natively on Windows. No Bash needed. |
|
|
| `bundle-lock.json` | The exact third-party payload this installer ships. Reviewed by commit. |
|
|
| `bundle-lock.ps1` | Creates and checks that lock. Also runs on the target server. |
|
|
| `refresh-bundle-lock.ps1` | Regenerates the lock, after showing what changed. |
|
|
| `verify_bundle_lock.py` | The same check for the Bash builder, so Linux needs no pwsh. |
|
|
|
|
## Building
|
|
|
|
Two builders, same result. Use whichever machine you are on - `build-installer.ps1`
|
|
does the whole job natively so a Windows work PC needs no Bash.
|
|
|
|
```bash
|
|
# Linux / WSL
|
|
./build-installer.sh ../../site-profile.example.json
|
|
```
|
|
|
|
```powershell
|
|
# Windows
|
|
.\build-installer.ps1 -Profile ..\..\site-profile.example.json
|
|
```
|
|
|
|
Both stage the app tree, build the SPA twice, write `plugins.iss`, copy the
|
|
installer scripts **from this directory**, and then verify the third-party
|
|
payload against `bundle-lock.json`. They exit non-zero if it does not match.
|
|
|
|
The payload itself is added by hand, and no longer needs Windows to produce:
|
|
|
|
```
|
|
bundle/wheels/ pip download -r requirements.txt --only-binary=:all: \
|
|
--platform win_amd64 --python-version 314 \
|
|
--implementation cp --abi cp314 -d wheels
|
|
bundle/python/ python-3.14.x-amd64.exe
|
|
bundle/httpplatformhandler/ httpPlatformHandler_amd64.msi
|
|
bundle/urlrewrite/ rewrite_amd64.msi (client-IP rule; see below)
|
|
bundle/mysql/ mysql-8.0.x-winx64.msi (bundled-database option only)
|
|
```
|
|
|
|
Then compile on Windows:
|
|
|
|
```
|
|
iscc ShopDBFlask.iss
|
|
```
|
|
|
|
**Inno Setup 6.6.0 or newer.** The wizard uses the built-in `windows11` custom
|
|
style, which earlier versions reject. The script fails at compile time with that
|
|
sentence rather than with a bare "WizardStyle is invalid".
|
|
|
|
The wheelhouse is **cp314-locked**. A different Python minor version means a
|
|
different wheelhouse; the installer will not use a Python it did not install.
|
|
|
|
## What is pinned, and where
|
|
|
|
Three layers, because no one of them covers the whole problem.
|
|
|
|
| Layer | Covers | Enforced |
|
|
|---|---|---|
|
|
| `requirements.txt` sha256 per package | every wheel is genuinely what upstream published | `pip --require-hashes` at install; aborts on mismatch |
|
|
| `bundle-lock.json` | the EXACT payload: wheels, Python installer, MSIs | both builders, and again on the server before anything runs |
|
|
| git | the application tree | code review |
|
|
|
|
`--require-hashes` alone is not enough. pip lists every artifact of a pinned
|
|
version - `cffi 2.1.0` has 100 hashes - so it proves the wheel is genuine, not
|
|
that it is the wheel this bundle was built and tested with. It also ignores
|
|
extra files in the wheelhouse, and says nothing about the Python installer or
|
|
the MSIs, all of which run as SYSTEM on the target server.
|
|
|
|
So `bundle-lock.json` records an exact file set with a sha256 and a byte size
|
|
each, and verification is **set equality**: a missing file, an unexpected extra
|
|
file, or changed content all fail. There is no install-time override.
|
|
|
|
The lockfile is `--universal`, so one file serves Linux (dev, Docker, CI) and the
|
|
Windows wheelhouse. A Linux-only resolve had silently omitted `colorama`, a
|
|
win32-only dependency of `click` - which in hash-checking mode is a hard error
|
|
rather than a quiet omission.
|
|
|
|
### Verifying the installer itself
|
|
|
|
`bundle-lock.json` is **inside** the thing it describes, so it proves the payload
|
|
was not altered between build and install - not that the `.exe` you received is
|
|
the one that was built. That needs something out of band. Two options, in order
|
|
of preference:
|
|
|
|
1. **Authenticode-sign the `.exe`** with a GE code-signing certificate. Windows
|
|
then shows a real publisher instead of "Unknown", which is also what stops an
|
|
operator learning to click through the SmartScreen warning.
|
|
2. **Publish a sha256 per release** through a different channel than the file
|
|
itself, and have the receiving site check it:
|
|
`Get-FileHash ShopDBFlask_Installer_*.exe -Algorithm SHA256`
|
|
|
|
Neither is wired up yet. Until one is, an installer is only as trustworthy as
|
|
the share it arrived on.
|
|
|
|
### Changing what ships
|
|
|
|
```powershell
|
|
.\refresh-bundle-lock.ps1 # show what changed, write nothing
|
|
.\refresh-bundle-lock.ps1 -Yes # write it
|
|
```
|
|
|
|
Then **commit `bundle-lock.json`**. That commit is the review - it is the only
|
|
place a change to what runs as SYSTEM on a customer's server becomes visible to
|
|
a human. `refresh-bundle-lock.ps1` refuses to overwrite an existing lock until
|
|
you have seen the diff, for that reason.
|
|
|
|
To stage a bundle before its lock exists: `ALLOW_UNLOCKED=1` (Bash) or
|
|
`-AllowUnlocked` (PowerShell). Bundles built that way must not be shipped.
|
|
|
|
Verifying a live server, months later and offline:
|
|
|
|
```powershell
|
|
shopdb-admin.ps1 verify
|
|
```
|
|
|
|
## Servers this installer did not build
|
|
|
|
It is built for greenfield: its own Python, its own venv, its own IIS objects.
|
|
Its upgrade path assumes the thing being upgraded came out of a previous run.
|
|
Two guards keep it from damaging a server that was deployed by hand.
|
|
|
|
**Python minor version.** An existing venv is reused, which is right for a repair
|
|
or an upgrade. It is wrong when the venv belongs to a different Python - the
|
|
wheelhouse is tagged for one minor version, so pip would die at the first
|
|
compiled package, *after* Python was installed and the app tree replaced. The
|
|
installer compares the two up front and stops with both version numbers.
|
|
|
|
**IIS objects.** Switching deployment method removes the other method's artifact,
|
|
which is correct when the installer owns both and dangerous when it does not: a
|
|
wrong `-MountAlias` would delete a live mount with no prompt. It now refuses
|
|
unless there is a version stamp proving it made the install, or you pass
|
|
`-AdoptExisting`. The refusal lists exactly what it would have removed.
|
|
|
|
An existing `web.config` is never overwritten in either case.
|
|
|
|
For the West Jefferson production server specifically, this is a **migration, not
|
|
an upgrade** - prod runs Python 3.13 against a hand-built deployment, so it needs
|
|
a deliberate window, a database backup, and web.config reconciled by hand.
|
|
|
|
## Bill of materials
|
|
|
|
Every build stages a CycloneDX 1.6 SBOM at `sbom.cdx.json`, inside the
|
|
application tree, so it installs onto the server with the app. Both ecosystems,
|
|
in one document:
|
|
|
|
- **Python** - every pin in `requirements.txt`, with the sha256 the installer
|
|
enforces. Environment markers are ignored: a `sys_platform == 'win32'`
|
|
dependency still installs on the target.
|
|
- **npm** - every package in `frontend/package-lock.json`. Build-only packages
|
|
are marked `scope: excluded` rather than dropped, so "not here" is
|
|
distinguishable from "not looked for".
|
|
|
|
It ships to the server because an air-gapped site cannot be scanned from
|
|
anywhere else. When a CVE lands, the answer is already on the box:
|
|
|
|
```powershell
|
|
shopdb-admin.ps1 verify # counts, and which bundle this is
|
|
shopdb-admin.ps1 verify -Path leaflet # is that component here, at what version
|
|
```
|
|
|
|
Generated by `scripts/generate_sbom.py` from files that are already pinned and
|
|
committed, so it is a translation rather than a scan - no network, no extra
|
|
toolchain on the build box, and byte-identical output for the same inputs. It is
|
|
deliberately not in `bundle-lock.json`: its provenance is git, not the payload.
|
|
|
|
## Client IP addresses
|
|
|
|
IIS does not set `X-Forwarded-For` on its own, and HttpPlatformHandler connects
|
|
from loopback. Without a rule, **every client reads as 127.0.0.1** - so the
|
|
GE-Enforce IP allowlist, the dashboard's visitor-location lookup and per-host
|
|
login rate limiting all stop working, silently.
|
|
|
|
The wizard asks, because the two answers are mutually exclusive:
|
|
|
|
- **Clients connect directly** (`-ClientIpSource direct`, the default) - installs
|
|
URL Rewrite from the bundle and sets `X-Forwarded-For` from `REMOTE_ADDR`.
|
|
Overwriting the header is what stops a client spoofing its own.
|
|
- **A proxy sits in front** (`-ClientIpSource proxy`) - leaves the rule off.
|
|
Behind ARR or a load balancer `REMOTE_ADDR` is the *proxy*, so applying the
|
|
rule would discard the real client IP.
|
|
|
|
An existing `web.config` is never overwritten - it is the one file on a server
|
|
that legitimately carries hand-edits. The installer reports what it found
|
|
instead.
|
|
|
|
## Deployment methods
|
|
|
|
Chosen in the wizard, and the bundle carries a SPA build for each because Vite
|
|
compiles the base path in - it cannot be switched at install time.
|
|
|
|
- **Its own site** on a port (default 8090).
|
|
- **Subpath** under an existing site, e.g. `http://<server-fqdn>/shopdb/`. Needs
|
|
no new DNS record. Three things must agree - the IIS application alias,
|
|
`MOUNT_PATH` in `.env`, and the SPA's build-time base - so the alias is fixed
|
|
per bundle (`SUBPATH_ALIAS`, default `shopdb`) and the installer refuses if the
|
|
bundle's build does not match what was asked for.
|
|
|
|
Switching between methods removes the other one's IIS artifact and reconciles
|
|
`MOUNT_PATH` and `CORS_ORIGINS`, so a server never ends up with both.
|
|
|
|
## Upgrades
|
|
|
|
Run a newer installer over an existing install. It:
|
|
|
|
- backs the database up first, **verifies** the dump is complete, and refuses to
|
|
migrate if it cannot;
|
|
- restores from that backup if migrations fail, and reports honestly that DDL
|
|
the failed migration committed cannot be undone;
|
|
- refuses to run a bundle older than what is installed;
|
|
- keeps `.env` unless new credentials are supplied, and copies it aside first;
|
|
- stops the app pool before replacing files, then starts it again.
|
|
|
|
Whether a run is an upgrade is decided by probing the **target database**, not by
|
|
whether the app directory exists - a rebuilt server pointed at an existing
|
|
database is an upgrade, and treating it as fresh would drop tables.
|
|
|
|
## Testing notes
|
|
|
|
Verified end to end on Windows Server 2025 against both a bundled MySQL 8.0 and
|
|
an existing MySQL 5.6: fresh install, upgrade, re-run idempotency, failure and
|
|
rollback, uninstall, and both deployment methods including switching between
|
|
them.
|
|
|
|
**Not yet verified:** a fully air-gapped run with the network disabled at the
|
|
hypervisor, and any load in a real browser (all HTTP checks so far used curl,
|
|
which sends no `Origin` header - so `CORS_ORIGINS` is untested in anger).
|