From fcfe5dff1ba158dc5609158c1132e0281222dc1d Mon Sep 17 00:00:00 2001 From: Hermes Date: Sat, 3 Oct 2026 21:31:09 -0700 Subject: [PATCH] DC-149: WireGuard hub provider (peer store, pool allocator, hub lifecycle) + tests --- .../network-provider-tailscale.test.js | 10 +- .../network-provider-wireguard.test.js | 565 +++++++++++ dashcaddy-api/src/network/index.js | 2 + .../src/network/providers/tailscale.js | 13 +- .../src/network/providers/wireguard.js | 935 ++++++++++++++++++ 5 files changed, 1517 insertions(+), 8 deletions(-) create mode 100644 dashcaddy-api/__tests__/network-provider-wireguard.test.js create mode 100644 dashcaddy-api/src/network/providers/wireguard.js diff --git a/dashcaddy-api/__tests__/network-provider-tailscale.test.js b/dashcaddy-api/__tests__/network-provider-tailscale.test.js index c390836..0983842 100644 --- a/dashcaddy-api/__tests__/network-provider-tailscale.test.js +++ b/dashcaddy-api/__tests__/network-provider-tailscale.test.js @@ -242,9 +242,13 @@ describe('tailscale provider: blockPerson()', () => { }); describe('tailscale provider: unblockPerson()', () => { - test('is a no-op at the Tailscale level and returns { restored: true }', async () => { - const p = createTailscaleProvider({ coordClient: makeCoord() }); - await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true }); + test('is honest (corrected spec): re-allow at ACL level only, devices need re-auth', async () => { + const coord = makeCoord(); + const p = createTailscaleProvider({ coordClient: coord }); + await expect(p.unblockPerson('1001')).resolves.toEqual({ restored: true, reauthRequired: true }); + // No device-key restore call exists — nothing may touch the coord API here. + expect(coord.request).not.toHaveBeenCalled(); + expect(coord.listDevices).not.toHaveBeenCalled(); }); }); diff --git a/dashcaddy-api/__tests__/network-provider-wireguard.test.js b/dashcaddy-api/__tests__/network-provider-wireguard.test.js new file mode 100644 index 0000000..f3e44d6 --- /dev/null +++ b/dashcaddy-api/__tests__/network-provider-wireguard.test.js @@ -0,0 +1,565 @@ +/** + * Tests for src/network/providers/wireguard.js (DC-149). + * + * Hermetic: execFile and the ENTIRE fs surface are injected stubs backed by + * an in-memory map — no child processes, no real files, no network. Same + * rigor as __tests__/network-provider-tailscale.test.js. + */ + +'use strict'; + +const { createWireGuardProvider } = require('../src/network/providers/wireguard'); +const { createTailscaleProvider } = require('../src/network/providers/tailscale'); +const { CONTRACT_METHODS } = require('../src/network/provider'); + +const PRIV = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='; // 44-char b64 +const PRIV2 = 'BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB='; +const PUB = 'a'.repeat(43) + '='; // satisfies the 44-char wg key shape +const PUB2 = 'b'.repeat(43) + '='; +const PUB3 = 'c'.repeat(43) + '='; + +/** + * In-memory fs stub: a Map with the sync fs surface the + * provider touches. Records nothing else — call assertions go on exec. + */ +function makeFs(initial = {}) { + const files = new Map(Object.entries(initial)); + return { + files, + readFileSync(p) { + if (!files.has(p)) { + const err = new Error("ENOENT: no such file '" + p + "'"); + err.code = 'ENOENT'; + throw err; + } + return files.get(p); + }, + writeFileSync(p, contents, opts) { + files.set(p, contents); + if (opts && opts.mode !== undefined) files.set(p + ':mode', String(opts.mode)); + }, + renameSync(from, to) { + if (!files.has(from)) { + const err = new Error("ENOENT: no such file '" + from + "'"); + err.code = 'ENOENT'; + throw err; + } + files.set(to, files.get(from)); + files.delete(from); + }, + existsSync(p) { + return files.has(p); + }, + mkdirSync() { + /* in-memory: dirs are implicit */ + }, + }; +} + +/** + * execFile stub with canned per-binary behavior. Records every call. + * Defaults: root uid, `wg genkey`→PRIV, `wg pubkey`→PUB derived from input, + * `wg show dcnet0 dump`→iface-only table, wg-quick up/down ok. + */ +function makeExec({ + genkey = PRIV, + dump = null, // string stdout for `wg show dcnet0 dump` + dumpFails = false, // `wg show` rejects → interface treated as down + missing = [], // binaries that fail with ENOENT + failQuickUp = false, +} = {}) { + const calls = []; + const fn = async (bin, args, opts = {}) => { + calls.push({ bin, args, opts }); + if (missing.includes(bin)) { + const err = new Error('spawn ' + bin + ' ENOENT'); + err.code = 'ENOENT'; + throw err; + } + if (bin === 'wg' && args[0] === 'genkey') return { stdout: genkey + '\n', stderr: '' }; + if (bin === 'wg' && args[0] === 'pubkey') { + // deterministic: derive from the piped private key so hub identity is stable + const input = String(opts.input || ''); + return { stdout: (input.includes('BBBB') ? PUB2 : PUB) + '\n', stderr: '' }; + } + if (bin === 'wg' && args[0] === 'show' && args[2] === 'dump') { + if (dumpFails) throw new Error('wg: dcnet0: No such device'); + return { stdout: dump !== null ? dump : PRIV + '\t' + PUB + '\t51820\toff\n', stderr: '' }; + } + if (bin === 'wg' && args[0] === '--version') return { stdout: 'wg v1.0.20210914\n', stderr: '' }; + if (bin === 'wg-quick') { + if (args[0] === 'up' && failQuickUp) throw new Error('wg-quick: `wg-quick up dcnet0` failed'); + if (args[0] === 'down') { + return { stdout: '', stderr: '' }; + } + return { stdout: '', stderr: '' }; + } + if (bin === 'sysctl') return { stdout: 'net.ipv4.ip_forward = 1\n', stderr: '' }; + return { stdout: '', stderr: '' }; + }; + fn.calls = calls; + return fn; +} + +/** Build a provider + captures with sensible defaults. */ +function makeProvider(over = {}) { + const fs = over.fs || makeFs(over.files || {}); + const exec = over.exec || makeExec(over.execOpts || {}); + const provider = createWireGuardProvider({ + execFileImpl: exec, + fsImpl: fs, + dataDir: '/data', + confPath: '/wg/dcnet0.conf', + unitPath: '/sys/dcnet-hub.service', + getuid: over.getuid || (() => 0), + now: over.now || (() => 1700000000000), + }); + return { provider, fs, exec }; +} + +/** Public WG dump line for a peer (epoch handshake optional). */ +function dumpLine(pubkey, addr, handshakeSec = 0) { + return [pubkey, '(none)', addr + '/32', '1.2.3.4:51820', String(handshakeSec), '100', '200', '0'].join('\t'); +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('wireguard provider: contract + capabilities', () => { + test('satisfies every CONTRACT_METHODS entry', () => { + const { provider } = makeProvider(); + for (const m of CONTRACT_METHODS) { + expect(typeof provider[m]).toBe('function'); + } + }); + + test('id() returns "wireguard"', () => { + expect(makeProvider().provider.id()).toBe('wireguard'); + }); + + test('capabilities: invites, perPeerFirewall, selfHosted; NO splitDnsApi', () => { + expect(makeProvider().provider.capabilities()).toEqual({ + invites: true, + splitDnsApi: false, + perPeerFirewall: true, + selfHosted: true, + }); + }); +}); + +describe('wireguard provider: dependency checks', () => { + test('missing `wg` binary → dependency_missing', async () => { + const exec = makeExec({ missing: ['wg'] }); + const { provider } = makeProvider({ exec }); + await expect(provider.getStatus()).rejects.toMatchObject({ + name: 'ProviderError', + code: 'dependency_missing', + retryable: false, + }); + }); + + test('missing `wg-quick` binary → dependency_missing', async () => { + const exec = makeExec({ missing: ['wg-quick'] }); + const { provider } = makeProvider({ exec }); + await expect(provider.ensureHub()).rejects.toMatchObject({ code: 'dependency_missing' }); + }); + + test('non-root uid without CAP_NET_ADMIN → dependency_missing', async () => { + const { provider } = makeProvider({ getuid: () => 1000 }); + await expect(provider.getStatus()).rejects.toMatchObject({ + code: 'dependency_missing', + }); + }); +}); + +describe('wireguard provider: ensureHub lifecycle', () => { + test('first call: generates hub key ONCE, writes conf + unit, ip_forward, brings iface up', async () => { + const { provider, fs, exec } = makeProvider(); + const res = await provider.ensureHub(); + expect(res).toMatchObject({ interface: 'dcnet0', address: '10.53.0.1/16', listenPort: 51820, up: true }); + const genkeys = exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey'); + expect(genkeys).toHaveLength(1); + // pubkey derived via stdin (private key never hits argv) + const pubcalls = exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'pubkey'); + expect(pubcalls).toHaveLength(1); + expect(pubcalls[0].opts.input).toContain(PRIV); + const conf = fs.files.get('/wg/dcnet0.conf'); + expect(conf).toContain('[Interface]'); + expect(conf).toContain('PrivateKey = ' + PRIV); + expect(conf).toContain('Address = 10.53.0.1/16'); + expect(conf).toContain('ListenPort = 51820'); + expect(fs.files.get('/sys/dcnet-hub.service')).toContain('ExecStart=/usr/bin/wg-quick up dcnet0'); + expect(exec.calls.some((c) => c.bin === 'sysctl' && c.args[1] === 'net.ipv4.ip_forward=1')).toBe(true); + }); + + test('idempotent: second call does NOT regenerate the hub key', async () => { + const { provider, exec } = makeProvider(); + await provider.ensureHub(); + await provider.ensureHub(); + expect(exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey')).toHaveLength(1); + }); + + test('restart: reuses the PrivateKey already in the conf (no genkey)', async () => { + const { provider, exec } = makeProvider({ + files: { '/wg/dcnet0.conf': '[Interface]\nPrivateKey = ' + PRIV2 + '\nAddress = 10.53.0.1/16\nListenPort = 51820\n' }, + }); + const res = await provider.ensureHub(); + expect(res.up).toBe(true); + expect(exec.calls.filter((c) => c.bin === 'wg' && c.args[0] === 'genkey')).toHaveLength(0); + }); + + test('missing /etc/wireguard dir is created (no throw)', async () => { + const { provider } = makeProvider(); + await expect(provider.ensureHub()).resolves.toBeTruthy(); + }); + + test('startHub / stopHub round-trip, stopHub tolerates "not up"', async () => { + const { provider, exec } = makeProvider(); + await provider.ensureHub(); + exec.calls.length = 0; + await provider.startHub(); + expect(exec.calls.some((c) => c.bin === 'wg-quick' && c.args[0] === 'up')).toBe(true); + await provider.stopHub(); + expect(exec.calls.some((c) => c.bin === 'wg-quick' && c.args[0] === 'down')).toBe(true); + // stop again — down on a non-existent iface must not throw + await expect(provider.stopHub()).resolves.toMatchObject({ stopped: 'dcnet0' }); + }); +}); + +describe('wireguard provider: address pool', () => { + test('deterministic lowest-free allocation, hub address excluded', async () => { + const { provider, fs } = makeProvider(); + const a = await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + const b = await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 }); + expect(a.address).toBe('10.53.0.2/32'); + expect(b.address).toBe('10.53.0.3/32'); + const store = JSON.parse(fs.files.get('/data/network-wg-peers.json')); + expect(store.peers.map((p) => p.address)).toEqual(['10.53.0.2/32', '10.53.0.3/32']); + }); + + test('skips taken addresses (gap reuse: lowest-free wins)', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 }); + await provider.removePeer(PUB); // frees 10.53.0.2 + const c = await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 }); + expect(c.address).toBe('10.53.0.2/32'); // lowest free, reused + }); + + test('survives restart: pool re-derived from the store, not a counter', async () => { + const shared = makeFs({ + '/data/network-wg-peers.json': JSON.stringify({ + version: 1, + peers: [ + { personId: 'p1', deviceId: 'd1', publicKey: PUB, address: '10.53.0.2/32', name: 'laptop', createdAt: '2026-01-01T00:00:00Z', blocked: false }, + ], + }), + }); + const exec = makeExec(); + const first = createWireGuardProvider({ + execFileImpl: exec, fsImpl: shared, dataDir: '/data', + confPath: '/wg/dcnet0.conf', unitPath: '/sys/dcnet-hub.service', + getuid: () => 0, now: () => 1700000000000, + }); + const r = await first.addPeer({ personId: 'p2', deviceId: 'd9', publicKey: PUB3 }); + expect(r.address).toBe('10.53.0.3/32'); // after the surviving 10.53.0.2 + // brand-new provider instance (fresh memory) over the same store + const second = createWireGuardProvider({ + execFileImpl: makeExec(), fsImpl: shared, dataDir: '/data', + confPath: '/wg/dcnet0.conf', unitPath: '/sys/dcnet-hub.service', + getuid: () => 0, now: () => 1700000000000, + }); + const devices = await second.listDevices(); + expect(devices.map((d) => d.ip).sort()).toEqual(['10.53.0.2', '10.53.0.3']); + }); +}); + +describe('wireguard provider: addPeer', () => { + test('writes [Peer] to conf + updates store; never stores a private key', async () => { + const { provider, fs } = makeProvider(); + const res = await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' }); + expect(res).toMatchObject({ deviceId: 'd1', address: '10.53.0.2/32' }); + expect(res.hubPublicKey).toBe(PUB); // derived from genkey'd hub private key + expect(res.allowedIps).toBe('10.53.0.0/16'); // spec §8.1 split tunnel + expect(res.dns).toBe('10.53.0.1'); // spec §8.3 + const conf = fs.files.get('/wg/dcnet0.conf'); + expect(conf).toContain('[Peer]'); + expect(conf).toContain('PublicKey = ' + PUB); + expect(conf).toContain('AllowedIPs = 10.53.0.2/32'); + expect(conf).toContain('# laptop'); + const store = JSON.parse(fs.files.get('/data/network-wg-peers.json')); + expect(store.peers).toHaveLength(1); + expect(store.peers[0]).toMatchObject({ personId: 'p1', deviceId: 'd1', publicKey: PUB, address: '10.53.0.2/32', name: 'laptop' }); + // §8.2: the server stores public keys only + expect(conf.includes(PRIV)).toBe(true); // hub key in conf is expected + expect(JSON.stringify(store).includes(PRIV)).toBe(false); + expect(store.peers[0].privateKey).toBeUndefined(); + }); + + test('apply path: wg-quick down + up (regenerated conf), never syncconf shell form', async () => { + const { provider, exec } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + const quick = exec.calls.filter((c) => c.bin === 'wg-quick'); + expect(quick.some((c) => c.args[0] === 'down')).toBe(true); + expect(quick.some((c) => c.args[0] === 'up')).toBe(true); + expect(quick.some((c) => c.args[0] === 'syncconf')).toBe(false); + const allCmds = exec.calls.map((c) => c.bin + ' ' + c.args.join(' ')).join(';'); + expect(allCmds.includes('wg-quick strip')).toBe(false); + }); + + test('rejects invalid public keys (bad_input)', async () => { + const { provider } = makeProvider(); + await expect(provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: 'not-a-key' })) + .rejects.toMatchObject({ code: 'bad_input' }); + await expect(provider.addPeer({ personId: 'p1', deviceId: 'd1' })) + .rejects.toMatchObject({ code: 'bad_input' }); + }); + + test('rejects duplicate publicKey (bad_input)', async () => { + const { provider } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await expect(provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB })) + .rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('wireguard provider: removePeer / revokeDevice / removePerson', () => { + test('removePeer rewrites conf without the [Peer] and drops the store record', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + const res = await provider.removePeer(PUB); + expect(res).toMatchObject({ removed: PUB, address: '10.53.0.2/32' }); + const conf = fs.files.get('/wg/dcnet0.conf'); + expect(conf).not.toContain('[Peer]'); + expect(JSON.parse(fs.files.get('/data/network-wg-peers.json')).peers).toHaveLength(0); + }); + + test('removePeer unknown key → not_found', async () => { + const { provider } = makeProvider(); + await expect(provider.removePeer(PUB)).rejects.toMatchObject({ code: 'not_found' }); + }); + + test('revokeDevice removes by deviceId', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + const res = await provider.revokeDevice('d1'); + expect(res).toMatchObject({ revoked: 'd1', publicKey: PUB }); + expect(fs.files.get('/wg/dcnet0.conf')).not.toContain('PublicKey = ' + PUB); + expect(JSON.parse(fs.files.get('/data/network-wg-peers.json')).peers).toHaveLength(0); + }); + + test('removePerson deletes peers + records entirely', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2 }); + await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 }); + const res = await provider.removePerson('p1'); + expect(res).toEqual({ deleted: 2, count: 2 }); + const store = JSON.parse(fs.files.get('/data/network-wg-peers.json')); + expect(store.peers).toHaveLength(1); + expect(store.peers[0].personId).toBe('p2'); + }); +}); + +describe('wireguard provider: blockPerson / unblockPerson', () => { + test('block removes peers from conf but KEEPS records; conf keeps no trace', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB2 }); + const res = await provider.blockPerson('p1'); + expect(res).toMatchObject({ blocked: true, count: 1 }); + const conf = fs.files.get('/wg/dcnet0.conf'); + expect(conf).not.toContain('PublicKey = ' + PUB); + expect(conf).toContain('PublicKey = ' + PUB2); // other person untouched + const store = JSON.parse(fs.files.get('/data/network-wg-peers.json')); + const blocked = store.peers.find((p) => p.publicKey === PUB); + expect(blocked).toBeTruthy(); // record kept for unblock + expect(blocked.blocked).toBe(true); + expect(blocked.address).toBe('10.53.0.2/32'); + }); + + test('unblock re-adds stored peers from the record, same addresses (WG restores immediately)', async () => { + const { provider, fs } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await provider.blockPerson('p1'); + const res = await provider.unblockPerson('p1'); + expect(res).toEqual({ restored: [PUB], count: 1, reauthRequired: false }); + const conf = fs.files.get('/wg/dcnet0.conf'); + expect(conf).toContain('PublicKey = ' + PUB); + expect(conf).toContain('AllowedIPs = 10.53.0.2/32'); // same /32 restored + const store = JSON.parse(fs.files.get('/data/network-wg-peers.json')); + expect(store.peers[0].blocked).toBe(false); + }); + + test('unblockPerson with nothing blocked → empty restore, still reauthRequired:false', async () => { + const { provider } = makeProvider(); + const res = await provider.unblockPerson('nobody'); + expect(res).toEqual({ restored: [], count: 0, reauthRequired: false }); + }); + + test('tailscale unblockPerson is honest: restored true, reauthRequired true, NO coord call', async () => { + const coord = { request: jest.fn(async () => ({})), listDevices: jest.fn(async () => []), listUsers: jest.fn(async () => []), deleteDevice: jest.fn(async () => ({})) }; + const ts = createTailscaleProvider({ coordClient: coord }); + const res = await ts.unblockPerson('1001'); + expect(res).toEqual({ restored: true, reauthRequired: true }); + expect(coord.request).not.toHaveBeenCalled(); // no un-expire API exists + expect(coord.listDevices).not.toHaveBeenCalled(); + }); +}); + +describe('wireguard provider: whois / listDevices / getStatus', () => { + test('whois(ip) → peer-store lookup by address (with and without /32)', async () => { + const { provider } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' }); + await expect(provider.whois('10.53.0.2')).resolves.toMatchObject({ + personId: 'p1', deviceId: 'd1', publicKey: PUB, blocked: false, + }); + await expect(provider.whois('10.53.0.2/32')).resolves.toMatchObject({ personId: 'p1' }); + await expect(provider.whois('10.53.9.9')).resolves.toBe(null); + await expect(provider.whois('')).rejects.toMatchObject({ code: 'bad_input' }); + }); + + test('listDevices merges store with wg dump handshakes (online window)', async () => { + const nowMs = 1700000000000; + const freshSec = Math.floor((nowMs - 30 * 1000) / 1000); // 30s ago → online + const staleSec = Math.floor((nowMs - 10 * 60 * 1000) / 1000); // 10m ago → offline + const exec = makeExec({ + dump: PRIV + '\t' + PUB + '\t51820\toff\n' + + dumpLine(PUB, '10.53.0.2', freshSec) + '\n' + + dumpLine(PUB2, '10.53.0.3', staleSec) + '\n', + }); + const { provider, fs } = makeProvider({ exec, now: () => nowMs }); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + await provider.addPeer({ personId: 'p2', deviceId: 'd2', publicKey: PUB2 }); + const devices = await provider.listDevices(); + expect(devices).toHaveLength(2); + const d1 = devices.find((d) => d.deviceId === 'd1'); + const d2 = devices.find((d) => d.deviceId === 'd2'); + expect(d1).toMatchObject({ ip: '10.53.0.2', online: true, userId: 'p1', rx: 100, tx: 200 }); + expect(d1.lastSeen).toBe(new Date(freshSec * 1000).toISOString()); + expect(d2.online).toBe(false); + expect(fs.files.get('/data/network-wg-peers.json')).toBeTruthy(); + }); + + test('getStatus: hub up with per-peer handshake state', async () => { + const nowMs = 1700000000000; + const freshSec = Math.floor((nowMs - 60 * 1000) / 1000); + const exec = makeExec({ dump: PRIV + '\t' + PUB + '\t51820\toff\n' + dumpLine(PUB, '10.53.0.2', freshSec) + '\n' }); + const { provider } = makeProvider({ exec, now: () => nowMs }); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + const st = await provider.getStatus(); + expect(st).toMatchObject({ + provider: 'wireguard', installed: true, connected: true, hubUp: true, + interface: 'dcnet0', address: '10.53.0.1/16', listenPort: 51820, + hubPublicKey: PUB, peerCount: 1, + }); + expect(st.peers[0]).toMatchObject({ publicKey: PUB, address: '10.53.0.2/32', online: true, rx: 100, tx: 200 }); + }); + + test('getStatus: iface down (wg show fails) degrades to connected:false, peers offline', async () => { + const exec = makeExec({ dumpFails: true }); + const { provider } = makeProvider({ exec }); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB }); + exec.calls.length = 0; + const st = await provider.getStatus(); + expect(st).toMatchObject({ provider: 'wireguard', connected: false, hubUp: false }); + expect(st.peerCount).toBe(1); // from the store, not wg + expect(st.peers[0].online).toBe(false); + expect(st.peers[0].latestHandshake).toBe(null); + }); + + test('listPeople projects unique persons with earliest createdAt', async () => { + const { provider } = makeProvider(); + await provider.addPeer({ personId: 'p1', deviceId: 'd1', publicKey: PUB, name: 'laptop' }); + await provider.addPeer({ personId: 'p1', deviceId: 'd2', publicKey: PUB2, name: 'phone' }); + await provider.addPeer({ personId: 'p2', deviceId: 'd3', publicKey: PUB3 }); + const people = await provider.listPeople(); + expect(people).toHaveLength(2); + expect(people.map((p) => p.id).sort()).toEqual(['p1', 'p2']); + expect(people.every((p) => p.email === null)).toBe(true); + }); +}); + +describe('wireguard provider: invites (spec §8.5)', () => { + test('createInvite → { inviteUrl: /network/wg/redeem/, id }, 7d TTL, persisted', async () => { + const t0 = 1700000000000; + const { provider, fs } = makeProvider({ now: () => t0 }); + const inv = await provider.createInvite({ role: 'member', email: 'a@b.c' }); + expect(inv.id).toMatch(/^wgi_/); + expect(inv.inviteUrl).toMatch(/^\/network\/wg\/redeem\/[A-Za-z0-9_-]{32}$/); + const store = JSON.parse(fs.files.get('/data/network-wg-invites.json')); + expect(store.invites).toHaveLength(1); + expect(store.invites[0].expiresAt).toBe(t0 + 7 * 24 * 60 * 60 * 1000); + expect(store.invites[0].usedAt).toBe(null); + }); + + test('redeem: single-use — second attempt fails', async () => { + const { provider } = makeProvider(); + const inv = await provider.createInvite({ role: 'member' }); + const token = inv.inviteUrl.split('/').pop(); + await expect(provider.redeemInvite(token)).resolves.toMatchObject({ role: 'member' }); + await expect(provider.redeemInvite(token)).rejects.toMatchObject({ + code: 'bad_input', // already used + }); + }); + + test('redeem: expired token → not_found and pruned from the store', async () => { + let t = 1700000000000; + const { provider, fs } = makeProvider({ now: () => t }); + const inv = await provider.createInvite({ role: 'member' }); + const token = inv.inviteUrl.split('/').pop(); + t += 7 * 24 * 60 * 60 * 1000 + 1; // past TTL + await expect(provider.redeemInvite(token)).rejects.toMatchObject({ code: 'not_found' }); + const store = JSON.parse(fs.files.get('/data/network-wg-invites.json')); + expect(store.invites).toHaveLength(0); + }); + + test('revokeInvite removes the token; unknown id → not_found', async () => { + const { provider } = makeProvider(); + const inv = await provider.createInvite({ role: 'member' }); + await expect(provider.revokeInvite(inv.id)).resolves.toEqual({ revoked: true }); + const token = inv.inviteUrl.split('/').pop(); + await expect(provider.redeemInvite(token)).rejects.toMatchObject({ code: 'not_found' }); + await expect(provider.revokeInvite('nope')).rejects.toMatchObject({ code: 'not_found' }); + }); + + test('createInvite validates role/email (bad_input)', async () => { + const { provider } = makeProvider(); + await expect(provider.createInvite({})).rejects.toMatchObject({ code: 'bad_input' }); + await expect(provider.createInvite({ role: 'member', email: 'nope' })).rejects.toMatchObject({ code: 'bad_input' }); + }); +}); + +describe('wireguard provider: unsupported / not-implemented surface', () => { + test('syncAccess → not_implemented (DC-151 injects the firewall compiler)', async () => { + const { provider } = makeProvider(); + await expect(provider.syncAccess()).rejects.toMatchObject({ + name: 'ProviderError', + code: 'not_implemented', + retryable: false, + }); + }); + + test('configureDns → unsupported (splitDnsApi capability false)', async () => { + const { provider } = makeProvider(); + await expect(provider.configureDns({ zone: 'z', server: 's' })).rejects.toMatchObject({ + name: 'ProviderError', + code: 'unsupported', + retryable: false, + }); + }); +}); + +describe('wireguard provider: registry registration', () => { + test('src/network/index registers wireguard alongside tailscale', () => { + jest.resetModules(); + const { registry } = require('../src/network'); + expect(registry.listProviderIds().sort()).toEqual(['tailscale', 'wireguard']); + const p = registry.setActiveProvider('wireguard'); + expect(p.id()).toBe('wireguard'); + // contract validation is enforced by setActiveProvider itself + for (const m of CONTRACT_METHODS) expect(typeof p[m]).toBe('function'); + registry.reset(); + }); +}); diff --git a/dashcaddy-api/src/network/index.js b/dashcaddy-api/src/network/index.js index b697922..b66461e 100644 --- a/dashcaddy-api/src/network/index.js +++ b/dashcaddy-api/src/network/index.js @@ -14,8 +14,10 @@ const registry = require('./registry'); const { createTailscaleProvider } = require('./providers/tailscale'); +const { createWireGuardProvider } = require('./providers/wireguard'); registry.registerProvider('tailscale', createTailscaleProvider); +registry.registerProvider('wireguard', createWireGuardProvider); module.exports = { registry, diff --git a/dashcaddy-api/src/network/providers/tailscale.js b/dashcaddy-api/src/network/providers/tailscale.js index ab11239..71c9237 100644 --- a/dashcaddy-api/src/network/providers/tailscale.js +++ b/dashcaddy-api/src/network/providers/tailscale.js @@ -259,14 +259,17 @@ function createTailscaleProvider({ }, /** - * Unblock is a no-op at the Tailscale level: key expiry is reversed by - * the user re-authenticating, and access restoration happens when - * DashCaddy re-syncs the ACL (syncAccess). Kept in the contract so the - * orchestration layer (DC-143) can call it uniformly across providers. + * Unblock is honest, not fully reversible (corrected spec): key expiry + * cannot be undone — there is no Tailscale API to un-expire a device + * key. This method re-allows the person at the ACL level only (the ACL + * compiler re-push on unblock drops the network deny); the user must + * RE-AUTHENTICATE each device for it to rejoin the tailnet. Kept in the + * contract so the orchestration layer (DC-143) can call it uniformly. + * @returns {{ restored: boolean, reauthRequired: boolean }} */ async unblockPerson() { await Promise.resolve(); // uniform async surface (contract: all async) - return { restored: true }; + return { restored: true, reauthRequired: true }; }, /** diff --git a/dashcaddy-api/src/network/providers/wireguard.js b/dashcaddy-api/src/network/providers/wireguard.js new file mode 100644 index 0000000..1491915 --- /dev/null +++ b/dashcaddy-api/src/network/providers/wireguard.js @@ -0,0 +1,935 @@ +/** + * WireGuard hub provider for the DashCaddy Private Network (DC-149, + * specs/PRIVATE-NETWORK-SPEC.md §3 + §8). + * + * Self-hosted hub-and-spoke: DashCaddy manages a `wg-quick` interface + * `dcnet0` on the host (10.53.0.1/16, spec §8.1). Client keypairs are + * generated in the invitee's BROWSER (spec §8.2) — the server only ever + * sees public keys; this provider NEVER generates client private keys. + * The only private key on the host is the HUB keypair, generated once + * with `wg genkey` and stored inside /etc/wireguard/dcnet0.conf (spec §8.4). + * + * Persistence (all atomic tmp+rename writes, like the other stores): + * - /etc/wireguard/dcnet0.conf — hub config + one [Peer] per device + * - data/network-wg-peers.json — { peers: [{ personId, deviceId, + * publicKey, address (10.53.x.y/32), name, createdAt, blocked? }] } + * - data/network-wg-invites.json — one-time tokens (7d TTL, single-use, + * spec §8.5) + * + * Address pool (spec §8.1): 10.53.0.0/16, one /32 per device, hub owns + * 10.53.0.1. Allocation is the deterministic lowest-free /32 derived from + * the peer store — never a counter — so it is stable across restarts and + * reuses freed addresses. + * + * Host requirements, checked lazily at first use and degrading cleanly + * with ProviderError code='dependency_missing': `wg`, `wg-quick`, and + * root (or CAP_NET_ADMIN). Conf changes apply by regenerating the conf + * and running `wg-quick down` + `wg-quick up` (tolerating "not up"); + * the `wg syncconf dcnet0 <(wg-quick strip dcnet0)` shell form is + * deliberately NOT used (no shell interpolation of interface state). + * + * Every child process and every filesystem touch is injectable for tests: + * createWireGuardProvider({ execFileImpl, fsImpl, dataDir, confPath, + * unitPath, listenPort, getuid, now }) + */ + +'use strict'; + +const path = require('path'); +const crypto = require('crypto'); +const { execFile } = require('child_process'); +const { ProviderError } = require('../errors'); + +const INTERFACE_NAME = 'dcnet0'; +const HUB_ADDRESS = '10.53.0.1/16'; +const HUB_HOST_INT = 1; // 10.53.0.1 — excluded from the peer pool +const POOL_MAX = 0xffff; // 10.53.255.255 — last usable pool host int +const DEFAULT_LISTEN_PORT = 51820; +const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // spec §8.5: 7-day TTL +const HANDSHAKE_FRESH_MS = 3 * 60 * 1000; // peer "online" window +const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/; // wg keys: 43 data chars + base64 pad +const DEFAULT_CONF_PATH = '/etc/wireguard/dcnet0.conf'; +const DEFAULT_UNIT_PATH = '/etc/systemd/system/dcnet-hub.service'; + +/** Monotonic counter for unique atomic-write tmp names. */ +let tmpCounter = 0; + +/** + * Default child-process runner: promisified execFile that also supports + * feeding stdin (`opts.input`) — needed for `wg pubkey`, which reads the + * private key from stdin (never argv, where it would leak to ps). + */ +function execFileDefault(bin, args, opts = {}) { + return new Promise((resolve, reject) => { + const child = execFile( + bin, + args, + { timeout: opts.timeout || 10000, maxBuffer: 1024 * 1024 }, + (err, stdout, stderr) => { + if (err) { + if (stdout !== undefined) err.stdout = stdout; + if (stderr !== undefined) err.stderr = stderr; + reject(err); + } else { + resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') }); + } + } + ); + if (opts.input !== undefined && child.stdin) { + child.stdin.write(opts.input); + child.stdin.end(); + } + }); +} + +/** Map any thrown value onto the shared ProviderError vocabulary. */ +function toProviderError(err) { + if (err instanceof ProviderError) return err; + if (err && err.code === 'ENOENT') { + return new ProviderError('wireguard: required binary not found: ' + String(err.message), { + code: 'dependency_missing', + retryable: false, + cause: err, + }); + } + const message = err && err.message ? err.message : String(err); + return new ProviderError('wireguard: ' + message, { + code: 'provider_error', + retryable: true, + cause: err, + }); +} + +/** Pool host int (10.53.x.y → x*256+y) ↔ dotted quad. */ +function poolIntToAddress(n) { + return '10.53.' + ((n >> 8) & 0xff) + '.' + (n & 0xff); +} + +function addressToPoolInt(addr) { + const m = /^10\.53\.(\d+)\.(\d+)(\/\d+)?$/.exec(String(addr || '')); + if (!m) return null; + return (parseInt(m[1], 10) << 8) | parseInt(m[2], 10); +} + +/** Strip a /32 (or any) suffix from an address for comparisons. */ +function bareAddress(addr) { + return String(addr || '').replace(/\/\d+$/, ''); +} + +/** + * Build the WireGuard NetworkProvider. + * + * @param {object} [deps] - all injectable, all optional (hermetic tests) + * @param {Function} [deps.execFileImpl] - async (bin, args, opts) runner + * @param {object} [deps.fsImpl] - fs surface used by this provider + * @param {string} [deps.dataDir] - store dir (default: platform dataDir) + * @param {string} [deps.confPath] - hub conf path + * @param {string} [deps.unitPath] - systemd unit path + * @param {number} [deps.listenPort] - UDP listen port (default 51820) + * @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check) + * @param {Function} [deps.now] - epoch-ms clock (invite TTL tests) + */ +function createWireGuardProvider({ + execFileImpl, + fsImpl, + dataDir, + confPath = DEFAULT_CONF_PATH, + unitPath = DEFAULT_UNIT_PATH, + listenPort = DEFAULT_LISTEN_PORT, + getuid, + now, +} = {}) { + const execFn = execFileImpl || execFileDefault; + const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow + const dataDirResolved = + dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir; + const peersPath = path.join(dataDirResolved, 'network-wg-peers.json'); + const invitesPath = path.join(dataDirResolved, 'network-wg-invites.json'); + const uidFn = getuid || (() => (process.getuid ? process.getuid() : 0)); + const nowFn = now || Date.now; + + // Hub key material, loaded/generated once per provider instance. + let hubPrivateKey = null; + let hubPublicKey = null; + let depsReady = null; // cached dependency check + let writeQueue = Promise.resolve(); // serialized store mutations + + /* ---------------------------------------------------------------- * + * filesystem helpers (all through the injected fs surface) * + * ---------------------------------------------------------------- */ + + function atomicWrite(filePath, contents, mode) { + tmpCounter += 1; + const tmp = filePath + '.tmp-' + process.pid + '-' + tmpCounter; + fs.writeFileSync(tmp, contents, mode !== undefined ? { mode } : undefined); + fs.renameSync(tmp, filePath); + } + + function readJson(filePath, fallback) { + let raw; + try { + raw = fs.readFileSync(filePath, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') return fallback; + throw err; + } + try { + return JSON.parse(raw); + } catch (err) { + // Corrupt store: move aside (best-effort) and restart empty — same + // recovery posture as the people store (DC-141). + try { + fs.renameSync(filePath, filePath + '.bak'); + } catch (_) { + /* best-effort */ + } + return fallback; + } + } + + function writeJson(filePath, obj) { + atomicWrite(filePath, JSON.stringify(obj, null, 2) + '\n', 0o600); + } + + function ensureDir(dir) { + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + } + + /* ---------------------------------------------------------------- * + * peer store * + * ---------------------------------------------------------------- */ + + function loadPeers() { + const data = readJson(peersPath, { version: 1, peers: [] }); + if (!data || !Array.isArray(data.peers)) return { version: 1, peers: [] }; + return data; + } + + function savePeers(store) { + writeJson(peersPath, store); + } + + /** Serialized read-modify-write so concurrent mutations never interleave. */ + function mutatePeers(fn) { + const run = writeQueue.then(() => { + const store = loadPeers(); + const result = fn(store); + if (result !== false) savePeers(store); + return result; + }); + writeQueue = run.catch(() => {}); + return run; + } + + /** Deterministic lowest-free /32 in 10.53.0.0/16, minus the hub address. */ + function allocateAddress(store) { + const taken = new Set(); + for (const p of store.peers) { + const n = addressToPoolInt(p.address); + if (n !== null) taken.add(n); + } + for (let n = HUB_HOST_INT + 1; n <= POOL_MAX; n += 1) { + if (!taken.has(n)) return poolIntToAddress(n) + '/32'; + } + throw new ProviderError('wireguard: address pool 10.53.0.0/16 exhausted', { + code: 'provider_error', + retryable: false, + }); + } + + /* ---------------------------------------------------------------- * + * hub config * + * ---------------------------------------------------------------- */ + + function renderConf(store) { + let out = + '[Interface]\n' + + 'PrivateKey = ' + hubPrivateKey + '\n' + + 'Address = ' + HUB_ADDRESS + '\n' + + 'ListenPort = ' + listenPort + '\n'; + const active = store.peers.filter((p) => !p.blocked); + if (active.length) out += '\n'; + for (const p of active) { + const label = (p.name || p.deviceId || '').replace(/[\r\n]/g, ' '); + if (label) out += '# ' + label + '\n'; + out += '[Peer]\nPublicKey = ' + p.publicKey + '\nAllowedIPs = ' + p.address + '\n'; + } + return out; + } + + /** Parse the hub PrivateKey back out of an existing conf (restart path). */ + function readConfPrivateKey() { + let raw; + try { + raw = fs.readFileSync(confPath, 'utf8'); + } catch (err) { + if (err && err.code === 'ENOENT') return null; + throw err; + } + const m = /^PrivateKey = (\S+)$/m.exec(raw); + return m ? m[1] : null; + } + + /** True when a wg-quick failure just means the interface was not up. */ + function isNotUpError(err) { + const text = ((err && (err.stderr || err.stdout)) || '') + ' ' + ((err && err.message) || ''); + return /not exist|not up|no such|does not exist/i.test(text); + } + + /** Bring dcnet0 down (tolerating "not up") then up with the new conf. */ + async function applyConf(store) { + atomicWrite(confPath, renderConf(store), 0o600); + try { + await execFn('wg-quick', ['down', INTERFACE_NAME]); + } catch (err) { + if (!isNotUpError(err)) throw toProviderError(err); // unexpected → surface + } + try { + await execFn('wg-quick', ['up', INTERFACE_NAME]); + } catch (err) { + if (!/already exists|already up/i.test(String((err && err.message) || ''))) { + throw toProviderError(err); + } + } + } + + async function interfaceIsUp() { + try { + await execFn('wg', ['show', INTERFACE_NAME, 'dump']); + return true; + } catch (_) { + return false; + } + } + + /* ---------------------------------------------------------------- * + * dependency + hub bootstrap * + * ---------------------------------------------------------------- */ + + async function checkDependencies() { + try { + await execFn('wg', ['--version']); + } catch (err) { + if (err && err.code === 'ENOENT') { + throw new ProviderError('wireguard: `wg` binary not installed', { + code: 'dependency_missing', + retryable: false, + }); + } + } + try { + await execFn('wg-quick', ['--help']); // exits non-zero with usage — ENOENT is the signal + } catch (err) { + if (err && err.code === 'ENOENT') { + throw new ProviderError('wireguard: `wg-quick` binary not installed', { + code: 'dependency_missing', + retryable: false, + }); + } + } + if (uidFn() !== 0) { + throw new ProviderError( + 'wireguard: hub management requires root or CAP_NET_ADMIN (wg-quick needs to create dcnet0)', + { code: 'dependency_missing', retryable: false } + ); + } + } + + function ensureDeps() { + if (!depsReady) { + depsReady = checkDependencies().catch((err) => { + depsReady = null; // transient failures (e.g. spawn hiccups) re-check + throw toProviderError(err); + }); + } + return depsReady; + } + + /** Generate the hub keypair host-side ONCE (spec §8.4). */ + async function ensureHubKey() { + if (hubPrivateKey) return; + const existing = readConfPrivateKey(); + if (existing) { + hubPrivateKey = existing; + } else { + const gen = await execFn('wg', ['genkey']); + hubPrivateKey = String(gen.stdout || '').trim(); + if (!WG_KEY_RE.test(hubPrivateKey)) { + throw new ProviderError('wireguard: `wg genkey` returned an invalid key', { + code: 'provider_error', + retryable: true, + }); + } + } + const pub = await execFn('wg', ['pubkey'], { input: hubPrivateKey + '\n' }); + hubPublicKey = String(pub.stdout || '').trim(); + } + + const SYSTEMD_UNIT = [ + '[Unit]', + 'Description=DashCaddy Private Network WireGuard hub (dcnet0)', + 'After=network-online.target', + 'Wants=network-online.target', + '', + '[Service]', + 'Type=oneshot', + 'RemainAfterExit=yes', + 'ExecStart=/usr/bin/wg-quick up ' + INTERFACE_NAME, + 'ExecStop=/usr/bin/wg-quick down ' + INTERFACE_NAME, + '', + '[Install]', + 'WantedBy=multi-user.target', + '', + ].join('\n'); + + /** + * Idempotent hub bootstrap: keypair, conf, systemd unit, ip_forward, + * interface up. Safe to call repeatedly (spec §8.4). + */ + async function ensureHub() { + await ensureDeps(); + ensureDir(path.dirname(confPath)); + await ensureHubKey(); + ensureDir(dataDirResolved); + if (!fs.existsSync(confPath)) { + atomicWrite(confPath, renderConf(loadPeers()), 0o600); + } + if (!fs.existsSync(unitPath)) { + atomicWrite(unitPath, SYSTEMD_UNIT, 0o644); + } + // Hub-and-spoke peer→peer traffic needs forwarding (spec §8.1). Failure + // (e.g. read-only /proc in a container) degrades to a warning, not a crash. + let ipForward = true; + try { + await execFn('sysctl', ['-w', 'net.ipv4.ip_forward=1']); + } catch (_) { + ipForward = false; + } + if (!(await interfaceIsUp())) { + try { + await execFn('wg-quick', ['up', INTERFACE_NAME]); + } catch (err) { + if (!/already exists|already up/i.test(String((err && err.message) || ''))) { + throw toProviderError(err); + } + } + } + const up = true; + return { interface: INTERFACE_NAME, address: HUB_ADDRESS, listenPort, up, ipForward }; + } + + /** Run fn after deps+hub are ready; normalize every failure. */ + async function withHub(fn) { + try { + await ensureHub(); + return await fn(); + } catch (err) { + throw toProviderError(err); + } + } + + /* ---------------------------------------------------------------- * + * wg state parsing * + * ---------------------------------------------------------------- */ + + /** + * `wg show dcnet0 dump` → { interface, peersByPubkey }. + * Interface row: private-key, public-key, listen-port, fwmark. + * Peer rows: public-key, preshared-key, allowed-ips, endpoint, + * latest-handshake (epoch s), rx, tx, persistent-keepalive. + */ + async function wgDump() { + const res = await execFn('wg', ['show', INTERFACE_NAME, 'dump']); + const lines = String(res.stdout || '').split('\n').filter((l) => l.trim()); + const iface = { up: true }; + const peersByPubkey = {}; + lines.forEach((line, idx) => { + const cols = line.split('\t'); + if (idx === 0) { + // first row is always the interface row in `dump` format: + // private-key, public-key, listen-port, fwmark + iface.publicKey = !cols[1] || cols[1] === '(none)' ? null : cols[1]; + iface.listenPort = parseInt(cols[2], 10) || null; + return; + } + if (cols.length >= 7) { + const handshake = parseInt(cols[4], 10) || 0; + peersByPubkey[cols[0]] = { + publicKey: cols[0], + allowedIps: cols[2], + endpoint: cols[3] === '(none)' ? null : cols[3], + latestHandshake: handshake > 0 ? handshake : null, + rx: parseInt(cols[5], 10) || 0, + tx: parseInt(cols[6], 10) || 0, + }; + } + }); + return { iface, peersByPubkey }; + } + + /* ---------------------------------------------------------------- * + * invite store (spec §8.5) * + * ---------------------------------------------------------------- */ + + function loadInvites() { + const data = readJson(invitesPath, { version: 1, invites: [] }); + if (!data || !Array.isArray(data.invites)) return { version: 1, invites: [] }; + return data; + } + + function saveInvites(store) { + writeJson(invitesPath, store); + } + + function pruneExpired(store) { + const t = nowFn(); + store.invites = store.invites.filter( + (i) => !i.usedAt && i.expiresAt > t + ); + } + + function inviteRecord(store, id) { + return store.invites.find((i) => i.id === id) || null; + } + + /* ---------------------------------------------------------------- * + * provider contract surface * + * ---------------------------------------------------------------- */ + + const provider = { + id() { + return 'wireguard'; + }, + + capabilities() { + return { + invites: true, // one-time tokens minted locally (§8.5) + splitDnsApi: false, // Technitium is written directly, not via provider API (§8.3) + perPeerFirewall: true, // nftables matrix (DC-150/151) + selfHosted: true, // no outside control plane + }; + }, + + /** Hub health: deps, iface up/down, per-peer handshakes. */ + async getStatus() { + return withHub(async () => { + let up = await interfaceIsUp(); + let dump = { iface: {}, peersByPubkey: {} }; + if (up) dump = await wgDump(); + const store = loadPeers(); + const nowMs = nowFn(); + const peers = store.peers + .filter((p) => !p.blocked) + .map((p) => { + const live = dump.peersByPubkey[p.publicKey] || null; + const fresh = !!(live && live.latestHandshake && nowMs - live.latestHandshake * 1000 < HANDSHAKE_FRESH_MS); + return { + publicKey: p.publicKey, + address: p.address, + name: p.name || null, + personId: p.personId, + deviceId: p.deviceId, + online: fresh, + latestHandshake: live ? live.latestHandshake : null, + rx: live ? live.rx : 0, + tx: live ? live.tx : 0, + }; + }); + return { + provider: 'wireguard', + installed: true, + connected: up, + hubUp: up, + interface: INTERFACE_NAME, + address: HUB_ADDRESS, + listenPort, + hubPublicKey: hubPublicKey, + peerCount: peers.length, + blockedPeerCount: store.peers.filter((p) => p.blocked).length, + peers, + }; + }); + }, + + /** Peer store merged with live `wg show` state. */ + async listDevices() { + return withHub(async () => { + const [store, up] = [loadPeers(), await interfaceIsUp()]; + let live = {}; + if (up) live = (await wgDump()).peersByPubkey; + const nowMs = nowFn(); + return store.peers.map((p) => { + const l = live[p.publicKey] || null; + const fresh = !!(l && l.latestHandshake && nowMs - l.latestHandshake * 1000 < HANDSHAKE_FRESH_MS); + return { + id: p.deviceId, + deviceId: p.deviceId, + name: p.name || null, + ip: bareAddress(p.address), + publicKey: p.publicKey, + userId: p.personId, + personId: p.personId, + online: fresh && !p.blocked, + lastSeen: l && l.latestHandshake + ? new Date(l.latestHandshake * 1000).toISOString() + : null, + rx: l ? l.rx : 0, + tx: l ? l.tx : 0, + blocked: !!p.blocked, + createdAt: p.createdAt, + }; + }); + }); + }, + + /** People projected from the peer store (WG has no control-plane users). */ + async listPeople() { + return withHub(async () => { + const store = loadPeers(); + const byPerson = new Map(); + for (const p of store.peers) { + const cur = byPerson.get(p.personId); + if (!cur) { + byPerson.set(p.personId, { + id: p.personId, + displayName: p.name || null, + email: null, + createdAt: p.createdAt, + deviceCount: 1, + }); + } else { + cur.deviceCount += 1; + if (!cur.displayName && p.name) cur.displayName = p.name; + if (p.createdAt && (!cur.createdAt || p.createdAt < cur.createdAt)) { + cur.createdAt = p.createdAt; + } + } + } + return [...byPerson.values()].map((person) => { + delete person.deviceCount; + return person; + }); + }); + }, + + /** + * Register a device public key (browser-generated, spec §8.2 — the + * server never sees a client private key). Allocates the deterministic + * lowest-free /32, appends a [Peer], applies the conf. + */ + async addPeer({ personId, deviceId, publicKey, name } = {}) { + if (!personId || typeof personId !== 'string') { + throw new ProviderError('addPeer: personId is required', { code: 'bad_input' }); + } + if (!deviceId || typeof deviceId !== 'string') { + throw new ProviderError('addPeer: deviceId is required', { code: 'bad_input' }); + } + if (!publicKey || typeof publicKey !== 'string' || !WG_KEY_RE.test(publicKey)) { + throw new ProviderError('addPeer: publicKey must be a WireGuard public key (44-char base64)', { + code: 'bad_input', + }); + } + return withHub(() => + mutatePeers(async (store) => { + if (store.peers.some((p) => p.publicKey === publicKey)) { + throw new ProviderError('addPeer: publicKey already registered', { + code: 'bad_input', + retryable: false, + }); + } + const address = allocateAddress(store); + const record = { + personId, + deviceId, + publicKey, + address, + name: name || null, + createdAt: new Date(nowFn()).toISOString(), + blocked: false, + }; + store.peers.push(record); + await applyConf(store); + return { + deviceId, + publicKey, + address, + allowedIps: '10.53.0.0/16', // spec §8.1: split tunnel, this CIDR only + dns: bareAddress(HUB_ADDRESS), // spec §8.3 + hubPublicKey: hubPublicKey, + hubAddress: HUB_ADDRESS, + listenPort, + }; + }) + ); + }, + + /** Remove one peer by public key (revocation, spec §8.2). */ + async removePeer(publicKey) { + if (!publicKey || typeof publicKey !== 'string') { + throw new ProviderError('removePeer: publicKey is required', { code: 'bad_input' }); + } + return withHub(() => + mutatePeers(async (store) => { + const idx = store.peers.findIndex((p) => p.publicKey === publicKey); + if (idx === -1) { + throw new ProviderError('removePeer: unknown publicKey', { + code: 'not_found', + retryable: false, + }); + } + const [removed] = store.peers.splice(idx, 1); + await applyConf(store); + return { removed: publicKey, address: removed.address }; + }) + ); + }, + + /** Remove every peer owned by a person, keeping records (for unblock). */ + async removePeersForPerson(personId) { + if (!personId) { + throw new ProviderError('removePeersForPerson: personId is required', { code: 'bad_input' }); + } + return withHub(() => + mutatePeers(async (store) => { + const owned = store.peers.filter((p) => p.personId === personId && !p.blocked); + if (!owned.length) return { removed: [], count: 0 }; + for (const p of owned) p.blocked = true; + await applyConf(store); + return { removed: owned.map((p) => p.publicKey), count: owned.length }; + }) + ); + }, + + /** + * Reversible block: drop the person's peers from the hub, keep the + * records so unblock can restore them exactly (addresses included). + */ + async blockPerson(userId) { + if (!userId) { + throw new ProviderError('blockPerson: userId is required', { code: 'bad_input' }); + } + const res = await provider.removePeersForPerson(userId); + return { blocked: true, removed: res.removed, count: res.count }; + }, + + /** + * Unblock: re-add every stored peer for the person — WireGuard restores + * immediately (spec §8.2: no re-auth dance, the hub just accepts the + * still-valid pubkey again). + */ + async unblockPerson(userId) { + if (!userId) { + throw new ProviderError('unblockPerson: userId is required', { code: 'bad_input' }); + } + // WireGuard restores immediately (spec §8.2): the still-valid pubkeys + // are simply accepted by the hub again — no re-auth required. + return withHub(() => + mutatePeers(async (store) => { + const owned = store.peers.filter((p) => p.personId === userId && p.blocked); + if (!owned.length) return { restored: [], count: 0, reauthRequired: false }; + for (const p of owned) p.blocked = false; + await applyConf(store); + return { restored: owned.map((p) => p.publicKey), count: owned.length, reauthRequired: false }; + }) + ); + }, + + /** Permanent removal: delete peer records + hub entries. */ + async removePerson(userId) { + if (!userId) { + throw new ProviderError('removePerson: userId is required', { code: 'bad_input' }); + } + return withHub(() => + mutatePeers(async (store) => { + const before = store.peers.length; + store.peers = store.peers.filter((p) => p.personId !== userId); + const deleted = before - store.peers.length; + if (deleted > 0) await applyConf(store); + return { deleted, count: deleted }; + }) + ); + }, + + /** Kick one device (by deviceId), keep the person's other devices. */ + async revokeDevice(deviceId) { + if (!deviceId) { + throw new ProviderError('revokeDevice: deviceId is required', { code: 'bad_input' }); + } + return withHub(() => + mutatePeers(async (store) => { + const peer = store.peers.find((p) => p.deviceId === deviceId); + if (!peer) { + throw new ProviderError('revokeDevice: unknown deviceId', { + code: 'not_found', + retryable: false, + }); + } + store.peers = store.peers.filter((p) => p.deviceId !== deviceId); + await applyConf(store); + return { revoked: deviceId, publicKey: peer.publicKey }; + }) + ); + }, + + /** + * nftables firewall compiler lands with DC-151 — not a silent no-op. + */ + async syncAccess() { + await Promise.reject( + new ProviderError('syncAccess lands with the nftables firewall compiler (DC-151)', { + code: 'not_implemented', + retryable: false, + }) + ); + }, + + /** + * Who owns the tunnel address? Peer-store lookup — forward_auth runs on + * the same host, so no live wg call is needed. Unknown → null (identity + * unknown is a legitimate answer, not an error). + */ + async whois(ip) { + if (!ip || typeof ip !== 'string') { + throw new ProviderError('whois: ip is required', { code: 'bad_input' }); + } + const bare = bareAddress(ip); + const store = loadPeers(); + const peer = store.peers.find((p) => bareAddress(p.address) === bare) || null; + if (!peer) return null; + return { + personId: peer.personId, + deviceId: peer.deviceId, + publicKey: peer.publicKey, + name: peer.name || null, + blocked: !!peer.blocked, + }; + }, + + /** + * Split-DNS is NOT a provider capability in WG mode (capability flag + * splitDnsApi=false): Technitium on the hub serves the zone directly + * (spec §8.3). Callers must check capabilities() first; this throws + * for those that don't. + */ + async configureDns() { + await Promise.reject( + new ProviderError( + 'wireguard: configureDns is unsupported — Technitium serves the private zone on 10.53.0.1 directly (spec §8.3)', + { code: 'unsupported', retryable: false } + ) + ); + }, + + /** + * Mint a one-time invite token (7d TTL, single-use, spec §8.5). The + * redemption page does browser keygen; this only mints the link. + * @returns {{ inviteUrl: string, id: string }} + */ + async createInvite({ role, email } = {}) { + if (!role || typeof role !== 'string') { + throw new ProviderError('createInvite: role is required', { code: 'bad_input' }); + } + if (email !== undefined && (typeof email !== 'string' || !email.includes('@'))) { + throw new ProviderError('createInvite: email must be a valid address when provided', { + code: 'bad_input', + }); + } + const token = crypto.randomBytes(24).toString('base64url'); + const id = 'wgi_' + crypto.randomBytes(8).toString('hex'); + const t = nowFn(); + const record = { + id, + token, + role, + email: email || null, + createdAt: new Date(t).toISOString(), + expiresAt: t + INVITE_TTL_MS, + usedAt: null, + }; + const store = loadInvites(); + pruneExpired(store); + store.invites.push(record); + saveInvites(store); + return { inviteUrl: '/network/wg/redeem/' + token, id }; + }, + + /** Revoke an invite by id (invalidates the token). */ + async revokeInvite(id) { + if (!id) { + throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' }); + } + const store = loadInvites(); + const rec = inviteRecord(store, id); + if (!rec) { + throw new ProviderError('revokeInvite: unknown invite id', { + code: 'not_found', + retryable: false, + }); + } + store.invites = store.invites.filter((i) => i.id !== id); + saveInvites(store); + return { revoked: true }; + }, + + /** + * Redeem a one-time token (called by the redemption route, DC-150s): + * single-use, 7-day TTL. Marks the token used on success. + * @returns {{ role: string, email: string|null }} invite payload + */ + async redeemInvite(token) { + if (!token || typeof token !== 'string') { + throw new ProviderError('redeemInvite: token is required', { code: 'bad_input' }); + } + const store = loadInvites(); + const rec = store.invites.find((i) => i.token === token) || null; + if (!rec) { + throw new ProviderError('redeemInvite: unknown or already-used token', { + code: 'not_found', + retryable: false, + }); + } + if (rec.usedAt) { + throw new ProviderError('redeemInvite: token already used', { + code: 'bad_input', + retryable: false, + }); + } + if (nowFn() > rec.expiresAt) { + store.invites = store.invites.filter((i) => i.token !== token); + saveInvites(store); + throw new ProviderError('redeemInvite: token expired', { + code: 'not_found', + retryable: false, + }); + } + rec.usedAt = new Date(nowFn()).toISOString(); + saveInvites(store); + return { role: rec.role, email: rec.email }; + }, + + /* --- hub lifecycle (beyond the contract; used by setup/admin) --- */ + + /** Idempotent bootstrap — see ensureHub above. */ + ensureHub, + + async startHub() { + return withHub(async () => { + await execFn('wg-quick', ['up', INTERFACE_NAME]); + return { started: INTERFACE_NAME }; + }); + }, + + async stopHub() { + try { + await execFn('wg-quick', ['down', INTERFACE_NAME]); + } catch (err) { + if (!isNotUpError(err)) throw toProviderError(err); + } + return { stopped: INTERFACE_NAME }; + }, + }; + + return provider; +} + +module.exports = { createWireGuardProvider, toProviderError };