mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +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:
@@ -1,17 +1,55 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { DatabaseService } from '../services/DatabaseService';
|
||||
|
||||
/**
|
||||
* Console session token lifetime. Short on purpose: these tokens are only
|
||||
* used to bridge an already-authenticated HTTP request into a WebSocket
|
||||
* upgrade, and each one is consumed by a single `wss:ws(target)` call.
|
||||
* Console session token lifetime. Short on purpose: these tokens bridge an
|
||||
* already-authenticated hub request into a remote WebSocket upgrade. Each
|
||||
* token is path-scoped and consumed on first interactive WebSocket upgrade
|
||||
* acceptance (before PTY spawn).
|
||||
*/
|
||||
const CONSOLE_SESSION_TTL_SECONDS = 60;
|
||||
const CONSOLE_SESSION_SCOPE = 'console_session';
|
||||
|
||||
/** Interactive surfaces that may mint or accept a console_session JWT. */
|
||||
export type ConsoleSessionPath = 'host-console' | 'container-exec';
|
||||
|
||||
export interface MintConsoleSessionOptions {
|
||||
path: ConsoleSessionPath;
|
||||
/** Hub operator identity for remote audit (option B). Never used as the session principal. */
|
||||
actingAs?: string;
|
||||
}
|
||||
|
||||
export interface ConsoleSessionClaims {
|
||||
scope: typeof CONSOLE_SESSION_SCOPE;
|
||||
username?: string;
|
||||
path: ConsoleSessionPath;
|
||||
acting_as?: string;
|
||||
}
|
||||
|
||||
const ACTING_AS_MAX = 64;
|
||||
const ACTING_AS_RE = /^[A-Za-z0-9._@+-]+$/;
|
||||
|
||||
/** Sanitize an optional hub operator name for the acting_as claim. */
|
||||
export function sanitizeActingAs(raw: unknown): string | undefined {
|
||||
if (typeof raw !== 'string') return undefined;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed || trimmed.length > ACTING_AS_MAX) return undefined;
|
||||
if (!ACTING_AS_RE.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function isConsoleSessionPath(value: unknown): value is ConsoleSessionPath {
|
||||
return value === 'host-console' || value === 'container-exec';
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a WebSocket pathname to the console_session path claim it requires.
|
||||
* Returns null for surfaces that must not accept console_session tokens.
|
||||
*/
|
||||
export function consoleSessionPathForPathname(pathname: string): ConsoleSessionPath | null {
|
||||
if (pathname.startsWith('/api/system/host-console')) return 'host-console';
|
||||
if (pathname === '/ws') return 'container-exec';
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -19,13 +57,19 @@ export interface ConsoleSessionClaims {
|
||||
* instance without leaking the long-lived node api_token onto a
|
||||
* machine-to-machine WebSocket. Throws if the JWT secret is not configured.
|
||||
*/
|
||||
export function mintConsoleSession(username?: string): string {
|
||||
export function mintConsoleSession(opts: MintConsoleSessionOptions): string {
|
||||
if (!isConsoleSessionPath(opts.path)) {
|
||||
throw new Error('Invalid console session path');
|
||||
}
|
||||
const jwtSecret = DatabaseService.getInstance().getGlobalSettings().auth_jwt_secret;
|
||||
if (!jwtSecret) throw new Error('No JWT secret configured');
|
||||
const payload: ConsoleSessionClaims = username
|
||||
? { scope: CONSOLE_SESSION_SCOPE, username }
|
||||
: { scope: CONSOLE_SESSION_SCOPE };
|
||||
return jwt.sign(payload, jwtSecret, { expiresIn: CONSOLE_SESSION_TTL_SECONDS });
|
||||
const payload: ConsoleSessionClaims = { scope: CONSOLE_SESSION_SCOPE, path: opts.path };
|
||||
const actingAs = sanitizeActingAs(opts.actingAs);
|
||||
if (actingAs) payload.acting_as = actingAs;
|
||||
return jwt.sign(payload, jwtSecret, {
|
||||
expiresIn: CONSOLE_SESSION_TTL_SECONDS,
|
||||
jwtid: randomUUID(),
|
||||
});
|
||||
}
|
||||
|
||||
/** True when `decoded.scope` is the console_session scope. */
|
||||
@@ -33,4 +77,14 @@ export function isConsoleSessionScope(scope: unknown): boolean {
|
||||
return scope === CONSOLE_SESSION_SCOPE;
|
||||
}
|
||||
|
||||
export { CONSOLE_SESSION_SCOPE };
|
||||
/**
|
||||
* Mark a console_session jti as used. Returns false when the jti is missing,
|
||||
* already consumed, or cannot be recorded (replay / malformed token).
|
||||
*/
|
||||
export function consumeConsoleSessionJti(jti: unknown, expiresAtMs: number): boolean {
|
||||
if (typeof jti !== 'string' || !jti) return false;
|
||||
if (!Number.isFinite(expiresAtMs)) return false;
|
||||
return DatabaseService.getInstance().consumeConsoleSessionJti(jti, expiresAtMs);
|
||||
}
|
||||
|
||||
export { CONSOLE_SESSION_SCOPE, CONSOLE_SESSION_TTL_SECONDS };
|
||||
|
||||
Reference in New Issue
Block a user