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

255 lines
15 KiB
Markdown

# 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.