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.
This commit is contained in:
2026-08-10 21:17:00 -04:00
parent 8c184a7f9a
commit 972f9ace0a
3 changed files with 29 additions and 5 deletions
+11 -2
View File
@@ -62,7 +62,16 @@ function nextFreeMeshIndex(existing) {
// Re-registering the same public key updates its endpoint/siteSlug but // Re-registering the same public key updates its endpoint/siteSlug but
// reuses its existing mesh index -- a gateway that re-registers after a // reuses its existing mesh index -- a gateway that re-registers after a
// restart must not get bumped to a new mesh subnet. // 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 redis = await getRedis();
const existing = await list(); const existing = await list();
const already = existing.find((g) => g.publicKey === publicKey); 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 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 }; const gateway = { id, publicKey, endpoint, siteSlug: siteSlug || '', meshIndex, createdAt: now, lastSeenAt: now };
await redis.hSet(gatewayKey(id), serialize(gateway)); await redis.hSet(gatewayKey(id), serialize(gateway));
await redis.zAdd(idxKey(), { score: now, value: id }); await redis.zAdd(idxKey(), { score: now, value: id });
+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). // 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')); 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. // WireGuard peer + site management — admin only.
router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard')); router.use('/wireguard', middleware.auth, middleware.requireJumpAdmin, require('./wireguard'));
// Gateway-to-gateway mesh — mixed auth (register/register-* are called by a // 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 // 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. // 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')); 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; module.exports = router;
+9
View File
@@ -142,6 +142,15 @@ router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, r
const data = await resp.json(); const data = await resp.json();
wgIface.setAddress(IFACE, meshCidrFor(data.meshIndex)); 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)' }); await meshGateway.register({ publicKey: data.gateway.publicKey, endpoint: data.gateway.endpoint, siteSlug: '(remote master)' });
wgIface.setPeer(IFACE, { wgIface.setPeer(IFACE, {
publicKey: data.gateway.publicKey, publicKey: data.gateway.publicKey,