mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-21 15:46:43 +00:00
fix(host-console): harden with security fixes, validation, and test coverage (#580)
Security: - Enforce RBAC (system:console permission) on WebSocket upgrade - Validate token_version for user sessions on WS connections - Expand env var sanitization to cover additional secret patterns (PRIVATE, AUTH, PASSPHRASE, ENCRYPT, SIGNING) and connection strings (REDIS_URL, MONGO_URI, AMQP_URL, DSN) Reliability: - Add session tracking with max 5 concurrent console sessions - Add WebSocket heartbeat (30s ping, 60s pong timeout) to detect and clean up dead connections and orphaned PTY processes - Differentiate PTY spawn error messages (shell not found, permission denied, generic failure) - Guard against duplicate cleanup when both WS close and PTY exit fire Observability: - Add structured logging with [HostConsole] prefix for session lifecycle (open, close, duration, user, pid) - Add diagnostic logging behind developer_mode for terminal resize events and message parse errors Frontend: - Replace hardcoded hex colors with oklch CSS custom properties (--terminal-bg, --terminal-fg, --terminal-cursor, etc.) - Apply design system material tokens (shadow-card-bevel, recessed well shadow, card border hierarchy, strokeWidth 1.5) Tests: - Add 20 tests covering env sanitization patterns (10 keyword categories + safe vars + case insensitivity), session tracking, and console-token RBAC (admin, viewer, deployer, API tokens) Docs: - Document admin role requirement and session limits - Add troubleshooting section (session limits, shell not found, proxy timeouts, missing console tab) - Update security section with expanded env var coverage
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* 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 request from 'supertest';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { setupTestDb, cleanupTestDb, TEST_USERNAME, TEST_JWT_SECRET } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let app: import('express').Express;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
// Mock LicenseService so Admiral-gated endpoints accept requests
|
||||
const { LicenseService } = await import('../services/LicenseService');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
|
||||
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral');
|
||||
({ app } = await import('../index'));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.restoreAllMocks();
|
||||
cleanupTestDb(tmpDir);
|
||||
});
|
||||
|
||||
// ─── Environment Variable Sanitization ──────────────────────────────────────
|
||||
|
||||
describe('HostTerminalService.sanitizeEnv', () => {
|
||||
let sanitizeEnv: (env: Record<string, string>) => Record<string, string>;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../services/HostTerminalService');
|
||||
sanitizeEnv = mod.HostTerminalService.sanitizeEnv;
|
||||
});
|
||||
|
||||
it('strips DATABASE_URL (explicit blocklist)', () => {
|
||||
const result = sanitizeEnv({ DATABASE_URL: 'postgres://...', PATH: '/usr/bin' });
|
||||
expect(result).not.toHaveProperty('DATABASE_URL');
|
||||
expect(result).toHaveProperty('PATH', '/usr/bin');
|
||||
});
|
||||
|
||||
it('strips REDIS_URL, MONGO_URI, AMQP_URL, DSN (explicit blocklist)', () => {
|
||||
const result = sanitizeEnv({
|
||||
REDIS_URL: 'redis://localhost',
|
||||
MONGO_URI: 'mongodb://localhost',
|
||||
AMQP_URL: 'amqp://localhost',
|
||||
DSN: 'sentry://...',
|
||||
HOME: '/home/user',
|
||||
});
|
||||
expect(result).not.toHaveProperty('REDIS_URL');
|
||||
expect(result).not.toHaveProperty('MONGO_URI');
|
||||
expect(result).not.toHaveProperty('AMQP_URL');
|
||||
expect(result).not.toHaveProperty('DSN');
|
||||
expect(result).toHaveProperty('HOME');
|
||||
});
|
||||
|
||||
it('strips vars matching SECRET pattern', () => {
|
||||
const result = sanitizeEnv({ JWT_SECRET: 'abc', APP_SECRET_KEY: 'xyz', LANG: 'en' });
|
||||
expect(result).not.toHaveProperty('JWT_SECRET');
|
||||
expect(result).not.toHaveProperty('APP_SECRET_KEY');
|
||||
expect(result).toHaveProperty('LANG');
|
||||
});
|
||||
|
||||
it('strips vars matching PASSWORD pattern', () => {
|
||||
const result = sanitizeEnv({ DB_PASSWORD: 'pass', SMTP_PASSWORD: 'pass', USER: 'me' });
|
||||
expect(result).not.toHaveProperty('DB_PASSWORD');
|
||||
expect(result).not.toHaveProperty('SMTP_PASSWORD');
|
||||
expect(result).toHaveProperty('USER');
|
||||
});
|
||||
|
||||
it('strips vars matching TOKEN pattern', () => {
|
||||
const result = sanitizeEnv({ API_TOKEN: '123', GITHUB_TOKEN: 'ghp_...', TERM: 'xterm' });
|
||||
expect(result).not.toHaveProperty('API_TOKEN');
|
||||
expect(result).not.toHaveProperty('GITHUB_TOKEN');
|
||||
expect(result).toHaveProperty('TERM');
|
||||
});
|
||||
|
||||
it('strips vars matching KEY pattern', () => {
|
||||
const result = sanitizeEnv({ AWS_ACCESS_KEY_ID: 'AKIA...', ENCRYPTION_KEY: 'k', SHELL: '/bin/bash' });
|
||||
expect(result).not.toHaveProperty('AWS_ACCESS_KEY_ID');
|
||||
expect(result).not.toHaveProperty('ENCRYPTION_KEY');
|
||||
expect(result).toHaveProperty('SHELL');
|
||||
});
|
||||
|
||||
it('strips vars matching CREDENTIAL pattern', () => {
|
||||
const result = sanitizeEnv({ GCP_CREDENTIAL: 'json...', PATH: '/usr/bin' });
|
||||
expect(result).not.toHaveProperty('GCP_CREDENTIAL');
|
||||
expect(result).toHaveProperty('PATH');
|
||||
});
|
||||
|
||||
it('strips vars matching PRIVATE pattern', () => {
|
||||
const result = sanitizeEnv({ SSH_PRIVATE_KEY: '-----BEGIN', PRIVATE_KEY_PEM: 'pem', HOSTNAME: 'box' });
|
||||
expect(result).not.toHaveProperty('SSH_PRIVATE_KEY');
|
||||
expect(result).not.toHaveProperty('PRIVATE_KEY_PEM');
|
||||
expect(result).toHaveProperty('HOSTNAME');
|
||||
});
|
||||
|
||||
it('strips vars matching AUTH pattern', () => {
|
||||
const result = sanitizeEnv({ GITHUB_AUTH: 'token', OAUTH_CLIENT: 'id', COMPOSE_DIR: '/app' });
|
||||
expect(result).not.toHaveProperty('GITHUB_AUTH');
|
||||
expect(result).not.toHaveProperty('OAUTH_CLIENT');
|
||||
expect(result).toHaveProperty('COMPOSE_DIR');
|
||||
});
|
||||
|
||||
it('strips vars matching PASSPHRASE pattern', () => {
|
||||
const result = sanitizeEnv({ GPG_PASSPHRASE: 'secret', PWD: '/home' });
|
||||
expect(result).not.toHaveProperty('GPG_PASSPHRASE');
|
||||
expect(result).toHaveProperty('PWD');
|
||||
});
|
||||
|
||||
it('strips vars matching ENCRYPT pattern', () => {
|
||||
const result = sanitizeEnv({ ENCRYPT_KEY: 'abc', ENCRYPTION_ALGO: 'aes', NODE_ENV: 'prod' });
|
||||
expect(result).not.toHaveProperty('ENCRYPT_KEY');
|
||||
expect(result).not.toHaveProperty('ENCRYPTION_ALGO');
|
||||
expect(result).toHaveProperty('NODE_ENV');
|
||||
});
|
||||
|
||||
it('strips vars matching SIGNING pattern', () => {
|
||||
const result = sanitizeEnv({ SIGNING_KEY: 'key', JWT_SIGNING_SECRET: 's', LC_ALL: 'C' });
|
||||
expect(result).not.toHaveProperty('SIGNING_KEY');
|
||||
expect(result).not.toHaveProperty('JWT_SIGNING_SECRET');
|
||||
expect(result).toHaveProperty('LC_ALL');
|
||||
});
|
||||
|
||||
it('preserves safe environment variables', () => {
|
||||
const safe = {
|
||||
PATH: '/usr/bin',
|
||||
HOME: '/home/user',
|
||||
COMPOSE_DIR: '/app/compose',
|
||||
NODE_ENV: 'production',
|
||||
TERM: 'xterm-256color',
|
||||
SHELL: '/bin/bash',
|
||||
LANG: 'en_US.UTF-8',
|
||||
};
|
||||
const result = sanitizeEnv(safe);
|
||||
expect(result).toEqual(safe);
|
||||
});
|
||||
|
||||
it('pattern matching is case-insensitive', () => {
|
||||
const result = sanitizeEnv({ my_secret: 'val', My_Password: 'val', PATH: '/usr/bin' });
|
||||
expect(result).not.toHaveProperty('my_secret');
|
||||
expect(result).not.toHaveProperty('My_Password');
|
||||
expect(result).toHaveProperty('PATH');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Session Limit ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('HostTerminalService session tracking', () => {
|
||||
let HostTerminalService: typeof import('../services/HostTerminalService').HostTerminalService;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import('../services/HostTerminalService');
|
||||
HostTerminalService = mod.HostTerminalService;
|
||||
});
|
||||
|
||||
it('activeSessions map is accessible and starts empty', () => {
|
||||
// Clear any leftover sessions
|
||||
HostTerminalService.activeSessions.clear();
|
||||
expect(HostTerminalService.activeSessions.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Console Token RBAC ─────────────────────────────────────────────────────
|
||||
|
||||
describe('POST /api/system/console-token', () => {
|
||||
it('returns 401 without authentication', async () => {
|
||||
const res = await request(app).post('/api/system/console-token');
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns 200 for admin user', async () => {
|
||||
const token = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(typeof res.body.token).toBe('string');
|
||||
});
|
||||
|
||||
it('returns 403 for non-admin user (viewer role)', async () => {
|
||||
// Create a viewer user in the DB
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const viewerHash = await bcrypt.hash('viewerpass', 1);
|
||||
db.addUser({ username: 'viewer_test', password_hash: viewerHash, role: 'viewer' });
|
||||
|
||||
const token = jwt.sign({ username: 'viewer_test' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 for deployer role', async () => {
|
||||
const { DatabaseService } = await import('../services/DatabaseService');
|
||||
const db = DatabaseService.getInstance();
|
||||
const deployerHash = await bcrypt.hash('deployerpass', 1);
|
||||
db.addUser({ username: 'deployer_test', password_hash: deployerHash, role: 'deployer' });
|
||||
|
||||
const token = jwt.sign({ username: 'deployer_test' }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${token}`);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 403 for API tokens', async () => {
|
||||
// Create an API token via the admin endpoint first
|
||||
const adminToken = jwt.sign({ username: TEST_USERNAME }, TEST_JWT_SECRET, { expiresIn: '1m' });
|
||||
const createRes = await request(app)
|
||||
.post('/api/api-tokens')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ name: 'test-console-blocked', scope: 'full-admin' });
|
||||
expect(createRes.status).toBe(201);
|
||||
const apiTokenValue = createRes.body.token;
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/system/console-token')
|
||||
.set('Authorization', `Bearer ${apiTokenValue}`);
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe('SCOPE_DENIED');
|
||||
});
|
||||
});
|
||||
+40
-5
@@ -2887,6 +2887,27 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
wsApiTokenScope = apiToken.scope;
|
||||
}
|
||||
|
||||
// For user session tokens (no scope), resolve against DB for up-to-date role and
|
||||
// token_version checks. This mirrors what authMiddleware does for HTTP requests.
|
||||
// Scoped tokens (api_token, node_proxy, console_session) skip this: they are
|
||||
// validated by their own logic above or by the gateway that issued them.
|
||||
let wsResolvedUser: { username: string; role: UserRole; token_version: number } | undefined;
|
||||
if (!decoded.scope && decoded.username) {
|
||||
const dbUser = DatabaseService.getInstance().getUserByUsername(decoded.username);
|
||||
if (!dbUser) {
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
if (decoded.tv !== undefined && dbUser.token_version !== decoded.tv) {
|
||||
console.log('[Auth] WS session rejected: token version mismatch for:', decoded.username);
|
||||
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
wsResolvedUser = { username: dbUser.username, role: dbUser.role as UserRole, token_version: dbUser.token_version };
|
||||
}
|
||||
|
||||
const url = req.url || '';
|
||||
const parsedUrl = new URL(url, `http://${req.headers.host || 'localhost'}`);
|
||||
const pathname = parsedUrl.pathname;
|
||||
@@ -3018,10 +3039,22 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
// RBAC gate: only users with 'system:console' permission may access the host console.
|
||||
// Console_session tokens are pre-validated by the gateway's requireAdmin() middleware,
|
||||
// so they skip this check. API tokens are already blocked by the scope gate above.
|
||||
const isConsoleSession = decoded.scope === 'console_session';
|
||||
if (!isConsoleSession) {
|
||||
const userRole = wsResolvedUser?.role;
|
||||
if (!userRole || !ROLE_PERMISSIONS[userRole]?.includes('system:console')) {
|
||||
console.log('[HostConsole] Access denied: insufficient permissions', { username: wsResolvedUser?.username || decoded.username, role: userRole });
|
||||
socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Admiral license gate: host console requires Admiral (paid + team variant).
|
||||
// For proxied connections (console_session tokens), trust the tier headers sent by the gateway;
|
||||
// for direct connections, check the local LicenseService.
|
||||
const isConsoleSession = decoded.scope === 'console_session';
|
||||
const consoleTierHeader = req.headers[PROXY_TIER_HEADER] as string | undefined;
|
||||
const consoleVariantHeader = req.headers[PROXY_VARIANT_HEADER] as string | undefined;
|
||||
const ls = LicenseService.getInstance();
|
||||
@@ -3036,13 +3069,15 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
const consoleUsername = wsResolvedUser?.username || decoded.username || 'console_session';
|
||||
const stackParam = parsedUrl.searchParams.get('stack');
|
||||
console.log('[HostConsole] WebSocket upgrade accepted', { username: consoleUsername, nodeId, stack: stackParam || '(root)' });
|
||||
const hostConsoleWss = new WebSocketServer({ noServer: true });
|
||||
hostConsoleWss.handleUpgrade(req, socket, head, (ws) => {
|
||||
hostConsoleWss.close();
|
||||
let targetDirectory = '';
|
||||
try {
|
||||
const baseDir = FileSystemService.getInstance(nodeId).getBaseDir();
|
||||
const stackParam = parsedUrl.searchParams.get('stack');
|
||||
if (stackParam) {
|
||||
const resolved = path.resolve(baseDir, stackParam);
|
||||
if (!resolved.startsWith(path.resolve(baseDir))) {
|
||||
@@ -3058,11 +3093,11 @@ server.on('upgrade', async (req, socket, head) => {
|
||||
targetDirectory = FileSystemService.getInstance(NodeRegistry.getInstance().getDefaultNodeId()).getBaseDir();
|
||||
}
|
||||
try {
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory);
|
||||
HostTerminalService.spawnTerminal(ws, targetDirectory, consoleUsername);
|
||||
} catch (error) {
|
||||
console.error('Failed to spawn host terminal:', error);
|
||||
console.error('[HostConsole] Unhandled spawn error:', { user: consoleUsername, error: (error as Error).message });
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(`Error spawning terminal: ${(error as Error).message}\r\n`);
|
||||
ws.send('Error: Failed to start terminal session.\r\n');
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,40 +2,122 @@ import * as os from 'os';
|
||||
import * as pty from 'node-pty';
|
||||
import { WebSocket } from 'ws';
|
||||
import { execSync } from 'child_process';
|
||||
import { isDebugEnabled } from '../utils/debug';
|
||||
|
||||
function getUnixShell() {
|
||||
let cachedShell: string | null = null;
|
||||
function getUnixShell(): string {
|
||||
if (cachedShell) return cachedShell;
|
||||
try {
|
||||
execSync('which bash', { stdio: 'ignore' });
|
||||
return 'bash';
|
||||
cachedShell = 'bash';
|
||||
} catch (e) {
|
||||
console.warn('[HostTerminalService] bash not found, falling back to sh:', (e as Error).message);
|
||||
return 'sh';
|
||||
cachedShell = 'sh';
|
||||
}
|
||||
return cachedShell;
|
||||
}
|
||||
|
||||
export class HostTerminalService {
|
||||
static spawnTerminal(ws: WebSocket, targetDirectory: string) {
|
||||
const shell = os.platform() === 'win32' ? 'powershell.exe' : getUnixShell();
|
||||
// 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;
|
||||
|
||||
// Strip sensitive backend secrets from the PTY environment so they are not
|
||||
// visible to the console user via `env` / `printenv`.
|
||||
// Pattern-based filtering: block any env var containing sensitive keywords.
|
||||
// Explicit fallback set catches vars that don't match patterns (e.g. DATABASE_URL).
|
||||
const SENSITIVE_PATTERNS = /SECRET|PASSWORD|TOKEN|KEY|CREDENTIAL/i;
|
||||
const SENSITIVE_KEYS = new Set(['DATABASE_URL']);
|
||||
const safeEnv = Object.fromEntries(
|
||||
Object.entries(process.env as Record<string, string>).filter(
|
||||
// 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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const ptyProcess = pty.spawn(shell, [], {
|
||||
name: 'xterm-color',
|
||||
cols: 80,
|
||||
rows: 30,
|
||||
cwd: targetDirectory,
|
||||
env: safeEnv,
|
||||
});
|
||||
static spawnTerminal(ws: WebSocket, targetDirectory: string, username: string) {
|
||||
// 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 });
|
||||
|
||||
// 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 });
|
||||
};
|
||||
|
||||
// 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) {
|
||||
@@ -43,27 +125,27 @@ export class HostTerminalService {
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('message', (message: string) => {
|
||||
ws.on('message', (raw: Buffer | ArrayBuffer | Buffer[]) => {
|
||||
try {
|
||||
const parsed = JSON.parse(message); // JSON-Up protocol
|
||||
const parsed = JSON.parse(raw.toString()); // JSON-Up protocol
|
||||
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 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse Host terminal message:', e);
|
||||
console.error('[HostConsole] Failed to parse terminal message:', { pid, error: (e as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Host terminal WebSocket closed, cleaning up PTY process');
|
||||
cleanup('WS');
|
||||
ptyProcess.kill();
|
||||
});
|
||||
|
||||
// Handle PTY process exit
|
||||
ptyProcess.onExit(({ exitCode, signal }) => {
|
||||
console.log(`Host terminal PTY process exited with code ${exitCode} and signal ${signal}`);
|
||||
cleanup('PTY exit', { exitCode, signal });
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user