Files
jump-host/nodejs/routes/wireguard.js
T
wmantly 43349579e1 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
2026-08-08 23:19:14 -04:00

148 lines
5.1 KiB
JavaScript

'use strict';
// WireGuard management API — admin-gated.
//
// Sites (exit nodes):
// GET /api/wireguard/sites list all exit nodes
// POST /api/wireguard/sites create exit node
// PATCH /api/wireguard/sites/:id update exit node
// DELETE /api/wireguard/sites/:id remove exit node
//
// Peers (client devices):
// GET /api/wireguard/peers list all peers (no private keys)
// POST /api/wireguard/peers create peer (returns private key ONCE)
// PATCH /api/wireguard/peers/:id update name / exit node / note
// DELETE /api/wireguard/peers/:id remove peer
// GET /api/wireguard/peers/:id/conf download wg0.conf (contains private key)
// GET /api/wireguard/peers/:id/qr PNG QR code of the client conf (base64)
const router = require('express').Router();
const QRCode = require('qrcode');
const conf = require('@simpleworkjs/conf');
const wgSite = require('../models/wg_site');
const wgPeer = require('../models/wg_peer');
const { renderClientConf } = require('../utils/wg_conf');
// Gateway's own WireGuard public key + endpoint come from conf.wireguard.
// These are set in docker-compose / theta-env and describe this gateway's
// wg0 interface that clients point at.
function gwConf() {
const wg = conf.wireguard || {};
return {
publicKey: wg.serverPublicKey || '',
endpoint: wg.serverEndpoint || '',
dns: wg.dns || '',
};
}
// ── Sites ───────────────────────────────────────────────────────────────────
router.get('/sites', async (req, res, next) => {
try {
res.json({ results: await wgSite.list() });
} catch (e) { next(e); }
});
router.post('/sites', async (req, res, next) => {
try {
const { name, endpoint, publicKey, subnet, exitAll, siteId, note } = req.body;
if (!name || !endpoint || !publicKey) {
return res.status(400).json({ message: 'name, endpoint, and publicKey are required' });
}
const site = await wgSite.create(
{ name, endpoint, publicKey, subnet, exitAll, siteId, note },
req.user && req.user.uid
);
res.status(201).json(site);
} catch (e) { next(e); }
});
router.patch('/sites/:id', async (req, res, next) => {
try {
const site = await wgSite.update(req.params.id, req.body);
res.json(site);
} catch (e) { next(e); }
});
router.delete('/sites/:id', async (req, res, next) => {
try {
await wgSite.remove(req.params.id);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Peers ───────────────────────────────────────────────────────────────────
router.get('/peers', async (req, res, next) => {
try {
res.json({ results: await wgPeer.list() });
} catch (e) { next(e); }
});
router.post('/peers', async (req, res, next) => {
try {
const { name, exitSiteId, note } = req.body;
if (!name) return res.status(400).json({ message: 'name is required' });
const peer = await wgPeer.create(
{ name, exitSiteId, note },
req.user && req.user.uid
);
// Return the full peer including privateKey — shown ONCE.
res.status(201).json(peer);
} catch (e) { next(e); }
});
router.patch('/peers/:id', async (req, res, next) => {
try {
const peer = await wgPeer.update(req.params.id, req.body);
res.json(wgPeer.toPublic(peer));
} catch (e) { next(e); }
});
router.delete('/peers/:id', async (req, res, next) => {
try {
await wgPeer.remove(req.params.id);
res.json({ ok: true });
} catch (e) { next(e); }
});
// ── Config / QR ─────────────────────────────────────────────────────────────
async function buildConf(id) {
const peer = await wgPeer.get(id); // includes privateKey
if (!peer) throw Object.assign(new Error('Peer not found'), { status: 404 });
const site = peer.exitSiteId ? await wgSite.get(peer.exitSiteId) : null;
const { publicKey, endpoint, dns } = gwConf();
return renderClientConf({ peer, site, serverPub: publicKey, serverEndpoint: endpoint, dns });
}
router.get('/peers/:id/conf', async (req, res, next) => {
try {
const confText = await buildConf(req.params.id);
const peer = await wgPeer.get(req.params.id);
const filename = `${(peer.name || peer.id).replace(/[^a-z0-9_-]/gi, '_')}.conf`;
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(confText);
} catch (e) { next(e); }
});
router.get('/peers/:id/qr', async (req, res, next) => {
try {
const confText = await buildConf(req.params.id);
const dataUrl = await QRCode.toDataURL(confText, {
errorCorrectionLevel: 'M',
width: 400,
margin: 2,
});
res.json({ qr: dataUrl });
} catch (e) { next(e); }
});
// Gateway's own public key (unauthenticated — needed to display in the UI).
router.get('/gateway-info', (req, res) => {
res.json({ ...gwConf() });
});
module.exports = router;