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