main
ts 976 lines 29.1 KB
Raw
1 import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2 import {
3 createServer,
4 request as httpRequest,
5 type IncomingMessage,
6 type RequestOptions as HTTPRequestOptions,
7 type ServerResponse,
8 } from "node:http";
9 import { request as httpsRequest, type RequestOptions as HTTPSRequestOptions } from "node:https";
10 import { dirname } from "node:path";
11 import { setTimeout as delay } from "node:timers/promises";
12 import { URL } from "node:url";
13 import { PRESENTATION_API_PATHS, RELAY_API_PATHS } from "../src/lib/apiPaths.js";
14 import { parseLeaseMetadata } from "../src/lib/metadata.js";
15
16 const PORT = parseIntegerEnv("PORT", 8081);
17 const PORTAL_API_BASE_URL = normalizeBaseURL(
18 process.env.PORTAL_API_BASE_URL || "https://portal:4017"
19 );
20 const HEADLESS_SHELL_URL = (process.env.HEADLESS_SHELL_URL || "").trim();
21 const FRONTEND_STATE_PATH = (process.env.PORTAL_FRONTEND_STATE_PATH || "").trim();
22 const DEFAULT_LANDING_PAGE_ENABLED = parseBooleanEnv("LANDING_PAGE_ENABLED", false);
23
24 const VIEWPORT_WIDTH = 1280;
25 const VIEWPORT_HEIGHT = 720;
26 const JPEG_QUALITY = 80;
27 const MAX_BYTES = 256 << 10;
28 const BODY_LIMIT = 1 << 16;
29 const COOLDOWN_MS = 30_000;
30 const PAGE_TIMEOUT_MS = 15_000;
31 const CDP_TIMEOUT_MS = 5_000;
32 const HTTP_TIMEOUT_MS = 5_000;
33 const JSON_LIMIT = 1 << 20;
34 const THUMBNAIL_CONTENT_TYPE = "image/jpeg";
35 const CORS_ALLOW_HEADERS = "Accept, Authorization, Content-Type, X-Portal-Access-Token";
36 const CORS_ALLOW_METHODS = "GET, HEAD, POST, DELETE, OPTIONS";
37
38 type APIEnvelope<T> =
39 | { ok: true; data: T }
40 | { ok: false; error?: { code?: string; message?: string }; data?: unknown };
41
42 interface RelayPublicStateResponse {
43 leases?: Lease[];
44 }
45
46 interface FrontendPublicStateResponse extends RelayPublicStateResponse {
47 landing_page_enabled: boolean;
48 }
49
50 interface Lease {
51 hostname?: string;
52 metadata?: unknown;
53 ready?: number;
54 }
55
56 interface PolicyPortSettings {
57 enabled: boolean;
58 max_leases: number;
59 }
60
61 interface RelayPolicySettings {
62 approval_mode?: string;
63 udp?: PolicyPortSettings;
64 tcp_port?: PolicyPortSettings;
65 }
66
67 interface FrontendPolicySettings extends RelayPolicySettings {
68 landing_page_enabled: boolean;
69 udp: PolicyPortSettings;
70 tcp_port: PolicyPortSettings;
71 }
72
73 interface RelayPolicyStateResponse {
74 policy?: RelayPolicySettings;
75 leases?: unknown[];
76 }
77
78 interface FrontendPolicyStateResponse {
79 policy: FrontendPolicySettings;
80 leases?: unknown[];
81 }
82
83 interface ServiceStatusResponse {
84 hostname: string;
85 registered: boolean;
86 service_alive: boolean;
87 }
88
89 interface FrontendState {
90 landing_page_enabled: boolean;
91 }
92
93 interface ThumbnailEntry {
94 data?: Buffer;
95 fetchedAt: number;
96 }
97
98 interface CDPReply {
99 id?: number;
100 sessionId?: string;
101 method?: string;
102 params?: unknown;
103 result?: Record<string, unknown>;
104 error?: { message?: string };
105 }
106
107 interface CDPWaiter {
108 method: string;
109 sessionId?: string;
110 resolve: (message: CDPReply) => void;
111 reject: (error: Error) => void;
112 }
113
114 interface CDPPendingReply {
115 resolve: (result: Record<string, unknown>) => void;
116 reject: (error: Error) => void;
117 }
118
119 const thumbnailCache = new Map<string, ThumbnailEntry>();
120 const pendingThumbnails = new Map<string, Promise<Buffer>>();
121 let captureChain: Promise<unknown> = Promise.resolve();
122 let frontendState = loadFrontendState();
123
124 function parseIntegerEnv(name: string, fallback: number): number {
125 const parsed = Number.parseInt(process.env[name] || "", 10);
126 return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
127 }
128
129 function parseBooleanEnv(name: string, fallback: boolean): boolean {
130 const raw = (process.env[name] || "").trim().toLowerCase();
131 if (["1", "true", "yes", "on"].includes(raw)) {
132 return true;
133 }
134 if (["0", "false", "no", "off"].includes(raw)) {
135 return false;
136 }
137 return fallback;
138 }
139
140 function normalizeBaseURL(raw: string): string {
141 const trimmed = raw.trim();
142 return trimmed.endsWith("/") ? trimmed.slice(0, -1) : trimmed;
143 }
144
145 function normalizeHostname(raw: string): string {
146 return raw.trim().toLowerCase().replace(/\.$/, "");
147 }
148
149 function isRecord(value: unknown): value is Record<string, unknown> {
150 return typeof value === "object" && value !== null && !Array.isArray(value);
151 }
152
153 function loadFrontendState(): FrontendState {
154 if (!FRONTEND_STATE_PATH) {
155 return { landing_page_enabled: DEFAULT_LANDING_PAGE_ENABLED };
156 }
157 try {
158 const parsed = JSON.parse(readFileSync(FRONTEND_STATE_PATH, "utf8")) as unknown;
159 if (isRecord(parsed) && typeof parsed.landing_page_enabled === "boolean") {
160 return { landing_page_enabled: parsed.landing_page_enabled };
161 }
162 } catch (error) {
163 if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
164 console.warn("failed to read frontend state", error);
165 }
166 }
167 return { landing_page_enabled: DEFAULT_LANDING_PAGE_ENABLED };
168 }
169
170 function saveFrontendState(): void {
171 if (!FRONTEND_STATE_PATH) {
172 return;
173 }
174 try {
175 mkdirSync(dirname(FRONTEND_STATE_PATH), { recursive: true });
176 writeFileSync(FRONTEND_STATE_PATH, `${JSON.stringify(frontendState, null, 2)}\n`, {
177 mode: 0o600,
178 });
179 } catch (error) {
180 console.warn("failed to write frontend state", error);
181 }
182 }
183
184 function hostnameMatchesPattern(pattern: string, hostname: string): boolean {
185 const normalizedPattern = normalizeHostname(pattern);
186 const normalizedHostname = normalizeHostname(hostname);
187 if (!normalizedPattern || !normalizedHostname) {
188 return false;
189 }
190 if (normalizedPattern === normalizedHostname) {
191 return true;
192 }
193 if (!normalizedPattern.startsWith("*.")) {
194 return false;
195 }
196 const suffix = normalizedPattern.slice(2);
197 if (!suffix.includes(".")) {
198 return false;
199 }
200 const dotIndex = normalizedHostname.indexOf(".");
201 return dotIndex > 0 && normalizedHostname.slice(dotIndex + 1) === suffix;
202 }
203
204 function requestJSON<T>(
205 rawURL: string,
206 options: { hostHeader?: string } = {}
207 ): Promise<T> {
208 return new Promise((resolve, reject) => {
209 const parsed = new URL(rawURL);
210 const requestOptions: HTTPRequestOptions = {
211 method: "GET",
212 hostname: parsed.hostname,
213 port: parsed.port,
214 path: `${parsed.pathname}${parsed.search}`,
215 headers: options.hostHeader ? { Host: options.hostHeader } : undefined,
216 timeout: HTTP_TIMEOUT_MS,
217 };
218
219 const handleResponse = (res: IncomingMessage) => {
220 collectResponseBody(res, JSON_LIMIT)
221 .then((body) => {
222 if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
223 reject(new Error(`${rawURL} status ${res.statusCode}: ${body}`));
224 return;
225 }
226 resolve(JSON.parse(body.toString("utf8")) as T);
227 })
228 .catch(reject);
229 };
230
231 const req =
232 parsed.protocol === "https:"
233 ? httpsRequest(requestOptions as HTTPSRequestOptions, handleResponse)
234 : httpRequest(requestOptions, handleResponse);
235
236 req.on("timeout", () => req.destroy(new Error(`${rawURL} timed out`)));
237 req.on("error", reject);
238 req.end();
239 });
240 }
241
242 function collectResponseBody(res: IncomingMessage, limit: number): Promise<Buffer> {
243 return new Promise((resolve, reject) => {
244 const chunks: Buffer[] = [];
245 let size = 0;
246 res.on("data", (chunk: Buffer) => {
247 size += chunk.length;
248 if (size > limit) {
249 res.destroy(new Error("response too large"));
250 return;
251 }
252 chunks.push(chunk);
253 });
254 res.on("error", reject);
255 res.on("end", () => resolve(Buffer.concat(chunks)));
256 });
257 }
258
259 function readRequestBody(req: IncomingMessage, limit: number): Promise<Buffer> {
260 return new Promise((resolve, reject) => {
261 const chunks: Buffer[] = [];
262 let size = 0;
263 req.on("data", (chunk: Buffer) => {
264 size += chunk.length;
265 if (size > limit) {
266 req.destroy(new Error("request body too large"));
267 return;
268 }
269 chunks.push(chunk);
270 });
271 req.on("error", reject);
272 req.on("end", () => resolve(Buffer.concat(chunks)));
273 });
274 }
275
276 function readJSONRequest(req: IncomingMessage): Promise<Record<string, unknown>> {
277 return readRequestBody(req, BODY_LIMIT).then((body) => {
278 const parsed = JSON.parse(body.toString("utf8")) as unknown;
279 return isRecord(parsed) ? parsed : {};
280 });
281 }
282
283 function relayURL(path: string): string {
284 return `${PORTAL_API_BASE_URL}${path}`;
285 }
286
287 function requestRelay<T>(
288 path: string,
289 options: { method?: string; body?: unknown; authorization?: string } = {}
290 ): Promise<{ statusCode: number; envelope: APIEnvelope<T> }> {
291 return new Promise((resolve, reject) => {
292 const parsed = new URL(relayURL(path));
293 const body =
294 options.body === undefined ? undefined : Buffer.from(JSON.stringify(options.body));
295 const headers: Record<string, string> = {};
296 if (options.authorization) {
297 headers.Authorization = options.authorization;
298 }
299 if (body) {
300 headers["Content-Type"] = "application/json";
301 headers["Content-Length"] = String(body.length);
302 }
303
304 const requestOptions: HTTPRequestOptions = {
305 method: options.method || "GET",
306 hostname: parsed.hostname,
307 port: parsed.port,
308 path: `${parsed.pathname}${parsed.search}`,
309 headers,
310 timeout: HTTP_TIMEOUT_MS,
311 };
312
313 const handleResponse = (relayRes: IncomingMessage) => {
314 collectResponseBody(relayRes, JSON_LIMIT)
315 .then((responseBody) => {
316 try {
317 resolve({
318 statusCode: relayRes.statusCode || 500,
319 envelope: JSON.parse(responseBody.toString("utf8")) as APIEnvelope<T>,
320 });
321 } catch (error) {
322 reject(error);
323 }
324 })
325 .catch(reject);
326 };
327
328 const req =
329 parsed.protocol === "https:"
330 ? httpsRequest(
331 {
332 ...requestOptions,
333 rejectUnauthorized: false,
334 } satisfies HTTPSRequestOptions,
335 handleResponse
336 )
337 : httpRequest(requestOptions, handleResponse);
338
339 req.on("timeout", () => req.destroy(new Error(`${path} timed out`)));
340 req.on("error", reject);
341 if (body) {
342 req.write(body);
343 }
344 req.end();
345 });
346 }
347
348 function mergePublicState(state: RelayPublicStateResponse): FrontendPublicStateResponse {
349 return {
350 ...state,
351 landing_page_enabled: frontendState.landing_page_enabled,
352 };
353 }
354
355 function mergePolicySettings(settings: RelayPolicySettings = {}): FrontendPolicySettings {
356 return {
357 approval_mode: settings.approval_mode || "auto",
358 udp: settings.udp || { enabled: false, max_leases: 0 },
359 tcp_port: settings.tcp_port || { enabled: false, max_leases: 0 },
360 landing_page_enabled: frontendState.landing_page_enabled,
361 };
362 }
363
364 function writeEnvelope<T>(res: ServerResponse, status: number, envelope: APIEnvelope<T>): void {
365 const body = Buffer.from(JSON.stringify(envelope));
366 res.writeHead(status, {
367 "Content-Type": "application/json",
368 "Content-Length": String(body.length),
369 });
370 res.end(body);
371 }
372
373 function writeData<T>(res: ServerResponse, status: number, data: T): void {
374 writeEnvelope(res, status, { ok: true, data });
375 }
376
377 function writeError(res: ServerResponse, status: number, code: string, message: string): void {
378 writeEnvelope(res, status, { ok: false, error: { code, message } });
379 }
380
381 function writeRelayEnvelope<T>(
382 res: ServerResponse,
383 relayResponse: { statusCode: number; envelope: APIEnvelope<T> }
384 ): void {
385 writeEnvelope(res, relayResponse.statusCode, relayResponse.envelope);
386 }
387
388 function authorizationHeader(req: IncomingMessage): string {
389 const authorization = req.headers.authorization;
390 return typeof authorization === "string" ? authorization : "";
391 }
392
393 async function servePublicState(res: ServerResponse): Promise<void> {
394 const relayResponse = await requestRelay<RelayPublicStateResponse>(RELAY_API_PATHS.public.state);
395 if (!relayResponse.envelope.ok) {
396 writeRelayEnvelope(res, relayResponse);
397 return;
398 }
399 writeData(res, relayResponse.statusCode, mergePublicState(relayResponse.envelope.data));
400 }
401
402 async function publicLeases(): Promise<Lease[]> {
403 const relayResponse = await requestRelay<RelayPublicStateResponse>(RELAY_API_PATHS.public.state);
404 if (!relayResponse.envelope.ok) {
405 return [];
406 }
407 return Array.isArray(relayResponse.envelope.data.leases)
408 ? relayResponse.envelope.data.leases
409 : [];
410 }
411
412 async function publicLeaseByHostname(hostname: string): Promise<Lease | undefined> {
413 const normalizedHostname = normalizeHostname(hostname);
414 if (!normalizedHostname) {
415 return undefined;
416 }
417 const leases = await publicLeases();
418 return leases.find((lease) => {
419 const leaseHostname = typeof lease.hostname === "string" ? lease.hostname : "";
420 return hostnameMatchesPattern(leaseHostname, normalizedHostname);
421 });
422 }
423
424 async function serveServiceStatus(req: IncomingMessage, res: ServerResponse): Promise<void> {
425 const url = new URL(req.url || "/", "http://api.local");
426 const hostname = normalizeHostname(url.searchParams.get("hostname") || "");
427 if (!hostname) {
428 writeError(res, 400, "invalid_request", "hostname is required");
429 return;
430 }
431
432 const lease = await publicLeaseByHostname(hostname);
433 writeData<ServiceStatusResponse>(res, 200, {
434 hostname: typeof lease?.hostname === "string" ? lease.hostname : hostname,
435 registered: Boolean(lease),
436 service_alive: Boolean(lease && typeof lease.ready === "number" && lease.ready > 0),
437 });
438 }
439
440 async function servePolicyState(req: IncomingMessage, res: ServerResponse): Promise<void> {
441 const relayResponse = await requestRelay<RelayPolicyStateResponse>(RELAY_API_PATHS.policy.state, {
442 authorization: authorizationHeader(req),
443 });
444 if (!relayResponse.envelope.ok) {
445 writeRelayEnvelope(res, relayResponse);
446 return;
447 }
448 writeData<FrontendPolicyStateResponse>(res, relayResponse.statusCode, {
449 leases: relayResponse.envelope.data.leases,
450 policy: mergePolicySettings(relayResponse.envelope.data.policy),
451 });
452 }
453
454 async function servePolicy(req: IncomingMessage, res: ServerResponse): Promise<void> {
455 if (req.method === "GET") {
456 const relayResponse = await requestRelay<RelayPolicySettings>(RELAY_API_PATHS.policy.root, {
457 authorization: authorizationHeader(req),
458 });
459 if (!relayResponse.envelope.ok) {
460 writeRelayEnvelope(res, relayResponse);
461 return;
462 }
463 writeData(res, relayResponse.statusCode, mergePolicySettings(relayResponse.envelope.data));
464 return;
465 }
466
467 let body: Record<string, unknown>;
468 try {
469 body = await readJSONRequest(req);
470 } catch {
471 writeError(res, 400, "invalid_json", "invalid request body");
472 return;
473 }
474
475 const nextLandingPageEnabled =
476 typeof body.landing_page_enabled === "boolean"
477 ? body.landing_page_enabled
478 : frontendState.landing_page_enabled;
479 let relayBody = { ...body };
480 delete relayBody.landing_page_enabled;
481 if (!("approval_mode" in relayBody) || !("udp" in relayBody) || !("tcp_port" in relayBody)) {
482 const current = await requestRelay<RelayPolicyStateResponse>(RELAY_API_PATHS.policy.state, {
483 authorization: authorizationHeader(req),
484 });
485 if (!current.envelope.ok) {
486 writeRelayEnvelope(res, current);
487 return;
488 }
489 const currentSettings = mergePolicySettings(current.envelope.data.policy);
490 relayBody = {
491 approval_mode: relayBody.approval_mode ?? currentSettings.approval_mode,
492 udp: relayBody.udp ?? currentSettings.udp,
493 tcp_port: relayBody.tcp_port ?? currentSettings.tcp_port,
494 };
495 }
496 const relayResponse = await requestRelay<RelayPolicySettings>(RELAY_API_PATHS.policy.root, {
497 method: "POST",
498 body: relayBody,
499 authorization: authorizationHeader(req),
500 });
501 if (!relayResponse.envelope.ok) {
502 writeRelayEnvelope(res, relayResponse);
503 return;
504 }
505
506 frontendState = { landing_page_enabled: nextLandingPageEnabled };
507 saveFrontendState();
508 writeData(res, relayResponse.statusCode, mergePolicySettings(relayResponse.envelope.data));
509 }
510
511 async function forwardPolicyUpdate(
512 req: IncomingMessage,
513 res: ServerResponse,
514 path: string
515 ): Promise<void> {
516 let body: Record<string, unknown>;
517 try {
518 body = await readJSONRequest(req);
519 } catch {
520 writeError(res, 400, "invalid_json", "invalid request body");
521 return;
522 }
523
524 writeRelayEnvelope(
525 res,
526 await requestRelay<unknown>(path, {
527 method: "POST",
528 body,
529 authorization: authorizationHeader(req),
530 })
531 );
532 }
533
534 async function leaseAllowsThumbnail(hostname: string): Promise<boolean> {
535 const lease = await publicLeaseByHostname(hostname);
536 return Boolean(lease && parseLeaseMetadata(lease.metadata).thumbnail.trim() === "");
537 }
538
539 async function resolveCDPWebSocketURL(): Promise<string> {
540 if (!HEADLESS_SHELL_URL) {
541 throw new Error("HEADLESS_SHELL_URL is not configured");
542 }
543
544 const parsed = new URL(HEADLESS_SHELL_URL);
545 const versionURL = `http://${parsed.host}/json/version`;
546 const info = await requestJSON<{ webSocketDebuggerUrl?: string }>(versionURL, {
547 hostHeader: "127.0.0.1",
548 });
549 if (!info.webSocketDebuggerUrl) {
550 throw new Error("/json/version did not return webSocketDebuggerUrl");
551 }
552
553 const wsURL = new URL(info.webSocketDebuggerUrl);
554 wsURL.host = parsed.host;
555 return wsURL.toString();
556 }
557
558 class CDPClient {
559 private nextID = 1;
560 private readonly pendingReplies = new Map<number, CDPPendingReply>();
561 private readonly waiters: CDPWaiter[] = [];
562 private readonly socket: WebSocket;
563
564 constructor(url: string) {
565 this.socket = new WebSocket(url);
566 this.socket.addEventListener("message", (event) => this.handleMessage(event));
567 this.socket.addEventListener("close", () => this.handleClose());
568 this.socket.addEventListener("error", () => this.handleClose(new Error("WebSocket error")));
569 }
570
571 connect(): Promise<void> {
572 if (this.socket.readyState === WebSocket.OPEN) {
573 return Promise.resolve();
574 }
575 if (this.socket.readyState === WebSocket.CLOSING || this.socket.readyState === WebSocket.CLOSED) {
576 return Promise.reject(new Error("WebSocket connection closed"));
577 }
578 return new Promise((resolve, reject) => {
579 const timeout = setTimeout(() => {
580 reject(new Error("connect to headless shell timed out"));
581 }, CDP_TIMEOUT_MS);
582
583 this.socket.addEventListener(
584 "open",
585 () => {
586 clearTimeout(timeout);
587 resolve();
588 },
589 { once: true }
590 );
591 this.socket.addEventListener(
592 "error",
593 () => {
594 clearTimeout(timeout);
595 reject(new Error("connect to headless shell failed"));
596 },
597 { once: true }
598 );
599 this.socket.addEventListener(
600 "close",
601 () => {
602 clearTimeout(timeout);
603 reject(new Error("WebSocket connection closed"));
604 },
605 { once: true }
606 );
607 });
608 }
609
610 close(): void {
611 this.socket.close();
612 }
613
614 send(
615 method: string,
616 params: Record<string, unknown> = {},
617 sessionId = "",
618 timeoutMs = CDP_TIMEOUT_MS
619 ): Promise<Record<string, unknown>> {
620 const id = this.nextID++;
621 const payload: Record<string, unknown> = { id, method, params };
622 if (sessionId) {
623 payload.sessionId = sessionId;
624 }
625
626 return new Promise((resolve, reject) => {
627 if (this.socket.readyState !== WebSocket.OPEN) {
628 reject(new Error("WebSocket connection is not open"));
629 return;
630 }
631 const timeout = setTimeout(() => {
632 this.pendingReplies.delete(id);
633 reject(new Error(`${method} timed out`));
634 }, timeoutMs);
635 const pendingReply: CDPPendingReply = {
636 resolve: (result) => {
637 clearTimeout(timeout);
638 resolve(result);
639 },
640 reject: (error) => {
641 clearTimeout(timeout);
642 reject(error);
643 },
644 };
645 this.pendingReplies.set(id, pendingReply);
646 try {
647 this.socket.send(JSON.stringify(payload));
648 } catch (error) {
649 this.pendingReplies.delete(id);
650 pendingReply.reject(error instanceof Error ? error : new Error("CDP send failed"));
651 }
652 });
653 }
654
655 waitForEvent(method: string, sessionId: string, timeoutMs: number): Promise<CDPReply> {
656 return new Promise((resolve, reject) => {
657 if (this.socket.readyState !== WebSocket.OPEN) {
658 reject(new Error("WebSocket connection is not open"));
659 return;
660 }
661 const waiter: CDPWaiter = { method, sessionId, resolve, reject };
662 this.waiters.push(waiter);
663 const timeout = setTimeout(() => {
664 const index = this.waiters.indexOf(waiter);
665 if (index >= 0) {
666 this.waiters.splice(index, 1);
667 }
668 reject(new Error(`${method} timed out`));
669 }, timeoutMs);
670
671 waiter.resolve = (message) => {
672 clearTimeout(timeout);
673 resolve(message);
674 };
675 waiter.reject = (error) => {
676 clearTimeout(timeout);
677 reject(error);
678 };
679 });
680 }
681
682 private handleClose(error = new Error("WebSocket connection closed")): void {
683 for (const pendingReply of this.pendingReplies.values()) {
684 pendingReply.reject(error);
685 }
686 this.pendingReplies.clear();
687
688 const waiters = this.waiters.splice(0);
689 for (const waiter of waiters) {
690 waiter.reject(error);
691 }
692 }
693
694 private handleMessage(event: MessageEvent): void {
695 const message = JSON.parse(String(event.data)) as CDPReply;
696 if (typeof message.id === "number") {
697 const pendingReply = this.pendingReplies.get(message.id);
698 if (!pendingReply) {
699 return;
700 }
701 this.pendingReplies.delete(message.id);
702 if (message.error) {
703 pendingReply.reject(new Error(message.error.message || "CDP command failed"));
704 return;
705 }
706 pendingReply.resolve(message.result || {});
707 return;
708 }
709
710 const waiter = this.waiters.find(
711 (candidate) =>
712 candidate.method === message.method &&
713 (!candidate.sessionId || candidate.sessionId === message.sessionId)
714 );
715 if (!waiter) {
716 return;
717 }
718 this.waiters.splice(this.waiters.indexOf(waiter), 1);
719 waiter.resolve(message);
720 }
721 }
722
723 function stringResultField(result: Record<string, unknown>, field: string): string {
724 const value = result[field];
725 return typeof value === "string" ? value : "";
726 }
727
728 async function captureScreenshot(hostname: string): Promise<Buffer> {
729 const cdpURL = await resolveCDPWebSocketURL();
730 const client = new CDPClient(cdpURL);
731
732 let browserContextID = "";
733 let targetID = "";
734
735 try {
736 await client.connect();
737 await client.send("Security.setIgnoreCertificateErrors", { ignore: true }).catch(() => ({}));
738
739 const context = await client.send("Target.createBrowserContext", {
740 disposeOnDetach: true,
741 });
742 browserContextID = stringResultField(context, "browserContextId");
743
744 const target = await client.send("Target.createTarget", {
745 url: "about:blank",
746 browserContextId: browserContextID,
747 });
748 targetID = stringResultField(target, "targetId");
749
750 const attached = await client.send("Target.attachToTarget", {
751 targetId: targetID,
752 flatten: true,
753 });
754 const sessionID = stringResultField(attached, "sessionId");
755 if (!sessionID) {
756 throw new Error("CDP target attach did not return sessionId");
757 }
758
759 await client.send("Page.enable", {}, sessionID);
760 await client.send(
761 "Emulation.setDeviceMetricsOverride",
762 {
763 width: VIEWPORT_WIDTH,
764 height: VIEWPORT_HEIGHT,
765 deviceScaleFactor: 1,
766 mobile: false,
767 },
768 sessionID
769 );
770
771 const loadEvent = client.waitForEvent("Page.loadEventFired", sessionID, PAGE_TIMEOUT_MS);
772 await client.send("Page.navigate", { url: `https://${hostname}` }, sessionID);
773 await loadEvent;
774 await delay(1_000);
775
776 const screenshot = await client.send(
777 "Page.captureScreenshot",
778 { format: "jpeg", quality: JPEG_QUALITY, fromSurface: true },
779 sessionID
780 );
781 const data = stringResultField(screenshot, "data");
782 if (!data) {
783 throw new Error("CDP screenshot returned empty data");
784 }
785 return Buffer.from(data, "base64");
786 } finally {
787 if (targetID) {
788 await client.send("Target.closeTarget", { targetId: targetID }).catch(() => ({}));
789 }
790 if (browserContextID) {
791 await client
792 .send("Target.disposeBrowserContext", { browserContextId: browserContextID })
793 .catch(() => ({}));
794 }
795 client.close();
796 }
797 }
798
799 async function captureAndStore(hostname: string): Promise<Buffer> {
800 try {
801 const data = await captureScreenshot(hostname);
802 if (data.length > MAX_BYTES) {
803 throw new Error(`thumbnail too large: ${data.length} bytes`);
804 }
805 thumbnailCache.set(hostname, { data, fetchedAt: Date.now() });
806 console.log(`thumbnail captured hostname=${hostname} size=${data.length}`);
807 return data;
808 } catch (error) {
809 thumbnailCache.set(hostname, { fetchedAt: Date.now() });
810 console.warn(`thumbnail capture failed hostname=${hostname}`, error);
811 throw error;
812 }
813 }
814
815 function loadThumbnail(hostname: string): Promise<Buffer> {
816 const entry = thumbnailCache.get(hostname);
817 if (entry) {
818 if (entry.data && entry.data.length > 0) {
819 return Promise.resolve(entry.data);
820 }
821 if (Date.now() - entry.fetchedAt < COOLDOWN_MS) {
822 return Promise.reject(new Error("thumbnail capture is cooling down"));
823 }
824 }
825
826 const existing = pendingThumbnails.get(hostname);
827 if (existing) {
828 return existing;
829 }
830
831 const capture = captureChain.then(() => captureAndStore(hostname));
832 captureChain = capture.catch(() => undefined);
833 pendingThumbnails.set(hostname, capture);
834 capture.then(
835 () => pendingThumbnails.delete(hostname),
836 () => pendingThumbnails.delete(hostname)
837 );
838 return capture;
839 }
840
841 function requestedThumbnailHostname(req: IncomingMessage): string {
842 const url = new URL(req.url || "/", "http://api.local");
843 if (!url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
844 return "";
845 }
846 try {
847 const hostname = normalizeHostname(
848 decodeURIComponent(url.pathname.slice(PRESENTATION_API_PATHS.thumbnail.prefix.length))
849 );
850 return hostname.includes("*") ? "" : hostname;
851 } catch {
852 return "";
853 }
854 }
855
856 function writeNotFound(res: ServerResponse): void {
857 res.writeHead(404, { "Cache-Control": "no-store" });
858 res.end();
859 }
860
861 function writeMethodNotAllowed(res: ServerResponse, allow = "GET"): void {
862 res.setHeader("Allow", allow);
863 writeError(res, 405, "method_not_allowed", "method not allowed");
864 }
865
866 async function serveThumbnail(req: IncomingMessage, res: ServerResponse): Promise<void> {
867 if (req.method !== "GET") {
868 writeMethodNotAllowed(res);
869 return;
870 }
871 const hostname = requestedThumbnailHostname(req);
872 if (!hostname || !HEADLESS_SHELL_URL) {
873 writeNotFound(res);
874 return;
875 }
876 if (!(await leaseAllowsThumbnail(hostname))) {
877 writeNotFound(res);
878 return;
879 }
880
881 const data = await loadThumbnail(hostname);
882 res.writeHead(200, {
883 "Content-Type": THUMBNAIL_CONTENT_TYPE,
884 "Cache-Control": "public, max-age=300",
885 "Content-Length": String(data.length),
886 });
887 res.end(data);
888 }
889
890 const server = createServer((req, res) => {
891 void (async () => {
892 res.setHeader("Access-Control-Allow-Origin", "*");
893 res.setHeader("Access-Control-Allow-Methods", CORS_ALLOW_METHODS);
894 res.setHeader("Access-Control-Allow-Headers", CORS_ALLOW_HEADERS);
895 res.setHeader("Access-Control-Max-Age", "600");
896 if (req.method === "OPTIONS") {
897 res.writeHead(204);
898 res.end();
899 return;
900 }
901
902 const url = new URL(req.url || "/", "http://api.local");
903 if (url.pathname === "/healthz") {
904 writeData(res, 200, { status: "ok" });
905 return;
906 }
907 if (url.pathname === PRESENTATION_API_PATHS.public.state) {
908 if (req.method !== "GET") {
909 writeMethodNotAllowed(res);
910 return;
911 }
912 await servePublicState(res);
913 return;
914 }
915 if (url.pathname === PRESENTATION_API_PATHS.service.status) {
916 if (req.method !== "GET") {
917 writeMethodNotAllowed(res);
918 return;
919 }
920 await serveServiceStatus(req, res);
921 return;
922 }
923 if (url.pathname === PRESENTATION_API_PATHS.policy.state) {
924 if (req.method !== "GET") {
925 writeMethodNotAllowed(res);
926 return;
927 }
928 await servePolicyState(req, res);
929 return;
930 }
931 if (url.pathname === PRESENTATION_API_PATHS.policy.root) {
932 if (req.method !== "GET" && req.method !== "POST") {
933 writeMethodNotAllowed(res, "GET, POST");
934 return;
935 }
936 await servePolicy(req, res);
937 return;
938 }
939 if (
940 url.pathname === PRESENTATION_API_PATHS.policy.leases ||
941 url.pathname === PRESENTATION_API_PATHS.policy.ips
942 ) {
943 if (req.method !== "POST") {
944 writeMethodNotAllowed(res, "POST");
945 return;
946 }
947 await forwardPolicyUpdate(
948 req,
949 res,
950 url.pathname === PRESENTATION_API_PATHS.policy.leases
951 ? RELAY_API_PATHS.policy.leases
952 : RELAY_API_PATHS.policy.ips
953 );
954 return;
955 }
956 if (url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
957 await serveThumbnail(req, res);
958 return;
959 }
960 writeNotFound(res);
961 })().catch((error) => {
962 console.warn("portal api request failed", error);
963 const url = new URL(req.url || "/", "http://api.local");
964 if (url.pathname.startsWith(PRESENTATION_API_PATHS.thumbnail.prefix)) {
965 writeNotFound(res);
966 return;
967 }
968 writeError(res, 502, "upstream_error", "upstream request failed");
969 });
970 });
971
972 server.listen(PORT, () => {
973 console.log(
974 `portal api listening on :${PORT} headless=${HEADLESS_SHELL_URL ? "enabled" : "disabled"} landing_page=${frontendState.landing_page_enabled}`
975 );
976 });