feat(routing): Lab Host Router closes the Gateway-to-Lab cross-VM gap

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.
This commit is contained in:
2026-08-24 20:14:39 -05:00
parent bf1e1b5c76
commit eae8e48d43
27 changed files with 1431 additions and 68 deletions
+1 -1
View File
@@ -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
@@ -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']
@@ -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
@@ -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
+1
View File
@@ -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
@@ -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-<id>) 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
@@ -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]
@@ -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"
@@ -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"
+8 -7
View File
@@ -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.
+1 -1
View File
@@ -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"
}
+1
View File
@@ -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 ."
)
+42 -11
View File
@@ -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-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 <<EOF
subjectAltName = DNS:$LAB_HOST_DNS,IP:$LAB_HOST_IP
extendedKeyUsage = serverAuth
EOF
openssl x509 -req -days "$DAYS" -sha256 -in router-server.csr -CA ca.pem -CAkey ca-key.pem \
-CAcreateserial -out router-server-cert.pem -extfile router-server-extfile.cnf
echo "==> 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 <lab-host-ip>"
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 <lab-host-ip>"
+14
View File
@@ -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"
+128
View File
@@ -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 };
+34 -15
View File
@@ -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();
});
+7
View File
@@ -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"]
+706
View File
@@ -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"
}
}
}
}
+15
View File
@@ -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"
}
}
+29
View File
@@ -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 };
+103
View File
@@ -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)`));
+73
View File
@@ -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); });
@@ -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://<lab-host-vm>: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"
@@ -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 $$;
+9 -4
View File
@@ -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;
+49 -1
View File
@@ -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,
};
+25 -4
View File
@@ -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