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.
This commit is contained in:
2026-08-10 20:31:42 -04:00
parent a50d1ef3c8
commit b95cb08c41
5 changed files with 237 additions and 8 deletions
+105
View File
@@ -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_<id>_<secret>`
// 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 };