From b8c8130efeed5c9a603640a0b7943a03cd4b4685 Mon Sep 17 00:00:00 2001 From: Aviral <124311066+AnonO6@users.noreply.github.com> Date: Sun, 8 Feb 2026 06:46:51 +0530 Subject: [PATCH] fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11448) * fix(gateway): use LAN IP for WebSocket/probe URLs when bind=lan (#11329) When gateway.bind=lan, the HTTP server correctly binds to 0.0.0.0 (all interfaces), but WebSocket connection URLs, probe targets, and Control UI links were hardcoded to 127.0.0.1. This caused CLI commands and status probes to show localhost-only URLs even in LAN mode, and made onboarding display misleading connection info. - Add pickPrimaryLanIPv4() to gateway/net.ts to detect the machine's primary LAN IPv4 address (prefers en0/eth0, falls back to any external interface) - Update pickProbeHostForBind() to use LAN IP when bind=lan - Update buildGatewayConnectionDetails() to use LAN IP and report "local lan " as the URL source - Update resolveControlUiLinks() to return LAN-accessible URLs - Update probe note in status.gather.ts to reflect new behavior - Add tests for pickPrimaryLanIPv4 and bind=lan URL resolution Closes #11329 Co-authored-by: Cursor * test: move vi.restoreAllMocks to afterEach in pickPrimaryLanIPv4 Per review feedback: avoid calling vi.restoreAllMocks() inside individual tests as it restores all spies globally and can cause ordering issues. Use afterEach in the describe block instead. Co-authored-by: Cursor * Changelog: note LAN bind URLs fix (#11448) (thanks @AnonO6) --------- Co-authored-by: Cursor Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + src/cli/daemon-cli/shared.ts | 4 +++ src/cli/daemon-cli/status.gather.ts | 2 +- src/commands/onboard-helpers.ts | 4 +++ src/gateway/call.test.ts | 48 +++++++++++++++++++++++++ src/gateway/call.ts | 11 ++++-- src/gateway/net.test.ts | 56 +++++++++++++++++++++++++++-- src/gateway/net.ts | 24 +++++++++++++ 8 files changed, 145 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59260e83b..701c09866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Cron: scheduler reliability (timer drift, restart catch-up, lock contention, stale running markers). (#10776) Thanks @tyler6204. - Cron: store migration hardening (legacy field migration, parse error handling, explicit delivery mode persistence). (#10776) Thanks @tyler6204. +- Gateway/CLI: when `gateway.bind=lan`, use a LAN IP for probe URLs and Control UI links. (#11448) Thanks @AnonO6. - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj. - Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi. diff --git a/src/cli/daemon-cli/shared.ts b/src/cli/daemon-cli/shared.ts index 7ad647b21..c5decd0dd 100644 --- a/src/cli/daemon-cli/shared.ts +++ b/src/cli/daemon-cli/shared.ts @@ -4,6 +4,7 @@ import { resolveGatewayWindowsTaskName, } from "../../daemon/constants.js"; import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; +import { pickPrimaryLanIPv4 } from "../../gateway/net.js"; import { getResolvedLoggerSettings } from "../../logging.js"; import { formatCliCommand } from "../command-format.js"; @@ -61,6 +62,9 @@ export function pickProbeHostForBind( if (bindMode === "tailnet") { return tailnetIPv4 ?? "127.0.0.1"; } + if (bindMode === "lan") { + return pickPrimaryLanIPv4() ?? "127.0.0.1"; + } return "127.0.0.1"; } diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index f4b323ad0..37bb99914 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -185,7 +185,7 @@ export async function gatherDaemonStatus( const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`; const probeNote = !probeUrlOverride && bindMode === "lan" - ? "Local probe uses loopback (127.0.0.1). bind=lan listens on 0.0.0.0 (all interfaces); use a LAN IP for remote clients." + ? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.` : !probeUrlOverride && bindMode === "loopback" ? "Loopback-only gateway; only local clients can connect." : undefined; diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index d708d5f97..ef9d969f1 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -11,6 +11,7 @@ import { CONFIG_PATH } from "../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { callGateway } from "../gateway/call.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; +import { pickPrimaryLanIPv4 } from "../gateway/net.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { isWSL } from "../infra/wsl.js"; @@ -450,6 +451,9 @@ export function resolveControlUiLinks(params: { if (bind === "tailnet" && tailnetIPv4) { return tailnetIPv4 ?? "127.0.0.1"; } + if (bind === "lan") { + return pickPrimaryLanIPv4() ?? "127.0.0.1"; + } return "127.0.0.1"; })(); const basePath = normalizeControlUiBasePath(params.basePath); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 04e90669b..0607ce34e 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const loadConfig = vi.fn(); const resolveGatewayPort = vi.fn(); const pickPrimaryTailnetIPv4 = vi.fn(); +const pickPrimaryLanIPv4 = vi.fn(); let lastClientOptions: { url?: string; @@ -29,6 +30,10 @@ vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv4, })); +vi.mock("./net.js", () => ({ + pickPrimaryLanIPv4, +})); + vi.mock("./client.js", () => ({ describeGatewayCloseCode: (code: number) => { if (code === 1000) { @@ -70,6 +75,7 @@ describe("callGateway url resolution", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); lastClientOptions = null; startMode = "hello"; closeCode = 1006; @@ -106,6 +112,28 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); }); + it("uses LAN IP when bind is lan and LAN IP is available", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + pickPrimaryLanIPv4.mockReturnValue("192.168.1.42"); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://192.168.1.42:18800"); + }); + + it("falls back to loopback when bind is lan but no LAN IP found", async () => { + loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "lan" } }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + pickPrimaryLanIPv4.mockReturnValue(undefined); + + await callGateway({ method: "health" }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800"); + }); + it("uses url override in remote mode even when remote url is missing", async () => { loadConfig.mockReturnValue({ gateway: { mode: "remote", bind: "loopback", remote: {} }, @@ -129,6 +157,7 @@ describe("buildGatewayConnectionDetails", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); }); it("uses explicit url overrides and omits bind details", () => { @@ -168,6 +197,21 @@ describe("buildGatewayConnectionDetails", () => { expect(details.message).toContain("Gateway target: ws://127.0.0.1:18789"); }); + it("uses LAN IP and reports lan source when bind is lan", () => { + loadConfig.mockReturnValue({ + gateway: { mode: "local", bind: "lan" }, + }); + resolveGatewayPort.mockReturnValue(18800); + pickPrimaryTailnetIPv4.mockReturnValue(undefined); + pickPrimaryLanIPv4.mockReturnValue("10.0.0.5"); + + const details = buildGatewayConnectionDetails(); + + expect(details.url).toBe("ws://10.0.0.5:18800"); + expect(details.urlSource).toBe("local lan 10.0.0.5"); + expect(details.bindDetail).toBe("Bind: lan"); + }); + it("prefers remote url when configured", () => { loadConfig.mockReturnValue({ gateway: { @@ -193,6 +237,7 @@ describe("callGateway error details", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); lastClientOptions = null; startMode = "hello"; closeCode = 1006; @@ -267,6 +312,7 @@ describe("callGateway url override auth requirements", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); lastClientOptions = null; startMode = "hello"; closeCode = 1006; @@ -303,6 +349,7 @@ describe("callGateway password resolution", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); lastClientOptions = null; startMode = "hello"; closeCode = 1006; @@ -404,6 +451,7 @@ describe("callGateway token resolution", () => { loadConfig.mockReset(); resolveGatewayPort.mockReset(); pickPrimaryTailnetIPv4.mockReset(); + pickPrimaryLanIPv4.mockReset(); lastClientOptions = null; startMode = "hello"; closeCode = 1006; diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 1f89b18e1..d3cf747e6 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -16,6 +16,7 @@ import { type GatewayClientName, } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; +import { pickPrimaryLanIPv4 } from "./net.js"; import { PROTOCOL_VERSION } from "./protocol/index.js"; export type CallGatewayOptions = { @@ -101,11 +102,15 @@ export function buildGatewayConnectionDetails( const tailnetIPv4 = pickPrimaryTailnetIPv4(); const bindMode = config.gateway?.bind ?? "loopback"; const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4; + const preferLan = bindMode === "lan"; + const lanIPv4 = preferLan ? pickPrimaryLanIPv4() : undefined; const scheme = tlsEnabled ? "wss" : "ws"; const localUrl = preferTailnet && tailnetIPv4 ? `${scheme}://${tailnetIPv4}:${localPort}` - : `${scheme}://127.0.0.1:${localPort}`; + : preferLan && lanIPv4 + ? `${scheme}://${lanIPv4}:${localPort}` + : `${scheme}://127.0.0.1:${localPort}`; const urlOverride = typeof options.url === "string" && options.url.trim().length > 0 ? options.url.trim() @@ -122,7 +127,9 @@ export function buildGatewayConnectionDetails( ? "missing gateway.remote.url (fallback local)" : preferTailnet && tailnetIPv4 ? `local tailnet ${tailnetIPv4}` - : "local loopback"; + : preferLan && lanIPv4 + ? `local lan ${lanIPv4}` + : "local loopback"; const remoteFallbackNote = remoteMisconfigured ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." : undefined; diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts index 2cbd75a2e..4d945e276 100644 --- a/src/gateway/net.test.ts +++ b/src/gateway/net.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it } from "vitest"; -import { resolveGatewayListenHosts } from "./net.js"; +import os from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { pickPrimaryLanIPv4, resolveGatewayListenHosts } from "./net.js"; describe("resolveGatewayListenHosts", () => { it("returns the input host when not loopback", async () => { @@ -25,3 +26,54 @@ describe("resolveGatewayListenHosts", () => { expect(hosts).toEqual(["127.0.0.1"]); }); }); + +describe("pickPrimaryLanIPv4", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns en0 IPv4 address when available", () => { + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo0: [ + { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + en0: [ + { address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + }); + expect(pickPrimaryLanIPv4()).toBe("192.168.1.42"); + }); + + it("returns eth0 IPv4 address when en0 is absent", () => { + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo: [ + { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + eth0: [ + { address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + }); + expect(pickPrimaryLanIPv4()).toBe("10.0.0.5"); + }); + + it("falls back to any non-internal IPv4 interface", () => { + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo: [ + { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + wlan0: [ + { address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + }); + expect(pickPrimaryLanIPv4()).toBe("172.16.0.99"); + }); + + it("returns undefined when only internal interfaces exist", () => { + vi.spyOn(os, "networkInterfaces").mockReturnValue({ + lo: [ + { address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, + ] as unknown as os.NetworkInterfaceInfo[], + }); + expect(pickPrimaryLanIPv4()).toBeUndefined(); + }); +}); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index e7730428b..e292aec25 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,6 +1,30 @@ import net from "node:net"; +import os from "node:os"; import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; +/** + * Pick the primary non-internal IPv4 address (LAN IP). + * Prefers common interface names (en0, eth0) then falls back to any external IPv4. + */ +export function pickPrimaryLanIPv4(): string | undefined { + const nets = os.networkInterfaces(); + const preferredNames = ["en0", "eth0"]; + for (const name of preferredNames) { + const list = nets[name]; + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) { + return entry.address; + } + } + for (const list of Object.values(nets)) { + const entry = list?.find((n) => n.family === "IPv4" && !n.internal); + if (entry?.address) { + return entry.address; + } + } + return undefined; +} + export function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) { return false;