fix: bind to 127.0.0.1 by default instead of 0.0.0.0

The daemon was binding to all network interfaces (0.0.0.0) by default
because server.listen(port) without a host argument listens on all
interfaces in Node.js. This exposed unauthenticated endpoints
(/balance, /status, /providers, /wallet/balance, etc.) to anyone
who could reach the port.

Changes:
- Add 'host' field to RoutstrdConfig (default: 127.0.0.1)
- Add --host CLI flag to 'start' and 'restart' commands
- Add ROUTSTRD_HOST env var support in parseArgs
- Pass host to server.listen(port, host, callback)
- Add startup warning when bound to 0.0.0.0
- Update getDaemonBaseUrl to use config.host (with 0.0.0.0 -> localhost
  fallback for client connections since 0.0.0.0 is not connectable)
- Update start-daemon.ts health checks to use the correct host
- Pass host through all startDaemon() call sites in cli.ts
- Document --host flag, config field, and security implications in README

Closes ROUTSTRD-BIND-ADDRESS
This commit is contained in:
redshift
2026-07-25 18:58:05 +01:00
parent 154f36d8d2
commit df226dfa90
7 changed files with 85 additions and 19 deletions
+12
View File
@@ -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
+9 -3
View File
@@ -168,6 +168,7 @@ async function restartDaemonsAfterUpdate(): Promise<void> {
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<void> {
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>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <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>", "Port to listen on")
.option("--host <host>", "Bind address (default: 127.0.0.1)")
.option("-p, --provider <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}'.`);
+9 -1
View File
@@ -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 };
}
+17 -3
View File
@@ -65,6 +65,7 @@ async function main(): Promise<void> {
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<void> {
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<void> {
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}`);
}
+23 -9
View File
@@ -27,11 +27,11 @@ function readDaemonOutput(offset: number): string {
}
}
async function isDaemonHealthy(port: string): Promise<boolean> {
async function isDaemonHealthy(port: string, host: string = "localhost"): Promise<boolean> {
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<boolean> {
}
}
/** 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<void> {
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<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)) {
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;
}
+4
View File
@@ -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",
+11 -3
View File
@@ -30,9 +30,16 @@ export async function loadConfig(): Promise<RoutstrdConfig> {
}
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<void> {
const config = await loadConfig();
await startDaemon({
port: String(config.port || 8008),
host: config.host || undefined,
provider: config.provider || undefined,
});
}