Files
sencho/backend/src/proxy/remoteNodeProxy.ts
T
Anso dc3699189d refactor(backend): extract remote proxy, WebSocket upgrade handler, and server factory (phase 3) (#733)
Phase 3 of the index.ts refactor. Pulls the remote HTTP/WS proxy plumbing,
the WebSocket upgrade dispatcher, and the http/WSS construction out of the
monolith. index.ts drops roughly 620 lines.

New modules:
- proxy/websocketProxy.ts: shared httpProxy.createProxyServer singleton
  (used by both the HTTP proxy middleware and the remote WS forwarder)
- proxy/remoteNodeProxy.ts: createRemoteProxyMiddleware() factory; consumes
  the isProxyExemptPath helper instead of open-coding the prefix list
- server.ts: createServer(app) returns { server, wss, pilotTunnelWss }
- services/FleetUpdateTrackerService.ts: singleton wrapping the in-flight
  fleet update tracker Map with create()/resolve() helpers
- helpers/consoleSession.ts: mintConsoleSession(), isConsoleSessionScope()
- websocket/upgradeHandler.ts: attachUpgrade(server, deps) dispatcher that
  runs the manual cookie/JWT verify and delegates to sub-handlers
- websocket/pilotTunnel.ts: handlePilotTunnel (pilot_enroll consumption and
  pilot_tunnel registration)
- websocket/notifications.ts: /ws/notifications local subscriber
- websocket/remoteForwarder.ts: remote-node WS proxy with console_session
  token exchange for interactive paths
- websocket/logs.ts: /api/stacks/:name/logs supervisor stream
- websocket/hostConsole.ts: /api/system/host-console PTY, Admiral-gated
- websocket/generic.ts: /ws exec + streamStats action dispatch, owns the
  terminalWs single-instance reference
- websocket/reject.ts: shared rejectUpgrade helper (replaces five copies)

Service extension:
- NotificationService: setBroadcaster(fn) replaced by subscribe(ws) that
  returns an unsubscriber; broadcastToSubscribers is now internal. Subscriber
  set lives on the service rather than in index.ts.

Wiring in index.ts:
- const app = createApp() already in place from Phase 2
- const { server, wss, pilotTunnelWss } = createServer(app)
- attachUpgrade(server, { wss, pilotTunnelWss })
- app.use('/api/', createRemoteProxyMiddleware())
- /api/system/console-token route now uses mintConsoleSession()
- deploy/down/update routes read the streaming target via getTerminalWs()
  (return type is WebSocket | undefined so the || undefined fallback is gone)

Code review fixes: five duplicated reject helpers collapsed into
websocket/reject.ts; dropped the createTracker/resolveTracker bind
aliases in index.ts so call sites go through the service directly;
removed em dashes; replaced req.url! with req.url || '/'.
2026-04-23 19:31:16 -04:00

121 lines
5.7 KiB
TypeScript

import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
import { NodeRegistry } from '../services/NodeRegistry';
import {
LicenseService,
PROXY_TIER_HEADER,
PROXY_VARIANT_HEADER,
} from '../services/LicenseService';
import { isProxyExemptPath } from '../helpers/proxyExemptPaths';
import { getErrorMessage } from '../utils/errors';
/**
* Build the remote-node HTTP proxy middleware. Mount once at `/api/` after
* authGate / auditLog / apiTokenScope; the middleware decides per-request
* whether to proxy or call next().
*
* A single http-proxy instance is shared across all remote nodes so we do not
* accumulate 'close' listeners or re-trigger the DEP0060 `util._extend`
* warning on every request (which the old per-handler factory pattern did).
* Per-request target resolution is handled via the `router` option.
*/
export function createRemoteProxyMiddleware(): RequestHandler {
const proxy = createProxyMiddleware<Request, Response>({
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(/\/$/, '');
},
// 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;
// the remote would try to verify it with its own secret and return 401.
// 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}`);
}
// Distributed License Enforcement: assert the main instance's license
// tier to the remote node so tier-gated routes honor the main's
// license instead of the node's local (likely Community) tier. The
// remote's authMiddleware only trusts these headers when the request
// carries a valid node_proxy JWT.
const proxyLs = LicenseService.getInstance();
proxyReq.setHeader(PROXY_TIER_HEADER, proxyLs.getTier());
proxyReq.setHeader(PROXY_VARIANT_HEADER, proxyLs.getVariant() || '');
// Strip the ?nodeId= query param so the remote's nodeContextMiddleware
// doesn't reject the request with 404 ("Node X not found") - the remote
// has no record of the gateway's node IDs and should treat the request
// as local. This affects endpoints like EventSource /api/containers/:id/logs
// that pass nodeId as a query param rather than the x-node-id header.
if (proxyReq.path.includes('nodeId=')) {
const [pathname, qs] = proxyReq.path.split('?');
const params = new URLSearchParams(qs || '');
params.delete('nodeId');
const newQs = params.toString();
proxyReq.path = pathname + (newQs ? `?${newQs}` : '');
}
// Body forwarding: conditionalJsonParser skips parsing for remote
// requests (see middleware/jsonParser.ts), so req's raw stream is
// intact and http-proxy's req.pipe(proxyReq) forwards the body
// automatically.
},
proxyRes: (proxyRes) => {
// Mark every response forwarded from a remote node with a sentinel
// header. The frontend (apiFetch / fetchForNode) checks this before
// firing the global 'sencho-unauthorized' event: a 401 from a remote
// means the stored api_token for that node is invalid, not that the
// user's own session expired. Without this distinction, any node with
// a bad token causes an immediate logout loop.
proxyRes.headers['x-sencho-proxy'] = '1';
},
error: (err, _req, proxyRes) => {
console.error('[Proxy] Remote node error:', getErrorMessage(err, 'unknown'));
// proxyRes can be either a ServerResponse (HTTP) or a raw Socket
// (WS/TCP errors). Only attempt to send an HTTP 502 if it is a
// proper ServerResponse with a headersSent flag; otherwise silently
// drop (the socket will be destroyed).
const res = proxyRes as { headersSent?: boolean; status?: (n: number) => { json: (b: unknown) => void } };
if (typeof res?.headersSent === 'boolean' && !res.headersSent && typeof res.status === 'function') {
res.status(502).json({
error: 'Remote node is unreachable. Check the API URL and ensure Sencho is running on that host.',
});
}
},
},
});
return (req: Request, res: Response, next: NextFunction): void => {
// The `/api/` mount strips the `/api` prefix, so req.path is now `/auth/…`,
// `/nodes/…`, etc. Gateway-level concerns are always handled locally.
if (isProxyExemptPath(`/api${req.path}`)) {
next();
return;
}
const node = NodeRegistry.getInstance().getNode(req.nodeId);
if (!node || node.type !== 'remote') {
next();
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.`,
});
return;
}
proxy(req, res, next);
};
}