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