relay: "n0" (default) | { urls } | "disabled" on NodeConfig,
SyncNodeOptions, and cli init (--relay <url[,url…]> / --no-relay).
Custom relays keep the n0 discovery preset and narrow only the relay
map; the elected relay travels inside pairing tickets, so peers need
no matching config. Validated at config load and node start.
The public n0 relays are rate-limited and meant for dev/testing;
production nodes point --relay at their own iroh-relay.- commit
- 4c8f8546df3daf6b6ac03d5ff1dfebab361a171a
- parent
- 5ce7cd8eee68232427e71545ac9578bf98b7f97d
- author
- Dami <dami.etoile@gmail.com>
| M | cli.ts | 49 | +++++++++++- |
| M | mod.ts | 10 | ++- |
| M | src/ | 42 | ++++++++++- |
| M | src/ | 16 | +++- |
| M | src/ | 10 | ++ |
| M | src/ | 12 | +++- |
| A | tests/ | 125 | ++++++++++++++++++++++++++++++ |
@@ -16,7 +16,14 @@
1616 * 1717 */1818 19-import { initConfig, loadConfig, saveConfig, type StorageConfig } from "./src/config.ts";19+import {20+ initConfig,21+ loadConfig,22+ type RelayConfig,23+ saveConfig,24+ type StorageConfig,25+ validateRelay,26+} from "./src/config.ts";2027 import { createSyncNode } from "./src/node.ts";2128 import { looksLikeTicket } from "./src/ticket.ts";2229 import { AppTicketStore, encodeAppTicket, looksLikeAppTicket } from "./src/appticket.ts";@@ -26,6 +33,7 @@ const USAGE = `lofi-node — self-hostable sync node for lofi apps
2633 Usage:2734 lofi-node init [--dir <dataDir>] [--app-id <id>] [--port <n>]2835 [--public-url <base>] [--open] [--storage-path <path>] [--memory]36+ [--relay <url[,url…]>] [--no-relay]2937 lofi-node start [--dir <dataDir>]3038 lofi-node pair <node-ticket> [--dir <dataDir>]3139 lofi-node ticket issue [--label <s>] [--url <base>] [--provision] [--dir <dataDir>]@@ -35,7 +43,11 @@ Usage:
3543 3644 The data directory defaults to ./lofi-node-data. New inits are ticket-gated3745 (--open opts out); app tickets are issued with \`ticket issue\` and pasted38-into the lofi app.`;46+into the lofi app.47+48+Relays default to n0's public servers — rate-limited, fine for development.49+For production point --relay at your own iroh-relay (comma-separated for50+more than one), or pass --no-relay for direct connections only.`;3951 4052 interface Args {4153 command: string;@@ -50,6 +62,8 @@ interface Args {
5062 memory: boolean;5163 provision: boolean;5264 storagePath?: string;65+ relayUrls: string[];66+ noRelay: boolean;5367 }5468 5569 function parseArgs(argv: string[]): Args {@@ -60,6 +74,8 @@ function parseArgs(argv: string[]): Args {
6074 open: false,6175 memory: false,6276 provision: false,77+ relayUrls: [],78+ noRelay: false,6379 };6480 for (let i = 1; i < argv.length; i++) {6581 const arg = argv[i];@@ -73,6 +89,9 @@ function parseArgs(argv: string[]): Args {
7389 else if (arg === "--memory") args.memory = true;7490 else if (arg === "--provision") args.provision = true;7591 else if (arg === "--storage-path") args.storagePath = argv[++i];92+ else if (arg === "--relay") {93+ args.relayUrls.push(...(argv[++i] ?? "").split(",").map((u) => u.trim()).filter(Boolean));94+ } else if (arg === "--no-relay") args.noRelay = true;7695 else args.positional.push(arg);7796 }7897 return args;@@ -94,6 +113,12 @@ function describeStorage(storage: StorageConfig, dir: string): string {
94113 return `sqlite (${storage.path ?? `${dir}/jazz`})`;95114 }96115 116+function describeRelay(relay: RelayConfig | undefined): string {117+ if (relay === undefined || relay === "n0") return "n0 public relays (dev default)";118+ if (relay === "disabled") return "disabled (direct connections only)";119+ return `custom (${relay.urls.join(", ")})`;120+}121+97122 function lanAddress(): string | null {98123 try {99124 for (const iface of Deno.networkInterfaces()) {@@ -111,17 +136,34 @@ async function cmdInit(args: Args) {
111136 const storage: StorageConfig = args.memory112137 ? { type: "memory" }113138 : { type: "sqlite", path: args.storagePath };139+ if (args.noRelay && args.relayUrls.length > 0) {140+ fail("--no-relay and --relay are mutually exclusive");141+ }142+ const relay: RelayConfig | undefined = args.noRelay143+ ? "disabled"144+ : args.relayUrls.length > 0145+ ? { urls: args.relayUrls }146+ : undefined;147+ if (relay) {148+ try {149+ validateRelay(relay);150+ } catch (e) {151+ fail((e as Error).message);152+ }153+ }114154 const config = await initConfig(args.dir, {115155 appId: args.appId,116156 listenPort: args.port,117157 access: args.open ? "open" : "ticket",118158 storage,119159 publicUrl: args.publicUrl,160+ relay,120161 });121162 console.log(`initialized ${args.dir}`);122163 console.log(` app id: ${config.appId}`);123164 console.log(` access: ${config.access}`);124165 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);166+ console.log(` relay: ${describeRelay(config.relay)}`);125167 console.log(` listen: ${config.listenPort ?? "(auto)"}`);126168 if (config.access === "ticket") {127169 console.log(`\nNext: lofi-node start --dir ${args.dir}`);@@ -143,6 +185,7 @@ async function cmdStart(args: Args) {
143185 storage: config.storage,144186 publicUrl: config.publicUrl,145187 upstream: config.upstream,188+ relay: config.relay,146189 allowLocalFirstAuth: config.allowLocalFirstAuth,147190 });148191 const status = node.status();@@ -171,6 +214,7 @@ async function cmdStart(args: Args) {
171214 );172215 if (status.mesh.state === "up") {173216 console.log(` mesh: up (node ${status.mesh.nodeId.slice(0, 16)}…)`);217+ console.log(` relay: ${describeRelay(config.relay)}`);174218 console.log(` ticket: ${status.mesh.ticket}`);175219 } else if (status.mesh.state === "unavailable") {176220 console.log(` mesh: UNAVAILABLE — ${status.mesh.reason}`);@@ -287,6 +331,7 @@ async function cmdStatus(args: Args) {
287331 console.log(` app id: ${config.appId}`);288332 console.log(` access: ${config.access}`);289333 console.log(` storage: ${describeStorage(config.storage, args.dir)}`);334+ console.log(` relay: ${describeRelay(config.relay)}`);290335 console.log(` listen: ${config.listenPort ?? "(auto)"}`);291336 console.log(292337 ` tickets: ${tickets.filter((t) => !t.revokedAt).length} active / ${tickets.length} issued`,@@ -33,8 +33,14 @@ export type {
3333 SyncNodeOptions,3434 SyncNodeStatus,3535 } from "./src/node.ts";36-export { initConfig, loadConfig, saveConfig, validateStorage } from "./src/config.ts";37-export type { NodeConfig, StorageConfig, UpstreamConfig } from "./src/config.ts";36+export {37+ initConfig,38+ loadConfig,39+ saveConfig,40+ validateRelay,41+ validateStorage,42+} from "./src/config.ts";43+export type { NodeConfig, RelayConfig, StorageConfig, UpstreamConfig } from "./src/config.ts";3844 export { looksLikeTicket } from "./src/ticket.ts";3945 export { decodeAppTicket, encodeAppTicket, looksLikeAppTicket } from "./src/appticket.ts";4046 export type { AppTicket } from "./src/appticket.ts";@@ -12,6 +12,16 @@ export type UpstreamConfig = "none" | { url: string } | { peer: string };
1212 * any mounted location (NAS, synced volume) — validated writable at boot. */1313 export type StorageConfig = { type: "sqlite"; path?: string } | { type: "memory" };1414 15+/** Which relay infrastructure the node's iroh endpoint uses. Relays assist16+ * holepunching (address discovery) and carry traffic only when a direct17+ * connection cannot be established. "n0": the public n0-computer relays —18+ * rate-limited, no SLA, blessed for development and testing. { urls }:19+ * operator-run or dedicated relay servers (iroh-relay); the chosen relay20+ * travels inside this node's pairing tickets, so peers need no matching21+ * config. "disabled": no relays at all — direct connections only, which22+ * also forgoes relay-assisted address discovery. */23+export type RelayConfig = "n0" | "disabled" | { urls: string[] };24+1525 /** Persisted daemon configuration (`<dataDir>/config.json`, version 2 —1626 * v1 files migrate lazily on load). */1727 export interface NodeConfig {@@ -32,6 +42,8 @@ export interface NodeConfig {
3242 /** Base URL embedded into issued tickets, e.g. "http://192.168.1.10:4802". */3343 publicUrl?: string;3444 upstream: UpstreamConfig;45+ /** Relay election; absent means "n0" (the public relays). */46+ relay?: RelayConfig;3547 allowLocalFirstAuth: boolean;3648 }3749 @@ -50,6 +62,28 @@ export function validateStorage(storage: StorageConfig): void {
5062 }5163 }5264 65+/** Throws with the offending value when a relay election is malformed:66+ * custom mode needs at least one parseable http(s) relay URL. */67+export function validateRelay(relay: RelayConfig): void {68+ if (relay === "n0" || relay === "disabled") return;69+ if (!Array.isArray(relay.urls) || relay.urls.length === 0) {70+ throw new Error('relay { urls } needs at least one relay URL (or use "n0" | "disabled")');71+ }72+ for (const url of relay.urls) {73+ let parsed: URL;74+ try {75+ parsed = new URL(url);76+ } catch {77+ throw new Error(78+ `relay URL "${url}" does not parse — expected e.g. https://relay.example.com`,79+ );80+ }81+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {82+ throw new Error(`relay URL "${url}" must be http(s), got ${parsed.protocol}`);83+ }84+ }85+}86+5387 function randomToken(prefix: string): string {5488 const bytes = crypto.getRandomValues(new Uint8Array(24));5589 const b64 = btoa(String.fromCharCode(...bytes)).replaceAll("+", "-").replaceAll("/", "_")@@ -95,6 +129,7 @@ export async function loadConfig(dataDir: string): Promise<NodeConfig | null> {
95129 }96130 const config = parsed as NodeConfig;97131 validateStorage(config.storage);132+ if (config.relay !== undefined) validateRelay(config.relay);98133 return config;99134 } catch (e) {100135 if (e instanceof Deno.errors.NotFound) return null;@@ -113,7 +148,10 @@ export async function saveConfig(dataDir: string, config: NodeConfig): Promise<v
113148 export async function initConfig(114149 dataDir: string,115150 overrides: Partial<116- Pick<NodeConfig, "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl">151+ Pick<152+ NodeConfig,153+ "appId" | "listenPort" | "upstream" | "access" | "storage" | "publicUrl" | "relay"154+ >117155 > = {},118156 ): Promise<NodeConfig> {119157 const existing = await loadConfig(dataDir);@@ -129,9 +167,11 @@ export async function initConfig(
129167 storage: overrides.storage ?? { type: "sqlite" },130168 publicUrl: overrides.publicUrl,131169 upstream: overrides.upstream ?? "none",170+ relay: overrides.relay,132171 allowLocalFirstAuth: true,133172 };134173 validateStorage(config.storage);174+ if (config.relay !== undefined) validateRelay(config.relay);135175 await saveConfig(dataDir, config);136176 return config;137177 }@@ -10,6 +10,7 @@
1010 // the endpoint closes — the acceptor loop's clean exit (proven at gate 0).1111 1212 import type { BiStream, Connection, Endpoint, IrohAddon } from "../native/addon.ts";13+import type { RelayConfig } from "../config.ts";1314 1415 const ALPN = [...new TextEncoder().encode("lofi-node/0")];1516 @@ -104,10 +105,21 @@ export class IrohNode {
104105 this.#endpoint = endpoint;105106 }106107 107- static async open(addon: IrohAddon, secretKey: Uint8Array): Promise<IrohNode> {108+ static async open(109+ addon: IrohAddon,110+ secretKey: Uint8Array,111+ relay: RelayConfig = "n0",112+ ): Promise<IrohNode> {108113 if (secretKey.length !== 32) throw new Error("iroh secret key must be 32 bytes");109114 const builder = addon.Endpoint.builder();110- builder.applyN0();115+ // Every mode starts from the n0 preset for its discovery + crypto116+ // provider; the relay election then narrows the relay map.117+ if (relay === "disabled") {118+ builder.applyN0DisableRelay();119+ } else {120+ builder.applyN0();121+ if (relay !== "n0") builder.relayMode(addon.RelayMode.customFromUrls(relay.urls));122+ }111123 builder.secretKey([...secretKey]);112124 builder.alpns([ALPN]);113125 return new IrohNode(addon, await builder.bind());@@ -81,9 +81,15 @@ export interface Endpoint {
8181 isClosed(): boolean;8282 }8383 84+/** Opaque relay election handle (constructed via the RelayMode factories). */85+export interface RelayMode {86+ toString(): string;87+}88+8489 export interface EndpointBuilder {8590 applyN0(): void;8691 applyN0DisableRelay(): void;92+ relayMode(mode: RelayMode): void;8793 secretKey(bytes: number[]): void;8894 alpns(alpns: number[][]): void;8995 bind(): Promise<Endpoint>;@@ -95,6 +101,10 @@ export interface IrohAddon {
95101 fromAddr(addr: EndpointAddr): EndpointTicket;96102 fromString(s: string): EndpointTicket;97103 };104+ RelayMode: {105+ disabled(): RelayMode;106+ customFromUrls(urls: string[]): RelayMode;107+ };98108 SecretKey: { fromBytes(bytes: number[]): SecretKey; generate(): SecretKey };99109 // lofi_ext100110 writeFrame(stream: SendStream, data: Uint8Array): Promise<void>;@@ -14,8 +14,10 @@ import {
1414 import { type JazzHandle, startJazz } from "./jazz.ts";1515 import {1616 loadOrCreateIrohKey,17+ type RelayConfig,1718 type StorageConfig,1819 type UpstreamConfig,20+ validateRelay,1921 validateStorage,2022 } from "./config.ts";2123 import { type Gate, startGate } from "./gate.ts";@@ -50,6 +52,12 @@ export interface SyncNodeOptions {
5052 /** "auto": bring up iroh if the dylib resolves; stay up without it unless a5153 * peer upstream NEEDS it. "off": plain Jazz server, dylib never loaded. */5254 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;5361 allowLocalFirstAuth?: boolean;5462 /** Explicit addon path (otherwise LOFI_NODE_IROH / in-repo build). */5563 irohLibPath?: string;@@ -143,6 +151,8 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
143151 let upstream: UpstreamConfig = options.upstream ?? "none";144152 const meshMode = options.mesh ?? "auto";145153 const access = options.access ?? "open";154+ const relay: RelayConfig = options.relay ?? "n0";155+ validateRelay(relay);146156 147157 // 0. Storage election. Back-compat: explicit storage wins, else today's148158 // rules (inMemory flag / absent dataDir → memory).@@ -174,7 +184,7 @@ export async function createSyncNode(options: SyncNodeOptions): Promise<SyncNode
174184 const key = options.dataDir175185 ? await loadOrCreateIrohKey(options.dataDir)176186 : crypto.getRandomValues(new Uint8Array(32));177- irohNode = await IrohNode.open(addon, key);187+ irohNode = await IrohNode.open(addon, key, relay);178188 ticketString = await irohNode.ticket();179189 mesh = { state: "up", nodeId: irohNode.idString(), ticket: ticketString };180190 } catch (e) {@@ -0,0 +1,125 @@
1+// Relay election: persisted in config.json, validated at load and node2+// start, and applied to the iroh endpoint (disabled relays still connect3+// directly on the local network; custom relay maps bind cleanly).4+5+import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert";6+import { initConfig, loadConfig, validateRelay } from "../src/config.ts";7+import { createSyncNode } from "../src/node.ts";8+import { loadIrohAddon } from "../src/native/addon.ts";9+import { IrohNode } from "../src/iroh/node.ts";10+import { resolveIrohLib } from "../src/native/loader.ts";11+12+const available = resolveIrohLib().status === "ok";13+14+Deno.test("relay: absent from new configs (n0 default) and round-trips when set", async () => {15+ const dir = await Deno.makeTempDir();16+ try {17+ const config = await initConfig(dir, {18+ relay: { urls: ["https://relay.example.com", "https://relay2.example.com"] },19+ });20+ assertEquals(config.relay, {21+ urls: ["https://relay.example.com", "https://relay2.example.com"],22+ });23+ const loaded = await loadConfig(dir);24+ assertEquals(loaded?.relay, {25+ urls: ["https://relay.example.com", "https://relay2.example.com"],26+ });27+ } finally {28+ await Deno.remove(dir, { recursive: true });29+ }30+ const plain = await Deno.makeTempDir();31+ try {32+ const config = await initConfig(plain, {});33+ assertEquals(config.relay, undefined);34+ } finally {35+ await Deno.remove(plain, { recursive: true });36+ }37+});38+39+Deno.test("relay: malformed elections are rejected with the offending value", () => {40+ validateRelay("n0");41+ validateRelay("disabled");42+ validateRelay({ urls: ["http://localhost:3340"] });43+ assertThrows(() => validateRelay({ urls: [] }), Error, "at least one relay URL");44+ assertThrows(() => validateRelay({ urls: ["not a url"] }), Error, '"not a url"');45+ assertThrows(() => validateRelay({ urls: ["ftp://relay.example.com"] }), Error, "http(s)");46+});47+48+Deno.test("relay: a config.json with a bad relay fails at load, not at dial time", async () => {49+ const dir = await Deno.makeTempDir();50+ try {51+ const config = await initConfig(dir, {});52+ await Deno.writeTextFile(53+ `${dir}/config.json`,54+ JSON.stringify({ ...config, relay: { urls: [] } }),55+ );56+ await assertRejects(() => loadConfig(dir), Error, "at least one relay URL");57+ } finally {58+ await Deno.remove(dir, { recursive: true });59+ }60+});61+62+Deno.test({63+ name: "relay: createSyncNode rejects a bad election before any boot work",64+ sanitizeOps: false,65+ sanitizeResources: false,66+ fn: async () => {67+ await assertRejects(68+ () =>69+ createSyncNode({70+ appId: crypto.randomUUID(),71+ backendSecret: "b",72+ adminSecret: "a",73+ mesh: "off",74+ relay: { urls: [] },75+ }),76+ Error,77+ "at least one relay URL",78+ );79+ },80+});81+82+Deno.test({83+ name: "relay: disabled endpoints still pair over direct addresses",84+ ignore: !available,85+ sanitizeOps: false,86+ sanitizeResources: false,87+ fn: async () => {88+ const addon = await loadIrohAddon();89+ const a = await IrohNode.open(addon, crypto.getRandomValues(new Uint8Array(32)), "disabled");90+ const b = await IrohNode.open(addon, crypto.getRandomValues(new Uint8Array(32)), "disabled");91+ try {92+ const ticket = await a.ticket();93+ const acceptPromise = a.accept();94+ const dialed = await b.connect(ticket);95+ const accepted = await acceptPromise;96+ assert(accepted !== null, "acceptor saw the inbound connection");97+ dialed.sendMsg(new TextEncoder().encode("direct only"));98+ const got = await accepted.recvMsg();99+ assertEquals(new TextDecoder().decode(got!), "direct only");100+ await dialed.close();101+ await accepted.close();102+ } finally {103+ await a.close();104+ await b.close();105+ }106+ },107+});108+109+Deno.test({110+ name: "relay: a custom relay map binds cleanly (no dial attempt at boot)",111+ ignore: !available,112+ sanitizeOps: false,113+ sanitizeResources: false,114+ fn: async () => {115+ const addon = await loadIrohAddon();116+ const node = await IrohNode.open(addon, crypto.getRandomValues(new Uint8Array(32)), {117+ urls: ["https://relay.invalid.example"],118+ });119+ try {120+ assert(node.idString().length > 0);121+ } finally {122+ await node.close();123+ }124+ },125+});