Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 7 additions & 1 deletion apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NodeOS.networkInterfaces>;

Expand Down Expand Up @@ -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)
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/startupAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/startupAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading