fix: listen on ipv6 loopback for gateway
This commit is contained in:
@@ -39,6 +39,7 @@ Docs: https://docs.clawd.bot
|
|||||||
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
|
- 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: 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: 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.
|
- 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)
|
- 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.
|
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
|
||||||
|
|||||||
@@ -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 };
|
import { resolveGatewayListenHosts } from "./net.js";
|
||||||
const testTailnetIPv6 = { value: undefined as string | undefined };
|
|
||||||
|
|
||||||
vi.mock("../infra/tailnet.js", () => ({
|
describe("resolveGatewayListenHosts", () => {
|
||||||
pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
|
it("returns the input host when not loopback", async () => {
|
||||||
pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
|
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||||
}));
|
canBindToHost: async () => {
|
||||||
|
throw new Error("should not be called");
|
||||||
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"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("203.0.113.9");
|
expect(hosts).toEqual(["0.0.0.0"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores forwarded-for from untrusted proxies", () => {
|
it("adds ::1 when IPv6 loopback is available", async () => {
|
||||||
const clientIp = resolveGatewayClientIp({
|
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||||
remoteAddr: "10.0.0.3",
|
canBindToHost: async () => true,
|
||||||
forwardedFor: "203.0.113.9",
|
|
||||||
trustedProxies: ["10.0.0.2"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("10.0.0.3");
|
expect(hosts).toEqual(["127.0.0.1", "::1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
|
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
|
||||||
const clientIp = resolveGatewayClientIp({
|
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||||
remoteAddr: "::ffff:10.0.0.2",
|
canBindToHost: async () => false,
|
||||||
forwardedFor: "203.0.113.9:1234",
|
|
||||||
trustedProxies: ["10.0.0.2"],
|
|
||||||
});
|
});
|
||||||
expect(clientIp).toBe("203.0.113.9");
|
expect(hosts).toEqual(["127.0.0.1"]);
|
||||||
});
|
|
||||||
|
|
||||||
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");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
|
|||||||
|
|
||||||
if (mode === "loopback") {
|
if (mode === "loopback") {
|
||||||
// 127.0.0.1 rarely fails, but handle gracefully
|
// 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
|
return "0.0.0.0"; // extreme fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "tailnet") {
|
if (mode === "tailnet") {
|
||||||
const tailnetIP = pickPrimaryTailnetIPv4();
|
const tailnetIP = pickPrimaryTailnetIPv4();
|
||||||
if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
|
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP;
|
||||||
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";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
|
|||||||
const host = customHost?.trim();
|
const host = customHost?.trim();
|
||||||
if (!host) return "0.0.0.0"; // invalid config → fall back to all
|
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
|
// Custom IP failed → fall back to LAN
|
||||||
return "0.0.0.0";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === "auto") {
|
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";
|
return "0.0.0.0";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
|
|||||||
* @param host - The host address to test
|
* @param host - The host address to test
|
||||||
* @returns True if we can successfully bind to this address
|
* @returns True if we can successfully bind to this address
|
||||||
*/
|
*/
|
||||||
async function canBindTo(host: string): Promise<boolean> {
|
export async function canBindToHost(host: string): Promise<boolean> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const testServer = net.createServer();
|
const testServer = net.createServer();
|
||||||
testServer.once("error", () => {
|
testServer.once("error", () => {
|
||||||
@@ -151,6 +151,16 @@ async function canBindTo(host: string): Promise<boolean> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolveGatewayListenHosts(
|
||||||
|
bindHost: string,
|
||||||
|
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
|
||||||
|
): Promise<string[]> {
|
||||||
|
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.
|
* Validate if a string is a valid IPv4 address.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createGatewayCloseHandler(params: {
|
|||||||
browserControl: { stop: () => Promise<void> } | null;
|
browserControl: { stop: () => Promise<void> } | null;
|
||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
|
httpServers?: HttpServer[];
|
||||||
}) {
|
}) {
|
||||||
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
|
||||||
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
|
||||||
@@ -108,14 +109,20 @@ export function createGatewayCloseHandler(params: {
|
|||||||
await params.browserControl.stop().catch(() => {});
|
await params.browserControl.stop().catch(() => {});
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
await new Promise<void>((resolve) => params.wss.close(() => resolve()));
|
||||||
const httpServer = params.httpServer as HttpServer & {
|
const servers =
|
||||||
closeIdleConnections?: () => void;
|
params.httpServers && params.httpServers.length > 0
|
||||||
};
|
? params.httpServers
|
||||||
if (typeof httpServer.closeIdleConnections === "function") {
|
: [params.httpServer];
|
||||||
httpServer.closeIdleConnections();
|
for (const server of servers) {
|
||||||
|
const httpServer = server as HttpServer & {
|
||||||
|
closeIdleConnections?: () => void;
|
||||||
|
};
|
||||||
|
if (typeof httpServer.closeIdleConnections === "function") {
|
||||||
|
httpServer.closeIdleConnections();
|
||||||
|
}
|
||||||
|
await new Promise<void>((resolve, reject) =>
|
||||||
|
httpServer.close((err) => (err ? reject(err) : resolve())),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
await new Promise<void>((resolve, reject) =>
|
|
||||||
params.httpServer.close((err) => (err ? reject(err) : resolve())),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
|||||||
import type { HooksConfigResolved } from "./hooks.js";
|
import type { HooksConfigResolved } from "./hooks.js";
|
||||||
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
|
||||||
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
import { listenGatewayHttpServer } from "./server/http-listen.js";
|
||||||
|
import { resolveGatewayListenHosts } from "./net.js";
|
||||||
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
|
||||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||||
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
import { createGatewayBroadcaster } from "./server-broadcast.js";
|
||||||
@@ -38,11 +39,14 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
canvasHostEnabled: boolean;
|
canvasHostEnabled: boolean;
|
||||||
allowCanvasHostInTests?: boolean;
|
allowCanvasHostInTests?: boolean;
|
||||||
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
|
log: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
logHooks: ReturnType<typeof createSubsystemLogger>;
|
logHooks: ReturnType<typeof createSubsystemLogger>;
|
||||||
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
logPlugins: ReturnType<typeof createSubsystemLogger>;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
httpServer: HttpServer;
|
httpServer: HttpServer;
|
||||||
|
httpServers: HttpServer[];
|
||||||
|
httpBindHosts: string[];
|
||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
clients: Set<GatewayWsClient>;
|
clients: Set<GatewayWsClient>;
|
||||||
broadcast: (
|
broadcast: (
|
||||||
@@ -100,30 +104,49 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
log: params.logPlugins,
|
log: params.logPlugins,
|
||||||
});
|
});
|
||||||
|
|
||||||
const httpServer = createGatewayHttpServer({
|
const bindHosts = await resolveGatewayListenHosts(params.bindHost);
|
||||||
canvasHost,
|
const httpServers: HttpServer[] = [];
|
||||||
controlUiEnabled: params.controlUiEnabled,
|
const httpBindHosts: string[] = [];
|
||||||
controlUiBasePath: params.controlUiBasePath,
|
for (const host of bindHosts) {
|
||||||
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
const httpServer = createGatewayHttpServer({
|
||||||
openResponsesEnabled: params.openResponsesEnabled,
|
canvasHost,
|
||||||
openResponsesConfig: params.openResponsesConfig,
|
controlUiEnabled: params.controlUiEnabled,
|
||||||
handleHooksRequest,
|
controlUiBasePath: params.controlUiBasePath,
|
||||||
handlePluginRequest,
|
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
|
||||||
resolvedAuth: params.resolvedAuth,
|
openResponsesEnabled: params.openResponsesEnabled,
|
||||||
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
openResponsesConfig: params.openResponsesConfig,
|
||||||
});
|
handleHooksRequest,
|
||||||
|
handlePluginRequest,
|
||||||
await listenGatewayHttpServer({
|
resolvedAuth: params.resolvedAuth,
|
||||||
httpServer,
|
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
|
||||||
bindHost: params.bindHost,
|
});
|
||||||
port: params.port,
|
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({
|
const wss = new WebSocketServer({
|
||||||
noServer: true,
|
noServer: true,
|
||||||
maxPayload: MAX_PAYLOAD_BYTES,
|
maxPayload: MAX_PAYLOAD_BYTES,
|
||||||
});
|
});
|
||||||
attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
|
for (const server of httpServers) {
|
||||||
|
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
|
||||||
|
}
|
||||||
|
|
||||||
const clients = new Set<GatewayWsClient>();
|
const clients = new Set<GatewayWsClient>();
|
||||||
const { broadcast } = createGatewayBroadcaster({ clients });
|
const { broadcast } = createGatewayBroadcaster({ clients });
|
||||||
@@ -140,6 +163,8 @@ export async function createGatewayRuntimeState(params: {
|
|||||||
return {
|
return {
|
||||||
canvasHost,
|
canvasHost,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
|
httpBindHosts,
|
||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
broadcast,
|
broadcast,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { getResolvedLoggerSettings } from "../logging.js";
|
|||||||
export function logGatewayStartup(params: {
|
export function logGatewayStartup(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
|
bindHosts?: string[];
|
||||||
port: number;
|
port: number;
|
||||||
tlsEnabled?: boolean;
|
tlsEnabled?: boolean;
|
||||||
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
log: { info: (msg: string, meta?: Record<string, unknown>) => void };
|
||||||
@@ -22,9 +23,16 @@ export function logGatewayStartup(params: {
|
|||||||
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
consoleMessage: `agent model: ${chalk.whiteBright(modelRef)}`,
|
||||||
});
|
});
|
||||||
const scheme = params.tlsEnabled ? "wss" : "ws";
|
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(
|
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}`);
|
params.log.info(`log file: ${getResolvedLoggerSettings().file}`);
|
||||||
if (params.isNixMode) {
|
if (params.isNixMode) {
|
||||||
params.log.info("gateway: running in Nix mode (config managed externally)");
|
params.log.info("gateway: running in Nix mode (config managed externally)");
|
||||||
|
|||||||
@@ -263,6 +263,8 @@ export async function startGatewayServer(
|
|||||||
const {
|
const {
|
||||||
canvasHost,
|
canvasHost,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
|
httpBindHosts,
|
||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
broadcast,
|
broadcast,
|
||||||
@@ -292,6 +294,7 @@ export async function startGatewayServer(
|
|||||||
canvasHostEnabled,
|
canvasHostEnabled,
|
||||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
||||||
logCanvas,
|
logCanvas,
|
||||||
|
log,
|
||||||
logHooks,
|
logHooks,
|
||||||
logPlugins,
|
logPlugins,
|
||||||
});
|
});
|
||||||
@@ -464,6 +467,7 @@ export async function startGatewayServer(
|
|||||||
logGatewayStartup({
|
logGatewayStartup({
|
||||||
cfg: cfgAtStart,
|
cfg: cfgAtStart,
|
||||||
bindHost,
|
bindHost,
|
||||||
|
bindHosts: httpBindHosts,
|
||||||
port,
|
port,
|
||||||
tlsEnabled: gatewayTls.enabled,
|
tlsEnabled: gatewayTls.enabled,
|
||||||
log,
|
log,
|
||||||
@@ -552,6 +556,7 @@ export async function startGatewayServer(
|
|||||||
browserControl,
|
browserControl,
|
||||||
wss,
|
wss,
|
||||||
httpServer,
|
httpServer,
|
||||||
|
httpServers,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user