Repository navigation
Platform skeleton: local TLS proxy and hardened infrastructure - #3
Merged
Merged
Conversation
scripts/gen-env.sh writes .env with random rsldev_ values and the Postgres password as a Compose secret file, keeps existing values, and refuses to replace a credential whose volume still exists. git-secrets now blocks filled credentials and any generated value. Part of #2
scripts/ca.sh creates a root that may only vouch for rsl-commerce.test and never for an IP address, and a 397-day certificate for the 8 local hosts. It reissues the certificate when it nears expiry, lists other hosts, fails to verify or doesn't match its key, and replaces a root that would expire before its certificate. tests/ca.test.sh checks all of it, including that names outside rsl-commerce.test are refused. Part of #2
compose.yaml runs Postgres 18, Valkey 9.1, RabbitMQ 4.3 (quorum queues by default), Mailpit and Meilisearch, each as a numeric non-root user with a read-only filesystem, no capabilities and a digest-pinned image. The nginx-unprivileged proxy terminates TLS for the 8 local hosts over HTTP/2: a placeholder at rsl-commerce.test, Mailpit at mail., and a 503 page for the addresses whose applications aren't in the stack yet. It refuses the handshake for any other name. `make up` waits until all 6 services are healthy; `make reset` still works when .env is incomplete. Part of #2
scripts/smoke.sh checks each of the 8 hosts for its status over HTTP/2, HSTS, its page and CSP, and the http:// redirect. It checks that unknown names and requests without SNI are refused. Each service is reached by name as the applications will reach it: a Postgres login over TCP (a wrong password fails), Valkey, a RabbitMQ queue that defaults to quorum, mail through Mailpit with UI writes through the proxy, and Meilisearch's key. It confirms every container's hardening and the published ports, and that a row survives make down and make up. Everything it writes it removes again. Requests trust only the platform's root and use --resolve, so it needs neither the hosts file nor a trust store. Part of #2
scripts/trust.sh (make trust, make untrust) trusts only the root. On WSL2 it uses Windows' CurrentUser\Root through Import-Certificate: one confirmation dialog, no administrator rights. It prints the thumbprint first and skips a root that is already trusted. The script and its values reach PowerShell encoded and through WSLENV, so no shell quoting is involved. On macOS it uses the System keychain, and on Linux the system store plus Chrome's and Firefox's NSS databases. untrust removes only roots with the platform's organization, so other roots such as mkcert's stay. `trust.sh check` reports trust for status and doctor. The root's organization and name prefix now live in lib.sh, shared by ca.sh and trust.sh. Part of #2
scripts/hosts.sh --check lists the addresses missing from the hosts file the browser uses, and any that point elsewhere. make hosts shows the change, then writes a marked block holding only the missing addresses. Every byte outside the block stays the same, CRLF included. A one-time hosts.rsl-commerce.bak keeps the original. The write compares hashes: if the file changed since it was read, or the staged copy changed, nothing is written. On WSL2 an elevated Windows PowerShell does the write after one UAC prompt. The script runs inline, by absolute path, with its values embedded as base64 or hex. macOS and Linux use sudo. tests/hosts.test.sh covers the transform on generated fixtures: LF, CRLF, no final newline, an existing block, conflicts, comments, case, a damaged block, and that a second pass changes nothing. The PowerShell helper moves to lib.sh for trust.sh and hosts.sh, and trust.sh check refuses an empty thumbprint. Part of #2
make bootstrap runs the preflight checks, writes the secrets, creates or renews the certificates, trusts the root and reports missing hosts lines. A second run asks for nothing and changes nothing. --ci stops after the certificates. make status shows each service's health, the certificate's days left, trust and the hosts file. make doctor checks, changing nothing: - Docker and Compose 5 or newer, and not running as root; - ports 80, 443 and 15672, on the Windows side under WSL2; - free space where Docker keeps its data; - XDG_RUNTIME_DIR, and a checkout under /mnt; - .env and the Postgres secret, the certificates, trust and hosts. It fails only on what would break the platform. --preflight runs just the prerequisites bootstrap needs. cert_days_left in lib.sh counts days with openssl alone, because date -d is GNU-only. Part of #2
make lint (scripts/lint.sh) checks the Compose files and nginx -t. It runs shellcheck on every script and hook, and hadolint on the Dockerfiles. It checks that every image, base image and action is pinned by digest or commit SHA, and that built images are never pulled. It runs the CA and hosts tests. The linters come from compose.tools.yaml: pinned, with no network, read-only and unprivileged. ci.yml runs three jobs: lint; smoke (bootstrap --ci, make up, make smoke, logs on failure, and always a reset); and the CA and hosts tests on macOS with LibreSSL and /bin/sh. CodeQL's actions are pinned to commit SHAs. Dependabot covers actions, both Compose files and the derived images' bases, with groups and a 7-day cooldown. It holds nginx to the 1.30 line and Postgres to major 18. Lint fixes: shellcheck directives for lib.sh, gen-env.sh, the hooks and ca.test.sh, and a numeric USER in the Mailpit Dockerfile. Part of #2
ADR 0001 records the stack the spike decided and the skeleton built: Compose with Make, the nginx proxy, the name-constrained local CA and how each system trusts it, the hosts block, generated secrets, the hardened services and the pins. It also records where the build differs from the spike: - a file-sourced Postgres secret; - a 0644 certificate key and secret file; - IPv6 excluded in the expanded form; - no NSS on macOS. The README lists the 8 addresses and Valkey, and adds getting started, the commands, what asks for permission on each system, the HSTS reset and troubleshooting with make doctor. Part of #2
The elevated write now expects the hash of the content shown in the diff, not of the staged file, so a file swapped in %TEMP% before the write is refused. The elevated script turns off module autoloading and uses only .NET and ipconfig, so no user-writable module folder can supply a command. A wrong address inside the rsl-commerce block counts as missing, and make hosts rewrites it; only lines outside the block are reported as conflicts. Part of #2
On Windows the root is checked against the shown thumbprint and added through the store API with no temporary file, so nothing can swap it. On macOS check requires trust settings, not only presence, the trust is SSL only, and a cancelled authorization says so. On Linux the system-store tools are found in sbin, and check also requires the root in every NSS database, which is what Chrome and Firefox read. Part of #2
The root now carries extendedKeyUsage serverAuth, so its key can't sign code or email that Windows, NSS or OpenSSL accept. The machine name in its common name is cut to fit the 64-character limit, which a long hostname exceeded. ca.test.sh now parses the permitted and excluded constraints. It also checks the root's usage and unique name, the leaf's P-256 key and minimum validity, cert_days_left, a refused S/MIME leaf, and a reissue when the leaf comes from another root. Part of #2
X-Forwarded-For is now the client's address alone, with X-Real-IP set and Forwarded dropped, so a client can't spoof the address a service reads. Upgrade passes through only for WebSocket. The one-hour read timeout applies only to Mailpit's live-update WebSocket; everything else keeps nginx's default. The Mailpit 503 page gets its stylesheet. Part of #2
RabbitMQ is healthy only once its ports, AMQP included, accept connections, and it keeps an Erlang crash dump in its volume. Every tmpfs has a size, with 512 MB for Meilisearch's indexing. Valkey's and Meilisearch's own tini act as subreapers under Docker's init. Part of #2
git-secrets now blocks filled credentials in the KEY: value form and in quotes, as well as KEY=value. Every .env.* file except .env.example is ignored, which covers gen-env.sh's temporary file. gen-env.sh adds a missing final newline before appending keys, so a hand-edited .env can't get two keys on one line. The README says to re-run .githooks/setup when it changes. Part of #2
Both now pass the placeholder credentials that down and reset use, because listing and logs create nothing. Before, status reported a running stack as not running. Part of #2
The new checks: AMQP from another container at rabbitmq:5672, as the backend will connect; the test message listed in /api/v1/messages; and Mailpit's live-update WebSocket upgrading through the proxy. Part of #2
A checkout made under umask 027 or 077 leaves proxy/ and config/ unreadable to the containers' users, and the stack can't start. Part of #2
CodeQL's checkout no longer persists credentials, and the job has a timeout. The macOS job parses every script with /bin/sh and runs make, which is GNU Make 3.81 there. Part of #2
The root's TLS-only usage, the Windows trust adding the root from memory, SSL-only trust on macOS, and the narrower residual risk for clients that ignore name constraints. Part of #2
Valkey now requires a generated VALKEY_PASSWORD. It reads the password from a file-sourced Compose secret (secrets/valkey.conf), so it never appears on a command line, and its healthcheck authenticates. The services split into two networks: edge, holding the proxy and Mailpit's UI, and an internal data network, holding Postgres, Valkey, RabbitMQ, Meilisearch and Mailpit's SMTP. The proxy can no longer reach any data service. RabbitMQ also joins a small rabbitmq-ui network, because an internal network can't publish its management port. Smoke checks that Valkey refuses a client without the password, that data is internal and that the proxy can't resolve the data services. Doctor and the git-secrets patterns cover the new key. Decided as D-13 on #2. Part of #2
10 tasks done
LibreSSL before 3.8, which is what macOS ships, ignores a CA's extendedKeyUsage when checking a purpose. On it, the check that the root refuses an email leaf failed on the macOS runner (reproduced with LibreSSL 3.5.3; 3.8.2, 4.1.2 and OpenSSL 3 refuse the leaf). There, the check now reports a skip with the reason, and the root's own usage limit is still asserted. The test also prints the library it uses. Part of #2
This was referenced Oct 8, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2
What changed
The platform's walking skeleton:
make bootstrap,make hostsandmake upgive a trustedhttps://rsl-commerce.testand Mailpit atmail., on top of hardened, digest-pinned infrastructure.compose.yaml):no-new-privileges,initand sized tmpfs mounts.edgeand an internaldatanetwork that the proxy can't reach (D-13).scripts/ca.sh): anopensslroot name-constrained torsl-commerce.test, with IPs excluded, limited to TLS server certificates, andpathlen:0. One P-256 certificate for the 8 hosts, reissued or rotated automatically.scripts/trust.sh,scripts/hosts.sh):CurrentUser\Root(one dialog, no admin), and the Windows hosts file through one UAC prompt, with a hash-checked compare-and-swap and a one-time backup.bootstrap,up,down,reset,logs,status,doctor,smoke,lint,trust,untrust,hosts, all POSIXshbehind Make.rsldev_values in a mode-600.env, with file-sourced secrets for Postgres and Valkey. git-secrets patterns cover filled credentials.ci.yml): lint, smoke, and the CA and hosts tests on macOS (LibreSSL,/bin/sh). CodeQL is re-pinned, and Dependabot covers actions, both Compose files and the derived images.Requirements covered
gen-env.sh; journals S1 and S7 (hashes unchanged on rerun, modes, volume guard)ca.sh;tests/ca.test.sh, 55 checks under OpenSSL 3 and LibreSSL 4.1.2openssl verify; WindowsX509Chainrefuseslocalhost,www.example.comand127.0.0.1leaves (name constraints) and code-signing and email leaves (usage); Edge and Chrome refusedlocalhost:8443(journals qa and rehearsal)LocalMachineuntouched; mkcert entry keptmake up6 of 6 healthy; without bootstrap it refuses, with no root-owned paths (rehearsal)smoke.shdocker inspectper container, published ports, internaldatanetwork; lint pin checksmoke.sh: 8 hosts over HTTP/2 with HSTS, CSP, pages and 301s; handshake refused (exit 35); plain HTTP closed (exit 52)Invoke-WebRequest200 and 200; @ABilenduke checked Edge and Chrome, twice (qa, rehearsal)smoke.sh: Postgres over TCP (wrong password refused), Valkey with and without the password, quorum queue, AMQP from another container, Mailpit SMTP to the API (writes and WebSocket through the proxy), Meilisearch key.gitignore; git-secrets samples (blocked and allowed); the repository scans cleanci.yml,codeql.yml,dependabot.ymldocs/adr/0001-platform-stack.md,README.mdVerification
Run locally on WSL2 (Docker Desktop, Compose 5.5.1) against the final commit, from a clean slate (journal rehearsal, plus the valkey journal for D-13):
make reset CONFIRM=yes, then remove.env,secrets/andcerts/;make uprefuses: ".env is missing";make untrust, thenmake bootstrap: one dialog;make up: 6 of 6 healthy in 10 s;make smoke: 28 of 28;make status: clean;make doctor: no failures;make bootstrap: no dialog, every hash unchanged;make lint: 8 of 8 (Compose,nginx -t, shellcheck, hadolint, pins, and both test suites).A four-lens adversarial review (security, containers, portability, acceptance) found 35 issues. Every confirmed one is fixed, one commit per area; see journal feedback.
Risk and rollback
CurrentUser\Root:make untrustremoves it.hosts.rsl-commerce.bakbeside it: delete the block by hand.make reset CONFIRM=yesremoves the volumes.