Master overview (PHASE4_1_PREPRODUCTION_HARDENING.md) covering all 27 substantive items, plus REMOTE_AND_SELF_PACED_ARCHITECTURE.md (reviews what already works for remote/self-paced delivery - student-driven provisioning, cohort-less students, idle-reaper-destroy-then-continue - and the one real gap: student authentication, explicitly Phase 5 scope, not built here). Updated PRODUCTION_NETWORK_SECURITY.md (the Gateway-to-Lab gap is now RESOLVED, with the full design writeup and a new control-plane-vs-data-plane section), PRODUCTION_DEPLOYMENT.md (four-cert TLS bundle), PRODUCTION_VALIDATION_CHECKLIST.md (Router mTLS check, admission control checks), PHASE4_PRODUCTION_INFRASTRUCTURE.md and ENGINEERING_SOP.md and LAB_ARCHITECTURE.md (stale 'known gap' pointers updated to point at the resolution), BACKUP_DR.md (destructive drill), README.md and platform/README.md (Phase 4.1 pointers, lab-router component, updated known-limitations). Also fixes a real pre-existing bug found while auditing what gets committed: infra/.gitignore's patterns were written repo-root-relative (e.g. "infra/terraform/...") but the file lives in infra/, so git resolved them relative to THAT directory and none of them ever matched anything - a real .terraform/ provider cache showed up as untracked when it should have been ignored. Fixed by dropping the redundant leading "infra/" from every pattern. Status: PHASE 4.1 COMPLETE / PRE-PRODUCTION HARDENING COMPLETE / READY FOR HARDWARE DEPLOYMENT.
15 KiB
Phase 4.1 — Pre-Production Hardening
Status: PHASE 4.1 COMPLETE. PRE-PRODUCTION HARDENING COMPLETE. READY FOR HARDWARE DEPLOYMENT.
Phase 4 built the production infrastructure package with the real Hetzner/Proxmox hardware not
yet purchased, and flagged one genuine architectural gap: the Gateway's mechanism for reaching
a student Lab assumed Gateway and Lab shared a Docker daemon, which is false the moment Labs run
on a genuinely separate rn-lab-host-01 VM. Phase 4.1's job was to close that gap and harden
everything else discovered along the way, still without the real hardware.
1. The core fix: a Lab Host Router
Before: Gateway → http://${env.internal_ip}:${env.internal_port} directly — a Docker
bridge-local address, unreachable across a real VM boundary.
After: Gateway → (mode-aware) → either direct (unchanged, dev/single-host) or router
(production): a small new service, platform/lab-router/, runs on the Lab Host itself,
authenticates the Gateway via mTLS (the same private CA the Docker TLS control plane already
used, extended with two new leaf certs), resolves an opaque routing token to a local container
purely from docker ps label state (no Postgres access, no call back to the Provisioner), and
reverse-proxies HTTP and WebSocket traffic to it.
The decision of which mode a given lab_host uses is data-driven
(lab_hosts.routing_mode/router_endpoint, platform/provisioner/migrations/004_lab_hosts_routing.sql),
resolved once per request in platform/gateway/src/lab-endpoint.js's labTarget() — the only
place in the Gateway that knows this distinction exists. Every proxy/fetch call site in
platform/gateway/src/server.js goes through it.
Exact env.internal_ip replacement: labTarget(env, token) returns { url, headers, agent, mode }. In direct mode, url is exactly the old http://internal_ip:internal_port. In
router mode, url is the lab_host's router_endpoint, headers carries
X-RootNode-Route-Token: <token> (computed server-side from the Provisioner-verified
environment row — never trusted from the browser), and agent is an https.Agent configured
with the Gateway's mTLS client identity.
2. No per-student host port
Deliberately not solved by publishing a host port per Lab (the originally-flagged, rejected approach). The Router resolves entirely from Docker's own label state on its own host — no port-per-student anything, no dynamic firewall rule per Lab.
3. Lab Host Router — design
See platform/lab-router/src/server.js's header comment for the full "narrow by design" list:
one route (proxy-by-token), requires mTLS (no bearer-token fallback), never exposes Docker API/
container IDs/network info to its caller, never provides student-to-student routing (each
request resolves to exactly the one container matching its own token).
4. Multi-Lab-Host ready
Routing is resolved per lab_host_id — adding rn-lab-host-02 later is one new lab_hosts row
(its own routing_mode/router_endpoint/TLS material), no Gateway code change. No scheduler
was built (explicitly out of scope) — the Provisioner still picks "the one enabled lab_host"
(selectLabHost), unchanged from Phase 1.
5. Local development compatibility
dev-local (the Unix-socket lab_host used by docker-compose.dev.yml) stays routing_mode: direct — zero behavior change for the T480/any local dev setup, confirmed by the full existing
regression suite passing unmodified (§29 below) and by every new drill in this phase running
against the same dev stack. The standalone single-container Lab image build/boot is unaffected
(it never touches the Gateway or Provisioner at all).
6-9. Simulation, HTTP/WS validation, security tests, routing token security
infra/scripts/simulate-two-host/ — a real two-Docker-network simulation (not mocks): Gateway
attached ONLY to an isolated bridge with no route to any Lab's bridge network, Lab Host Router
host-networked (mirrors egress-proxy's existing pattern) so it can reach whatever per-Lab
bridge the Provisioner creates. 13/13 real assertions pass, including:
- Gateway genuinely cannot open a TCP connection to a Lab's Docker-internal IP (proven from inside the Gateway container's own network namespace via a raw connect attempt).
- Gateway reaches the Lab correctly through the Router — HTTP AND a real WebSocket terminal
session running a genuine
kubectl get nodesinside the Lab. - Alice/Bob isolation (own lab ALLOW, other's DENY — HTTP and WebSocket both), garbage tokens, anonymous requests, and destroyed-environment tokens are all rejected through the real code path.
- The Router refuses any connection with no client certificate — mTLS is the real authentication mechanism.
Routing token security reviewed: already CSPRNG since Phase 1 (generateRoutingToken), never
logged in plaintext (the Router's tokenFingerprint truncates), redacted in every API response
except the two narrow internal reads that legitimately need it, and — critically — the header
the Router trusts is computed server-side from the Provisioner-verified environment row, never
read from the browser's own request, so a browser cannot forge routing by supplying its own
X-RootNode-Route-Token.
10. Control plane vs data plane
Kept strictly separate — see docs/PRODUCTION_NETWORK_SECURITY.md's dedicated section. Student
browser traffic is never proxied through the Docker API at any point.
11. Firewall update
infra/firewall/rn-lab-host-01.rules's old speculative wide port-range allowance replaced with
one narrow rule: platform_ip → the Router's port only (8443 default, configurable).
12. Self-paced / remote delivery architecture check
docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md. Short version: student-driven provisioning,
cohort-less students, and "idle reaper destroys → progress survives → student returns → fresh
Lab → curriculum continues" all already worked before this phase and are now proven end-to-end
by two new drills. The one real gap for actual remote/self-paced delivery is student
authentication (today's bare student_id login assumes an instructor-controlled roster) — not
built here, explicitly Phase 5 scope.
13. Abandoned attempt cleanup
scenario_attempts.status = 'abandoned' (already a valid value since Phase 3B, never
previously set) now gets set on: environment destroy/reset (student- or instructor-triggered),
logout, session expiry (piggybacked on the existing hourly session sweeper — no new background
job), and switching to a different scenario while one is still open. Never a punitive marker —
result stays NULL, history is preserved, instructors see an honest "abandoned (reason)"
instead of a phantom "still in progress."
14. Gateway session cache
Reviewed — already a tight 2-second TTL since Phase 3A, specifically to bound the revocation-propagation window. Decision: keep it, don't add push-based invalidation — the added coupling (Platform API would need to know how to reach the Gateway) isn't justified by shrinking an already-2-second window further. Documented reasoning, not a silently-accepted gap.
15. Rate limiting
Login rate limiting moved from an in-memory Map (reset on every restart, not shareable across
instances) to Postgres (login_attempts table, migrations/016_login_attempts.sql) —
platform/api/src/security.js's tooManyAttempts(). No Redis added. Restart behavior
confirmed for real: platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine
Platform API restart in the same run.
16. Pagination
Real server-side pagination (platform/api/src/pagination.js) on: the audit log (was a flat
LIMIT 200), student attempt history (was LIMIT 100), and assessment session history (was
fully unbounded — the one genuine growth risk found). Scenario analytics was deliberately
left as-is — grouped by scenario id, naturally bounded around ~100 rows, no real growth risk to
paginate against. Frontend (AuditView.jsx, AssessmentsView.jsx, StudentsView.jsx) updated
to match; frontend build confirmed clean.
17. Needs-attention configuration
NEEDS_ATTENTION_FAIL_THRESHOLD/NEEDS_ATTENTION_STALE_DAYS moved from hardcoded constants to
env-var-configurable (matching the existing session-TTL pattern), sensible defaults unchanged.
18. Assessment authoring UX
Raw-JSON pool_spec textarea replaced with a structured editor
(frontend/src/admin/AssessmentsView.jsx's PoolSpecEditor): required scenarios and random
pools picked from a real scenario catalog (GET /api/admin/scenario-catalog, new), grouped by
curriculum stage, via native multi-select — no new UI framework. An owner-only "advanced" raw
JSON view remains for anything the structured editor doesn't cover.
19. Cohort role-in-cohort
cohort_instructors.role_in_cohort existed since Phase 2B but was never read. Implemented
minimally: teacher (unchanged full capability) vs viewer (read-only — dashboards/analytics
still visible, but Prepare/Destroy Labs and provisioning a student's environment are now 403'd
server-side, rbac.js's requireCohortWriteAccess/requireStudentCohortWriteAccess).
20. Backup / restore destructive drill
infra/backup/test-destructive-restore-drill.sh — real owner/admin/cohort/3-students/progress/
attempts/assessment/achievements seeded through the real Platform API, encrypted-backed-up, the
disposable Postgres container genuinely destroyed (not just restored into a second
container), a fresh Postgres created from nothing, restored, and the real Platform API booted
again against the restored database and used to log in as the restored admin over HTTP. All
data verified present; never touches the developer's real database.
21. Lab Host rebuild drill
infra/scripts/drills/test-lab-host-loss.js — a student's Lab container is destroyed out from
under the Provisioner (no clean destroy call, simulating total Lab Host loss). Proves: Platform
stays up throughout, the stale environment is never presented as healthy once the Provisioner's
real reconcileOnStartup reconciliation runs, academic progress is untouched, and the student
can launch a genuinely new Lab and continue.
22. Deployment rollback test
infra/scripts/drills/test-deployment-rollback.sh — builds a known-good and a deliberately
broken gateway image, "deploys" each via the real deterministic-tag mechanism, proves the smoke
test catches the regression, computes the rollback target with the exact same
deploy-history.log logic rollback-production.sh uses, and proves the rollback recovers.
Documents explicitly that image rollback never implies a DB migration rollback.
23. CI/CD failure tests
infra/scripts/drills/test-ci-fails-closed.sh — proves all six required failure modes (test/
syntax failure, frontend build failure, image build failure, config validation failure,
migration preflight failure, staging smoke test failure) genuinely exit nonzero. Found and
fixed two real pre-existing bugs: staging-smoke-test.sh and production-smoke-test.sh both
had a classic set -e gotcha (curl ... && echo "OK" as one statement — a failing command
that isn't the last in an && chain doesn't trigger -e) that let an unreachable/broken
endpoint fall through silently instead of failing the smoke test. Fixed in both files.
24. Security scanning
infra/ci/gitea-workflows/build-and-test.yml's new security-scan job — npm audit, gitleaks
(secrets), hadolint (Dockerfiles), shellcheck (shell scripts), trivy (container image CVEs).
All free/open-source. Severity policy documented in docs/SECURITY_SCANNING.md: CRITICAL/HIGH
blocks the build, MEDIUM/LOW is reported only. security-scan gates build-images the same
way regression does.
25. Version / release visibility
GET /api/version (Platform API) — app version (package.json), deployed Git SHA
(GIT_SHA env var, set to the same value as the image tag in the production compose file), and
the most recently applied migration as a schema-version stand-in. Unauthenticated, minimal, no
infrastructure detail.
26. Admission control failure mode
resource-policy.yaml's new (disabled-by-default) admission_control.max_concurrent_labs —
when set, the Provisioner checks it BEFORE touching Docker at all, so a full host fails cleanly
(503, CAPACITY_EXCEEDED) instead of failing mid-provision. Relayed by the Platform API as a
clean "Lab capacity is temporarily full. Try again shortly." — never a raw 500. A new
GET /capacity Provisioner endpoint feeds the instructor dashboard's capacity display.
27. Fake cohort dress rehearsal
infra/scripts/drills/test-fake-cohort-rehearsal.js — one continuous narrative: instructor
login, cohort + 4 students, curriculum assignment, Prepare Labs, students working different
scenarios, hints, a deliberate failed validation, instructor analytics, a Lab reset, a Lab
reaped (same consequence as the real idle reaper — see the drill's own comment on why the real
multi-minute timer isn't waited out), the student returning and continuing, an exam
assigned/taken, and end-of-class Lab destruction — progress verified intact after every
destructive step. 14/14 passing.
28. Documentation
This file, docs/REMOTE_AND_SELF_PACED_ARCHITECTURE.md, docs/SECURITY_SCANNING.md (new).
Updated: docs/LAB_ARCHITECTURE.md, docs/PRODUCTION_NETWORK_SECURITY.md,
docs/PRODUCTION_DEPLOYMENT.md, docs/PRODUCTION_VALIDATION_CHECKLIST.md, docs/BACKUP_DR.md,
docs/ENGINEERING_SOP.md, README.md, platform/README.md.
29. Regression
Re-ran the full existing suite after every application-code change in this phase:
e2e-phase2a.js (10/10), e2e-phase2b.js (12/12), e2e-phase3a.js (21/21 — including a real
audit-log pagination-shape fix and confirming rate-limit persistence across a genuine restart),
e2e-phase3b.js (24/24). Frontend production build clean. Real Lab image builds/boots (both
dev-compose and standalone single-container). tofu validate, ansible-playbook --syntax-check, production Compose config (both platform and lab-host), caddy validate,
promtool/amtool, and the backup/restore mechanism all re-validated clean after the
infrastructure changes in this phase (new lab-router service, new firewall rule, new Ansible
tasks). No knowingly-unexplained regression was left in this codebase at the end of this phase.
What Phase 4.1 deliberately did NOT build
Production deployment itself, Hetzner/Proxmox installation, public course signup, payments, subscriptions, parent portal, marketing site, full LMS, video hosting, email automation, certificates, course marketplace, mobile app, a multi-host scheduler, an AI tutor, invasive proctoring, held scenario content, student email/password authentication (documented as the one real gap for self-paced delivery, not built). All explicit non-goals per this phase's authorization.