Files
rootnode-academy/docs/PHASE4_1_PREPRODUCTION_HARDENING.md
ced4568 5bc0743b08
Release to Docker Hub / Build & Push Docker Image (push) Canceled after 0s
Release to Docker Hub / Create GitHub Release (push) Canceled after 0s
docs(phase4.1): pre-production hardening documentation
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.
2026-08-24 20:16:20 -05:00

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 nodes inside 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.