clone

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

main
lofi-node / src / node.ts
node.ts 14.7K
1// createSyncNode — the one constructor. Consumer vocabulary is app, storage,2// upstream, pairing, access, status; iroh/dlopen/tunnel/jazz-napi/gate stay3// internal.45import { loadIrohAddon } from "./native/addon.ts";6import { IrohNode } from "./iroh/node.ts";7import {8 runTunnelAcceptor,9 startTunnelListener,10 type TunnelAcceptor,11 type TunnelConnStat,12 type TunnelListener,13} from "./tunnel.ts";14import { type JazzHandle, startJazz } from "./jazz.ts";15import {16 loadOrCreateIrohKey,17 type RelayConfig,18 type StorageConfig,19 type UpstreamConfig,20 validateRelay,21 validateStorage,22} from "./config.ts";23import { type Gate, startGate } from "./gate.ts";24import { TicketActivity } from "./ticket-activity.ts";25import {26 type AppTicketRecord,27 AppTicketStore,28 encodeAppTicket,29 isRevokedByLineage,30} from "./appticket.ts";31import { MeshUnavailableError } from "./errors.ts";3233/** Options for {@link createSyncNode}. */34export interface SyncNodeOptions {35 /** Jazz app id (a UUID). */36 appId: string;37 /** Jazz backend secret. */38 backendSecret: string;39 /** Jazz admin secret; in ticket mode the gate injects it server-side and40 * it never transits clients. */41 adminSecret: string;42 /** Omit (or set inMemory) for an ephemeral node — tests, throwaway relays. */43 dataDir?: string;44 inMemory?: boolean;45 /** Public port (the gate's in ticket mode); auto-allocated when omitted. */46 listen?: { port?: number };47 /** "open" (library default — today's behavior): everything reaching the48 * port syncs. "ticket": only requests carrying an issued app-ticket secret49 * (/t/<secret>/… serverUrl base path) reach Jazz; CLI init defaults here. */50 access?: "open" | "ticket";51 /** Where node data lives; sqlite path may be any mounted location. */52 storage?: StorageConfig;53 /** Base URL embedded into issued app tickets (e.g. http://192.168.1.10:4802).54 * Defaults to the gate's local URL — fine for same-host, wrong for LAN. */55 publicUrl?: string;56 /** "none" (default) | direct URL (e.g. Jazz Cloud) | peer ticket over iroh. */57 upstream?: UpstreamConfig;58 /** "auto": bring up iroh if the dylib resolves; stay up without it unless a59 * peer upstream NEEDS it. "off": plain Jazz server, dylib never loaded. */60 mesh?: "auto" | "off";61 /** Relay election for the iroh endpoint: "n0" (default — the public62 * n0-computer relays, fine for dev/testing), { urls } for operator-run63 * relays, or "disabled" for direct connections only. Peers dial this node64 * via the relay named in its pairing ticket, so no matching config is65 * needed on the other side. */66 relay?: RelayConfig;67 allowLocalFirstAuth?: boolean;68 /** Explicit addon path (otherwise LOFI_NODE_IROH / in-repo build). */69 irohLibPath?: string;70}7172/** iroh mesh state: up (with pairing ticket + live tunnel stats), off, or73 * unavailable with the precise reason (no silent degradation). */74export type MeshStatus =75 | { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] }76 | { state: "off" }77 | { state: "unavailable"; reason: string };7879/** Public view of an issued app-connect ticket (never the secret). */80export interface AppTicketInfo {81 id: string;82 scope: "sync" | "provision";83 label?: string;84 /** Id of the ticket this one was derived from (scope-down exchange). */85 parentId?: string;86 createdAt: string;87 /** True when the ticket itself or any ancestor is revoked. */88 revoked: boolean;89}9091/** Snapshot returned by {@link SyncNode.status}. */92export interface SyncNodeStatus {93 appId: string;94 access: "open" | "ticket";95 jazz: { url: string; port: number; storage: StorageConfig };96 tickets: AppTicketInfo[];97 /** Live gated connections and last observed activity, joined with labels;98 * empty on open-mode nodes. */99 gatedConnections: {100 ticketId: string;101 connections: number;102 lastSeenAt?: string;103 label?: string;104 }[];105 upstream: UpstreamConfig;106 mesh: MeshStatus;107}108109/** A running sync node — see {@link createSyncNode}. */110export interface SyncNode {111 /** Public URL — what JAZZ_SERVER_URL points at in open mode; in ticket mode112 * apps use a ticket's own /t/<secret> URL against this same host:port. */113 url: string;114 port: number;115 appId: string;116 /** Node-pairing ticket; throws MeshUnavailableError if mesh is down. */117 ticket(): string;118 /** Issue an app-connect ticket (ticket mode only). The returned string is119 * shown once — the secret is never stored, only its digest. scope120 * "provision" additionally unlocks store administration through the gate121 * (a strict superset of sync). */122 issueTicket(123 options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" },124 ): Promise<{125 id: string;126 ticket: string;127 }>;128 /** Revoke by id: new connections 401 immediately; live gated sockets close129 * with 4001 within the sweep interval. */130 revokeTicket(id: string): Promise<boolean>;131 listTickets(): Promise<AppTicketInfo[]>;132 /** Re-elect the upstream to a peer AT RUNTIME. The public port never133 * changes. Throws MeshUnavailableError if the mesh is down. */134 pair(peerTicket: string): Promise<void>;135 status(): SyncNodeStatus;136 stop(): Promise<void>;137}138139function toTicketInfo(record: AppTicketRecord, all: AppTicketRecord[]): AppTicketInfo {140 return {141 id: record.id,142 scope: record.scope ?? "sync",143 label: record.label,144 parentId: record.parentId,145 createdAt: record.createdAt,146 revoked: isRevokedByLineage(record, all),147 };148}149150/** Fail fast with the offending path when a user-chosen storage location is151 * not writable (NAS/synced-volume friendliness; no silent degradation). */152async function probeWritable(path: string): Promise<void> {153 try {154 await Deno.mkdir(path, { recursive: true });155 const probe = `${path}/.lofi-write-probe`;156 await Deno.writeTextFile(probe, "probe");157 await Deno.remove(probe);158 } catch (e) {159 throw new Error(`storage path "${path}" is not writable: ${(e as Error).message}`);160 }161}162163/**164 * Start a sync node: an embedded Jazz server, optional iroh mesh, and (in165 * ticket mode) the public access gate. The returned {@link SyncNode} is the166 * whole operational surface: tickets, pairing, status, stop.167 */168export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode> {169 let upstream: UpstreamConfig = options.upstream ?? "none";170 const meshMode = options.mesh ?? "auto";171 const access = options.access ?? "open";172 const relay: RelayConfig = options.relay ?? "n0";173 validateRelay(relay);174175 // 0. Storage election. Back-compat: explicit storage wins, else today's176 // rules (inMemory flag / absent dataDir → memory).177 const storage: StorageConfig = options.storage ??178 (options.inMemory ?? options.dataDir === undefined ? { type: "memory" } : { type: "sqlite" });179 validateStorage(storage);180 let jazzDataDir: string | undefined;181 if (storage.type === "sqlite") {182 jazzDataDir = storage.path ??183 (options.dataDir ? `${options.dataDir}/jazz` : undefined);184 if (jazzDataDir === undefined) {185 throw new Error('storage {type:"sqlite"} needs a path (or a node dataDir)');186 }187 await probeWritable(jazzDataDir);188 }189 const inMemory = storage.type === "memory";190191 // 1. Mesh (iroh) — optional unless the upstream election requires it.192 // Connections are attached at status() time (they live in the tunnels).193 let mesh:194 | { state: "up"; nodeId: string; ticket: string }195 | { state: "off" }196 | { state: "unavailable"; reason: string } = { state: "off" };197 let irohNode: IrohNode | null = null;198 let ticketString: string | null = null;199 if (meshMode === "auto") {200 try {201 const addon = await loadIrohAddon(options.irohLibPath);202 const key = options.dataDir203 ? await loadOrCreateIrohKey(options.dataDir)204 : crypto.getRandomValues(new Uint8Array(32));205 irohNode = await IrohNode.open(addon, key, relay);206 ticketString = await irohNode.ticket();207 mesh = { state: "up", nodeId: irohNode.idString(), ticket: ticketString };208 } catch (e) {209 if (e instanceof MeshUnavailableError) {210 mesh = { state: "unavailable", reason: e.message };211 } else {212 throw e;213 }214 }215 }216217 // 2. Resolve the upstream election to a concrete URL for JazzServer.218 let tunnelListener: TunnelListener | null = null;219 function resolveUpstream(220 election: UpstreamConfig,221 ): { url: string | undefined; listener: TunnelListener | null } {222 if (election === "none") return { url: undefined, listener: null };223 if ("url" in (election as object)) {224 return { url: (election as { url: string }).url, listener: null };225 }226 const ticket = (election as { peer: string }).peer;227 if (!irohNode) {228 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";229 throw new MeshUnavailableError(`peer upstream requires the mesh: ${reason}`);230 }231 const listener = startTunnelListener(irohNode, ticket);232 return { url: `ws://127.0.0.1:${listener.port}`, listener };233 }234235 function startJazzFor(upstreamUrl: string | undefined, port: number | undefined) {236 return startJazz({237 appId: options.appId,238 backendSecret: options.backendSecret,239 adminSecret: options.adminSecret,240 port,241 dataDir: jazzDataDir,242 inMemory,243 upstreamUrl,244 allowLocalFirstAuth: options.allowLocalFirstAuth,245 });246 }247248 // 3. Jazz server. In ticket mode Jazz takes an ephemeral loopback port and249 // the gate owns the public one; in open mode Jazz gets the listen port250 // directly (today's exact behavior — JazzServer binds loopback-only, so251 // "open" remains same-host/reverse-proxy territory).252 const gated = access === "ticket";253 let jazz: JazzHandle;254 const initial = resolveUpstream(upstream);255 tunnelListener = initial.listener;256 try {257 jazz = await startJazzFor(initial.url, gated ? undefined : options.listen?.port);258 } catch (e) {259 await initial.listener?.close();260 await irohNode?.close();261 throw e;262 }263 if (new URL(jazz.url.replace(/^ws/, "http")).hostname !== "127.0.0.1") {264 // The gate's enforcement assumes Jazz is loopback-only (true at265 // alpha.53); a future alpha binding wider would need a firewall note.266 console.warn(`lofi-node: JazzServer bound ${jazz.url}, expected loopback`);267 }268269 // 3b. App tickets + gate (ticket mode).270 const ticketStore = await AppTicketStore.load(options.dataDir);271 const ticketActivity = await TicketActivity.load(options.dataDir);272 let gate: Gate | null = null;273 if (gated) {274 gate = startGate({275 port: options.listen?.port ?? 0,276 target: () => jazz.url.replace(/^ws/, "http"),277 mode: "ticket",278 store: ticketStore,279 appId: options.appId,280 adminSecret: options.adminSecret,281 publicBase: options.publicUrl,282 nodeTicket: ticketString ?? undefined,283 activity: ticketActivity,284 });285 }286287 // 4. Inbound bridge: peers' tunnels land directly on the INTERNAL Jazz URL288 // — paired peers are authenticated by possession of the node-pairing289 // ticket, so this is not a gate bypass.290 let acceptor: TunnelAcceptor | null = null;291 if (irohNode) {292 acceptor = runTunnelAcceptor(irohNode, jazz.url);293 }294295 const publicPort = gate ? gate.port : jazz.port;296 const publicUrl = gate ? `ws://127.0.0.1:${gate.port}` : jazz.url;297298 // status() is sync; keep a ticket snapshot refreshed by every ticket call299 // and opportunistically by status() itself (next call sees fresh state).300 const snapshotTickets = async () => {301 const records = await ticketStore.list();302 return records.map((record) => toTicketInfo(record, records));303 };304 let ticketSnapshot: AppTicketInfo[] = await snapshotTickets();305 const refreshTickets = async () => {306 ticketSnapshot = await snapshotTickets();307 return ticketSnapshot;308 };309310 let stopped: Promise<void> | null = null;311 let pairing: Promise<void> | null = null;312 return {313 url: publicUrl,314 port: publicPort,315 appId: options.appId,316 ticket: () => {317 if (!ticketString) {318 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";319 throw new MeshUnavailableError(reason);320 }321 return ticketString;322 },323 issueTicket: async (ticketOptions = {}) => {324 if (!gate) throw new Error('issueTicket requires access: "ticket"');325 const scope = ticketOptions.scope ?? "sync";326 const { record, secret } = await ticketStore.issue(ticketOptions.label, scope);327 const base = (ticketOptions.publicBase ?? options.publicUrl ??328 `http://127.0.0.1:${gate.port}`).replace(/\/+$/, "");329 const ticket = encodeAppTicket({330 v: 1,331 appId: options.appId,332 url: `${base}/t/${secret}`,333 scope: scope === "provision" ? "provision" : undefined,334 label: ticketOptions.label,335 node: ticketString ?? undefined,336 });337 await refreshTickets();338 return { id: record.id, ticket };339 },340 revokeTicket: async (id: string) => {341 const revoked = (await ticketStore.revoke(id)) !== null;342 await refreshTickets();343 return revoked;344 },345 listTickets: () => refreshTickets(),346 pair: (peerTicket: string) => {347 if (stopped) return Promise.reject(new Error("node is stopped"));348 if (pairing) return Promise.reject(new Error("a pair() is already in progress"));349 pairing = (async () => {350 try {351 // In gated mode the public port belongs to the gate, so Jazz can352 // restart on a fresh ephemeral port; in open mode keep the port so353 // clients reconnect to an unchanged URL.354 const keptPort = gate ? undefined : jazz.port;355 // resolveUpstream throws (MeshUnavailableError) BEFORE teardown.356 const next = resolveUpstream({ peer: peerTicket });357 const oldListener = tunnelListener;358 tunnelListener = next.listener;359 await oldListener?.close();360 await jazz.stop();361 jazz = await startJazzFor(next.url, keptPort);362 upstream = { peer: peerTicket };363 } finally {364 pairing = null;365 }366 })();367 return pairing;368 },369 status: () => {370 refreshTickets().catch(() => {});371 return {372 appId: options.appId,373 access,374 jazz: { url: jazz.url, port: jazz.port, storage },375 tickets: ticketSnapshot,376 gatedConnections: (gate?.stats() ?? []).map((entry) => ({377 ...entry,378 label: ticketSnapshot.find((t) => t.id === entry.ticketId)?.label,379 })),380 upstream,381 mesh: mesh.state === "up"382 ? {383 ...mesh,384 connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],385 }386 : mesh,387 };388 },389 stop: () => (stopped ??= (async () => {390 acceptor?.close();391 await tunnelListener?.close();392 await gate?.close();393 await ticketActivity.flush().catch(() => {});394 await jazz.stop();395 await irohNode?.close();396 })()),397 };398}