merge dc/DC-149 into p7-wave1

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

No files matched your search

+2
View File
@@ -14,8 +14,10 @@
const registry = require('./registry');
const { createTailscaleProvider } = require('./providers/tailscale');
const { createWireGuardProvider } = require('./providers/wireguard');
registry.registerProvider('tailscale', createTailscaleProvider);
registry.registerProvider('wireguard', createWireGuardProvider);
module.exports = {
registry,
@@ -259,14 +259,17 @@ function createTailscaleProvider({
},
/**
* Unblock is a no-op at the Tailscale level: key expiry is reversed by
* the user re-authenticating, and access restoration happens when
* DashCaddy re-syncs the ACL (syncAccess). Kept in the contract so the
* orchestration layer (DC-143) can call it uniformly across providers.
* Unblock is honest, not fully reversible (corrected spec): key expiry
* cannot be undone — there is no Tailscale API to un-expire a device
* key. This method re-allows the person at the ACL level only (the ACL
* compiler re-push on unblock drops the network deny); the user must
* RE-AUTHENTICATE each device for it to rejoin the tailnet. Kept in the
* contract so the orchestration layer (DC-143) can call it uniformly.
* @returns {{ restored: boolean, reauthRequired: boolean }}
*/
async unblockPerson() {
await Promise.resolve(); // uniform async surface (contract: all async)
return { restored: true };
return { restored: true, reauthRequired: true };
},
/**
@@ -0,0 +1,935 @@
/**
* WireGuard hub provider for the DashCaddy Private Network (DC-149,
* specs/PRIVATE-NETWORK-SPEC.md §3 + §8).
*
* Self-hosted hub-and-spoke: DashCaddy manages a `wg-quick` interface
* `dcnet0` on the host (10.53.0.1/16, spec §8.1). Client keypairs are
* generated in the invitee's BROWSER (spec §8.2) — the server only ever
* sees public keys; this provider NEVER generates client private keys.
* The only private key on the host is the HUB keypair, generated once
* with `wg genkey` and stored inside /etc/wireguard/dcnet0.conf (spec §8.4).
*
* Persistence (all atomic tmp+rename writes, like the other stores):
* - /etc/wireguard/dcnet0.conf — hub config + one [Peer] per device
* - data/network-wg-peers.json — { peers: [{ personId, deviceId,
* publicKey, address (10.53.x.y/32), name, createdAt, blocked? }] }
* - data/network-wg-invites.json — one-time tokens (7d TTL, single-use,
* spec §8.5)
*
* Address pool (spec §8.1): 10.53.0.0/16, one /32 per device, hub owns
* 10.53.0.1. Allocation is the deterministic lowest-free /32 derived from
* the peer store — never a counter — so it is stable across restarts and
* reuses freed addresses.
*
* Host requirements, checked lazily at first use and degrading cleanly
* with ProviderError code='dependency_missing': `wg`, `wg-quick`, and
* root (or CAP_NET_ADMIN). Conf changes apply by regenerating the conf
* and running `wg-quick down` + `wg-quick up` (tolerating "not up");
* the `wg syncconf dcnet0 <(wg-quick strip dcnet0)` shell form is
* deliberately NOT used (no shell interpolation of interface state).
*
* Every child process and every filesystem touch is injectable for tests:
* createWireGuardProvider({ execFileImpl, fsImpl, dataDir, confPath,
* unitPath, listenPort, getuid, now })
*/
'use strict';
const path = require('path');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { ProviderError } = require('../errors');
const INTERFACE_NAME = 'dcnet0';
const HUB_ADDRESS = '10.53.0.1/16';
const HUB_HOST_INT = 1; // 10.53.0.1 — excluded from the peer pool
const POOL_MAX = 0xffff; // 10.53.255.255 — last usable pool host int
const DEFAULT_LISTEN_PORT = 51820;
const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // spec §8.5: 7-day TTL
const HANDSHAKE_FRESH_MS = 3 * 60 * 1000; // peer "online" window
const WG_KEY_RE = /^[A-Za-z0-9+/]{43}=$/; // wg keys: 43 data chars + base64 pad
const DEFAULT_CONF_PATH = '/etc/wireguard/dcnet0.conf';
const DEFAULT_UNIT_PATH = '/etc/systemd/system/dcnet-hub.service';
/** Monotonic counter for unique atomic-write tmp names. */
let tmpCounter = 0;
/**
* Default child-process runner: promisified execFile that also supports
* feeding stdin (`opts.input`) — needed for `wg pubkey`, which reads the
* private key from stdin (never argv, where it would leak to ps).
*/
function execFileDefault(bin, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = execFile(
bin,
args,
{ timeout: opts.timeout || 10000, maxBuffer: 1024 * 1024 },
(err, stdout, stderr) => {
if (err) {
if (stdout !== undefined) err.stdout = stdout;
if (stderr !== undefined) err.stderr = stderr;
reject(err);
} else {
resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
}
}
);
if (opts.input !== undefined && child.stdin) {
child.stdin.write(opts.input);
child.stdin.end();
}
});
}
/** Map any thrown value onto the shared ProviderError vocabulary. */
function toProviderError(err) {
if (err instanceof ProviderError) return err;
if (err && err.code === 'ENOENT') {
return new ProviderError('wireguard: required binary not found: ' + String(err.message), {
code: 'dependency_missing',
retryable: false,
cause: err,
});
}
const message = err && err.message ? err.message : String(err);
return new ProviderError('wireguard: ' + message, {
code: 'provider_error',
retryable: true,
cause: err,
});
}
/** Pool host int (10.53.x.y → x*256+y) ↔ dotted quad. */
function poolIntToAddress(n) {
return '10.53.' + ((n >> 8) & 0xff) + '.' + (n & 0xff);
}
function addressToPoolInt(addr) {
const m = /^10\.53\.(\d+)\.(\d+)(\/\d+)?$/.exec(String(addr || ''));
if (!m) return null;
return (parseInt(m[1], 10) << 8) | parseInt(m[2], 10);
}
/** Strip a /32 (or any) suffix from an address for comparisons. */
function bareAddress(addr) {
return String(addr || '').replace(/\/\d+$/, '');
}
/**
* Build the WireGuard NetworkProvider.
*
* @param {object} [deps] - all injectable, all optional (hermetic tests)
* @param {Function} [deps.execFileImpl] - async (bin, args, opts) runner
* @param {object} [deps.fsImpl] - fs surface used by this provider
* @param {string} [deps.dataDir] - store dir (default: platform dataDir)
* @param {string} [deps.confPath] - hub conf path
* @param {string} [deps.unitPath] - systemd unit path
* @param {number} [deps.listenPort] - UDP listen port (default 51820)
* @param {Function} [deps.getuid] - uid probe (root/CAP_NET_ADMIN check)
* @param {Function} [deps.now] - epoch-ms clock (invite TTL tests)
*/
function createWireGuardProvider({
execFileImpl,
fsImpl,
dataDir,
confPath = DEFAULT_CONF_PATH,
unitPath = DEFAULT_UNIT_PATH,
listenPort = DEFAULT_LISTEN_PORT,
getuid,
now,
} = {}) {
const execFn = execFileImpl || execFileDefault;
const fs = fsImpl || require('fs'); // eslint-disable-line no-shadow
const dataDirResolved =
dataDir !== undefined ? dataDir : require('../../../platform-paths').dataDir;
const peersPath = path.join(dataDirResolved, 'network-wg-peers.json');
const invitesPath = path.join(dataDirResolved, 'network-wg-invites.json');
const uidFn = getuid || (() => (process.getuid ? process.getuid() : 0));
const nowFn = now || Date.now;
// Hub key material, loaded/generated once per provider instance.
let hubPrivateKey = null;
let hubPublicKey = null;
let depsReady = null; // cached dependency check
let writeQueue = Promise.resolve(); // serialized store mutations
/* ---------------------------------------------------------------- *
* filesystem helpers (all through the injected fs surface) *
* ---------------------------------------------------------------- */
function atomicWrite(filePath, contents, mode) {
tmpCounter += 1;
const tmp = filePath + '.tmp-' + process.pid + '-' + tmpCounter;
fs.writeFileSync(tmp, contents, mode !== undefined ? { mode } : undefined);
fs.renameSync(tmp, filePath);
}
function readJson(filePath, fallback) {
let raw;
try {
raw = fs.readFileSync(filePath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return fallback;
throw err;
}
try {
return JSON.parse(raw);
} catch (err) {
// Corrupt store: move aside (best-effort) and restart empty — same
// recovery posture as the people store (DC-141).
try {
fs.renameSync(filePath, filePath + '.bak');
} catch (_) {
/* best-effort */
}
return fallback;
}
}
function writeJson(filePath, obj) {
atomicWrite(filePath, JSON.stringify(obj, null, 2) + '\n', 0o600);
}
function ensureDir(dir) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
/* ---------------------------------------------------------------- *
* peer store *
* ---------------------------------------------------------------- */
function loadPeers() {
const data = readJson(peersPath, { version: 1, peers: [] });
if (!data || !Array.isArray(data.peers)) return { version: 1, peers: [] };
return data;
}
function savePeers(store) {
writeJson(peersPath, store);
}
/** Serialized read-modify-write so concurrent mutations never interleave. */
function mutatePeers(fn) {
const run = writeQueue.then(() => {
const store = loadPeers();
const result = fn(store);
if (result !== false) savePeers(store);
return result;
});
writeQueue = run.catch(() => {});
return run;
}
/** Deterministic lowest-free /32 in 10.53.0.0/16, minus the hub address. */
function allocateAddress(store) {
const taken = new Set();
for (const p of store.peers) {
const n = addressToPoolInt(p.address);
if (n !== null) taken.add(n);
}
for (let n = HUB_HOST_INT + 1; n <= POOL_MAX; n += 1) {
if (!taken.has(n)) return poolIntToAddress(n) + '/32';
}
throw new ProviderError('wireguard: address pool 10.53.0.0/16 exhausted', {
code: 'provider_error',
retryable: false,
});
}
/* ---------------------------------------------------------------- *
* hub config *
* ---------------------------------------------------------------- */
function renderConf(store) {
let out =
'[Interface]\n' +
'PrivateKey = ' + hubPrivateKey + '\n' +
'Address = ' + HUB_ADDRESS + '\n' +
'ListenPort = ' + listenPort + '\n';
const active = store.peers.filter((p) => !p.blocked);
if (active.length) out += '\n';
for (const p of active) {
const label = (p.name || p.deviceId || '').replace(/[\r\n]/g, ' ');
if (label) out += '# ' + label + '\n';
out += '[Peer]\nPublicKey = ' + p.publicKey + '\nAllowedIPs = ' + p.address + '\n';
}
return out;
}
/** Parse the hub PrivateKey back out of an existing conf (restart path). */
function readConfPrivateKey() {
let raw;
try {
raw = fs.readFileSync(confPath, 'utf8');
} catch (err) {
if (err && err.code === 'ENOENT') return null;
throw err;
}
const m = /^PrivateKey = (\S+)$/m.exec(raw);
return m ? m[1] : null;
}
/** True when a wg-quick failure just means the interface was not up. */
function isNotUpError(err) {
const text = ((err && (err.stderr || err.stdout)) || '') + ' ' + ((err && err.message) || '');
return /not exist|not up|no such|does not exist/i.test(text);
}
/** Bring dcnet0 down (tolerating "not up") then up with the new conf. */
async function applyConf(store) {
atomicWrite(confPath, renderConf(store), 0o600);
try {
await execFn('wg-quick', ['down', INTERFACE_NAME]);
} catch (err) {
if (!isNotUpError(err)) throw toProviderError(err); // unexpected → surface
}
try {
await execFn('wg-quick', ['up', INTERFACE_NAME]);
} catch (err) {
if (!/already exists|already up/i.test(String((err && err.message) || ''))) {
throw toProviderError(err);
}
}
}
async function interfaceIsUp() {
try {
await execFn('wg', ['show', INTERFACE_NAME, 'dump']);
return true;
} catch (_) {
return false;
}
}
/* ---------------------------------------------------------------- *
* dependency + hub bootstrap *
* ---------------------------------------------------------------- */
async function checkDependencies() {
try {
await execFn('wg', ['--version']);
} catch (err) {
if (err && err.code === 'ENOENT') {
throw new ProviderError('wireguard: `wg` binary not installed', {
code: 'dependency_missing',
retryable: false,
});
}
}
try {
await execFn('wg-quick', ['--help']); // exits non-zero with usage — ENOENT is the signal
} catch (err) {
if (err && err.code === 'ENOENT') {
throw new ProviderError('wireguard: `wg-quick` binary not installed', {
code: 'dependency_missing',
retryable: false,
});
}
}
if (uidFn() !== 0) {
throw new ProviderError(
'wireguard: hub management requires root or CAP_NET_ADMIN (wg-quick needs to create dcnet0)',
{ code: 'dependency_missing', retryable: false }
);
}
}
function ensureDeps() {
if (!depsReady) {
depsReady = checkDependencies().catch((err) => {
depsReady = null; // transient failures (e.g. spawn hiccups) re-check
throw toProviderError(err);
});
}
return depsReady;
}
/** Generate the hub keypair host-side ONCE (spec §8.4). */
async function ensureHubKey() {
if (hubPrivateKey) return;
const existing = readConfPrivateKey();
if (existing) {
hubPrivateKey = existing;
} else {
const gen = await execFn('wg', ['genkey']);
hubPrivateKey = String(gen.stdout || '').trim();
if (!WG_KEY_RE.test(hubPrivateKey)) {
throw new ProviderError('wireguard: `wg genkey` returned an invalid key', {
code: 'provider_error',
retryable: true,
});
}
}
const pub = await execFn('wg', ['pubkey'], { input: hubPrivateKey + '\n' });
hubPublicKey = String(pub.stdout || '').trim();
}
const SYSTEMD_UNIT = [
'[Unit]',
'Description=DashCaddy Private Network WireGuard hub (dcnet0)',
'After=network-online.target',
'Wants=network-online.target',
'',
'[Service]',
'Type=oneshot',
'RemainAfterExit=yes',
'ExecStart=/usr/bin/wg-quick up ' + INTERFACE_NAME,
'ExecStop=/usr/bin/wg-quick down ' + INTERFACE_NAME,
'',
'[Install]',
'WantedBy=multi-user.target',
'',
].join('\n');
/**
* Idempotent hub bootstrap: keypair, conf, systemd unit, ip_forward,
* interface up. Safe to call repeatedly (spec §8.4).
*/
async function ensureHub() {
await ensureDeps();
ensureDir(path.dirname(confPath));
await ensureHubKey();
ensureDir(dataDirResolved);
if (!fs.existsSync(confPath)) {
atomicWrite(confPath, renderConf(loadPeers()), 0o600);
}
if (!fs.existsSync(unitPath)) {
atomicWrite(unitPath, SYSTEMD_UNIT, 0o644);
}
// Hub-and-spoke peer→peer traffic needs forwarding (spec §8.1). Failure
// (e.g. read-only /proc in a container) degrades to a warning, not a crash.
let ipForward = true;
try {
await execFn('sysctl', ['-w', 'net.ipv4.ip_forward=1']);
} catch (_) {
ipForward = false;
}
if (!(await interfaceIsUp())) {
try {
await execFn('wg-quick', ['up', INTERFACE_NAME]);
} catch (err) {
if (!/already exists|already up/i.test(String((err && err.message) || ''))) {
throw toProviderError(err);
}
}
}
const up = true;
return { interface: INTERFACE_NAME, address: HUB_ADDRESS, listenPort, up, ipForward };
}
/** Run fn after deps+hub are ready; normalize every failure. */
async function withHub(fn) {
try {
await ensureHub();
return await fn();
} catch (err) {
throw toProviderError(err);
}
}
/* ---------------------------------------------------------------- *
* wg state parsing *
* ---------------------------------------------------------------- */
/**
* `wg show dcnet0 dump` → { interface, peersByPubkey }.
* Interface row: private-key, public-key, listen-port, fwmark.
* Peer rows: public-key, preshared-key, allowed-ips, endpoint,
* latest-handshake (epoch s), rx, tx, persistent-keepalive.
*/
async function wgDump() {
const res = await execFn('wg', ['show', INTERFACE_NAME, 'dump']);
const lines = String(res.stdout || '').split('\n').filter((l) => l.trim());
const iface = { up: true };
const peersByPubkey = {};
lines.forEach((line, idx) => {
const cols = line.split('\t');
if (idx === 0) {
// first row is always the interface row in `dump` format:
// private-key, public-key, listen-port, fwmark
iface.publicKey = !cols[1] || cols[1] === '(none)' ? null : cols[1];
iface.listenPort = parseInt(cols[2], 10) || null;
return;
}
if (cols.length >= 7) {
const handshake = parseInt(cols[4], 10) || 0;
peersByPubkey[cols[0]] = {
publicKey: cols[0],
allowedIps: cols[2],
endpoint: cols[3] === '(none)' ? null : cols[3],
latestHandshake: handshake > 0 ? handshake : null,
rx: parseInt(cols[5], 10) || 0,
tx: parseInt(cols[6], 10) || 0,
};
}
});
return { iface, peersByPubkey };
}
/* ---------------------------------------------------------------- *
* invite store (spec §8.5) *
* ---------------------------------------------------------------- */
function loadInvites() {
const data = readJson(invitesPath, { version: 1, invites: [] });
if (!data || !Array.isArray(data.invites)) return { version: 1, invites: [] };
return data;
}
function saveInvites(store) {
writeJson(invitesPath, store);
}
function pruneExpired(store) {
const t = nowFn();
store.invites = store.invites.filter(
(i) => !i.usedAt && i.expiresAt > t
);
}
function inviteRecord(store, id) {
return store.invites.find((i) => i.id === id) || null;
}
/* ---------------------------------------------------------------- *
* provider contract surface *
* ---------------------------------------------------------------- */
const provider = {
id() {
return 'wireguard';
},
capabilities() {
return {
invites: true, // one-time tokens minted locally (§8.5)
splitDnsApi: false, // Technitium is written directly, not via provider API (§8.3)
perPeerFirewall: true, // nftables matrix (DC-150/151)
selfHosted: true, // no outside control plane
};
},
/** Hub health: deps, iface up/down, per-peer handshakes. */
async getStatus() {
return withHub(async () => {
let up = await interfaceIsUp();
let dump = { iface: {}, peersByPubkey: {} };
if (up) dump = await wgDump();
const store = loadPeers();
const nowMs = nowFn();
const peers = store.peers
.filter((p) => !p.blocked)
.map((p) => {
const live = dump.peersByPubkey[p.publicKey] || null;
const fresh = !!(live && live.latestHandshake && nowMs - live.latestHandshake * 1000 < HANDSHAKE_FRESH_MS);
return {
publicKey: p.publicKey,
address: p.address,
name: p.name || null,
personId: p.personId,
deviceId: p.deviceId,
online: fresh,
latestHandshake: live ? live.latestHandshake : null,
rx: live ? live.rx : 0,
tx: live ? live.tx : 0,
};
});
return {
provider: 'wireguard',
installed: true,
connected: up,
hubUp: up,
interface: INTERFACE_NAME,
address: HUB_ADDRESS,
listenPort,
hubPublicKey: hubPublicKey,
peerCount: peers.length,
blockedPeerCount: store.peers.filter((p) => p.blocked).length,
peers,
};
});
},
/** Peer store merged with live `wg show` state. */
async listDevices() {
return withHub(async () => {
const [store, up] = [loadPeers(), await interfaceIsUp()];
let live = {};
if (up) live = (await wgDump()).peersByPubkey;
const nowMs = nowFn();
return store.peers.map((p) => {
const l = live[p.publicKey] || null;
const fresh = !!(l && l.latestHandshake && nowMs - l.latestHandshake * 1000 < HANDSHAKE_FRESH_MS);
return {
id: p.deviceId,
deviceId: p.deviceId,
name: p.name || null,
ip: bareAddress(p.address),
publicKey: p.publicKey,
userId: p.personId,
personId: p.personId,
online: fresh && !p.blocked,
lastSeen: l && l.latestHandshake
? new Date(l.latestHandshake * 1000).toISOString()
: null,
rx: l ? l.rx : 0,
tx: l ? l.tx : 0,
blocked: !!p.blocked,
createdAt: p.createdAt,
};
});
});
},
/** People projected from the peer store (WG has no control-plane users). */
async listPeople() {
return withHub(async () => {
const store = loadPeers();
const byPerson = new Map();
for (const p of store.peers) {
const cur = byPerson.get(p.personId);
if (!cur) {
byPerson.set(p.personId, {
id: p.personId,
displayName: p.name || null,
email: null,
createdAt: p.createdAt,
deviceCount: 1,
});
} else {
cur.deviceCount += 1;
if (!cur.displayName && p.name) cur.displayName = p.name;
if (p.createdAt && (!cur.createdAt || p.createdAt < cur.createdAt)) {
cur.createdAt = p.createdAt;
}
}
}
return [...byPerson.values()].map((person) => {
delete person.deviceCount;
return person;
});
});
},
/**
* Register a device public key (browser-generated, spec §8.2 — the
* server never sees a client private key). Allocates the deterministic
* lowest-free /32, appends a [Peer], applies the conf.
*/
async addPeer({ personId, deviceId, publicKey, name } = {}) {
if (!personId || typeof personId !== 'string') {
throw new ProviderError('addPeer: personId is required', { code: 'bad_input' });
}
if (!deviceId || typeof deviceId !== 'string') {
throw new ProviderError('addPeer: deviceId is required', { code: 'bad_input' });
}
if (!publicKey || typeof publicKey !== 'string' || !WG_KEY_RE.test(publicKey)) {
throw new ProviderError('addPeer: publicKey must be a WireGuard public key (44-char base64)', {
code: 'bad_input',
});
}
return withHub(() =>
mutatePeers(async (store) => {
if (store.peers.some((p) => p.publicKey === publicKey)) {
throw new ProviderError('addPeer: publicKey already registered', {
code: 'bad_input',
retryable: false,
});
}
const address = allocateAddress(store);
const record = {
personId,
deviceId,
publicKey,
address,
name: name || null,
createdAt: new Date(nowFn()).toISOString(),
blocked: false,
};
store.peers.push(record);
await applyConf(store);
return {
deviceId,
publicKey,
address,
allowedIps: '10.53.0.0/16', // spec §8.1: split tunnel, this CIDR only
dns: bareAddress(HUB_ADDRESS), // spec §8.3
hubPublicKey: hubPublicKey,
hubAddress: HUB_ADDRESS,
listenPort,
};
})
);
},
/** Remove one peer by public key (revocation, spec §8.2). */
async removePeer(publicKey) {
if (!publicKey || typeof publicKey !== 'string') {
throw new ProviderError('removePeer: publicKey is required', { code: 'bad_input' });
}
return withHub(() =>
mutatePeers(async (store) => {
const idx = store.peers.findIndex((p) => p.publicKey === publicKey);
if (idx === -1) {
throw new ProviderError('removePeer: unknown publicKey', {
code: 'not_found',
retryable: false,
});
}
const [removed] = store.peers.splice(idx, 1);
await applyConf(store);
return { removed: publicKey, address: removed.address };
})
);
},
/** Remove every peer owned by a person, keeping records (for unblock). */
async removePeersForPerson(personId) {
if (!personId) {
throw new ProviderError('removePeersForPerson: personId is required', { code: 'bad_input' });
}
return withHub(() =>
mutatePeers(async (store) => {
const owned = store.peers.filter((p) => p.personId === personId && !p.blocked);
if (!owned.length) return { removed: [], count: 0 };
for (const p of owned) p.blocked = true;
await applyConf(store);
return { removed: owned.map((p) => p.publicKey), count: owned.length };
})
);
},
/**
* Reversible block: drop the person's peers from the hub, keep the
* records so unblock can restore them exactly (addresses included).
*/
async blockPerson(userId) {
if (!userId) {
throw new ProviderError('blockPerson: userId is required', { code: 'bad_input' });
}
const res = await provider.removePeersForPerson(userId);
return { blocked: true, removed: res.removed, count: res.count };
},
/**
* Unblock: re-add every stored peer for the person — WireGuard restores
* immediately (spec §8.2: no re-auth dance, the hub just accepts the
* still-valid pubkey again).
*/
async unblockPerson(userId) {
if (!userId) {
throw new ProviderError('unblockPerson: userId is required', { code: 'bad_input' });
}
// WireGuard restores immediately (spec §8.2): the still-valid pubkeys
// are simply accepted by the hub again — no re-auth required.
return withHub(() =>
mutatePeers(async (store) => {
const owned = store.peers.filter((p) => p.personId === userId && p.blocked);
if (!owned.length) return { restored: [], count: 0, reauthRequired: false };
for (const p of owned) p.blocked = false;
await applyConf(store);
return { restored: owned.map((p) => p.publicKey), count: owned.length, reauthRequired: false };
})
);
},
/** Permanent removal: delete peer records + hub entries. */
async removePerson(userId) {
if (!userId) {
throw new ProviderError('removePerson: userId is required', { code: 'bad_input' });
}
return withHub(() =>
mutatePeers(async (store) => {
const before = store.peers.length;
store.peers = store.peers.filter((p) => p.personId !== userId);
const deleted = before - store.peers.length;
if (deleted > 0) await applyConf(store);
return { deleted, count: deleted };
})
);
},
/** Kick one device (by deviceId), keep the person's other devices. */
async revokeDevice(deviceId) {
if (!deviceId) {
throw new ProviderError('revokeDevice: deviceId is required', { code: 'bad_input' });
}
return withHub(() =>
mutatePeers(async (store) => {
const peer = store.peers.find((p) => p.deviceId === deviceId);
if (!peer) {
throw new ProviderError('revokeDevice: unknown deviceId', {
code: 'not_found',
retryable: false,
});
}
store.peers = store.peers.filter((p) => p.deviceId !== deviceId);
await applyConf(store);
return { revoked: deviceId, publicKey: peer.publicKey };
})
);
},
/**
* nftables firewall compiler lands with DC-151 — not a silent no-op.
*/
async syncAccess() {
await Promise.reject(
new ProviderError('syncAccess lands with the nftables firewall compiler (DC-151)', {
code: 'not_implemented',
retryable: false,
})
);
},
/**
* Who owns the tunnel address? Peer-store lookup — forward_auth runs on
* the same host, so no live wg call is needed. Unknown → null (identity
* unknown is a legitimate answer, not an error).
*/
async whois(ip) {
if (!ip || typeof ip !== 'string') {
throw new ProviderError('whois: ip is required', { code: 'bad_input' });
}
const bare = bareAddress(ip);
const store = loadPeers();
const peer = store.peers.find((p) => bareAddress(p.address) === bare) || null;
if (!peer) return null;
return {
personId: peer.personId,
deviceId: peer.deviceId,
publicKey: peer.publicKey,
name: peer.name || null,
blocked: !!peer.blocked,
};
},
/**
* Split-DNS is NOT a provider capability in WG mode (capability flag
* splitDnsApi=false): Technitium on the hub serves the zone directly
* (spec §8.3). Callers must check capabilities() first; this throws
* for those that don't.
*/
async configureDns() {
await Promise.reject(
new ProviderError(
'wireguard: configureDns is unsupported — Technitium serves the private zone on 10.53.0.1 directly (spec §8.3)',
{ code: 'unsupported', retryable: false }
)
);
},
/**
* Mint a one-time invite token (7d TTL, single-use, spec §8.5). The
* redemption page does browser keygen; this only mints the link.
* @returns {{ inviteUrl: string, id: string }}
*/
async createInvite({ role, email } = {}) {
if (!role || typeof role !== 'string') {
throw new ProviderError('createInvite: role is required', { code: 'bad_input' });
}
if (email !== undefined && (typeof email !== 'string' || !email.includes('@'))) {
throw new ProviderError('createInvite: email must be a valid address when provided', {
code: 'bad_input',
});
}
const token = crypto.randomBytes(24).toString('base64url');
const id = 'wgi_' + crypto.randomBytes(8).toString('hex');
const t = nowFn();
const record = {
id,
token,
role,
email: email || null,
createdAt: new Date(t).toISOString(),
expiresAt: t + INVITE_TTL_MS,
usedAt: null,
};
const store = loadInvites();
pruneExpired(store);
store.invites.push(record);
saveInvites(store);
return { inviteUrl: '/network/wg/redeem/' + token, id };
},
/** Revoke an invite by id (invalidates the token). */
async revokeInvite(id) {
if (!id) {
throw new ProviderError('revokeInvite: id is required', { code: 'bad_input' });
}
const store = loadInvites();
const rec = inviteRecord(store, id);
if (!rec) {
throw new ProviderError('revokeInvite: unknown invite id', {
code: 'not_found',
retryable: false,
});
}
store.invites = store.invites.filter((i) => i.id !== id);
saveInvites(store);
return { revoked: true };
},
/**
* Redeem a one-time token (called by the redemption route, DC-150s):
* single-use, 7-day TTL. Marks the token used on success.
* @returns {{ role: string, email: string|null }} invite payload
*/
async redeemInvite(token) {
if (!token || typeof token !== 'string') {
throw new ProviderError('redeemInvite: token is required', { code: 'bad_input' });
}
const store = loadInvites();
const rec = store.invites.find((i) => i.token === token) || null;
if (!rec) {
throw new ProviderError('redeemInvite: unknown or already-used token', {
code: 'not_found',
retryable: false,
});
}
if (rec.usedAt) {
throw new ProviderError('redeemInvite: token already used', {
code: 'bad_input',
retryable: false,
});
}
if (nowFn() > rec.expiresAt) {
store.invites = store.invites.filter((i) => i.token !== token);
saveInvites(store);
throw new ProviderError('redeemInvite: token expired', {
code: 'not_found',
retryable: false,
});
}
rec.usedAt = new Date(nowFn()).toISOString();
saveInvites(store);
return { role: rec.role, email: rec.email };
},
/* --- hub lifecycle (beyond the contract; used by setup/admin) --- */
/** Idempotent bootstrap — see ensureHub above. */
ensureHub,
async startHub() {
return withHub(async () => {
await execFn('wg-quick', ['up', INTERFACE_NAME]);
return { started: INTERFACE_NAME };
});
},
async stopHub() {
try {
await execFn('wg-quick', ['down', INTERFACE_NAME]);
} catch (err) {
if (!isNotUpError(err)) throw toProviderError(err);
}
return { stopped: INTERFACE_NAME };
},
};
return provider;
}
module.exports = { createWireGuardProvider, toProviderError };