From bac80f0886d79f8570ec58ed0d5a537bbbdb5296 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 25 Jan 2026 05:48:40 +0000 Subject: [PATCH] fix: listen on ipv6 loopback for gateway --- CHANGELOG.md | 1 + src/gateway/net.test.ts | 83 ++++++----------------------- src/gateway/net.ts | 22 +++++--- src/gateway/server-close.ts | 23 +++++--- src/gateway/server-runtime-state.ts | 63 +++++++++++++++------- src/gateway/server-startup-log.ts | 10 +++- src/gateway/server.impl.ts | 5 ++ 7 files changed, 107 insertions(+), 100 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d9630b5c..53bda7630 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Docs: https://docs.clawd.bot - Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690) +- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index d580d4bc2..46c426d63 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,77 +1,28 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { describe, expect, it } from "vitest"; -const testTailnetIPv4 = { value: undefined as string | undefined }; -const testTailnetIPv6 = { value: undefined as string | undefined }; +import { resolveGatewayListenHosts } from "./net.js"; -vi.mock("../infra/tailnet.js", () => ({ - pickPrimaryTailnetIPv4: () => testTailnetIPv4.value, - pickPrimaryTailnetIPv6: () => testTailnetIPv6.value, -})); - -import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js"; - -describe("gateway net", () => { - beforeEach(() => { - testTailnetIPv4.value = undefined; - testTailnetIPv6.value = undefined; - }); - - test("treats loopback as local", () => { - expect(isLocalGatewayAddress("127.0.0.1")).toBe(true); - expect(isLocalGatewayAddress("127.0.1.1")).toBe(true); - expect(isLocalGatewayAddress("::1")).toBe(true); - expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true); - }); - - test("treats local tailnet IPv4 as local", () => { - testTailnetIPv4.value = "100.64.0.1"; - expect(isLocalGatewayAddress("100.64.0.1")).toBe(true); - expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true); - }); - - test("ignores non-matching tailnet IPv4", () => { - testTailnetIPv4.value = "100.64.0.1"; - expect(isLocalGatewayAddress("100.64.0.2")).toBe(false); - }); - - test("treats local tailnet IPv6 as local", () => { - testTailnetIPv6.value = "fd7a:115c:a1e0::123"; - expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true); - }); - - test("uses forwarded-for when remote is a trusted proxy", () => { - const clientIp = resolveGatewayClientIp({ - remoteAddr: "10.0.0.2", - forwardedFor: "203.0.113.9, 10.0.0.2", - trustedProxies: ["10.0.0.2"], +describe("resolveGatewayListenHosts", () => { + it("returns the input host when not loopback", async () => { + const hosts = await resolveGatewayListenHosts("0.0.0.0", { + canBindToHost: async () => { + throw new Error("should not be called"); + }, }); - expect(clientIp).toBe("203.0.113.9"); + expect(hosts).toEqual(["0.0.0.0"]); }); - test("ignores forwarded-for from untrusted proxies", () => { - const clientIp = resolveGatewayClientIp({ - remoteAddr: "10.0.0.3", - forwardedFor: "203.0.113.9", - trustedProxies: ["10.0.0.2"], + it("adds ::1 when IPv6 loopback is available", async () => { + const hosts = await resolveGatewayListenHosts("127.0.0.1", { + canBindToHost: async () => true, }); - expect(clientIp).toBe("10.0.0.3"); + expect(hosts).toEqual(["127.0.0.1", "::1"]); }); - test("normalizes trusted proxy IPs and strips forwarded ports", () => { - const clientIp = resolveGatewayClientIp({ - remoteAddr: "::ffff:10.0.0.2", - forwardedFor: "203.0.113.9:1234", - trustedProxies: ["10.0.0.2"], + it("keeps only IPv4 loopback when IPv6 is unavailable", async () => { + const hosts = await resolveGatewayListenHosts("127.0.0.1", { + canBindToHost: async () => false, }); - expect(clientIp).toBe("203.0.113.9"); - }); - - test("falls back to x-real-ip when forwarded-for is missing", () => { - const clientIp = resolveGatewayClientIp({ - remoteAddr: "10.0.0.2", - realIp: "203.0.113.10", - trustedProxies: ["10.0.0.2"], - }); - expect(clientIp).toBe("203.0.113.10"); + expect(hosts).toEqual(["127.0.0.1"]); }); }); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 4c7394979..608ec872f 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -97,14 +97,14 @@ export async function resolveGatewayBindHost( if (mode === "loopback") { // 127.0.0.1 rarely fails, but handle gracefully - if (await canBindTo("127.0.0.1")) return "127.0.0.1"; + if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; return "0.0.0.0"; // extreme fallback } if (mode === "tailnet") { const tailnetIP = pickPrimaryTailnetIPv4(); - if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP; - if (await canBindTo("127.0.0.1")) return "127.0.0.1"; + if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP; + if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; return "0.0.0.0"; } @@ -116,13 +116,13 @@ export async function resolveGatewayBindHost( const host = customHost?.trim(); if (!host) return "0.0.0.0"; // invalid config → fall back to all - if (isValidIPv4(host) && (await canBindTo(host))) return host; + if (isValidIPv4(host) && (await canBindToHost(host))) return host; // Custom IP failed → fall back to LAN return "0.0.0.0"; } if (mode === "auto") { - if (await canBindTo("127.0.0.1")) return "127.0.0.1"; + if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; return "0.0.0.0"; } @@ -136,7 +136,7 @@ export async function resolveGatewayBindHost( * @param host - The host address to test * @returns True if we can successfully bind to this address */ -async function canBindTo(host: string): Promise { +export async function canBindToHost(host: string): Promise { return new Promise((resolve) => { const testServer = net.createServer(); testServer.once("error", () => { @@ -151,6 +151,16 @@ async function canBindTo(host: string): Promise { }); } +export async function resolveGatewayListenHosts( + bindHost: string, + opts?: { canBindToHost?: (host: string) => Promise }, +): Promise { + if (bindHost !== "127.0.0.1") return [bindHost]; + const canBind = opts?.canBindToHost ?? canBindToHost; + if (await canBind("::1")) return [bindHost, "::1"]; + return [bindHost]; +} + /** * Validate if a string is a valid IPv4 address. * diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 3bd40f118..da9f5a39e 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -28,6 +28,7 @@ export function createGatewayCloseHandler(params: { browserControl: { stop: () => Promise } | null; wss: WebSocketServer; httpServer: HttpServer; + httpServers?: HttpServer[]; }) { return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => { const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : ""; @@ -108,14 +109,20 @@ export function createGatewayCloseHandler(params: { await params.browserControl.stop().catch(() => {}); } await new Promise((resolve) => params.wss.close(() => resolve())); - const httpServer = params.httpServer as HttpServer & { - closeIdleConnections?: () => void; - }; - if (typeof httpServer.closeIdleConnections === "function") { - httpServer.closeIdleConnections(); + const servers = + params.httpServers && params.httpServers.length > 0 + ? params.httpServers + : [params.httpServer]; + for (const server of servers) { + const httpServer = server as HttpServer & { + closeIdleConnections?: () => void; + }; + if (typeof httpServer.closeIdleConnections === "function") { + httpServer.closeIdleConnections(); + } + await new Promise((resolve, reject) => + httpServer.close((err) => (err ? reject(err) : resolve())), + ); } - await new Promise((resolve, reject) => - params.httpServer.close((err) => (err ? reject(err) : resolve())), - ); }; } diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 788467518..6acd56ac0 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -10,6 +10,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js"; import type { HooksConfigResolved } from "./hooks.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { listenGatewayHttpServer } from "./server/http-listen.js"; +import { resolveGatewayListenHosts } from "./net.js"; import { createGatewayPluginRequestHandler } from "./server/plugins-http.js"; import type { GatewayWsClient } from "./server/ws-types.js"; import { createGatewayBroadcaster } from "./server-broadcast.js"; @@ -38,11 +39,14 @@ export async function createGatewayRuntimeState(params: { canvasHostEnabled: boolean; allowCanvasHostInTests?: boolean; logCanvas: { info: (msg: string) => void; warn: (msg: string) => void }; + log: { info: (msg: string) => void; warn: (msg: string) => void }; logHooks: ReturnType; logPlugins: ReturnType; }): Promise<{ canvasHost: CanvasHostHandler | null; httpServer: HttpServer; + httpServers: HttpServer[]; + httpBindHosts: string[]; wss: WebSocketServer; clients: Set; broadcast: ( @@ -100,30 +104,49 @@ export async function createGatewayRuntimeState(params: { log: params.logPlugins, }); - const httpServer = createGatewayHttpServer({ - canvasHost, - controlUiEnabled: params.controlUiEnabled, - controlUiBasePath: params.controlUiBasePath, - openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, - openResponsesEnabled: params.openResponsesEnabled, - openResponsesConfig: params.openResponsesConfig, - handleHooksRequest, - handlePluginRequest, - resolvedAuth: params.resolvedAuth, - tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, - }); - - await listenGatewayHttpServer({ - httpServer, - bindHost: params.bindHost, - port: params.port, - }); + const bindHosts = await resolveGatewayListenHosts(params.bindHost); + const httpServers: HttpServer[] = []; + const httpBindHosts: string[] = []; + for (const host of bindHosts) { + const httpServer = createGatewayHttpServer({ + canvasHost, + controlUiEnabled: params.controlUiEnabled, + controlUiBasePath: params.controlUiBasePath, + openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, + openResponsesEnabled: params.openResponsesEnabled, + openResponsesConfig: params.openResponsesConfig, + handleHooksRequest, + handlePluginRequest, + resolvedAuth: params.resolvedAuth, + tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, + }); + try { + await listenGatewayHttpServer({ + httpServer, + bindHost: host, + port: params.port, + }); + httpServers.push(httpServer); + httpBindHosts.push(host); + } catch (err) { + if (host === bindHosts[0]) throw err; + params.log.warn( + `gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`, + ); + } + } + const httpServer = httpServers[0]; + if (!httpServer) { + throw new Error("Gateway HTTP server failed to start"); + } const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_PAYLOAD_BYTES, }); - attachGatewayUpgradeHandler({ httpServer, wss, canvasHost }); + for (const server of httpServers) { + attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost }); + } const clients = new Set(); const { broadcast } = createGatewayBroadcaster({ clients }); @@ -140,6 +163,8 @@ export async function createGatewayRuntimeState(params: { return { canvasHost, httpServer, + httpServers, + httpBindHosts, wss, clients, broadcast, diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts index e8ee19573..cf6d2575c 100644 --- a/src/gateway/server-startup-log.ts +++ b/src/gateway/server-startup-log.ts @@ -7,6 +7,7 @@ import { getResolvedLoggerSettings } from "../logging.js"; export function logGatewayStartup(params: { cfg: ReturnType; bindHost: string; + bindHosts?: string[]; port: number; tlsEnabled?: boolean; log: { info: (msg: string, meta?: Record) => void }; @@ -22,9 +23,16 @@ export function logGatewayStartup(params: { consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`, }); const scheme = params.tlsEnabled ? "wss" : "ws"; + const formatHost = (host: string) => (host.includes(":") ? `[${host}]` : host); + const hosts = + params.bindHosts && params.bindHosts.length > 0 ? params.bindHosts : [params.bindHost]; + const primaryHost = hosts[0] ?? params.bindHost; params.log.info( - `listening on ${scheme}://${params.bindHost}:${params.port} (PID ${process.pid})`, + `listening on ${scheme}://${formatHost(primaryHost)}:${params.port} (PID ${process.pid})`, ); + for (const host of hosts.slice(1)) { + params.log.info(`listening on ${scheme}://${formatHost(host)}:${params.port}`); + } params.log.info(`log file: ${getResolvedLoggerSettings().file}`); if (params.isNixMode) { params.log.info("gateway: running in Nix mode (config managed externally)"); diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 6ab5d91d9..c89e0d699 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -263,6 +263,8 @@ export async function startGatewayServer( const { canvasHost, httpServer, + httpServers, + httpBindHosts, wss, clients, broadcast, @@ -292,6 +294,7 @@ export async function startGatewayServer( canvasHostEnabled, allowCanvasHostInTests: opts.allowCanvasHostInTests, logCanvas, + log, logHooks, logPlugins, }); @@ -464,6 +467,7 @@ export async function startGatewayServer( logGatewayStartup({ cfg: cfgAtStart, bindHost, + bindHosts: httpBindHosts, port, tlsEnabled: gatewayTls.enabled, log, @@ -552,6 +556,7 @@ export async function startGatewayServer( browserControl, wss, httpServer, + httpServers, }); return {