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