/** * HTTP + WebSocket server bootstrap for remote-control. * * Responsible for: * - Creating and configuring the HTTP server (middleware, TLS in future T-1.3) * - Wiring up the WebSocket upgrade handler (see upgrade.ts) * - Serving LEGACY routes for the browser HTML client (html.ts) * - Managing the connected-client set and the broadcast helper * - Returning a RemoteServer handle to the extension entry point (index.ts) * * LEGACY routes (kept until Phase 2 ships and html.ts is retired): * GET / — serves the browser HTML UI (buildHTML from html.ts) * GET /index.html — same as / * GET /manifest.json — PWA manifest * GET /icon.svg — PWA icon * WS /ws — WebSocket endpoint for the browser client * * Future route modules live under routes/ and are wired here once they * land in T-1.5, T-1.6, T-1.7. */ import { randomBytes } from "node:crypto"; import type { IncomingMessage } from "node:http"; import { createServer } from "node:http"; import { createRequire } from "node:module"; import type { AddressInfo } from "node:net"; import type { ExtensionAPI, ExtensionContext, } from "@earendil-works/pi-coding-agent"; import { buildPairingUrl, consumePairingToken, generatePairingToken, printPairingQr, } from "../auth/pairing.js"; import { createToken, validateBearer, validateBearerSync, warmTokenCache, } from "../auth/tokens.js"; import { generateSessionId, loadOrCreateToken, parseCookies, SESSION_COOKIE, validateToken, } from "../auth.js"; import { parseBindAddress, readRemoteControlConfig } from "../config.js"; import { buildHTML } from "../html.js"; // LEGACY: browser HTML client import { buildSyncMessage } from "../messages.js"; import { handleCommands } from "./routes/commands.js"; import { handleHealth } from "./routes/health.js"; import { handleInput } from "./routes/input.js"; import { handleSessions } from "./routes/sessions.js"; import type { RemoteServer, WsClient, WsServer } from "./types.js"; import { createUpgradeHandler } from "./upgrade.js"; import { extractBearer, readBody, sendJson } from "./util.js"; // --------------------------------------------------------------------------- // Load ws (bundled with pi) without needing @types/ws installed locally // --------------------------------------------------------------------------- const _require = createRequire(import.meta.url); const wsModule = _require("ws") as { WebSocketServer: new (opts: { noServer: boolean }) => WsServer; OPEN: number; }; const { WebSocketServer, OPEN } = wsModule; // --------------------------------------------------------------------------- // startServer // --------------------------------------------------------------------------- export async function startServer( pi: ExtensionAPI, ctx: ExtensionContext, ): Promise { const config = await readRemoteControlConfig(); const bindAddr = config.bindAddress ?? ""; const { host: bindHost, port: bindPort } = bindAddr ? parseBindAddress(bindAddr) : { host: "127.0.0.1", port: 0 }; const clientChangeListeners: Array<() => void> = []; const clients = new Set(); const token = await loadOrCreateToken(); await warmTokenCache(); // pre-load multi-tokens into sync cache // Map of valid session IDs → expiry timestamp (ms since epoch) const SESSION_TTL_MS = 86_400_000; // 24 h — matches cookie Max-Age const validSessions = new Map(); const pruneExpiredSessions = (): void => { const now = Date.now(); for (const [id, expiresAt] of validSessions) { if (expiresAt <= now) validSessions.delete(id); } }; /** Check if a request is authenticated. * Accepts: session cookie | legacy ?token= | Authorization: Bearer */ function isAuthenticated(req: IncomingMessage): boolean { // 1. Session cookie (legacy browser HTML client) const cookies = parseCookies(req.headers.cookie); const sessionId = cookies[SESSION_COOKIE]; const sessionExpiry = sessionId ? validSessions.get(sessionId) : undefined; if (sessionExpiry !== undefined && sessionExpiry > Date.now()) return true; // 2. Legacy single token via query param (smoke tests, CLI) const url = new URL(req.url ?? "/", "http://localhost"); const providedToken = url.searchParams.get("token"); if (providedToken && validateToken(providedToken, token)) return true; // 3. Bearer token via Authorization header or ?token= (iOS app, multi-token) const bearer = extractBearer(req); if (bearer) { if (validateToken(bearer, token)) return true; if (validateBearerSync(bearer)) return true; // multi-token sync cache } return false; } /** Async auth check — also validates multi-token bearer from auth/tokens store */ async function isAuthenticatedAsync(req: IncomingMessage): Promise { if (isAuthenticated(req)) return true; const bearer = extractBearer(req); if (bearer) { const entry = await validateBearer(bearer).catch(() => null); if (entry) return true; } return false; } function broadcast(msg: object): void { const data = JSON.stringify(msg); for (const client of clients) { if (client.readyState === OPEN) { try { client.send(data); } catch { /* ignore */ } } } } function sync(currentCtx: ExtensionContext): void { broadcast(buildSyncMessage(currentCtx)); } // ── HTTP server ───────────────────────────────────────────────────────── const httpServer = createServer((req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); const pathname = url.pathname; // LEGACY: PWA manifest — served for the browser HTML client if (pathname === "/manifest.json") { res.writeHead(200, { "Content-Type": "application/manifest+json; charset=utf-8", }); res.end( JSON.stringify({ name: "Pi Remote", short_name: "Pi", description: "Remote control for Pi sessions", start_url: "/", display: "standalone", background_color: "#0d1117", theme_color: "#0d1117", icons: [{ src: "/icon.svg", sizes: "any", type: "image/svg+xml" }], }), ); return; } // LEGACY: PWA icon — served for the browser HTML client if (pathname === "/icon.svg") { res.writeHead(200, { "Content-Type": "image/svg+xml", "Cache-Control": "public, max-age=86400", }); res.end( `π`, ); return; } // LEGACY: browser HTML client root route if (pathname === "/" || pathname === "/index.html") { const cookies = parseCookies(req.headers.cookie); const sc = cookies[SESSION_COOKIE]; const hasValidSession = sc !== undefined && (validSessions.get(sc) ?? 0) > Date.now(); const providedToken = url.searchParams.get("token"); const hasValidToken = providedToken && validateToken(providedToken, token); if (!hasValidSession && !hasValidToken) { res.writeHead(403, { "Content-Type": "text/plain; charset=utf-8" }); res.end( "Forbidden — valid token required. Use the URL shown in the pi terminal.", ); return; } // If authenticated via token (first visit), issue a session cookie and redirect to clean URL if (!hasValidSession && hasValidToken) { pruneExpiredSessions(); const sessionId = generateSessionId(); validSessions.set(sessionId, Date.now() + SESSION_TTL_MS); res.writeHead(302, { "Set-Cookie": `${SESSION_COOKIE}=${sessionId}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`, Location: "/", }); res.end(); return; } // Valid session cookie — serve the browser HTML UI const nonce = randomBytes(16).toString("base64"); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "X-Frame-Options": "DENY", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; manifest-src 'self'; base-uri 'none'`, }); res.end(buildHTML(nonce)); // LEGACY: renders the browser HTML client return; } // New API routes (IC-2) — bearer token auth const asyncHandler = async (): Promise => { // GET /pair-qr — generates pairing token + prints QR to response if (pathname === "/pair-qr" && req.method === "GET") { if (!isAuthenticated(req)) { sendJson(res, 403, { error: "forbidden" }); return true; } const addr = httpServer.address() as { address: string; port: number; } | null; const port = addr?.port ?? 7777; const rawHost = (config.publicBaseUrl ?? config.advertisedBaseUrl) ?.replace(/^https?:\/\//, "") .replace(/\/$/, "") ?? addr?.address ?? "localhost"; // Strip :port suffix if publicBaseUrl already contains it const host = rawHost.includes(":") ? rawHost.slice(0, rawHost.lastIndexOf(":")) : rawHost; const pairingEntry = generatePairingToken(); const url = buildPairingUrl({ host, port, pairingToken: pairingEntry.token, fingerprint: "NO-TLS-YET-REPLACE-IN-T-1-3", sidecarName: "pi-remote", }); res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" }); // Render QR inline const qrcode = await import("qrcode"); const qr = await qrcode.toString(url, { type: "terminal", small: true, }); res.end(`${qr}\n${url}\n\nExpires in 10 minutes.`); return true; } // POST /pair — unauthenticated, uses one-time pairingToken if (pathname === "/pair" && req.method === "POST") { let body: Record = {}; try { const raw = await readBody(req); if (raw.trim()) body = JSON.parse(raw) as Record; } catch { sendJson(res, 400, { error: "bad_request", message: "Invalid JSON" }); return true; } const pairingToken = typeof body.pairingToken === "string" ? body.pairingToken : null; if (!pairingToken || !consumePairingToken(pairingToken)) { sendJson(res, 403, { error: "invalid_pairing_token", message: "Pairing token invalid or expired", }); return true; } const deviceName = typeof body.deviceName === "string" ? body.deviceName : "iOS device"; const entry = await createToken(deviceName); sendJson(res, 200, { bearerToken: entry.token, sidecarId: entry.id }); return true; } // All other API routes require auth const isApiAuthed = await isAuthenticatedAsync(req); if (!isApiAuthed) return false; // GET /health if (pathname === "/health" && req.method === "GET") { await handleHealth(req, res); return true; } // /sessions — list + create if (pathname === "/sessions") { await handleSessions(req, res); return true; } // /sessions/:id — PATCH, DELETE const sessMatch = pathname.match(/^\/sessions\/([^/]+)$/); if (sessMatch) { await handleSessions(req, res, decodeURIComponent(sessMatch[1])); return true; } // /sessions/:id/thumbnail const thumbMatch = pathname.match(/^\/sessions\/([^/]+)\/thumbnail$/); if (thumbMatch) { await handleSessions( req, res, decodeURIComponent(thumbMatch[1]), "thumbnail", ); return true; } // /sessions/:id/commands const cmdMatch = pathname.match(/^\/sessions\/([^/]+)\/commands$/); if (cmdMatch) { await handleCommands(req, res, decodeURIComponent(cmdMatch[1]), pi); return true; } // /sessions/:id/input const inputMatch = pathname.match(/^\/sessions\/([^/]+)\/input$/); if (inputMatch) { await handleInput(req, res, decodeURIComponent(inputMatch[1])); return true; } return false; }; asyncHandler() .then((handled) => { if (!handled) { res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }); res.end("Not found"); } }) .catch((err: Error) => { res.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" }); res.end(err.message); }); }); // ── WebSocket server ──────────────────────────────────────────────────── const wss = new WebSocketServer({ noServer: true }); httpServer.on("error", (err: Error) => { console.error("[remote-control] httpServer error:", err.message); }); wss.on("error", (err: Error) => { console.error("[remote-control] wss error:", err.message); }); // Wire upgrade handler (see upgrade.ts for path routing) httpServer.on("upgrade", createUpgradeHandler(wss, isAuthenticated)); // ── LEGACY: browser HTML client WebSocket connection handling ─────────── wss.on("connection", (ws: WsClient) => { clients.add(ws); for (const cb of clientChangeListeners) cb(); // Send full state snapshot to the new client try { ws.send(JSON.stringify(buildSyncMessage(ctx))); } catch { /* client disconnected before first send */ } // Per-connection rate limiting: max 30 prompts per 60 seconds const RATE_WINDOW_MS = 60_000; const RATE_MAX = 30; const MAX_MSG_BYTES = 64 * 1024; const recentPrompts: number[] = []; ws.on("message", (data: Buffer) => { if (data.length > MAX_MSG_BYTES) return; let msg: { type?: string; text?: string }; try { const parsed: unknown = JSON.parse(data.toString()); if (typeof parsed !== "object" || parsed === null) return; msg = parsed as { type?: string; text?: string }; } catch { return; } if (msg.type === "stop") { if (!ctx.isIdle()) { ctx.abort(); } return; } if ( msg.type === "prompt" && typeof msg.text === "string" && msg.text.trim() ) { const text = msg.text.trim(); // Sliding-window rate limit const now = Date.now(); const cutoff = now - RATE_WINDOW_MS; while (recentPrompts.length > 0 && recentPrompts[0] < cutoff) recentPrompts.shift(); if (recentPrompts.length >= RATE_MAX) return; recentPrompts.push(now); if (ctx.isIdle()) { pi.sendUserMessage(text); } else { pi.sendUserMessage(text, { deliverAs: "followUp" }); } } }); const onClose = (): void => { clients.delete(ws); broadcast({ type: "status", clientCount: clients.size }); for (const cb of clientChangeListeners) cb(); }; ws.on("close", onClose); ws.on("error", onClose); }); // ── Listen ─────────────────────────────────────────────────────────────── return new Promise((resolve) => { httpServer.listen(bindPort, bindHost, () => { resolve({ broadcast, sync, stop: () => new Promise((res) => { // Forcefully kill all WebSocket clients — terminate() sends no // close frame and doesn't wait for the remote end to acknowledge, // so it can't hang on an unresponsive client. for (const client of clients) { try { client.terminate(); } catch { /* ignore */ } } clients.clear(); // Safety timeout — if wss/http shutdown callbacks never fire // (e.g. lingering keep-alive sockets), resolve anyway so the // session_shutdown handler doesn't block pi from exiting. const timeout = setTimeout(() => { httpServer.close(() => {}); httpServer.closeAllConnections?.(); res(); }, 2000); wss.close(() => httpServer.close(() => { clearTimeout(timeout); res(); }), ); }), clientCount: () => clients.size, onClientChange: (cb: () => void) => { clientChangeListeners.push(cb); }, get port() { return (httpServer.address() as AddressInfo | null)?.port ?? 0; }, get token() { return token; }, }); }); }); }