Files
dashcaddy/dashcaddy-api/src/network/wg-firewall.js
T

629 lines
23 KiB
JavaScript

/**
* 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_<name>`) 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_<sanitized>. */
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<string, boolean>} 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<object>} opts.people - person records {id, groups[], status,
* devices[]} from the people store
* @param {Object<string,string>} opts.peerAddresses - `${personId}/${deviceId}`
* → 10.53.x.y (nested {personId:{deviceId:ip}} also accepted)
* @param {Object<string,{ip:string,port:number,protocol?:'tcp'|'udp'}>} 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<string|null>}
*/
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,
};