import type { Server } from "http"; import type { IncomingMessage } from "http"; import type { Duplex } from "stream"; import { WebSocketServer, WebSocket } from "ws"; import * as pty from "@lydell/node-pty"; import { randomUUID } from "node:crypto"; import { existsSync } from "node:fs"; import os from "node:os"; import { parse } from "node:url"; import { serverEnv } from "@/lib/server/env"; import { getAuthCookieName } from "@/lib/server/modules/auth/cookies"; import { authenticateSession } from "@/lib/server/modules/auth/service"; import { markInteractiveTerminalAvailable } from "@/lib/server/modules/terminal/availability"; interface ResizeMessage { type: "resize"; cols?: number; rows?: number; } interface AttachMessage { type: "attach"; target?: string; sessionId?: string; } interface InputMessage { type: "input"; data: string; } interface PingMessage { type: "ping"; } type TerminalMessage = ResizeMessage | AttachMessage | InputMessage | PingMessage; type AuthenticatedSession = { userId: string; username: string; sessionId: string; }; type TerminalUpgradeRequest = IncomingMessage & { terminalSession?: AuthenticatedSession | null; }; /** Docker's own rule for container names; also keeps `-flags` out of `docker exec`. */ const CONTAINER_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,254}$/; const activeTerminalSessionIdsByUser = new Map>(); /** * Allowlist of environment variable names that are safe to forward to PTY * child processes. Deliberately excludes any application secrets such as * DATABASE_URL, AUTH_SESSION_SECRET, API keys, etc. that live in process.env. */ const PTY_SAFE_ENV_KEYS = new Set([ "HOME", "USER", "LOGNAME", "SHELL", "PATH", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES", "COLORTERM", "DISPLAY", "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "DBUS_SESSION_BUS_ADDRESS", ]); const DEFAULT_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; function currentUser() { try { return os.userInfo(); } catch { return null; } } /** * The shell a login on this machine would get: the account's own shell, else * bash, else sh (the Docker image is Alpine, where bash may be missing). */ export function resolveLoginShell(): string { if (os.platform() === "win32") return "powershell.exe"; const candidates = [currentUser()?.shell, process.env.SHELL, "/bin/bash", "/bin/sh"]; for (const candidate of candidates) { if (candidate && candidate.startsWith("/") && existsSync(candidate)) { return candidate; } } return "sh"; } /** * Build a minimal, safe environment for PTY child processes. systemd starts * Homeio without HOME, USER or a full PATH, so fill those from the account * the server runs as -- a login shell needs them to find its profile. */ export function buildPtyEnv(shell = resolveLoginShell()): Record { const env: Record = { TERM: "xterm-256color", COLORTERM: "truecolor", }; for (const key of PTY_SAFE_ENV_KEYS) { const val = process.env[key]; if (val !== undefined) env[key] = val; } const user = currentUser(); env.HOME ??= user?.homedir || os.homedir(); env.USER ??= user?.username ?? "root"; env.LOGNAME ??= env.USER; env.SHELL = shell; env.PATH ??= DEFAULT_PATH; env.LANG ??= "C.UTF-8"; return env; } /** * Browsers send cookies on a WebSocket handshake from any page on the same * site -- including another app on this server's IP at a different port. A * root shell must only open for Homeio's own pages, so the Origin has to name * the host the request came in on. Clients that send no Origin are not * browsers and still need the session cookie. */ export function isSameOriginUpgrade(request: IncomingMessage): boolean { const origin = request.headers.origin; if (!origin) return true; let originHost: string; try { originHost = new URL(origin).host.toLowerCase(); } catch { return false; } const forwardedHost = request.headers["x-forwarded-host"]; const hosts = [ request.headers.host, ...(Array.isArray(forwardedHost) ? forwardedHost : [forwardedHost]), ] .flatMap((value) => (value ?? "").split(",")) .map((value) => value.trim().toLowerCase()) .filter((value) => value.length > 0); return hosts.includes(originHost); } function parseCookies(headerValue: string | undefined) { if (!headerValue) return {} as Record; const parsed: Record = {}; for (const segment of headerValue.split(";")) { const [rawKey, ...rawValueParts] = segment.split("="); const key = rawKey?.trim(); if (!key) continue; const rawValue = rawValueParts.join("=").trim(); if (!rawValue) continue; try { parsed[key] = decodeURIComponent(rawValue); } catch { parsed[key] = rawValue; } } return parsed; } function closeUpgradeSocket(socket: Duplex, statusCode: number, message: string) { socket.write( `HTTP/1.1 ${statusCode} ${message}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n${JSON.stringify({ error: message, })}`, ); socket.destroy(); } function sendWsErrorAndClose(ws: WebSocket, code: number, errorCode: string, message: string) { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ type: "error", code: errorCode, message, }), ); } ws.close(code, message.slice(0, 120)); } function registerActiveSession(userId: string, sessionId: string) { const set = activeTerminalSessionIdsByUser.get(userId) ?? new Set(); if (set.size >= serverEnv.TERMINAL_MAX_SESSIONS_PER_USER) { return false; } set.add(sessionId); activeTerminalSessionIdsByUser.set(userId, set); return true; } function unregisterActiveSession(userId: string, sessionId: string) { const set = activeTerminalSessionIdsByUser.get(userId); if (!set) return; set.delete(sessionId); if (set.size === 0) { activeTerminalSessionIdsByUser.delete(userId); } } function clampDimension(value: unknown, fallback: number, max: number) { if (typeof value !== "number" || !Number.isFinite(value)) return fallback; return Math.min(max, Math.max(1, Math.floor(value))); } function isControlMessage(value: unknown): value is TerminalMessage { if (!value || typeof value !== "object") return false; const record = value as { type?: string }; return record.type === "resize" || record.type === "attach" || record.type === "input" || record.type === "ping"; } /** * Initialize WebSocket server for terminal functionality * @param server - HTTP server instance */ export function initializeWebSocketServer(server: Server): void { const wss = new WebSocketServer({ noServer: true }); markInteractiveTerminalAvailable(); wss.on("connection", (ws: WebSocket, request: TerminalUpgradeRequest) => { let ptyProcess: pty.IPty | null = null; let targetContainer: string | null = null; let closed = false; let idleTimer: NodeJS.Timeout | null = null; let lifetimeTimer: NodeJS.Timeout | null = null; const terminalSessionId = randomUUID(); let cols = 80; let rows = 24; const authSession = request.terminalSession ?? null; const shell = resolveLoginShell(); const shellArgs = os.platform() === "win32" ? [] : ["-l"]; const ptyEnv = buildPtyEnv(shell); const cwd = existsSync(ptyEnv.HOME) ? ptyEnv.HOME : "/"; if (serverEnv.TERMINAL_WS_REQUIRE_AUTH && !authSession) { sendWsErrorAndClose(ws, 4401, "unauthorized", "Unauthorized"); return; } if (authSession && !registerActiveSession(authSession.userId, terminalSessionId)) { sendWsErrorAndClose( ws, 4408, "session_limit_reached", "Too many active terminal sessions for this user", ); return; } const resetIdleTimer = () => { if (idleTimer) { clearTimeout(idleTimer); } idleTimer = setTimeout(() => { sendWsErrorAndClose(ws, 4000, "session_idle_timeout", "Terminal session timed out"); }, serverEnv.TERMINAL_IDLE_TIMEOUT_MS); idleTimer.unref?.(); }; const clearTimers = () => { if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; } if (lifetimeTimer) { clearTimeout(lifetimeTimer); lifetimeTimer = null; } }; const killPty = () => { if (!ptyProcess) return; try { ptyProcess.kill(); } catch { // ignored } finally { ptyProcess = null; } }; const cleanup = () => { if (closed) return; closed = true; clearTimers(); killPty(); if (authSession) { unregisterActiveSession(authSession.userId, terminalSessionId); } }; lifetimeTimer = setTimeout(() => { sendWsErrorAndClose(ws, 4001, "session_max_lifetime", "Terminal max session lifetime reached"); }, serverEnv.TERMINAL_MAX_SESSION_MS); lifetimeTimer.unref?.(); resetIdleTimer(); const attachPty = ( nextPty: pty.IPty, onExit: (exitCode: { exitCode: number; signal?: number }) => void, ) => { ptyProcess = nextPty; nextPty.onData((data: string) => { if (ptyProcess !== nextPty) return; resetIdleTimer(); if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }); nextPty.onExit((exitCode: { exitCode: number; signal?: number }) => { if (ptyProcess !== nextPty) return; onExit(exitCode); }); }; // Create PTY for host shell const createHostPty = (): void => { try { const nextPty = pty.spawn(shell, shellArgs, { name: "xterm-256color", cols, rows, cwd, env: ptyEnv, }); attachPty(nextPty, () => { if (closed) return; ws.close(); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Failed to create PTY:", error); ws.send(`\r\n\x1b[1;31mFailed to create terminal: ${message}\x1b[0m\r\n`); ws.close(); } }; const createDockerPty = (container: string, shellBin: "bash" | "sh"): void => { try { const nextPty = pty.spawn("docker", ["exec", "-it", container, shellBin], { name: "xterm-256color", cols, rows, cwd, env: ptyEnv, }); attachPty(nextPty, (exitCode: { exitCode: number; signal?: number }) => { if (closed) return; if (exitCode.exitCode !== 0 && shellBin === "bash" && targetContainer) { createDockerPty(targetContainer, "sh"); return; } if (ws.readyState === WebSocket.OPEN) { ws.send("\r\n\x1b[1;33mContainer session ended\x1b[0m\r\n"); } targetContainer = null; createHostPty(); }); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.error("Failed to create Docker PTY:", error); ws.send(`\r\n\x1b[1;31mFailed to attach to container: ${message}\x1b[0m\r\n`); targetContainer = null; createHostPty(); } }; // Initialize with host shell createHostPty(); // Handle WebSocket messages ws.on("message", (data: Buffer | string) => { resetIdleTimer(); const message = data.toString(); try { // Try to parse as JSON for control messages const parsed = JSON.parse(message) as unknown; if (!isControlMessage(parsed)) { if (ptyProcess) { ptyProcess.write(message); } return; } if (parsed.type === "resize") { cols = clampDimension(parsed.cols, cols, 500); rows = clampDimension(parsed.rows, rows, 200); ptyProcess?.resize(cols, rows); return; } if (parsed.type === "ping") { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: "pong" })); } return; } if (parsed.type === "input") { if (ptyProcess) { ptyProcess.write(parsed.data ?? ""); } return; } if (parsed.type === "attach" && parsed.target) { if (!CONTAINER_NAME_PATTERN.test(parsed.target)) { if (ws.readyState === WebSocket.OPEN) { ws.send("\r\n\x1b[1;31mInvalid container name\x1b[0m\r\n"); } return; } // Switch to docker container killPty(); targetContainer = parsed.target; createDockerPty(parsed.target, "bash"); return; } } catch { // Not JSON, treat as terminal input if (ptyProcess) { ptyProcess.write(message); } } }); ws.on("close", () => { cleanup(); }); ws.on("error", (error: Error) => { console.error("WebSocket error:", error); cleanup(); }); }); // Handle upgrade for WebSocket server.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) => { const { pathname } = parse(request.url || ""); if (pathname !== "/api/terminal") return; if (serverEnv.DEMO_MODE) { closeUpgradeSocket(socket, 403, "Terminal is disabled in demo mode"); return; } if (!isSameOriginUpgrade(request)) { closeUpgradeSocket(socket, 403, "Forbidden"); return; } void (async () => { let terminalSession: AuthenticatedSession | null = null; if (serverEnv.TERMINAL_WS_REQUIRE_AUTH) { const cookies = parseCookies(request.headers.cookie); const sessionToken = cookies[getAuthCookieName()] ?? null; const session = await authenticateSession(sessionToken); if (!session) { closeUpgradeSocket(socket, 401, "Unauthorized"); return; } terminalSession = { userId: session.userId, username: session.username, sessionId: session.sessionId, }; } const terminalRequest = request as TerminalUpgradeRequest; terminalRequest.terminalSession = terminalSession; wss.handleUpgrade(request, socket, head, (ws: WebSocket) => { wss.emit("connection", ws, terminalRequest); }); })().catch(() => { closeUpgradeSocket(socket, 500, "Internal Server Error"); }); }); }