mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
dc3699189d
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 || '/'.
121 lines
5.7 KiB
TypeScript
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);
|
|
};
|
|
}
|