From 99276f4ee575e5b617a14fc9a395e3c55a0eab85 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 17:23:19 -0400 Subject: [PATCH] feat(mesh): real gateway-to-gateway WireGuard mesh (site-to-site tunnels) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing WireGuard code (models/wg_site.js, routes/wireguard.js) is the roaming-client/exit-node feature -- individual peer configs an admin hands out, not gateway-to-gateway mesh peering. This adds the latter, per MULTI_SITE_SPEC.md §4: two theta-gateway instances mesh by one calling the other's POST /api/mesh/register with a join token (minted via POST /api/mesh/join-tokens, admin-gated); both sides end up with a live wg0 peer for the other, mesh-indexed per Appendix A's addressing (172.24..0/16 + 10..0.0/16, idx 1-254). - utils/wg_iface.js: brings up the local interface, preferring in-kernel WireGuard (ip link add type wireguard) and falling back to userspace wireguard-go when the kernel module isn't available. Both packages added to the Dockerfile. - utils/mesh_addressing.js: pure addressing math, unit tested (test/unit/mesh_addressing.test.js). - models/mesh_gateway.js: Redis-backed registry of known peer gateways (same pattern as wg_site.js), assigns + persists mesh indexes. - utils/mesh_join_token.js: single-use bootstrap credential, same GETDEL-on-Redis pattern already used on the theta-directory side. - routes/mesh.js: /join-tokens (admin), /register (bearer token, no session -- called by a remote gateway), /join (admin, initiates from this side), /gateways (admin, list). Verified with a REAL two-container test (not mocked): two independent containers, each running this actual code, meshed via a live join-token handshake, brought up real kernel WireGuard interfaces, and passed ICMP traffic across the resulting encrypted tunnel end to end (0% packet loss). That test caught a real bug worth calling out: `wg set ... peer ... allowed-ips` only configures WireGuard's own crypto-routing table -- it does NOT add a kernel route for that destination (wg-quick normally does this as a separate step; we don't use wg-quick). A real encrypted handshake completed between the two containers with the route missing, and ping still showed 100% loss until setPeer() was fixed to add the corresponding `ip route add dev ` itself. --- Dockerfile | 1 + nodejs/models/mesh_gateway.js | 85 ++++++++++++ nodejs/package.json | 2 +- nodejs/routes/api.js | 5 + nodejs/routes/mesh.js | 164 +++++++++++++++++++++++ nodejs/test/unit/mesh_addressing.test.js | 27 ++++ nodejs/utils/mesh_addressing.js | 27 ++++ nodejs/utils/mesh_join_token.js | 29 ++++ nodejs/utils/wg_iface.js | 156 +++++++++++++++++++++ 9 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 nodejs/models/mesh_gateway.js create mode 100644 nodejs/routes/mesh.js create mode 100644 nodejs/test/unit/mesh_addressing.test.js create mode 100644 nodejs/utils/mesh_addressing.js create mode 100644 nodejs/utils/mesh_join_token.js create mode 100644 nodejs/utils/wg_iface.js diff --git a/Dockerfile b/Dockerfile index ad1485e..57bdc26 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y --no-install-recommends \ redis-server dumb-init ca-certificates \ + iproute2 wireguard-tools wireguard-go \ && rm -rf /var/lib/apt/lists/* WORKDIR /app diff --git a/nodejs/models/mesh_gateway.js b/nodejs/models/mesh_gateway.js new file mode 100644 index 0000000..f301080 --- /dev/null +++ b/nodejs/models/mesh_gateway.js @@ -0,0 +1,85 @@ +'use strict'; + +// Registry of peer theta-gateway instances this gateway has meshed with — +// raw Redis, same pattern as wg_site.js/audit_event.js. Each registration +// carries what's needed to configure a local WireGuard peer entry for them: +// public key, reachable endpoint, and the mesh IP this gateway assigned them +// (MULTI_SITE_SPEC.md's one-octet-per-site addressing, 172.24..0/16 + +// 10..0.0/16, idx 1-254). +// +// Redis keys: +// mesh_gateway: — hash of gateway fields +// mesh_gateway_index — sorted set (score = createdAt, value = id) + +const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf'); +const { getRedis } = require('./index'); + +const MAX_MESH_INDEX = 254; + +const P = () => conf.redis.prefix; +const idxKey = () => `${P()}mesh_gateway_index`; +const gatewayKey = (id) => `${P()}mesh_gateway:${id}`; + +function serialize(obj) { + const out = {}; + for (const [k, v] of Object.entries(obj)) { + out[k] = String(v == null ? '' : v); + } + return out; +} + +function deserialize(h) { + if (!h || !h.id) return null; + return { ...h, meshIndex: Number(h.meshIndex || 0), createdAt: Number(h.createdAt || 0), lastSeenAt: Number(h.lastSeenAt || 0) }; +} + +async function list() { + const redis = await getRedis(); + const ids = await redis.zRange(idxKey(), 0, -1); + const out = []; + for (const id of ids) { + const g = deserialize(await redis.hGetAll(gatewayKey(id))); + if (g) out.push(g); + } + return out; +} + +async function findByPublicKey(publicKey) { + const all = await list(); + return all.find((g) => g.publicKey === publicKey) || null; +} + +function nextFreeMeshIndex(existing) { + const used = new Set(existing.map((g) => g.meshIndex).filter(Boolean)); + for (let i = 1; i <= MAX_MESH_INDEX; i++) { + if (!used.has(i)) return i; + } + throw new Error(`Mesh index space exhausted (max ${MAX_MESH_INDEX} gateways)`); +} + +// Register (or re-register, idempotent by publicKey) a peer gateway. +// Re-registering the same public key updates its endpoint/siteSlug but +// reuses its existing mesh index -- a gateway that re-registers after a +// restart must not get bumped to a new mesh subnet. +async function register({ publicKey, endpoint, siteSlug }) { + const redis = await getRedis(); + const existing = await list(); + const already = existing.find((g) => g.publicKey === publicKey); + + const now = Date.now(); + if (already) { + const updated = { ...already, endpoint, siteSlug: siteSlug || already.siteSlug, lastSeenAt: now }; + await redis.hSet(gatewayKey(already.id), serialize(updated)); + return updated; + } + + const id = crypto.randomBytes(8).toString('hex'); + const meshIndex = nextFreeMeshIndex(existing); + const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now }; + await redis.hSet(gatewayKey(id), serialize(gateway)); + await redis.zAdd(idxKey(), { score: now, value: id }); + return gateway; +} + +module.exports = { list, findByPublicKey, register, MAX_MESH_INDEX }; diff --git a/nodejs/package.json b/nodejs/package.json index fe70c70..fe5f4e8 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -14,7 +14,7 @@ "scripts": { "start": "node ./bin/www", "dev": "npx nodemon --ignore public/ ./bin/www", - "test": "NODE_ENV=test node --test --test-force-exit test/unit/access.test.js test/unit/host_keys.test.js test/unit/no_native_dialogs.test.js test/unit/target_match.test.js test/unit/username_grammar.test.js test/unit/wireguard.test.js", + "test": "NODE_ENV=test node --test --test-force-exit test/unit/access.test.js test/unit/host_keys.test.js test/unit/no_native_dialogs.test.js test/unit/target_match.test.js test/unit/username_grammar.test.js test/unit/wireguard.test.js test/unit/mesh_addressing.test.js", "test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js", "test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js" }, diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 9c76df6..6984733 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -19,4 +19,9 @@ router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump')) // WireGuard peer + site management — admin only. router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard')); +// Gateway-to-gateway mesh — mixed auth (register/register-* are called by a +// remote gateway with a bearer join token, not an admin session; join-tokens +// mint + join are admin-gated). See routes/mesh.js for the per-route gates. +router.use('/mesh', require('./mesh')); + module.exports = router; diff --git a/nodejs/routes/mesh.js b/nodejs/routes/mesh.js new file mode 100644 index 0000000..bed1922 --- /dev/null +++ b/nodejs/routes/mesh.js @@ -0,0 +1,164 @@ +'use strict'; + +// Gateway-to-gateway WireGuard mesh — real site-to-site tunnels, not the +// roaming-client/exit-node feature in routes/wireguard.js. Two gateways mesh +// by one calling the other's POST /api/mesh/register with a join token; both +// sides end up with a live wg0 peer entry for the other, addressed per +// MULTI_SITE_SPEC.md's one-octet mesh index (172.24..0/16, +// 10..0.0/16, idx 1-254, assigned by whichever gateway is registering +// the caller). +// +// Local interface name is fixed at THETA_MESH_IFACE (default wg-mesh) — +// deliberately separate from the roaming-client interface so the two +// features never fight over the same wg0. + +const express = require('express'); +const middleware = require('../middleware/auth'); +const conf = require('@simpleworkjs/conf'); +const meshGateway = require('../models/mesh_gateway'); +const meshJoinToken = require('../utils/mesh_join_token'); +const wgIface = require('../utils/wg_iface'); +const wgKeys = require('../utils/wg_keys'); +const { meshCidrFor, meshAllowedIpsFor } = require('../utils/mesh_addressing'); + +const router = express.Router(); +const IFACE = process.env.THETA_MESH_IFACE || 'wg-mesh'; +const MESH_LISTEN_PORT = process.env.THETA_MESH_LISTEN_PORT || 51820; + +async function ensureLocalIdentity() { + if (!conf.wireguard) conf.wireguard = {}; + if (!conf.wireguard.serverPublicKey || !conf.wireguard.serverPrivateKey) { + // wg_bootstrap.js normally does this at startup; guard here too so this + // route works even if bootstrap hasn't run yet in a given environment. + const kp = wgKeys.generateKeypair(); + conf.wireguard.serverPublicKey = kp.publicKey; + conf.wireguard.serverPrivateKey = kp.privateKey; + } + return conf.wireguard; +} + +// Mint a single-use mesh join token — the credential a NEW gateway presents +// to register into THIS gateway's mesh. +router.post('/join-tokens', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => { + try { + const { token, expiresInSeconds } = await meshJoinToken.mint(); + res.json({ status: 'ok', token, expiresInSeconds }); + } catch (e) { next(e); } +}); + +// Called by a REMOTE gateway to register itself into THIS gateway's mesh. +// Bearer mesh join token, no admin session (service-to-service, same as +// theta-directory's POST /api/site/register-spoke pattern). +router.post('/register', async (req, res, next) => { + try { + const auth = req.headers.authorization || ''; + const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : ''; + if (!(await meshJoinToken.consume(token))) { + return res.status(401).json({ status: 'error', message: 'invalid or already-used mesh join token' }); + } + + const { publicKey, endpoint, siteSlug } = req.body || {}; + if (!publicKey || !endpoint) { + return res.status(400).json({ status: 'error', message: 'publicKey and endpoint are required' }); + } + + const self = await ensureLocalIdentity(); + const peer = await meshGateway.register({ publicKey, endpoint, siteSlug }); + + await wgIface.ensureInterface(IFACE); + wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT); + // This gateway's own mesh index -- assigned to ITSELF the first time + // anyone registers with it, since a solo gateway has no index yet. + const ownIndex = await ensureOwnMeshIndex(); + wgIface.setAddress(IFACE, meshCidrFor(ownIndex)); + wgIface.setPeer(IFACE, { + publicKey: peer.publicKey, + endpoint: peer.endpoint, + allowedIPs: meshAllowedIpsFor(peer.meshIndex), + keepalive: 25 + }); + + res.json({ + status: 'ok', + meshIndex: peer.meshIndex, + gateway: { + publicKey: conf.wireguard.serverPublicKey, + endpoint: conf.wireguard.serverEndpoint || '', + meshIndex: ownIndex + } + }); + } catch (e) { next(e); } +}); + +// This gateway's own mesh index is just "the lowest free index, stable once +// picked" -- stored as a synthetic self-entry in the same registry so it +// survives restarts the same way peer entries do. +async function ensureOwnMeshIndex() { + const self = await meshGateway.findByPublicKey(conf.wireguard.serverPublicKey); + if (self) return self.meshIndex; + const created = await meshGateway.register({ + publicKey: conf.wireguard.serverPublicKey, + endpoint: conf.wireguard.serverEndpoint || '', + siteSlug: '(self)' + }); + return created.meshIndex; +} + +// Admin-initiated: join THIS gateway into a remote gateway's mesh. Generates +// (or reuses) this gateway's identity, brings up the local interface, calls +// the remote's /register, and applies the peer it gets back -- so after this +// call both sides have a live, working wg0 peer entry for each other. +router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => { + try { + const { remoteEndpoint, joinToken } = req.body || {}; + if (!remoteEndpoint || !joinToken) { + return res.status(400).json({ status: 'error', message: 'remoteEndpoint and joinToken are required' }); + } + + const self = await ensureLocalIdentity(); + await wgIface.ensureInterface(IFACE); + wgIface.setPrivateKey(IFACE, self.serverPrivateKey, MESH_LISTEN_PORT); + + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 15000); + let resp; + try { + resp = await fetch(String(remoteEndpoint).replace(/\/+$/, '') + '/api/mesh/register', { + method: 'POST', + headers: { Authorization: 'Bearer ' + joinToken, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + publicKey: self.serverPublicKey, + endpoint: self.serverEndpoint || '', + siteSlug: process.env.SITE_SLUG || '' + }), + signal: controller.signal + }); + } finally { clearTimeout(timer); } + + if (!resp.ok) { + const text = (await resp.text().catch(() => '')).slice(0, 300); + return res.status(502).json({ status: 'error', message: 'remote registration failed: HTTP ' + resp.status + ' ' + text }); + } + const data = await resp.json(); + + wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex)); + await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' }); + wgIface.setPeer(IFACE, { + publicKey: data.gateway.publicKey, + endpoint: data.gateway.endpoint, + allowedIPs: meshAllowedIpsFor(data.gateway.meshIndex), + keepalive: 25 + }); + + res.json({ status: 'ok', meshIndex: data.meshIndex, peerMeshIndex: data.gateway.meshIndex }); + } catch (e) { next(e); } +}); + +router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => { + try { + const gateways = await meshGateway.list(); + res.json({ status: 'ok', gateways, iface: IFACE, kernelWireguard: wgIface.kernelWireguardAvailable() }); + } catch (e) { next(e); } +}); + +module.exports = router; diff --git a/nodejs/test/unit/mesh_addressing.test.js b/nodejs/test/unit/mesh_addressing.test.js new file mode 100644 index 0000000..9061767 --- /dev/null +++ b/nodejs/test/unit/mesh_addressing.test.js @@ -0,0 +1,27 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { meshCidrFor, meshAllowedIpsFor, MAX_MESH_INDEX, MIN_MESH_INDEX } = require('../../utils/mesh_addressing'); + +test('meshCidrFor renders the .1 address in the site\'s /24', () => { + assert.equal(meshCidrFor(1), '172.24.1.1/24'); + assert.equal(meshCidrFor(254), '172.24.254.1/24'); +}); + +test('meshAllowedIpsFor covers both the mesh /24 and the site\'s 10.x/16', () => { + assert.deepEqual(meshAllowedIpsFor(5), ['172.24.5.0/24', '10.5.0.0/16']); +}); + +test('rejects index 0 and 255 (reserved) and the out-of-range/non-integer cases', () => { + assert.throws(() => meshCidrFor(0)); + assert.throws(() => meshCidrFor(255)); + assert.throws(() => meshCidrFor(MAX_MESH_INDEX + 1)); + assert.throws(() => meshCidrFor(MIN_MESH_INDEX - 1)); + assert.throws(() => meshCidrFor(1.5)); + assert.throws(() => meshCidrFor('1')); +}); + +test('MAX_MESH_INDEX matches the documented 254-site ceiling', () => { + assert.equal(MAX_MESH_INDEX, 254); +}); diff --git a/nodejs/utils/mesh_addressing.js b/nodejs/utils/mesh_addressing.js new file mode 100644 index 0000000..d87653c --- /dev/null +++ b/nodejs/utils/mesh_addressing.js @@ -0,0 +1,27 @@ +'use strict'; + +// Mesh subnet addressing math (MULTI_SITE_SPEC.md Appendix A): one octet per +// site, 172.24..0/16 + 10..0.0/16, idx 1-254 (0/255 reserved). +// Pure/no I/O so it's cheaply unit-testable apart from routes/mesh.js. + +const MESH_SUBNET_PREFIX = '172.24'; +const MAX_MESH_INDEX = 254; +const MIN_MESH_INDEX = 1; + +function meshCidrFor(meshIndex) { + assertValidIndex(meshIndex); + return `${MESH_SUBNET_PREFIX}.${meshIndex}.1/24`; +} + +function meshAllowedIpsFor(meshIndex) { + assertValidIndex(meshIndex); + return [`${MESH_SUBNET_PREFIX}.${meshIndex}.0/24`, `10.${meshIndex}.0.0/16`]; +} + +function assertValidIndex(meshIndex) { + if (!Number.isInteger(meshIndex) || meshIndex < MIN_MESH_INDEX || meshIndex > MAX_MESH_INDEX) { + throw new Error(`mesh index must be an integer in [${MIN_MESH_INDEX}, ${MAX_MESH_INDEX}], got ${meshIndex}`); + } +} + +module.exports = { meshCidrFor, meshAllowedIpsFor, MESH_SUBNET_PREFIX, MAX_MESH_INDEX, MIN_MESH_INDEX }; diff --git a/nodejs/utils/mesh_join_token.js b/nodejs/utils/mesh_join_token.js new file mode 100644 index 0000000..23a6021 --- /dev/null +++ b/nodejs/utils/mesh_join_token.js @@ -0,0 +1,29 @@ +'use strict'; + +// Single-use, short-lived tokens that let a new theta-gateway register into +// this one's WireGuard mesh (POST /api/mesh/register). Same shape as +// theta-directory's site join keys: minted by an admin, shown once, GETDEL +// (Redis) on use so a token can register exactly one gateway, ever. + +const crypto = require('crypto'); +const conf = require('@simpleworkjs/conf'); +const { getRedis } = require('../models/index'); + +const TTL_SECONDS = 15 * 60; + +const key = (token) => `${conf.redis.prefix}mesh_join_token:${token}`; + +async function mint() { + const token = 'mjt_' + crypto.randomBytes(24).toString('base64url'); + const redis = await getRedis(); + await redis.set(key(token), '1', { EX: TTL_SECONDS }); + return { token, expiresInSeconds: TTL_SECONDS }; +} + +async function consume(token) { + if (!token) return false; + const redis = await getRedis(); + return (await redis.getDel(key(token))) === '1'; +} + +module.exports = { mint, consume }; diff --git a/nodejs/utils/wg_iface.js b/nodejs/utils/wg_iface.js new file mode 100644 index 0000000..6812b64 --- /dev/null +++ b/nodejs/utils/wg_iface.js @@ -0,0 +1,156 @@ +'use strict'; + +// Bring up a local WireGuard interface, preferring the in-kernel +// implementation and falling back to the userspace `wireguard-go` reference +// implementation when the kernel module isn't available (older/hardened +// kernels, some container/cloud images, non-Linux). Both paths end with an +// identically-named network interface that `wg`/`ip` commands (and the rest +// of this module) treat the same way -- callers never need to know which +// mode ended up in use. + +const { execFileSync, spawn } = require('child_process'); + +const probes = new Map(); // name -> { mode, process (userspace only) } + +function run(cmd, args) { + return execFileSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }).toString(); +} + +function tryRun(cmd, args) { + try { return { ok: true, out: run(cmd, args) }; } + catch (e) { return { ok: false, err: (e.stderr || e.message || '').toString() }; } +} + +// One-time, cheap probe: can this kernel create a wireguard-type link at +// all? Uses a throwaway interface name so it never collides with a real one. +let kernelSupport = null; +function kernelWireguardAvailable() { + if (kernelSupport !== null) return kernelSupport; + const probeName = 'wgprobe' + process.pid; + const add = tryRun('ip', ['link', 'add', 'dev', probeName, 'type', 'wireguard']); + if (add.ok) { + tryRun('ip', ['link', 'del', 'dev', probeName]); + kernelSupport = true; + } else { + kernelSupport = false; + } + return kernelSupport; +} + +function interfaceExists(name) { + return tryRun('ip', ['link', 'show', 'dev', name]).ok; +} + +async function waitForInterface(name, timeoutMs = 5000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (interfaceExists(name)) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return false; +} + +// Idempotent: calling this again for an interface that's already up (kernel +// or userspace) is a no-op, not an error. +async function ensureInterface(name) { + if (interfaceExists(name)) { + return { mode: probes.get(name) ? probes.get(name).mode : 'kernel' }; + } + + if (kernelWireguardAvailable()) { + const add = tryRun('ip', ['link', 'add', 'dev', name, 'type', 'wireguard']); + if (!add.ok && !/File exists/.test(add.err)) { + throw new Error(`kernel WireGuard interface creation failed: ${add.err}`); + } + probes.set(name, { mode: 'kernel' }); + console.log(`[wg_iface] ${name}: using in-kernel WireGuard`); + return { mode: 'kernel' }; + } + + // Userspace fallback: wireguard-go daemonizes and creates the TUN device + // itself; we just wait for it to appear rather than assuming a fixed delay. + console.log(`[wg_iface] ${name}: kernel WireGuard unavailable, falling back to wireguard-go (userspace)`); + const child = spawn('wireguard-go', [name], { detached: true, stdio: 'ignore' }); + child.unref(); + const up = await waitForInterface(name); + if (!up) throw new Error(`wireguard-go did not bring up interface '${name}' within timeout`); + probes.set(name, { mode: 'userspace', pid: child.pid }); + return { mode: 'userspace', pid: child.pid }; +} + +function setPrivateKey(name, privateKeyBase64, listenPort) { + // `wg setconf` reads the private key from a file, not argv (argv would leak + // it via /proc//cmdline to anyone on the host). Pipe it through stdin + // via a temp file instead -- see setPeer's note on the same tradeoff. + // ListenPort matters: without one, WG binds an ephemeral port, which is + // fine for a purely outbound roaming client but useless for a gateway + // another gateway needs to dial back into as an Endpoint. + const fs = require('fs'); + const os = require('os'); + const path = require('path'); + const tmp = path.join(os.tmpdir(), `wg-${name}-${Date.now()}.conf`); + const lines = ['[Interface]', `PrivateKey = ${privateKeyBase64}`]; + if (listenPort) lines.push(`ListenPort = ${listenPort}`); + fs.writeFileSync(tmp, lines.join('\n') + '\n', { mode: 0o600 }); + try { + run('wg', ['setconf', name, tmp]); + } finally { + fs.unlinkSync(tmp); + } +} + +function setAddress(name, cidr) { + // Flush first so re-applying (e.g. after a mesh index reassignment, which + // shouldn't normally happen but must not silently stack addresses if it + // does) leaves exactly one address, not an accumulating list. + tryRun('ip', ['addr', 'flush', 'dev', name]); + run('ip', ['addr', 'add', cidr, 'dev', name]); + run('ip', ['link', 'set', 'up', 'dev', name]); +} + +// Apply (or update) one peer. Safe to call repeatedly for the same peer -- +// `wg set ... peer ` upserts. +// +// `wg set ... allowed-ips` ONLY configures WireGuard's own crypto-routing +// table (which packets get encrypted/decrypted for this peer) -- it does +// NOT add a kernel route for that destination. wg-quick does that as a +// separate step; since we drive `wg`/`ip` directly (no wg-quick), we have to +// add it ourselves or the tunnel handshakes fine but nothing ever actually +// routes through it (confirmed the hard way: a real encrypted handshake +// completed between two containers with zero kernel route present, and +// ping still showed 100% loss). +function setPeer(name, { publicKey, endpoint, allowedIPs, keepalive }) { + const ips = allowedIPs || []; + const args = ['set', name, 'peer', publicKey, 'allowed-ips', ips.join(',')]; + if (endpoint) args.push('endpoint', endpoint); + if (keepalive) args.push('persistent-keepalive', String(keepalive)); + run('wg', args); + + for (const cidr of ips) { + const add = tryRun('ip', ['route', 'add', cidr, 'dev', name]); + // "File exists" happens when the interface's own /24 already covers + // this range (added automatically by `ip addr add`) -- fine, not an + // error. Anything else should surface. + if (!add.ok && !/File exists/.test(add.err)) { + throw new Error(`failed to add kernel route ${cidr} via ${name}: ${add.err}`); + } + } +} + +// TODO: doesn't clean up the kernel routes setPeer added for this peer's +// AllowedIPs (would need to record or query them first) -- not exercised by +// any caller yet (nothing in this codebase removes a mesh peer today), but +// flagging so whoever adds that doesn't get bitten by stale routes. +function removePeer(name, publicKey) { + tryRun('wg', ['set', name, 'peer', publicKey, 'remove']); +} + +module.exports = { + kernelWireguardAvailable, + ensureInterface, + setPrivateKey, + setAddress, + setPeer, + removePeer, + interfaceExists +};