mirror of
https://github.com/Routstr/routstrd.git
synced 2026-10-05 12:28:23 +00:00
fix: support IPv6 daemon bind addresses (#70)
Co-authored-by: redshift <213178690+1ftredsh@users.noreply.github.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user