From 972f9ace0a60c0be5a1941d7d95a3f5a0af6d3ee Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 21:17:00 -0400 Subject: [PATCH] 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. --- nodejs/models/mesh_gateway.js | 13 +++++++++++-- nodejs/routes/api.js | 12 +++++++++--- nodejs/routes/mesh.js | 9 +++++++++ 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/nodejs/models/mesh_gateway.js b/nodejs/models/mesh_gateway.js index 8a24eab..a914a39 100644 --- a/nodejs/models/mesh_gateway.js +++ b/nodejs/models/mesh_gateway.js @@ -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,7 +84,7 @@ 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 }); diff --git a/nodejs/routes/api.js b/nodejs/routes/api.js index 6984733..4bd9342 100644 --- a/nodejs/routes/api.js +++ b/nodejs/routes/api.js @@ -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; diff --git a/nodejs/routes/mesh.js b/nodejs/routes/mesh.js index 15a2811..4c3a6cc 100644 --- a/nodejs/routes/mesh.js +++ b/nodejs/routes/mesh.js @@ -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,