fix: support IPv6 daemon bind addresses (#70)

Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
This commit is contained in:
redshift
2026-08-15 15:07:04 +01:00
committed by GitHub
co-authored by redshift
parent 553ac95618
commit a662a99188
4 changed files with 190 additions and 49 deletions
+2 -2
View File
@@ -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=="],
+18 -14
View File
@@ -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<boolean> {
async function healthyDaemonHost(
port: string,
host = "127.0.0.1",
): Promise<string | null> {
// 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://${host}:${port}/health`, {
const existing = await fetch(`http://${candidate}:${port}/health`, {
signal: controller.signal,
});
return existing.ok;
if (existing.ok) return candidate;
} catch {
return false;
// 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<void> {
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;
}
+91 -15
View File
@@ -29,12 +29,38 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
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, {
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: {
...(authorization ? { Authorization: authorization } : {}),
...(bodyString ? { "Content-Type": "application/json" } : {}),
},
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<CommandResponse>;
}
async function callLocalDaemon(
path: string,
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object },
config: RoutstrdConfig,
): Promise<CommandResponse> {
// 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<CommandResponse> {
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,22 +154,25 @@ export async function callAuth(
options: { method?: "GET" | "POST" | "PATCH" | "DELETE"; body?: object } = {},
): Promise<CommandResponse> {
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<boolean> {
try {
const config = await loadConfig();
const baseUrl = getDaemonBaseUrl(config);
const url = `${baseUrl}/health`;
if (config.daemonUrl) {
const baseUrl = config.daemonUrl.replace(/\/$/, "");
const url = `${baseUrl}/health`;
let authorization: string | undefined;
if (config.daemonUrl && config.nsec) {
if (config.nsec) {
const secretKey = parseSecretKey(config.nsec);
authorization = await createNIP98Authorization(secretKey, url, "GET");
}
try {
const response = await fetch(url, {
headers: authorization ? { Authorization: authorization } : {},
});
@@ -127,6 +182,27 @@ export async function isDaemonRunning(): Promise<boolean> {
}
}
// 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;
}
}
export function getUserNpub(config: RoutstrdConfig): string | null {
if (!config.nsec) return null;
try {
+61
View File
@@ -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");
});
});