feat(directory): real mesh-gateway count on the Multi-Site modal; docs links
"Theta Gateways: N active gateways" was counting this app's own unrelated WireGuard roaming-client/exit-node Resources (metadata.subType === 'wireguard') -- a completely different subsystem from the gateway-to-gateway mesh the modal is actually about, and it never queried jump-host's mesh registry at all (so it couldn't show the local self-entry either, since there was nothing mesh-related being counted in the first place). Added utils/jump_client.js (same pattern as utils/proxy_client.js: reuses jump-host's existing self-service jmp_ API token system rather than inventing a new credential) to query jump-host's real GET /api/mesh/gateways. Reports a null count (not misleading 0) when the integration isn't configured/reachable, surfaced distinctly in the UI. Also added help links to the published multi-site/mesh docs on the modal. Includes docs links + count only -- this session also discovered that utils/proxy_client.js's PROXY_INTERNAL_URL, and now JUMP_INTERNAL_URL, were never actually wired into theta-suite's docker-compose.yml, so both service-to-service integrations were unreachable in every real deployment despite existing in code (fixed in theta-suite separately).
This commit is contained in:
+1
-1
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js --forceExit"
|
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/jump_client.test.js --forceExit"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
|
|||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
const groups = require('../utils/groups');
|
const groups = require('../utils/groups');
|
||||||
const meshReplicate = require('../utils/site_replicate');
|
const meshReplicate = require('../utils/site_replicate');
|
||||||
|
const jumpClient = require('../utils/jump_client');
|
||||||
|
|
||||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
@@ -954,8 +955,6 @@ async function probeMasterHealth(cfg) {
|
|||||||
router.get('/site-status', async (req, res, next) => {
|
router.get('/site-status', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||||
const allResources = await Resource.list();
|
|
||||||
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
|
||||||
|
|
||||||
const cfg = siteConfig.get();
|
const cfg = siteConfig.get();
|
||||||
const wanConnected = await probeMasterHealth(cfg);
|
const wanConnected = await probeMasterHealth(cfg);
|
||||||
@@ -970,6 +969,12 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
// fully joined but silently stuck on the one-time snapshot, which was
|
// fully joined but silently stuck on the one-time snapshot, which was
|
||||||
// otherwise invisible anywhere in the UI.
|
// otherwise invisible anywhere in the UI.
|
||||||
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
|
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
|
||||||
|
// Real gateway-to-gateway mesh peer count from jump-host's own registry
|
||||||
|
// (utils/jump_client.js), not this app's unrelated WireGuard
|
||||||
|
// roaming-client Resources. count is null (not 0) when the query
|
||||||
|
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
||||||
|
// tell" instead of showing a misleading zero.
|
||||||
|
const gateways = await jumpClient.getGatewayCount();
|
||||||
res.json({
|
res.json({
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
config: {
|
config: {
|
||||||
@@ -984,7 +989,8 @@ router.get('/site-status', async (req, res, next) => {
|
|||||||
},
|
},
|
||||||
sitesCount: sites.length,
|
sitesCount: sites.length,
|
||||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||||
gatewaysCount: gateResources.length
|
gatewaysCount: gateways.count,
|
||||||
|
gatewaysNote: gateways.note
|
||||||
});
|
});
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
let mockBaoStore = new Map();
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||||
|
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('jump_client', () => {
|
||||||
|
let jumpClient;
|
||||||
|
let originalFetch;
|
||||||
|
let mockFetchImpl;
|
||||||
|
let calls;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockBaoStore = new Map();
|
||||||
|
calls = [];
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ status: 'ok', gateways: [] }) });
|
||||||
|
originalFetch = global.fetch;
|
||||||
|
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
|
||||||
|
jumpClient = require('../utils/jump_client');
|
||||||
|
jumpClient._reset();
|
||||||
|
delete process.env.JUMP_INTERNAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count (not zero) when JUMP_INTERNAL_URL is not configured', async () => {
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/JUMP_INTERNAL_URL/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count when no token is stored in OpenBao', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/no jump-host API token/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the real gateway count on success', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ status: 'ok', gateways: [{ siteSlug: '(self)' }, { siteSlug: 'site-b' }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expect(result.note).toBe('ok');
|
||||||
|
expect(calls[0][0]).toBe('http://jump-host.internal/api/mesh/gateways');
|
||||||
|
expect(calls[0][1].headers.Authorization).toBe('Bearer jmp_test_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count on a non-2xx response', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => ({ ok: false, status: 403 });
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/HTTP 403/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a network failure without throwing', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => { throw new Error('connection refused'); };
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/failed: connection refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Service-to-service client for jump-host's mesh registry -- used by the
|
||||||
|
// Directory's Multi-Site & Network Gateway Status modal to show the real
|
||||||
|
// number of gateway-to-gateway WireGuard mesh peers (see MULTI_SITE_SPEC.md),
|
||||||
|
// instead of counting the unrelated older WireGuard roaming-client/exit-node
|
||||||
|
// Resources in this app's own catalog (a different subsystem entirely --
|
||||||
|
// api_directory_admin.js used to filter Resource.list() for
|
||||||
|
// metadata.subType === 'wireguard', which has nothing to do with the mesh).
|
||||||
|
//
|
||||||
|
// Same pattern as utils/proxy_client.js: reuses jump-host's existing
|
||||||
|
// self-service API token system (models/api_token.js, `jmp_<id>_<secret>`
|
||||||
|
// bearer tokens) rather than inventing a new credential type. The token must
|
||||||
|
// be minted by a jump-admin user (GET /api/mesh/gateways requires
|
||||||
|
// requireJumpAdmin, which checks the token's creator's username/groups, not
|
||||||
|
// anything the token itself carries) and stored in OpenBao.
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
const PATH = 'integrations/theta-jump'; // baoConf adds the secret/data prefix
|
||||||
|
const REQUEST_TIMEOUT_MS = 10000;
|
||||||
|
|
||||||
|
let cachedToken = null;
|
||||||
|
|
||||||
|
async function loadToken() {
|
||||||
|
if (cachedToken) return cachedToken;
|
||||||
|
let stored;
|
||||||
|
try {
|
||||||
|
stored = await baoConf.get(PATH);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[jump_client] could not read ${PATH} from OpenBao: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!stored || !stored.token) return null;
|
||||||
|
cachedToken = stored.token;
|
||||||
|
return cachedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpBaseUrl() {
|
||||||
|
// Not OpenBao -- this is where jump-host's admin API lives, not a secret.
|
||||||
|
return process.env.JUMP_INTERNAL_URL || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns { count, note }. count is null (not 0) when the query couldn't run
|
||||||
|
// at all (not configured, unreachable, unauthorized) -- the modal shows a
|
||||||
|
// count of gateways it could actually see, not a misleading "0" that reads
|
||||||
|
// as "you have no mesh peers" when the truth is "this isn't wired up yet".
|
||||||
|
async function getGatewayCount() {
|
||||||
|
const base = jumpBaseUrl();
|
||||||
|
if (!base) {
|
||||||
|
return { count: null, note: 'skipped: JUMP_INTERNAL_URL not configured' };
|
||||||
|
}
|
||||||
|
const token = await loadToken();
|
||||||
|
if (!token) {
|
||||||
|
return { count: null, note: `skipped: no jump-host API token at OpenBao ${PATH} -- mint one on jump-host (as a jump-admin user) and store it there` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(base.replace(/\/+$/, '') + '/api/mesh/gateways', {
|
||||||
|
headers: { Authorization: 'Bearer ' + token },
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
return { count: null, note: `failed: HTTP ${resp.status}` };
|
||||||
|
}
|
||||||
|
const body = await resp.json();
|
||||||
|
const gateways = Array.isArray(body.gateways) ? body.gateways : [];
|
||||||
|
return { count: gateways.length, note: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
return { count: null, note: `failed: ${err.message}` };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test seam.
|
||||||
|
function _reset() { cachedToken = null; }
|
||||||
|
|
||||||
|
module.exports = { getGatewayCount, _reset, PATH };
|
||||||
@@ -3332,7 +3332,11 @@
|
|||||||
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
|
'<span>' + (isMaster ? '👑 <strong>Master Site Node</strong>' : '⚡ <strong>Spoke Site Node</strong>') + '</span>' +
|
||||||
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
|
'<span class="badge bg-' + (isMaster ? 'warning text-dark' : 'info text-dark') + '">' + esc(cfg.siteMode || 'master') + '</span>' +
|
||||||
'</h5>' +
|
'</h5>' +
|
||||||
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node.</p>' +
|
'<p class="card-text text-muted small mb-2">Multi-site directory & replication state for this node. ' +
|
||||||
|
'<a href="https://theta42.github.io/theta-suite/sso/multi-site.html" target="_blank" rel="noopener"><i class="fa-solid fa-book me-1"></i>Directory join docs</a>' +
|
||||||
|
' · ' +
|
||||||
|
'<a href="https://theta42.github.io/theta-suite/jump-host/mesh.html" target="_blank" rel="noopener"><i class="fa-solid fa-book me-1"></i>Gateway mesh docs</a>' +
|
||||||
|
'</p>' +
|
||||||
'<table class="table table-sm text-start mb-0">' +
|
'<table class="table table-sm text-start mb-0">' +
|
||||||
'<tr><th>Local Site Slug:</th><td><code>' + esc(cfg.siteSlug || 'site-default') + '</code></td></tr>' +
|
'<tr><th>Local Site Slug:</th><td><code>' + esc(cfg.siteSlug || 'site-default') + '</code></td></tr>' +
|
||||||
'<tr><th>Master Authority URL:</th><td>' + (cfg.masterUrl ? ('<code>' + esc(cfg.masterUrl) + '</code>') : '<em>(This Node is Master)</em>') + '</td></tr>' +
|
'<tr><th>Master Authority URL:</th><td>' + (cfg.masterUrl ? ('<code>' + esc(cfg.masterUrl) + '</code>') : '<em>(This Node is Master)</em>') + '</td></tr>' +
|
||||||
@@ -3344,7 +3348,9 @@
|
|||||||
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-triangle-exclamation me-1"></i> Snapshot only (re-join to register for live updates)</span>') + '</td></tr>' : '') +
|
: '<span class="badge bg-warning text-dark"><i class="fa-solid fa-triangle-exclamation me-1"></i> Snapshot only (re-join to register for live updates)</span>') + '</td></tr>' : '') +
|
||||||
(isMaster ? '<tr><th>Registered Spokes:</th><td><span class="badge bg-success">' + (cfg.registeredSpokesCount || 0) + ' receiving live updates</span></td></tr>' : '') +
|
(isMaster ? '<tr><th>Registered Spokes:</th><td><span class="badge bg-success">' + (cfg.registeredSpokesCount || 0) + ' receiving live updates</span></td></tr>' : '') +
|
||||||
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
|
'<tr><th>Registered Sites:</th><td><span class="badge bg-primary">' + (res.sitesCount || 0) + ' sites</span></td></tr>' +
|
||||||
'<tr><th>Theta Gateways:</th><td><span class="badge bg-dark">' + (res.gatewaysCount || 0) + ' active gateways</span></td></tr>' +
|
'<tr><th>Theta Gateways:</th><td>' + (res.gatewaysCount == null
|
||||||
|
? '<span class="badge bg-secondary" title="' + esc(res.gatewaysNote || 'not configured') + '"><i class="fa-solid fa-question me-1"></i> Unknown (jump-host integration not configured)</span>'
|
||||||
|
: '<span class="badge bg-dark">' + res.gatewaysCount + ' active gateway' + (res.gatewaysCount === 1 ? '' : 's') + '</span>') + '</td></tr>' +
|
||||||
'</table>' +
|
'</table>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
|
|||||||
Reference in New Issue
Block a user