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:
@@ -120,4 +120,20 @@ module.exports = {
|
||||
|
||||
// Orchestrator-only keys (ignored by the app, read by theta-env).
|
||||
stack: {},
|
||||
|
||||
// WireGuard mesh configuration.
|
||||
// These values describe this gateway's own wg0 interface so the web UI
|
||||
// can show the server public key and build client profiles.
|
||||
// Override via environment: app_wireguard__serverPublicKey, etc.
|
||||
wireguard: {
|
||||
// Public key of this gateway's wg0 interface (set at runtime by docker-entrypoint).
|
||||
serverPublicKey: '',
|
||||
// "host:port" that WireGuard clients connect to, e.g. "gw.theta42.com:51820".
|
||||
serverEndpoint: '',
|
||||
// DNS server to push to clients, e.g. "10.1.0.1" or leave empty for none.
|
||||
dns: '',
|
||||
// Base of the IP pool for peer assignment: first two octets.
|
||||
// Peers are assigned 10.100.0.2, 10.100.0.3, …, 10.100.255.254.
|
||||
poolBase: '10.100.0',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
Generated
+310
-4
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.19.1",
|
||||
"name": "theta-gateway",
|
||||
"version": "2.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-jump-host",
|
||||
"version": "1.19.1",
|
||||
"name": "theta-gateway",
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
@@ -30,6 +30,7 @@
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ssh2": "^1.16.0"
|
||||
@@ -318,6 +319,30 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/anymatch": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
|
||||
@@ -587,6 +612,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -621,6 +655,17 @@
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
@@ -630,6 +675,24 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/compressible": {
|
||||
"version": "2.0.18",
|
||||
"resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
|
||||
@@ -772,6 +835,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/decompress-response": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||
@@ -814,6 +886,12 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dottie": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.7.tgz",
|
||||
@@ -856,6 +934,12 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
@@ -1135,6 +1219,19 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
@@ -1183,6 +1280,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@@ -1417,6 +1523,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-glob": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||
@@ -1504,6 +1619,18 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||
@@ -1894,6 +2021,42 @@
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
@@ -1903,6 +2066,15 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "8.4.2",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
|
||||
@@ -1938,6 +2110,15 @@
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prebuild-install": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||
@@ -2005,6 +2186,23 @@
|
||||
"once": "^1.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
@@ -2107,6 +2305,21 @@
|
||||
"node": ">= 20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/retry-as-promised": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||
@@ -2293,6 +2506,12 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
@@ -2581,6 +2800,32 @@
|
||||
"safe-buffer": "~5.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-json-comments": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
|
||||
@@ -2873,6 +3118,12 @@
|
||||
"node": "^20.17.0 || >=22.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wkx": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
|
||||
@@ -2882,6 +3133,20 @@
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
@@ -2909,6 +3174,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
|
||||
@@ -2917,6 +3188,41 @@
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -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/*.test.js test/integration/*.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": "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"
|
||||
},
|
||||
@@ -40,6 +40,7 @@
|
||||
"model-redis": "^1.6.0",
|
||||
"moment": "^2.30.1",
|
||||
"mustache": "^4.2.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"redis": "^6.1.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ssh2": "^1.16.0"
|
||||
|
||||
@@ -16,4 +16,7 @@ router.use('/api-token', middleware.auth, require('./api_token'));
|
||||
// Jump-host data — jump admin only (audit log, active sessions, metrics).
|
||||
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
|
||||
|
||||
// WireGuard peer + site management — admin only.
|
||||
router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard'));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -47,5 +47,6 @@ router.get('/login', (req, res) => res.render('login', {
|
||||
router.get('/dashboard', (req, res) => res.render('dashboard', {...values}));
|
||||
router.get('/sessions', (req, res) => res.render('sessions', {...values}));
|
||||
router.get('/audit', (req, res) => res.render('audit', {...values}));
|
||||
router.get('/wireguard', (req, res) => res.render('wireguard', {...values}));
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
'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;
|
||||
@@ -37,6 +37,7 @@ module.exports = {
|
||||
nav: [
|
||||
{href: '/dashboard', icon: 'fa-solid fa-gauge-high', label: 'Dashboard', groups: []},
|
||||
{href: '/sessions', icon: 'fa-solid fa-plug-circle-bolt', label: 'Sessions', groups: []},
|
||||
{href: '/wireguard', icon: 'fa-solid fa-shield-halved', label: 'WireGuard', groups: ['admin', 'app_jump_admin']},
|
||||
{href: '/audit', icon: 'fa-solid fa-clipboard-list', label: 'Audit', groups: ['admin', 'app_jump_admin']},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
// Renders a WireGuard client wg0.conf from a peer + site record.
|
||||
//
|
||||
// The generated config is a standard WireGuard config that works with:
|
||||
// - wg-quick (Linux / macOS)
|
||||
// - the official WireGuard iOS / Android apps (via QR code)
|
||||
// - TunnelBear, WireGuard Windows client, etc.
|
||||
|
||||
/**
|
||||
* Build a client wg0.conf string.
|
||||
*
|
||||
* @param {object} peer - WG peer record from wg_peer model
|
||||
* @param {object} site - Exit node record from wg_site model (may be null)
|
||||
* @param {string} serverPub - Gateway server's own public key
|
||||
* @param {string} serverEndpoint - "host:port" for the gateway
|
||||
* @param {string} dns - Optional DNS server to push to client
|
||||
* @returns {string}
|
||||
*/
|
||||
function renderClientConf({ peer, site, serverPub, serverEndpoint, dns }) {
|
||||
const allowedIPs = site
|
||||
? (site.exitAll ? '0.0.0.0/0, ::/0' : site.subnet || '0.0.0.0/0')
|
||||
: '0.0.0.0/0, ::/0'; // no exit site = full tunnel
|
||||
|
||||
const lines = [
|
||||
'[Interface]',
|
||||
`PrivateKey = ${peer.privateKey}`,
|
||||
`Address = ${peer.assignedIP}/32`,
|
||||
];
|
||||
|
||||
if (dns) lines.push(`DNS = ${dns}`);
|
||||
|
||||
lines.push('');
|
||||
lines.push('[Peer]');
|
||||
lines.push(`PublicKey = ${serverPub}`);
|
||||
|
||||
// If client selected an exit site, add preshared key routing hint via
|
||||
// AllowedIPs. Site gateways are peers-of-peers; the server handles routing.
|
||||
if (site) {
|
||||
lines.push(`AllowedIPs = ${allowedIPs}`);
|
||||
} else {
|
||||
lines.push('AllowedIPs = 0.0.0.0/0, ::/0');
|
||||
}
|
||||
|
||||
lines.push(`Endpoint = ${serverEndpoint}`);
|
||||
lines.push('PersistentKeepalive = 25');
|
||||
|
||||
if (peer.note) {
|
||||
lines.unshift(`# ${peer.note}`);
|
||||
}
|
||||
|
||||
return lines.join('\n') + '\n';
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal server-side [Peer] block for wg0.conf (for reference/export).
|
||||
*/
|
||||
function renderServerPeerBlock(peer) {
|
||||
return [
|
||||
`# ${peer.name || peer.id}`,
|
||||
'[Peer]',
|
||||
`PublicKey = ${peer.publicKey}`,
|
||||
`AllowedIPs = ${peer.assignedIP}/32`,
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
module.exports = { renderClientConf, renderServerPeerBlock };
|
||||
@@ -0,0 +1,29 @@
|
||||
'use strict';
|
||||
|
||||
// WireGuard key generation using Node's built-in crypto (X25519).
|
||||
// WireGuard keys ARE X25519 keys in raw base64 — no wg binary needed.
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generate a WireGuard keypair.
|
||||
* @returns {{ privateKey: string, publicKey: string }} — base64 encoded
|
||||
*/
|
||||
function generateKeypair() {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('x25519', {
|
||||
publicKeyEncoding: { type: 'spki', format: 'der' },
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'der' },
|
||||
});
|
||||
|
||||
// DER-encoded PKCS#8 private key: raw 32-byte X25519 scalar starts at offset 16
|
||||
const rawPriv = privateKey.slice(16, 48);
|
||||
// DER-encoded SPKI public key: raw 32-byte point starts at offset 12
|
||||
const rawPub = publicKey.slice(12, 44);
|
||||
|
||||
return {
|
||||
privateKey: rawPriv.toString('base64'),
|
||||
publicKey: rawPub.toString('base64'),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { generateKeypair };
|
||||
@@ -0,0 +1,680 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<style>
|
||||
/* ── WireGuard Page Styles ──────────────────────────────────────────────── */
|
||||
:root {
|
||||
--wg-green: #22c55e;
|
||||
--wg-purple: #7c3aed;
|
||||
--wg-blue: #3b82f6;
|
||||
--wg-dark: #0f172a;
|
||||
--wg-card: #1e293b;
|
||||
--wg-border: rgba(255,255,255,.08);
|
||||
}
|
||||
|
||||
#wg-shell {
|
||||
background: linear-gradient(135deg, #0f172a 0%, #1a1035 100%);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
/* ── Hero header ────────────────────────────────────────────────────────── */
|
||||
.wg-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.wg-hero-icon {
|
||||
width: 52px; height: 52px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(135deg, var(--wg-purple), var(--wg-blue));
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.4rem; color: #fff;
|
||||
box-shadow: 0 0 24px rgba(124,58,237,.4);
|
||||
}
|
||||
.wg-hero h1 { font-size: 1.7rem; font-weight: 700; color: #f1f5f9; margin: 0; }
|
||||
.wg-hero p { color: #94a3b8; font-size: .9rem; margin: 0; }
|
||||
|
||||
/* ── Cards ──────────────────────────────────────────────────────────────── */
|
||||
.wg-card {
|
||||
background: var(--wg-card);
|
||||
border: 1px solid var(--wg-border);
|
||||
border-radius: 16px;
|
||||
padding: 1.4rem 1.6rem;
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
.wg-card-title {
|
||||
font-size: 1rem; font-weight: 600; color: #e2e8f0;
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
margin-bottom: 1.1rem;
|
||||
}
|
||||
.wg-card-title i { color: var(--wg-blue); }
|
||||
|
||||
/* ── Tables ─────────────────────────────────────────────────────────────── */
|
||||
.wg-table { width: 100%; border-collapse: collapse; font-size: .88rem; }
|
||||
.wg-table th {
|
||||
color: #64748b; font-weight: 600; font-size: .75rem; text-transform: uppercase;
|
||||
letter-spacing: .05em; padding: .5rem .8rem; border-bottom: 1px solid var(--wg-border);
|
||||
}
|
||||
.wg-table td {
|
||||
padding: .65rem .8rem; color: #cbd5e1;
|
||||
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.wg-table tr:last-child td { border-bottom: none; }
|
||||
.wg-table tr:hover td { background: rgba(255,255,255,.03); }
|
||||
|
||||
/* ── Badges ─────────────────────────────────────────────────────────────── */
|
||||
.wg-badge {
|
||||
display: inline-block; padding: .2rem .6rem; border-radius: 6px;
|
||||
font-size: .72rem; font-weight: 600; letter-spacing: .03em;
|
||||
}
|
||||
.wg-badge-exit { background: rgba(124,58,237,.2); color: #a78bfa; border: 1px solid rgba(124,58,237,.3); }
|
||||
.wg-badge-full { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.25); }
|
||||
.wg-badge-split { background: rgba(59,130,246,.15); color: #60a5fa; border: 1px solid rgba(59,130,246,.25); }
|
||||
|
||||
/* ── Monospace key display ──────────────────────────────────────────────── */
|
||||
.wg-key {
|
||||
font-family: 'JetBrains Mono', 'Fira Code', monospace;
|
||||
font-size: .74rem; color: #7dd3fc;
|
||||
background: rgba(14,165,233,.08); padding: .15rem .5rem;
|
||||
border-radius: 5px; white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; max-width: 180px; display: inline-block;
|
||||
}
|
||||
|
||||
/* ── Action buttons ─────────────────────────────────────────────────────── */
|
||||
.wg-btn {
|
||||
border: none; border-radius: 8px; padding: .35rem .75rem;
|
||||
font-size: .8rem; font-weight: 500; cursor: pointer;
|
||||
display: inline-flex; align-items: center; gap: .35rem;
|
||||
transition: all .15s;
|
||||
}
|
||||
.wg-btn-primary { background: var(--wg-purple); color: #fff; }
|
||||
.wg-btn-primary:hover { background: #6d28d9; transform: translateY(-1px); }
|
||||
.wg-btn-secondary { background: rgba(255,255,255,.07); color: #94a3b8; }
|
||||
.wg-btn-secondary:hover { background: rgba(255,255,255,.12); color: #e2e8f0; }
|
||||
.wg-btn-danger { background: rgba(239,68,68,.15); color: #f87171; border: 1px solid rgba(239,68,68,.25); }
|
||||
.wg-btn-danger:hover { background: rgba(239,68,68,.25); }
|
||||
.wg-btn-qr { background: rgba(34,197,94,.15); color: #4ade80; border: 1px solid rgba(34,197,94,.2); }
|
||||
.wg-btn-qr:hover { background: rgba(34,197,94,.25); }
|
||||
.wg-btn-conf { background: rgba(59,130,246,.15); color: #60a5fa; border: 1px solid rgba(59,130,246,.2); }
|
||||
.wg-btn-conf:hover { background: rgba(59,130,246,.25); }
|
||||
|
||||
/* ── Gateway info strip ──────────────────────────────────────────────────── */
|
||||
.wg-gw-info {
|
||||
display: flex; flex-wrap: wrap; gap: 1rem;
|
||||
background: rgba(124,58,237,.07); border: 1px solid rgba(124,58,237,.2);
|
||||
border-radius: 12px; padding: 1rem 1.3rem; margin-bottom: 1.8rem;
|
||||
}
|
||||
.wg-gw-info-item { display: flex; flex-direction: column; gap: .15rem; }
|
||||
.wg-gw-info-label { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: #7c3aed; }
|
||||
.wg-gw-info-value { font-family: monospace; font-size: .82rem; color: #e2e8f0; }
|
||||
.wg-gw-info-value.empty { color: #475569; font-style: italic; }
|
||||
|
||||
/* ── QR modal ───────────────────────────────────────────────────────────── */
|
||||
#wg-qr-img {
|
||||
display: block; margin: 1rem auto; max-width: 320px; width: 100%;
|
||||
border-radius: 12px; border: 3px solid rgba(255,255,255,.08);
|
||||
background: #fff; padding: 8px;
|
||||
}
|
||||
.wg-qr-instructions {
|
||||
background: rgba(34,197,94,.08); border: 1px solid rgba(34,197,94,.2);
|
||||
border-radius: 8px; padding: .75rem 1rem; margin-top: 1rem;
|
||||
font-size: .82rem; color: #86efac;
|
||||
}
|
||||
|
||||
/* ── Empty states ───────────────────────────────────────────────────────── */
|
||||
.wg-empty {
|
||||
text-align: center; padding: 2.5rem; color: #475569;
|
||||
}
|
||||
.wg-empty i { font-size: 2rem; margin-bottom: .5rem; display: block; }
|
||||
|
||||
/* ── Form styles inside modal ───────────────────────────────────────────── */
|
||||
.wg-modal-body .form-label { color: #94a3b8; font-size: .85rem; }
|
||||
.wg-modal-body .form-control, .wg-modal-body .form-select {
|
||||
background: #0f172a; border: 1px solid rgba(255,255,255,.1); color: #e2e8f0;
|
||||
}
|
||||
.wg-modal-body .form-control:focus, .wg-modal-body .form-select:focus {
|
||||
background: #0f172a; border-color: var(--wg-purple); color: #e2e8f0;
|
||||
box-shadow: 0 0 0 3px rgba(124,58,237,.2);
|
||||
}
|
||||
.wg-modal-body .form-control::placeholder { color: #475569; }
|
||||
.wg-modal-body .form-check-input:checked { background-color: var(--wg-purple); border-color: var(--wg-purple); }
|
||||
|
||||
/* ── Divider label ──────────────────────────────────────────────────────── */
|
||||
.wg-section-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.wg-section-header h2 {
|
||||
font-size: 1rem; font-weight: 600; color: #e2e8f0;
|
||||
display: flex; align-items: center; gap: .5rem; margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="wg-shell">
|
||||
|
||||
<!-- Hero -->
|
||||
<div class="wg-hero">
|
||||
<div class="wg-hero-icon"><i class="fa-solid fa-shield-halved"></i></div>
|
||||
<div>
|
||||
<h1>WireGuard Mesh</h1>
|
||||
<p>Manage client profiles, exit nodes, and VPN tunnels</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gateway Info Strip -->
|
||||
<div class="wg-gw-info" id="wg-gw-info">
|
||||
<div class="wg-gw-info-item">
|
||||
<span class="wg-gw-info-label"><i class="fa-solid fa-server me-1"></i>Gateway Endpoint</span>
|
||||
<span class="wg-gw-info-value empty" id="gw-endpoint">loading…</span>
|
||||
</div>
|
||||
<div class="wg-gw-info-item">
|
||||
<span class="wg-gw-info-label"><i class="fa-solid fa-key me-1"></i>Server Public Key</span>
|
||||
<span class="wg-gw-info-value empty" id="gw-pubkey">loading…</span>
|
||||
</div>
|
||||
<div class="wg-gw-info-item">
|
||||
<span class="wg-gw-info-label"><i class="fa-solid fa-circle-info me-1"></i>DNS Pushed to Clients</span>
|
||||
<span class="wg-gw-info-value empty" id="gw-dns">loading…</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
|
||||
<!-- ── Left column: Exit Sites ───────────────────────────────────────── -->
|
||||
<div class="col-lg-5 mb-3">
|
||||
<div class="wg-card">
|
||||
<div class="wg-section-header">
|
||||
<h2><i class="fa-solid fa-globe"></i> Exit Nodes</h2>
|
||||
<button class="wg-btn wg-btn-primary" onclick="openAddSiteModal()">
|
||||
<i class="fa-solid fa-plus"></i> Add Site
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="sites-table-wrap">
|
||||
<div class="wg-empty"><i class="fa-solid fa-globe"></i>No exit nodes yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Right column: Peers ───────────────────────────────────────────── -->
|
||||
<div class="col-lg-7 mb-3">
|
||||
<div class="wg-card">
|
||||
<div class="wg-section-header">
|
||||
<h2><i class="fa-solid fa-laptop"></i> Client Peers</h2>
|
||||
<button class="wg-btn wg-btn-primary" onclick="openAddPeerModal()">
|
||||
<i class="fa-solid fa-plus"></i> New Client
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="peers-table-wrap">
|
||||
<div class="wg-empty"><i class="fa-solid fa-laptop"></i>No peers yet</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- /row -->
|
||||
|
||||
</div><!-- /wg-shell -->
|
||||
|
||||
<!-- ── QR Code Modal ────────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="wg-qr-modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title text-light">
|
||||
<i class="fa-solid fa-qrcode me-2"></i>
|
||||
<span id="wg-qr-title">WireGuard QR Code</span>
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body text-center">
|
||||
<div id="wg-qr-loading" class="py-4">
|
||||
<div class="spinner-border text-primary" role="status"></div>
|
||||
<div class="text-muted mt-2 small">Generating…</div>
|
||||
</div>
|
||||
<img id="wg-qr-img" src="" alt="WireGuard QR Code" style="display:none;">
|
||||
<div class="wg-qr-instructions" style="text-align:left;">
|
||||
<i class="fa-brands fa-apple me-1"></i><i class="fa-brands fa-android me-1"></i>
|
||||
Open the <strong>WireGuard</strong> app → tap <strong>+</strong> → <strong>Scan QR Code</strong>
|
||||
</div>
|
||||
<div class="mt-3 d-flex gap-2 justify-content-center flex-wrap">
|
||||
<button class="wg-btn wg-btn-conf" id="wg-download-conf-btn">
|
||||
<i class="fa-solid fa-download"></i> Download .conf
|
||||
</button>
|
||||
<button class="wg-btn wg-btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add/Edit Site Modal ───────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="wg-site-modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title text-light" id="wg-site-modal-title">
|
||||
<i class="fa-solid fa-globe me-2"></i>Add Exit Node
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body wg-modal-body">
|
||||
<input type="hidden" id="site-edit-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name <span class="text-danger">*</span></label>
|
||||
<input class="form-control" id="site-name" placeholder="e.g. Netherlands (10.5)">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Endpoint <span class="text-danger">*</span></label>
|
||||
<input class="form-control" id="site-endpoint" placeholder="nl.theta42.com:51820">
|
||||
<div class="form-text text-muted">Public host:port clients connect to</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Site Public Key <span class="text-danger">*</span></label>
|
||||
<input class="form-control font-monospace" id="site-pubkey" placeholder="Base64 WireGuard public key">
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-8 mb-3">
|
||||
<label class="form-label">Routable Subnet</label>
|
||||
<input class="form-control" id="site-subnet" placeholder="10.5.0.0/16">
|
||||
<div class="form-text text-muted">IP range this site routes</div>
|
||||
</div>
|
||||
<div class="col-4 mb-3">
|
||||
<label class="form-label">Site ID</label>
|
||||
<input class="form-control" id="site-siteid" placeholder="5">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3 form-check">
|
||||
<input type="checkbox" class="form-check-input" id="site-exitall">
|
||||
<label class="form-check-label text-secondary" for="site-exitall">
|
||||
Full tunnel exit (AllowedIPs = 0.0.0.0/0)
|
||||
</label>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note</label>
|
||||
<input class="form-control" id="site-note" placeholder="Optional description">
|
||||
</div>
|
||||
<div class="actionMessage" id="site-modal-msg" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button class="wg-btn wg-btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="wg-btn wg-btn-primary" onclick="submitSite()">
|
||||
<i class="fa-solid fa-save me-1"></i><span id="site-submit-label">Add Exit Node</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Add Peer Modal ────────────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="wg-peer-modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title text-light">
|
||||
<i class="fa-solid fa-laptop me-2"></i>New WireGuard Client
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body wg-modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Device Name <span class="text-danger">*</span></label>
|
||||
<input class="form-control" id="peer-name" placeholder="e.g. william-iphone">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Exit Node</label>
|
||||
<select class="form-select" id="peer-exit">
|
||||
<option value="">— Full tunnel via gateway (default) —</option>
|
||||
</select>
|
||||
<div class="form-text text-muted">Where this client's traffic exits the mesh</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note</label>
|
||||
<input class="form-control" id="peer-note" placeholder="Optional description">
|
||||
</div>
|
||||
<div class="actionMessage" id="peer-modal-msg" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button class="wg-btn wg-btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="wg-btn wg-btn-primary" onclick="submitPeer()">
|
||||
<i class="fa-solid fa-key me-1"></i>Generate Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edit Peer Exit Modal ──────────────────────────────────────────────── -->
|
||||
<div class="modal fade" id="wg-peer-edit-modal" tabindex="-1">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content bg-dark border border-secondary">
|
||||
<div class="modal-header border-secondary">
|
||||
<h5 class="modal-title text-light">
|
||||
<i class="fa-solid fa-route me-2"></i>Edit Client Profile
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body wg-modal-body">
|
||||
<input type="hidden" id="peer-edit-id">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Device Name</label>
|
||||
<input class="form-control" id="peer-edit-name">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Exit Node</label>
|
||||
<select class="form-select" id="peer-edit-exit">
|
||||
<option value="">— Full tunnel via gateway (default) —</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Note</label>
|
||||
<input class="form-control" id="peer-edit-note">
|
||||
</div>
|
||||
<div class="actionMessage" id="peer-edit-msg" style="display:none;"></div>
|
||||
</div>
|
||||
<div class="modal-footer border-secondary">
|
||||
<button class="wg-btn wg-btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button class="wg-btn wg-btn-primary" onclick="savePeerEdit()">
|
||||
<i class="fa-solid fa-save me-1"></i>Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
/* ── State ──────────────────────────────────────────────────────────────── */
|
||||
var wgSites = [];
|
||||
var wgPeers = [];
|
||||
var currentQrPeerId = null;
|
||||
|
||||
/* ── Bootstrap modal handles ────────────────────────────────────────────── */
|
||||
var qrModal, siteModal, peerModal, peerEditModal;
|
||||
$(document).ready(function(){
|
||||
qrModal = new bootstrap.Modal(document.getElementById('wg-qr-modal'));
|
||||
siteModal = new bootstrap.Modal(document.getElementById('wg-site-modal'));
|
||||
peerModal = new bootstrap.Modal(document.getElementById('wg-peer-modal'));
|
||||
peerEditModal = new bootstrap.Modal(document.getElementById('wg-peer-edit-modal'));
|
||||
loadAll();
|
||||
});
|
||||
|
||||
/* ── API helpers ────────────────────────────────────────────────────────── */
|
||||
function api(method, path, body, cb) {
|
||||
var opts = {
|
||||
method: method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
fetch('/api/wireguard' + path, opts)
|
||||
.then(function(r){ return r.json().then(function(d){ return {ok: r.ok, d: d}; }); })
|
||||
.then(function(r){ cb(r.ok ? null : (r.d.message || 'Error'), r.d); })
|
||||
.catch(function(e){ cb(e.message); });
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
/* ── Load everything ────────────────────────────────────────────────────── */
|
||||
function loadAll() {
|
||||
api('GET', '/gateway-info', null, function(err, info){
|
||||
if (!err && info) {
|
||||
var ep = info.endpoint || ''; var pk = info.publicKey || ''; var dns = info.dns || '';
|
||||
$('#gw-endpoint').text(ep || '(not configured)').toggleClass('empty', !ep);
|
||||
$('#gw-pubkey').text(pk || '(not configured)').toggleClass('empty', !pk);
|
||||
$('#gw-dns').text(dns || '(not configured)').toggleClass('empty', !dns);
|
||||
}
|
||||
});
|
||||
loadSites(function(){ loadPeers(); });
|
||||
}
|
||||
|
||||
function loadSites(cb) {
|
||||
api('GET', '/sites', null, function(err, data){
|
||||
wgSites = (!err && data && data.results) || [];
|
||||
renderSitesTable();
|
||||
if (cb) cb();
|
||||
});
|
||||
}
|
||||
|
||||
function loadPeers() {
|
||||
api('GET', '/peers', null, function(err, data){
|
||||
wgPeers = (!err && data && data.results) || [];
|
||||
renderPeersTable();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Render helpers ─────────────────────────────────────────────────────── */
|
||||
function siteLabel(exitSiteId) {
|
||||
if (!exitSiteId) return '<span class="wg-badge wg-badge-full">Full Tunnel</span>';
|
||||
var site = wgSites.find(function(s){ return s.id === exitSiteId; });
|
||||
return site
|
||||
? '<span class="wg-badge wg-badge-exit"><i class="fa-solid fa-location-dot me-1"></i>' + esc(site.name) + '</span>'
|
||||
: '<span class="wg-badge wg-badge-split">Unknown</span>';
|
||||
}
|
||||
|
||||
function renderSitesTable() {
|
||||
var $w = $('#sites-table-wrap');
|
||||
if (!wgSites.length) {
|
||||
$w.html('<div class="wg-empty"><i class="fa-solid fa-globe"></i>No exit nodes yet.<br><span class="small">Add a site to give clients a routable exit point.</span></div>');
|
||||
return;
|
||||
}
|
||||
var html = '<table class="wg-table"><thead><tr>'
|
||||
+ '<th>Name</th><th>Endpoint</th><th>Subnet</th><th class="text-end">Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
wgSites.forEach(function(s){
|
||||
var exitBadge = s.exitAll
|
||||
? '<span class="wg-badge wg-badge-full">Full Exit</span>'
|
||||
: '<span class="wg-badge wg-badge-split">Split</span>';
|
||||
html += '<tr>'
|
||||
+ '<td><strong class="text-light">' + esc(s.name) + '</strong><br><span class="text-muted small">' + (s.siteId ? 'Site ' + esc(s.siteId) : '') + '</span></td>'
|
||||
+ '<td><code class="small text-info">' + esc(s.endpoint) + '</code></td>'
|
||||
+ '<td><code class="small">' + esc(s.subnet || '—') + '</code> ' + exitBadge + '</td>'
|
||||
+ '<td class="text-end">'
|
||||
+ '<button class="wg-btn wg-btn-secondary me-1" onclick="openEditSiteModal(\'' + esc(s.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||
+ '<button class="wg-btn wg-btn-danger" onclick="deleteSite(\'' + esc(s.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$w.html(html);
|
||||
}
|
||||
|
||||
function renderPeersTable() {
|
||||
var $w = $('#peers-table-wrap');
|
||||
if (!wgPeers.length) {
|
||||
$w.html('<div class="wg-empty"><i class="fa-solid fa-laptop"></i>No client peers yet.<br><span class="small">Create a profile to get a QR code or .conf file.</span></div>');
|
||||
return;
|
||||
}
|
||||
var html = '<table class="wg-table"><thead><tr>'
|
||||
+ '<th>Device</th><th>IP</th><th>Exit</th><th>Public Key</th><th class="text-end">Actions</th>'
|
||||
+ '</tr></thead><tbody>';
|
||||
wgPeers.forEach(function(p){
|
||||
html += '<tr>'
|
||||
+ '<td><strong class="text-light">' + esc(p.name) + '</strong>'
|
||||
+ (p.note ? '<br><span class="text-muted small">' + esc(p.note) + '</span>' : '')
|
||||
+ '</td>'
|
||||
+ '<td><code class="small text-success">' + esc(p.assignedIP) + '</code></td>'
|
||||
+ '<td>' + siteLabel(p.exitSiteId) + '</td>'
|
||||
+ '<td><span class="wg-key" title="' + esc(p.publicKey) + '">' + esc(p.publicKey) + '</span></td>'
|
||||
+ '<td class="text-end" style="white-space:nowrap;">'
|
||||
+ '<button class="wg-btn wg-btn-qr me-1" onclick="showQr(\'' + esc(p.id) + '\',\'' + esc(p.name) + '\')" title="QR Code"><i class="fa-solid fa-qrcode"></i></button>'
|
||||
+ '<button class="wg-btn wg-btn-conf me-1" onclick="downloadConf(\'' + esc(p.id) + '\')" title="Download .conf"><i class="fa-solid fa-download"></i></button>'
|
||||
+ '<button class="wg-btn wg-btn-secondary me-1" onclick="openEditPeerModal(\'' + esc(p.id) + '\')" title="Edit"><i class="fa-solid fa-pen"></i></button>'
|
||||
+ '<button class="wg-btn wg-btn-danger" onclick="deletePeer(\'' + esc(p.id) + '\')" title="Remove"><i class="fa-solid fa-trash"></i></button>'
|
||||
+ '</td></tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
$w.html(html);
|
||||
}
|
||||
|
||||
/* ── Exit node select population ────────────────────────────────────────── */
|
||||
function populateExitSelect(selectId, selectedId) {
|
||||
var $sel = $('#' + selectId).empty();
|
||||
$sel.append('<option value="">— Full tunnel via gateway (default) —</option>');
|
||||
wgSites.forEach(function(s){
|
||||
var opt = $('<option>').val(s.id).text(s.name + (s.endpoint ? ' (' + s.endpoint + ')' : ''));
|
||||
if (s.id === selectedId) opt.prop('selected', true);
|
||||
$sel.append(opt);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Sites ──────────────────────────────────────────────────────────────── */
|
||||
function openAddSiteModal() {
|
||||
$('#site-edit-id').val('');
|
||||
$('#site-name, #site-endpoint, #site-pubkey, #site-subnet, #site-siteid, #site-note').val('');
|
||||
$('#site-exitall').prop('checked', false);
|
||||
$('#wg-site-modal-title').html('<i class="fa-solid fa-globe me-2"></i>Add Exit Node');
|
||||
$('#site-submit-label').text('Add Exit Node');
|
||||
$('#site-modal-msg').hide();
|
||||
siteModal.show();
|
||||
setTimeout(function(){ $('#site-name').focus(); }, 300);
|
||||
}
|
||||
|
||||
function openEditSiteModal(id) {
|
||||
var site = wgSites.find(function(s){ return s.id === id; });
|
||||
if (!site) return;
|
||||
$('#site-edit-id').val(site.id);
|
||||
$('#site-name').val(site.name);
|
||||
$('#site-endpoint').val(site.endpoint);
|
||||
$('#site-pubkey').val(site.publicKey);
|
||||
$('#site-subnet').val(site.subnet);
|
||||
$('#site-siteid').val(site.siteId);
|
||||
$('#site-note').val(site.note);
|
||||
$('#site-exitall').prop('checked', !!site.exitAll);
|
||||
$('#wg-site-modal-title').html('<i class="fa-solid fa-pen me-2"></i>Edit Exit Node');
|
||||
$('#site-submit-label').text('Save Changes');
|
||||
$('#site-modal-msg').hide();
|
||||
siteModal.show();
|
||||
}
|
||||
|
||||
function submitSite() {
|
||||
var id = $('#site-edit-id').val();
|
||||
var payload = {
|
||||
name: $('#site-name').val().trim(),
|
||||
endpoint: $('#site-endpoint').val().trim(),
|
||||
publicKey: $('#site-pubkey').val().trim(),
|
||||
subnet: $('#site-subnet').val().trim() || '0.0.0.0/0',
|
||||
siteId: $('#site-siteid').val().trim(),
|
||||
note: $('#site-note').val().trim(),
|
||||
exitAll: $('#site-exitall').is(':checked'),
|
||||
};
|
||||
if (!payload.name || !payload.endpoint || !payload.publicKey) {
|
||||
return showMsg('#site-modal-msg', 'Name, endpoint, and public key are required.', 'danger');
|
||||
}
|
||||
var method = id ? 'PATCH' : 'POST';
|
||||
var path = id ? '/sites/' + id : '/sites';
|
||||
api(method, path, payload, function(err){
|
||||
if (err) return showMsg('#site-modal-msg', err, 'danger');
|
||||
siteModal.hide();
|
||||
loadSites(function(){ loadPeers(); });
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteSite(id) {
|
||||
var site = wgSites.find(function(s){ return s.id === id; });
|
||||
if (!site) return;
|
||||
var ok = await app.messages.confirm('Remove exit node "' + site.name + '"? Any peers using it will fall back to full tunnel.', '#sites-table-wrap', 'danger');
|
||||
if (!ok) return;
|
||||
api('DELETE', '/sites/' + id, null, function(err){
|
||||
if (err) return app.messages.action('Error: ' + err, '#sites-table-wrap', 'danger');
|
||||
loadSites(function(){ loadPeers(); });
|
||||
});
|
||||
}
|
||||
|
||||
/* ── Peers ──────────────────────────────────────────────────────────────── */
|
||||
function openAddPeerModal() {
|
||||
$('#peer-name, #peer-note').val('');
|
||||
populateExitSelect('peer-exit', '');
|
||||
$('#peer-modal-msg').hide();
|
||||
peerModal.show();
|
||||
setTimeout(function(){ $('#peer-name').focus(); }, 300);
|
||||
}
|
||||
|
||||
function submitPeer() {
|
||||
var payload = {
|
||||
name: $('#peer-name').val().trim(),
|
||||
exitSiteId: $('#peer-exit').val(),
|
||||
note: $('#peer-note').val().trim(),
|
||||
};
|
||||
if (!payload.name) return showMsg('#peer-modal-msg', 'Device name is required.', 'danger');
|
||||
api('POST', '/peers', payload, function(err, peer){
|
||||
if (err) return showMsg('#peer-modal-msg', err, 'danger');
|
||||
peerModal.hide();
|
||||
loadPeers();
|
||||
// Auto-open the QR for the new peer
|
||||
showQr(peer.id, peer.name);
|
||||
});
|
||||
}
|
||||
|
||||
function openEditPeerModal(id) {
|
||||
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||
if (!peer) return;
|
||||
$('#peer-edit-id').val(peer.id);
|
||||
$('#peer-edit-name').val(peer.name);
|
||||
$('#peer-edit-note').val(peer.note || '');
|
||||
populateExitSelect('peer-edit-exit', peer.exitSiteId);
|
||||
$('#peer-edit-msg').hide();
|
||||
peerEditModal.show();
|
||||
}
|
||||
|
||||
function savePeerEdit() {
|
||||
var id = $('#peer-edit-id').val();
|
||||
var payload = {
|
||||
name: $('#peer-edit-name').val().trim(),
|
||||
exitSiteId: $('#peer-edit-exit').val(),
|
||||
note: $('#peer-edit-note').val().trim(),
|
||||
};
|
||||
api('PATCH', '/peers/' + id, payload, function(err){
|
||||
if (err) return showMsg('#peer-edit-msg', err, 'danger');
|
||||
peerEditModal.hide();
|
||||
loadPeers();
|
||||
});
|
||||
}
|
||||
|
||||
async function deletePeer(id) {
|
||||
var peer = wgPeers.find(function(p){ return p.id === id; });
|
||||
if (!peer) return;
|
||||
var ok = await app.messages.confirm('Remove peer "' + peer.name + '"? Their VPN access will be revoked immediately.', '#peers-table-wrap', 'danger');
|
||||
if (!ok) return;
|
||||
api('DELETE', '/peers/' + id, null, function(err){
|
||||
if (err) return app.messages.action('Error: ' + err, '#peers-table-wrap', 'danger');
|
||||
loadPeers();
|
||||
});
|
||||
}
|
||||
|
||||
/* ── QR Code ────────────────────────────────────────────────────────────── */
|
||||
function showQr(peerId, peerName) {
|
||||
currentQrPeerId = peerId;
|
||||
$('#wg-qr-title').text((peerName || 'Peer') + ' — WireGuard Profile');
|
||||
$('#wg-qr-img').hide();
|
||||
$('#wg-qr-loading').show();
|
||||
$('#wg-download-conf-btn').off('click').on('click', function(){ downloadConf(peerId); });
|
||||
qrModal.show();
|
||||
api('GET', '/peers/' + peerId + '/qr', null, function(err, data){
|
||||
$('#wg-qr-loading').hide();
|
||||
if (err || !data || !data.qr) {
|
||||
$('#wg-qr-img').attr('src', '').hide();
|
||||
return;
|
||||
}
|
||||
$('#wg-qr-img').attr('src', data.qr).show();
|
||||
});
|
||||
}
|
||||
|
||||
function downloadConf(peerId) {
|
||||
// Opens in a new tab — browser downloads the attachment
|
||||
window.open('/api/wireguard/peers/' + peerId + '/conf', '_blank');
|
||||
}
|
||||
|
||||
/* ── Utility ────────────────────────────────────────────────────────────── */
|
||||
function showMsg(selector, text, type) {
|
||||
$(selector)
|
||||
.removeClass('alert-success alert-danger alert-warning')
|
||||
.addClass('alert alert-' + (type || 'danger'))
|
||||
.text(text)
|
||||
.show();
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user