daemon.ts
1 /** 2 * opencli micro-daemon — HTTP + WebSocket bridge between CLI and Chrome Extension. 3 * 4 * Architecture: 5 * CLI → HTTP POST /command → daemon → WebSocket → Extension 6 * Extension → WebSocket result → daemon → HTTP response → CLI 7 * 8 * Security (defense-in-depth against browser-based CSRF): 9 * 1. Origin check — reject HTTP/WS from non chrome-extension:// origins 10 * 2. Custom header — require X-OpenCLI header (browsers can't send it 11 * without CORS preflight, which we deny) 12 * 3. No CORS headers on command endpoints — only /ping is readable from the 13 * Browser Bridge extension origin so the extension can probe daemon reachability 14 * 4. Body size limit — 1 MB max to prevent OOM 15 * 5. WebSocket verifyClient — reject upgrade before connection is established 16 * 17 * Lifecycle: 18 * - Auto-spawned by opencli on first browser command 19 * - Persistent — stays alive until explicit shutdown, SIGTERM, or uninstall 20 * - Listens on localhost:19825 21 */ 22 23 import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; 24 import { WebSocketServer, WebSocket, type RawData } from 'ws'; 25 import { DEFAULT_DAEMON_PORT } from './constants.js'; 26 import { EXIT_CODES } from './errors.js'; 27 import { log } from './logger.js'; 28 import { PKG_VERSION } from './version.js'; 29 30 const PORT = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10); 31 32 // ─── State ─────────────────────────────────────────────────────────── 33 34 let extensionWs: WebSocket | null = null; 35 let extensionVersion: string | null = null; 36 let extensionCompatRange: string | null = null; 37 const pending = new Map<string, { 38 resolve: (data: unknown) => void; 39 reject: (error: Error) => void; 40 timer: ReturnType<typeof setTimeout>; 41 }>(); 42 // Extension log ring buffer 43 interface LogEntry { level: string; msg: string; ts: number; } 44 const LOG_BUFFER_SIZE = 200; 45 const logBuffer: LogEntry[] = []; 46 47 function pushLog(entry: LogEntry): void { 48 logBuffer.push(entry); 49 if (logBuffer.length > LOG_BUFFER_SIZE) logBuffer.shift(); 50 } 51 52 // ─── HTTP Server ───────────────────────────────────────────────────── 53 54 const MAX_BODY = 1024 * 1024; // 1 MB — commands are tiny; this prevents OOM 55 56 function readBody(req: IncomingMessage): Promise<string> { 57 return new Promise((resolve, reject) => { 58 const chunks: Buffer[] = []; 59 let size = 0; 60 let aborted = false; 61 req.on('data', (c: Buffer) => { 62 size += c.length; 63 if (size > MAX_BODY) { aborted = true; req.destroy(); reject(new Error('Body too large')); return; } 64 chunks.push(c); 65 }); 66 req.on('end', () => { if (!aborted) resolve(Buffer.concat(chunks).toString('utf-8')); }); 67 req.on('error', (err) => { if (!aborted) reject(err); }); 68 }); 69 } 70 71 function jsonResponse( 72 res: ServerResponse, 73 status: number, 74 data: unknown, 75 extraHeaders?: Record<string, string>, 76 ): void { 77 res.writeHead(status, { 'Content-Type': 'application/json', ...extraHeaders }); 78 res.end(JSON.stringify(data)); 79 } 80 81 export function getResponseCorsHeaders(pathname: string, origin?: string): Record<string, string> | undefined { 82 if (pathname !== '/ping') return undefined; 83 if (!origin || !origin.startsWith('chrome-extension://')) return undefined; 84 return { 85 'Access-Control-Allow-Origin': origin, 86 Vary: 'Origin', 87 }; 88 } 89 90 async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> { 91 // ─── Security: Origin & custom-header check ────────────────────── 92 // Block browser-based CSRF: browsers always send an Origin header on 93 // cross-origin requests. Node.js CLI fetch does NOT send Origin, so 94 // legitimate CLI requests pass through. Chrome Extension connects via 95 // WebSocket (which bypasses this HTTP handler entirely). 96 const origin = req.headers['origin'] as string | undefined; 97 if (origin && !origin.startsWith('chrome-extension://')) { 98 jsonResponse(res, 403, { ok: false, error: 'Forbidden: cross-origin request blocked' }); 99 return; 100 } 101 102 // CORS: do NOT send Access-Control-Allow-Origin for normal requests. 103 // Only handle preflight so browsers get a definitive "no" answer. 104 if (req.method === 'OPTIONS') { 105 // No ACAO header → browser will block the actual request. 106 res.writeHead(204); 107 res.end(); 108 return; 109 } 110 111 const url = req.url ?? '/'; 112 const pathname = url.split('?')[0]; 113 114 // Health-check endpoint — no X-OpenCLI header required. 115 // Used by the extension to silently probe daemon reachability before 116 // attempting a WebSocket connection (avoids uncatchable ERR_CONNECTION_REFUSED). 117 // Security note: this endpoint is reachable by any client that passes the 118 // origin check above (chrome-extension:// or no Origin header, e.g. curl). 119 // Timing side-channels can reveal daemon presence to local processes, which 120 // is an accepted risk given the daemon is loopback-only and short-lived. 121 if (req.method === 'GET' && pathname === '/ping') { 122 jsonResponse(res, 200, { ok: true }, getResponseCorsHeaders(pathname, origin)); 123 return; 124 } 125 126 // Require custom header on all other HTTP requests. Browsers cannot attach 127 // custom headers in "simple" requests, and our preflight returns no 128 // Access-Control-Allow-Headers, so scripted fetch() from web pages is 129 // blocked even if Origin check is somehow bypassed. 130 if (!req.headers['x-opencli']) { 131 jsonResponse(res, 403, { ok: false, error: 'Forbidden: missing X-OpenCLI header' }); 132 return; 133 } 134 135 if (req.method === 'GET' && pathname === '/status') { 136 const uptime = process.uptime(); 137 const mem = process.memoryUsage(); 138 jsonResponse(res, 200, { 139 ok: true, 140 pid: process.pid, 141 uptime, 142 daemonVersion: PKG_VERSION, 143 extensionConnected: extensionWs?.readyState === WebSocket.OPEN, 144 extensionVersion, 145 extensionCompatRange, 146 pending: pending.size, 147 memoryMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, 148 port: PORT, 149 }); 150 return; 151 } 152 153 if (req.method === 'GET' && pathname === '/logs') { 154 const params = new URL(url, `http://localhost:${PORT}`).searchParams; 155 const level = params.get('level'); 156 const filtered = level 157 ? logBuffer.filter(e => e.level === level) 158 : logBuffer; 159 jsonResponse(res, 200, { ok: true, logs: filtered }); 160 return; 161 } 162 163 if (req.method === 'DELETE' && pathname === '/logs') { 164 logBuffer.length = 0; 165 jsonResponse(res, 200, { ok: true }); 166 return; 167 } 168 169 if (req.method === 'POST' && pathname === '/shutdown') { 170 jsonResponse(res, 200, { ok: true, message: 'Shutting down' }); 171 setTimeout(() => shutdown(), 100); 172 return; 173 } 174 175 if (req.method === 'POST' && url === '/command') { 176 try { 177 const body = JSON.parse(await readBody(req)); 178 if (!body.id) { 179 jsonResponse(res, 400, { ok: false, error: 'Missing command id' }); 180 return; 181 } 182 183 if (!extensionWs || extensionWs.readyState !== WebSocket.OPEN) { 184 jsonResponse(res, 503, { id: body.id, ok: false, error: 'Extension not connected. Please install the opencli Browser Bridge extension.' }); 185 return; 186 } 187 188 const timeoutMs = typeof body.timeout === 'number' && body.timeout > 0 189 ? body.timeout * 1000 190 : 120000; 191 if (pending.has(body.id)) { 192 jsonResponse(res, 409, { 193 id: body.id, 194 ok: false, 195 error: 'Duplicate command id already pending; retry', 196 }); 197 return; 198 } 199 const result = await new Promise<unknown>((resolve, reject) => { 200 const timer = setTimeout(() => { 201 pending.delete(body.id); 202 reject(new Error(`Command timeout (${timeoutMs / 1000}s)`)); 203 }, timeoutMs); 204 pending.set(body.id, { resolve, reject, timer }); 205 extensionWs!.send(JSON.stringify(body)); 206 }); 207 208 jsonResponse(res, 200, result); 209 } catch (err) { 210 jsonResponse(res, err instanceof Error && err.message.includes('timeout') ? 408 : 400, { 211 ok: false, 212 error: err instanceof Error ? err.message : 'Invalid request', 213 }); 214 } 215 return; 216 } 217 218 jsonResponse(res, 404, { error: 'Not found' }); 219 } 220 221 // ─── WebSocket for Extension ───────────────────────────────────────── 222 223 const httpServer = createServer((req, res) => { handleRequest(req, res).catch(() => { res.writeHead(500); res.end(); }); }); 224 const wss = new WebSocketServer({ 225 server: httpServer, 226 path: '/ext', 227 verifyClient: ({ req }: { req: IncomingMessage }) => { 228 // Block browser-originated WebSocket connections. Browsers don't 229 // enforce CORS on WebSocket, so a malicious webpage could connect to 230 // ws://localhost:19825/ext and impersonate the Extension. Real Chrome 231 // Extensions send origin chrome-extension://<id>. 232 const origin = req.headers['origin'] as string | undefined; 233 return !origin || origin.startsWith('chrome-extension://'); 234 }, 235 }); 236 237 wss.on('connection', (ws: WebSocket) => { 238 log.info('[daemon] Extension connected'); 239 extensionWs = ws; 240 extensionVersion = null; // cleared until hello message arrives 241 extensionCompatRange = null; 242 243 // ── Heartbeat: ping every 15s, close if 2 pongs missed ── 244 let missedPongs = 0; 245 const heartbeatInterval = setInterval(() => { 246 if (ws.readyState !== WebSocket.OPEN) { 247 clearInterval(heartbeatInterval); 248 return; 249 } 250 if (missedPongs >= 2) { 251 log.warn('[daemon] Extension heartbeat lost, closing connection'); 252 clearInterval(heartbeatInterval); 253 ws.terminate(); 254 return; 255 } 256 missedPongs++; 257 ws.ping(); 258 }, 15000); 259 260 ws.on('pong', () => { 261 missedPongs = 0; 262 }); 263 264 ws.on('message', (data: RawData) => { 265 try { 266 const msg = JSON.parse(data.toString()); 267 268 // Handle hello message from extension (version handshake) 269 if (msg.type === 'hello') { 270 extensionVersion = typeof msg.version === 'string' ? msg.version : null; 271 extensionCompatRange = typeof msg.compatRange === 'string' ? msg.compatRange : null; 272 return; 273 } 274 275 // Handle log messages from extension 276 if (msg.type === 'log') { 277 if (msg.level === 'error') log.error(`[ext] ${msg.msg}`); 278 else if (msg.level === 'warn') log.warn(`[ext] ${msg.msg}`); 279 else log.info(`[ext] ${msg.msg}`); 280 pushLog({ level: msg.level, msg: msg.msg, ts: msg.ts ?? Date.now() }); 281 return; 282 } 283 284 // Handle command results 285 const p = pending.get(msg.id); 286 if (p) { 287 clearTimeout(p.timer); 288 pending.delete(msg.id); 289 p.resolve(msg); 290 } 291 } catch { 292 // Ignore malformed messages 293 } 294 }); 295 296 ws.on('close', () => { 297 log.info('[daemon] Extension disconnected'); 298 clearInterval(heartbeatInterval); 299 if (extensionWs === ws) { 300 extensionWs = null; 301 extensionVersion = null; 302 extensionCompatRange = null; 303 // Reject all pending requests since the extension is gone 304 for (const [id, p] of pending) { 305 clearTimeout(p.timer); 306 p.reject(new Error('Extension disconnected')); 307 } 308 pending.clear(); 309 } 310 }); 311 312 ws.on('error', () => { 313 clearInterval(heartbeatInterval); 314 if (extensionWs === ws) { 315 extensionWs = null; 316 extensionVersion = null; 317 extensionCompatRange = null; 318 // Reject pending requests in case 'close' does not follow this 'error' 319 for (const [, p] of pending) { 320 clearTimeout(p.timer); 321 p.reject(new Error('Extension disconnected')); 322 } 323 pending.clear(); 324 } 325 }); 326 }); 327 328 // ─── Start ─────────────────────────────────────────────────────────── 329 330 httpServer.listen(PORT, '127.0.0.1', () => { 331 log.info(`[daemon] Listening on http://127.0.0.1:${PORT}`); 332 }); 333 334 httpServer.on('error', (err: NodeJS.ErrnoException) => { 335 if (err.code === 'EADDRINUSE') { 336 log.error(`[daemon] Port ${PORT} already in use — another daemon is likely running. Exiting.`); 337 process.exit(EXIT_CODES.SERVICE_UNAVAIL); 338 } 339 log.error(`[daemon] Server error: ${err.message}`); 340 process.exit(EXIT_CODES.GENERIC_ERROR); 341 }); 342 343 // Graceful shutdown 344 function shutdown(): void { 345 // Reject all pending requests so CLI doesn't hang 346 for (const [, p] of pending) { 347 clearTimeout(p.timer); 348 p.reject(new Error('Daemon shutting down')); 349 } 350 pending.clear(); 351 if (extensionWs) extensionWs.close(); 352 httpServer.close(); 353 process.exit(EXIT_CODES.SUCCESS); 354 } 355 356 process.on('SIGTERM', shutdown); 357 process.on('SIGINT', shutdown);