diff --git a/README.md b/README.md index 094f30a..9433e01 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,16 @@ With custom port: routstrd start --port 9000 ``` +With a specific bind address (default is `127.0.0.1` for security): +```sh +routstrd start --host 0.0.0.0 +``` + +> ⚠️ **Security note:** By default, routstrd binds to `127.0.0.1` (localhost only). +> Several endpoints (e.g. `/balance`, `/status`, `/providers`) do not require +> authentication and will leak sensitive information if exposed. Only bind to +> `0.0.0.0` if you have a firewall or reverse proxy in place. + With specific provider: ```sh routstrd start --provider https://your-provider.com @@ -139,6 +149,7 @@ Configuration is stored in `~/.routstrd/config.json`: ```json { "port": 8008, + "host": "127.0.0.1", "provider": null, "cocodPath": null } @@ -149,6 +160,7 @@ Configuration is stored in `~/.routstrd/config.json`: - `ROUTSTRD_DIR` - Config directory (default: `~/.routstrd`) - `ROUTSTRD_SOCKET` - Socket path (default: `~/.routstrd/routstrd.sock`) - `ROUTSTRD_PID` - PID file path (default: `~/.routstrd/routstrd.pid`) +- `ROUTSTRD_HOST` - Bind address override (default: `127.0.0.1`) ## Development diff --git a/src/cli.ts b/src/cli.ts index ec5da58..a2f85d2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -168,6 +168,7 @@ async function restartDaemonsAfterUpdate(): Promise { console.log("Starting routstrd daemon..."); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); console.log("routstrd daemon restarted."); @@ -238,7 +239,7 @@ async function initDaemon(): Promise { console.log(`Database will be stored at: ${DB_PATH}`); initializeWallet(); - await startDaemon({ port: String(config.port || 8008) }); + await startDaemon({ port: String(config.port || 8008), host: config.host || undefined }); await setupIntegration(config); @@ -497,8 +498,9 @@ program .command("start") .description("Start the background daemon") .option("--port ", "Port to listen on") + .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") - .action(async (options: { port?: string; provider?: string }) => { + .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); // Stop a legacy cocod daemon (from older routstrd versions) before @@ -506,6 +508,7 @@ program await stopLegacyCocod(); await startDaemon({ port: options.port || String(config.port || 8008), + host: options.host || config.host || undefined, provider: options.provider, }); }); @@ -1793,8 +1796,9 @@ program .command("restart") .description("Restart the background daemon") .option("--port ", "Port to listen on") + .option("--host ", "Bind address (default: 127.0.0.1)") .option("-p, --provider ", "Default provider to use") - .action(async (options: { port?: string; provider?: string }) => { + .action(async (options: { port?: string; host?: string; provider?: string }) => { await requireLocalDaemon(); const config = await loadConfig(); const wasRunning = await isDaemonRunning(); @@ -1832,6 +1836,7 @@ program console.log("Starting daemon..."); await startDaemon({ port: options.port || String(config.port || 8008), + host: options.host || config.host || undefined, provider: options.provider, }); console.log("Daemon restarted."); @@ -1914,6 +1919,7 @@ program console.log("Starting daemon..."); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); console.log(`Daemon restarted with mode '${selectedMode}'.`); diff --git a/src/daemon/args.ts b/src/daemon/args.ts index 659f35d..77226f6 100644 --- a/src/daemon/args.ts +++ b/src/daemon/args.ts @@ -1,8 +1,10 @@ export function parseArgs(argv: string[]): { port: number; + host: string | null; provider: string | null; } { const portFlagIndex = argv.findIndex((arg) => arg === "--port"); + const hostFlagIndex = argv.findIndex((arg) => arg === "--host"); const providerFlagIndex = argv.findIndex( (arg) => arg === "--provider" || arg === "-p", ); @@ -11,9 +13,15 @@ export function parseArgs(argv: string[]): { portFlagIndex !== -1 ? Number.parseInt(argv[portFlagIndex + 1] || "8008", 10) : 8008; + + // --host flag takes precedence, then ROUTSTRD_HOST env var + const hostValue = + hostFlagIndex !== -1 ? argv[hostFlagIndex + 1] : undefined; + const host = hostValue ? hostValue.trim() : (process.env.ROUTSTRD_HOST || null); + const providerValue = providerFlagIndex !== -1 ? argv[providerFlagIndex + 1] : undefined; const provider = providerValue ? providerValue.trim() : null; - return { port, provider }; + return { port, host, provider }; } diff --git a/src/daemon/index.ts b/src/daemon/index.ts index 713f204..02470bc 100644 --- a/src/daemon/index.ts +++ b/src/daemon/index.ts @@ -65,6 +65,7 @@ async function main(): Promise { const config = await loadDaemonConfig(); const port = args.port; + const host = args.host || config.host || "127.0.0.1"; const provider = args.provider || config.provider; const requestResponseLogDir = process.env.ROUTSTRD_REQUEST_RESPONSE_LOG_DIR || @@ -80,7 +81,7 @@ async function main(): Promise { await ensureDirs(); - const updatedConfig = { ...config, port, provider }; + const updatedConfig = { ...config, port, host, provider }; saveDaemonConfig(updatedConfig); const sqliteDriver = await createBunSqliteDriver(DB_PATH, { logger: daemonSdkLogger }); @@ -304,8 +305,21 @@ async function main(): Promise { process.once("SIGINT", shutdownForSignal); process.once("SIGTERM", shutdownForSignal); - server.listen(port, async () => { - logger.log(`Routstr daemon listening on http://localhost:${port}/v1`); + // Warn when binding to all interfaces — unauthenticated endpoints expose + // balance info, provider lists, and internal state to anyone who can reach + // the port. + if (host === "0.0.0.0") { + logger.warn( + "⚠️ WARNING: Daemon is bound to 0.0.0.0 (all network interfaces). " + + "Several endpoints (e.g. /balance, /status, /providers) do not require " + + "authentication and will leak sensitive information to anyone on the " + + "network. Consider binding to 127.0.0.1 unless you have a firewall or " + + "reverse proxy in place.", + ); + } + + server.listen(port, host, async () => { + logger.log(`Routstr daemon listening on http://${host}:${port}/v1`); if (requestResponseLogDir) { logger.log(`Raw request/response logs: ${requestResponseLogDir}`); } diff --git a/src/start-daemon.ts b/src/start-daemon.ts index b20d150..2bfcf46 100644 --- a/src/start-daemon.ts +++ b/src/start-daemon.ts @@ -27,11 +27,11 @@ function readDaemonOutput(offset: number): string { } } -async function isDaemonHealthy(port: string): Promise { +async function isDaemonHealthy(port: string, host: string = "localhost"): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), 2000); try { - const existing = await fetch(`http://localhost:${port}/health`, { + const existing = await fetch(`http://${host}:${port}/health`, { signal: controller.signal, }); return existing.ok; @@ -42,22 +42,34 @@ async function isDaemonHealthy(port: string): Promise { } } +/** When the daemon binds to 0.0.0.0, the CLI must still connect via + * localhost (or 127.0.0.1) since 0.0.0.0 is not a connectable address. */ +function clientHost(host: string | undefined): string { + if (!host || host === "0.0.0.0") return "localhost"; + return host; +} + async function startDaemonUnlocked( - options: { port?: string; provider?: string }, + options: { port?: string; host?: string; provider?: string }, ): Promise { 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)) { - console.log(`Routstr daemon already running on http://localhost:${port}/v1`); + if (await isDaemonHealthy(port, ch)) { + console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); return; } if (options.port) { args.push("--port", options.port); } + if (options.host) { + args.push("--host", options.host); + } if (options.provider) { args.push("--provider", options.provider); } @@ -98,7 +110,7 @@ async function startDaemonUnlocked( ); } - if (await isDaemonHealthy(port)) { + if (await isDaemonHealthy(port, ch)) { console.log(`Routstr daemon started (PID: ${proc.pid}).`); return; } @@ -110,13 +122,15 @@ async function startDaemonUnlocked( } export async function startDaemon( - options: { port?: string; provider?: string } = {}, + options: { port?: string; host?: string; provider?: string } = {}, ): 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)) { - console.log(`Routstr daemon already running on http://localhost:${port}/v1`); + if (await isDaemonHealthy(port, ch)) { + console.log(`Routstr daemon already running on http://${ch}:${port}/v1`); return; } diff --git a/src/utils/config.ts b/src/utils/config.ts index 202b2ca..8e160bf 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -32,6 +32,9 @@ export interface NwcConfig { export interface RoutstrdConfig { port: number; + /** Bind address for the HTTP server. Defaults to 127.0.0.1 (localhost only) + * for security — set to 0.0.0.0 to listen on all interfaces. */ + host: string; provider: string | null; cocodPath: string | null; mode?: "xcashu" | "apikeys"; @@ -58,6 +61,7 @@ export interface RoutstrdConfig { export const DEFAULT_CONFIG: RoutstrdConfig = { port: 8008, + host: "127.0.0.1", provider: null, cocodPath: null, mode: "apikeys", diff --git a/src/utils/daemon-client.ts b/src/utils/daemon-client.ts index 1086072..f3177a3 100644 --- a/src/utils/daemon-client.ts +++ b/src/utils/daemon-client.ts @@ -30,9 +30,16 @@ export async function loadConfig(): Promise { } export function getDaemonBaseUrl(config: RoutstrdConfig): string { - return ( - config.daemonUrl?.replace(/\/$/, "") || `http://localhost:${config.port}` - ); + if (config.daemonUrl) { + return config.daemonUrl.replace(/\/$/, ""); + } + // When bound to 0.0.0.0, connect via localhost since 0.0.0.0 is not + // a connectable address from a client perspective. + const host = + !config.host || config.host === "0.0.0.0" + ? "localhost" + : config.host; + return `http://${host}:${config.port}`; } export function getAuthBaseUrl(config: RoutstrdConfig): string { @@ -145,6 +152,7 @@ export async function startDaemonProcess(): Promise { const config = await loadConfig(); await startDaemon({ port: String(config.port || 8008), + host: config.host || undefined, provider: config.provider || undefined, }); }