openclaw/src/gateway/net.test.ts

214 lines
7.7 KiB
TypeScript
Raw Normal View History

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>
2026-02-08 06:46:51 +05:30
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
isPrivateOrLoopbackAddress,
isTrustedProxyAddress,
pickPrimaryLanIPv4,
resolveGatewayListenHosts,
} from "./net.js";
describe("isTrustedProxyAddress", () => {
describe("exact IP matching", () => {
it("returns true when IP matches exactly", () => {
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
});
it("returns false when IP does not match", () => {
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
});
it("returns true when IP matches one of multiple proxies", () => {
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5", "172.16.0.1"])).toBe(
true,
);
});
});
describe("CIDR subnet matching", () => {
it("returns true when IP is within /24 subnet", () => {
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/24"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/24"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.254", ["10.42.0.0/24"])).toBe(true);
});
it("returns false when IP is outside /24 subnet", () => {
expect(isTrustedProxyAddress("10.42.1.1", ["10.42.0.0/24"])).toBe(false);
expect(isTrustedProxyAddress("10.43.0.1", ["10.42.0.0/24"])).toBe(false);
});
it("returns true when IP is within /16 subnet", () => {
expect(isTrustedProxyAddress("172.19.5.100", ["172.19.0.0/16"])).toBe(true);
expect(isTrustedProxyAddress("172.19.255.255", ["172.19.0.0/16"])).toBe(true);
});
it("returns false when IP is outside /16 subnet", () => {
expect(isTrustedProxyAddress("172.20.0.1", ["172.19.0.0/16"])).toBe(false);
});
it("returns true when IP is within /32 subnet (single IP)", () => {
expect(isTrustedProxyAddress("10.42.0.0", ["10.42.0.0/32"])).toBe(true);
});
it("returns false when IP does not match /32 subnet", () => {
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.0/32"])).toBe(false);
});
it("handles mixed exact IPs and CIDR notation", () => {
const proxies = ["192.168.1.1", "10.42.0.0/24", "172.19.0.0/16"];
expect(isTrustedProxyAddress("192.168.1.1", proxies)).toBe(true); // exact match
expect(isTrustedProxyAddress("10.42.0.59", proxies)).toBe(true); // CIDR match
expect(isTrustedProxyAddress("172.19.5.100", proxies)).toBe(true); // CIDR match
expect(isTrustedProxyAddress("10.43.0.1", proxies)).toBe(false); // no match
});
});
describe("backward compatibility", () => {
it("preserves exact IP matching behavior (no CIDR notation)", () => {
// Old configs with exact IPs should work exactly as before
expect(isTrustedProxyAddress("192.168.1.1", ["192.168.1.1"])).toBe(true);
expect(isTrustedProxyAddress("192.168.1.2", ["192.168.1.1"])).toBe(false);
expect(isTrustedProxyAddress("10.0.0.5", ["192.168.1.1", "10.0.0.5"])).toBe(true);
});
it("does NOT treat plain IPs as /32 CIDR (exact match only)", () => {
// "10.42.0.1" without /32 should match ONLY that exact IP
expect(isTrustedProxyAddress("10.42.0.1", ["10.42.0.1"])).toBe(true);
expect(isTrustedProxyAddress("10.42.0.2", ["10.42.0.1"])).toBe(false);
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.1"])).toBe(false);
});
it("handles IPv4-mapped IPv6 addresses (existing normalizeIp behavior)", () => {
// Existing normalizeIp() behavior should be preserved
expect(isTrustedProxyAddress("::ffff:192.168.1.1", ["192.168.1.1"])).toBe(true);
});
});
describe("edge cases", () => {
it("returns false when IP is undefined", () => {
expect(isTrustedProxyAddress(undefined, ["192.168.1.1"])).toBe(false);
});
it("returns false when trustedProxies is undefined", () => {
expect(isTrustedProxyAddress("192.168.1.1", undefined)).toBe(false);
});
it("returns false when trustedProxies is empty", () => {
expect(isTrustedProxyAddress("192.168.1.1", [])).toBe(false);
});
it("returns false for invalid CIDR notation", () => {
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/33"])).toBe(false); // invalid prefix
expect(isTrustedProxyAddress("10.42.0.59", ["10.42.0.0/-1"])).toBe(false); // negative prefix
expect(isTrustedProxyAddress("10.42.0.59", ["invalid/24"])).toBe(false); // invalid IP
});
});
});
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(hosts).toEqual(["0.0.0.0"]);
});
it("adds ::1 when IPv6 loopback is available", async () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
canBindToHost: async () => true,
});
expect(hosts).toEqual(["127.0.0.1", "::1"]);
});
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
canBindToHost: async () => false,
});
expect(hosts).toEqual(["127.0.0.1"]);
});
});
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>
2026-02-08 06:46:51 +05:30
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();
});
});
describe("isPrivateOrLoopbackAddress", () => {
it("accepts loopback, private, link-local, and cgnat ranges", () => {
const accepted = [
"127.0.0.1",
"::1",
"10.1.2.3",
"172.16.0.1",
"172.31.255.254",
"192.168.0.1",
"169.254.10.20",
"100.64.0.1",
"100.127.255.254",
"::ffff:100.100.100.100",
"fc00::1",
"fd12:3456:789a::1",
"fe80::1",
"fe9a::1",
"febb::1",
];
for (const ip of accepted) {
expect(isPrivateOrLoopbackAddress(ip)).toBe(true);
}
});
it("rejects public addresses", () => {
const rejected = ["1.1.1.1", "8.8.8.8", "172.32.0.1", "203.0.113.10", "2001:4860:4860::8888"];
for (const ip of rejected) {
expect(isPrivateOrLoopbackAddress(ip)).toBe(false);
}
});
});