496 lines
17 KiB
TypeScript
496 lines
17 KiB
TypeScript
/**
|
|
* 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 } 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<RemoteServer> {
|
|
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<WsClient>();
|
|
const token = await loadOrCreateToken();
|
|
|
|
// 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<string, number>();
|
|
|
|
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 <multi-token>
|
|
*/
|
|
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;
|
|
// async validateBearer checked in asyncHandler for API routes;
|
|
// for WS upgrade we do a sync fallback: legacy token only here.
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/** Async auth check — also validates multi-token bearer from auth/tokens store */
|
|
async function isAuthenticatedAsync(req: IncomingMessage): Promise<boolean> {
|
|
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(
|
|
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 180 180"><rect width="180" height="180" rx="40" fill="#0d1117"/><text x="90" y="133" font-family="-apple-system,Helvetica,Arial,sans-serif" font-size="110" text-anchor="middle" fill="#3fb950">π</text></svg>`,
|
|
);
|
|
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<boolean> => {
|
|
// 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<string, unknown> = {};
|
|
try {
|
|
const raw = await readBody(req);
|
|
if (raw.trim()) body = JSON.parse(raw) as Record<string, unknown>;
|
|
} 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<void>((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;
|
|
},
|
|
});
|
|
});
|
|
});
|
|
}
|