From dc004b4d775eaaddb5626bc3dd7af88b61d55b7f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:29:41 +0000 Subject: [PATCH 1/5] test(server): reproduce #17246, wildcard bind advertises host-only hotspot and 198.18/15 tunnel addresses as LAN Co-Authored-By: Claude Opus 5.5 --- .../src/environment/DirectEndpoints.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 08b16959e864..67dd6074cfc7 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -122,6 +122,38 @@ describe("resolveBoundEndpoints", () => { ]); }); + it("skips the Windows Mobile hotspot adapter, which only this machine's own clients reach", () => { + const interfaces = { + ...INTERFACES, + "Local Area Connection* 2": virtualInterface("192.168.137.1"), + "LAN-Verbindung* 10": virtualInterface("192.168.137.2"), + }; + expect(resolveBoundEndpoints({ 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/" }, + ]); + }); + + it("skips VPN tunnel addresses in the 198.18.0.0/15 benchmarking range, which is never a LAN", () => { + const interfaces = { + ...INTERFACES, + utun40: [ + { + address: "198.19.2.2", + netmask: "255.255.255.0", + family: "IPv4" as const, + mac: "00:00:00:00:00:00", + internal: false, + cidr: "198.19.2.2/24", + }, + ], + }; + expect(resolveBoundEndpoints({ 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/" }, + ]); + }); + it("tags only addresses on Tailscale's interface as tailnet, not other VPNs in its range", () => { const interfaces = { en0: INTERFACES.en0, From 0833ff314a0c20c1b92e3cd7d377be615fbde26e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:41:03 +0000 Subject: [PATCH 2/5] fix(server): wildcard bind stops advertising hotspot and VPN tunnel addresses as LAN Windows Mobile hotspot adapters (`Local Area Connection* `, localized) join the virtual-interface name filter, and 198.18.0.0/15 tunnel addresses are no longer advertised as direct endpoints. Fixes pingdotgg/t3code#17246 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environment/DirectEndpoints.ts | 6 +++++- apps/server/src/startupAccess.ts | 9 ++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 99e33b98f970..fae6fa01200a 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -38,8 +38,12 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} // Credentials travel over HTTP; public IPs leak them and hostnames can resolve differently per client. +// VPN and proxy clients put their tunnels in 198.18.0.0/15 (RFC 2544), which is never a LAN. const isAdvertisableAddress = (address: string): boolean => - NodeNet.isIPv4(address) && !address.startsWith("127.") && isPrivateNetworkAddress(address); + NodeNet.isIPv4(address) && + !address.startsWith("127.") && + !/^198\.1[89]\./.test(address) && + isPrivateNetworkAddress(address); // macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. const isTailscaleInterface = ( diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index cd2815931e30..c87ee58924ef 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -39,13 +39,12 @@ export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; /** - * 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 hotspot's + * localized `Local Area Connection* 2` have private addresses only this machine + * reaches. A host bridge such as `br0` or Proxmox's `vmbr0` 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 From d815c6c1f4ad80056dca3e3c2db0d6c564622d5e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 14:51:30 +0000 Subject: [PATCH 3/5] fix(server): describe hotspot and bridge reachability accurately Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environment/DirectEndpoints.test.ts | 2 +- apps/server/src/startupAccess.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 67dd6074cfc7..d49ed46c52f3 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -122,7 +122,7 @@ describe("resolveBoundEndpoints", () => { ]); }); - it("skips the Windows Mobile hotspot adapter, which only this machine's own clients reach", () => { + it("skips the Windows Mobile hotspot adapter, which only devices on this machine's hotspot reach", () => { const interfaces = { ...INTERFACES, "Local Area Connection* 2": virtualInterface("192.168.137.1"), diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index c87ee58924ef..d99029ac5374 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -39,9 +39,9 @@ export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; /** - * Container and VM networks, macOS's `bridge100`+, and Windows hotspot's - * localized `Local Area Connection* 2` have private addresses only this machine - * reaches. A host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN. + * Container and VM networks, macOS's `bridge100`+ and Windows hotspot's + * localized `Local Area Connection* 2` reach only this machine's guests or the + * devices it shares a connection with. 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)|\* \d+$/; From 2061bd78a2a0d6b5d9f8d4f93eeda0177a9ce15a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 27 Oct 2026 15:17:19 +0000 Subject: [PATCH 4/5] fix(server): private-network pairing URL skips 198.18/15 VPN tunnel addresses With a wildcard bind and privateNetworkOnly, the headless pairing connection string took the first private IPv4, so a VPN tunnel listed before the LAN interface (e.g. utun at 198.19.2.2) became the printed URL. The 198.18.0.0/15 check now lives in one isVpnTunnelAddress helper shared with DirectEndpoints. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/environment/DirectEndpoints.ts | 4 ++-- apps/server/src/startupAccess.test.ts | 9 +++++++++ apps/server/src/startupAccess.ts | 6 +++++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index fae6fa01200a..5c4273da6a67 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -21,6 +21,7 @@ import { formatHostForUrl, isLoopbackHost, isPrivateNetworkAddress, + isVpnTunnelAddress, isWildcardHost, VIRTUAL_INTERFACE, } from "../startupAccess.ts"; @@ -38,11 +39,10 @@ export class DirectEndpoints extends Context.Service< >()("t3/environment/DirectEndpoints") {} // Credentials travel over HTTP; public IPs leak them and hostnames can resolve differently per client. -// VPN and proxy clients put their tunnels in 198.18.0.0/15 (RFC 2544), which is never a LAN. const isAdvertisableAddress = (address: string): boolean => NodeNet.isIPv4(address) && !address.startsWith("127.") && - !/^198\.1[89]\./.test(address) && + !isVpnTunnelAddress(address) && isPrivateNetworkAddress(address); // macOS utun names need Tailscale's IPv6 range to identify them; WARP shares its IPv4 range. diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index fd56b4a4c86f..4d0a12d52c35 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -55,6 +55,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"); +}); + describe("acceptsPrivateNetworkConnection", () => { const accepts = (localAddress: string | undefined, remoteAddress: string | undefined) => acceptsPrivateNetworkConnection({ localAddress, remoteAddress }); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index d99029ac5374..f067f72fea31 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -55,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 @@ -101,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 From 83b165e0bdd1099daac2ab2c49bd123fe7d5f33a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Thu, 12 Nov 2026 09:00:12 +0000 Subject: [PATCH 5/5] docs(server): name Windows hidden virtual adapters in the interface filter comment AI-assisted; verified with the tests and tools listed above. --- apps/server/src/startupAccess.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 1861b2ca44e0..b9c511cb3a9e 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -39,9 +39,9 @@ export const isLoopbackHost = (host: string | undefined): boolean => { }; /** - * Container and VM networks, macOS's `bridge100`+ and Windows hotspot's - * localized `Local Area Connection* 2` reach only this machine's guests or the - * devices it shares a connection with. A host bridge such as `br0` carries the LAN. + * 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)|\* \d+$/;