From b95cb08c41ff3b10640f050a55159ac0b216e359 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 10 Aug 2026 20:31:42 -0400 Subject: [PATCH] feat(multi-site): no-inbound relay automation via theta-proxy's existing API tokens Implements TODO items "service-to-service auth model" and "no-inbound relay automation" together -- the first was never really "no credential type exists", it was that nothing wired one of the credential types that ALREADY exist (theta-proxy, jump-host, and this app each already have their own self-service API token system, models/api_token.js) into an actual inter-service call. This is that wiring, not a new invented credential type. - utils/proxy_client.js: ensureRelayRoute({host, ip, targetPort}) calls theta-proxy's real Host API (GET/POST/PUT /api/host) using a `prx_...` token an operator mints on theta-proxy and stores in OpenBao (secret/integrations/theta-proxy), same pattern as agent_keys.js. Idempotent and best-effort -- never fails the caller if the token/URL isn't configured, since this is an enhancement on top of a working join, not a join requirement. - POST /api/site/spokes accepts optional noInbound/meshIp/publicHost fields; when a spoke reports itself no-inbound, the master best-effort creates/updates the matching relay route automatically. SiteSpoke gained the fields + a relayNote for visibility. Verified against a REAL running theta-proxy container (not mocked): booted it standalone, logged in as the local admin, minted a real `prx_` token via its actual API, and drove ensureRelayRoute() against it for real. Caught a real bug doing this: GET /api/host/:item wraps the record in `{item, results}`, not flat -- the mocked unit tests (which I wrote first) all had the flat shape baked in and passed cleanly, so this only surfaced against the real API. Fixed in both the implementation and the unit tests' mocked response shape. --- nodejs/models/site_spoke.js | 12 +++- nodejs/package.json | 2 +- nodejs/routes/api_site.js | 28 ++++++-- nodejs/tests/proxy_client.test.js | 98 ++++++++++++++++++++++++++++ nodejs/utils/proxy_client.js | 105 ++++++++++++++++++++++++++++++ 5 files changed, 237 insertions(+), 8 deletions(-) create mode 100644 nodejs/tests/proxy_client.test.js create mode 100644 nodejs/utils/proxy_client.js diff --git a/nodejs/models/site_spoke.js b/nodejs/models/site_spoke.js index 3763f56..61ddaae 100644 --- a/nodejs/models/site_spoke.js +++ b/nodejs/models/site_spoke.js @@ -29,7 +29,17 @@ class SiteSpoke extends Model { siteSlug: { type: 'string' }, pushToken: { type: 'string', isRequired: true }, created_on: { type: 'integer' }, - last_seen_on: { type: 'integer' } + last_seen_on: { type: 'integer' }, + // No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of + // its own reports its WG mesh IP + the public hostname it wants + // reached at; the master then best-effort creates a matching relay + // route on its own theta-proxy (utils/proxy_client.js). relayNote + // records what happened for visibility in the UI -- this automation + // is optional/best-effort, never a join requirement. + noInbound: { type: 'boolean', default: false }, + meshIp: { type: 'string' }, + publicHost: { type: 'string' }, + relayNote: { type: 'string' } }; toPublic() { diff --git a/nodejs/package.json b/nodejs/package.json index 4f50e44..57b090f 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 --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 --forceExit" }, "jest": { "testEnvironment": "node", diff --git a/nodejs/routes/api_site.js b/nodejs/routes/api_site.js index f9a3f45..509abe8 100644 --- a/nodejs/routes/api_site.js +++ b/nodejs/routes/api_site.js @@ -120,27 +120,43 @@ router.post('/spokes', async (req, res, next) => { const key = await SiteJoinKey.authenticate(rawKey); if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' }); - const { endpoint, siteSlug } = req.body || {}; + const { endpoint, siteSlug, noInbound, meshIp, publicHost } = req.body || {}; if (!endpoint || !/^https?:\/\//.test(endpoint)) { return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' }); } const now = Math.floor(Date.now() / 1000); let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0]; + const patch = { siteSlug: siteSlug || (spoke && spoke.siteSlug) || null, last_seen_on: now, noInbound: !!noInbound, meshIp: meshIp || '', publicHost: publicHost || '' }; if (spoke) { - await spoke.update({ siteSlug: siteSlug || spoke.siteSlug, last_seen_on: now }); + await spoke.update(patch); } else { spoke = await SiteSpoke.create({ id: crypto.randomUUID(), endpoint, - siteSlug: siteSlug || null, pushToken: SiteSpoke.generatePushToken(), created_on: now, - last_seen_on: now + ...patch }); } - logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug }); - res.json({ status: 'ok', pushToken: spoke.pushToken }); + + // No-inbound relay automation: best-effort, never blocks registration. + // See utils/proxy_client.js for why this reuses theta-proxy's existing + // API token system rather than a new credential type. + let relayNote = 'not applicable (spoke has inbound access)'; + if (noInbound) { + if (meshIp && publicHost) { + const proxyClient = require('../utils/proxy_client'); + const result = await proxyClient.ensureRelayRoute({ host: publicHost, ip: meshIp, targetPort: 3001 }); + relayNote = result.note; + } else { + relayNote = 'skipped: noInbound set but meshIp/publicHost missing'; + } + await spoke.update({ relayNote }); + } + + logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug, noInbound: !!noInbound, relayNote }); + res.json({ status: 'ok', pushToken: spoke.pushToken, relay: { note: relayNote } }); } catch (e) { next(e); } }); diff --git a/nodejs/tests/proxy_client.test.js b/nodejs/tests/proxy_client.test.js new file mode 100644 index 0000000..ccf410a --- /dev/null +++ b/nodejs/tests/proxy_client.test.js @@ -0,0 +1,98 @@ +'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('proxy_client', () => { + let proxyClient; + let originalFetch; + let mockFetchImpl; + let calls; + + beforeEach(() => { + jest.resetModules(); + mockBaoStore = new Map(); + calls = []; + mockFetchImpl = async () => ({ ok: true, status: 404 }); + originalFetch = global.fetch; + global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); }; + proxyClient = require('../utils/proxy_client'); + proxyClient._reset(); + delete process.env.PROXY_INTERNAL_URL; + }); + + afterEach(() => { + global.fetch = originalFetch; + }); + + test('skips cleanly when required fields are missing', async () => { + const result = await proxyClient.ensureRelayRoute({ host: '', ip: '', targetPort: 0 }); + expect(result.note).toMatch(/required/); + expect(calls.length).toBe(0); + }); + + test('skips cleanly when PROXY_INTERNAL_URL is not configured', async () => { + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toMatch(/PROXY_INTERNAL_URL/); + expect(calls.length).toBe(0); + }); + + test('skips cleanly when no token is stored in OpenBao', async () => { + process.env.PROXY_INTERNAL_URL = 'https://proxy.internal'; + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toMatch(/no proxy API token/); + expect(calls.length).toBe(0); + }); + + test('creates the route when the host does not already exist', async () => { + process.env.PROXY_INTERNAL_URL = 'https://proxy.internal'; + mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' }); + mockFetchImpl = async (url, opts) => { + if (opts.method === undefined) return { ok: true, status: 404 }; // GET lookup + if (opts.method === 'POST') return { ok: true, status: 200 }; + throw new Error('unexpected method ' + opts.method); + }; + + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toBe('created'); + + const postCall = calls.find((c) => c[1].method === 'POST'); + expect(postCall[0]).toBe('https://proxy.internal/api/host'); + expect(postCall[1].headers.Authorization).toBe('Bearer prx_test_token'); + expect(JSON.parse(postCall[1].body)).toEqual({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + }); + + test('updates the route when it exists but points somewhere else', async () => { + process.env.PROXY_INTERNAL_URL = 'https://proxy.internal'; + mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' }); + mockFetchImpl = async (url, opts) => { + if (!opts.method) return { ok: true, status: 200, json: async () => ({ results: { ip: '172.24.9.9', targetPort: 3001 } }) }; + if (opts.method === 'PUT') return { ok: true, status: 200 }; + throw new Error('unexpected method ' + opts.method); + }; + + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toBe('updated'); + }); + + test('is a no-op when the route already matches', async () => { + process.env.PROXY_INTERNAL_URL = 'https://proxy.internal'; + mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' }); + mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ results: { ip: '172.24.5.1', targetPort: 3001 } }) }); + + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toBe('already up to date'); + }); + + test('reports a network failure without throwing', async () => { + process.env.PROXY_INTERNAL_URL = 'https://proxy.internal'; + mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' }); + mockFetchImpl = async () => { throw new Error('connection refused'); }; + + const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 }); + expect(result.note).toMatch(/failed: connection refused/); + }); +}); diff --git a/nodejs/utils/proxy_client.js b/nodejs/utils/proxy_client.js new file mode 100644 index 0000000..fd5d131 --- /dev/null +++ b/nodejs/utils/proxy_client.js @@ -0,0 +1,105 @@ +'use strict'; + +// Service-to-service client for theta-proxy's Host management API -- +// MULTI_SITE_SPEC.md's "no-inbound relay automation" (a master creating a +// relay route so a spoke with zero inbound path of its own is reachable). +// +// Deliberately does NOT invent a new credential type. theta-proxy already +// has a self-service API token system (models/api_token.js, `prx__` +// bearer tokens that authenticate as their creator's user + group snapshot -- +// same pattern this app and jump-host both already have their own copy of). +// The "service-to-service auth" gap was never "no credential type exists" -- +// it's that nothing wired one of these tokens into an actual inter-service +// call. This is that wiring, using the credential type that was already +// there. The token itself is operator-provisioned (minted on theta-proxy by +// an admin with Host-management rights) and stored in OpenBao, same as the +// agent-signing key in agent_keys.js. + +const baoConf = require('@simpleworkjs/bao-conf'); + +const PATH = 'integrations/theta-proxy'; // 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(`[proxy_client] could not read ${PATH} from OpenBao: ${err.message}`); + return null; + } + if (!stored || !stored.token) return null; + cachedToken = stored.token; + return cachedToken; +} + +function proxyBaseUrl() { + // Not OpenBao -- this is where the proxy's admin API lives, not a secret. + // No safe default: relaying to a guessed host would be worse than + // refusing, so this must be explicitly configured. + return process.env.PROXY_INTERNAL_URL || ''; +} + +// Creates the relay Host route if missing, updates its target IP if it +// already exists and points somewhere else. Idempotent -- safe to call +// again for the same host on every spoke resync. +// +// Returns { note } describing what happened (created/updated/skipped/failed) +// rather than throwing on a missing token or base URL -- callers (spoke +// registration) must never fail the whole registration just because this +// automation isn't configured yet; it's an enhancement layered on top of a +// working join, not a requirement of one. +async function ensureRelayRoute({ host, ip, targetPort }) { + if (!host || !ip || !targetPort) { + return { note: 'skipped: host, ip, and targetPort are all required' }; + } + const base = proxyBaseUrl(); + if (!base) { + return { note: 'skipped: PROXY_INTERNAL_URL not configured' }; + } + const token = await loadToken(); + if (!token) { + return { note: `skipped: no proxy API token at OpenBao ${PATH} -- mint one on theta-proxy and store it there` }; + } + + const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' }; + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + try { + const existing = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), { + headers, signal: controller.signal + }); + + if (existing.status === 200) { + // GET /api/host/:item wraps the record in { item, results }, not + // flat -- confirmed against a real running proxy (this check + // silently always "updated" instead of no-op'ing until fixed). + const body = await existing.json(); + const current = body.results || body; + if (current.ip === ip && Number(current.targetPort) === Number(targetPort)) { + return { note: 'already up to date' }; + } + const put = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), { + method: 'PUT', headers, body: JSON.stringify({ ip, targetPort }), signal: controller.signal + }); + return put.ok ? { note: 'updated' } : { note: `update failed: HTTP ${put.status}` }; + } + + const create = await fetch(base.replace(/\/+$/, '') + '/api/host', { + method: 'POST', headers, body: JSON.stringify({ host, ip, targetPort }), signal: controller.signal + }); + return create.ok ? { note: 'created' } : { note: `create failed: HTTP ${create.status}` }; + } catch (err) { + return { note: `failed: ${err.message}` }; + } finally { + clearTimeout(timer); + } +} + +// Test seam. +function _reset() { cachedToken = null; } + +module.exports = { ensureRelayRoute, _reset, PATH };