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:
Anso
2026-07-23 12:59:53 -04:00
committed by GitHub
parent ed5ca9c4f6
commit dd54a2e483
43 changed files with 1230 additions and 199 deletions
+4 -3
View File
@@ -54,9 +54,10 @@ export function handleGenericWs(
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).
// console_session tokens are already admin-gated at creation time and
// path/jti-gated in upgradeHandler. 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) {
+33 -37
View File
@@ -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();
+20 -5
View File
@@ -5,6 +5,7 @@ import { LicenseService } from '../services/LicenseService';
import { wsProxyServer } from '../proxy/websocketProxy';
import { getErrorMessage } from '../utils/errors';
import { rejectUpgrade as reject } from './reject';
import { consoleSessionPathForPathname } from '../helpers/consoleSession';
/**
* Forward a WebSocket upgrade to a remote Sencho instance. Handles the
@@ -23,9 +24,14 @@ export async function handleRemoteForwarder(
req: IncomingMessage,
socket: Duplex,
head: Buffer,
opts: { pathname: string; target: { apiUrl: string; apiToken: string } },
opts: {
pathname: string;
target: { apiUrl: string; apiToken: string };
/** Hub browser operator; recorded as acting_as on the remote audit trail. */
actingAs?: string;
},
): Promise<void> {
const { pathname, target } = opts;
const { pathname, target, actingAs } = opts;
if (!target.apiUrl) return reject(socket, 503, 'Service Unavailable');
const wsTarget = target.apiUrl.replace(/\/$/, '').replace(/^https?/, (m) => m === 'https' ? 'wss' : 'ws');
@@ -38,24 +44,33 @@ export async function handleRemoteForwarder(
// direct api_token access. Pilot loopback targets skip this: there is no
// long-lived api_token to exchange, and host-console is disabled on pilot
// mode at the capability registry anyway.
const isInteractiveConsolePath = pathname === '/api/system/host-console' || pathname === '/ws';
const sessionPath = consoleSessionPathForPathname(pathname);
let bearerTokenForProxy = target.apiToken;
if (isInteractiveConsolePath && !isPilotLoopback) {
if (sessionPath && !isPilotLoopback) {
try {
const consoleHeaders = LicenseService.getInstance().getProxyHeaders();
const tokenRes = await fetch(`${target.apiUrl.replace(/\/$/, '')}/api/system/console-token`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${target.apiToken}`,
'Content-Type': 'application/json',
[PROXY_TIER_HEADER]: consoleHeaders.tier,
},
body: JSON.stringify({
path: sessionPath,
...(actingAs ? { acting_as: actingAs } : {}),
}),
});
if (!tokenRes.ok) {
console.error(`[WS Proxy] Remote console-token request failed: ${tokenRes.status}`);
return reject(socket, 502, 'Bad Gateway');
}
const data = await tokenRes.json() as { token?: string };
if (typeof data.token === 'string') bearerTokenForProxy = data.token;
if (typeof data.token !== 'string' || !data.token) {
console.error('[WS Proxy] Remote console-token response missing token');
return reject(socket, 502, 'Bad Gateway');
}
bearerTokenForProxy = data.token;
} catch (e) {
console.error('[WS Proxy] Failed to fetch remote console token:', getErrorMessage(e, 'unknown'));
return reject(socket, 502, 'Bad Gateway');
+90 -6
View File
@@ -20,6 +20,14 @@ import { validateApiToken, touchApiTokenLastUsed } from '../utils/apiTokenAuth';
import { isDebugEnabled } from '../utils/debug';
import { PROXY_TIER_HEADER } from '../services/license-headers';
import { isLicenseTier, normalizeTier } from '../services/license-normalize';
import { HOST_CONSOLE_COMMUNITY_CAPABILITY } from '../services/CapabilityRegistry';
import { remoteAdvertisesCapability } from '../helpers/remoteCapabilities';
import {
consoleSessionPathForPathname,
consumeConsoleSessionJti,
isConsoleSessionScope,
} from '../helpers/consoleSession';
function parseCookies(req: IncomingMessage): Record<string, string> {
const header = req.headers.cookie || '';
@@ -31,6 +39,17 @@ function parseCookies(req: IncomingMessage): Record<string, string> {
);
}
/**
* Parse ?nodeId= as a strict positive integer. Returns null when the param is
* absent (caller should use the default node). Returns undefined when the
* param is present but malformed (caller should 404).
*/
function parseStrictNodeIdParam(raw: string | null): number | null | undefined {
if (raw == null || raw === '') return null;
if (!/^[1-9][0-9]*$/.test(raw)) return undefined;
return Number(raw);
}
// The two WebSocket paths open to any authenticated user (read-only/deploy-only
// API tokens and non-admin sessions on a remote node). Defined once so the
// scope gate and the remote-forward gate cannot drift apart.
@@ -68,8 +87,15 @@ export function remoteWsForwardAllowed(
if (ctx.isProxyToken) return false;
// console_session is pre-gated as admin at issuance (routes/console.ts).
if (ctx.decoded.scope === 'console_session') return true;
// Reject opaque API tokens on remote Host Console forward (would mint
// console_session and get a host shell). Local denial is enforced in
// attachUpgrade before handleHostConsoleWs. Container exec (/ws) keeps
// its existing full-admin API-token allow.
if (pathname.startsWith('/api/system/host-console') && ctx.wsApiTokenScope) {
return false;
}
// A read-only/deploy-only api_token is already rejected for these paths by
// the scope gate; only full-admin survives to here.
// the scope gate; only full-admin survives to here (container exec /ws).
if (ctx.wsApiTokenScope) return ctx.wsApiTokenScope === 'full-admin';
const role = ctx.wsResolvedUser?.role;
if (!role) return false;
@@ -136,7 +162,16 @@ export function attachUpgrade(
try {
// Opaque sen_sk_ API tokens: handled before jwt.verify. Prefix +
// length + checksum reject malformed keys without touching SQLite.
let decoded: { username?: string; scope?: string; role?: string; tv?: number };
let decoded: {
username?: string;
scope?: string;
role?: string;
tv?: number;
path?: string;
acting_as?: string;
jti?: string;
exp?: number;
};
let wsApiTokenScope: string | null = null;
if (looksLikeApiToken(token)) {
const validation = validateApiToken(token);
@@ -151,7 +186,7 @@ export function attachUpgrade(
const settings = DatabaseService.getInstance().getGlobalSettings();
const jwtSecret = settings.auth_jwt_secret;
if (!jwtSecret) throw new Error('No JWT secret');
decoded = jwt.verify(token, jwtSecret) as { username?: string; scope?: string; role?: string; tv?: number };
decoded = jwt.verify(token, jwtSecret) as typeof decoded;
}
// Node proxy tokens are machine-to-machine credentials and must never be
@@ -180,6 +215,21 @@ export function attachUpgrade(
const parsedUrl = new URL(req.url || '', `http://${req.headers.host || 'localhost'}`);
const pathname = parsedUrl.pathname;
// Path-scoped, one-time console_session tokens for interactive surfaces.
// Consume runs at upgrade acceptance (before PTY spawn); missing exp/jti fail closed.
if (isConsoleSessionScope(decoded.scope)) {
const requiredPath = consoleSessionPathForPathname(pathname);
if (!requiredPath || decoded.path !== requiredPath) {
return reject(socket, 403, 'Forbidden');
}
if (typeof decoded.exp !== 'number' || typeof decoded.jti !== 'string' || !decoded.jti) {
return reject(socket, 401, 'Unauthorized');
}
if (!consumeConsoleSessionJti(decoded.jti, decoded.exp * 1000)) {
return reject(socket, 401, 'Unauthorized');
}
}
// Gate WebSocket paths by API token scope
if (wsApiTokenScope) {
if (wsApiTokenScope === 'read-only' || wsApiTokenScope === 'deploy-only') {
@@ -219,8 +269,11 @@ export function attachUpgrade(
return;
}
const nodeIdParam = parsedUrl.searchParams.get('nodeId');
const nodeId = nodeIdParam ? parseInt(nodeIdParam, 10) : NodeRegistry.getInstance().getDefaultNodeId();
const parsedNodeId = parseStrictNodeIdParam(parsedUrl.searchParams.get('nodeId'));
if (parsedNodeId === undefined) {
return reject(socket, 404, 'Not Found');
}
const nodeId = parsedNodeId ?? NodeRegistry.getInstance().getDefaultNodeId();
const node = NodeRegistry.getInstance().getNode(nodeId);
// Notification push channel: local only when no remote nodeId is
@@ -243,6 +296,23 @@ export function attachUpgrade(
if (!remoteWsForwardAllowed(pathname, { wsResolvedUser, wsApiTokenScope, isProxyToken, decoded })) {
return reject(socket, 403, 'Forbidden');
}
// Community hubs require host-console-community before forwarding Host
// Console (marks remotes that no longer paid-gate the console path).
// Admiral hubs skip the probe for legacy host-console peers. Fail
// closed on probe errors; never fall through to local handlers for a
// named remote.
if (
pathname.startsWith('/api/system/host-console')
&& LicenseService.getInstance().getTier() !== 'paid'
) {
const communityCapable = await remoteAdvertisesCapability(
nodeId,
HOST_CONSOLE_COMMUNITY_CAPABILITY,
);
if (!communityCapable) {
return reject(socket, 403, 'Forbidden');
}
}
// Resolve the proxy target through NodeRegistry so pilot-mode nodes
// (empty api_url + api_token, loopback bridge instead) and proxy-mode
// nodes share one dispatch path. Mirrors proxy/remoteNodeProxy.ts.
@@ -253,7 +323,11 @@ export function attachUpgrade(
// serve gateway-local data for a request that named a remote node.
return reject(socket, 503, 'Service Unavailable');
}
await handleRemoteForwarder(req, socket, head, { pathname, target });
await handleRemoteForwarder(req, socket, head, {
pathname,
target,
actingAs: wsResolvedUser?.username || decoded.username,
});
return;
}
@@ -264,6 +338,16 @@ export function attachUpgrade(
}
if (pathname.startsWith('/api/system/host-console')) {
// Opaque API tokens never open a local host shell (parity with the
// remote forward gate). Do not rely on missing wsResolvedUser alone.
if (wsApiTokenScope) {
return reject(socket, 403, 'Forbidden');
}
// Unknown / deleted nodeIds must not fall through to the hub compose
// root via getComposeDir's env default.
if (!node) {
return reject(socket, 404, 'Not Found');
}
handleHostConsoleWs(req, socket, head, {
nodeId,
decoded,