Skip to content

Platform skeleton: local TLS proxy and hardened infrastructure - #3

Merged
ABilenduke merged 22 commits into
mainfrom
feat/2-platform-skeleton
Oct 8, 2026
Merged

ABilenduke merged 22 commits into
mainfrom
feat/2-platform-skeleton

Conversation

@ABilenduke

Copy link
Copy Markdown
Contributor

Closes #2

What changed

The platform's walking skeleton: make bootstrap, make hosts and make up give a trusted https://rsl-commerce.test and Mailpit at mail., on top of hardened, digest-pinned infrastructure.

  • Stack (compose.yaml):
    • PostgreSQL 18, Valkey 9.1 (password required), RabbitMQ 4.3 (quorum queues by default), Mailpit and Meilisearch, plus an nginx-unprivileged TLS proxy for the 8 local addresses.
    • Every container runs as a numeric non-root user, read-only, with no capabilities, no-new-privileges, init and sized tmpfs mounts.
    • Two networks: edge and an internal data network that the proxy can't reach (D-13).
  • Local TLS (scripts/ca.sh): an openssl root name-constrained to rsl-commerce.test, with IPs excluded, limited to TLS server certificates, and pathlen:0. One P-256 certificate for the 8 hosts, reissued or rotated automatically.
  • Trust and hosts (scripts/trust.sh, scripts/hosts.sh):
    • WSL2: Windows 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.
    • macOS and Linux paths are written and linted.
  • Commands: bootstrap, up, down, reset, logs, status, doctor, smoke, lint, trust, untrust, hosts, all POSIX sh behind Make.
  • Secrets: generated rsldev_ values in a mode-600 .env, with file-sourced secrets for Postgres and Valkey. git-secrets patterns cover filled credentials.
  • CI (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.
  • Docs: ADR 0001 (the stack and where the build differs from the spike) and the README (getting started, commands, what asks for permission, troubleshooting).

Requirements covered

Requirement Evidence
REQ-001 Generated secrets gen-env.sh; journals S1 and S7 (hashes unchanged on rerun, modes, volume guard)
REQ-002 CA and leaf ca.sh; tests/ca.test.sh, 55 checks under OpenSSL 3 and LibreSSL 4.1.2
REQ-003 Constraint enforced openssl verify; Windows X509Chain refuses localhost, www.example.com and 127.0.0.1 leaves (name constraints) and code-signing and email leaves (usage); Edge and Chrome refused localhost:8443 (journals qa and rehearsal)
REQ-004 Trust on WSL2 journal S5: added, skipped on rerun, declined (non-zero), untrusted, re-trusted; LocalMachine untouched; mkcert entry kept
REQ-005 Portability macOS and Linux paths shellcheck-clean; the macOS CI job on this PR
REQ-006, REQ-007 Hosts journal S6: 33 fixture checks; the real file minus the block is byte-identical; CRLF kept; backup once; no prompt on rerun
REQ-008 Stack up make up 6 of 6 healthy; without bootstrap it refuses, with no root-owned paths (rehearsal)
REQ-009 Hardening smoke.sh docker inspect per container, published ports, internal data network; lint pin check
REQ-010 TLS and routing smoke.sh: 8 hosts over HTTP/2 with HSTS, CSP, pages and 301s; handshake refused (exit 35); plain HTTP closed (exit 52)
REQ-011 Browser trust Invoke-WebRequest 200 and 200; @ABilenduke checked Edge and Chrome, twice (qa, rehearsal)
REQ-012 Services 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
REQ-013 Down and reset smoke: a row survives down and up; rehearsal: reset declined and confirmed, files kept
REQ-014 Status and doctor journal S7, rehearsal
REQ-015 No secrets committed .gitignore; git-secrets samples (blocked and allowed); the repository scans clean
REQ-016 CI and updates this PR's checks; ci.yml, codeql.yml, dependabot.yml
REQ-017 Docs docs/adr/0001-platform-stack.md, README.md
REQ-018 Untrust journal S5: ours removed, mkcert kept

Verification

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/ and certs/;
  • make up refuses: ".env is missing";
  • make untrust, then make bootstrap: one dialog;
  • make up: 6 of 6 healthy in 10 s;
  • make smoke: 28 of 28;
  • make status: clean; make doctor: no failures;
  • a second 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

  • Local only. Nothing here runs in production.
  • Two changes to the developer's machine, each confirmed by them:
    • The root in CurrentUser\Root: make untrust removes it.
    • A marked block in the hosts file, with hosts.rsl-commerce.bak beside it: delete the block by hand.
  • Rollback: revert the merge. make reset CONFIRM=yes removes the volumes.
  • Known gaps:
    • macOS and Linux bootstrap haven't been run on real machines (only their tests, in CI).
    • The new CI jobs aren't required checks yet (D-11).
    • Both are follow-ups.

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
@ABilenduke ABilenduke linked an issue Oct 8, 2026 that may be closed by this pull request
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
@ABilenduke
ABilenduke merged commit 3bd75fd into main Oct 8, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Platform skeleton: bootstrap, proxy and local TLS

1 participant