diff --git a/nodejs/conf/base.js b/nodejs/conf/base.js index be04f27..f842fb8 100644 --- a/nodejs/conf/base.js +++ b/nodejs/conf/base.js @@ -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', + }, }; diff --git a/nodejs/models/wg_peer.js b/nodejs/models/wg_peer.js new file mode 100644 index 0000000..118bd22 --- /dev/null +++ b/nodejs/models/wg_peer.js @@ -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: — 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 }; diff --git a/nodejs/models/wg_site.js b/nodejs/models/wg_site.js new file mode 100644 index 0000000..5c13cb9 --- /dev/null +++ b/nodejs/models/wg_site.js @@ -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: — 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 }; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index f8c07b5..73a7b35 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -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" + } } } } diff --git a/nodejs/package.json b/nodejs/package.json index 7725ac6..fe6238e 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/*.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" diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 08d0646..9c76df6 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -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; diff --git a/nodejs/routes/render.js b/nodejs/routes/render.js index 78ce405..755def6 100644 --- a/nodejs/routes/render.js +++ b/nodejs/routes/render.js @@ -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; diff --git a/nodejs/routes/wireguard.js b/nodejs/routes/wireguard.js new file mode 100644 index 0000000..45e980c --- /dev/null +++ b/nodejs/routes/wireguard.js @@ -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; diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 5c5cac6..74e3317 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -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']}, ], }; diff --git a/nodejs/utils/wg_conf.js b/nodejs/utils/wg_conf.js new file mode 100644 index 0000000..bcdbb98 --- /dev/null +++ b/nodejs/utils/wg_conf.js @@ -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 }; diff --git a/nodejs/utils/wg_keys.js b/nodejs/utils/wg_keys.js new file mode 100644 index 0000000..07c0f40 --- /dev/null +++ b/nodejs/utils/wg_keys.js @@ -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 }; diff --git a/nodejs/views/wireguard.ejs b/nodejs/views/wireguard.ejs new file mode 100644 index 0000000..014e4f2 --- /dev/null +++ b/nodejs/views/wireguard.ejs @@ -0,0 +1,680 @@ +<%- include('top') %> + + + +
+ + +
+
+
+

WireGuard Mesh

+

Manage client profiles, exit nodes, and VPN tunnels

+
+
+ + +
+
+ Gateway Endpoint + loading… +
+
+ Server Public Key + loading… +
+
+ DNS Pushed to Clients + loading… +
+
+ +
+ + +
+
+
+

Exit Nodes

+ +
+ +
+
No exit nodes yet
+
+
+
+ + +
+
+
+

Client Peers

+ +
+ +
+
No peers yet
+
+
+
+ +
+ +
+ + + + + + + + + + + + + + + +<%- include('bottom') %>