diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index 622b47f91a65e..4b563155c6bfe 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -161,10 +161,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()( @@ -562,7 +566,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 6548b2ccd8c0e..5e1459b3dc2ab 100644 --- a/apps/server/src/environment/DirectEndpoints.test.ts +++ b/apps/server/src/environment/DirectEndpoints.test.ts @@ -137,6 +137,36 @@ 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("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 1f7d9f705620c..e883766e070c4 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -24,7 +24,7 @@ import { import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "./ServerEnvironment.ts"; -import { isLoopbackHost, isPrivateNetworkAddress, VIRTUAL_INTERFACE } from "../startupAccess.ts"; +import { isLoopbackHost, isPrivateNetworkAddress, isVirtualInterface } from "../startupAccess.ts"; type NetworkInterfacesMap = ReturnType; @@ -70,7 +70,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 a48bb5263f8ec..159778b3c7e03 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -21,6 +21,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"); @@ -34,16 +35,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"); }); @@ -53,10 +59,21 @@ 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"); }); +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, + platform: "linux", + }), + ).toBe("http://192.168.0.18: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 f393faecebf91..1720300bd7beb 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -7,6 +7,7 @@ import { isSharedAddressSpaceHost, isWildcardHost, } 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"; @@ -44,9 +45,19 @@ export const isLoopbackHost = (host: string | undefined): boolean => { * host bridge such as `br0` or Proxmox's `vmbr0` carries the LAN address, so * it stays listed. */ -export const VIRTUAL_INTERFACE = +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 * plain or IPv4-mapped IPv6 form. @@ -86,6 +97,7 @@ const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap, privateNetworkOnly: boolean, + platform: NodeJS.Platform, ): string => { if (!host) { return "localhost"; @@ -96,7 +108,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) => @@ -128,12 +140,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}`; }; @@ -196,10 +210,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();