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;