Files
rootnode-academy/platform/provisioner/src/egress.js
T
ced4568andClaude Sonnet 5 0116ee1522 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]>
2026-08-18 01:22:10 -05:00

103 lines
4.4 KiB
JavaScript

// 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 };