merge dc/DC-151 into p7-wave1
This commit is contained in:
2 files changed
+1101
No files matched your search
@@ -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 <rollbackFile>`
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_<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,
|
||||||
|
};
|
||||||
Reference in new issue
Block a user