DC-149: WireGuard hub provider (peer store, pool allocator, hub lifecycle) + tests
This commit is contained in:
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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
Reference in new issue
Block a user