diff --git a/platform/egress-proxy/Dockerfile b/platform/egress-proxy/Dockerfile new file mode 100644 index 0000000..b5aaa16 --- /dev/null +++ b/platform/egress-proxy/Dockerfile @@ -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"] diff --git a/platform/egress-proxy/allowlist.txt b/platform/egress-proxy/allowlist.txt new file mode 100644 index 0000000..ad0d45f --- /dev/null +++ b/platform/egress-proxy/allowlist.txt @@ -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 diff --git a/platform/egress-proxy/squid.conf b/platform/egress-proxy/squid.conf new file mode 100644 index 0000000..97e9ae0 --- /dev/null +++ b/platform/egress-proxy/squid.conf @@ -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 diff --git a/platform/provisioner/src/egress.js b/platform/provisioner/src/egress.js new file mode 100644 index 0000000..57bc87d --- /dev/null +++ b/platform/provisioner/src/egress.js @@ -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 };