diff --git a/dashcaddy-api/__tests__/network-wg-firewall.test.js b/dashcaddy-api/__tests__/network-wg-firewall.test.js new file mode 100644 index 0000000..58cbffc --- /dev/null +++ b/dashcaddy-api/__tests__/network-wg-firewall.test.js @@ -0,0 +1,473 @@ +/** + * Tests for src/network/wg-firewall.js (DC-151). + * Coverage: + * - golden ruleset text: single resource single grant, group grant + * expansion, blocked person absent from ALL sets, default-deny tails, + * admin ports only from admin set + * - corrected baseline: WG UDP handshake rule NOT bound to dcnet0 + * (externalInterface scoping + null → unscoped), peer-to-peer forward + * default-deny (oifname dcnet0 drop) + * - name sanitization: 'plex-2!' → net_dc_res_plex_2, collision guard + * - admin lockout guard; assertAdminIsolation (positive + adversarial) + * - transactional apply (mocked execFile + fs): check-failure → no apply + * + temp deleted; apply-failure → {applied:false} + rollback file + * unchanged; verify-mismatch → rollback attempted; rollback() re-applies + * previous; unique temp paths across applies + */ + +'use strict'; + +const { + compileRuleset, + createWgFirewallApplier, + assertAdminIsolation, + resourceSetName, + FirewallCheckError, + FirewallApplyError, +} = require('../src/network/wg-firewall'); + +// -------------------------------------------------------------------------- +// Fixtures +// -------------------------------------------------------------------------- + +const PEOPLE = [ + { + id: 'admin1', + displayName: 'Admin', + groups: ['family'], + status: 'active', + devices: [{ keyId: 'k1', name: 'laptop', ip: '10.53.0.10' }], + }, + { + id: 'alice', + displayName: 'Alice', + groups: ['family'], + status: 'active', + devices: [{ keyId: 'k2', name: 'phone', ip: '10.53.1.20' }], + }, + { + id: 'bob', + displayName: 'Bob', + groups: [], + status: 'active', + devices: [{ keyId: 'k3', name: 'pc', ip: '10.53.2.30' }], + }, + { + id: 'mallory', + displayName: 'Mallory', + groups: ['family'], + status: 'blocked', + devices: [{ keyId: 'k4', name: 'tab', ip: '10.53.3.40' }], + }, +]; + +const PEERS = { + 'admin1/dev1': '10.53.0.10', + 'alice/dev1': '10.53.1.20', + 'bob/dev1': '10.53.2.30', + 'mallory/dev1': '10.53.3.40', +}; + +const RESOURCES = { + plex: { ip: '10.53.0.1', port: 32400, protocol: 'tcp' }, + nas: { ip: '10.53.9.9', port: 445, protocol: 'tcp' }, +}; + +const BASE = { + accessMap: { grants: [] }, + people: PEOPLE, + peerAddresses: PEERS, + resources: RESOURCES, + adminPeers: ['admin1'], +}; + +// -------------------------------------------------------------------------- +// Compiler: golden ruleset text +// -------------------------------------------------------------------------- + +describe('wg-firewall: compileRuleset golden text', () => { + test('single resource single grant — exact set element + accept rule', () => { + const text = compileRuleset({ + ...BASE, + accessMap: { + grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex', allowed: true }], + }, + resources: { plex: RESOURCES.plex }, + }); + expect(text).toContain('set net_dc_res_plex {'); + expect(text).toContain('elements = { 10.53.1.20 }'); + expect(text).toContain( + 'ip saddr @net_dc_res_plex ip daddr 10.53.0.1 tcp dport 32400 accept comment "resource plex"' + ); + // blocked + ungranted peers are NOT in the plex resource set + const plexSet = text.split('set net_dc_res_plex {')[1].split('}')[0]; + expect(plexSet).not.toContain('10.53.3.40'); // mallory + expect(plexSet).not.toContain('10.53.2.30'); // bob + expect(plexSet).not.toContain('10.53.0.10'); // admin not granted plex → not in res set + expect(plexSet).toContain('elements = { 10.53.1.20'); + }); + + test('group grant expands to every active member (person-override honored)', () => { + const text = compileRuleset({ + ...BASE, + accessMap: { + grants: [ + { principal: { type: 'group', id: 'family' }, resource: 'nas', allowed: true }, + { principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: false }, + ], + }, + resources: { nas: RESOURCES.nas }, + }); + // family = admin1, alice, mallory(blocked); alice overridden to false + expect(text).toMatch(/set net_dc_res_nas \{\s*\n\s*type ipv4_addr\s*\n\s*elements = \{ 10\.53\.0\.10 \}/); + expect(text).toContain('ip saddr @net_dc_res_nas ip daddr 10.53.9.9 tcp dport 445 accept'); + }); + + test('blocked person absent from ALL sets (resource sets and admin set)', () => { + const text = compileRuleset({ + ...BASE, + accessMap: { + grants: [{ principal: { type: 'group', id: 'family' }, resource: 'plex', allowed: true }], + }, + adminPeers: ['admin1', 'mallory'], // mallory blocked → must NOT get admin + }); + const adminSetMatch = text.match(/set net_dc_admins \{[^}]*elements = \{ ([^}]*) \}/); + expect(adminSetMatch).toBeTruthy(); + const adminEls = adminSetMatch[1].split(', '); + expect(adminEls).toEqual(['10.53.0.10']); // mallory's 10.53.3.40 excluded + expect(adminEls).not.toContain('10.53.3.40'); + }); + + test('default-deny tails present: hub input, peer egress, peer-to-peer', () => { + const text = compileRuleset(BASE); + expect(text).toContain('ct state new drop comment "default-deny: hub input"'); + expect(text).toContain('drop comment "default-deny: peer egress without grant"'); + expect(text).toContain('drop comment "default-deny: peer-to-peer / new hub-to-peer"'); + }); + + test('admin ports only from admin set (3001 + 22)', () => { + const text = compileRuleset(BASE); + expect(text).toContain( + 'iifname "dcnet0" ip saddr @net_dc_admins tcp dport { 22, 3001 } accept comment "admin ports: admin peers only"' + ); + }); + + test('table declared as inet dcnet with destroy-first', () => { + const text = compileRuleset(BASE); + expect(text).toContain('destroy table inet dcnet'); + expect(text).toContain('table inet dcnet {'); + }); +}); + +// -------------------------------------------------------------------------- +// Compiler: corrected baseline (judge gate) +// -------------------------------------------------------------------------- + +describe('wg-firewall: WG UDP handshake + peer-to-peer baseline (corrected)', () => { + test('UDP allow rule is NOT bound to dcnet0 — scoped to external interface', () => { + const text = compileRuleset({ ...BASE, externalInterface: 'eth0' }); + const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820')); + expect(udpLines).toHaveLength(1); + expect(udpLines[0]).toContain('iifname "eth0"'); + expect(udpLines[0]).not.toContain('dcnet0'); + }); + + test('externalInterface null → unscoped UDP rule with comment', () => { + const text = compileRuleset(BASE); + const udpLines = text.split('\n').filter((l) => l.includes('udp dport 51820')); + expect(udpLines).toHaveLength(1); + expect(udpLines[0]).not.toContain('iifname'); + expect(udpLines[0]).toContain('external iface unspecified'); + }); + + test('custom listen port honored', () => { + const text = compileRuleset({ ...BASE, wgListenPort: 51821, externalInterface: 'ens3' }); + expect(text).toContain('iifname "ens3" udp dport 51821 accept'); + }); + + test('peer-to-peer forwarding default-deny: oifname dcnet0 drop tail', () => { + const text = compileRuleset(BASE); + expect(text).toContain('oifname "dcnet0" drop'); + // and no accept rule that would let peer→peer through ahead of it + const forward = text.split('chain forward {')[1].split('}')[0]; + expect(forward).not.toMatch(/oifname "dcnet0".*accept/); + }); + + test('hub-local resource lands in input chain; peer resource in forward chain', () => { + const text = compileRuleset({ + ...BASE, + accessMap: { + grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'nas', allowed: true }], + }, + }); + const input = text.split('chain input {')[1].split('chain forward')[0]; + const forward = text.split('chain forward {')[1]; + expect(input).toContain('@net_dc_res_plex'); + expect(input).not.toContain('@net_dc_res_nas'); + expect(forward).toContain('@net_dc_res_nas'); + expect(forward).not.toContain('@net_dc_res_plex'); + }); +}); + +// -------------------------------------------------------------------------- +// Compiler: sanitization + guards +// -------------------------------------------------------------------------- + +describe('wg-firewall: sanitization and guards', () => { + test("resource 'plex-2!' sanitizes to a safe set name", () => { + expect(resourceSetName('plex-2!')).toBe('net_dc_res_plex_2'); + const text = compileRuleset({ + ...BASE, + resources: { 'plex-2!': { ip: '10.53.0.1', port: 32400 } }, + accessMap: { + grants: [{ principal: { type: 'person', id: 'alice' }, resource: 'plex-2!', allowed: true }], + }, + }); + expect(text).toContain('set net_dc_res_plex_2 {'); + expect(text).toContain('@net_dc_res_plex_2'); + expect(text).toContain('ip saddr @net_dc_res_plex_2 ip daddr 10.53.0.1 tcp dport 32400 accept'); + }); + + test('set-name collision between distinct resources throws', () => { + expect(() => + compileRuleset({ ...BASE, resources: { 'plex 1': { ip: '10.53.0.1', port: 1 }, 'plex!1': { ip: '10.53.0.2', port: 2 } } }) + ).toThrow(/collision/); + }); + + test('admin lockout guard: zero resolvable admin peers throws', () => { + expect(() => compileRuleset({ ...BASE, adminPeers: ['ghost'] })).toThrow(/lockout/); + }); + + test('invalid resource (missing ip / bad port) throws', () => { + expect(() => compileRuleset({ ...BASE, resources: { x: { port: 80 } } })).toThrow(/ip required/); + expect(() => compileRuleset({ ...BASE, resources: { x: { ip: '10.53.0.1', port: 0 } } })).toThrow(/port/); + }); +}); + +// -------------------------------------------------------------------------- +// assertAdminIsolation +// -------------------------------------------------------------------------- + +describe('wg-firewall: assertAdminIsolation', () => { + test('passes on a correctly compiled ruleset', () => { + const text = compileRuleset(BASE); + expect(assertAdminIsolation(text, { adminPeers: ['admin1'] })).toBe(true); + }); + + test('rejects an admin port allowed without the admin set', () => { + const bad = 'iifname "dcnet0" tcp dport 22 accept'; + expect(() => assertAdminIsolation(bad)).toThrow(/without @net_dc_admins/); + }); + + test('rejects an admin port allowed from a resource set', () => { + const bad = 'iifname "dcnet0" ip saddr @net_dc_res_plex tcp dport 3001 accept'; + expect(() => assertAdminIsolation(bad)).toThrow(/resource set/); + }); + + test('rejects an admin port allowed from a literal peer IP', () => { + const bad = 'iifname "dcnet0" ip saddr 10.53.4.4 tcp dport 22 accept'; + expect(() => assertAdminIsolation(bad)).toThrow(/literal peer saddr/); + }); +}); + +// -------------------------------------------------------------------------- +// Transactional applier (hermetic: mocked execFile + fs) +// -------------------------------------------------------------------------- + +function mockFs() { + const files = new Map(); + return { + files, + writeFileSync: jest.fn((p, c) => files.set(p, c)), + readFileSync: jest.fn((p) => { + if (!files.has(p)) { const e = new Error('ENOENT'); e.code = 'ENOENT'; throw e; } + return files.get(p); + }), + existsSync: jest.fn((p) => files.has(p)), + unlinkSync: jest.fn((p) => files.delete(p)), + }; +} + +/** execFile mock with a per-call script of behaviors keyed by args. */ +function mockExec({ current = 'table inet dcnet { }', checkErr, applyErr, verifyOut } = {}) { + const calls = []; + const impl = async (cmd, args) => { // eslint-disable-line require-await -- scripted mock, no awaits by design + calls.push({ cmd, args: [...args] }); + const joined = args.join(' '); + if (joined.startsWith('-c -f')) { + if (checkErr) { + const e = new Error('check failed'); + e.stderr = 'syntax error near unexpected token'; + throw e; + } + return { stdout: '' }; + } + if (joined.startsWith('-f')) { + if (applyErr) { + const e = new Error('apply failed'); + e.stderr = 'operation failed: Device or resource busy'; + throw e; + } + return { stdout: '' }; + } + if (joined.startsWith('list table')) { + if (verifyOut !== undefined) return { stdout: verifyOut }; + return { stdout: current }; + } + return { stdout: '' }; + }; + return { impl, calls }; +} + +const RULESET = compileRuleset(BASE); +const RULESET2 = compileRuleset({ + ...BASE, + accessMap: { grants: [{ principal: { type: 'person', id: 'bob' }, resource: 'plex', allowed: true }] }, +}); + +describe('wg-firewall: transactional apply', () => { + test('happy path: check → rollback write → apply → verify; temp cleaned up', async () => { + const fs = mockFs(); + const { impl, calls } = mockExec(); + const applier = createWgFirewallApplier({ + execFileImpl: impl, + fsImpl: fs, + tempDir: '/tmp/x', + rollbackPath: '/tmp/x/rollback.nft', + }); + const res = await applier.apply(RULESET); + expect(res.applied).toBe(true); + expect(res.verified).toBe(true); + // order: check(-c -f) before rollback write before apply(-f) before verify + const checkIdx = calls.findIndex((c) => c.args[0] === '-c'); + const applyIdx = calls.findIndex((c) => c.args[0] === '-f'); + const listIdxs = calls.map((c, i) => (c.args[0] === 'list' ? i : -1)).filter((i) => i >= 0); + expect(checkIdx).toBe(0); + expect(applyIdx).toBeGreaterThan(checkIdx); + // one `list` before apply (capture previous) + one after (verify) + expect(listIdxs).toHaveLength(2); + expect(listIdxs[0]).toBeLessThan(applyIdx); + expect(listIdxs[1]).toBeGreaterThan(applyIdx); + const writePaths = fs.writeFileSync.mock.calls.map((c) => c[0]); + const tempWrites = writePaths.filter((p) => p !== '/tmp/x/rollback.nft'); + expect(tempWrites).toHaveLength(1); + const tempPath = tempWrites[0]; + // write order: temp (for check) then rollback BEFORE the apply exec + expect(writePaths).toEqual([tempPath, '/tmp/x/rollback.nft']); + const applyCall = calls[applyIdx]; + expect(applyCall.args).toEqual(['-f', tempPath]); + expect(applyCall.args[1]).not.toBe('/tmp/x/rollback.nft'); + // rollback file holds the PREVIOUS table dump + expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('table inet dcnet { }'); + // temp file deleted at the end + expect(fs.existsSync(tempPath)).toBe(false); + expect(res.previousExisted).toBe(true); + }); + + test('check failure → no apply, temp deleted, FirewallCheckError with stderr', async () => { + const fs = mockFs(); + const { impl, calls } = mockExec({ checkErr: true }); + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + await expect(applier.apply(RULESET)).rejects.toMatchObject({ + name: 'FirewallCheckError', + code: 'check_failed', + applied: false, + stderr: expect.stringContaining('syntax error'), + }); + // NOTHING applied: no bare `-f` run, no `nft list` verify, no rollback write + expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(0); + expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(0); + expect(fs.writeFileSync).toHaveBeenCalledTimes(1); // only the temp file + expect(fs.files.size).toBe(0); // temp deleted + }); + + test('apply failure → {applied:false} thrown, rollback file unchanged, old rules intact', async () => { + const fs = mockFs(); + fs.files.set('/tmp/x/rollback.nft', 'PREVIOUS-ROLLBACK-CONTENT'); + const { impl, calls } = mockExec({ applyErr: true }); + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + await expect(applier.apply(RULESET)).rejects.toMatchObject({ + name: 'FirewallApplyError', + applied: false, + }); + // rollback file NOT overwritten by the failed apply (old rules intact) + expect(fs.readFileSync('/tmp/x/rollback.nft')).toBe('PREVIOUS-ROLLBACK-CONTENT'); + // temp cleaned, only the rollback file remains + expect(fs.files.size).toBe(1); + // verify never reached: the only `nft -f` run is the failed apply — + // no rollback re-apply, and exactly one `list` (the pre-apply capture) + expect(calls.filter((c) => c.args[0] === '-f')).toHaveLength(1); + expect(calls.filter((c) => c.args[0] === 'list')).toHaveLength(1); + }); + + test('verify mismatch → rollback attempted, verify error carries rolledBack', async () => { + const fs = mockFs(); + const { impl, calls } = mockExec({ verifyOut: '' }); // list returns no table + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + await expect(applier.apply(RULESET)).rejects.toMatchObject({ + name: 'FirewallVerifyError', + code: 'verify_failed', + rolledBack: true, + }); + // last exec is the rollback `nft -f ` + const last = calls[calls.length - 1]; + expect(last.args).toEqual(['-f', '/tmp/x/rollback.nft']); + }); + + test('rollback() re-applies the previous ruleset file', async () => { + const fs = mockFs(); + fs.files.set('/tmp/x/rollback.nft', 'table inet dcnet { OLD }'); + const { impl, calls } = mockExec(); + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + const res = await applier.rollback(); + expect(res.applied).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0].args).toEqual(['-f', '/tmp/x/rollback.nft']); + }); + + test('rollback() without a rollback file throws', async () => { + const fs = mockFs(); + const { impl } = mockExec(); + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + await expect(applier.rollback()).rejects.toThrow(/no rollback file/); + }); + + test('two applies never share a temp path', async () => { + const fs = mockFs(); + const { impl } = mockExec(); + const applier = createWgFirewallApplier({ + execFileImpl: impl, fsImpl: fs, tempDir: '/tmp/x', rollbackPath: '/tmp/x/rollback.nft', + }); + const r1 = await applier.apply(RULESET); + const r2 = await applier.apply(RULESET2); + expect(r1.tempPath).not.toBe(r2.tempPath); + expect(r1.tempPath).toMatch(/dcnet-nft-/); + expect(r2.tempPath).toMatch(/dcnet-nft-/); + }); + + test('apply rejects empty/non-string ruleset before any I/O', async () => { + const fs = mockFs(); + const { impl, calls } = mockExec(); + const applier = createWgFirewallApplier({ execFileImpl: impl, fsImpl: fs }); + await expect(applier.apply('')).rejects.toThrow(/non-empty/); + expect(calls).toHaveLength(0); + expect(fs.writeFileSync).not.toHaveBeenCalled(); + }); + + test('FirewallCheckError is exported and distinguishable', () => { + const e = new FirewallCheckError('x', { stderr: 's' }); + expect(e instanceof Error).toBe(true); + expect(e.applied).toBe(false); + expect(new FirewallApplyError('y').applied).toBe(false); + }); +}); diff --git a/dashcaddy-api/src/network/wg-firewall.js b/dashcaddy-api/src/network/wg-firewall.js new file mode 100644 index 0000000..2d9549c --- /dev/null +++ b/dashcaddy-api/src/network/wg-firewall.js @@ -0,0 +1,628 @@ +/** + * 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, +};