From 77e7efc2fc9d1e16dc7a81ecb5aa619133bdf558 Mon Sep 17 00:00:00 2001 From: repparw Date: Fri, 6 Nov 2026 16:09:28 -0300 Subject: [PATCH 1/2] fix(server): NixOS container and systemd-nspawn veths are no longer advertised as LAN routes --- .../src/environment/DirectEndpoints.test.ts | 16 ++++++++++++++++ apps/server/src/startupAccess.test.ts | 15 +++++++++++++++ apps/server/src/startupAccess.ts | 10 +++++----- 3 files changed, 36 insertions(+), 5 deletions(-) diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index def0434140c32..3965fbcd8e03b 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -134,6 +134,22 @@ describe("resolveBoundEndpoints", () => { ]); }); + it("skips NixOS container and systemd-nspawn veths, which only this machine reaches", () => { + const interfaces = { + ...INTERFACES, + "ve-jellyfin": virtualInterface("192.168.53.193"), + "ve-qbittorrent": virtualInterface("192.168.55.225"), + "vb-jellyfin": virtualInterface("192.168.100.1"), + "vz-machine": virtualInterface("192.168.101.1"), + }; + expect( + resolveBoundEndpoints({ platform: "linux", 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("omits Windows host-only adapters without excluding guest NICs or explicit binds", () => { const interfaces = { ...INTERFACES, diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index fd56b4a4c86f3..6420fb387a473 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -55,6 +55,21 @@ it("prefers a LAN address over a tailnet one for a private-network pairing URL", ).toBe("http://192.168.1.42:3773"); }); +it("skips NixOS container and systemd-nspawn veths for a private-network pairing URL", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { "ve-jellyfin": [ipv4("192.168.53.193")], eth0: [ipv4("192.168.0.18")] }, + privateNetworkOnly: true, + }), + ).toBe("http://192.168.0.18:3773"); + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { "vb-jellyfin": [ipv4("192.168.100.1")] }, + privateNetworkOnly: true, + }), + ).toBe("http://localhost: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 910584d110b31..8766939cc3c2e 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -40,13 +40,13 @@ 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 (Docker, libvirt, VMware, VirtualBox, Hyper-V, + * systemd-nspawn's `ve-*`/`vz-*`, NixOS containers' `vb-*`, macOS's + * `bridge100`+) have private addresses only this machine reaches. Host + * bridges such as `br0` or `vmbr0` carry the LAN address, so they stay listed. */ 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|ve-|vb-|vz-|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; /** * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in From 103718e51774d60adc289741fce0b2439dfddc58 Mon Sep 17 00:00:00 2001 From: repparw Date: Sat, 7 Nov 2026 02:03:54 -0300 Subject: [PATCH 2/2] fix(server): apply the container veth prefixes to Linux discovery only --- apps/server/src/cli/pair.ts | 8 +++-- .../src/environment/DirectEndpoints.test.ts | 14 ++++++++ .../server/src/environment/DirectEndpoints.ts | 6 ++-- apps/server/src/startupAccess.test.ts | 23 +++++++++++-- apps/server/src/startupAccess.ts | 33 ++++++++++++++----- 5 files changed, 69 insertions(+), 15 deletions(-) diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 904182ad29c6b..9dc6645effb69 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -148,10 +148,14 @@ export class ServePortOccupiedError extends Schema.TaggedError +const resolveDirectPairingBaseUrl = ( + state: PersistedServerRuntimeState, + platform: NodeJS.Platform, +): string => state.devUrl ?? resolveHeadlessConnectionString(state.host, state.port, { privateNetworkOnly: state.privateNetworkOnly, + platform, }); export class DevServerNotProxiableError extends Schema.TaggedError()( @@ -529,7 +533,7 @@ export const pairCommand = Command.make("pair", { pairingBaseUrl = resolved.baseUrl; notes.push(...resolved.notes); } else { - pairingBaseUrl = resolveDirectPairingBaseUrl(target.state); + pairingBaseUrl = resolveDirectPairingBaseUrl(target.state, yield* HostProcess.Platform); if (isLoopbackHost(new URL(pairingBaseUrl).hostname)) { notes.push( "This URL is only reachable from this machine. To reach it from your other devices, run `t3 up`, or re-run with --tailscale.", diff --git a/apps/server/src/environment/DirectEndpoints.test.ts b/apps/server/src/environment/DirectEndpoints.test.ts index 3965fbcd8e03b..b2c4989815233 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -150,6 +150,20 @@ describe("resolveBoundEndpoints", () => { ]); }); + it("keeps a Windows alias matching a Linux container prefix eligible", () => { + const interfaces = { + ...INTERFACES, + "ve-office": virtualInterface("192.168.1.30"), + }; + 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/" }, + { kind: "lan", httpBaseUrl: "http://192.168.1.30:3773/" }, + ]); + }); + it("omits Windows host-only adapters without excluding guest NICs or explicit binds", () => { const interfaces = { ...INTERFACES, diff --git a/apps/server/src/environment/DirectEndpoints.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..8622b5e6cee49 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -23,8 +23,8 @@ import { formatHostForUrl, isLoopbackHost, isPrivateNetworkAddress, + isVirtualInterface, isWildcardHost, - VIRTUAL_INTERFACE, } from "../startupAccess.ts"; type NetworkInterfacesMap = ReturnType; @@ -71,7 +71,9 @@ export function resolveBoundEndpoints(input: { ); const addresses = isWildcardHost(input.host) ? Object.entries(input.interfaces) - .flatMap(([name, entries]) => (VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []))) + .flatMap(([name, entries]) => + isVirtualInterface(name, input.platform) ? [] : (entries ?? []), + ) .filter( (entry) => !entry.internal && diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 6420fb387a473..41740d4cf5e65 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -19,6 +19,7 @@ const ipv4 = (address: string, internal = false): NodeOS.NetworkInterfaceInfo => it("resolves wildcard hosts to a concrete external interface when one is available", () => { const connectionString = resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces: { en0: [ipv4("192.168.1.42")], lo0: [ipv4("127.0.0.1", true)] }, + platform: "linux", }); expect(connectionString).toBe("http://192.168.1.42:3773"); @@ -32,16 +33,21 @@ it("skips public and container addresses for a private-network listener", () => eth1: [ipv4("10.0.0.5")], }; - expect(resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces })).toBe( + expect(resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces, platform: "linux" })).toBe( "http://203.0.113.7:3773", ); expect( - resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces, privateNetworkOnly: true }), + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces, + privateNetworkOnly: true, + platform: "linux", + }), ).toBe("http://10.0.0.5:3773"); expect( resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces: { eth0: [ipv4("203.0.113.7")] }, privateNetworkOnly: true, + platform: "linux", }), ).toBe("http://localhost:3773"); }); @@ -51,6 +57,7 @@ it("prefers a LAN address over a tailnet one for a private-network pairing URL", resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces: { tailscale0: [ipv4("100.101.102.103")], en0: [ipv4("192.168.1.42")] }, privateNetworkOnly: true, + platform: "linux", }), ).toBe("http://192.168.1.42:3773"); }); @@ -60,16 +67,28 @@ it("skips NixOS container and systemd-nspawn veths for a private-network pairing resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces: { "ve-jellyfin": [ipv4("192.168.53.193")], eth0: [ipv4("192.168.0.18")] }, privateNetworkOnly: true, + platform: "linux", }), ).toBe("http://192.168.0.18:3773"); expect( resolveHeadlessConnectionString("0.0.0.0", 3773, { interfaces: { "vb-jellyfin": [ipv4("192.168.100.1")] }, privateNetworkOnly: true, + platform: "linux", }), ).toBe("http://localhost:3773"); }); +it("keeps a renamed Windows adapter matching a Linux container prefix eligible", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { "ve-office": [ipv4("192.168.0.5")] }, + privateNetworkOnly: true, + platform: "win32", + }), + ).toBe("http://192.168.0.5: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 8766939cc3c2e..872c0437f63ff 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -2,6 +2,7 @@ import * as NodeNet from "node:net"; import * as NodeOS from "node:os"; import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { QrCode } from "@t3tools/shared/qrCode"; import { buildPairingAppLink } from "@t3tools/shared/remote"; import * as Effect from "effect/Effect"; @@ -40,13 +41,23 @@ export const formatHostForUrl = (host: string): string => host.includes(":") && !host.startsWith("[") ? `[${host}]` : host; /** - * Container and VM networks (Docker, libvirt, VMware, VirtualBox, Hyper-V, - * systemd-nspawn's `ve-*`/`vz-*`, NixOS containers' `vb-*`, macOS's - * `bridge100`+) have private addresses only this machine reaches. Host - * bridges such as `br0` or `vmbr0` carry the LAN address, so they stay listed. + * 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. */ -export const VIRTUAL_INTERFACE = - /^(docker|br-|veth|virbr|ve-|vb-|vz-|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; +const VIRTUAL_INTERFACE = + /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; + +/** + * systemd-nspawn's `ve-*`/`vz-*` and NixOS containers' `vb-*` name host-side + * veths after the machine. Windows aliases are user-renameable, so these + * prefixes only apply to Linux interface discovery. + */ +const LINUX_CONTAINER_INTERFACE = /^(ve-|vb-|vz-)/; + +export const isVirtualInterface = (name: string, platform: NodeJS.Platform): boolean => + VIRTUAL_INTERFACE.test(name) || (platform === "linux" && LINUX_CONTAINER_INTERFACE.test(name)); /** * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in @@ -87,6 +98,7 @@ const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap, privateNetworkOnly: boolean, + platform: NodeJS.Platform, ): string => { if (!host) { return "localhost"; @@ -97,7 +109,7 @@ const resolveHeadlessConnectionHost = ( } const interfaceEntries = Object.entries(interfaces).flatMap(([name, entries]) => - privateNetworkOnly && VIRTUAL_INTERFACE.test(name) ? [] : (entries ?? []), + privateNetworkOnly && isVirtualInterface(name, platform) ? [] : (entries ?? []), ); const externalIpv4Entries = interfaceEntries.filter( (entry) => @@ -129,12 +141,14 @@ export const resolveHeadlessConnectionString = ( options: { readonly interfaces?: NetworkInterfacesMap; readonly privateNetworkOnly?: boolean | undefined; - } = {}, + readonly platform: NodeJS.Platform; + }, ): string => { const connectionHost = resolveHeadlessConnectionHost( host, options.interfaces ?? NodeOS.networkInterfaces(), options.privateNetworkOnly === true, + options.platform, ); return `http://${formatHostForUrl(connectionHost)}:${port}`; }; @@ -197,10 +211,11 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const serverConfig = yield* ServerConfig.ServerConfig; const httpServer = yield* HttpServer.HttpServer; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const platform = yield* HostProcess.Platform; const connectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), - { privateNetworkOnly: serverConfig.privateNetworkOnly }, + { privateNetworkOnly: serverConfig.privateNetworkOnly, platform }, ); const issued = yield* serverAuth.issueStartupPairingCredential();