From a662a991885995ca010841b103394785d26a055d Mon Sep 17 00:00:00 2001 From: redshift <213178690+sh1ftred@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:07:04 +0100 Subject: [PATCH] fix: support IPv6 daemon bind addresses (#70) Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com> --- bun.lock | 4 +- src/start-daemon.ts | 50 ++++++------ src/utils/daemon-client.ts | 124 ++++++++++++++++++++++++------ tests/utils/daemon-client.test.ts | 61 +++++++++++++++ 4 files changed, 190 insertions(+), 49 deletions(-) create mode 100644 tests/utils/daemon-client.test.ts diff --git a/bun.lock b/bun.lock index 52ef082..a6f4afe 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@cashu/cashu-ts": "^4.3.0", "@cashu/coco-core": "^1.0.1", "@cashu/coco-sqlite-bun": "^1.0.1", - "@routstr/sdk": "^0.3.19", + "@routstr/sdk": "^0.3.20", "@scure/bip39": "^2.2.0", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", @@ -98,7 +98,7 @@ "@panva/hpke-noble": ["@panva/hpke-noble@1.1.3", "", { "dependencies": { "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@noble/post-quantum": "^0.6.1" }, "peerDependencies": { "hpke": "^1.0.0" } }, "sha512-zPG7MR9x7QE7+KdYsKBO9H0vp3AdYt9/4AT3ab7T7W6SL0fdRqhgNRu8q4OGTJNLeKpdbkkRb6LhBDaA9+9xWQ=="], - "@routstr/sdk": ["@routstr/sdk@0.3.19", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-48HsKUxzINDRXohFC55ud/x7gmz6rkUB15LZEOgoiLyIkD1L7nfTcwY2B91c1nhrhWVg0sP0W3wYXIMidmaUHg=="], + "@routstr/sdk": ["@routstr/sdk@0.3.20", "", { "dependencies": { "@cashu/cashu-ts": "^3.1.1", "applesauce-core": "^5.1.0", "applesauce-relay": "^5.1.0", "applesauce-sqlite": "^6.0.0", "ehbp": "^0.2.3", "rxjs": "^7.8.1", "tinfoil": "^1.1.6", "zustand": "^5.0.5" }, "optionalDependencies": { "better-sqlite3": "^12.10.0" }, "peerDependencies": { "typescript": ">=5.0.0" } }, "sha512-R7BjOT4LNZYk0ypebWKKm9CZD9PP66OvuhAZ7ABiCFcc8F0iynVFMTcz+mZMZldKO5RY+h+OqWa5L1u//B2aqQ=="], "@scure/base": ["@scure/base@2.2.0", "", {}, "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg=="], diff --git a/src/start-daemon.ts b/src/start-daemon.ts index a9c58a5..d6d636a 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -8,6 +8,7 @@ import { import { logger } from "./utils/logger"; import { CONFIG_DIR, LOGS_DIR } from "./utils/config"; import { withCrossProcessLock } from "./utils/process-lock"; +import { urlHosts } from "./utils/daemon-client"; import { fileURLToPath } from "url"; const DAEMON_STARTUP_LOCK_PATH = `${CONFIG_DIR}/routstrd-startup.lock`; @@ -103,23 +104,26 @@ function fileSize(path: string): number { } } -async function isDaemonHealthy(port: string, host = "127.0.0.1"): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 2000); - try { - const existing = await fetch(`http://${host}:${port}/health`, { - signal: controller.signal, - }); - return existing.ok; - } catch { - return false; - } finally { - clearTimeout(timeoutId); +async function healthyDaemonHost( + port: string, + host = "127.0.0.1", +): Promise { + // Return the responding candidate so status output shows a connectable URL. + for (const candidate of urlHosts(host)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + try { + const existing = await fetch(`http://${candidate}:${port}/health`, { + signal: controller.signal, + }); + if (existing.ok) return candidate; + } catch { + // Try the next candidate host. + } finally { + clearTimeout(timeoutId); + } } -} - -function clientHost(host?: string): string { - return !host || host === "0.0.0.0" ? "127.0.0.1" : host; + return null; } async function startDaemonUnlocked( @@ -128,12 +132,12 @@ async function startDaemonUnlocked( const args: string[] = []; const port = options.port || "8008"; const host = options.host || "127.0.0.1"; - const ch = clientHost(host); const pollIntervalMs = 250; const startupTimeoutMs = 10 * 60 * 1000; - if (await isDaemonHealthy(port, ch)) { - console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); + const existingHost = await healthyDaemonHost(port, host); + if (existingHost) { + console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`); return; } @@ -207,7 +211,7 @@ async function startDaemonUnlocked( ); } - if (await isDaemonHealthy(port, ch)) { + if (await healthyDaemonHost(port, host)) { printStartupProgress(progressLogOffset); console.log( `Routstr daemon started (PID: ${proc.pid}, ${formatElapsed(Date.now() - startedAt)}).`, @@ -226,11 +230,11 @@ export async function startDaemon( ): Promise { const port = options.port || "8008"; const host = options.host || "127.0.0.1"; - const ch = clientHost(host); const startupTimeoutMs = 10 * 60 * 1000; - if (await isDaemonHealthy(port, ch)) { - console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); + const existingHost = await healthyDaemonHost(port, host); + if (existingHost) { + console.log(`Routstr daemon already running on http://${existingHost}:${port}/v1`); return; } diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index ff27906..7ae13ef 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -29,12 +29,38 @@ export async function loadConfig(): Promise { return DEFAULT_CONFIG; } +/** Format a bind address for use as a URL host. */ +export function urlHost(host?: string): string { + return urlHosts(host)[0] ?? "127.0.0.1"; +} + +/** Return connectable URL hosts for a bind address, in preference order. */ +export function urlHosts(host?: string): string[] { + if (!host || host === "0.0.0.0") return ["127.0.0.1", "[::1]"]; + if (host === "::") return ["[::1]", "127.0.0.1"]; + if (host.startsWith("[") && host.endsWith("]")) return [host]; + if (host.includes(":")) return [`[${host.replace(/%/g, "%25")}]`]; + return [host]; +} + +function localDaemonBaseUrls(config: RoutstrdConfig): string[] { + return urlHosts(config.host).map( + (host) => `http://${host}:${config.port}`, + ); +} + +class DaemonConnectionError extends Error { + constructor(cause: unknown) { + super("Failed to connect to daemon", { cause }); + this.name = "DaemonConnectionError"; + } +} + export function getDaemonBaseUrl(config: RoutstrdConfig): string { if (config.daemonUrl) { return config.daemonUrl.replace(/\/$/, ""); } - const host = config.host === "0.0.0.0" ? "127.0.0.1" : config.host; - return `http://${host}:${config.port}`; + return `http://${urlHost(config.host)}:${config.port}`; } export function getAuthBaseUrl(config: RoutstrdConfig): string { @@ -69,14 +95,20 @@ async function _callUrl( ); } - const response = await fetch(url, { - method, - headers: { - ...(authorization ? { Authorization: authorization } : {}), - ...(bodyString ? { "Content-Type": "application/json" } : {}), - }, - body: bodyString, - }); + const headers = new Headers(); + if (authorization) headers.set("Authorization", authorization); + if (bodyString) headers.set("Content-Type", "application/json"); + + let response: Response; + try { + response = await fetch(url, { + method, + headers, + body: bodyString, + }); + } catch (error) { + throw new DaemonConnectionError(error); + } if (!response.ok) { const errorData = (await response.json()) as { error?: string }; @@ -86,13 +118,33 @@ async function _callUrl( return response.json() as Promise; } +async function callLocalDaemon( + path: string, + options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object }, + config: RoutstrdConfig, +): Promise { + // Retry only connection failures; HTTP errors prove that a server answered. + let connectionError: DaemonConnectionError | undefined; + for (const baseUrl of localDaemonBaseUrls(config)) { + try { + return await _callUrl(baseUrl, path, options, config); + } catch (error) { + if (!(error instanceof DaemonConnectionError)) throw error; + connectionError = error; + } + } + throw connectionError ?? new Error("No daemon host candidates available"); +} + export async function callDaemon( path: string, options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {}, ): Promise { const config = await loadConfig(); - const baseUrl = getDaemonBaseUrl(config); - return _callUrl(baseUrl, path, options, config); + if (config.daemonUrl) { + return _callUrl(getDaemonBaseUrl(config), path, options, config); + } + return callLocalDaemon(path, options, config); } /** Like callDaemon but sends requests to the auth proxy URL instead. @@ -102,26 +154,50 @@ export async function callAuth( options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {}, ): Promise { const config = await loadConfig(); - const baseUrl = getAuthBaseUrl(config); - return _callUrl(baseUrl, path, options, config); + if (!config.authUrl && !config.daemonUrl) { + return callLocalDaemon(path, options, config); + } + return _callUrl(getAuthBaseUrl(config), path, options, config); } export async function isDaemonRunning(): Promise { try { const config = await loadConfig(); - const baseUrl = getDaemonBaseUrl(config); - const url = `${baseUrl}/health`; - let authorization: string | undefined; - if (config.daemonUrl && config.nsec) { - const secretKey = parseSecretKey(config.nsec); - authorization = await createNIP98Authorization(secretKey, url, "GET"); + if (config.daemonUrl) { + const baseUrl = config.daemonUrl.replace(/\/$/, ""); + const url = `${baseUrl}/health`; + let authorization: string | undefined; + if (config.nsec) { + const secretKey = parseSecretKey(config.nsec); + authorization = await createNIP98Authorization(secretKey, url, "GET"); + } + try { + const response = await fetch(url, { + headers: authorization ? { Authorization: authorization } : {}, + }); + return response.ok; + } catch { + return false; + } } - const response = await fetch(url, { - headers: authorization ? { Authorization: authorization } : {}, - }); - return response.ok; + // A wildcard bind may be listening on either loopback family. + for (const baseUrl of localDaemonBaseUrls(config)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 2000); + try { + const response = await fetch(`${baseUrl}/health`, { + signal: controller.signal, + }); + if (response.ok) return true; + } catch { + // Try the next candidate host. + } finally { + clearTimeout(timeoutId); + } + } + return false; } catch { return false; } diff --git a/tests/utils/daemon-client.test.ts b/tests/utils/daemon-client.test.ts new file mode 100644 index 0000000..761b416 --- /dev/null +++ b/tests/utils/daemon-client.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test"; +import { + getDaemonBaseUrl, + urlHost, + urlHosts, +} from "../../src/utils/daemon-client"; +import type { RoutstrdConfig } from "../../src/utils/config"; + +function config(host: string): RoutstrdConfig { + return { + port: 8008, + host, + provider: null, + cocodPath: null, + }; +} + +describe("daemon URL host formatting", () => { + test("prefers IPv4 loopback for an IPv4 wildcard", () => { + expect(urlHosts("0.0.0.0")).toEqual(["127.0.0.1", "[::1]"]); + expect(getDaemonBaseUrl(config("0.0.0.0"))).toBe("http://127.0.0.1:8008"); + }); + + test("prefers IPv6 loopback for an IPv6 wildcard", () => { + expect(urlHosts("::")).toEqual(["[::1]", "127.0.0.1"]); + expect(getDaemonBaseUrl(config("::"))).toBe("http://[::1]:8008"); + }); + + test("brackets IPv6 literals", () => { + expect(urlHost("2001:db8::1")).toBe("[2001:db8::1]"); + expect(getDaemonBaseUrl(config("2001:db8::1"))).toBe( + "http://[2001:db8::1]:8008", + ); + }); + + test("does not bracket an already-bracketed IPv6 literal twice", () => { + expect(urlHost("[::1]")).toBe("[::1]"); + }); + + test("escapes an IPv6 zone identifier for use in a URL", () => { + expect(urlHost("fe80::1%en0")).toBe("[fe80::1%25en0]"); + }); + + test("leaves IPv4 addresses and hostnames unchanged", () => { + expect(urlHost("127.0.0.1")).toBe("127.0.0.1"); + expect(urlHost("localhost")).toBe("localhost"); + }); + + test("uses the default loopback when the host is absent", () => { + expect(urlHost()).toBe("127.0.0.1"); + }); + + test("preserves an explicitly configured daemon URL", () => { + expect( + getDaemonBaseUrl({ + ...config("::"), + daemonUrl: "https://daemon.example/", + }), + ).toBe("https://daemon.example"); + }); +});