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.
227 lines
9.6 KiB
TypeScript
227 lines
9.6 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as pty from 'node-pty';
|
|
import { WebSocket } from 'ws';
|
|
import { execSync } from 'child_process';
|
|
import { isDebugEnabled } from '../utils/debug';
|
|
import { isPathWithinBase } from '../utils/validation';
|
|
import { DatabaseService } from './DatabaseService';
|
|
|
|
/**
|
|
* Identity recorded against a console session in the audit log. Built by the
|
|
* upgrade handler once the RBAC and tier gates have passed, so every spawned
|
|
* host shell leaves a durable open/close trail (not just an ephemeral log).
|
|
*/
|
|
export interface ConsoleAuditContext {
|
|
readonly username: string;
|
|
// null marks the local node (matching AuditLogEntry.node_id); the console
|
|
// path always supplies a concrete id.
|
|
readonly nodeId: number | null;
|
|
readonly ipAddress: string;
|
|
/** Hub operator for remote console_session bridges; null for direct sessions. */
|
|
readonly actingAs?: string | null;
|
|
}
|
|
|
|
const CONSOLE_AUDIT_PATH = '/api/system/host-console';
|
|
// xterm grids never approach these bounds; the cap rejects malformed or
|
|
// hostile resize frames before they reach node-pty.
|
|
const MAX_TERMINAL_DIMENSION = 1000;
|
|
|
|
let cachedShell: string | null = null;
|
|
function getUnixShell(): string {
|
|
if (cachedShell) return cachedShell;
|
|
try {
|
|
execSync('which bash', { stdio: 'ignore' });
|
|
cachedShell = 'bash';
|
|
} catch (e) {
|
|
console.warn('[HostTerminalService] bash not found, falling back to sh:', (e as Error).message);
|
|
cachedShell = 'sh';
|
|
}
|
|
return cachedShell;
|
|
}
|
|
|
|
// Pattern-based filtering: block any env var whose name contains sensitive keywords.
|
|
// Broad matching is intentional; false positives (stripping a benign var like COLORTERM)
|
|
// are safer than false negatives (leaking a secret through printenv).
|
|
const SENSITIVE_PATTERNS = /SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL|PRIVATE|AUTH|PASSPHRASE|ENCRYPT|SIGNING/i;
|
|
|
|
// Explicit set catches well-known connection strings that may not match the pattern.
|
|
const SENSITIVE_KEYS = new Set(['DATABASE_URL', 'REDIS_URL', 'MONGO_URI', 'AMQP_URL', 'DSN']);
|
|
|
|
const MAX_CONSOLE_SESSIONS = 5;
|
|
const PING_INTERVAL_MS = 30_000;
|
|
const PONG_TIMEOUT_MS = 60_000;
|
|
|
|
// Cached sanitized environment; process.env does not change at runtime.
|
|
let cachedSafeEnv: Record<string, string> | null = null;
|
|
|
|
export class HostTerminalService {
|
|
static activeSessions = new Map<number, { username: string; startedAt: number }>();
|
|
|
|
/**
|
|
* Sanitize a set of environment variables by removing entries whose names
|
|
* match sensitive patterns or are in the explicit blocklist.
|
|
*/
|
|
static sanitizeEnv(env: Record<string, string>): Record<string, string> {
|
|
return Object.fromEntries(
|
|
Object.entries(env).filter(
|
|
([k]) => !SENSITIVE_PATTERNS.test(k) && !SENSITIVE_KEYS.has(k)
|
|
)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Resolve the working directory for a console session. With no stack the
|
|
* session opens at the base directory. With a stack, the resolved path must
|
|
* be the base itself or sit strictly below it: a bare prefix match would let
|
|
* a sibling like `<base>-evil` (reached via `../<base>-evil`) escape, so the
|
|
* canonical path-boundary check is used. Returns null when the stack escapes.
|
|
*/
|
|
static resolveConsoleDirectory(baseDir: string, stackParam: string | null): string | null {
|
|
const resolvedBase = path.resolve(baseDir);
|
|
if (!stackParam) return resolvedBase;
|
|
const resolved = path.resolve(resolvedBase, stackParam);
|
|
return isPathWithinBase(resolved, resolvedBase) ? resolved : null;
|
|
}
|
|
|
|
/**
|
|
* Record a console session lifecycle event in the audit log. Failures here
|
|
* must never tear down a live shell, so the write is best-effort.
|
|
*/
|
|
private static recordAudit(audit: ConsoleAuditContext, statusCode: number, summary: string): void {
|
|
try {
|
|
DatabaseService.getInstance().insertAuditLog({
|
|
timestamp: Date.now(),
|
|
username: audit.username,
|
|
method: 'WS',
|
|
path: CONSOLE_AUDIT_PATH,
|
|
status_code: statusCode,
|
|
node_id: audit.nodeId,
|
|
ip_address: audit.ipAddress,
|
|
summary,
|
|
acting_as: audit.actingAs ?? null,
|
|
});
|
|
} catch (err) {
|
|
console.error('[HostConsole] Failed to write session audit log:', err);
|
|
}
|
|
}
|
|
|
|
static spawnTerminal(ws: WebSocket, targetDirectory: string, audit: ConsoleAuditContext) {
|
|
const { username } = audit;
|
|
// Enforce concurrent session limit
|
|
if (HostTerminalService.activeSessions.size >= MAX_CONSOLE_SESSIONS) {
|
|
console.warn('[HostConsole] Session rejected: max concurrent sessions reached', {
|
|
current: HostTerminalService.activeSessions.size,
|
|
max: MAX_CONSOLE_SESSIONS,
|
|
user: username,
|
|
});
|
|
ws.send('Error: Maximum console sessions reached. Close an existing session and try again.\r\n');
|
|
ws.close();
|
|
return;
|
|
}
|
|
|
|
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
|
|
if (!cachedSafeEnv) {
|
|
cachedSafeEnv = HostTerminalService.sanitizeEnv(process.env as Record<string, string>);
|
|
}
|
|
const startedAt = Date.now();
|
|
|
|
let ptyProcess: pty.IPty;
|
|
try {
|
|
ptyProcess = pty.spawn(shell, [], {
|
|
name: 'xterm-color',
|
|
cols: 80,
|
|
rows: 30,
|
|
cwd: targetDirectory,
|
|
env: cachedSafeEnv,
|
|
});
|
|
} catch (e) {
|
|
const msg = (e as Error).message || '';
|
|
console.error('[HostConsole] Failed to spawn PTY', { user: username, directory: targetDirectory, error: msg });
|
|
if (/ENOENT|not found/i.test(msg)) {
|
|
ws.send('Error: Shell not found on this system. Ensure bash or sh is installed.\r\n');
|
|
} else if (/EACCES|permission/i.test(msg)) {
|
|
ws.send('Error: Permission denied when spawning shell process.\r\n');
|
|
} else {
|
|
ws.send('Error: Failed to start terminal session.\r\n');
|
|
}
|
|
ws.close();
|
|
return;
|
|
}
|
|
|
|
const pid = ptyProcess.pid;
|
|
HostTerminalService.activeSessions.set(pid, { username, startedAt });
|
|
console.log('[HostConsole] Session opened', { user: username, directory: targetDirectory, shell, pid });
|
|
HostTerminalService.recordAudit(audit, 101, 'Opened host console session');
|
|
if (isDebugEnabled()) console.debug('[HostConsole:diag] Session-open audit recorded', { user: username, node: audit.nodeId, pid });
|
|
|
|
// Guard against duplicate cleanup when both WS close and PTY exit fire
|
|
let cleaned = false;
|
|
const cleanup = (source: string, extra?: Record<string, unknown>) => {
|
|
if (cleaned) return;
|
|
cleaned = true;
|
|
clearInterval(pingInterval);
|
|
HostTerminalService.activeSessions.delete(pid);
|
|
const durationMs = Date.now() - startedAt;
|
|
console.log(`[HostConsole] Session closed (${source})`, { user: username, pid, durationMs, ...extra });
|
|
HostTerminalService.recordAudit(audit, 200, `Closed host console session (${durationMs}ms)`);
|
|
};
|
|
|
|
// Heartbeat: detect dead connections and clean up orphaned PTY processes
|
|
let lastPong = Date.now();
|
|
const pingInterval = setInterval(() => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.ping();
|
|
if (Date.now() - lastPong > PONG_TIMEOUT_MS) {
|
|
console.warn('[HostConsole] Heartbeat timeout, terminating session', { user: username, pid });
|
|
clearInterval(pingInterval);
|
|
ws.terminate();
|
|
ptyProcess.kill();
|
|
}
|
|
}
|
|
}, PING_INTERVAL_MS);
|
|
ws.on('pong', () => { lastPong = Date.now(); });
|
|
|
|
ptyProcess.onData((data) => {
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.send(data); // Raw-Down protocol
|
|
}
|
|
});
|
|
|
|
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
|
|
try {
|
|
const parsed = JSON.parse(raw.toString()); // JSON-Up protocol
|
|
if (parsed.type === 'input') {
|
|
ptyProcess.write(parsed.payload);
|
|
} else if (parsed.type === 'resize') {
|
|
const { cols, rows } = parsed;
|
|
if (
|
|
Number.isInteger(cols) && Number.isInteger(rows) &&
|
|
cols > 0 && rows > 0 &&
|
|
cols <= MAX_TERMINAL_DIMENSION && rows <= MAX_TERMINAL_DIMENSION
|
|
) {
|
|
ptyProcess.resize(cols, rows);
|
|
if (isDebugEnabled()) console.debug('[HostConsole:diag] Terminal resized', { cols, rows, pid });
|
|
} else if (isDebugEnabled()) {
|
|
console.debug('[HostConsole:diag] Ignored invalid resize frame', { cols, rows, pid });
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[HostConsole] Failed to parse terminal message:', { pid, error: (e as Error).message });
|
|
}
|
|
});
|
|
|
|
ws.on('close', () => {
|
|
cleanup('WS');
|
|
ptyProcess.kill();
|
|
});
|
|
|
|
ptyProcess.onExit(({ exitCode, signal }) => {
|
|
cleanup('PTY exit', { exitCode, signal });
|
|
if (ws.readyState === WebSocket.OPEN) {
|
|
ws.close();
|
|
}
|
|
});
|
|
}
|
|
}
|