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:
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user