diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 2b022cd069d2..583e6f1df7ae 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -166,6 +166,24 @@ describe("resolveBoundEndpoints", () => { } }); + it("omits Windows hotspot adapters and 198.18.0.0/15 VPN tunnels from a wildcard bind", () => { + const interfaces = { + ...INTERFACES, + "Local Area Connection* 2": virtualInterface("192.168.137.1"), + "LAN-Verbindung* 10": virtualInterface("192.168.137.2"), + utun40: virtualInterface("198.19.2.2"), + }; + expect( + resolveBoundEndpoints({ platform: "win32", host: "0.0.0.0", port: 3773, interfaces }), + ).toEqual([ + { kind: "lan", httpBaseUrl: "http://192.168.1.10:3773/" }, + { kind: "tailnet", httpBaseUrl: "http://100.101.102.103:3773/" }, + ]); + expect( + resolveBoundEndpoints({ platform: "darwin", host: "198.19.2.2", port: 3773, interfaces }), + ).toEqual([{ kind: "lan", httpBaseUrl: "http://198.19.2.2:3773/" }]); + }); + it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => { const interfaces = { en0: INTERFACES.en0, diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 5b9ecc98299c..ced942632955 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -23,7 +23,12 @@ import { } from "@t3tools/shared/hostClassification"; import * as ServerConfig from "../config.ts"; -import { isLoopbackHost, isPrivateNetworkAddress, VIRTUAL_INTERFACE } from "../startupAccess.ts"; +import { + isLoopbackHost, + isPrivateNetworkAddress, + isVpnTunnelAddress, + VIRTUAL_INTERFACE, +} from "../startupAccess.ts"; type NetworkInterfacesMap = ReturnType; @@ -75,6 +80,7 @@ export function resolveBoundEndpoints(input: { !entry.internal && entry.family === "IPv4" && isAdvertisableAddress(entry.address) && + !isVpnTunnelAddress(entry.address) && !(input.platform === "win32" && WINDOWS_HOST_ADAPTER_MAC.test(entry.mac)), ) .map((entry) => entry.address) diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index a48bb5263f8e..327bbddf1d0a 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -57,6 +57,15 @@ it("prefers a LAN address over a tailnet one for a private-network pairing URL", ).toBe("http://192.168.1.42:3773"); }); +it("skips a VPN tunnel address for a private-network pairing URL", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { utun40: [ipv4("198.19.2.2")], en0: [ipv4("192.168.1.42")] }, + privateNetworkOnly: true, + }), + ).toBe("http://192.168.1.42:3773"); +}); + it("targets the bound interface when the listener binds a specific address", () => { const hostFor = (ip: string) => loopbackUrlHostForAddress(NetAddress.inetAddressFromIpStringUnsafe(ip, 3773)); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index f393faecebf9..b9c511cb3a9e 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -39,13 +39,12 @@ export const isLoopbackHost = (host: string | undefined): boolean => { }; /** - * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, and - * macOS's `bridge100`+) have private addresses only this machine reaches. A - * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so - * it stays listed. + * Container and VM networks, macOS's `bridge100`+, and Windows' hidden virtual + * adapters such as the hotspot's localized `Local Area Connection* 2` reach only + * this machine's guests or tethered devices. A host bridge such as `br0` carries the LAN. */ export const VIRTUAL_INTERFACE = - /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)|\* \d+$/; /** * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in @@ -56,6 +55,9 @@ export const isPrivateNetworkAddress = (address: string): boolean => { return NodeNet.isIPv4(ipv4) && !ipv4.startsWith("169.254.") && isPrivateNetworkHost(ipv4); }; +/** VPN and proxy clients put their tunnels in 198.18.0.0/15 (RFC 2544), which is never a LAN. */ +export const isVpnTunnelAddress = (address: string): boolean => /^198\.1[89]\./.test(address); + /** * A private-network listener binds every interface but serves only * connections that stay on loopback, a LAN, or the tailnet. Both ends must be @@ -102,7 +104,8 @@ const resolveHeadlessConnectionHost = ( (entry) => !entry.internal && isIpv4Family(entry.family) && - (!privateNetworkOnly || isPrivateNetworkAddress(entry.address)), + (!privateNetworkOnly || + (isPrivateNetworkAddress(entry.address) && !isVpnTunnelAddress(entry.address))), ); // A tailnet or other CGNAT address reaches fewer devices than the LAN one. const externalIpv4 = privateNetworkOnly