feat(wireguard): WireGuard peer manager UI — QR codes, .conf download, per-client exit node selection (#42)

* feat(wireguard): WireGuard peer manager UI with QR, .conf download, and per-client exit node selection

- models/wg_peer.js     — Redis-backed peer store with auto IP allocation (10.100.0.x)
- models/wg_site.js     — Redis-backed exit node store (admin-managed sites)
- utils/wg_keys.js      — X25519 keypair gen via Node crypto (no wg binary needed)
- utils/wg_conf.js      — client wg0.conf renderer
- routes/wireguard.js   — REST API: CRUD sites/peers, GET /conf, GET /qr (QRCode PNG)
- views/wireguard.ejs   — full dark-mode UI: exit node table, peer table, QR modal,
                           .conf download, exit node picker per client
- conf/base.js          — conf.wireguard block (serverPublicKey, serverEndpoint, dns, poolBase)
- Nav: WireGuard link added (admin-gated)
- No wg binary dep in Node process — key gen is pure JS X25519

* fix(test): update test script to run unit tests without native bcrypt binary dependency in CI

* fix(ui): replace native browser alert/confirm with app.messages in WireGuard view
This commit is contained in:
2026-08-08 23:19:14 -04:00
committed by GitHub
parent 2a7a7c01da
commit 43349579e1
12 changed files with 1460 additions and 5 deletions
+119
View File
@@ -0,0 +1,119 @@
'use strict';
// WireGuard peer model — raw Redis (same pattern as audit_event.js).
//
// Each peer is a client device (phone, laptop, etc.) with a unique keypair and
// an assigned IP on the gateway's wg0 interface.
//
// Redis keys:
// wg_peer:<id> — hash of peer fields
// wg_peer_index — sorted set (score = createdAt, value = id)
// wg_peer_ip_seq — integer counter for next assignable IP
//
// Assigned IPs are allocated from the gateway's wg pool (default 10.0.0.0/8
// range starting at .2 — .1 is the gateway itself). Override via conf.wireguard.
//
// Fields:
// id - 16-char hex
// name - human label, e.g. "william-phone"
// publicKey - WireGuard public key (X25519 base64)
// privateKey - WireGuard private key — PRIVATE, not returned by toPublic()
// assignedIP - e.g. "10.0.0.2"
// exitSiteId - ID of the wg_site to route through ('' = full tunnel via gw)
// createdBy - uid of admin/user who created it
// createdAt - unix ms
// note - free-text
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const { generateKeypair } = require('../utils/wg_keys');
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}wg_peer_index`;
const peerKey = (id) => `${P()}wg_peer:${id}`;
const ipSeqKey = () => `${P()}wg_peer_ip_seq`;
// Start IP allocation at x.x.x.2 (x.x.x.1 is the gateway interface).
const WG_POOL_BASE = (conf.wireguard && conf.wireguard.poolBase) || '10.100.0';
function seqToIP(seq) {
// Allocate within /16 pool: 10.100.0.2 10.100.255.254
const octet3 = Math.floor((seq - 2) / 254);
const octet4 = ((seq - 2) % 254) + 1;
return `${WG_POOL_BASE.split('.').slice(0, 2).join('.')}.${octet3}.${octet4 + 1}`;
}
function serialize(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return { ...h, createdAt: Number(h.createdAt || 0) };
}
/** Strip the private key before sending to the client. */
function toPublic(peer) {
if (!peer) return null;
const { privateKey: _priv, ...pub } = peer; // eslint-disable-line no-unused-vars
return pub;
}
async function create(data, createdBy) {
const redis = await getRedis();
const id = crypto.randomBytes(8).toString('hex');
const seq = await redis.incr(ipSeqKey());
const { privateKey, publicKey } = generateKeypair();
const peer = {
id,
name: data.name || 'unnamed',
publicKey,
privateKey, // stored server-side; sent once on create / conf download
assignedIP: seqToIP(seq),
exitSiteId: data.exitSiteId || '',
createdBy: createdBy || '',
createdAt: Date.now(),
note: data.note || '',
};
await redis.hSet(peerKey(id), serialize(peer));
await redis.zAdd(idxKey(), { score: peer.createdAt, value: id });
return peer; // includes privateKey — caller decides what to expose
}
async function get(id) {
const redis = await getRedis();
return deserialize(await redis.hGetAll(peerKey(id)));
}
async function update(id, patch) {
const redis = await getRedis();
const existing = await get(id);
if (!existing) throw Object.assign(new Error('Peer not found'), { status: 404 });
// Only allow mutable fields to be patched
const allowed = ['name', 'exitSiteId', 'note'];
const safe = {};
for (const k of allowed) if (k in patch) safe[k] = patch[k];
const merged = { ...existing, ...safe };
await redis.hSet(peerKey(id), serialize(merged));
return merged;
}
async function remove(id) {
const redis = await getRedis();
await redis.del(peerKey(id));
await redis.zRem(idxKey(), id);
}
async function list() {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1, { REV: true });
const peers = await Promise.all(ids.map(get));
return peers.filter(Boolean).map(toPublic);
}
module.exports = { create, get, update, remove, list, toPublic };
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// WireGuard exit-node / site model — raw Redis (same pattern as audit_event.js).
//
// Each "site" is a location clients can route through. Admins add/remove sites
// dynamically via the Theta Gateway UI.
//
// Redis keys:
// wg_site:<id> — hash of site fields
// wg_site_index — sorted set (score = createdAt, value = id)
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}wg_site_index`;
const siteKey = (id) => `${P()}wg_site:${id}`;
function serialize(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = typeof v === 'boolean' ? (v ? '1' : '0') : String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return {
...h,
createdAt: Number(h.createdAt || 0),
exitAll: h.exitAll === '1',
};
}
async function create(data, createdBy) {
const id = crypto.randomBytes(8).toString('hex');
const site = {
id,
name: data.name || 'Unnamed Site',
endpoint: data.endpoint || '',
publicKey: data.publicKey || '',
subnet: data.subnet || '0.0.0.0/0',
exitAll: !!data.exitAll,
siteId: data.siteId || '',
note: data.note || '',
createdBy: createdBy || '',
createdAt: Date.now(),
};
const redis = await getRedis();
await redis.hSet(siteKey(id), serialize(site));
await redis.zAdd(idxKey(), { score: site.createdAt, value: id });
return site;
}
async function get(id) {
const redis = await getRedis();
return deserialize(await redis.hGetAll(siteKey(id)));
}
async function update(id, patch) {
const redis = await getRedis();
const existing = await get(id);
if (!existing) throw Object.assign(new Error('Site not found'), { status: 404 });
const merged = { ...existing, ...patch, id }; // id is immutable
await redis.hSet(siteKey(id), serialize(merged));
return merged;
}
async function remove(id) {
const redis = await getRedis();
await redis.del(siteKey(id));
await redis.zRem(idxKey(), id);
}
async function list() {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1);
const sites = await Promise.all(ids.map(get));
return sites.filter(Boolean);
}
module.exports = { create, get, update, remove, list };