Files
sencho/backend/src/websocket/hostConsole.ts
T
Anso dd54a2e483 feat: graduate Host Console to Community admins (#1669)
* feat: graduate Host Console to Community admins

Make Host Console available to Community and Admiral admins (system:console), add host-console-community for mixed fleets, and keep opaque API tokens off the host shell.

* docs: document Host Console deep links

Cover root and stack-scoped Console URLs, correct the phone treatment note, and pin parse/build round-trips in senchoRoute tests.

* fix: bind Host Console socket to the resolved node

Treat unresolved activeNode as loading, target the WebSocket with an explicit nodeId, and wait for stack deep-link hydration so the shell cannot open on the wrong node or compose root. Add regression coverage for node/stack retargeting and fail-closed directory resolution.

* fix: harden Host Console node binding, audit acting_as, and console_session tokens

Reject unknown or malformed nodeIds before spawning a PTY. Record hub operators in audit_log.acting_as for remote console_session bridges. Path-scope and one-time-consume console_session JWTs so Host Console mints cannot open container exec or be replayed.

* test: expect acting_as in audit CSV export header

Align the CSV export assertion with the P0-2B acting_as column added to audit log exports.
2026-07-23 12:59:53 -04:00

122 lines
4.5 KiB
TypeScript

import type { IncomingMessage } from 'http';
import type { Duplex } from 'stream';
import WebSocket, { WebSocketServer } from 'ws';
import { FileSystemService } from '../services/FileSystemService';
import { HostTerminalService } from '../services/HostTerminalService';
import { ROLE_PERMISSIONS } from '../middleware/permissions';
import type { UserRole } from '../services/DatabaseService';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { isConsoleSessionScope } from '../helpers/consoleSession';
interface HostConsoleContext {
nodeId: number;
decoded: { scope?: string; username?: string; acting_as?: string };
isProxyToken: boolean;
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
stackParam: string | null;
}
/**
* Handle `/api/system/host-console` WebSocket upgrades.
*
* Enforces two gates before spawning the host PTY:
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
* interactive host shell directly (remote forwarding mints a
* console_session via POST /console-token instead).
* 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.
*
* Path scoping and one-time jti consumption are enforced in upgradeHandler
* before this handler runs.
*/
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 = isConsoleSessionScope(decoded.scope);
if (!isConsoleSession) {
const userRole = wsResolvedUser?.role;
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
console.log('[HostConsole] Access denied: insufficient permissions', {
username: wsResolvedUser?.username || decoded.username,
role: userRole,
});
return reject(socket, 403, 'Forbidden');
}
}
// Option B: console_session principal stays "console_session"; hub operator
// is recorded separately as acting_as. Browser sessions use the real user.
const consoleUsername = isConsoleSession
? 'console_session'
: (wsResolvedUser?.username || decoded.username || 'unknown');
const actingAs = isConsoleSession && typeof decoded.acting_as === 'string' && decoded.acting_as
? decoded.acting_as
: null;
console.log('[HostConsole] WebSocket upgrade accepted', {
username: consoleUsername,
actingAs,
nodeId,
stack: stackParam || '(root)',
});
// Client IP for the audit trail. Express's req.ip is unavailable on a raw
// upgrade socket, so take the first x-forwarded-for hop and fall back to the
// socket address.
const forwarded = req.headers['x-forwarded-for'];
const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : '';
const ipAddress = xff || req.socket.remoteAddress || '';
const hostConsoleWss = new WebSocketServer({ noServer: true });
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
hostConsoleWss.close();
let targetDirectory: string;
try {
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
if (resolved === null) {
ws.send('Error: Invalid stack path\r\n');
ws.close();
return;
}
targetDirectory = resolved;
} catch (error) {
console.error('[HostConsole] Failed to resolve console directory', {
user: consoleUsername,
actingAs,
nodeId,
stack: stackParam || '(root)',
error: getErrorMessage(error, 'unknown'),
});
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to resolve console directory.\r\n');
ws.close();
}
return;
}
const auditCtx = { username: consoleUsername, actingAs, nodeId, ipAddress };
try {
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
} catch (error) {
console.error('[HostConsole] Unhandled spawn error:', {
user: consoleUsername,
actingAs,
error: getErrorMessage(error, 'unknown'),
});
if (ws.readyState === WebSocket.OPEN) {
ws.send('Error: Failed to start terminal session.\r\n');
ws.close();
}
}
});
}