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 <ip>" 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* Changelog: note LAN bind URLs fix (#11448) (thanks @AnonO6)

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Aviral
2026-02-08 06:46:51 +05:30
committed by GitHub
parent ea423bbbfd
commit b8c8130efe
8 changed files with 145 additions and 5 deletions

View File

@@ -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: 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. - 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. - Memory: set Voyage embeddings `input_type` for improved retrieval. (#10818) Thanks @mcinteerj.
- Media understanding: recognize `.caf` audio attachments for transcription. (#10982) Thanks @succ985. - 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. - Telegram: auto-inject DM topic threadId in message tool + subagent announce. (#7235) Thanks @Lukavyi.

View File

@@ -4,6 +4,7 @@ import {
resolveGatewayWindowsTaskName, resolveGatewayWindowsTaskName,
} from "../../daemon/constants.js"; } from "../../daemon/constants.js";
import { resolveGatewayLogPaths } from "../../daemon/launchd.js"; import { resolveGatewayLogPaths } from "../../daemon/launchd.js";
import { pickPrimaryLanIPv4 } from "../../gateway/net.js";
import { getResolvedLoggerSettings } from "../../logging.js"; import { getResolvedLoggerSettings } from "../../logging.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
@@ -61,6 +62,9 @@ export function pickProbeHostForBind(
if (bindMode === "tailnet") { if (bindMode === "tailnet") {
return tailnetIPv4 ?? "127.0.0.1"; return tailnetIPv4 ?? "127.0.0.1";
} }
if (bindMode === "lan") {
return pickPrimaryLanIPv4() ?? "127.0.0.1";
}
return "127.0.0.1"; return "127.0.0.1";
} }

View File

