Compare commits

..

4 Commits

Author SHA1 Message Date
wmantly cde45fff1d release(v2.1.1): mesh peer cleanup, GET /api/mesh/self, real mesh-registration bugfixes 2026-08-10 21:22:05 -04:00
wmantly 972f9ace0a fix(mesh): route ordering shadowed /api/mesh/register; initiator side never got a self-entry
Two real bugs found while live-testing the new GET /api/mesh/self
endpoint with two actual jump-host containers (mesh-joined for real,
not mocked):

1. routes/api.js mounted `/` (routes/jump.js, admin-session-gated)
   before `/mesh`. Since router.use('/', ...) matches every /api/*
   path, EVERY /api/mesh/* request -- including /register, which is
   authenticated by a bearer mesh join token, not an admin session --
   hit that admin gate first and 401'd before routes/mesh.js ever ran.
   Confirmed live: a real gateway-to-gateway /join call failed with a
   checkApiToken/LoginFailed error instead of ever reaching /register.
   Reordered so /mesh is mounted first.

2. POST /register (the receiving side of a join) persists a `(self)`
   registry entry via ensureOwnMeshIndex(), but POST /join (the
   initiating side) never did -- so GET /api/mesh/self and the mesh
   UI's own-entry handling silently saw nothing on whichever gateway
   called /join. Fixed by registering a self-entry there too, using
   the exact meshIndex the remote assigned (models/mesh_gateway.js's
   register() now accepts an explicit meshIndex instead of always
   auto-picking one from the local registry, which has no reason to
   agree with what's actually configured on the live wg0 interface).

Verified with two real containers joined over a live network: both
sides now report their own correct mesh IP via GET /api/mesh/self,
and both appear correctly in GET /api/mesh/gateways.
2026-08-10 21:17:00 -04:00
wmantly 8c184a7f9a feat(mesh): GET /api/mesh/self for local bootstrap self-IP discovery
A no-inbound spoke's join script (theta-suite's bootstrap/site-join.js)
needs its own gateway's mesh IP to hand to sso-manager-node's
/api/site/join, but the only existing read (GET /api/mesh/gateways)
requires a full jump-admin session -- unusable from an unattended
bootstrap script. Add a narrower read gated only by a valid jmp_ API
token (any self-service token, same as theta-proxy's prx_ tokens for
proxy_client.js), exposing just this gateway's own mesh IP.
2026-08-10 20:53:47 -04:00
wmantly b6efcff25e fix(mesh): peer removal now cleans up its kernel routes
wg_iface.removePeer() previously just did `wg set ... remove` -- the
kernel routes setPeer() adds for a peer's AllowedIPs (since wg itself
only configures crypto-routing, not kernel routes -- see setPeer's own
comment) were never cleaned up, a real TODO flagged in code but never
exercised because nothing removed a mesh peer at all.

- removePeer() now queries the peer's current AllowedIPs (`wg show
  <iface> allowed-ips`) BEFORE removing it -- once gone, wg no longer
  knows what to clean up -- and issues `ip route del` for each.
- New DELETE /api/mesh/gateways/:id (models/mesh_gateway.js gained
  remove()) actually calls removePeer(), so the fix has a real caller;
  previously there was no removal path anywhere in the mesh feature at
  all. Refuses to remove the local "(self)" entry. Does not reach out
  to the remote gateway to remove the reciprocal peer -- that side
  needs the same action taken independently.
- Mesh UI: remove button per non-self peer row, using app.messages.confirm
  (not native confirm() -- caught by this repo's own no-native-dialogs
  test, which failed on first pass and is now green).

Verified for real with a live WireGuard interface in a container: routes
for a peer's AllowedIPs present after setPeer, confirmed gone after
removePeer, while the interface's own local route correctly survives.
2026-08-10 20:18:39 -04:00
7 changed files with 121 additions and 12 deletions
+6
View File
@@ -1,3 +1,9 @@
## v2.1.1
- fix: **mesh peer removal now cleans up its kernel routes.** `wg_iface.removePeer()` dropped the WireGuard peer entry but left the `ip route` entries `setPeer()` had added, so a removed peer's subnet stayed routed into a dead tunnel. Fixed by querying `wg show <iface> allowed-ips` before removal and `ip route del`-ing each CIDR. Verified live: routes present after `setPeer`, gone after `removePeer`, this gateway's own local route untouched. Exposed via `DELETE /api/mesh/gateways/:id` + a remove button in the mesh UI.
- feat: **`GET /api/mesh/self`** — this gateway's own mesh IP, gated by any valid self-service API token rather than a full jump-admin session, so an unattended local script (e.g. `theta-suite`'s no-inbound relay bootstrap) can discover it without admin credentials.
- fix: **`/api/mesh/register` was unreachable via HTTP.** `routes/api.js` mounted `/` (admin-session-gated `routes/jump.js`) before `/mesh`; since `router.use('/', ...)` matches every `/api/*` path, every `/api/mesh/*` request — including `/register`, authenticated by a bearer mesh join token, not an admin session — hit that admin gate first and 401'd before `routes/mesh.js` ever ran. A real gateway-to-gateway `/join` call failed with a `checkApiToken`/`LoginFailed` error instead of registering. Found live-testing the new `/self` endpoint with two real containers; fixed by mounting `/mesh` first.
- fix: **the initiating side of a mesh join never recorded its own identity.** `POST /register` (the receiving side) persists a `(self)` registry entry via `ensureOwnMeshIndex()`, but `POST /join` (the initiating side) never did, so `GET /api/mesh/self` and the mesh UI's own-entry handling silently saw nothing on whichever gateway called `/join`. Fixed by registering a self-entry there too, using the exact mesh index the remote assigned (`models/mesh_gateway.js`'s `register()` now accepts an explicit `meshIndex` instead of always auto-picking one from the local registry). Verified with two real meshed containers: both sides now report their own correct mesh IP.
## v2.1.0
- feat: **Gateway-to-gateway WireGuard mesh** (`routes/mesh.js`) — real site-to-site tunnels between theta-gateway instances, distinct from the existing roaming-client/exit-node WireGuard feature. Join-token bootstrap (`POST /api/mesh/join-tokens`, `/register`, `/join`), mesh-index addressing (172.24.\<idx\>.0/16 + 10.\<idx\>.0.0/16, per `theta-suite`'s `docs/MULTI_SITE_SPEC.md`).
- feat: **In-kernel WireGuard with a userspace fallback** (`utils/wg_iface.js`) — prefers `ip link add type wireguard`, falls back to `wireguard-go` when the kernel module isn't available (older/hardened kernels, some container images, non-Linux). Both packages added to the Dockerfile.
+21 -3
View File
@@ -62,7 +62,16 @@ function nextFreeMeshIndex(existing) {
// Re-registering the same public key updates its endpoint/siteSlug but
// reuses its existing mesh index -- a gateway that re-registers after a
// restart must not get bumped to a new mesh subnet.
async function register({ publicKey, endpoint, siteSlug }) {
//
// meshIndex is normally auto-assigned (the next free local index) -- correct
// when THIS gateway is the one handing out indices (POST /register, for both
// the caller and itself via ensureOwnMeshIndex). But the INITIATING side of
// a join (POST /join) doesn't get to pick its own index -- the remote
// already assigned it and returned it in the response -- so an explicit
// meshIndex is accepted to record that exact value instead of whatever this
// gateway's own local registry would have auto-picked (which has no reason
// to agree with the value actually configured on the live wg0 interface).
async function register({ publicKey, endpoint, siteSlug, meshIndex: explicitMeshIndex }) {
const redis = await getRedis();
const existing = await list();
const already = existing.find((g) => g.publicKey === publicKey);
@@ -75,11 +84,20 @@ async function register({ publicKey, endpoint, siteSlug }) {
}
const id = crypto.randomBytes(8).toString('hex');
const meshIndex = nextFreeMeshIndex(existing);
const meshIndex = explicitMeshIndex || nextFreeMeshIndex(existing);
const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now };
await redis.hSet(gatewayKey(id), serialize(gateway));
await redis.zAdd(idxKey(), { score: now, value: id });
return gateway;
}
module.exports = { list, findByPublicKey, register, MAX_MESH_INDEX };
async function remove(id) {
const redis = await getRedis();
const gw = deserialize(await redis.hGetAll(gatewayKey(id)));
if (!gw) return null;
await redis.del(gatewayKey(id));
await redis.zRem(idxKey(), id);
return gw;
}
module.exports = { list, findByPublicKey, register, remove, MAX_MESH_INDEX };
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "theta-gateway",
"version": "2.1.0",
"version": "2.1.1",
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
"author": [
{
+9 -3
View File
@@ -13,15 +13,21 @@ router.use('/user', middleware.auth, require('./user'));
// admin gate (see routes/api_token.js for why a token can't reach admin routes).
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'));
// Gateway-to-gateway mesh — mixed auth (register/register-* are called by a
// remote gateway with a bearer join token, not an admin session; join-tokens
// mint + join are admin-gated). See routes/mesh.js for the per-route gates.
// MUST be registered before the '/' mount below: '/' matches every /api/*
// path (it's the catch-all for routes/jump.js), so registering it first
// would shadow every /api/mesh/* route with admin-session auth before
// routes/mesh.js's own per-route gates ever ran -- confirmed live, this
// silently 401'd /register's bearer-join-token callers with a
// checkApiToken/LoginFailed error instead of ever reaching mesh.js.
router.use('/mesh', require('./mesh'));
// Jump-host data — jump admin only (audit log, active sessions, metrics).
router.use('/', middleware.auth, middleware.requireJumpAdmin, require('./jump'));
module.exports = router;
+48
View File
@@ -142,6 +142,15 @@ router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, r
const data = await resp.json();
wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex));
// Persist OUR OWN identity too, not just the remote peer's -- the
// receiving side of /register does this via ensureOwnMeshIndex(), but
// the initiating side (here) never did, so GET /api/mesh/self and the
// mesh UI's own-entry/"(self)" handling both silently saw nothing on
// whichever gateway called /join. register() is upsert-by-publicKey
// and reuses an existing entry's index, so this is safe to call even
// if a self-entry from a PRIOR /register (as the receiving side of a
// different peer) already exists.
await meshGateway.register({ publicKey: self.serverPublicKey, endpoint: self.serverEndpoint || '', siteSlug: '(self)', meshIndex: data.meshIndex });
await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' });
wgIface.setPeer(IFACE, {
publicKey: data.gateway.publicKey,
@@ -154,6 +163,24 @@ router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, r
} catch (e) { next(e); }
});
// This gateway's own mesh address, for a LOCAL bootstrap script to discover
// (e.g. theta-suite's site-join, running on the same host as this gateway)
// without needing full jump-admin session auth -- any valid jmp_ API token
// (middleware.auth, no requireJumpAdmin) is enough, same service-to-service
// pattern as theta-proxy's prx_ tokens for proxy_client.js. Not a peer
// listing, so no admin-only audit/config data is exposed here.
router.get('/self', middleware.auth, async (req, res, next) => {
try {
const self = conf.wireguard || {};
let meshIp = null;
if (self.serverPublicKey) {
const entry = await meshGateway.findByPublicKey(self.serverPublicKey);
if (entry) meshIp = meshCidrFor(entry.meshIndex).split('/')[0];
}
res.json({ status: 'ok', meshIp, joined: !!meshIp, iface: IFACE });
} catch (e) { next(e); }
});
router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const gateways = await meshGateway.list();
@@ -161,4 +188,25 @@ router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req
} catch (e) { next(e); }
});
// Remove a peer gateway: tears down its local WG peer entry + kernel routes
// (wgIface.removePeer) and drops it from the registry. Does NOT reach out to
// the remote gateway to remove the reciprocal peer entry there -- an admin
// on that side needs to do the same. Refuses to remove the self-entry
// ("(self)"), since that's this gateway's own identity, not a peer.
router.delete('/gateways/:id', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
try {
const gateways = await meshGateway.list();
const target = gateways.find((g) => g.id === req.params.id);
if (!target) return res.status(404).json({ status: 'error', message: 'gateway not found' });
if (target.siteSlug === '(self)') {
return res.status(400).json({ status: 'error', message: 'cannot remove this gateway\'s own self-entry' });
}
wgIface.removePeer(IFACE, target.publicKey);
await meshGateway.remove(target.id);
res.json({ status: 'ok', removed: { id: target.id, siteSlug: target.siteSlug, meshIndex: target.meshIndex } });
} catch (e) { next(e); }
});
module.exports = router;
+22 -4
View File
@@ -137,12 +137,30 @@ function setPeer(name, { publicKey, endpoint, allowedIPs, keepalive }) {
}
}
// TODO: doesn't clean up the kernel routes setPeer added for this peer's
// AllowedIPs (would need to record or query them first) -- not exercised by
// any caller yet (nothing in this codebase removes a mesh peer today), but
// flagging so whoever adds that doesn't get bitten by stale routes.
// Removes the peer AND the kernel routes setPeer() added for its
// AllowedIPs -- query them BEFORE removing the peer (once gone, `wg` no
// longer knows what to clean up, and nothing else tracks these routes,
// since they were added by us directly, not by wg-quick).
//
// Safe to assume none of a peer's AllowedIPs collide with this gateway's
// own local address range: mesh indexes are unique per gateway
// (models/mesh_gateway.js's nextFreeMeshIndex), so a peer's
// 172.24.<peerIndex>.0/24 can never equal our own 172.24.<ownIndex>.0/24.
function removePeer(name, publicKey) {
const show = tryRun('wg', ['show', name, 'allowed-ips']);
let allowedIPs = [];
if (show.ok) {
const line = show.out.split('\n').find((l) => l.startsWith(publicKey + '\t'));
if (line) {
allowedIPs = (line.split('\t')[1] || '').split(/\s+/).filter((ip) => ip && ip !== '(none)');
}
}
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
for (const cidr of allowedIPs) {
tryRun('ip', ['route', 'del', cidr, 'dev', name]);
}
}
module.exports = {
+14 -1
View File
@@ -109,7 +109,7 @@ function renderGatewaysTable(gateways) {
return;
}
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th>'
+ '<th>Site</th><th>Mesh Index</th><th>Mesh Address</th><th>Endpoint</th><th>Public Key</th><th>Last Seen</th><th class="text-end">Actions</th>'
+ '</tr></thead><tbody>';
gateways.forEach(function(g){
var isSelf = g.siteSlug === '(self)';
@@ -120,12 +120,25 @@ function renderGatewaysTable(gateways) {
+ '<td><code class="small text-primary">' + esc(g.endpoint || '—') + '</code></td>'
+ '<td><code class="small text-truncate d-inline-block" style="max-width:220px;" title="' + esc(g.publicKey) + '">' + esc(g.publicKey) + '</code></td>'
+ '<td class="small text-muted">' + (g.lastSeenAt ? new Date(Number(g.lastSeenAt)).toLocaleString() : '—') + '</td>'
+ '<td class="text-end">' + (isSelf ? '' :
'<button class="btn btn-sm btn-outline-danger" onclick="removeMeshGateway(\'' + esc(g.id) + '\', \'' + esc(g.siteSlug || g.id) + '\')" title="Remove peer"><i class="fa-solid fa-trash"></i></button>')
+ '</td>'
+ '</tr>';
});
html += '</tbody></table>';
$w.html(html);
}
async function removeMeshGateway(id, label) {
var ok = await app.messages.confirm('Remove mesh peer "' + label + '"? This tears down the local WireGuard peer + routes. The other side keeps its half until removed there too.', $('#mesh-gateways-wrap'), 'danger');
if (!ok) return;
app.api.delete('mesh/gateways/' + id, function(err){
if (err) return app.messages.toast('Failed to remove gateway: ' + err.message, 'danger');
app.messages.toast('Gateway removed', 'success');
loadMeshStatus();
});
}
function mintMeshJoinToken() {
app.api.post('mesh/join-tokens', {}, function(err, data){
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');