clone

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

fix/secret-file-permissions
lofi-node / src / node.ts
node.ts 13.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 { type AppTicketRecord, AppTicketStore, encodeAppTicket } from "./appticket.ts";25import { MeshUnavailableError } from "./errors.ts";2627/** Options for {@link createSyncNode}. */28export interface SyncNodeOptions {29 /** Jazz app id (a UUID). */30 appId: string;31 /** Jazz backend secret. */32 backendSecret: string;33 /** Jazz admin secret; in ticket mode the gate injects it server-side and34 * it never transits clients. */35 adminSecret: string;36 /** Omit (or set inMemory) for an ephemeral node — tests, throwaway relays. */37 dataDir?: string;38 inMemory?: boolean;39 /** Public port (the gate's in ticket mode); auto-allocated when omitted. */40 listen?: { port?: number };41 /** "open" (library default — today's behavior): everything reaching the42 * port syncs. "ticket": only requests carrying an issued app-ticket secret43 * (/t/<secret>/… serverUrl base path) reach Jazz; CLI init defaults here. */44 access?: "open" | "ticket";45 /** Where node data lives; sqlite path may be any mounted location. */46 storage?: StorageConfig;47 /** Base URL embedded into issued app tickets (e.g. http://192.168.1.10:4802).48 * Defaults to the gate's local URL — fine for same-host, wrong for LAN. */49 publicUrl?: string;50 /** "none" (default) | direct URL (e.g. Jazz Cloud) | peer ticket over iroh. */51 upstream?: UpstreamConfig;52 /** "auto": bring up iroh if the dylib resolves; stay up without it unless a53 * peer upstream NEEDS it. "off": plain Jazz server, dylib never loaded. */54 mesh?: "auto" | "off";55 /** Relay election for the iroh endpoint: "n0" (default — the public56 * n0-computer relays, fine for dev/testing), { urls } for operator-run57 * relays, or "disabled" for direct connections only. Peers dial this node58 * via the relay named in its pairing ticket, so no matching config is59 * needed on the other side. */60 relay?: RelayConfig;61 allowLocalFirstAuth?: boolean;62 /** Explicit addon path (otherwise LOFI_NODE_IROH / in-repo build). */63 irohLibPath?: string;64}6566/** iroh mesh state: up (with pairing ticket + live tunnel stats), off, or67 * unavailable with the precise reason (no silent degradation). */68export type MeshStatus =69 | { state: "up"; nodeId: string; ticket: string; connections: TunnelConnStat[] }70 | { state: "off" }71 | { state: "unavailable"; reason: string };7273/** Public view of an issued app-connect ticket (never the secret). */74export interface AppTicketInfo {75 id: string;76 scope: "sync" | "provision";77 label?: string;78 createdAt: string;79 revoked: boolean;80}8182/** Snapshot returned by {@link SyncNode.status}. */83export interface SyncNodeStatus {84 appId: string;85 access: "open" | "ticket";86 jazz: { url: string; port: number; storage: StorageConfig };87 tickets: AppTicketInfo[];88 upstream: UpstreamConfig;89 mesh: MeshStatus;90}9192/** A running sync node — see {@link createSyncNode}. */93export interface SyncNode {94 /** Public URL — what JAZZ_SERVER_URL points at in open mode; in ticket mode95 * apps use a ticket's own /t/<secret> URL against this same host:port. */96 url: string;97 port: number;98 appId: string;99 /** Node-pairing ticket; throws MeshUnavailableError if mesh is down. */100 ticket(): string;101 /** Issue an app-connect ticket (ticket mode only). The returned string is102 * shown once — the secret is never stored, only its digest. scope103 * "provision" additionally unlocks store administration through the gate104 * (a strict superset of sync). */105 issueTicket(106 options?: { label?: string; publicBase?: string; scope?: "sync" | "provision" },107 ): Promise<{108 id: string;109 ticket: string;110 }>;111 /** Revoke by id: new connections 401 immediately; live gated sockets close112 * with 4001 within the sweep interval. */113 revokeTicket(id: string): Promise<boolean>;114 listTickets(): Promise<AppTicketInfo[]>;115 /** Re-elect the upstream to a peer AT RUNTIME. The public port never116 * changes. Throws MeshUnavailableError if the mesh is down. */117 pair(peerTicket: string): Promise<void>;118 status(): SyncNodeStatus;119 stop(): Promise<void>;120}121122function toTicketInfo(record: AppTicketRecord): AppTicketInfo {123 return {124 id: record.id,125 scope: record.scope ?? "sync",126 label: record.label,127 createdAt: record.createdAt,128 revoked: Boolean(record.revokedAt),129 };130}131132/** Fail fast with the offending path when a user-chosen storage location is133 * not writable (NAS/synced-volume friendliness; no silent degradation). */134async function probeWritable(path: string): Promise<void> {135 try {136 await Deno.mkdir(path, { recursive: true });137 const probe = `${path}/.lofi-write-probe`;138 await Deno.writeTextFile(probe, "probe");139 await Deno.remove(probe);140 } catch (e) {141 throw new Error(`storage path "${path}" is not writable: ${(e as Error).message}`);142 }143}144145/**146 * Start a sync node: an embedded Jazz server, optional iroh mesh, and (in147 * ticket mode) the public access gate. The returned {@link SyncNode} is the148 * whole operational surface: tickets, pairing, status, stop.149 */150export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode> {151 let upstream: UpstreamConfig = options.upstream ?? "none";152 const meshMode = options.mesh ?? "auto";153 const access = options.access ?? "open";154 const relay: RelayConfig = options.relay ?? "n0";155 validateRelay(relay);156157 // 0. Storage election. Back-compat: explicit storage wins, else today's158 // rules (inMemory flag / absent dataDir → memory).159 const storage: StorageConfig = options.storage ??160 (options.inMemory ?? options.dataDir === undefined ? { type: "memory" } : { type: "sqlite" });161 validateStorage(storage);162 let jazzDataDir: string | undefined;163 if (storage.type === "sqlite") {164 jazzDataDir = storage.path ??165 (options.dataDir ? `${options.dataDir}/jazz` : undefined);166 if (jazzDataDir === undefined) {167 throw new Error('storage {type:"sqlite"} needs a path (or a node dataDir)');168 }169 await probeWritable(jazzDataDir);170 }171 const inMemory = storage.type === "memory";172173 // 1. Mesh (iroh) — optional unless the upstream election requires it.174 // Connections are attached at status() time (they live in the tunnels).175 let mesh:176 | { state: "up"; nodeId: string; ticket: string }177 | { state: "off" }178 | { state: "unavailable"; reason: string } = { state: "off" };179 let irohNode: IrohNode | null = null;180 let ticketString: string | null = null;181 if (meshMode === "auto") {182 try {183 const addon = await loadIrohAddon(options.irohLibPath);184 const key = options.dataDir185 ? await loadOrCreateIrohKey(options.dataDir)186 : crypto.getRandomValues(new Uint8Array(32));187 irohNode = await IrohNode.open(addon, key, relay);188 ticketString = await irohNode.ticket();189 mesh = { state: "up", nodeId: irohNode.idString(), ticket: ticketString };190 } catch (e) {191 if (e instanceof MeshUnavailableError) {192 mesh = { state: "unavailable", reason: e.message };193 } else {194 throw e;195 }196 }197 }198199 // 2. Resolve the upstream election to a concrete URL for JazzServer.200 let tunnelListener: TunnelListener | null = null;201 function resolveUpstream(202 election: UpstreamConfig,203 ): { url: string | undefined; listener: TunnelListener | null } {204 if (election === "none") return { url: undefined, listener: null };205 if ("url" in (election as object)) {206 return { url: (election as { url: string }).url, listener: null };207 }208 const ticket = (election as { peer: string }).peer;209 if (!irohNode) {210 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";211 throw new MeshUnavailableError(`peer upstream requires the mesh: ${reason}`);212 }213 const listener = startTunnelListener(irohNode, ticket);214 return { url: `ws://127.0.0.1:${listener.port}`, listener };215 }216217 function startJazzFor(upstreamUrl: string | undefined, port: number | undefined) {218 return startJazz({219 appId: options.appId,220 backendSecret: options.backendSecret,221 adminSecret: options.adminSecret,222 port,223 dataDir: jazzDataDir,224 inMemory,225 upstreamUrl,226 allowLocalFirstAuth: options.allowLocalFirstAuth,227 });228 }229230 // 3. Jazz server. In ticket mode Jazz takes an ephemeral loopback port and231 // the gate owns the public one; in open mode Jazz gets the listen port232 // directly (today's exact behavior — JazzServer binds loopback-only, so233 // "open" remains same-host/reverse-proxy territory).234 const gated = access === "ticket";235 let jazz: JazzHandle;236 const initial = resolveUpstream(upstream);237 tunnelListener = initial.listener;238 try {239 jazz = await startJazzFor(initial.url, gated ? undefined : options.listen?.port);240 } catch (e) {241 await initial.listener?.close();242 await irohNode?.close();243 throw e;244 }245 if (new URL(jazz.url.replace(/^ws/, "http")).hostname !== "127.0.0.1") {246 // The gate's enforcement assumes Jazz is loopback-only (true at247 // alpha.53); a future alpha binding wider would need a firewall note.248 console.warn(`lofi-node: JazzServer bound ${jazz.url}, expected loopback`);249 }250251 // 3b. App tickets + gate (ticket mode).252 const ticketStore = await AppTicketStore.load(options.dataDir);253 let gate: Gate | null = null;254 if (gated) {255 gate = startGate({256 port: options.listen?.port ?? 0,257 target: () => jazz.url.replace(/^ws/, "http"),258 mode: "ticket",259 store: ticketStore,260 appId: options.appId,261 adminSecret: options.adminSecret,262 });263 }264265 // 4. Inbound bridge: peers' tunnels land directly on the INTERNAL Jazz URL266 // — paired peers are authenticated by possession of the node-pairing267 // ticket, so this is not a gate bypass.268 let acceptor: TunnelAcceptor | null = null;269 if (irohNode) {270 acceptor = runTunnelAcceptor(irohNode, jazz.url);271 }272273 const publicPort = gate ? gate.port : jazz.port;274 const publicUrl = gate ? `ws://127.0.0.1:${gate.port}` : jazz.url;275276 // status() is sync; keep a ticket snapshot refreshed by every ticket call277 // and opportunistically by status() itself (next call sees fresh state).278 let ticketSnapshot: AppTicketInfo[] = (await ticketStore.list()).map(toTicketInfo);279 const refreshTickets = async () => {280 ticketSnapshot = (await ticketStore.list()).map(toTicketInfo);281 return ticketSnapshot;282 };283284 let stopped: Promise<void> | null = null;285 let pairing: Promise<void> | null = null;286 return {287 url: publicUrl,288 port: publicPort,289 appId: options.appId,290 ticket: () => {291 if (!ticketString) {292 const reason = mesh.state === "unavailable" ? mesh.reason : "mesh is off";293 throw new MeshUnavailableError(reason);294 }295 return ticketString;296 },297 issueTicket: async (ticketOptions = {}) => {298 if (!gate) throw new Error('issueTicket requires access: "ticket"');299 const scope = ticketOptions.scope ?? "sync";300 const { record, secret } = await ticketStore.issue(ticketOptions.label, scope);301 const base = (ticketOptions.publicBase ?? options.publicUrl ??302 `http://127.0.0.1:${gate.port}`).replace(/\/+$/, "");303 const ticket = encodeAppTicket({304 v: 1,305 appId: options.appId,306 url: `${base}/t/${secret}`,307 scope: scope === "provision" ? "provision" : undefined,308 label: ticketOptions.label,309 node: ticketString ?? undefined,310 });311 await refreshTickets();312 return { id: record.id, ticket };313 },314 revokeTicket: async (id: string) => {315 const revoked = (await ticketStore.revoke(id)) !== null;316 await refreshTickets();317 return revoked;318 },319 listTickets: () => refreshTickets(),320 pair: (peerTicket: string) => {321 if (stopped) return Promise.reject(new Error("node is stopped"));322 if (pairing) return Promise.reject(new Error("a pair() is already in progress"));323 pairing = (async () => {324 try {325 // In gated mode the public port belongs to the gate, so Jazz can326 // restart on a fresh ephemeral port; in open mode keep the port so327 // clients reconnect to an unchanged URL.328 const keptPort = gate ? undefined : jazz.port;329 // resolveUpstream throws (MeshUnavailableError) BEFORE teardown.330 const next = resolveUpstream({ peer: peerTicket });331 const oldListener = tunnelListener;332 tunnelListener = next.listener;333 await oldListener?.close();334 await jazz.stop();335 jazz = await startJazzFor(next.url, keptPort);336 upstream = { peer: peerTicket };337 } finally {338 pairing = null;339 }340 })();341 return pairing;342 },343 status: () => {344 refreshTickets().catch(() => {});345 return {346 appId: options.appId,347 access,348 jazz: { url: jazz.url, port: jazz.port, storage },349 tickets: ticketSnapshot,350 upstream,351 mesh: mesh.state === "up"352 ? {353 ...mesh,354 connections: [...(tunnelListener?.stats() ?? []), ...(acceptor?.stats() ?? [])],355 }356 : mesh,357 };358 },359 stop: () => (stopped ??= (async () => {360 acceptor?.close();361 await tunnelListener?.close();362 await gate?.close();363 await jazz.stop();364 await irohNode?.close();365 })()),366 };367}