mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +00:00
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 || '/'.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import type { Node } from '../services/DatabaseService';
|
||||
import {
|
||||
LicenseService,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
import { wsProxyServer } from '../proxy/websocketProxy';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
|
||||
/**
|
||||
* Forward a WebSocket upgrade to a remote Sencho instance. Handles the
|
||||
* console_session token exchange for interactive paths so the long-lived
|
||||
* api_token never reaches an interactive terminal (the remote's upgrade
|
||||
* handler rejects node_proxy tokens on those paths).
|
||||
*
|
||||
* The caller must have already established that `node.type === 'remote'`
|
||||
* and that `api_url` + `api_token` are present.
|
||||
*/
|
||||
export async function handleRemoteForwarder(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
opts: { node: Node; pathname: string },
|
||||
): Promise<void> {
|
||||
const { node, pathname } = opts;
|
||||
// Guaranteed non-null by caller; assert here so the rest of the function is nullsafe.
|
||||
if (!node.api_url || !node.api_token) return reject(socket, 503, 'Service Unavailable');
|
||||
|
||||
const wsTarget = node.api_url.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
|
||||
|
||||
// Interactive console paths (host console / container exec) are guarded on
|
||||
// the remote by an isProxyToken check that rejects the long-lived api_token.
|
||||
// Exchange it for a short-lived console_session token before forwarding so
|
||||
// the remote allows the connection while keeping the guard intact for
|
||||
// direct api_token access.
|
||||
const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
|
||||
let bearerTokenForProxy = node.api_token;
|
||||
if (isInteractiveConsolePath) {
|
||||
try {
|
||||
const ls = LicenseService.getInstance();
|
||||
const tokenRes = await fetch(`${node.api_url.replace(/\/$/, '')}/api/system/console-token`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${node.api_token}`,
|
||||
[PROXY_TIER_HEADER]: ls.getTier(),
|
||||
[PROXY_VARIANT_HEADER]: ls.getVariant() || '',
|
||||
},
|
||||
});
|
||||
if (!tokenRes.ok) {
|
||||
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
|
||||
return reject(socket, 502, 'Bad Gateway');
|
||||
}
|
||||
const data = await tokenRes.json() as { token?: string };
|
||||
if (typeof data.token === 'string') bearerTokenForProxy = data.token;
|
||||
} catch (e) {
|
||||
console.error('[WS Proxy] Failed to fetch remote console token:', getErrorMessage(e, 'unknown'));
|
||||
return reject(socket, 502, 'Bad Gateway');
|
||||
}
|
||||
}
|
||||
|
||||
req.headers['authorization'] = `Bearer ${bearerTokenForProxy}`;
|
||||
delete req.headers['x-node-id'];
|
||||
// Strip the browser's session cookie: signed by this instance's JWT secret
|
||||
// and would fail verification on the remote. Auth is handled exclusively
|
||||
// via the Bearer token.
|
||||
delete req.headers['cookie'];
|
||||
const wsLs = LicenseService.getInstance();
|
||||
req.headers[PROXY_TIER_HEADER] = wsLs.getTier();
|
||||
req.headers[PROXY_VARIANT_HEADER] = wsLs.getVariant() || '';
|
||||
// Strip nodeId from the forwarded URL so the remote treats the request as
|
||||
// local. The remote has no record of the gateway's nodeId; leaving it would
|
||||
// trigger nodeContext's 404 branch.
|
||||
const fwdUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
||||
fwdUrl.searchParams.delete('nodeId');
|
||||
req.url = fwdUrl.pathname + (fwdUrl.searchParams.toString() ? `?${fwdUrl.searchParams.toString()}` : '');
|
||||
wsProxyServer.ws(req, socket, head, { target: wsTarget });
|
||||
}
|
||||
Reference in New Issue
Block a user