mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix(host-console): audit session lifecycle and harden path, resize, and route gating (#1263)
* fix(host-console): audit session lifecycle and harden path, resize, and route gating Record an audit-log entry when a host console session opens and when it closes (capturing user, node, client IP, and timestamp), so interactive host-shell access leaves a durable, accountable trail instead of only an ephemeral log line. Fix a stack-path boundary check that allowed a sibling directory sharing the base path's prefix to pass; directory resolution now uses the canonical within-base check via a small testable helper. Validate terminal resize frames (positive integers within a sane bound) before forwarding them to the PTY, dropping malformed frames instead of passing them through. Mirror the backend admin-only console permission on the frontend route so a non-admin who reaches the view cannot mount a console the server would reject, and log (rather than silently swallow) a working-directory resolution failure. Add unit coverage for the path helper, audit open/close rows, resize validation, and the spawn-error path, plus a WebSocket-upgrade integration test exercising the full gate chain and a live session-open audit row. * fix(host-console): record the node the shell actually runs in When the requested node's directory cannot be resolved, the session falls back to the default node's base directory. Record that fallback node in the session audit row (and log it) so the audit trail names the node the shell actually runs in rather than the originally requested one. Strengthen the upgrade integration test to assert the open row captures the user, node, and client IP.
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Integration tests for the Host Console WebSocket upgrade. These drive the
|
||||
* real upgrade handler through a listening server (no mocked sockets) to verify
|
||||
* the gate chain end-to-end: unauthenticated, machine-credential, RBAC, and
|
||||
* tier rejections, plus the accepted path (which must record an audit row) and
|
||||
* the stack-path boundary rejection.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import WebSocket from 'ws';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
const HOST_CONSOLE_PATH = '/api/system/host-console';
|
||||
|
||||
describe('WebSocket upgrade - host console auth enforcement', () => {
|
||||
let tmpDir: string;
|
||||
let server: import('http').Server;
|
||||
let getTierSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.restoreAllMocks();
|
||||
tmpDir = await setupTestDb();
|
||||
// Host console requires paid + admiral; mock the license so the tier gate
|
||||
// passes for the admin/accepted cases. Individual tests override as needed.
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
getTierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
const mod = await import('../index');
|
||||
server = mod.server;
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
function wsUrl(query = ''): string {
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') throw new Error('Server not listening');
|
||||
return `ws://127.0.0.1:${addr.port}${HOST_CONSOLE_PATH}${query}`;
|
||||
}
|
||||
|
||||
function adminToken(): string {
|
||||
return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
}
|
||||
|
||||
/** Resolve to the HTTP status of a rejected upgrade (0 if no response). */
|
||||
function expectRejected(ws: WebSocket): Promise<number> {
|
||||
return new Promise<number>((resolve) => {
|
||||
ws.on('unexpected-response', (_req, res) => resolve(res.statusCode ?? 0));
|
||||
ws.on('open', () => { ws.close(); resolve(200); });
|
||||
ws.on('error', () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
it('rejects an upgrade with no token (401)', async () => {
|
||||
expect(await expectRejected(new WebSocket(wsUrl()))).toBe(401);
|
||||
});
|
||||
|
||||
it('rejects a node_proxy machine token (403)', async () => {
|
||||
const token = jwt.sign({ scope: 'node_proxy' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Authorization: `Bearer ${token}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects a non-admin user without system:console (403)', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const hash = await bcrypt.hash('viewerpass', 1);
|
||||
try {
|
||||
DatabaseService.getInstance().addUser({ username: 'hc_viewer', password_hash: hash, role: 'viewer' });
|
||||
} catch {
|
||||
// already exists from a prior run in the same worker
|
||||
}
|
||||
const token = jwt.sign({ username: 'hc_viewer', role: 'viewer' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${token}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('rejects an admin on a sub-Admiral tier (403)', async () => {
|
||||
getTierSpy.mockReturnValueOnce('free');
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
expect(await expectRejected(ws)).toBe(403);
|
||||
});
|
||||
|
||||
it('accepts an admin on Admiral and records an open audit row', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog');
|
||||
const ws = new WebSocket(wsUrl(), { headers: { Cookie: `sencho_token=${adminToken()}` } });
|
||||
|
||||
const opened = await new Promise<boolean>((resolve) => {
|
||||
ws.on('open', () => resolve(true));
|
||||
ws.on('error', () => resolve(false));
|
||||
ws.on('unexpected-response', () => resolve(false));
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
|
||||
// The session-open audit row is written server-side just after the PTY
|
||||
// spawns; poll briefly for it, then assert it captures the real identity.
|
||||
let openRow: Omit<import('../services/DatabaseService').AuditLogEntry, 'id'> | undefined;
|
||||
await waitFor(() => {
|
||||
const call = insertSpy.mock.calls.find(
|
||||
([entry]) => entry?.path === HOST_CONSOLE_PATH && entry?.summary === 'Opened host console session',
|
||||
);
|
||||
if (call) openRow = call[0];
|
||||
return openRow !== undefined;
|
||||
});
|
||||
expect(openRow).toBeDefined();
|
||||
expect(openRow?.username).toBe(TEST_USERNAME);
|
||||
expect(typeof openRow?.node_id).toBe('number');
|
||||
expect(openRow?.ip_address).toBeTruthy();
|
||||
|
||||
ws.close();
|
||||
insertSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects a stack path that escapes the base directory', async () => {
|
||||
const ws = new WebSocket(wsUrl('?stack=' + encodeURIComponent('../escape-evil')), {
|
||||
headers: { Cookie: `sencho_token=${adminToken()}` },
|
||||
});
|
||||
const firstMessage = await new Promise<string>((resolve) => {
|
||||
ws.on('message', (data) => resolve(data.toString()));
|
||||
ws.on('error', () => resolve(''));
|
||||
ws.on('unexpected-response', () => resolve(''));
|
||||
});
|
||||
expect(firstMessage).toContain('Invalid stack path');
|
||||
ws.close();
|
||||
});
|
||||
});
|
||||
|
||||
/** Poll a predicate up to ~1s; resolve true as soon as it passes. */
|
||||
async function waitFor(predicate: () => boolean): Promise<boolean> {
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (predicate()) return true;
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
}
|
||||
return predicate();
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
* Tests for the Host Console feature: environment sanitization, session limits,
|
||||
* and console-token RBAC enforcement.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
||||
import request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import * as path from 'path';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as pty from 'node-pty';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
@@ -163,6 +166,204 @@ describe('HostTerminalService session tracking', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Stack-Path Resolution ──────────────────────────────────────────────────
|
||||
|
||||
describe('HostTerminalService.resolveConsoleDirectory', () => {
|
||||
let resolveConsoleDirectory: (baseDir: string, stackParam: string | null) => string | null;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../services/HostTerminalService');
|
||||
resolveConsoleDirectory = mod.HostTerminalService.resolveConsoleDirectory;
|
||||
});
|
||||
|
||||
it('returns the base directory when no stack is given', () => {
|
||||
expect(resolveConsoleDirectory('/srv/compose', null)).toBe(path.resolve('/srv/compose'));
|
||||
});
|
||||
|
||||
it('resolves a stack subdirectory below the base', () => {
|
||||
expect(resolveConsoleDirectory('/srv/compose', 'my-stack')).toBe(
|
||||
path.resolve('/srv/compose', 'my-stack'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a sibling-prefix escape (../<base>-evil)', () => {
|
||||
// The historical bug: a bare startsWith() matched `/srv/compose-evil`
|
||||
// because it shares the `/srv/compose` prefix. The path.sep boundary
|
||||
// must reject it.
|
||||
expect(resolveConsoleDirectory('/srv/compose', '../compose-evil')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a parent-directory escape', () => {
|
||||
expect(resolveConsoleDirectory('/srv/compose', '../..')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects an absolute path outside the base', () => {
|
||||
const outside = path.resolve('/etc');
|
||||
if (outside !== path.resolve('/srv/compose')) {
|
||||
expect(resolveConsoleDirectory('/srv/compose', outside)).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Session Audit Trail + Resize Validation ────────────────────────────────
|
||||
|
||||
interface FakeWs extends EventEmitter {
|
||||
readyState: number;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
ping: ReturnType<typeof vi.fn>;
|
||||
terminate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeFakeWs(): FakeWs {
|
||||
const ws = new EventEmitter() as FakeWs;
|
||||
ws.readyState = 1; // WebSocket.OPEN
|
||||
ws.send = vi.fn();
|
||||
ws.close = vi.fn();
|
||||
ws.ping = vi.fn();
|
||||
ws.terminate = vi.fn();
|
||||
return ws;
|
||||
}
|
||||
|
||||
function makeFakePty(pid = 4242) {
|
||||
let exitCb: ((e: { exitCode: number; signal: number }) => void) | undefined;
|
||||
return {
|
||||
pid,
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn((cb: (e: { exitCode: number; signal: number }) => void) => { exitCb = cb; }),
|
||||
write: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
triggerExit: (e: { exitCode: number; signal: number }) => exitCb?.(e),
|
||||
};
|
||||
}
|
||||
|
||||
describe('HostTerminalService.spawnTerminal', () => {
|
||||
let HostTerminalService: typeof import('../services/HostTerminalService').HostTerminalService;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
let insertSpy: ReturnType<typeof vi.spyOn>;
|
||||
let spawnSpy: ReturnType<typeof vi.spyOn>;
|
||||
const audit = { username: 'admin', nodeId: 7, ipAddress: '9.9.9.9' };
|
||||
|
||||
beforeAll(async () => {
|
||||
HostTerminalService = (await import('../services/HostTerminalService')).HostTerminalService;
|
||||
DatabaseService = (await import('../services/DatabaseService')).DatabaseService;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
HostTerminalService.activeSessions.clear();
|
||||
insertSpy = vi.spyOn(DatabaseService.getInstance(), 'insertAuditLog').mockImplementation(() => {});
|
||||
spawnSpy = vi.spyOn(pty, 'spawn');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
insertSpy.mockRestore();
|
||||
spawnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('writes an open audit row when a session starts', () => {
|
||||
spawnSpy.mockReturnValue(makeFakePty() as unknown as pty.IPty);
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
username: 'admin',
|
||||
method: 'WS',
|
||||
path: '/api/system/host-console',
|
||||
status_code: 101,
|
||||
node_id: 7,
|
||||
ip_address: '9.9.9.9',
|
||||
summary: 'Opened host console session',
|
||||
}),
|
||||
);
|
||||
ws.emit('close'); // clean up the heartbeat interval
|
||||
});
|
||||
|
||||
it('writes a close audit row with duration when the socket closes', () => {
|
||||
spawnSpy.mockReturnValue(makeFakePty() as unknown as pty.IPty);
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
insertSpy.mockClear();
|
||||
|
||||
ws.emit('close');
|
||||
|
||||
expect(insertSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'WS',
|
||||
path: '/api/system/host-console',
|
||||
status_code: 200,
|
||||
summary: expect.stringMatching(/^Closed host console session \(\d+ms\)$/),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('writes exactly one close row when the PTY exits and the socket then closes', () => {
|
||||
const fakePty = makeFakePty();
|
||||
spawnSpy.mockReturnValue(fakePty as unknown as pty.IPty);
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
insertSpy.mockClear();
|
||||
|
||||
// Both cleanup triggers fire (PTY exit, then the WS close it provokes); the
|
||||
// `cleaned` guard must collapse them to a single close audit row.
|
||||
fakePty.triggerExit({ exitCode: 0, signal: 0 });
|
||||
ws.emit('close');
|
||||
|
||||
const closeRows = insertSpy.mock.calls.filter(
|
||||
([entry]: [{ summary?: string }]) => entry?.summary?.startsWith('Closed host console session'),
|
||||
);
|
||||
expect(closeRows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('applies a valid resize frame and rejects malformed ones', () => {
|
||||
const fakePty = makeFakePty();
|
||||
spawnSpy.mockReturnValue(fakePty as unknown as pty.IPty);
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 120, rows: 40 })));
|
||||
expect(fakePty.resize).toHaveBeenCalledWith(120, 40);
|
||||
|
||||
// Boundary: the max dimension is accepted, one above it is rejected.
|
||||
fakePty.resize.mockClear();
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 1000, rows: 1000 })));
|
||||
expect(fakePty.resize).toHaveBeenCalledWith(1000, 1000);
|
||||
|
||||
fakePty.resize.mockClear();
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 1001, rows: 40 })));
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: -1, rows: 40 })));
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: 99999, rows: 40 })));
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'resize', cols: '80', rows: 40 })));
|
||||
expect(fakePty.resize).not.toHaveBeenCalled();
|
||||
|
||||
ws.emit('close');
|
||||
});
|
||||
|
||||
it('forwards input frames to the PTY', () => {
|
||||
const fakePty = makeFakePty();
|
||||
spawnSpy.mockReturnValue(fakePty as unknown as pty.IPty);
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
|
||||
ws.emit('message', Buffer.from(JSON.stringify({ type: 'input', payload: 'ls -la\n' })));
|
||||
expect(fakePty.write).toHaveBeenCalledWith('ls -la\n');
|
||||
|
||||
ws.emit('close');
|
||||
});
|
||||
|
||||
it('reports a shell-not-found error and closes when spawn throws ENOENT', () => {
|
||||
spawnSpy.mockImplementation(() => { throw new Error('spawn sh ENOENT'); });
|
||||
const ws = makeFakeWs();
|
||||
HostTerminalService.spawnTerminal(ws as never, '/tmp', audit);
|
||||
|
||||
expect(ws.send).toHaveBeenCalledWith(expect.stringContaining('Shell not found'));
|
||||
expect(ws.close).toHaveBeenCalled();
|
||||
expect(HostTerminalService.activeSessions.size).toBe(0);
|
||||
expect(insertSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Console Token RBAC ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/system/console-token', () => {
|
||||
|
||||
@@ -1,8 +1,29 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -47,7 +68,43 @@ export class HostTerminalService {
|
||||
);
|
||||
}
|
||||
|
||||
static spawnTerminal(ws: WebSocket, targetDirectory: string, username: string) {
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
} 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', {
|
||||
@@ -92,6 +149,8 @@ export class HostTerminalService {
|
||||
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;
|
||||
@@ -102,6 +161,7 @@ export class HostTerminalService {
|
||||
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
|
||||
@@ -131,8 +191,17 @@ export class HostTerminalService {
|
||||
if (parsed.type === 'input') {
|
||||
ptyProcess.write(parsed.payload);
|
||||
} else if (parsed.type === 'resize') {
|
||||
ptyProcess.resize(parsed.cols, parsed.rows);
|
||||
if (isDebugEnabled()) console.debug('[HostConsole:diag] Terminal resized', { cols: parsed.cols, rows: parsed.rows, pid });
|
||||
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 });
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { IncomingMessage } from 'http';
|
||||
import type { Duplex } from 'stream';
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import path from 'path';
|
||||
import { FileSystemService } from '../services/FileSystemService';
|
||||
import { NodeRegistry } from '../services/NodeRegistry';
|
||||
import { HostTerminalService } from '../services/HostTerminalService';
|
||||
@@ -82,28 +81,45 @@ export function handleHostConsoleWs(
|
||||
stack: stackParam || '(root)',
|
||||
});
|
||||
|
||||
// Client IP for the audit trail. Express's req.ip is unavailable on a raw
|
||||
// upgrade socket, so take the first x-forwarded-for hop and fall back to the
|
||||
// socket address.
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const xff = typeof forwarded === 'string' ? forwarded.split(',')[0].trim() : '';
|
||||
const ipAddress = xff || req.socket.remoteAddress || '';
|
||||
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
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();
|
||||
if (stackParam) {
|
||||
const resolved = path.resolve(baseDir, stackParam);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
ws.send('Error: Invalid stack path\r\n');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
targetDirectory = resolved;
|
||||
} else {
|
||||
targetDirectory = baseDir;
|
||||
const resolved = HostTerminalService.resolveConsoleDirectory(baseDir, stackParam);
|
||||
if (resolved === null) {
|
||||
ws.send('Error: Invalid stack path\r\n');
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
||||
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', {
|
||||
user: consoleUsername,
|
||||
nodeId,
|
||||
fallbackNodeId,
|
||||
stack: stackParam || '(root)',
|
||||
error: getErrorMessage(error, 'unknown'),
|
||||
});
|
||||
targetDirectory = FileSystemService.getInstance(fallbackNodeId).getBaseDir();
|
||||
auditNodeId = fallbackNodeId;
|
||||
}
|
||||
const auditCtx = { username: consoleUsername, nodeId: auditNodeId, ipAddress };
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, auditCtx);
|
||||
} catch (error) {
|
||||
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: getErrorMessage(error, 'unknown') });
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
|
||||
@@ -77,6 +77,7 @@ The Host Console is one of the most powerful features in Sencho and is treated a
|
||||
- **Admin role required.** Only users with the **admin** role can open a console session.
|
||||
- **Admiral license required.** The Console tab is not available on the Community or Skipper tiers.
|
||||
- **Browser sessions only.** Console sessions are only available from a signed-in browser session, not from API tokens.
|
||||
- **Audited.** Every console session is recorded in the audit log. Opening and closing a session each write an entry capturing the user, node, client IP, and timestamp, so shell access is fully accountable.
|
||||
|
||||
<Warning>
|
||||
The Host Console provides unrestricted shell access to the machine running Sencho. Do not expose Sencho on a public network without HTTPS and strong authentication.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Suspense, lazy, type ReactNode } from 'react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useAuth } from '@/context/AuthContext';
|
||||
import { AdmiralGate } from '../AdmiralGate';
|
||||
import { CapabilityGate } from '../CapabilityGate';
|
||||
import { HubOnlyGate } from '../HubOnlyGate';
|
||||
@@ -105,6 +106,7 @@ export function ViewRouter({
|
||||
onClearNotifications,
|
||||
renderEditor,
|
||||
}: ViewRouterProps): ReactNode {
|
||||
const { can } = useAuth();
|
||||
if (activeView === 'settings') {
|
||||
return (
|
||||
<SettingsPage
|
||||
@@ -120,6 +122,10 @@ export function ViewRouter({
|
||||
return <ResourcesView />;
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Mirror the backend RBAC gate (system:console, admin-only). The nav
|
||||
// item is already admin-gated; this stops a non-admin who reaches the
|
||||
// view another way from mounting a console that the server will 403.
|
||||
if (!can('system:console')) return null;
|
||||
return (
|
||||
<AdmiralGate>
|
||||
<CapabilityGate capability="host-console" featureName="Host Console">
|
||||
|
||||
Reference in New Issue
Block a user