Files
jump-host/nodejs/models/mesh_gateway.js
T
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

104 lines
3.8 KiB
JavaScript

'use strict';
// Registry of peer theta-gateway instances this gateway has meshed with —
// raw Redis, same pattern as wg_site.js/audit_event.js. Each registration
// carries what's needed to configure a local WireGuard peer entry for them:
// public key, reachable endpoint, and the mesh IP this gateway assigned them
// (MULTI_SITE_SPEC.md's one-octet-per-site addressing, 172.24.<idx>.0/16 +
// 10.<idx>.0.0/16, idx 1-254).
//
// Redis keys:
// mesh_gateway:<id> — hash of gateway fields
// mesh_gateway_index — sorted set (score = createdAt, value = id)
const crypto = require('crypto');
const conf = require('@simpleworkjs/conf');
const { getRedis } = require('./index');
const MAX_MESH_INDEX = 254;
const P = () => conf.redis.prefix;
const idxKey = () => `${P()}mesh_gateway_index`;
const gatewayKey = (id) => `${P()}mesh_gateway:${id}`;
function serialize(obj) {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = String(v == null ? '' : v);
}
return out;
}
function deserialize(h) {
if (!h || !h.id) return null;
return { ...h, meshIndex: Number(h.meshIndex || 0), createdAt: Number(h.createdAt || 0), lastSeenAt: Number(h.lastSeenAt || 0) };
}
async function list() {
const redis = await getRedis();
const ids = await redis.zRange(idxKey(), 0, -1);
const out = [];
for (const id of ids) {
const g = deserialize(await redis.hGetAll(gatewayKey(id)));
if (g) out.push(g);
}
return out;
}
async function findByPublicKey(publicKey) {
const all = await list();
return all.find((g) => g.publicKey === publicKey) || null;
}
function nextFreeMeshIndex(existing) {
const used = new Set(existing.map((g) => g.meshIndex).filter(Boolean));
for (let i = 1; i <= MAX_MESH_INDEX; i++) {
if (!used.has(i)) return i;
}
throw new Error(`Mesh index space exhausted (max ${MAX_MESH_INDEX} gateways)`);
}
// Register (or re-register, idempotent by publicKey) a peer gateway.
// 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.
//
// 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);
const now = Date.now();
if (already) {
const updated = { ...already, endpoint, siteSlug: siteSlug || already.siteSlug, lastSeenAt: now };
await redis.hSet(gatewayKey(already.id), serialize(updated));
return updated;
}
const id = crypto.randomBytes(8).toString('hex');
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;
}
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 };