From eae8e48d4325606f39142113eb961432a0f41037 Mon Sep 17 00:00:00 2001 From: ced4568 Date: Mon, 24 Aug 2026 20:14:39 -0500 Subject: [PATCH] feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds platform/lab-router/ — a narrow service that runs on the Lab Host, authenticates the Gateway via mTLS, and resolves an opaque routing token to a local container purely from Docker label state (no Postgres access, no callback to the Provisioner). Replaces the Gateway's env.internal_ip assumption (only valid when Gateway and Lab share a Docker daemon) with a data-driven direct/router decision per lab_host (lab_hosts.routing_mode/router_endpoint, migration 004), resolved in one place (platform/gateway/src/lab-endpoint.js's labTarget()) that every proxy/fetch call site in server.js now goes through. Extends the existing Docker TLS CA (infra/scripts/docker-tls/generate-docker-tls.sh) with a second mTLS leaf pair (router-server/gateway-client) for this new data-plane connection, deployed by the docker-tls Ansible role into a separate directory from the control-plane pair. Firewall (rn-lab-host-01.rules) replaces the old speculative wide port-range allowance with one narrow rule: platform_ip -> the Router's port only. dev-local stays routing_mode: direct — zero behavior change for local dev, confirmed by the full existing regression suite passing unmodified. --- infra/ansible/group_vars/production.yml | 2 +- .../roles/docker-tls/handlers/main.yml | 16 + infra/ansible/roles/docker-tls/tasks/main.yml | 47 ++ .../roles/platform/templates/gateway.env.j2 | 8 + infra/docker/IMAGE_STRATEGY.md | 1 + .../lab-host/docker-compose.production.yml | 33 +- .../platform/docker-compose.production.yml | 9 + .../provisioner-config/lab-hosts.yaml | 15 +- .../staging/provisioner-config/lab-hosts.yaml | 17 +- infra/firewall/rn-lab-host-01.rules | 15 +- infra/firewall/validate-firewall.sh | 2 +- infra/scripts/ci/tag-and-push.sh | 1 + .../scripts/docker-tls/generate-docker-tls.sh | 55 +- infra/scripts/validate-compose.sh | 14 + platform/gateway/src/lab-endpoint.js | 128 ++++ platform/gateway/src/server.js | 49 +- platform/lab-router/Dockerfile | 7 + platform/lab-router/package-lock.json | 706 ++++++++++++++++++ platform/lab-router/package.json | 15 + platform/lab-router/src/resolve.js | 29 + platform/lab-router/src/server.js | 103 +++ platform/lab-router/test/run.js | 73 ++ .../provisioner/config/lab-hosts.seed.yaml | 30 +- .../migrations/004_lab_hosts_routing.sql | 32 + platform/provisioner/src/config.js | 13 +- platform/provisioner/src/lifecycle.js | 50 +- platform/provisioner/src/server.js | 29 +- 27 files changed, 1431 insertions(+), 68 deletions(-) create mode 100644 platform/gateway/src/lab-endpoint.js create mode 100644 platform/lab-router/Dockerfile create mode 100644 platform/lab-router/package-lock.json create mode 100644 platform/lab-router/package.json create mode 100644 platform/lab-router/src/resolve.js create mode 100644 platform/lab-router/src/server.js create mode 100644 platform/lab-router/test/run.js create mode 100644 platform/provisioner/migrations/004_lab_hosts_routing.sql diff --git a/infra/ansible/group_vars/production.yml b/infra/ansible/group_vars/production.yml index a76c711..1d9def6 100644 --- a/infra/ansible/group_vars/production.yml +++ b/infra/ansible/group_vars/production.yml @@ -13,7 +13,7 @@ lab_host_ip: __LAB_HOST_VM_IP__ management_cidr: __YOUR_ADMIN_ACCESS_CIDR__ # e.g. a VPN/WireGuard subnet, never 0.0.0.0/0 proxmox_mgmt_cidr: __PROXMOX_HOST_MGMT_CIDR__ lab_subnet_range: "10.42.0.0/16" # matches NO_PROXY_CIDRS already used by the app -lab_gateway_port_range: "30000:32767" # see docs/PRODUCTION_NETWORK_SECURITY.md — Gateway-to-Lab reachability follow-up +lab_router_port: "8443" # Gateway -> Lab Host Router (data plane) — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 # ── Storage (Phase 4 §4 — a decision framework, not a final layout; see docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §4) rootnode_data_root: /srv/rootnode-data # expected to be a ZFS dataset mountpoint on the real host diff --git a/infra/ansible/roles/docker-tls/handlers/main.yml b/infra/ansible/roles/docker-tls/handlers/main.yml index 5ee92c0..03d00e1 100644 --- a/infra/ansible/roles/docker-tls/handlers/main.yml +++ b/infra/ansible/roles/docker-tls/handlers/main.yml @@ -4,3 +4,19 @@ name: docker state: restarted daemon_reload: true + +# Phase 4.1 — these restart the whole per-host compose wrapper unit (no per-container systemd +# units exist in this project's convention, see the `platform`/`lab-host` roles), which is +# heavier than a surgical single-container restart but consistent with how every other +# config-changed-so-restart-the-stack case is already handled here. +- name: restart lab-router + ansible.builtin.systemd: + name: rootnode-lab-host.service + state: restarted + when: inventory_hostname in groups['lab_host'] + +- name: restart gateway + ansible.builtin.systemd: + name: rootnode-platform.service + state: restarted + when: inventory_hostname in groups['platform'] diff --git a/infra/ansible/roles/docker-tls/tasks/main.yml b/infra/ansible/roles/docker-tls/tasks/main.yml index bc5a548..3e1c884 100644 --- a/infra/ansible/roles/docker-tls/tasks/main.yml +++ b/infra/ansible/roles/docker-tls/tasks/main.yml @@ -87,3 +87,50 @@ mode: "0644" when: inventory_hostname in groups['lab_host'] notify: restart docker with tls + +# ── Phase 4.1: the DATA-plane mTLS pair (Gateway <-> Lab Host Router). Same CA as the +# CONTROL-plane pair above, separate directory (/etc/rootnode/lab-router-tls) so the two +# concerns stay bind-mount-separable — the `lab-router`/`gateway` containers only ever need +# their own pair mounted in, never the Docker TLS one. See +# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3. +- name: Create /etc/rootnode/lab-router-tls + ansible.builtin.file: + path: /etc/rootnode/lab-router-tls + state: directory + owner: root + group: root + mode: "0700" + +- name: Deploy CA cert for the data-plane pair (public, both hosts) + ansible.builtin.copy: + src: "{{ docker_tls_local_bundle_dir }}/ca.pem" + dest: /etc/rootnode/lab-router-tls/ca.pem + owner: root + group: root + mode: "0444" + +- name: Deploy Lab Host Router server cert/key (rn-lab-host-01 only) + ansible.builtin.copy: + src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}" + dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}" + owner: root + group: root + mode: "{{ item.mode }}" + loop: + - { src: router-server-cert.pem, dest: router-server-cert.pem, mode: "0444" } + - { src: router-server-key.pem, dest: router-server-key.pem, mode: "0400" } + when: inventory_hostname in groups['lab_host'] + notify: restart lab-router + +- name: Deploy Gateway client cert/key (rn-platform-01 only) + ansible.builtin.copy: + src: "{{ docker_tls_local_bundle_dir }}/{{ item.src }}" + dest: "/etc/rootnode/lab-router-tls/{{ item.dest }}" + owner: root + group: root + mode: "{{ item.mode }}" + loop: + - { src: gateway-client-cert.pem, dest: gateway-client-cert.pem, mode: "0444" } + - { src: gateway-client-key.pem, dest: gateway-client-key.pem, mode: "0400" } + when: inventory_hostname in groups['platform'] + notify: restart gateway diff --git a/infra/ansible/roles/platform/templates/gateway.env.j2 b/infra/ansible/roles/platform/templates/gateway.env.j2 index ee28586..b2a6b8f 100644 --- a/infra/ansible/roles/platform/templates/gateway.env.j2 +++ b/infra/ansible/roles/platform/templates/gateway.env.j2 @@ -3,3 +3,11 @@ PLATFORM_API_URL=http://api:4300 PROVISIONER_URL=http://provisioner:4100 PROVISIONER_SHARED_SECRET={{ vault_provisioner_shared_secret }} GATEWAY_PORT=4200 +# Phase 4.1 — mTLS client identity for the Lab Host Router (data plane). Only used when a +# lab_host's routing_mode is 'router' (see platform/gateway/src/lab-endpoint.js); harmless if +# every configured lab_host is still 'direct'. Paths are inside the container — mounted from +# /etc/rootnode/lab-router-tls on the host by the `docker-tls` role, same pattern as the +# Provisioner's Docker TLS client cert. +LAB_ROUTER_TLS_CA=/tls/ca.pem +LAB_ROUTER_TLS_CERT=/tls/gateway-client-cert.pem +LAB_ROUTER_TLS_KEY=/tls/gateway-client-key.pem diff --git a/infra/docker/IMAGE_STRATEGY.md b/infra/docker/IMAGE_STRATEGY.md index 4e9210c..3f70342 100644 --- a/infra/docker/IMAGE_STRATEGY.md +++ b/infra/docker/IMAGE_STRATEGY.md @@ -8,6 +8,7 @@ | `rootnode/gateway` | `platform/gateway/Dockerfile` | `{{ registry }}/rootnode/gateway` | | `rootnode/provisioner` | `platform/provisioner/Dockerfile` | `{{ registry }}/rootnode/provisioner` | | `rootnode/egress-proxy` | `platform/egress-proxy/Dockerfile` | `{{ registry }}/rootnode/egress-proxy` | +| `rootnode/lab-router` | `platform/lab-router/Dockerfile` (Phase 4.1) | `{{ registry }}/rootnode/lab-router` | | `rootnode/lab` | root `Dockerfile` (the student Lab image) | `{{ registry }}/rootnode/lab` | ## Tagging diff --git a/infra/docker/lab-host/docker-compose.production.yml b/infra/docker/lab-host/docker-compose.production.yml index e170c22..d8b8ce6 100644 --- a/infra/docker/lab-host/docker-compose.production.yml +++ b/infra/docker/lab-host/docker-compose.production.yml @@ -1,10 +1,13 @@ # Production compose for rn-lab-host-01. Deployed by infra/ansible/roles/lab-host/ to # /opt/rootnode/lab-host/docker-compose.yml, run as a systemd unit -# (rootnode-lab-host.service.j2). Runs exactly one long-lived service: the shared egress proxy -# every student Lab's outbound traffic passes through (platform/egress-proxy/, unchanged since -# Phase 1). Student Lab containers themselves are NOT part of this compose file — they're -# created/destroyed individually by the Provisioner over the remote Docker TLS endpoint -# (dockerd itself, not compose), same mechanism as dev, just remote. +# (rootnode-lab-host.service.j2). Runs the shared egress proxy every student Lab's outbound +# traffic passes through (platform/egress-proxy/, unchanged since Phase 1) and, as of Phase +# 4.1, the Lab Host Router (platform/lab-router/) that gives the Gateway on rn-platform-01 a +# way to reach a running Lab without ever touching a Docker-internal address — see +# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3. Student Lab containers themselves are NOT +# part of this compose file — they're created/destroyed individually by the Provisioner over +# the remote Docker TLS endpoint (dockerd itself, not compose), same mechanism as dev, just +# remote; the Router finds them by label, purely from local Docker state (see its own README). networks: rn-egress: @@ -22,6 +25,26 @@ services: # is the actual control, see platform/egress-proxy/allowlist.txt) networks: [rn-egress] + lab-router: + image: "{{ registry }}/rootnode/lab-router:{{ image_tag }}" + restart: unless-stopped + # Needs the host's own routing table to reach whichever per-Lab bridge network + # (rootnode-lab-net-) the Provisioner created for a given container — those networks + # aren't known ahead of time and aren't attachable one-by-one, the same reason egress-proxy + # would need this too if it initiated connections outward instead of just accepting them. + # Restricted to platform_ip on port {{ lab_router_port | default('8443') }} at the host + # firewall (infra/firewall/rn-lab-host-01.rules) — this is the ONLY thing that makes host + # networking here safe, same defense-in-depth pattern as the Docker TLS port. + network_mode: host + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - /etc/rootnode/lab-router-tls:/tls:ro + environment: + LAB_ROUTER_PORT: "{{ lab_router_port | default('8443') }}" + LAB_ROUTER_TLS_CA: /tls/ca.pem + LAB_ROUTER_TLS_CERT: /tls/router-server-cert.pem + LAB_ROUTER_TLS_KEY: /tls/router-server-key.pem + node-exporter: image: prom/node-exporter:latest restart: unless-stopped diff --git a/infra/docker/platform/docker-compose.production.yml b/infra/docker/platform/docker-compose.production.yml index 9e0ae63..4117391 100644 --- a/infra/docker/platform/docker-compose.production.yml +++ b/infra/docker/platform/docker-compose.production.yml @@ -88,6 +88,11 @@ services: image: "{{ registry }}/rootnode/platform-api:{{ image_tag }}" restart: unless-stopped env_file: /etc/rootnode/platform.env + environment: + # Phase 4.1 §25 — not a secret, just the deployed tag; set directly here rather than in + # the env template since it's the same value already used for the image tag above, not a + # separately-managed setting. + GIT_SHA: "{{ image_tag }}" depends_on: postgres: condition: service_healthy @@ -99,6 +104,10 @@ services: image: "{{ registry }}/rootnode/gateway:{{ image_tag }}" restart: unless-stopped env_file: /etc/rootnode/gateway.env + volumes: + # mTLS client identity for the Lab Host Router (data plane) — see gateway.env.j2's + # LAB_ROUTER_TLS_* vars. Read-only; the private key never leaves this one bind mount. + - /etc/rootnode/lab-router-tls:/tls:ro depends_on: [provisioner, api] networks: [rn-platform-internal] diff --git a/infra/environments/production/provisioner-config/lab-hosts.yaml b/infra/environments/production/provisioner-config/lab-hosts.yaml index aa45a90..519c740 100644 --- a/infra/environments/production/provisioner-config/lab-hosts.yaml +++ b/infra/environments/production/provisioner-config/lab-hosts.yaml @@ -1,7 +1,12 @@ # Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev # Unix socket. Same mechanism the Provisioner has had since Phase 1 -# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint — -# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7. +# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at real remote endpoints. +# +# Phase 4.1 correction: routing_mode/router_endpoint is REQUIRED here — Phase 4's original +# claim that "no application code changes required" was true for the Provisioner's CONTROL +# plane (Docker mTLS) but false for the Gateway's DATA plane, which used to assume +# env.internal_ip was directly reachable. It is not, across two real VMs. See +# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md. # # {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml. # Rendered by the `platform` role; the raw file here keeps the placeholder for readability. @@ -9,8 +14,14 @@ lab_hosts: - lab_host_id: labhost-01 display_name: "RootNode Lab Host VM 01 (production)" + # CONTROL plane: Provisioner -> dockerd, mTLS, port 2376. endpoint: "tcp://{{ lab_host_ip }}:2376" tls_ca_path: /etc/rootnode/docker-tls/ca.pem tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem enabled: true + # DATA plane: Gateway -> Lab Host Router, mTLS, router's own port (8443 by default — + # infra/docker/lab-host/docker-compose.production.yml). Gateway never touches a + # Docker-internal address once routing_mode is 'router'. + routing_mode: router + router_endpoint: "https://{{ lab_host_ip }}:8443" diff --git a/infra/environments/staging/provisioner-config/lab-hosts.yaml b/infra/environments/staging/provisioner-config/lab-hosts.yaml index aa45a90..a71eb42 100644 --- a/infra/environments/staging/provisioner-config/lab-hosts.yaml +++ b/infra/environments/staging/provisioner-config/lab-hosts.yaml @@ -1,16 +1,17 @@ -# Production lab_hosts seed — points at the real rn-lab-host-01 VM instead of Phase 1's dev -# Unix socket. Same mechanism the Provisioner has had since Phase 1 -# (platform/provisioner/config/lab-hosts.seed.yaml), just pointed at a real remote endpoint — -# no application code changes required for this, see docs/PRODUCTION_NETWORK_SECURITY.md §7. -# -# {{ lab_host_ip }} is an Ansible template variable — see infra/ansible/group_vars/production.yml. -# Rendered by the `platform` role; the raw file here keeps the placeholder for readability. +# Staging lab_hosts seed — see infra/environments/production/provisioner-config/lab-hosts.yaml +# for the full explanation; this is the same shape, lower stakes. Staging's `lab_host_ip` is +# distinct from production's own (infra/ansible/group_vars/staging.yml), so it needs the same +# router routing_mode as production once staging genuinely runs on two hosts. If staging is +# ever collapsed onto a single host instead, change routing_mode to `direct` and drop +# router_endpoint — that's the only edit needed either way. lab_hosts: - lab_host_id: labhost-01 - display_name: "RootNode Lab Host VM 01 (production)" + display_name: "RootNode Lab Host VM 01 (staging)" endpoint: "tcp://{{ lab_host_ip }}:2376" tls_ca_path: /etc/rootnode/docker-tls/ca.pem tls_cert_path: /etc/rootnode/docker-tls/provisioner-client-cert.pem tls_key_path: /etc/rootnode/docker-tls/provisioner-client-key.pem enabled: true + routing_mode: router + router_endpoint: "https://{{ lab_host_ip }}:8443" diff --git a/infra/firewall/rn-lab-host-01.rules b/infra/firewall/rn-lab-host-01.rules index 7f084b9..196dda5 100644 --- a/infra/firewall/rn-lab-host-01.rules +++ b/infra/firewall/rn-lab-host-01.rules @@ -38,13 +38,14 @@ # endpoint: ALLOW" example in the Phase 4 spec, encoded literally here. -A INPUT -p tcp --dport 2376 -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT -# Gateway -> per-Lab published port range, rn-platform-01 only. REQUIRED so the Gateway can -# reach each student's Lab UI/terminal over the network once Labs run on a genuinely separate -# host — see docs/PRODUCTION_NETWORK_SECURITY.md §"Gateway-to-Lab cross-VM reachability" for -# why this exists and the small Provisioner/Gateway code change it depends on (NOT YET BUILT — -# this firewall rule is the network-policy half; the port-publishing half is an explicit, -# reviewed application-code follow-up, tracked there rather than silently assumed here). --A INPUT -p tcp --dport {{ lab_gateway_port_range | default('30000:32767') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT +# Lab Host Router (8443) — rn-platform-01 (the Gateway) only. This is the DATA-plane +# equivalent of the Docker TLS rule above: the Gateway never reaches a Lab's Docker-internal +# address directly (no per-Lab port publishing, no wide port range needed) — it reaches this +# one mTLS-authenticated Router instead, which resolves the request to a local container on +# ITS OWN host. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and +# docs/PRODUCTION_NETWORK_SECURITY.md — this closes the "Gateway-to-Lab cross-VM reachability" +# gap Phase 4 flagged as a required follow-up; it is no longer open. +-A INPUT -p tcp --dport {{ lab_router_port | default('8443') }} -s {{ platform_ip }} -m conntrack --ctstate NEW -j ACCEPT # Prometheus (rn-platform-01) scraping this host's node-exporter/cadvisor — metrics only, # read-only exposition endpoints, still restricted to the one legitimate scraper. diff --git a/infra/firewall/validate-firewall.sh b/infra/firewall/validate-firewall.sh index e6138a3..334ab79 100755 --- a/infra/firewall/validate-firewall.sh +++ b/infra/firewall/validate-firewall.sh @@ -21,7 +21,7 @@ render() { -e 's/{{ lab_host_ip }}/10.10.10.20/g' \ -e 's/{{ lab_subnet_range }}/10.42.0.0\/16/g' \ -e 's/{{ proxmox_mgmt_cidr }}/203.0.113.0\/24/g' \ - -e "s/{{ lab_gateway_port_range | default('30000:32767') }}/30000:32767/g" \ + -e "s/{{ lab_router_port | default('8443') }}/8443/g" \ "$1" > "$2" } diff --git a/infra/scripts/ci/tag-and-push.sh b/infra/scripts/ci/tag-and-push.sh index 6b33bcf..05369e8 100755 --- a/infra/scripts/ci/tag-and-push.sh +++ b/infra/scripts/ci/tag-and-push.sh @@ -22,6 +22,7 @@ declare -A IMAGES=( [rootnode/gateway]="platform/gateway/Dockerfile platform/gateway" [rootnode/provisioner]="platform/provisioner/Dockerfile platform/provisioner" [rootnode/egress-proxy]="platform/egress-proxy/Dockerfile platform/egress-proxy" + [rootnode/lab-router]="platform/lab-router/Dockerfile platform/lab-router" [rootnode/lab]="Dockerfile ." ) diff --git a/infra/scripts/docker-tls/generate-docker-tls.sh b/infra/scripts/docker-tls/generate-docker-tls.sh index 7de4282..1a17900 100755 --- a/infra/scripts/docker-tls/generate-docker-tls.sh +++ b/infra/scripts/docker-tls/generate-docker-tls.sh @@ -1,14 +1,21 @@ #!/usr/bin/env bash -# Generates a private CA and a mutual-TLS cert/key pair for the Provisioner <-> rn-lab-host-01 -# Docker Engine connection (docs/PRODUCTION_NETWORK_SECURITY.md §7). The application already -# supports this (platform/provisioner/src/docker.js buildClient() has required mTLS since -# Phase 1) — this script only ever needed to exist for the CERTIFICATES, not new app code. +# Generates a private CA and TWO mutual-TLS cert/key pairs, both under that one CA: +# 1. Provisioner <-> rn-lab-host-01 Docker Engine (CONTROL plane, port 2376). The application +# already supports this (platform/provisioner/src/docker.js buildClient() has required +# mTLS since Phase 1) — this script only ever needed to exist for the CERTIFICATES. +# 2. Gateway <-> rn-lab-host-01 Lab Host Router (DATA plane, port 8443) — Phase 4.1, see +# docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and docs/PRODUCTION_NETWORK_SECURITY.md. +# One CA for both keeps the PKI surface small (one thing to protect, one rotation runbook) — +# see docs/RUNBOOK_DOCKER_TLS_ROTATION.md, which now covers all four leaf certs, not just two. # # Output goes to $OUT_DIR (default ./out, gitignored) — never into a tracked path. Deploy the # resulting files with the `docker-tls` Ansible role, which places: -# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd's --tlsverify config -# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem +# rn-lab-host-01: ca.pem, server-cert.pem, server-key.pem -> dockerd --tlsverify +# ca.pem, router-server-cert.pem, router-server-key.pem -> lab-router (data plane) +# rn-platform-01: ca.pem, provisioner-client-cert.pem, provisioner-client-key.pem # -> /etc/rootnode/docker-tls/ (referenced by lab-hosts.yaml) +# ca.pem, gateway-client-cert.pem, gateway-client-key.pem +# -> /etc/rootnode/lab-router-tls/ (referenced by gateway's LAB_ROUTER_TLS_*) # # Usage: # ./generate-docker-tls.sh [--out-dir DIR] [--lab-host-ip IP] [--lab-host-dns NAME] [--days N] @@ -61,13 +68,37 @@ EOF openssl x509 -req -days "$DAYS" -sha256 -in client.csr -CA ca.pem -CAkey ca-key.pem \ -CAcreateserial -out provisioner-client-cert.pem -extfile client-extfile.cnf -rm -f server.csr client.csr server-extfile.cnf client-extfile.cnf -chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem -chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem +echo "==> Generating server cert for rn-lab-host-01 (Lab Host Router, data plane)" +openssl genrsa -out router-server-key.pem 4096 +openssl req -subj "/CN=$LAB_HOST_DNS" -sha256 -new -key router-server-key.pem -out router-server.csr + +cat > router-server-extfile.cnf < Generating client cert for the Gateway (data plane)" +openssl genrsa -out gateway-client-key.pem 4096 +openssl req -subj "/CN=rootnode-gateway" -new -key gateway-client-key.pem -out gateway-client.csr + +openssl x509 -req -days "$DAYS" -sha256 -in gateway-client.csr -CA ca.pem -CAkey ca-key.pem \ + -CAcreateserial -out gateway-client-cert.pem -extfile client-extfile.cnf + +rm -f server.csr client.csr router-server.csr gateway-client.csr server-extfile.cnf client-extfile.cnf router-server-extfile.cnf +chmod 0400 ca-key.pem server-key.pem provisioner-client-key.pem router-server-key.pem gateway-client-key.pem +chmod 0444 ca.pem server-cert.pem provisioner-client-cert.pem router-server-cert.pem gateway-client-cert.pem echo "==> Done. Generated in $OUT_DIR:" ls -la echo -echo "Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)." -echo "Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)." -echo "Verify with: ../verify-docker-tls.sh $OUT_DIR " +echo "CONTROL plane (Provisioner -> dockerd, port 2376):" +echo " Deploy server-{cert,key}.pem + ca.pem to rn-lab-host-01 (docker-tls Ansible role)." +echo " Deploy provisioner-client-{cert,key}.pem + ca.pem to rn-platform-01 (same role)." +echo "DATA plane (Gateway -> Lab Host Router, port 8443):" +echo " Deploy router-server-{cert,key}.pem + ca.pem to rn-lab-host-01 (lab-router Ansible role)." +echo " Deploy gateway-client-{cert,key}.pem + ca.pem to rn-platform-01 (platform Ansible role)." +echo +echo "Verify Docker control-plane TLS with: ../verify-docker-tls.sh $OUT_DIR " diff --git a/infra/scripts/validate-compose.sh b/infra/scripts/validate-compose.sh index 5cd1b43..4dc38ae 100755 --- a/infra/scripts/validate-compose.sh +++ b/infra/scripts/validate-compose.sh @@ -36,3 +36,17 @@ sed -e 's/{{ academy_domain }}/academy.example.com/g' -e 's/{{ management_cidr } chmod a+r "$TMPDIR/Caddyfile" docker run --rm -v "$TMPDIR:/etc/caddy:ro" caddy:2-alpine caddy validate --config /etc/caddy/Caddyfile 2>&1 | grep -q "Valid configuration" \ && echo "OK: Caddyfile is valid" || { echo "FAIL: Caddyfile did not validate"; exit 1; } + +# Phase 4.1: rn-lab-host-01's compose file (egress-proxy, lab-router, observability agents) +# was never covered here before — added when the lab-router service landed. +echo +echo "--- lab-host production compose ---" +LABHOST_TMPDIR="$(mktemp -d)" +sed \ + -e 's/{{ registry }}/registry.example.com/g' \ + -e 's/{{ image_tag }}/test/g' \ + -e "s/{{ lab_router_port | default('8443') }}/8443/g" \ + ../lab-host/docker-compose.production.yml > "$LABHOST_TMPDIR/docker-compose.yml" +docker compose -f "$LABHOST_TMPDIR/docker-compose.yml" config --quiet +echo "OK: lab-host production compose file is well-formed YAML and passes docker compose's own schema validation" +rm -rf "$LABHOST_TMPDIR" diff --git a/platform/gateway/src/lab-endpoint.js b/platform/gateway/src/lab-endpoint.js new file mode 100644 index 0000000..9960b25 --- /dev/null +++ b/platform/gateway/src/lab-endpoint.js @@ -0,0 +1,128 @@ +// Phase 4.1 — the ONE place the Gateway decides how to reach a student's Lab. Everything else +// in server.js just calls labTarget(env, token) and gets back a plain { url, headers, agent } +// it can hand to http-proxy or fetch, with zero knowledge of Docker, VMs, or lab_hosts. See +// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and docs/PRODUCTION_NETWORK_SECURITY.md. +// +// env.lab_host_routing_mode / env.lab_host_router_endpoint come from the Provisioner's +// by-token response (a join against lab_hosts — see provisioner lifecycle.js +// getEnvironmentByTokenWithRouting). Two modes: +// +// 'direct' (default, dev/single-host) — unchanged from every prior phase: the Gateway +// reaches env.internal_ip:internal_port itself, because Gateway and the Lab share a Docker +// daemon/network namespace. +// +// 'router' (production, two real VMs) — the Gateway never touches a Docker-internal +// address. It reaches that lab_host's Lab Host Router (platform/lab-router) instead, over +// mTLS, carrying the environment's routing_token in a header so the Router — which lives on +// the SAME host as the Lab and can resolve the token from local `docker ps` alone — knows +// which local container to proxy to. + +const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const { URL } = require('url'); + +const ROUTE_TOKEN_HEADER = 'x-rootnode-route-token'; + +// mTLS client identity the Gateway presents to a Lab Host Router. One shared cert/key/ca is +// enough for Phase 4.1's single lab_host; if a second lab_host ever needs a distinct client +// identity, this becomes a map keyed by lab_host_id — not needed yet (see docs +// "multi-host-ready" note, no scheduler built here). +const TLS_CA = process.env.LAB_ROUTER_TLS_CA; +const TLS_CERT = process.env.LAB_ROUTER_TLS_CERT; +const TLS_KEY = process.env.LAB_ROUTER_TLS_KEY; + +let _agent = null; +function routerAgent() { + if (_agent) return _agent; + if (!TLS_CA || !TLS_CERT || !TLS_KEY) { + throw new Error( + 'A lab_host is configured with routing_mode=router but LAB_ROUTER_TLS_CA/CERT/KEY are not set — refusing to connect to the Lab Host Router unauthenticated.' + ); + } + _agent = new https.Agent({ + ca: fs.readFileSync(TLS_CA), + cert: fs.readFileSync(TLS_CERT), + key: fs.readFileSync(TLS_KEY), + // The Router's server cert is issued by the same private CA (infra/scripts/docker-tls/ + // generate-docker-tls.sh) — never fall back to the public CA trust store for this. + }); + return _agent; +} + +// env: the (possibly-cached) environment row from the Provisioner's by-token response. +// token: the routing token the browser's URL already carried (Gateway never needs the +// Provisioner to hand it back — see provisioner server.js redactRow's comment on why +// routing_token is redacted everywhere except the two routes that legitimately need it, +// neither of which is this one). +function labTarget(env, token) { + if (env.lab_host_routing_mode === 'router') { + if (!env.lab_host_router_endpoint) { + throw new Error(`lab_host '${env.lab_host_id}' has routing_mode=router but no router_endpoint`); + } + return { + url: env.lab_host_router_endpoint, + headers: { [ROUTE_TOKEN_HEADER]: token }, + agent: routerAgent(), + mode: 'router', + // The browser's original Host header (e.g. "localhost:4210" or the real public origin) + // has nothing to do with the Router's own TLS identity — without this, http-proxy's + // outgoing HTTPS connection inherits that unrelated Host header for SNI/hostname + // verification and the Router's cert (correctly issued for ITS OWN name) fails to + // match. changeOrigin makes http-proxy rewrite the outgoing Host header (and therefore + // the TLS servername) to match the actual target — found by testing against the real + // Phase 4.1 simulation harness, not theoretical. + changeOrigin: true, + }; + } + // direct — unchanged behavior from every phase before this one. + return { url: `http://${env.internal_ip}:${env.internal_port}`, headers: {}, agent: undefined, mode: 'direct', changeOrigin: false }; +} + +// Used by the two Gateway call sites that need to make a one-shot request to the Lab and read +// its JSON body (progress/answer/validate relay, hint-count stripping) — everywhere else +// (the actual proxy paths) uses http-proxy directly with the same target/agent/headers. +// +// Deliberately built on node:http/node:https rather than global fetch: fetch's undici +// dispatcher isn't a stable, dependency-free way to attach a custom mTLS agent across Node +// versions, and http.request/https.request support client certs natively with zero new +// dependencies (stdlib covers it — see the ladder in CLAUDE.md's coding-style guidance). +// Mimics just enough of fetch's response shape ({ status, json() }) that call sites barely +// change. +function labRequest(env, token, path, { method = 'GET', headers = {}, body } = {}) { + const target = labTarget(env, token); + const url = new URL(path, target.url); + const transport = url.protocol === 'https:' ? https : http; + const payload = body === undefined ? undefined : (typeof body === 'string' ? body : JSON.stringify(body)); + + return new Promise((resolve, reject) => { + const req = transport.request( + url, + { + method, + agent: target.agent, + headers: { + ...headers, + ...target.headers, + ...(payload !== undefined ? { 'Content-Length': Buffer.byteLength(payload) } : {}), + }, + }, + (res) => { + const chunks = []; + res.on('data', (c) => chunks.push(c)); + res.on('end', () => { + const text = Buffer.concat(chunks).toString('utf8'); + resolve({ + status: res.statusCode, + json: async () => JSON.parse(text), + }); + }); + } + ); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +module.exports = { labTarget, labRequest, ROUTE_TOKEN_HEADER }; diff --git a/platform/gateway/src/server.js b/platform/gateway/src/server.js index 09c230c..420fe27 100644 --- a/platform/gateway/src/server.js +++ b/platform/gateway/src/server.js @@ -19,6 +19,11 @@ const express = require('express'); const httpProxy = require('http-proxy'); const { provisioner } = require('./provisioner-client'); // still used ONLY for the token->environment lookup a Lab request needs; all mutating calls (provision/reset/destroy) now live in the Platform API. const envCache = require('./env-cache'); +// Phase 4.1 — the ONE place that knows how to reach a Lab (direct Docker-internal address, or +// via that lab_host's Lab Host Router). Every proxy.web/proxy.ws/labRequest call below uses +// labTarget's output instead of building `http://${env.internal_ip}:${env.internal_port}` +// itself. See lab-endpoint.js's header comment and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1. +const { labTarget, labRequest } = require('./lab-endpoint'); const PORT = Number(process.env.GATEWAY_PORT) || 4200; const PLATFORM_API_URL = process.env.PLATFORM_API_URL || 'http://localhost:4300'; @@ -90,6 +95,9 @@ async function resolveByToken(token) { return body; } +// internal_ip is set by the Provisioner as soon as a container exists, regardless of routing +// mode (it's a Docker-internal fact, independent of how the Gateway will reach it) — so this +// check stays valid unchanged for both 'direct' and 'router' lab_hosts. function isReachable(env) { return env && env.status === 'running' && env.internal_ip; } @@ -116,9 +124,9 @@ async function checkOwnership(req, env) { // ── Progress relay (unchanged mechanism from Phase 2A, now passes environment_id + // validation_count through so Platform API can log a real scenario_attempts row) ────────── -async function relayScenarioOutcome({ env, req, res, scenarioPath, extractOutcome }) { +async function relayScenarioOutcome({ env, token, req, res, scenarioPath, extractOutcome }) { if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.'); - const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}${scenarioPath}`, { + const labRes = await labRequest(env, token, scenarioPath, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(req.body || {}), @@ -157,7 +165,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/validate', express.json(), async const own = await checkOwnership(req, env); if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); await relayScenarioOutcome({ - env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`, + env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/validate`, extractOutcome: (body) => (typeof body.passed === 'boolean' ? body.passed : null), }); }); @@ -167,7 +175,7 @@ app.post('/lab/:token/api/scenarios/:scenarioId/answer', express.json(), async ( const own = await checkOwnership(req, env); if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); await relayScenarioOutcome({ - env, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`, + env, token: req.params.token, req, res, scenarioPath: `/api/scenarios/${req.params.scenarioId}/answer`, extractOutcome: (body) => (typeof body.correct === 'boolean' ? body.correct : null), }); }); @@ -223,7 +231,7 @@ app.get('/lab/:token/api/scenarios/:scenarioId', async (req, res) => { if (!own.ok) return sendLabError(res, own.reason === 'invalid_token' ? 404 : 403, own.reason, 'This lab is not available to you.'); if (!isReachable(env)) return sendLabError(res, 503, 'lab_unavailable', 'Your lab is not currently running.'); try { - const labRes = await fetch(`http://${env.internal_ip}:${env.internal_port}/api/scenarios/${req.params.scenarioId}`); + const labRes = await labRequest(env, req.params.token, `/api/scenarios/${req.params.scenarioId}`); const body = await labRes.json().catch(() => ({})); const { hints, ...withoutHints } = body; res.status(labRes.status).json({ ...withoutHints, hint_count: Array.isArray(hints) ? hints.length : 0 }); @@ -259,7 +267,8 @@ app.use('/lab/:token', async (req, res, next) => { // for this browser. res.append('Set-Cookie', `${LAB_ACTIVE_COOKIE}=1; Path=/; HttpOnly; SameSite=Lax; Max-Age=28800`); req.url = req.url.replace(/^\/lab\/[^/]+/, '') || '/'; - proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` }); + const target = labTarget(env, token); + proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin }); }); // Resolves "the environment this browser's session currently owns and can reach," for the @@ -268,20 +277,23 @@ app.use('/lab/:token', async (req, res, next) => { // the WS upgrade handler so there's exactly one place this logic lives. Gated on // LAB_ACTIVE_COOKIE first — see its definition above for why "has a running env" alone isn't // a safe signal (it's true on the dashboard too). +// Returns { env, token } (not just env) — Phase 4.1's labTarget needs the routing token +// alongside the environment row for router-mode lab_hosts, and this is the only place that +// derives the token from launch_path rather than the URL, so it has to hand both back. async function resolveOwnedRunningEnvironment(req) { const cookies = parseCookies(req); if (!cookies[LAB_ACTIVE_COOKIE]) return null; - const token = cookies[STUDENT_COOKIE]; - if (!token) return null; + const sessionToken = cookies[STUDENT_COOKIE]; + if (!sessionToken) return null; const studentId = await resolveSessionStudentId(req); if (!studentId) return null; try { - const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${token}` } }); + const envRes = await fetch(`${PLATFORM_API_URL}/api/environment/status`, { headers: { Cookie: `${STUDENT_COOKIE}=${sessionToken}` } }); const envInfo = await envRes.json().catch(() => null); if (envInfo?.status !== 'running' || !envInfo.launch_path) return null; const lookupToken = envInfo.launch_path.split('/')[2]; const env = await resolveByToken(lookupToken); - return isReachable(env) && env.student_id === studentId ? env : null; + return isReachable(env) && env.student_id === studentId ? { env, token: lookupToken } : null; } catch { return null; } @@ -303,8 +315,11 @@ function looksLikeLabPath(pathname) { // or the Platform API (student dashboard, admin portal, roster/curriculum/progress) ──────── app.use(async (req, res) => { if (looksLikeLabPath(req.path)) { - const env = await resolveOwnedRunningEnvironment(req); - if (env) return proxy.web(req, res, { target: `http://${env.internal_ip}:${env.internal_port}` }); + const owned = await resolveOwnedRunningEnvironment(req); + if (owned) { + const target = labTarget(owned.env, owned.token); + return proxy.web(req, res, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin }); + } } proxy.web(req, res, { target: PLATFORM_API_URL }); }); @@ -319,12 +334,16 @@ server.on('upgrade', async (req, socket, head) => { const own = await checkOwnership(req, env); if (!own.ok || !isReachable(env)) return socket.destroy(); req.url = rest || '/'; - return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` }); + const target = labTarget(env, token); + return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin }); } // Unprefixed WS (the existing Lab UI's /shell-ws, hit while "inside" a lab session). - const env = await resolveOwnedRunningEnvironment(req); - if (env) return proxy.ws(req, socket, head, { target: `http://${env.internal_ip}:${env.internal_port}` }); + const owned = await resolveOwnedRunningEnvironment(req); + if (owned) { + const target = labTarget(owned.env, owned.token); + return proxy.ws(req, socket, head, { target: target.url, agent: target.agent, headers: target.headers, changeOrigin: target.changeOrigin }); + } socket.destroy(); }); diff --git a/platform/lab-router/Dockerfile b/platform/lab-router/Dockerfile new file mode 100644 index 0000000..d905c65 --- /dev/null +++ b/platform/lab-router/Dockerfile @@ -0,0 +1,7 @@ +FROM node:20-slim +WORKDIR /app +COPY package.json ./ +RUN npm install --production +COPY src/ ./src/ +EXPOSE 8443 +CMD ["node", "src/server.js"] diff --git a/platform/lab-router/package-lock.json b/platform/lab-router/package-lock.json new file mode 100644 index 0000000..3c0c243 --- /dev/null +++ b/platform/lab-router/package-lock.json @@ -0,0 +1,706 @@ +{ + "name": "rootnode-lab-router", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rootnode-lab-router", + "version": "0.1.0", + "dependencies": { + "dockerode": "^4.0.2", + "http-proxy": "^1.18.1" + } + }, + "node_modules/@balena/dockerignore": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@balena/dockerignore/-/dockerignore-1.0.2.tgz", + "integrity": "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.14.4", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", + "integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", + "dependencies": { + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" + }, + "engines": { + "node": ">=12.10.0" + } + }, + "node_modules/@grpc/grpc-js/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", + "dependencies": { + "tweetnacl": "^0.14.3" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buildcheck": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/buildcheck/-/buildcheck-0.0.7.tgz", + "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", + "optional": true, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/cpu-features": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.10.tgz", + "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "buildcheck": "~0.0.6", + "nan": "^2.19.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/docker-modem": { + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", + "integrity": "sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==", + "dependencies": { + "debug": "^4.1.1", + "readable-stream": "^3.5.0", + "split-ca": "^1.0.1", + "ssh2": "^1.15.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/dockerode": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/dockerode/-/dockerode-4.0.12.tgz", + "integrity": "sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==", + "dependencies": { + "@balena/dockerignore": "^1.0.2", + "@grpc/grpc-js": "^1.11.1", + "@grpc/proto-loader": "^0.7.13", + "docker-modem": "^5.0.7", + "protobufjs": "^7.3.2", + "tar-fs": "^2.1.4", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">= 8.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==" + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/nan": { + "version": "2.28.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", + "integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==", + "optional": true + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/split-ca": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/split-ca/-/split-ca-1.0.1.tgz", + "integrity": "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==" + }, + "node_modules/ssh2": { + "version": "1.17.0", + "resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.17.0.tgz", + "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", + "hasInstallScript": true, + "dependencies": { + "asn1": "^0.2.6", + "bcrypt-pbkdf": "^1.0.2" + }, + "engines": { + "node": ">=10.16.0" + }, + "optionalDependencies": { + "cpu-features": "~0.0.10", + "nan": "^2.23.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tar-fs": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz", + "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "engines": { + "node": ">=12" + } + } + } +} diff --git a/platform/lab-router/package.json b/platform/lab-router/package.json new file mode 100644 index 0000000..9f5ff7a --- /dev/null +++ b/platform/lab-router/package.json @@ -0,0 +1,15 @@ +{ + "name": "rootnode-lab-router", + "version": "0.1.0", + "description": "RootNode Academy Phase 4.1 — Lab Host Router. Runs on the same Docker host as student Labs; resolves an opaque routing token to a local container and reverse-proxies the Platform Gateway's data-plane traffic to it. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3.", + "private": true, + "main": "src/server.js", + "scripts": { + "start": "node src/server.js", + "test": "node test/run.js" + }, + "dependencies": { + "dockerode": "^4.0.2", + "http-proxy": "^1.18.1" + } +} diff --git a/platform/lab-router/src/resolve.js b/platform/lab-router/src/resolve.js new file mode 100644 index 0000000..bbbb741 --- /dev/null +++ b/platform/lab-router/src/resolve.js @@ -0,0 +1,29 @@ +// Token -> local container resolution, split out from server.js so it can be unit-tested +// against a real (or fake) Docker client without needing TLS material or a bound port. +// See server.js's header comment for the full design rationale. + +const ROUTING_TOKEN_LABEL = 'rootnode.routing-token'; + +function tokenFingerprint(token) { + return token ? `${token.slice(0, 6)}…(${token.length})` : '(none)'; +} + +// Resolves a routing token to a live local container's reachable base URL, purely from local +// Docker state — no Postgres, no Provisioner call. Returns null for "no such token" AND "token +// belonged to a destroyed/replaced container" — both cases the caller treats identically (404). +async function resolveTarget(docker, token) { + if (!token || typeof token !== 'string' || token.length < 16) return null; // reject garbage before even hitting Docker + const containers = await docker.listContainers({ + filters: JSON.stringify({ label: [`${ROUTING_TOKEN_LABEL}=${token}`], status: ['running'] }), + }); + if (containers.length !== 1) return null; // 0 = unknown/destroyed; >1 should never happen (label is per-env-unique) but fail closed either way + const info = containers[0]; + const networks = info.NetworkSettings && info.NetworkSettings.Networks; + if (!networks) return null; + const netName = Object.keys(networks)[0]; + const ip = netName && networks[netName].IPAddress; + if (!ip) return null; + return `http://${ip}:80`; // every Lab container listens on 80 internally — see docs/LAB_ARCHITECTURE.md +} + +module.exports = { resolveTarget, tokenFingerprint, ROUTING_TOKEN_LABEL }; diff --git a/platform/lab-router/src/server.js b/platform/lab-router/src/server.js new file mode 100644 index 0000000..c97c01f --- /dev/null +++ b/platform/lab-router/src/server.js @@ -0,0 +1,103 @@ +// RootNode Academy — Lab Host Router (Phase 4.1). +// +// Runs on the SAME Docker host as student Labs (rn-lab-host-01 in production). Its only job: +// take a request from the Platform Gateway carrying an opaque routing token, find the ONE +// local container labeled with that token, and reverse-proxy the request to it. See +// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1-3 and docs/PRODUCTION_NETWORK_SECURITY.md. +// +// Deliberately narrow: +// - Never talks to Postgres, the Provisioner, or anything off this host — it resolves purely +// from local `docker ps` state (containers are labeled by the Provisioner at creation time, +// see provisioner lifecycle.js), so a destroyed/reset environment's old token simply has no +// matching container anymore. No separate "is this token still valid" check is needed. +// - Only one route exists: proxy-by-token. No container listing, no Docker-API passthrough, +// no endpoint that echoes back a container id, IP, or network name to the caller. +// - Requires mTLS — the Node https server itself rejects any connection that doesn't present +// a certificate signed by the configured CA (requestCert + rejectUnauthorized), so an +// unauthenticated caller never reaches route-resolution logic at all. This is the ONLY +// authentication; there is no bearer-token/API-key fallback (mTLS or nothing). +// - Binds to an internal address only — this process has no idea what "public" means, that's +// enforced by the firewall (infra/firewall/rn-lab-host-01.rules) and by never being given a +// public-facing port mapping in the production Compose file. Defense in depth: even if the +// firewall were misconfigured, mTLS still blocks anyone without the Gateway's client cert. + +const fs = require('fs'); +const https = require('https'); +const httpProxy = require('http-proxy'); +const Docker = require('dockerode'); +const { resolveTarget, tokenFingerprint } = require('./resolve'); + +const PORT = Number(process.env.LAB_ROUTER_PORT) || 8443; +const TLS_CA = process.env.LAB_ROUTER_TLS_CA; +const TLS_CERT = process.env.LAB_ROUTER_TLS_CERT; +const TLS_KEY = process.env.LAB_ROUTER_TLS_KEY; +const ROUTE_TOKEN_HEADER = 'x-rootnode-route-token'; + +if (!TLS_CA || !TLS_CERT || !TLS_KEY) { + console.error('FATAL: LAB_ROUTER_TLS_CA/CERT/KEY are not set. Refusing to start an unauthenticated Lab Host Router.'); + process.exit(1); +} + +// Always the local socket — the Router only ever inspects containers on ITS OWN host, never a +// remote Docker daemon (that's the Provisioner's job, over a completely separate mTLS +// connection — see docs/PHASE4_1_PREPRODUCTION_HARDENING.md §10 "control plane vs data plane"). +const docker = new Docker({ socketPath: process.env.DOCKER_SOCKET || '/var/run/docker.sock' }); + +const proxy = httpProxy.createProxyServer({ ws: true }); +proxy.on('error', (err, req, res) => { + console.error('[lab-router] proxy error:', err.message); + if (res && res.writeHead && !res.headersSent) { + res.writeHead(502, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'lab_unreachable' })); + } else if (res && res.destroy) { + res.destroy(); + } +}); + +async function handleRequest(req, res) { + const token = req.headers[ROUTE_TOKEN_HEADER]; + let target; + try { + target = await resolveTarget(docker, token); + } catch (e) { + console.error('[lab-router] docker lookup failed:', e.message); + res.writeHead(502, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'router_internal_error' })); + } + if (!target) { + console.log(`[lab-router] unknown/stale token ${tokenFingerprint(token)} -> 404`); + res.writeHead(404, { 'Content-Type': 'application/json' }); + return res.end(JSON.stringify({ error: 'unknown_environment' })); + } + proxy.web(req, res, { target }); +} + +async function handleUpgrade(req, socket, head) { + const token = req.headers[ROUTE_TOKEN_HEADER]; + let target; + try { + target = await resolveTarget(docker, token); + } catch (e) { + console.error('[lab-router] docker lookup failed (ws):', e.message); + return socket.destroy(); + } + if (!target) { + console.log(`[lab-router] unknown/stale token ${tokenFingerprint(token)} -> ws rejected`); + return socket.destroy(); + } + proxy.ws(req, socket, head, { target }); +} + +const tlsOptions = { + ca: fs.readFileSync(TLS_CA), + cert: fs.readFileSync(TLS_CERT), + key: fs.readFileSync(TLS_KEY), + requestCert: true, + rejectUnauthorized: true, // the entire authentication mechanism — see file header +}; + +const server = https.createServer(tlsOptions, handleRequest); +server.on('upgrade', handleUpgrade); +server.on('tlsClientError', (err) => console.warn('[lab-router] rejected unauthenticated/invalid TLS client:', err.message)); + +server.listen(PORT, () => console.log(`Lab Host Router listening on :${PORT} (mTLS required)`)); diff --git a/platform/lab-router/test/run.js b/platform/lab-router/test/run.js new file mode 100644 index 0000000..09fe362 --- /dev/null +++ b/platform/lab-router/test/run.js @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Fast, isolated unit test for resolve.js's token->container logic — no live Docker daemon, no +// TLS material, no network. Uses a fake dockerode-shaped client so this runs in milliseconds +// as part of every regression pass. The REAL cross-host proxying behavior (mTLS handshake, +// actual container resolution, HTTP+WS traffic) is covered by the two-host simulation harness +// instead — see infra/scripts/simulate-two-host/README.md and +// docs/PHASE4_1_PREPRODUCTION_HARDENING.md §6, not duplicated here. + +const assert = require('assert'); +const { resolveTarget, tokenFingerprint } = require('../src/resolve'); + +function fakeDocker(containers) { + return { + listContainers: async ({ filters }) => { + const parsed = JSON.parse(filters); + const wantLabel = parsed.label[0]; + const wantStatus = parsed.status[0]; + return containers.filter((c) => c.Labels && `${Object.keys(c.Labels)[0]}=${Object.values(c.Labels)[0]}` === wantLabel && c.State === wantStatus); + }, + }; +} + +async function main() { + const runningContainer = { + Labels: { 'rootnode.routing-token': 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' }, + State: 'running', + NetworkSettings: { Networks: { 'rootnode-lab-net-abc': { IPAddress: '172.20.0.5' } } }, + }; + + { + const docker = fakeDocker([runningContainer]); + const target = await resolveTarget(docker, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + assert.strictEqual(target, 'http://172.20.0.5:80', 'known running token resolves to its container'); + console.log('ok — known token resolves to the correct container address'); + } + + { + const docker = fakeDocker([runningContainer]); + const target = await resolveTarget(docker, 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'); + assert.strictEqual(target, null, 'unknown token resolves to nothing'); + console.log('ok — unknown token resolves to null (404 at the HTTP layer)'); + } + + { + // Simulates a destroyed/reset environment: the container that used to match this label is + // gone (not present in the list at all — a real reset/destroy removes the old container). + const docker = fakeDocker([]); + const target = await resolveTarget(docker, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + assert.strictEqual(target, null, 'stale token (container destroyed) resolves to nothing'); + console.log('ok — stale/destroyed-environment token resolves to null, same as unknown'); + } + + { + const docker = fakeDocker([runningContainer]); + for (const garbage of [undefined, null, '', 'short', 123, {}]) { + const target = await resolveTarget(docker, garbage); + assert.strictEqual(target, null, `garbage input ${JSON.stringify(garbage)} must not resolve`); + } + console.log('ok — garbage/malformed tokens are rejected before ever touching Docker'); + } + + { + assert.strictEqual(tokenFingerprint('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'aaaaaa…(32)'); + assert.strictEqual(tokenFingerprint(undefined), '(none)'); + const fp = tokenFingerprint('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + assert.ok(!fp.includes('aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), 'fingerprint never contains the full token — never logged in plaintext'); + console.log('ok — token fingerprint for logs never reveals the full token'); + } + + console.log('\nAll lab-router unit tests passed.'); +} + +main().catch((e) => { console.error(e); process.exit(1); }); diff --git a/platform/provisioner/config/lab-hosts.seed.yaml b/platform/provisioner/config/lab-hosts.seed.yaml index cd30122..c63cdeb 100644 --- a/platform/provisioner/config/lab-hosts.seed.yaml +++ b/platform/provisioner/config/lab-hosts.seed.yaml @@ -1,19 +1,31 @@ -# Phase 1 lab_hosts seed — applied to the DB at Provisioner startup (config.js -# applyLabHostsSeed), not managed via a runtime API (no host-management endpoints in Phase 1, -# matching "don't overbuild scheduling yet" — see docs/PHASE1_PLATFORM_FOUNDATION.md §4). +# lab_hosts seed — applied to the DB at Provisioner startup (config.js applyLabHostsSeed), not +# managed via a runtime API (no host-management endpoints yet — see +# docs/PHASE1_PLATFORM_FOUNDATION.md §4, "don't overbuild scheduling yet"). # -# Exactly one enabled row for Phase 1. Swapping this for a remote tcp://:2376 -# entry with TLS material is the entire code change needed to point at a real, separate Lab -# Host VM in production — see docs/LAB_ARCHITECTURE.md §1 and correction D. +# `endpoint`/`tls_*_path` — the Provisioner's CONTROL-plane connection to this host's Docker +# daemon (create/start/stop/remove containers). Unchanged since Phase 1. +# +# `routing_mode`/`router_endpoint` — Phase 4.1 addition, the Gateway's DATA-plane path to a +# running Lab on this host. See docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1 and +# docs/PRODUCTION_NETWORK_SECURITY.md. +# direct — Gateway reaches env.internal_ip:internal_port itself. Only correct when Gateway +# and this lab_host's Docker daemon are on the same network namespace (dev, +# single-host). This is the default and requires no router_endpoint. +# router — Gateway reaches this lab_host's Lab Host Router (platform/lab-router) instead, +# over mTLS, and never touches a Docker-internal address. Required once this +# lab_host is a genuinely separate VM from the Gateway. lab_hosts: - lab_host_id: dev-local display_name: "Local development Docker socket" endpoint: "unix:///var/run/docker.sock" enabled: true + routing_mode: direct - # Production example (not active — shown for reference; uncomment and fill in when a real - # Lab Host VM exists): + # Production example (not active — shown for reference; uncomment and fill in once a real + # Lab Host VM exists). CONTROL plane: Provisioner -> dockerd, mTLS, port 2376. DATA plane: + # Gateway -> Lab Host Router, mTLS, router's own port (infra default 8443) — see + # infra/docker/lab-host/docker-compose.production.yml and infra/ansible/roles/lab-router. # - lab_host_id: labhost-01 # display_name: "RootNode Lab Host VM 01" # endpoint: "tcp://10.20.30.5:2376" @@ -21,3 +33,5 @@ lab_hosts: # tls_cert_path: /etc/rootnode/tls/labhost-01/cert.pem # tls_key_path: /etc/rootnode/tls/labhost-01/key.pem # enabled: true + # routing_mode: router + # router_endpoint: "https://10.20.30.5:8443" diff --git a/platform/provisioner/migrations/004_lab_hosts_routing.sql b/platform/provisioner/migrations/004_lab_hosts_routing.sql new file mode 100644 index 0000000..03b0da0 --- /dev/null +++ b/platform/provisioner/migrations/004_lab_hosts_routing.sql @@ -0,0 +1,32 @@ +-- lab_hosts routing mode — Phase 4.1, docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1. +-- +-- Removes the Gateway's dependency on env.internal_ip being reachable directly, which only +-- holds when Gateway and the Lab container share a Docker daemon (true in dev, false once +-- Labs run on a genuinely separate rn-lab-host-01 VM). A lab_host is either: +-- 'direct' — Gateway reaches env.internal_ip:internal_port itself. Correct when Gateway and +-- this lab_host's Docker daemon share a network namespace (dev, single-host). +-- 'router' — Gateway reaches this lab_host's Lab Host Router (router_endpoint) over mTLS +-- instead, carrying the environment's routing_token in a header; the Router +-- resolves that token to a local container on ITS OWN host and reverse-proxies. +-- Correct once this lab_host is a genuinely separate VM. +-- +-- Existing 'dev-local' row keeps routing_mode='direct' (the default) — no behavior change for +-- current dev/single-host setups. All existing lab_hosts rows and every environments row are +-- untouched; this only adds columns. + +ALTER TABLE lab_hosts ADD COLUMN IF NOT EXISTS routing_mode TEXT NOT NULL DEFAULT 'direct'; +ALTER TABLE lab_hosts ADD COLUMN IF NOT EXISTS router_endpoint TEXT; + +DO $$ BEGIN + ALTER TABLE lab_hosts ADD CONSTRAINT lab_hosts_routing_mode_check + CHECK (routing_mode IN ('direct', 'router')); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; + +DO $$ BEGIN + ALTER TABLE lab_hosts ADD CONSTRAINT lab_hosts_router_endpoint_required_check + CHECK (routing_mode = 'direct' OR router_endpoint IS NOT NULL); +EXCEPTION + WHEN duplicate_object THEN NULL; +END $$; diff --git a/platform/provisioner/src/config.js b/platform/provisioner/src/config.js index 194397c..1ea75b2 100644 --- a/platform/provisioner/src/config.js +++ b/platform/provisioner/src/config.js @@ -24,14 +24,19 @@ function loadLabHostsSeed() { async function applyLabHostsSeed(pool) { const hosts = loadLabHostsSeed(); for (const h of hosts) { + const routingMode = h.routing_mode || 'direct'; // Phase 4.1 — see migration 004's comment + if (routingMode === 'router' && !h.router_endpoint) { + throw new Error(`lab_host '${h.lab_host_id}' has routing_mode: router but no router_endpoint configured`); + } await pool.query( - `INSERT INTO lab_hosts (lab_host_id, display_name, endpoint, tls_ca_path, tls_cert_path, tls_key_path, enabled) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO lab_hosts (lab_host_id, display_name, endpoint, tls_ca_path, tls_cert_path, tls_key_path, enabled, routing_mode, router_endpoint) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (lab_host_id) DO UPDATE SET display_name = excluded.display_name, endpoint = excluded.endpoint, tls_ca_path = excluded.tls_ca_path, tls_cert_path = excluded.tls_cert_path, - tls_key_path = excluded.tls_key_path, enabled = excluded.enabled`, - [h.lab_host_id, h.display_name, h.endpoint, h.tls_ca_path || null, h.tls_cert_path || null, h.tls_key_path || null, h.enabled !== false] + tls_key_path = excluded.tls_key_path, enabled = excluded.enabled, + routing_mode = excluded.routing_mode, router_endpoint = excluded.router_endpoint`, + [h.lab_host_id, h.display_name, h.endpoint, h.tls_ca_path || null, h.tls_cert_path || null, h.tls_key_path || null, h.enabled !== false, routingMode, h.router_endpoint || null] ); } return hosts.length; diff --git a/platform/provisioner/src/lifecycle.js b/platform/provisioner/src/lifecycle.js index 746a801..bbacd1a 100644 --- a/platform/provisioner/src/lifecycle.js +++ b/platform/provisioner/src/lifecycle.js @@ -40,6 +40,21 @@ async function getEnvironment(pool, environmentId) { return rows[0] || null; } +// Phase 4.1 — the Gateway's read path (GET /environments/by-token/:token) needs to know HOW +// to reach the Lab, not just its Docker-internal address: is this lab_host 'direct' (Gateway +// reaches env.internal_ip itself) or 'router' (Gateway must go through that lab_host's Lab +// Host Router instead)? Joining lab_hosts here keeps that decision entirely data-driven — +// the Gateway never hardcodes or guesses a host's routing mode. +async function getEnvironmentByTokenWithRouting(pool, token) { + const { rows } = await pool.query( + `SELECT e.*, h.routing_mode AS lab_host_routing_mode, h.router_endpoint AS lab_host_router_endpoint + FROM environments e JOIN lab_hosts h ON h.lab_host_id = e.lab_host_id + WHERE e.routing_token = $1`, + [token] + ); + return rows[0] || null; +} + async function listEnvironments(pool, studentId) { const { rows } = await pool.query( 'SELECT * FROM environments WHERE student_id = $1 ORDER BY created_at DESC', @@ -90,6 +105,23 @@ async function provisionEnvironment({ pool, studentId, resourcePolicy, imageRef, throw Object.assign(new Error(`student '${studentId}' already has a non-terminal environment (${existing.environment_id})`), { code: 'ALREADY_EXISTS', existing }); } + // Admission control (Phase 4.1 §26) — checked BEFORE touching Docker at all, so a full host + // fails cleanly and fast (503, no partial network/container left behind to clean up) instead + // of failing mid-provision with a generic 500 once Docker itself runs out of room. No-op + // when maxConcurrentLabs isn't configured (see resourcePolicyFor's comment on why that's the + // default). + if (resourcePolicy.maxConcurrentLabs) { + const { rows } = await pool.query( + `SELECT count(*)::int AS n FROM environments WHERE status NOT IN ('destroyed', 'error')` + ); + if (rows[0].n >= resourcePolicy.maxConcurrentLabs) { + throw Object.assign( + new Error('Lab capacity is temporarily full. Try again shortly.'), + { code: 'CAPACITY_EXCEEDED', current: rows[0].n, max: resourcePolicy.maxConcurrentLabs } + ); + } + } + const labHost = await selectLabHost(pool); const docker = clientFor(labHost); const routingToken = generateRoutingToken(); @@ -145,6 +177,15 @@ async function provisionEnvironment({ pool, studentId, resourcePolicy, imageRef, const container = await docker.createContainer({ name: containerName, Image: imageRef, + // Phase 4.1: labeled by routing token (not environment_id alone) so the Lab Host Router + // — which runs on this same Docker host and has no Postgres access — can resolve + // "which local container does this opaque token address" purely from local `docker ps`, + // with no cross-VM call back to the Provisioner needed at request time. See + // docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1, platform/lab-router/src/router.js. + Labels: { + 'rootnode.routing-token': routingToken, + 'rootnode.environment-id': env.environment_id, + }, HostConfig: { Privileged: true, // required for k3s — see docs/LAB_ARCHITECTURE.md §7, not avoidable NetworkMode: networkName, @@ -321,6 +362,13 @@ async function resetEnvironment({ pool, environmentId, resourcePolicy, egressCon const container = await docker.createContainer({ name: containerName, Image: env.image_ref, + // Phase 4.1 — same routing-token label as provisionEnvironment; a reset keeps the same + // environment_id/routing_token (same DB row) so this must relabel the RECREATED container + // the same way, or the Lab Host Router would resolve stale/no container for this token. + Labels: { + 'rootnode.routing-token': env.routing_token, + 'rootnode.environment-id': environmentId, + }, HostConfig: { Privileged: true, NetworkMode: networkName, @@ -453,7 +501,7 @@ async function reconcileOnStartup(pool) { } module.exports = { - generateRoutingToken, emitEvent, getEnvironment, listEnvironments, getActiveEnvironmentForStudent, + generateRoutingToken, emitEvent, getEnvironment, getEnvironmentByTokenWithRouting, listEnvironments, getActiveEnvironmentForStudent, provisionEnvironment, destroyEnvironment, resetEnvironment, suspendEnvironment, resumeEnvironment, pollHealth, shortId, reconcileOnStartup, }; diff --git a/platform/provisioner/src/server.js b/platform/provisioner/src/server.js index ba922fc..ef735ed 100644 --- a/platform/provisioner/src/server.js +++ b/platform/provisioner/src/server.js @@ -40,11 +40,18 @@ function egressConfig() { function resourcePolicyFor() { const policy = getResourcePolicy().default; + // Phase 4.1 §26 — optional; null/unset (the default) means no cap is enforced, matching the + // Phase 4 rule against fabricating a concurrency number before it's measured. Set this from + // infra/scripts/capacity-model.js's real output once the host exists (see + // infra/environments/production/capacity.yml.example's hard-admission-control field) — see + // docs/PHASE4_1_PREPRODUCTION_HARDENING.md §26. + const admission = getResourcePolicy().admission_control || {}; return { cpuLimit: policy.cpu_limit, memLimitMb: policy.mem_limit_mb, pidsLimit: policy.pids_limit, storageLimitMb: policy.storage_limit_mb, + maxConcurrentLabs: admission.max_concurrent_labs || null, }; } @@ -81,7 +88,10 @@ function isValidUUID(v) { function asyncRoute(fn) { return (req, res) => fn(req, res).catch((e) => { console.error(`[${req.requestId}]`, e); - const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : 500; + // CAPACITY_EXCEEDED (Phase 4.1 §26) is 503, not 500 — the host being full is an expected, + // recoverable operational state, not an application error. See lifecycle.js's admission + // check, which runs BEFORE any Docker/network resources are touched. + const status = e.code === 'NOT_FOUND' ? 404 : e.code === 'ALREADY_EXISTS' ? 409 : e.code === 'CAPACITY_EXCEEDED' ? 503 : 500; res.status(status).json({ error: e.message, code: e.code || 'INTERNAL', request_id: req.requestId }); }); } @@ -98,6 +108,14 @@ app.get('/health', asyncRoute(async (req, res) => { res.status(ok ? 200 : 503).json({ ok, db: dbOk, lab_hosts: hostChecks }); })); +// GET /capacity — Phase 4.1 §26. Internal-only, same shared-secret auth as everything else +// here — lets the Platform API's instructor dashboard show real capacity state instead of +// instructors only discovering a full host when a student's launch fails. +app.get('/capacity', asyncRoute(async (req, res) => { + const { rows } = await getPool().query(`SELECT count(*)::int AS n FROM environments WHERE status NOT IN ('destroyed', 'error')`); + res.json({ current: rows[0].n, max: resourcePolicyFor().maxConcurrentLabs }); +})); + // POST /environments — { student_id } app.post('/environments', asyncRoute(async (req, res) => { const { student_id } = req.body || {}; @@ -130,9 +148,12 @@ app.get('/environments', asyncRoute(async (req, res) => { // owning student_id) before proxying. Internal-only, same shared-secret auth as every other // route here — the Gateway is the only caller, browsers never reach this directly. app.get('/environments/by-token/:token', asyncRoute(async (req, res) => { - const { rows } = await getPool().query('SELECT * FROM environments WHERE routing_token = $1', [req.params.token]); - if (!rows.length) return res.status(404).json({ error: 'not found', request_id: req.requestId }); - res.json(redactRow(rows[0])); + // Phase 4.1: also joins lab_host_routing_mode/lab_host_router_endpoint so the Gateway can + // decide direct-vs-router without knowing anything about Docker topology itself — see + // lifecycle.getEnvironmentByTokenWithRouting and docs/PHASE4_1_PREPRODUCTION_HARDENING.md §1. + const env = await lifecycle.getEnvironmentByTokenWithRouting(getPool(), req.params.token); + if (!env) return res.status(404).json({ error: 'not found', request_id: req.requestId }); + res.json(redactRow(env)); })); // GET /environments/:id/token — Phase 2A addition. Every other read path deliberately redacts