feat(networking): enforce isolated lab egress policy

Egress enforcement is a firewall-layer control, not an application-layer
convention (correction B). HTTP_PROXY/HTTPS_PROXY configure containerd for a
clean pull path; they enforce nothing on their own.

- src/egress.js — per-environment dedicated iptables chain
  (ROOTNODE-ENV-<short-id>), built with -A (append, order-preserving) rather
  than the -I (insert-at-head) pattern an earlier design draft used, which
  would have installed rules in reverse order and let the default-deny shadow
  the proxy/DNS accepts. Required infrastructure (egress proxy, DNS) is
  explicitly permitted first, then RFC1918 ranges are dropped unconditionally,
  then a final default-deny catches the public internet. One jump rule per
  environment hooks its subnet into its chain from DOCKER-USER (the chain
  Docker guarantees it won't silently rewrite). Destroy is deterministic:
  unhook, flush, delete — no comment-grep matching.

  The ordering fix is empirically verified, not just reasoned about: the
  egress proxy's own address is itself an RFC1918 address (it's Platform-side
  infrastructure), and testing confirms it's reachable while the broader
  RFC1918 range is still blocked — exactly the scenario the original ordering
  bug would have broken.

- platform/egress-proxy/ — shared Squid instance (one per deployment, not
  per-student), destination-allowlisted (dstdomain ACL against a plain-text
  allowlist file), starting set covers docker.io/ghcr.io/gcr.io/quay.io/k8s.io.
  Proxy performs domain allowlisting; the firewall chain above is what makes
  it unbypassable, not the other way around.

  The egress proxy's address is resolved per-student-network (each network's
  Docker gateway IP, since the proxy runs with host networking and is present
  on every bridge gateway) rather than a fixed hostname — a static address
  wouldn't be reachable from more than one isolated student network at a time.
  Found while testing with two simultaneous environments.

