merge dc/DC-149 into p7-wave1

This commit is contained in:
Hermes committed 2026-10-03 21:33:37 -07:00
commit 151b42e0ea
5 files changed
+1517 -8

No files matched your search

@@ -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();
});
});
@@ -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<path, string> 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/<token>, 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();
});
});
+2
View File
@@ -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,
@@ -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 };
},
/**
@@ -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 };