mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-26 02:06:49 +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,121 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import DockerController from '../services/DockerController';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
|
||||
/**
|
||||
* Module-scope singleton: the most recent WebSocket to send
|
||||
* `{action: 'connectTerminal'}` receives streaming output from any subsequent
|
||||
* compose deploy/down/update. Routes that want to echo compose progress read
|
||||
* the current value via `getTerminalWs()`.
|
||||
*
|
||||
* Intentionally single-instance. If multiple clients connect, the last one
|
||||
* wins. This matches pre-refactor behavior; race-hardening is a separate
|
||||
* concern.
|
||||
*/
|
||||
let terminalWs: WebSocket | undefined;
|
||||
|
||||
export function getTerminalWs(): WebSocket | undefined {
|
||||
return terminalWs;
|
||||
}
|
||||
|
||||
interface GenericContext {
|
||||
decoded: { scope?: string; username?: string; tv?: number };
|
||||
isProxyToken: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle the generic `/ws` upgrade: terminal (container exec) and streaming
|
||||
* stats. Gates node-proxy tokens and non-admin users; the subsequent
|
||||
* connection handler processes `{action: ...}` messages from the client.
|
||||
*/
|
||||
export function handleGenericWs(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
wss: WebSocketServer,
|
||||
ctx: GenericContext,
|
||||
): void {
|
||||
const { decoded, isProxyToken } = ctx;
|
||||
|
||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||
|
||||
// Admin enforcement: container exec requires admin role.
|
||||
// console_session tokens are already admin-gated at creation time.
|
||||
// API tokens reaching this point have full-admin scope (read-only /
|
||||
// deploy-only are blocked by the upgrade handler's scope gate).
|
||||
if (!decoded.scope) {
|
||||
const execUser = decoded.username ? DatabaseService.getInstance().getUserByUsername(decoded.username) : undefined;
|
||||
if (!execUser) {
|
||||
console.warn('[Exec] User account not found:', decoded.username);
|
||||
return reject(socket, 401, 'Unauthorized');
|
||||
}
|
||||
if (decoded.tv !== undefined && execUser.token_version !== decoded.tv) {
|
||||
console.warn('[Exec] Session invalidated (token version mismatch):', decoded.username);
|
||||
return reject(socket, 401, 'Unauthorized');
|
||||
}
|
||||
if (execUser.role !== 'admin') {
|
||||
console.warn('[Exec] Non-admin user rejected:', decoded.username);
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
if (isDebugEnabled()) {
|
||||
console.debug('[Exec:diag] WS upgrade for exec path', {
|
||||
username: decoded.username,
|
||||
scope: decoded.scope || 'user-session',
|
||||
});
|
||||
}
|
||||
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||
wss.emit('connection', ws, req);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wire up the `connection` handler on the main wss. Processes `{action}`
|
||||
* messages for `connectTerminal` (captures the ws for deploy-output
|
||||
* streaming), `streamStats`, and `execContainer`. `{type}` messages (input,
|
||||
* resize, ping) are handled by per-session listeners registered inside
|
||||
* `execContainer`'s closure.
|
||||
*/
|
||||
export function attachGenericConnectionHandlers(wss: WebSocketServer): void {
|
||||
wss.on('connection', (ws) => {
|
||||
console.log('WebSocket connected');
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message.toString());
|
||||
if (!data.action) return;
|
||||
|
||||
if (data.action === 'connectTerminal') {
|
||||
terminalWs = ws;
|
||||
} else if (data.action === 'streamStats') {
|
||||
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
// When a WS is proxied from a gateway to this remote instance, the
|
||||
// nodeId in the message belongs to the gateway's DB and won't
|
||||
// resolve locally. Fall back to local.
|
||||
let nodeId = requestedId;
|
||||
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
|
||||
DockerController.getInstance(nodeId).streamStats(data.containerId, ws).catch((err: Error) => {
|
||||
console.error('[WS] streamStats error:', err.message);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
} else if (data.action === 'execContainer') {
|
||||
const requestedId = data.nodeId ? parseInt(data.nodeId, 10) : NodeRegistry.getInstance().getDefaultNodeId();
|
||||
let nodeId = requestedId;
|
||||
try { NodeRegistry.getInstance().getDocker(requestedId); } catch { nodeId = NodeRegistry.getInstance().getDefaultNodeId(); }
|
||||
DockerController.getInstance(nodeId).execContainer(data.containerId, ws).catch((err: Error) => {
|
||||
console.error('[WS] execContainer error:', err.message);
|
||||
if (ws.readyState === WebSocket.OPEN) ws.close();
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Malformed JSON - ignore silently
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user