merge dc/DC-149 into p7-wave1
This commit is contained in:
5 files changed
+1517
-8
No files matched your search
@@ -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