mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 07:36:40 +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,116 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import path from 'path';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { HostTerminalService } from '../services/HostTerminalService';
|
||||
import {
|
||||
LicenseService,
|
||||
isLicenseTier,
|
||||
isLicenseVariant,
|
||||
normalizeTier,
|
||||
normalizeVariant,
|
||||
PROXY_TIER_HEADER,
|
||||
PROXY_VARIANT_HEADER,
|
||||
} from '../services/LicenseService';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
import type { UserRole } from '../services/DatabaseService';
|
||||
import { getErrorMessage } from '../utils/errors';
|
||||
import { rejectUpgrade as reject } from './reject';
|
||||
|
||||
interface HostConsoleContext {
|
||||
nodeId: number;
|
||||
decoded: { scope?: string; username?: string };
|
||||
isProxyToken: boolean;
|
||||
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
||||
stackParam: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle `/api/system/host-console` WebSocket upgrades.
|
||||
*
|
||||
* Enforces three gates before spawning the host PTY:
|
||||
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
|
||||
* interactive host shell.
|
||||
* 2. RBAC: user session tokens require the `system:console` permission.
|
||||
* console_session tokens are pre-gated at issuance (see
|
||||
* `routes/console.ts`) and skip this check.
|
||||
* 3. License: host console requires paid + admiral. For console_session
|
||||
* tokens the tier/variant is trusted from the gateway-supplied headers;
|
||||
* otherwise the local LicenseService is consulted.
|
||||
*/
|
||||
export function handleHostConsoleWs(
|
||||
req: IncomingMessage,
|
||||
socket: Duplex,
|
||||
head: Buffer,
|
||||
ctx: HostConsoleContext,
|
||||
): void {
|
||||
const { nodeId, decoded, isProxyToken, wsResolvedUser, stackParam } = ctx;
|
||||
|
||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||
|
||||
const isConsoleSession = decoded.scope === 'console_session';
|
||||
if (!isConsoleSession) {
|
||||
const userRole = wsResolvedUser?.role;
|
||||
const consolePermission: PermissionAction = 'system:console';
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
|
||||
console.log('[HostConsole] Access denied: insufficient permissions', {
|
||||
username: wsResolvedUser?.username || decoded.username,
|
||||
role: userRole,
|
||||
});
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
const ls = LicenseService.getInstance();
|
||||
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
||||
? normalizeTier(consoleTierHeader)
|
||||
: ls.getTier();
|
||||
const consoleVariant = (isConsoleSession && consoleVariantHeader !== undefined && isLicenseVariant(consoleVariantHeader))
|
||||
? normalizeVariant(consoleVariantHeader)
|
||||
: ls.getVariant();
|
||||
if (consoleTier !== 'paid' || consoleVariant !== 'admiral') {
|
||||
return reject(socket, 403, 'Forbidden');
|
||||
}
|
||||
|
||||
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
||||
console.log('[HostConsole] WebSocket upgrade accepted', {
|
||||
username: consoleUsername,
|
||||
nodeId,
|
||||
stack: stackParam || '(root)',
|
||||
});
|
||||
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
try {
|
||||
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
if (stackParam) {
|
||||
const resolved = path.resolve(baseDir, stackParam);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
ws.send('Error: Invalid stack path\r\n');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
targetDirectory = resolved;
|
||||
} else {
|
||||
targetDirectory = baseDir;
|
||||
}
|
||||
} catch {
|
||||
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
||||
}
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
|
||||
} catch (error) {
|
||||
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('Error: Failed to start terminal session.\r\n');
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user