An air-gapped site cannot be scanned from anywhere else, so when a CVE lands the only way to answer 'is that component here, and at what version' was to RDP in and go looking. The frontend was the real blind spot: nothing recorded which version of leaflet, dompurify, jspdf or html2canvas ends up inside the compiled SPA. scripts/generate_sbom.py emits CycloneDX 1.6 covering both ecosystems - every pin in requirements.txt with the sha256 the installer enforces, and every package in package-lock.json. Build-only npm packages are marked scope 'excluded' rather than dropped, so 'not here' stays distinguishable from 'not looked for'. Dependency edges are real: uv's '# via' comments give the Python graph and package-lock gives the npm one. Hand-rolled rather than cyclonedx-py plus cyclonedx-npm because both inputs are already pinned and committed - this is a format translation, not a scan - and because the build box may be a work PC with nothing but Python and Node. It is deterministic by construction: same inputs, byte-identical output, so regenerating does not churn. Staged into the application tree by both builders, so it installs onto the server with the app. shopdb-admin.ps1 verify reports it and searches it by component name, which is the question actually being asked. Packages appearing at several depths in package-lock (node_modules/vite and node_modules/vitest/node_modules/vite) are merged, and a copy reachable outside the dev tree makes the component count as shipped. Emitting both produced duplicate bom-refs, which CycloneDX forbids and scanners reject; getting the dev merge backwards would have hidden a shipped package from a CVE search. Not covered by bundle-lock.json on purpose: its provenance is git, not the third-party payload.
229 lines
10 KiB
Markdown
229 lines
10 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.
|
|
|
|
### 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).
|