Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cde45fff1d | |||
| 972f9ace0a | |||
| 8c184a7f9a | |||
| b6efcff25e |
@@ -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
|
## 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: **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.
|
- 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.
|
||||||
|
|||||||
@@ -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,11 +84,20 @@ 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 });
|
||||||
return gateway;
|
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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "theta-gateway",
|
"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",
|
"description": "SSH jump host for the theta42 stack — LDAP-authenticated, directory-driven host bridging with audit and metrics",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -154,6 +163,24 @@ router.post('/join', middleware.auth, middleware.requireJumpAdmin, async (req, r
|
|||||||
} catch (e) { next(e); }
|
} 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) => {
|
router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const gateways = await meshGateway.list();
|
const gateways = await meshGateway.list();
|
||||||
@@ -161,4 +188,25 @@ router.get('/gateways', middleware.auth, middleware.requireJumpAdmin, async (req
|
|||||||
} catch (e) { next(e); }
|
} 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -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
|
// Removes the peer AND the kernel routes setPeer() added for its
|
||||||
// AllowedIPs (would need to record or query them first) -- not exercised by
|
// AllowedIPs -- query them BEFORE removing the peer (once gone, `wg` no
|
||||||
// any caller yet (nothing in this codebase removes a mesh peer today), but
|
// longer knows what to clean up, and nothing else tracks these routes,
|
||||||
// flagging so whoever adds that doesn't get bitten by stale 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) {
|
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']);
|
tryRun('wg', ['set', name, 'peer', publicKey, 'remove']);
|
||||||
|
|
||||||
|
for (const cidr of allowedIPs) {
|
||||||
|
tryRun('ip', ['route', 'del', cidr, 'dev', name]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
+14
-1
@@ -109,7 +109,7 @@ function renderGatewaysTable(gateways) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var html = '<table class="table table-striped table-hover mb-0 align-middle"><thead><tr>'
|
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>';
|
+ '</tr></thead><tbody>';
|
||||||
gateways.forEach(function(g){
|
gateways.forEach(function(g){
|
||||||
var isSelf = g.siteSlug === '(self)';
|
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-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><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="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>';
|
+ '</tr>';
|
||||||
});
|
});
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
$w.html(html);
|
$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() {
|
function mintMeshJoinToken() {
|
||||||
app.api.post('mesh/join-tokens', {}, function(err, data){
|
app.api.post('mesh/join-tokens', {}, function(err, data){
|
||||||
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
|
if (err) return app.messages.toast('Failed to mint join token: ' + err.message, 'danger');
|
||||||
|
|||||||
Reference in New Issue
Block a user