mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
dd54a2e483
* 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.
122 lines
4.5 KiB
TypeScript
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();
|
|
}
|
|
}
|
|
});
|
|
}
|