@@ -185,7 +185,7 @@ export async function gatherDaemonStatus(
const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`; const probeUrl = probeUrlOverride ?? `ws://${probeHost}:${daemonPort}`;
const probeNote = const probeNote =
!probeUrlOverride && bindMode === "lan" !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" : !probeUrlOverride && bindMode === "loopback"
? "Loopback-only gateway; only local clients can connect." ? "Loopback-only gateway; only local clients can connect."
: undefined; : undefined;

View File

@@ -11,6 +11,7 @@ import { CONFIG_PATH } from "../config/config.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { callGateway } from "../gateway/call.js"; import { callGateway } from "../gateway/call.js";
import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js"; import { normalizeControlUiBasePath } from "../gateway/control-ui-shared.js";
import { pickPrimaryLanIPv4 } from "../gateway/net.js";
import { isSafeExecutableValue } from "../infra/exec-safety.js"; import { isSafeExecutableValue } from "../infra/exec-safety.js";
import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js";
import { isWSL } from "../infra/wsl.js"; import { isWSL } from "../infra/wsl.js";
@@ -450,6 +451,9 @@ export function resolveControlUiLinks(params: {
if (bind === "tailnet" && tailnetIPv4) { if (bind === "tailnet" && tailnetIPv4) {
return tailnetIPv4 ?? "127.0.0.1"; return tailnetIPv4 ?? "127.0.0.1";
} }
if (bind === "lan") {
return pickPrimaryLanIPv4() ?? "127.0.0.1";
}
return "127.0.0.1"; return "127.0.0.1";
})(); })();
const basePath = normalizeControlUiBasePath(params.basePath); const basePath = normalizeControlUiBasePath(params.basePath);

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const loadConfig = vi.fn(); const loadConfig = vi.fn();
const resolveGatewayPort = vi.fn(); const resolveGatewayPort = vi.fn();
const pickPrimaryTailnetIPv4 = vi.fn(); const pickPrimaryTailnetIPv4 = vi.fn();
const pickPrimaryLanIPv4 = vi.fn();
let lastClientOptions: { let lastClientOptions: {
url?: string; url?: string;
@@ -29,6 +30,10 @@ vi.mock("../infra/tailnet.js", () => ({
pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv4,
})); }));
vi.mock("./net.js", () => ({
pickPrimaryLanIPv4,
}));
vi.mock("./client.js", () => ({ vi.mock("./client.js", () => ({
describeGatewayCloseCode: (code: number) => { describeGatewayCloseCode: (code: number) => {
if (code === 1000) { if (code === 1000) {
@@ -70,6 +75,7 @@ describe("callGateway url resolution", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
lastClientOptions = null; lastClientOptions = null;
startMode = "hello"; startMode = "hello";
closeCode = 1006; closeCode = 1006;
@@ -106,6 +112,28 @@ describe("callGateway url resolution", () => {
expect(lastClientOptions?.url).toBe("ws://100.64.0.1:18800"); 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 () => { it("uses url override in remote mode even when remote url is missing", async () => {
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
gateway: { mode: "remote", bind: "loopback", remote: {} }, gateway: { mode: "remote", bind: "loopback", remote: {} },
@@ -129,6 +157,7 @@ describe("buildGatewayConnectionDetails", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
}); });
it("uses explicit url overrides and omits bind details", () => { 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"); 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", () => { it("prefers remote url when configured", () => {
loadConfig.mockReturnValue({ loadConfig.mockReturnValue({
gateway: { gateway: {
@@ -193,6 +237,7 @@ describe("callGateway error details", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
lastClientOptions = null; lastClientOptions = null;
startMode = "hello"; startMode = "hello";
closeCode = 1006; closeCode = 1006;
@@ -267,6 +312,7 @@ describe("callGateway url override auth requirements", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
lastClientOptions = null; lastClientOptions = null;
startMode = "hello"; startMode = "hello";
closeCode = 1006; closeCode = 1006;
@@ -303,6 +349,7 @@ describe("callGateway password resolution", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
lastClientOptions = null; lastClientOptions = null;
startMode = "hello"; startMode = "hello";
closeCode = 1006; closeCode = 1006;
@@ -404,6 +451,7 @@ describe("callGateway token resolution", () => {
loadConfig.mockReset(); loadConfig.mockReset();
resolveGatewayPort.mockReset(); resolveGatewayPort.mockReset();
pickPrimaryTailnetIPv4.mockReset(); pickPrimaryTailnetIPv4.mockReset();
pickPrimaryLanIPv4.mockReset();
lastClientOptions = null; lastClientOptions = null;
startMode = "hello"; startMode = "hello";
closeCode = 1006; closeCode = 1006;

View File

@@ -16,6 +16,7 @@ import {
type GatewayClientName, type GatewayClientName,
} from "../utils/message-channel.js"; } from "../utils/message-channel.js";
import { GatewayClient } from "./client.js"; import { GatewayClient } from "./client.js";
import { pickPrimaryLanIPv4 } from "./net.js";
import { PROTOCOL_VERSION } from "./protocol/index.js"; import { PROTOCOL_VERSION } from "./protocol/index.js";
export type CallGatewayOptions = { export type CallGatewayOptions = {
@@ -101,11 +102,15 @@ export function buildGatewayConnectionDetails(
const tailnetIPv4 = pickPrimaryTailnetIPv4(); const tailnetIPv4 = pickPrimaryTailnetIPv4();
const bindMode = config.gateway?.bind ?? "loopback"; const bindMode = config.gateway?.bind ?? "loopback";
const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4; const preferTailnet = bindMode === "tailnet" && !!tailnetIPv4;
const preferLan = bindMode === "lan";
const lanIPv4 = preferLan ? pickPrimaryLanIPv4() : undefined;
const scheme = tlsEnabled ? "wss" : "ws"; const scheme = tlsEnabled ? "wss" : "ws";
const localUrl = const localUrl =
preferTailnet && tailnetIPv4 preferTailnet && tailnetIPv4
? `${scheme}://${tailnetIPv4}:${localPort}` ? `${scheme}://${tailnetIPv4}:${localPort}`
: `${scheme}://127.0.0.1:${localPort}`; : preferLan && lanIPv4
? `${scheme}://${lanIPv4}:${localPort}`
: `${scheme}://127.0.0.1:${localPort}`;
const urlOverride = const urlOverride =
typeof options.url === "string" && options.url.trim().length > 0 typeof options.url === "string" && options.url.trim().length > 0
? options.url.trim() ? options.url.trim()
@@ -122,7 +127,9 @@ export function buildGatewayConnectionDetails(
? "missing gateway.remote.url (fallback local)" ? "missing gateway.remote.url (fallback local)"
: preferTailnet && tailnetIPv4 : preferTailnet && tailnetIPv4
? `local tailnet ${tailnetIPv4}` ? `local tailnet ${tailnetIPv4}`
: "local loopback"; : preferLan && lanIPv4
? `local lan ${lanIPv4}`
: "local loopback";
const remoteFallbackNote = remoteMisconfigured const remoteFallbackNote = remoteMisconfigured
? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local." ? "Warn: gateway.mode=remote but gateway.remote.url is missing; set gateway.remote.url or switch gateway.mode=local."
: undefined; : undefined;

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import os from "node:os";
import { resolveGatewayListenHosts } from "./net.js"; import { afterEach, describe, expect, it, vi } from "vitest";
import { pickPrimaryLanIPv4, resolveGatewayListenHosts } from "./net.js";
describe("resolveGatewayListenHosts", () => { describe("resolveGatewayListenHosts", () => {
it("returns the input host when not loopback", async () => { it("returns the input host when not loopback", async () => {
@@ -25,3 +26,54 @@ describe("resolveGatewayListenHosts", () => {
expect(hosts).toEqual(["127.0.0.1"]); 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();
});
});

View File

@@ -1,6 +1,30 @@
import net from "node:net"; import net from "node:net";
import os from "node:os";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; 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 { export function isLoopbackAddress(ip: string | undefined): boolean {
if (!ip) { if (!ip) {
return false; return false;