mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-16 21:48:45 +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:
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user