mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 10:46:51 +00:00
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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -20,17 +20,13 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
const proxy = createProxyMiddleware<Request, Response>({
|
const proxy = createProxyMiddleware<Request, Response>({
|
||||||
target: 'http://localhost:0', // placeholder - overridden per-request by router
|
target: 'http://localhost:0', // placeholder - overridden per-request by router
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
router: (req) => {
|
router: (req) => req.proxyTarget?.apiUrl.replace(/\/$/, ''),
|
||||||
const node = NodeRegistry.getInstance().getNode(req.nodeId);
|
|
||||||
return node?.api_url?.replace(/\/$/, '');
|
|
||||||
},
|
|
||||||
// When mounted at app.use('/api/', ...), Express strips the '/api/' prefix from
|
// 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
|
// 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').
|
// receives the full path (e.g. '/stats' becomes '/api/stats').
|
||||||
pathRewrite: (path) => '/api' + path,
|
pathRewrite: (path) => '/api' + path,
|
||||||
on: {
|
on: {
|
||||||
proxyReq: (proxyReq, req) => {
|
proxyReq: (proxyReq, req) => {
|
||||||
const node = NodeRegistry.getInstance().getNode(req.nodeId);
|
|
||||||
// Strip headers that must not reach the remote instance:
|
// Strip headers that must not reach the remote instance:
|
||||||
// - x-node-id: remote Sencho treats all requests as local
|
// - x-node-id: remote Sencho treats all requests as local
|
||||||
// - cookie: the browser's sencho_token is signed with THIS instance's JWT secret;
|
// - 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.
|
// Authentication is handled exclusively via the Bearer token below.
|
||||||
proxyReq.removeHeader('x-node-id');
|
proxyReq.removeHeader('x-node-id');
|
||||||
proxyReq.removeHeader('cookie');
|
proxyReq.removeHeader('cookie');
|
||||||
if (node?.api_token) {
|
// Pilot-agent targets carry an empty token; see NodeRegistry.getProxyTarget.
|
||||||
proxyReq.setHeader('Authorization', `Bearer ${node.api_token}`);
|
if (req.proxyTarget?.apiToken) {
|
||||||
|
proxyReq.setHeader('Authorization', `Bearer ${req.proxyTarget.apiToken}`);
|
||||||
}
|
}
|
||||||
// Distributed License Enforcement: assert the main instance's license
|
// Distributed License Enforcement: assert the main instance's license
|
||||||
// tier to the remote node so tier-gated routes honor the main's
|
// tier to the remote node so tier-gated routes honor the main's
|
||||||
@@ -107,13 +104,21 @@ export function createRemoteProxyMiddleware(): RequestHandler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!node.api_url || !node.api_token) {
|
const target = NodeRegistry.getInstance().getProxyTarget(req.nodeId);
|
||||||
res.status(503).json({
|
if (!target) {
|
||||||
error: `Remote node "${node.name}" has no API URL or token configured. Update it in Settings → Nodes.`,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
req.proxyTarget = target;
|
||||||
proxy(req, res, next);
|
proxy(req, res, next);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ declare global {
|
|||||||
mfaPendingUserId?: number;
|
mfaPendingUserId?: number;
|
||||||
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
/** True when the pending MFA session originated from an SSO login (LDAP or OIDC) rather than a password login. */
|
||||||
mfaPendingSso?: boolean;
|
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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user