mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 23:32:19 +00:00
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.
This commit is contained in:
@@ -2,22 +2,16 @@ import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { HostTerminalService } from '../services/HostTerminalService';
|
||||
import { PROXY_TIER_HEADER } from '../services/license-headers';
|
||||
import {
|
||||
isLicenseTier,
|
||||
normalizeTier,
|
||||
} from '../services/license-normalize';
|
||||
import { LicenseService } from '../services/LicenseService';
|
||||
import { ROLE_PERMISSIONS, type PermissionAction } from '../middleware/permissions';
|
||||
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 };
|
||||
decoded: { scope?: string; username?: string; acting_as?: string };
|
||||
isProxyToken: boolean;
|
||||
wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
||||
stackParam: string | null;
|
||||
@@ -26,15 +20,16 @@ interface HostConsoleContext {
|
||||
/**
|
||||
* Handle `/api/system/host-console` WebSocket upgrades.
|
||||
*
|
||||
* Enforces three gates before spawning the host PTY:
|
||||
* Enforces two gates before spawning the host PTY:
|
||||
* 1. Machine-credential rejection: node_proxy tokens cannot reach an
|
||||
* interactive host shell.
|
||||
* 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.
|
||||
* 3. License: host console requires the paid tier. For console_session
|
||||
* tokens the tier is trusted from the gateway-supplied header;
|
||||
* otherwise the local LicenseService is consulted.
|
||||
*
|
||||
* Path scoping and one-time jti consumption are enforced in upgradeHandler
|
||||
* before this handler runs.
|
||||
*/
|
||||
export function handleHostConsoleWs(
|
||||
req: IncomingMessage,
|
||||
@@ -46,11 +41,10 @@ export function handleHostConsoleWs(
|
||||
|
||||
if (isProxyToken) return reject(socket, 403, 'Forbidden');
|
||||
|
||||
const isConsoleSession = decoded.scope === 'console_session';
|
||||
const isConsoleSession = isConsoleSessionScope(decoded.scope);
|
||||
if (!isConsoleSession) {
|
||||
const userRole = wsResolvedUser?.role;
|
||||
const consolePermission: PermissionAction = 'system:console';
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes(consolePermission)) {
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
|
||||
console.log('[HostConsole] Access denied: insufficient permissions', {
|
||||
username: wsResolvedUser?.username || decoded.username,
|
||||
role: userRole,
|
||||
@@ -59,18 +53,18 @@ export function handleHostConsoleWs(
|
||||
}
|
||||
}
|
||||
|
||||
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const ls = LicenseService.getInstance();
|
||||
const consoleTier = (isConsoleSession && isLicenseTier(consoleTierHeader))
|
||||
? normalizeTier(consoleTierHeader)
|
||||
: ls.getTier();
|
||||
if (consoleTier !== 'paid') {
|
||||
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;
|
||||
|
||||
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
||||
console.log('[HostConsole] WebSocket upgrade accepted', {
|
||||
username: consoleUsername,
|
||||
actingAs,
|
||||
nodeId,
|
||||
stack: stackParam || '(root)',
|
||||
});
|
||||
@@ -86,10 +80,6 @@ export function handleHostConsoleWs(
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory: string;
|
||||
// The shell may end up rooted at a different node than requested if the
|
||||
// requested node's directory cannot be resolved; the audit row must name
|
||||
// the node the shell actually runs in, so track it alongside the directory.
|
||||
let auditNodeId: number = nodeId;
|
||||
try {
|
||||
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
|
||||
@@ -100,22 +90,28 @@ export function handleHostConsoleWs(
|
||||
}
|
||||
targetDirectory = resolved;
|
||||
} catch (error) {
|
||||
const fallbackNodeId = NodeRegistry.getInstance().getDefaultNodeId();
|
||||
console.error('[HostConsole] Failed to resolve console directory; falling back to the default node base dir', {
|
||||
console.error('[HostConsole] Failed to resolve console directory', {
|
||||
user: consoleUsername,
|
||||
actingAs,
|
||||
nodeId,
|
||||
fallbackNodeId,
|
||||
stack: stackParam || '(root)',
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
});
|
||||
targetDirectory = FileSystemService.getInstance(fallbackNodeId).getBaseDir();
|
||||
auditNodeId = fallbackNodeId;
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send('Error: Failed to resolve console directory.\r\n');
|
||||
ws.close();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const auditCtx = { username: consoleUsername, nodeId: auditNodeId, ipAddress };
|
||||
const auditCtx = { username: consoleUsername, actingAs, nodeId, ipAddress };
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
|
||||
} catch (error) {
|
||||
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user