Verified: student-to-student isolation (A cannot reach B's internal IP),
RFC1918 blocking (A cannot reach a simulated trusted-infra address), and the
proxy allowlist itself (CONNECT tunnel to an allowlisted host succeeds,
non-allowlisted gets a clean 403 from Squid).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-18 01:22:10 -05:00
co-authored by Claude Sonnet 5
parent c8dd33522f
commit 0116ee1522
4 changed files with 151 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y --no-install-recommends squid \
&& rm -rf /var/lib/apt/lists/*
COPY squid.conf /etc/squid/squid.conf
COPY allowlist.txt /etc/squid/allowlist.txt
EXPOSE 3128
CMD ["squid", "-N", "-d", "1"]
+17
View File
@@ -0,0 +1,17 @@
# RootNode Academy egress allowlist — one domain per line, matched with Squid's dstdomain ACL
# (a leading "." matches the domain and all subdomains). Configuration-driven, operator-edited,
# never student- or Lab-container-writable (docs/PHASE1_PLATFORM_FOUNDATION.md §6).
#
# Starting set covers what the existing scenario content actually pulls (nginx, busybox-style
# images used across scenarios/data/*.json setup_commands) — confirm/expand against real usage
# before relying on this for a live cohort; this is a first pass, not a final audit.
.docker.io
.docker.com
registry-1.docker.io
auth.docker.io
production.cloudflare.docker.com
.ghcr.io
.gcr.io
.k8s.io
.quay.io
+22
View File
@@ -0,0 +1,22 @@
# RootNode Academy shared egress proxy — one instance, not per-student.
# Destination allowlisting only; the firewall (egress.js) is the actual enforcement boundary
# that makes this proxy unbypassable, not the other way around
# (docs/PHASE1_PLATFORM_FOUNDATION.md §5/§6, correction B).
http_port 3128
acl allowed_dst dstdomain "/etc/squid/allowlist.txt"
acl SSL_ports port 443
acl Safe_ports port 80 443
acl CONNECT method CONNECT
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow allowed_dst
http_access deny all
# Audit/troubleshooting log — requirement #7. Squid's default access log format already
# includes timestamp, client IP, method, URL, and result code, which is what "why did this
# scenario's image pull fail" support needs.
access_log /var/log/squid/access.log squid
cache deny all
+102
View File
@@ -0,0 +1,102 @@
// Per-environment firewall chain management — the actual security enforcement for egress
// (correction B: HTTP_PROXY/HTTPS_PROXY are configuration, not the control; this is the
// control). See docs/PHASE1_PLATFORM_FOUNDATION.md §5 for the full design and the ordering
// bug this replaces.
//
// Each environment gets its own dedicated chain, built with -A (append — order-preserving,
// no head-insertion trap), reached from DOCKER-USER by exactly one jump rule matching that
// student's subnet. Requires the process to have NET_ADMIN/NET_RAW and see the host network
// namespace (in production: the Provisioner running on/against the Lab Host; in Phase 1 dev:
// a --network host --cap-add=NET_ADMIN --cap-add=NET_RAW container — see
// platform/provisioner/docker-compose.dev.yml).
const { execFile } = require('child_process');
const { promisify } = require('util');
const execFileAsync = promisify(execFile);
const RFC1918_RANGES = ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'];
function chainName(environmentId) {
// iptables chain names top out around 28 chars on the legacy backend — keep this well
// under that. Deterministic from the environment id so setup/teardown always agree.
const shortId = environmentId.replace(/-/g, '').slice(0, 12);
return `ROOTNODE-ENV-${shortId}`;
}
async function iptables(args) {
try {
return await execFileAsync('iptables', args);
} catch (e) {
throw new Error(`iptables ${args.join(' ')} failed: ${e.stderr || e.message}`);
}
}
// Best-effort — used during teardown/cleanup where "already gone" is a success, not a failure.
async function iptablesIgnoreMissing(args) {
try {
await execFileAsync('iptables', args);
} catch (e) {
// "No chain/target/match by that name" (ENOENT-equivalent for iptables) is fine on cleanup.
if (!/No chain|does not exist|matching rule exist/i.test(e.stderr || '')) {
throw new Error(`iptables ${args.join(' ')} failed: ${e.stderr || e.message}`);
}
}
}
/**
* Installs the egress chain for one environment and hooks it into DOCKER-USER.
* @param {object} opts
* @param {string} opts.environmentId
* @param {string} opts.subnet - e.g. '172.19.0.0/24' (from docker network inspect)
* @param {string} opts.egressProxyHost
* @param {number} opts.egressProxyPort
* @param {string} opts.dnsResolver
* @param {string[]} [opts.extraAllowedHosts] - explicitly-required Platform endpoints, if any
*/
async function setupEgressChain({ environmentId, subnet, egressProxyHost, egressProxyPort, dnsResolver, extraAllowedHosts = [] }) {
const chain = chainName(environmentId);
await iptables(['-N', chain]);
// Required infrastructure — explicitly permitted FIRST, before any blanket deny.
await iptables(['-A', chain, '-d', egressProxyHost, '-p', 'tcp', '--dport', String(egressProxyPort), '-j', 'ACCEPT']);
await iptables(['-A', chain, '-d', dnsResolver, '-p', 'udp', '--dport', '53', '-j', 'ACCEPT']);
await iptables(['-A', chain, '-d', dnsResolver, '-p', 'tcp', '--dport', '53', '-j', 'ACCEPT']);
for (const host of extraAllowedHosts) {
await iptables(['-A', chain, '-d', host, '-j', 'ACCEPT']);
}
// Everything that must never be reachable, regardless of the above — unconditional.
for (const range of RFC1918_RANGES) {
await iptables(['-A', chain, '-d', range, '-j', 'DROP']);
}
await iptables(['-A', chain, '-j', 'DROP']); // default deny — catches the public internet too
// Hook this student's subnet into the chain. -I is intentional here: new environments'
// jump rules should be evaluated ahead of any broader, pre-existing DOCKER-USER rule.
// Since every environment matches a disjoint source subnet, the order of jump rules
// relative to each other never matters.
await iptables(['-I', 'DOCKER-USER', '-s', subnet, '-j', chain]);
return { chain };
}
async function teardownEgressChain({ environmentId, subnet }) {
const chain = chainName(environmentId);
await iptablesIgnoreMissing(['-D', 'DOCKER-USER', '-s', subnet, '-j', chain]);
await iptablesIgnoreMissing(['-F', chain]);
await iptablesIgnoreMissing(['-X', chain]);
return { chain };
}
async function chainExists(environmentId) {
const chain = chainName(environmentId);
try {
await execFileAsync('iptables', ['-L', chain, '-n']);
return true;
} catch {
return false;
}
}
module.exports = { chainName, setupEgressChain, teardownEgressChain, chainExists, RFC1918_RANGES };