diff --git a/nodejs/package.json b/nodejs/package.json index 4556858..0b95cd6 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -11,7 +11,7 @@ "scripts": { "start": "node ./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 tests/reconciler.test.js tests/nmap_plugin.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/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js --forceExit" }, "jest": { "testEnvironment": "node", diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index 5ebaf57..eb92911 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -11,6 +11,7 @@ const { projectResources } = require('@simpleworkjs/directory-schema'); const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP; const groups = require('../utils/groups'); 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 // transitively in the parent. Idempotent and non-fatal: "already a member" is @@ -987,8 +988,6 @@ async function probeMasterHealth(cfg) { router.get('/site-status', async (req, res, next) => { try { 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 wanConnected = await probeMasterHealth(cfg); @@ -1003,6 +1002,12 @@ router.get('/site-status', async (req, res, next) => { // fully joined but silently stuck on the one-time snapshot, which was // otherwise invisible anywhere in the UI. 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({ status: 'ok', config: { @@ -1017,7 +1022,8 @@ router.get('/site-status', async (req, res, next) => { }, sitesCount: sites.length, 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); } }); diff --git a/nodejs/tests/jump_client.test.js b/nodejs/tests/jump_client.test.js new file mode 100644 index 0000000..f2cba91 --- /dev/null +++ b/nodejs/tests/jump_client.test.js @@ -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/); + }); +}); diff --git a/nodejs/utils/jump_client.js b/nodejs/utils/jump_client.js new file mode 100644 index 0000000..ac6e880 --- /dev/null +++ b/nodejs/utils/jump_client.js @@ -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__` +// 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 }; diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 8769962..384eaca 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -3332,7 +3332,11 @@ '' + (isMaster ? '👑 Master Site Node' : '⚡ Spoke Site Node') + '' + '' + esc(cfg.siteMode || 'master') + '' + '' + - '

Multi-site directory & replication state for this node.

' + + '

Multi-site directory & replication state for this node. ' + + 'Directory join docs' + + ' · ' + + 'Gateway mesh docs' + + '

' + '' + '' + '' + @@ -3344,7 +3348,9 @@ : ' Snapshot only (re-join to register for live updates)') + '' : '') + (isMaster ? '' : '') + '' + - '' + + '' + '
Local Site Slug:' + esc(cfg.siteSlug || 'site-default') + '
Master Authority URL:' + (cfg.masterUrl ? ('' + esc(cfg.masterUrl) + '') : '(This Node is Master)') + '
Registered Spokes:' + (cfg.registeredSpokesCount || 0) + ' receiving live updates
Registered Sites:' + (res.sitesCount || 0) + ' sites
Theta Gateways:' + (res.gatewaysCount || 0) + ' active gateways
Theta Gateways:' + (res.gatewaysCount == null + ? ' Unknown (jump-host integration not configured)' + : '' + res.gatewaysCount + ' active gateway' + (res.gatewaysCount === 1 ? '' : 's') + '') + '
' + '' + '' +