/** * WireGuard hub firewall for the DashCaddy Private Network (DC-151, * specs/PRIVATE-NETWORK-SPEC.md §4 "Layer 1 — WireGuard" + §8.1 baseline). * * Two halves: * * 1. compileRuleset() — a PURE compiler (no I/O, fully deterministic): * access map + people + peer addresses + resources → the complete * nftables ruleset text for table `inet dcnet`. * * Baseline model (corrected per judge-gate review of §8.1): * - The WireGuard UDP listen port carries HANDSHAKE traffic that * arrives on the EXTERNAL interface, never on dcnet0. The accept * rule is scoped to `externalInterface` when given, otherwise * emitted interface-unscoped with an explanatory comment. * - dcnet0 LOCAL input (input chain, iifname "dcnet0"): established/ * related, ICMP from 10.53.0.0/16, admin ports (API 3001 / SSH 22) * ONLY from the admin set, hub-local resource ports to 10.53.0.1, * then drop other new inbound to the hub IP. * - PEER traffic (forward chain): per-resource nft sets * (`net_dc_res_`) gate saddr→resource ip:dport for resources * living at peer /32s; default-deny tails drop ALL other iifname * "dcnet0" (peer egress without a grant) and oifname "dcnet0" * (peer-to-peer + new hub→peer) traffic. * * Default-deny everywhere on dcnet0-derived traffic; blocked people's * IPs appear in NO set (resource sets nor the admin set). * * 2. createWgFirewallApplier() — transactional apply with injectable * { execFileImpl, fsImpl }: write to a UNIQUE temp file, `nft -c -f` * (check), write the rollback file (previous ruleset) BEFORE applying, * `nft -f` (apply), verify via `nft list table inet dcnet`, roll back * on verify mismatch. A check failure deletes the temp file and throws * FirewallCheckError with NOTHING applied; an apply failure leaves the * old rules and the rollback file untouched and throws with * { applied: false }. * * 3. assertAdminIsolation() — static guard (tests + DC-148-style callers): * every admin-port rule must be scoped to the admin set only. */ 'use strict'; const { execFile } = require('child_process'); const { promisify } = require('util'); const fsDefault = require('fs'); const os = require('os'); const path = require('path'); const execFileDefault = promisify(execFile); const HUB_IP = '10.53.0.1'; const PEER_CIDR = '10.53.0.0/16'; const WG_IFACE = 'dcnet0'; const TABLE = 'inet dcnet'; const DEFAULT_ADMIN_PORTS = [22, 3001]; const DEFAULT_WG_LISTEN_PORT = 51820; const ADMIN_SET = 'net_dc_admins'; const RES_SET_PREFIX = 'net_dc_res_'; // --------------------------------------------------------------------------- // Errors // --------------------------------------------------------------------------- class FirewallError extends Error { /** * @param {string} message * @param {object} [opts] * @param {string} [opts.code] - machine-readable code * @param {boolean} [opts.applied] - did any part of the new ruleset land? * @param {string} [opts.stderr] - captured nft stderr, when available * @param {*} [opts.cause] */ constructor(message, { code, applied, stderr, cause, rolledBack } = {}) { super(message); this.name = this.constructor.name; this.code = code || 'firewall_error'; this.applied = !!applied; if (stderr !== undefined) this.stderr = stderr; if (cause !== undefined) this.cause = cause; if (rolledBack !== undefined) this.rolledBack = !!rolledBack; } } /** `nft -c -f` rejected the ruleset. Nothing was applied; temp deleted. */ class FirewallCheckError extends FirewallError { constructor(message, opts = {}) { super(message, { ...opts, code: 'check_failed', applied: false }); } } /** `nft -f` failed. Old rules and rollback file are untouched. */ class FirewallApplyError extends FirewallError { constructor(message, opts = {}) { super(message, { ...opts, code: 'apply_failed', applied: false }); } } /** Post-apply verification failed. Rollback was attempted. */ class FirewallVerifyError extends FirewallError { constructor(message, opts = {}) { super(message, { ...opts, code: 'verify_failed', applied: true }); } } // --------------------------------------------------------------------------- // Pure compiler // --------------------------------------------------------------------------- /** * Sanitize a resource name into a safe nftables identifier segment. * 'plex-2!' → 'plex_2'; leading digits get a 'res_' prefix; empty → 'unnamed'. * @param {string} name * @returns {string} */ function sanitizeResourceSegment(name) { const raw = String(name) .toLowerCase() .replace(/[^a-z0-9_]+/g, '_') .replace(/^_+|_+$/g, ''); if (!raw) return 'unnamed'; return /^[0-9]/.test(raw) ? 'res_' + raw : raw; } /** Full set name for a resource: net_dc_res_. */ function resourceSetName(resourceName) { return RES_SET_PREFIX + sanitizeResourceSegment(resourceName); } function _personStatus(person) { return person && typeof person.status === 'string' ? person.status : 'active'; } /** * Effective access for one person, mirroring the access store semantics * (src/security/network-access-store.js effectiveAccess): union of the * grants of every group they belong to, then person-level grants OVERRIDE * group grants in both directions. * @returns {Object} resource → allowed */ function computePersonAccess(accessMap, person) { const grants = accessMap && Array.isArray(accessMap.grants) ? accessMap.grants : []; const groups = new Set( Array.isArray(person.groups) ? person.groups.filter((g) => typeof g === 'string') : [] ); const resources = {}; for (const g of grants) { if (g && g.principal && g.principal.type === 'group' && groups.has(g.principal.id)) { resources[g.resource] = g.allowed; } } for (const g of grants) { if (g && g.principal && g.principal.type === 'person' && g.principal.id === person.id) { resources[g.resource] = g.allowed; } } return resources; } /** * All assigned peer IPs for a person. `peerAddresses` is the flat map * `${personId}/${deviceId}` → 10.53.x.y (canonical), with a nested * { [personId]: { [deviceId]: ip } } shape also accepted. * @returns {string[]} sorted unique IPs */ function peerIpsForPerson(peerAddresses, person) { const ips = []; const prefix = person.id + '/'; for (const [key, ip] of Object.entries(peerAddresses || {})) { if (typeof ip === 'string' && key.startsWith(prefix) && key.length > prefix.length) { ips.push(ip); } } const nested = peerAddresses ? peerAddresses[person.id] : null; if (nested && typeof nested === 'object') { for (const ip of Object.values(nested)) { if (typeof ip === 'string') ips.push(ip); } } return [...new Set(ips)].sort(); } function _dportList(ports) { const sorted = [...ports].map((p) => parseInt(p, 10)).sort((a, b) => a - b); return `{ ${sorted.join(', ')} }`; } /** * Resolve the admin set IPs from ACTIVE admin people. Blocked/removed admins * contribute nothing. Throws (lockout guard) when admin ports would be * emitted with zero reachable admin peers. * @returns {string[]} sorted unique admin peer IPs */ function _resolveAdminIps(activePersons, adminPeers, peerAddresses) { const adminIds = new Set( Array.isArray(adminPeers) ? adminPeers.filter((id) => typeof id === 'string') : [] ); const ips = new Set(); for (const p of activePersons) { if (!adminIds.has(p.id)) continue; for (const ip of peerIpsForPerson(peerAddresses, p)) ips.add(ip); } if (ips.size === 0) { throw new Error( 'wg-firewall: admin lockout guard — admin ports requested but no active admin peer IPs resolved' ); } return [...ips].sort(); } /** * Build the per-resource set entries and split resources into hub-local * (input chain, ip === hub) vs peer (forward chain) buckets. Validates * resource entries and guards against sanitization collisions. * @returns {{resourceSets: Array, hubResources: Array, peerResources: Array}} */ function _buildResourceSets({ resources, activePersons, accessMap, peerAddresses }) { const setNames = new Map(); // sanitized segment → resource name (collision guard) const resourceSets = []; const hubResources = []; const peerResources = []; for (const [name, resource] of Object.entries(resources)) { if (!resource || typeof resource.ip !== 'string' || !resource.ip) { throw new Error(`wg-firewall: resource ${name}: ip required`); } if (!Number.isInteger(resource.port) || resource.port < 1 || resource.port > 65535) { throw new Error(`wg-firewall: resource ${name}: valid port required`); } const segment = sanitizeResourceSegment(name); const collides = setNames.get(segment); if (collides && collides !== name) { throw new Error( `wg-firewall: set-name collision — resources "${collides}" and "${name}" both sanitize to ${RES_SET_PREFIX}${segment}` ); } setNames.set(segment, name); const ips = new Set(); for (const person of activePersons) { if (computePersonAccess(accessMap, person)[name] === true) { for (const ip of peerIpsForPerson(peerAddresses, person)) ips.add(ip); } } const setName = RES_SET_PREFIX + segment; resourceSets.push({ name, setName, ips: [...ips].sort() }); const bucket = resource.ip === HUB_IP ? hubResources : peerResources; bucket.push({ name, setName, resource }); } return { resourceSets, hubResources, peerResources }; } function _resourceRule(setName, resource, comment) { const proto = resource.protocol === 'udp' ? 'udp' : 'tcp'; return `ip saddr @${setName} ip daddr ${resource.ip} ${proto} dport ${resource.port} accept comment "${comment}"`; } /** * Compile the full nftables ruleset for the WireGuard hub. * * @param {object} opts * @param {object} opts.accessMap - { grants: [...] } (network-access.json) * @param {Array} opts.people - person records {id, groups[], status, * devices[]} from the people store * @param {Object} opts.peerAddresses - `${personId}/${deviceId}` * → 10.53.x.y (nested {personId:{deviceId:ip}} also accepted) * @param {Object} opts.resources * @param {string[]} opts.adminPeers - person IDs allowed admin ports * @param {number[]} [opts.adminPorts=[22,3001]] * @param {number} [opts.wgListenPort=51820] - UDP handshake listen port * @param {string|null} [opts.externalInterface=null] - external iface name for * the WG handshake rule; null → unscoped rule with a comment * @returns {string} complete nftables file text * @throws {Error} on admin lockout (adminPorts with zero active admin peer * IPs), set-name collisions, or invalid resource entries */ function compileRuleset({ accessMap = { grants: [] }, people = [], peerAddresses = {}, resources = {}, adminPeers = [], adminPorts = DEFAULT_ADMIN_PORTS, wgListenPort = DEFAULT_WG_LISTEN_PORT, externalInterface = null, } = {}) { if (!resources || typeof resources !== 'object' || Array.isArray(resources)) { throw new Error('wg-firewall: resources map required'); } const persons = Array.isArray(people) ? people : Object.values(people || {}); const active = persons.filter( (p) => p && typeof p.id === 'string' && _personStatus(p) === 'active' ); // ---- admin set (never includes blocked/removed people's IPs) ---------- const adminIps = _resolveAdminIps(active, adminPeers, peerAddresses); const ports = adminPorts.length ? adminPorts : DEFAULT_ADMIN_PORTS; // ---- per-resource sets + rule split (hub-local input vs peer forward) - const { resourceSets, hubResources, peerResources } = _buildResourceSets({ resources, activePersons: active, accessMap, peerAddresses, }); // ---- emit ------------------------------------------------------------- const L = []; L.push('#!/usr/sbin/nft -f'); L.push('# DashCaddy Private Network - WireGuard hub firewall (DC-151 wg-firewall)'); L.push('# GENERATED FILE - do not edit; recompiled from network-access.json.'); L.push('# Default-deny: blocked people appear in no set; unmatched dcnet0 traffic is dropped.'); L.push(''); L.push('destroy table inet dcnet'); L.push(''); L.push(`table ${TABLE} {`); L.push(''); L.push(' # Admin peers allowed to reach hub admin ports'); L.push(` set ${ADMIN_SET} {`); L.push(' type ipv4_addr'); L.push(` elements = { ${adminIps.join(', ')} }`); L.push(' }'); L.push(''); for (const { name, setName, ips } of resourceSets) { L.push(` # Resource grants: ${name}`); L.push(` set ${setName} {`); L.push(' type ipv4_addr'); if (ips.length) { L.push(` elements = { ${ips.join(', ')} }`); } L.push(' }'); L.push(''); } L.push(' chain input {'); L.push(' type filter hook input priority 0; policy accept;'); L.push(''); L.push(' # WireGuard handshake: arrives on the EXTERNAL interface, never dcnet0'); if (externalInterface) { L.push( ` iifname "${externalInterface}" udp dport ${wgListenPort} accept comment "wg handshake on external iface"` ); } else { L.push( ` udp dport ${wgListenPort} accept comment "wg handshake - external iface unspecified, left unscoped"` ); } L.push(''); L.push(' # dcnet0 local input'); L.push(` iifname "${WG_IFACE}" ct state established,related accept comment "dcnet0 established/related"`); L.push( ` iifname "${WG_IFACE}" ip saddr ${PEER_CIDR} icmp type echo-request accept comment "icmp from peers"` ); L.push( ` iifname "${WG_IFACE}" ip saddr @${ADMIN_SET} tcp dport ${_dportList(ports)} accept comment "admin ports: admin peers only"` ); for (const { name, setName, resource } of hubResources) { L.push( ` iifname "${WG_IFACE}" ${_resourceRule(setName, resource, `resource ${name}`)}` ); } L.push( ` iifname "${WG_IFACE}" ip daddr ${HUB_IP} ct state new drop comment "default-deny: hub input"` ); L.push(' }'); L.push(''); L.push(' chain forward {'); L.push(' type filter hook forward priority 0; policy accept;'); L.push(''); L.push(` iifname "${WG_IFACE}" ct state established,related accept comment "dcnet0 established/related"`); for (const { name, setName, resource } of peerResources) { L.push(` iifname "${WG_IFACE}" ${_resourceRule(setName, resource, `resource ${name}`)}`); } L.push(` iifname "${WG_IFACE}" drop comment "default-deny: peer egress without grant"`); L.push(` oifname "${WG_IFACE}" drop comment "default-deny: peer-to-peer / new hub-to-peer"`); L.push(' }'); L.push('}'); L.push(''); return L.join('\n'); } // --------------------------------------------------------------------------- // Static admin-isolation guard // --------------------------------------------------------------------------- /** * Assert that every admin-port rule in the ruleset references ONLY the * admin set: an admin port may not be allowed from a resource set, a * literal peer IP, or with no source scoping at all. * * @param {string} rulesetText - compiled nftables file text * @param {object} [opts] * @param {number[]} [opts.adminPorts=[22,3001]] * @returns {true} when isolated * @throws {Error} (name 'AdminIsolationError', .violations[]) otherwise */ function assertAdminIsolation(rulesetText, { adminPorts = DEFAULT_ADMIN_PORTS } = {}) { const ports = new Set(adminPorts.map((p) => String(parseInt(p, 10)))); const violations = []; for (const line of String(rulesetText).split('\n')) { const m = line.match(/dport\s+(?:\{\s*([^}]*?)\s*\}|(\d+))/); if (!m) continue; const listed = (m[1] ? m[1].split(/[,\s]+/) : [m[2]]).filter(Boolean); if (!listed.some((p) => ports.has(p))) continue; const trimmed = line.trim(); if (!line.includes(`@${ADMIN_SET}`)) { violations.push(`admin port allowed without @${ADMIN_SET}: ${trimmed}`); } if (line.includes(`@${RES_SET_PREFIX}`)) { violations.push(`admin port rule references a resource set: ${trimmed}`); } if (/ip saddr 10\.53\.\d/.test(line)) { violations.push(`admin port rule uses a literal peer saddr: ${trimmed}`); } } if (violations.length) { const err = new Error('admin isolation violation:\n ' + violations.join('\n ')); err.name = 'AdminIsolationError'; err.violations = violations; throw err; } return true; } // --------------------------------------------------------------------------- // Transactional applier // --------------------------------------------------------------------------- let _tempSeq = 0; /** mkstemp-style unique temp path — never a fixed path. */ function _uniqueTempPath(dir) { _tempSeq += 1; const rand = Math.random().toString(36).slice(2, 10); return path.join(dir, `dcnet-nft-${process.pid}-${_tempSeq}-${rand}.nft`); } function _errStderr(err) { if (!err) return ''; if (typeof err.stderr === 'string') return err.stderr; return err.message || String(err); } function _bestEffortUnlink(fs, p) { try { fs.unlinkSync(p); } catch { /* already gone — fine */ } } /** * Build the transactional nftables applier. * * @param {object} [deps] - all injectable (hermetic tests mock these) * @param {Function} [deps.execFileImpl] - promisified execFile(cmd, args) * @param {object} [deps.fsImpl] - { writeFileSync, readFileSync, existsSync, * unlinkSync } * @param {string} [deps.nftBinary='nft'] * @param {string} [deps.tempDir] - directory for unique temp ruleset files * @param {string} [deps.rollbackPath] - where the previous ruleset is kept */ function createWgFirewallApplier({ execFileImpl, fsImpl, nftBinary = 'nft', tempDir, rollbackPath, } = {}) { const execFn = execFileImpl || execFileDefault; const fs = fsImpl || fsDefault; const tmpDir = tempDir || path.join(os.tmpdir()); const rollbackFile = rollbackPath || path.join(tmpDir, 'dashcaddy-dcnet-rollback.nft'); async function nft(args) { // eslint-disable-line require-await -- passthrough to injected execFile promise return execFn(nftBinary, args); } /** * Capture the currently-applied dcnet table, or null when absent. * @returns {Promise} */ async function captureCurrent() { try { const res = await nft(['list', 'table', 'inet', 'dcnet']); return res && typeof res.stdout === 'string' ? res.stdout : null; } catch { return null; // table does not exist yet — first apply } } return { rollbackFile, /** * Transactional apply: * 1. write ruleset to a UNIQUE temp file * 2. `nft -c -f` — on failure delete temp, throw FirewallCheckError, * nothing applied * 3. write the rollback file (previous ruleset) BEFORE applying * 4. `nft -f` — on failure delete temp, throw FirewallApplyError * {applied:false}; old rules + rollback file untouched * 5. verify via `nft list table inet dcnet`; mismatch → attempt * rollback, throw FirewallVerifyError {applied:true, rolledBack} * * @param {string} rulesetText * @returns {Promise<{applied:true, verified:true, tempPath:string, * rollbackFile:string, previousExisted:boolean}>} */ async apply(rulesetText) { if (typeof rulesetText !== 'string' || rulesetText.length === 0) { throw new Error('wg-firewall: apply() requires non-empty ruleset text'); } const tempPath = _uniqueTempPath(tmpDir); fs.writeFileSync(tempPath, rulesetText); // 1. check — reject before touching anything live. try { await nft(['-c', '-f', tempPath]); } catch (err) { _bestEffortUnlink(fs, tempPath); throw new FirewallCheckError( `nft -c rejected ruleset (${_errStderr(err).trim()})`, { stderr: _errStderr(err) } ); } // 2. snapshot previous + write rollback BEFORE applying. Keep the // prior rollback file content so a FAILED apply leaves it (the // last-known-good rollback) untouched. const previous = await captureCurrent(); let priorRollback; try { priorRollback = fs.readFileSync(rollbackFile); } catch { priorRollback = undefined; // no prior rollback file } const rollbackContent = previous !== null ? previous : 'destroy table inet dcnet\n'; fs.writeFileSync(rollbackFile, rollbackContent); // 3. apply. try { await nft(['-f', tempPath]); } catch (err) { _bestEffortUnlink(fs, tempPath); if (priorRollback !== undefined) { fs.writeFileSync(rollbackFile, priorRollback); } else { _bestEffortUnlink(fs, rollbackFile); } throw new FirewallApplyError(`nft -f apply failed (${_errStderr(err).trim()})`, { stderr: _errStderr(err), }); } // 4. verify. let verified = false; let verifyStderr = ''; try { const res = await nft(['list', 'table', 'inet', 'dcnet']); const out = res && typeof res.stdout === 'string' ? res.stdout : ''; verified = out.includes(TABLE); if (!verified) verifyStderr = 'nft list table inet dcnet output missing table'; } catch (err) { verifyStderr = _errStderr(err); } if (!verified) { let rolledBack = false; try { await nft(['-f', rollbackFile]); rolledBack = true; } catch { rolledBack = false; } _bestEffortUnlink(fs, tempPath); throw new FirewallVerifyError( `post-apply verify failed (${verifyStderr.trim()}); rollback ${ rolledBack ? 'succeeded' : 'FAILED — inspect host firewall manually' }`, { stderr: verifyStderr, rolledBack } ); } _bestEffortUnlink(fs, tempPath); return { applied: true, verified: true, tempPath, rollbackFile, previousExisted: previous !== null, }; }, /** * Re-apply the rollback file (the pre-apply ruleset). * @returns {Promise<{applied:true, rollbackFile:string}>} * @throws {Error} when no rollback file exists or re-apply fails */ async rollback() { if (!fs.existsSync(rollbackFile)) { throw new Error(`wg-firewall: no rollback file at ${rollbackFile}`); } try { await nft(['-f', rollbackFile]); } catch (err) { throw new FirewallApplyError( `rollback re-apply failed (${_errStderr(err).trim()})`, { stderr: _errStderr(err) } ); } return { applied: true, rollbackFile }; }, }; } module.exports = { compileRuleset, createWgFirewallApplier, assertAdminIsolation, sanitizeResourceSegment, resourceSetName, FirewallError, FirewallCheckError, FirewallApplyError, FirewallVerifyError, ADMIN_SET, HUB_IP, PEER_CIDR, };