clone

a self-hosted sync node for lofi apps: jazz sync over iroh, ticket-gated, with no static ip or cloud needed

feat/browser-cors-sync-tickets
lofi-node / src / gate.ts
gate.ts 24.7K
1// The access gate: lofi-node's public front listener. JazzServer binds2// loopback-only, so the gate is BOTH the enforcement point for app tickets3// and what makes the node reachable beyond localhost at all.4//5// Ticket mode routes requests shaped /t/<secret>/… — the secret arrives as a6// serverUrl base-path prefix (jazz clients preserve base paths through every7// WS connect and catalogue fetch), gets verified against the ticket store8// (timing-safe, digest vs digest), and is stripped before proxying to the9// internal Jazz port. WS is terminated and re-originated (safe: Jazz auth is10// an in-band frame, proven by the iroh tunnel doing the same); HTTP streams.1112import {13 type AppTicketPop,14 type AppTicketStore,15 encodeAppTicket,16 hashSecret,17 importPopKey,18 isRevokedByLineage,19 popMessage,20 SECRET_LENGTH,21 verifyPopSignature,22} from "./appticket.ts";23import { forwardableHeaders, sanitizeCloseCode } from "./tunnel.ts";2425const DEBUG = Deno.env.get("LOFI_NODE_DEBUG") === "1";26function debug(...args: unknown[]): void {27 if (DEBUG) console.error("[gate]", ...args);28}2930/** WS close code sent when a ticket is revoked mid-session; the app should31 * surface re-enrollment (see docs/app-ticket.md). */32export const CLOSE_TICKET_REVOKED = 4001;3334const TICKET_PATH = new RegExp(`^/t/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);35const CONNECT_PATH = new RegExp(`^/c/([A-Za-z0-9_-]{${SECRET_LENGTH}})(/.*)?$`);3637/** How long an unanswered proof-of-possession challenge stays valid. */38const CHALLENGE_TTL_MS = 120_000;39/** Sliding lifetime of a connect token; refreshed on each authenticated use,40 * so a live sync session never expires mid-flight. Tokens are memory-only —41 * a node restart invalidates them and the client re-runs the exchange. */42const CONNECT_TOKEN_TTL_MS = 86_400_000;43/** Outstanding-challenge cap per ticket; the oldest is dropped past it. */44const MAX_CHALLENGES_PER_TICKET = 32;4546type PopChallenge = { ticketId: string; nonce: string; expiresAt: number };47type ConnectToken = { ticketId: string; expiresAt: number };4849export interface GateOptions {50 port: number;51 hostname?: string;52 /** Internal Jazz http URL, read per-request so Jazz restarts (pair()) never53 * invalidate the gate. */54 target: () => string;55 mode: "open" | "ticket";56 store: AppTicketStore;57 /** The node's Jazz app id + admin secret, for provision-scoped injection58 * and the store-status endpoint. The secret never transits the gate inbound59 * — client-supplied X-Jazz-Admin-Secret headers are stripped in ticket60 * mode; the gate injects config's own for provision-scoped requests. */61 appId: string;62 adminSecret: string;63 /** Base URL embedded into tickets minted by the derive endpoint (the64 * node's publicUrl); defaults to the gate's own loopback URL — same65 * election as CLI-issued tickets. */66 publicBase?: string;67 /** The node's iroh EndpointTicket, carried on derived tickets exactly like68 * node-issued ones. */69 nodeTicket?: string;70 /** Activity sink notified on every authenticated request and WS open —71 * feeds the daemon-owned last-seen sidecar. */72 activity?: { note(ticketId: string): void };73}7475export interface Gate {76 port: number;77 url: string;78 /** Live gated WS connections and last observed activity per ticket id. */79 stats(): { ticketId: string; connections: number; lastSeenAt?: string }[];80 close(): Promise<void>;81}8283function unauthorized(): Response {84 return new Response(JSON.stringify({ error: "invalid_ticket" }), {85 status: 401,86 headers: { "content-type": "application/json" },87 });88}8990const CORS_ALLOW_METHODS = "GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS";9192function corsHeaders(req: Request): Headers {93 const headers = new Headers({94 "access-control-allow-origin": "*",95 "access-control-allow-methods": CORS_ALLOW_METHODS,96 });97 const requestedHeaders = req.headers.get("access-control-request-headers");98 if (requestedHeaders) headers.set("access-control-allow-headers", requestedHeaders);99 if (req.headers.get("access-control-request-private-network") === "true") {100 headers.set("access-control-allow-private-network", "true");101 }102 return headers;103}104105function corsPreflight(req: Request): Response {106 return new Response(null, { status: 204, headers: corsHeaders(req) });107}108109function withCors(req: Request, response: Response): Response {110 const headers = new Headers(response.headers);111 for (const [name, value] of corsHeaders(req)) headers.set(name, value);112 return new Response(response.body, {113 status: response.status,114 statusText: response.statusText,115 headers,116 });117}118119/** Pump two OPEN (or opening) WebSockets both ways until either closes.120 * `backlog` holds client data that arrived before the upstream dial finished;121 * flushed before the listener attaches (same task — no interleave). */122export function pumpWebSockets(123 client: WebSocket,124 upstream: WebSocket,125 backlog: (string | ArrayBuffer)[] = [],126): void {127 client.binaryType = "arraybuffer";128 upstream.binaryType = "arraybuffer";129 let torndown = false;130 const teardown = (code: number, reason: string) => {131 if (torndown) return;132 torndown = true;133 for (const ws of [client, upstream]) {134 if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {135 try {136 ws.close(sanitizeCloseCode(code), reason.slice(0, 120));137 } catch {138 // already closing139 }140 }141 }142 };143144 const forward = (to: WebSocket) => (ev: MessageEvent) => {145 try {146 to.send(ev.data);147 } catch {148 teardown(1011, "gate forward failed");149 }150 };151 for (const data of backlog) upstream.send(data);152 backlog.length = 0;153 client.addEventListener("message", forward(upstream));154 upstream.addEventListener("message", forward(client));155 client.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));156 upstream.addEventListener("close", (ev) => teardown(ev.code || 1000, ev.reason ?? ""));157 client.addEventListener("error", () => teardown(1011, "client ws error"));158 upstream.addEventListener("error", () => teardown(1011, "upstream ws error"));159}160161async function proxyHttp(162 req: Request,163 targetBase: string,164 path: string,165 headerOverrides?: Record<string, string | null>,166): Promise<Response> {167 try {168 const headers = forwardableHeaders(req.headers);169 for (const [name, value] of Object.entries(headerOverrides ?? {})) {170 if (value === null) delete headers[name];171 else headers[name] = value;172 }173 const res = await fetch(new URL(path, targetBase), {174 method: req.method,175 headers,176 body: req.method === "GET" || req.method === "HEAD" ? undefined : req.body,177 });178 return new Response(res.body, {179 status: res.status,180 headers: forwardableHeaders(res.headers),181 });182 } catch (e) {183 debug("http proxy error", (e as Error).message);184 return new Response("upstream unavailable", { status: 502 });185 }186}187188/** Node-served, metadata-only store preflight: lets a sync-scoped client189 * classify `no_schema` / hash-mismatch instead of hanging on writes (lofi#109190 * failure surface). Never returns schema contents, policies, or secrets. */191async function storeStatus(192 targetBase: string,193 appId: string,194 adminSecret: string,195): Promise<Response> {196 try {197 const admin = { "X-Jazz-Admin-Secret": adminSecret };198 const schemasRes = await fetch(new URL(`/apps/${appId}/schemas`, targetBase), {199 headers: admin,200 });201 if (!schemasRes.ok) {202 await schemasRes.body?.cancel();203 debug("store-status schemas fetch", schemasRes.status);204 return new Response(JSON.stringify({ error: "store_unavailable" }), {205 status: 502,206 headers: { "content-type": "application/json" },207 });208 }209 const body = await schemasRes.json() as {210 hashes?: string[];211 schemas?: { hash: string; publishedAt?: number | string }[];212 };213 // Head = newest published schema; the bare `hashes` array is NOT ordered.214 const entries = body.schemas ?? [];215 let headHash: string | undefined;216 let newest = -Infinity;217 for (const entry of entries) {218 const at = typeof entry.publishedAt === "string"219 ? Date.parse(entry.publishedAt)220 : entry.publishedAt ?? 0;221 if (at >= newest) {222 newest = at;223 headHash = entry.hash;224 }225 }226 const hashes = body.hashes ?? entries.map((e) => e.hash);227 headHash ??= hashes[hashes.length - 1];228 let permissionsHead: string | null = null;229 try {230 const headRes = await fetch(231 new URL(`/apps/${appId}/admin/permissions/head`, targetBase),232 { headers: admin },233 );234 if (headRes.ok) {235 permissionsHead = ((await headRes.json()) as { head?: string | null }).head ?? null;236 } else {237 await headRes.body?.cancel();238 }239 } catch {240 // permissions head stays null241 }242 const schema = hashes.length > 0243 ? { deployed: true, headHash, permissionsHead }244 : { deployed: false };245 return new Response(JSON.stringify({ v: 1, appId, schema }), {246 headers: { "content-type": "application/json" },247 });248 } catch (e) {249 debug("store-status error", (e as Error).message);250 return new Response(JSON.stringify({ error: "store_unavailable" }), {251 status: 502,252 headers: { "content-type": "application/json" },253 });254 }255}256257const ADMIN_PATH = /^\/apps\/[^/]+\/admin(\/|$)/;258259function proxyWebSocket(req: Request, targetBase: string, path: string): {260 response: Response;261 socket: WebSocket;262} {263 const requestedProtocols = req.headers.get("sec-websocket-protocol");264 const { socket, response } = Deno.upgradeWebSocket(265 req,266 requestedProtocols ? { protocol: requestedProtocols.split(",")[0].trim() } : {},267 );268 // Buffer client data while the upstream dial is in flight; pumpWebSockets269 // flushes before attaching its own listener (same task, no gap).270 const backlog: (string | ArrayBuffer)[] = [];271 const buffer = (ev: MessageEvent) => backlog.push(ev.data);272 socket.addEventListener("message", buffer);273 socket.addEventListener("open", () => {274 const wsUrl = new URL(path, targetBase);275 wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";276 const upstream = requestedProtocols277 ? new WebSocket(wsUrl, requestedProtocols.split(",").map((s) => s.trim()))278 : new WebSocket(wsUrl);279 upstream.addEventListener("open", () => {280 socket.removeEventListener("message", buffer);281 pumpWebSockets(socket, upstream, backlog);282 });283 upstream.addEventListener("error", () => {284 try {285 socket.close(1011, "upstream dial failed");286 } catch {287 // already closed288 }289 });290 });291 return { response, socket };292}293294export function startGate(options: GateOptions): Gate {295 const hostname = options.hostname ?? "0.0.0.0";296 const liveByTicket = new Map<string, Set<WebSocket>>();297298 // Proof-of-possession state, memory-only by design: challenges are299 // single-use with a short TTL, and connect tokens are stored hashed (the300 // node never stores secrets) with a sliding TTL.301 const challenges = new Map<string, PopChallenge>();302 const connectTokens = new Map<string, ConnectToken>();303304 const base64urlNoPad = (bytes: Uint8Array): string => {305 let bin = "";306 for (const b of bytes) bin += String.fromCharCode(b);307 return btoa(bin).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");308 };309310 const mintChallenge = (ticketId: string): { id: string; nonce: string } => {311 const perTicket = [...challenges.entries()].filter(([, c]) => c.ticketId === ticketId);312 if (perTicket.length >= MAX_CHALLENGES_PER_TICKET) {313 let oldestId = perTicket[0][0];314 let oldestAt = perTicket[0][1].expiresAt;315 for (const [id, challenge] of perTicket) {316 if (challenge.expiresAt < oldestAt) {317 oldestAt = challenge.expiresAt;318 oldestId = id;319 }320 }321 challenges.delete(oldestId);322 }323 const id = base64urlNoPad(crypto.getRandomValues(new Uint8Array(12)));324 const nonce = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));325 challenges.set(id, { ticketId, nonce, expiresAt: Date.now() + CHALLENGE_TTL_MS });326 return { id, nonce };327 };328329 const answerChallenge = async (330 ticketId: string,331 pop: AppTicketPop,332 body: { id?: unknown; sig?: unknown },333 ): Promise<string | null> => {334 if (typeof body.id !== "string" || typeof body.sig !== "string") return null;335 const challenge = challenges.get(body.id);336 // Single-use: the challenge dies on its first answer attempt, valid or not.337 challenges.delete(body.id);338 if (!challenge || challenge.ticketId !== ticketId || challenge.expiresAt < Date.now()) {339 return null;340 }341 const verified = await verifyPopSignature(342 pop.spki,343 popMessage(options.appId, ticketId, challenge.nonce),344 body.sig,345 );346 if (!verified) return null;347 const token = base64urlNoPad(crypto.getRandomValues(new Uint8Array(32)));348 connectTokens.set(await hashSecret(token), {349 ticketId,350 expiresAt: Date.now() + CONNECT_TOKEN_TTL_MS,351 });352 return token;353 };354355 const lastSeenAt = new Map<string, string>();356 const seen = (ticketId: string) => {357 lastSeenAt.set(ticketId, new Date().toISOString());358 options.activity?.note(ticketId);359 };360361 const track = (ticketId: string, socket: WebSocket) => {362 let set = liveByTicket.get(ticketId);363 if (!set) liveByTicket.set(ticketId, set = new Set());364 set.add(socket);365 socket.addEventListener("close", () => {366 seen(ticketId);367 set.delete(socket);368 if (set.size === 0) liveByTicket.delete(ticketId);369 });370 };371372 // Revocation sweep: piggyback on the store's hot-reload — poll live tickets373 // and close sockets whose ticket flipped to revoked. Cheap (only runs while374 // gated sockets exist).375 const sweep = setInterval(async () => {376 // Expired proof-of-possession state ages out regardless of connections.377 const now = Date.now();378 for (const [id, challenge] of challenges) {379 if (challenge.expiresAt < now) challenges.delete(id);380 }381 for (const [digest, token] of connectTokens) {382 if (token.expiresAt < now) connectTokens.delete(digest);383 }384 if (liveByTicket.size === 0 && connectTokens.size === 0 && challenges.size === 0) return;385 const records = await options.store.list();386 const dead = (ticketId: string): boolean => {387 const record = records.find((r) => r.id === ticketId);388 return !record || isRevokedByLineage(record, records);389 };390 for (const [id, challenge] of challenges) {391 if (dead(challenge.ticketId)) challenges.delete(id);392 }393 for (const [digest, token] of connectTokens) {394 if (dead(token.ticketId)) connectTokens.delete(digest);395 }396 for (const [ticketId, sockets] of liveByTicket) {397 if (!dead(ticketId)) continue;398 for (const socket of [...sockets]) {399 try {400 socket.close(CLOSE_TICKET_REVOKED, "ticket revoked");401 } catch {402 // already closing403 }404 }405 liveByTicket.delete(ticketId);406 }407 }, 2000);408409 const server = Deno.serve(410 { hostname, port: options.port, onListen: () => {} },411 async (req) => {412 const url = new URL(req.url);413 const isUpgrade = req.headers.get("upgrade")?.toLowerCase() === "websocket";414 const targetBase = options.target();415 if (!isUpgrade && req.method === "OPTIONS") return corsPreflight(req);416 const respond = (response: Response): Response =>417 isUpgrade ? response : withCors(req, response);418419 if (options.mode === "open") {420 // Open nodes still serve the preflight so lofi's store classifier421 // works against dev setups.422 if (url.pathname === "/store-status" && !isUpgrade) {423 return respond(await storeStatus(targetBase, options.appId, options.adminSecret));424 }425 const path = url.pathname + url.search;426 return respond(427 isUpgrade428 ? proxyWebSocket(req, targetBase, path).response429 : await proxyHttp(req, targetBase, path),430 );431 }432433 // Ticket mode.434 if (url.pathname === "/health" && !isUpgrade) {435 return respond(await proxyHttp(req, targetBase, "/health"));436 }437 const match = url.pathname.match(TICKET_PATH);438 if (!match) return respond(new Response("not found", { status: 404 }));439 const [, secret, rest] = match;440 const verdict = await options.store.verify(secret);441 if (verdict.status !== "valid") {442 debug("rejected", verdict.status, url.pathname.slice(0, 24));443 return respond(unauthorized());444 }445 const scope = verdict.record.scope ?? "sync";446 seen(verdict.record.id);447 let strippedPath = rest || "/";448 // Possession-bound tickets: the bare secret opens only the449 // proof-of-possession exchange; everything else must ride a connect450 // token minted by a fresh signature from the bound device key. A stolen451 // ticket string alone therefore no longer connects.452 if (verdict.record.pop) {453 // Liveness stays reachable with the bare secret — it reveals nothing454 // a valid ticket holder could not learn from the open /health route.455 if (strippedPath === "/health" && !isUpgrade) {456 return respond(await proxyHttp(req, targetBase, "/health"));457 }458 if (strippedPath === "/pop/challenge" && !isUpgrade) {459 if (req.method !== "POST") {460 return respond(new Response("method not allowed", { status: 405 }));461 }462 const challenge = mintChallenge(verdict.record.id);463 return respond(464 new Response(465 JSON.stringify({466 v: 1,467 id: challenge.id,468 nonce: challenge.nonce,469 expiresIn: CHALLENGE_TTL_MS / 1000,470 }),471 { headers: { "content-type": "application/json" } },472 ),473 );474 }475 if (strippedPath === "/pop/answer" && !isUpgrade) {476 if (req.method !== "POST") {477 return respond(new Response("method not allowed", { status: 405 }));478 }479 let body: { id?: unknown; sig?: unknown } = {};480 try {481 body = await req.json() as { id?: unknown; sig?: unknown };482 } catch {483 // malformed body falls through to the single rejection path484 }485 const token = await answerChallenge(verdict.record.id, verdict.record.pop, body);486 if (token === null) {487 debug("rejected pop answer", verdict.record.id);488 return respond(unauthorized());489 }490 debug("pop verified", verdict.record.id);491 return respond(492 new Response(493 JSON.stringify({ v: 1, connect: token, expiresIn: CONNECT_TOKEN_TTL_MS / 1000 }),494 { headers: { "content-type": "application/json" } },495 ),496 );497 }498 const connect = strippedPath.match(CONNECT_PATH);499 if (!connect) {500 debug("rejected pop-bound bare path", verdict.record.id);501 return respond(unauthorized());502 }503 const [, connectSecret, connectRest] = connect;504 const tokenDigest = await hashSecret(connectSecret);505 const token = connectTokens.get(tokenDigest);506 if (!token || token.ticketId !== verdict.record.id || token.expiresAt < Date.now()) {507 debug("rejected connect token", verdict.record.id);508 return respond(unauthorized());509 }510 // Sliding lifetime: each authenticated use extends the token.511 token.expiresAt = Date.now() + CONNECT_TOKEN_TTL_MS;512 strippedPath = connectRest || "/";513 } else if (strippedPath.startsWith("/pop/")) {514 // The exchange only exists for bound tickets; a bearer ticket probing515 // it is a client-configuration error, and the path must never leak516 // upstream.517 return respond(518 new Response(JSON.stringify({ error: "pop_not_bound" }), {519 status: 400,520 headers: { "content-type": "application/json" },521 }),522 );523 }524 if (strippedPath === "/store-status" && !isUpgrade) {525 return respond(await storeStatus(targetBase, options.appId, options.adminSecret));526 }527 // Scope-down exchange: a provision ticket mints a derived sync ticket528 // (the lofi app persists that one at rest, keeping the provision ticket529 // sealed or memory-only). The derived record carries parentId, so530 // revoking the provision ticket kills its derived tickets too. A sync531 // ticket gets the SAME 401 as an invalid one (nothing to enumerate).532 if (strippedPath === "/derive-sync-ticket" && !isUpgrade) {533 if (scope !== "provision") {534 debug("rejected sync-scope on derive", verdict.record.id);535 return respond(unauthorized());536 }537 if (req.method !== "POST") {538 return respond(new Response("method not allowed", { status: 405 }));539 }540 let requested: string | undefined;541 let deviceKey: { alg?: unknown; spki?: unknown } | undefined;542 try {543 const body = await req.json() as { label?: unknown; devicePublicKey?: unknown };544 if (typeof body.label === "string") requested = body.label;545 if (typeof body.devicePublicKey === "object" && body.devicePublicKey !== null) {546 deviceKey = body.devicePublicKey as { alg?: unknown; spki?: unknown };547 }548 } catch {549 // empty or non-JSON body: fall through to the default label550 }551 // An offered device key binds the derived ticket to possession. A552 // malformed key is a client bug, not an auth probe — 400, not 401.553 let pop: { alg: "ES256"; spki: string } | undefined;554 if (deviceKey !== undefined) {555 if (deviceKey.alg !== "ES256" || typeof deviceKey.spki !== "string") {556 return respond(557 new Response(JSON.stringify({ error: "invalid_device_key" }), {558 status: 400,559 headers: { "content-type": "application/json" },560 }),561 );562 }563 try {564 await importPopKey(deviceKey.spki);565 } catch {566 return respond(567 new Response(JSON.stringify({ error: "invalid_device_key" }), {568 status: 400,569 headers: { "content-type": "application/json" },570 }),571 );572 }573 pop = { alg: "ES256", spki: deviceKey.spki };574 }575 const parent = verdict.record;576 const label = requested ?? `${parent.label ?? parent.id} (sync)`;577 const derived = await options.store.issue(label, "sync", parent.id, pop);578 const base = (options.publicBase ??579 `http://127.0.0.1:${(server.addr as Deno.NetAddr).port}`).replace(/\/+$/, "");580 const ticket = encodeAppTicket({581 v: 1,582 appId: options.appId,583 url: `${base}/t/${derived.secret}`,584 label,585 node: options.nodeTicket,586 });587 debug("derived sync ticket", derived.record.id, "from", parent.id, pop ? "pop" : "bearer");588 return respond(589 new Response(590 JSON.stringify({591 v: 1,592 id: derived.record.id,593 ticket,594 ...(pop ? { pop: true } : {}),595 }),596 { headers: { "content-type": "application/json" } },597 ),598 );599 }600 // Admin/catalogue-mutating routes need provision scope; a sync ticket601 // gets the SAME 401 shape as an invalid ticket (nothing to enumerate).602 if (ADMIN_PATH.test(strippedPath) && scope !== "provision") {603 debug("rejected sync-scope on admin path", verdict.record.id);604 return respond(unauthorized());605 }606 const path = strippedPath + url.search;607 debug(isUpgrade ? "ws" : req.method, path, "ticket", verdict.record.id, scope);608 if (isUpgrade) {609 const { response, socket } = proxyWebSocket(req, targetBase, path);610 track(verdict.record.id, socket);611 return respond(response);612 }613 // The admin secret only ever originates from the node: inbound headers614 // are stripped; provision-scoped requests get config's secret injected615 // (on catalogue reads too — the merge flow fetches the head schema616 // verbatim without ever holding the secret client-side).617 return respond(618 await proxyHttp(req, targetBase, path, {619 "x-jazz-admin-secret": scope === "provision" ? options.adminSecret : null,620 }),621 );622 },623 );624625 const port = (server.addr as Deno.NetAddr).port;626 return {627 port,628 url: `http://127.0.0.1:${port}`,629 stats: () => {630 const ids = new Set([...liveByTicket.keys(), ...lastSeenAt.keys()]);631 return [...ids].map((ticketId) => ({632 ticketId,633 connections: liveByTicket.get(ticketId)?.size ?? 0,634 ...(lastSeenAt.has(ticketId) ? { lastSeenAt: lastSeenAt.get(ticketId) } : {}),635 }));636 },637 close: async () => {638 clearInterval(sweep);639 await server.shutdown();640 },641 };642}