From 94ce7c71d24bc16656ca1bb8167f469b3ffd33b5 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 8 May 2026 09:03:22 -0400 Subject: [PATCH] fix(proxy): route pilot-agent HTTP via PilotTunnelBridge loopback (#989) The remote-node HTTP proxy resolved targets by reading nodes.api_url and nodes.api_token directly from the database. Both fields are empty for pilot-agent nodes by design, which produced a misleading 503 ("no API URL or token configured. Update it in Settings, Nodes.") for any API call targeting a healthy pilot-agent: stack creation, log retrieval, and every other resource a pilot-agent should serve. NodeRegistry.getProxyTarget already encapsulates the correct dispatch. For proxy mode it returns the persisted api_url and api_token. For pilot-agent it returns the loopback URL of the active PilotTunnelBridge with an empty token, since the bridge re-authenticates implicitly via the pre-verified tunnel socket. Switch all three lookup sites in remoteNodeProxy to this helper, cache the resolved target on req.proxyTarget so the http-proxy router and proxyReq callbacks do not re-resolve, and split the 503 message so pilot-agent operators see "Pilot tunnel to X is disconnected" instead of the proxy-mode hint. --- .../src/__tests__/pilot-agent-proxy.test.ts | 91 +++++++++++++++++++ backend/src/proxy/remoteNodeProxy.ts | 27 +++--- backend/src/types/express.ts | 2 + 3 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 backend/src/__tests__/pilot-agent-proxy.test.ts diff --git a/backend/src/__tests__/pilot-agent-proxy.test.ts b/backend/src/__tests__/pilot-agent-proxy.test.ts new file mode 100644 index 00000000..fd57a977 --- /dev/null +++ b/backend/src/__tests__/pilot-agent-proxy.test.ts @@ -0,0 +1,91 @@ +/** + * Regression guard for the pilot-agent dispatch path in the remote-node HTTP + * proxy. + * + * `docs/internal/architecture/pilot-agent.md` describes the intended routing: + * `PilotTunnelBridge` opens a loopback HTTP server and `NodeRegistry.getProxyTarget` + * returns that loopback URL for pilot-agent nodes. The proxy middleware must + * resolve targets via `getProxyTarget` rather than reading `node.api_url` + * directly, otherwise pilot-agent nodes can never accept HTTP API calls + * regardless of tunnel state. + * + * This test covers two assertions on the response gate: + * 1. A pilot-agent node with no active tunnel returns 503 with a + * tunnel-disconnected message (not the proxy-mode "configure api_url" + * message, which would mislead the operator). + * 2. A proxy-mode node missing api_url still returns the original + * configuration message, so the diagnostic preserved for proxy mode is + * not lost. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb'; + +describe('remoteNodeProxy pilot-agent dispatch', () => { + let tmpDir: string; + let app: import('express').Express; + let authHeader: string; + let pilotAgentNodeId: number; + let proxyModeMissingNodeId: number; + + beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + + const { DatabaseService } = await import('../services/DatabaseService'); + + pilotAgentNodeId = DatabaseService.getInstance().addNode({ + name: 'pilot-agent-test', + type: 'remote', + mode: 'pilot_agent', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + proxyModeMissingNodeId = DatabaseService.getInstance().addNode({ + name: 'proxy-mode-misconfigured', + type: 'remote', + mode: 'proxy', + compose_dir: '/tmp', + is_default: false, + api_url: '', + api_token: '', + }); + + const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' }); + authHeader = `Bearer ${token}`; + }); + + afterAll(() => { + cleanupTestDb(tmpDir); + }); + + it('returns 503 with a pilot-tunnel-disconnected message when no tunnel is active', async () => { + const res = await request(app) + .post('/api/stacks') + .set('Authorization', authHeader) + .set('x-node-id', String(pilotAgentNodeId)) + .send({ name: 'whatever' }); + + expect(res.status).toBe(503); + expect(res.body?.error).toMatch(/pilot tunnel/i); + expect(res.body?.error).toMatch(/disconnected/i); + expect(res.body?.error).not.toMatch(/api url or token/i); + }); + + it('returns 503 with the configuration message for proxy-mode nodes missing api_url', async () => { + const res = await request(app) + .post('/api/stacks') + .set('Authorization', authHeader) + .set('x-node-id', String(proxyModeMissingNodeId)) + .send({ name: 'whatever' }); + + expect(res.status).toBe(503); + expect(res.body?.error).toMatch(/api url or token/i); + expect(res.body?.error).toMatch(/Settings/); + expect(res.body?.error).not.toMatch(/pilot tunnel/i); + }); +}); diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index e444ac26..a8d073f9 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -20,17 +20,13 @@ export function createRemoteProxyMiddleware(): RequestHandler { const proxy = createProxyMiddleware({ target: 'http://localhost:0', // placeholder - overridden per-request by router changeOrigin: true, - router: (req) => { - const node = NodeRegistry.getInstance().getNode(req.nodeId); - return node?.api_url?.replace(/\/$/, ''); - }, + router: (req) => req.proxyTarget?.apiUrl.replace(/\/$/, ''), // When mounted at app.use('/api/', ...), Express strips the '/api/' prefix from // req.url before the middleware sees it. Re-add it so the remote Sencho instance // receives the full path (e.g. '/stats' becomes '/api/stats'). pathRewrite: (path) => '/api' + path, on: { proxyReq: (proxyReq, req) => { - const node = NodeRegistry.getInstance().getNode(req.nodeId); // Strip headers that must not reach the remote instance: // - x-node-id: remote Sencho treats all requests as local // - cookie: the browser's sencho_token is signed with THIS instance's JWT secret; @@ -38,8 +34,9 @@ export function createRemoteProxyMiddleware(): RequestHandler { // Authentication is handled exclusively via the Bearer token below. proxyReq.removeHeader('x-node-id'); proxyReq.removeHeader('cookie'); - if (node?.api_token) { - proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`); + // Pilot-agent targets carry an empty token; see NodeRegistry.getProxyTarget. + if (req.proxyTarget?.apiToken) { + proxyReq.setHeader('Authorization', `Bearer ${req.proxyTarget.apiToken}`); } // Distributed License Enforcement: assert the main instance's license // tier to the remote node so tier-gated routes honor the main's @@ -107,13 +104,21 @@ export function createRemoteProxyMiddleware(): RequestHandler { return; } - if (!node.api_url || !node.api_token) { - res.status(503).json({ - error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`, - }); + const target = NodeRegistry.getInstance().getProxyTarget(req.nodeId); + if (!target) { + if (node.mode === 'pilot_agent') { + res.status(503).json({ + error: `Pilot tunnel to "${node.name}" is disconnected. Operations will resume when the agent reconnects.`, + }); + } else { + res.status(503).json({ + error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`, + }); + } return; } + req.proxyTarget = target; proxy(req, res, next); }; } diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 20bbebc5..673fe045 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -19,6 +19,8 @@ declare global { mfaPendingUserId?: number; /** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */ mfaPendingSso?: boolean; + /** Cached remote-proxy target resolved by `remoteNodeProxy`'s outer gate so the http-proxy router/proxyReq callbacks do not re-resolve. */ + proxyTarget?: { apiUrl: string; apiToken: string }; } } }