diff --git a/backend/src/__tests__/host-console.test.ts b/backend/src/__tests__/host-console.test.ts new file mode 100644 index 00000000..49be429c --- /dev/null +++ b/backend/src/__tests__/host-console.test.ts @@ -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) => Record; + + 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'); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 85ff2104..49d0eda7 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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(); } } diff --git a/backend/src/services/HostTerminalService.ts b/backend/src/services/HostTerminalService.ts index 4c8c2db8..476623fc 100644 --- a/backend/src/services/HostTerminalService.ts +++ b/backend/src/services/HostTerminalService.ts @@ -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).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 | null = null; + +export class HostTerminalService { + static activeSessions = new Map(); + + /** + * Sanitize a set of environment variables by removing entries whose names + * match sensitive patterns or are in the explicit blocklist. + */ + static sanitizeEnv(env: Record): Record { + 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); + } + 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) => { + 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(); } diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index 129aa9fc..943d22b1 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -48,15 +48,21 @@ In a typical Docker deployment, the Sencho container runs Linux, so the console ## Availability -The Host Console is available to **admin** users on the **Admiral** tier. If your instance is on the Community or Skipper tier, the Console tab does not appear in the navigation bar at all. Attempting to access the console endpoint directly without the correct license is rejected. +The Host Console is available exclusively to users with the **admin** role on the **Admiral** tier. Other roles (node-admin, deployer, viewer, auditor) cannot access the console, even on Admiral-licensed instances. If your instance is on the Community or Skipper tier, the Console tab does not appear in the navigation bar at all. Attempting to access the console endpoint directly without the correct license or role is rejected. + +## Session limits + +Sencho enforces a maximum of **5 concurrent console sessions** per instance. If you hit this limit, close an existing session before opening a new one. Each session also includes an automatic heartbeat; if the browser tab is closed or the network drops, the server detects the dead connection and cleans up the session within about a minute. ## Security considerations The Host Console has stricter access requirements than other features: -- **Browser sessions only.** API tokens used for multi-node communication cannot open a console session. +- **Admin role required.** Only users with the `admin` role can open a console session. Non-admin roles are blocked at both the UI and the API level. +- **Browser sessions only.** API tokens used for automation or multi-node communication cannot open a console session. - **Admiral license required.** The license check is enforced at every layer, not just in the UI. -- **Sensitive environment variables are stripped.** Environment variables whose names suggest secrets (passwords, tokens, keys, credentials) and certain well-known database connection strings are automatically removed from the console environment. Running `env` or `printenv` inside the console will not reveal them. +- **Session invalidation.** Changing a user's password or role immediately invalidates any active session tokens. A user who is downgraded from admin loses console access instantly. +- **Sensitive environment variables are stripped.** Environment variables whose names suggest secrets (passwords, tokens, keys, credentials, auth-related variables, private keys, passphrases, encryption and signing secrets) and certain well-known connection strings are automatically removed from the console environment. Running `env` or `printenv` inside the console will not reveal them. 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. @@ -68,3 +74,23 @@ The Host Console has stricter access requirements than other features: - Running `docker compose logs --follow` or `docker ps` directly - Editing files with `nano` or `vim` for quick fixes - Running maintenance scripts or one-off commands on the host + +## Troubleshooting + + + + Sencho limits concurrent console sessions to 5 per instance. Close one or more existing console sessions (either through the UI or by closing the browser tab) and try again. Stale sessions from disconnected browsers are cleaned up automatically within about a minute. + + + + This typically means the shell could not be found on the host. In a Docker deployment, ensure `bash` or `sh` is available inside the container. You can check by running `docker exec which bash` from the host. Also verify that your user has the `admin` role and your instance has an active Admiral license. + + + + If you are running Sencho behind a reverse proxy (e.g. Nginx, Caddy, Traefik), the proxy may be closing WebSocket connections after its default idle timeout. Increase the WebSocket timeout in your proxy configuration. For Nginx, set `proxy_read_timeout` and `proxy_send_timeout` to a higher value (e.g. `3600s`). + + + + The Console tab only appears for users with the `admin` role on an Admiral-licensed instance. Verify both conditions are met. If you recently upgraded your license, you may need to refresh the page. + + diff --git a/docs/images/host-console/host-console-overview.png b/docs/images/host-console/host-console-overview.png index 864b3fcc..2c2742f0 100644 Binary files a/docs/images/host-console/host-console-overview.png and b/docs/images/host-console/host-console-overview.png differ diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index 34a69ed3..a94ff01a 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -11,6 +11,18 @@ interface HostConsoleProps { onClose: () => void; } +/** Build the xterm theme from CSS custom properties (resolved once per call). */ +function getTerminalTheme() { + const s = getComputedStyle(document.documentElement); + return { + background: s.getPropertyValue('--terminal-bg').trim(), + foreground: s.getPropertyValue('--terminal-fg').trim(), + cursor: s.getPropertyValue('--terminal-cursor').trim(), + cursorAccent: s.getPropertyValue('--terminal-cursor-accent').trim(), + selectionBackground: s.getPropertyValue('--terminal-selection').trim(), + }; +} + export default function HostConsole({ stackName, onClose }: HostConsoleProps) { const { activeNode } = useNodes(); const terminalRef = useRef(null); @@ -39,13 +51,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { let mounted = true; const term = new Terminal({ - theme: { - background: '#1e1e1e', - foreground: '#d4d4d4', - cursor: '#ffffff', - cursorAccent: '#000000', - selectionBackground: 'rgba(255, 255, 255, 0.3)', - }, + theme: getTerminalTheme(), fontFamily: "'Geist Mono', monospace", fontSize: 14, cursorBlink: true, @@ -154,10 +160,10 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { }, [stackName, cleanup]); return ( -
-
+
+
- + Host Console {activeNode && ( @@ -176,11 +182,14 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { )}
-
+
diff --git a/frontend/src/index.css b/frontend/src/index.css index 701c6ec5..0d574af7 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -142,6 +142,13 @@ --tracking-normal: 0em; --spacing: 0.25rem; + + /* Terminal (dark well even in light theme) */ + --terminal-bg: oklch(0.12 0 0); + --terminal-fg: oklch(0.85 0 0); + --terminal-cursor: oklch(1 0 0); + --terminal-cursor-accent: oklch(0 0 0); + --terminal-selection: oklch(1 0 0 / 0.3); } /* ───────────────────────────────────────────────────────────── @@ -271,6 +278,10 @@ --shadow-lg: 0px 4px 6px -1px hsl(0 0% 0% / 0.15); --shadow-xl: 0px 8px 10px -1px hsl(0 0% 0% / 0.20); --shadow-2xl: 0px 12px 20px -2px hsl(0 0% 0% / 0.30); + + /* Terminal (recessed well, matches page background) + --terminal-fg/cursor/selection: inherited from :root (both themes use a dark terminal) */ + --terminal-bg: oklch(0.08 0 0); } /* ─────────────────────────────────────────────────────────────