From 5717b881770f1f84dff73f52b16b383286855cb2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 15:56:20 +0000 Subject: [PATCH 1/2] test(server): reproduce #33425 Windows VM adapters in pairing URL These tests reproduce #33425: on Windows, a private-network listener advertises the VirtualBox host-only adapter address instead of the Wi-Fi LAN address, because the virtual-interface filter does not match the Windows adapter names VirtualBox and VMware use. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/startupAccess.test.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index fd56b4a4c86f3..b4b7a9dd207c9 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -55,6 +55,20 @@ it("prefers a LAN address over a tailnet one for a private-network pairing URL", ).toBe("http://192.168.1.42:3773"); }); +it("skips Windows VirtualBox and VMware host-only adapters for a private-network listener", () => { + expect( + resolveHeadlessConnectionString("0.0.0.0", 3773, { + interfaces: { + "VirtualBox Host-Only Network": [{ ...ipv4("192.168.56.1"), mac: "0a:00:27:00:00:0a" }], + "VMware Network Adapter VMnet8": [{ ...ipv4("192.168.159.1"), mac: "00:50:56:c0:00:08" }], + "Wi-Fi": [{ ...ipv4("192.168.1.20"), mac: "3c:22:fb:12:34:56" }], + }, + privateNetworkOnly: true, + platform: "win32", + }), + ).toBe("http://192.168.1.20:3773"); +}); + describe("acceptsPrivateNetworkConnection", () => { const accepts = (localAddress: string | undefined, remoteAddress: string | undefined) => acceptsPrivateNetworkConnection({ localAddress, remoteAddress }); From a8565cf4ecc5cc16273894a90197994ad3a93794 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 9 Nov 2026 16:05:35 +0000 Subject: [PATCH 2/2] fix(server): t3 pair skips Windows VirtualBox and VMware host-only adapters On Windows, a private-network listener's pairing URL could use a VirtualBox or VMware host-only adapter address, which no other machine reaches. DirectEndpoints already recognised these adapters by their MAC prefixes; the pairing URL now applies the same check. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/cli/pair.ts | 8 ++++++-- apps/server/src/environment/DirectEndpoints.ts | 4 +--- apps/server/src/startupAccess.ts | 13 +++++++++++-- 3 files changed, 18 insertions(+), 7 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.ts b/apps/server/src/environment/DirectEndpoints.ts index 47e9772e20fe1..9985093410a04 100644 --- a/apps/server/src/environment/DirectEndpoints.ts +++ b/apps/server/src/environment/DirectEndpoints.ts @@ -25,6 +25,7 @@ import { isPrivateNetworkAddress, isWildcardHost, VIRTUAL_INTERFACE, + WINDOWS_HOST_ADAPTER_MAC, } from "../startupAccess.ts"; type NetworkInterfacesMap = ReturnType; @@ -52,9 +53,6 @@ const isTailscaleInterface = ( /^tailscale/i.test(name) || entries.some((entry) => entry.family === "IPv6" && isTailnetHost(entry.address)); -// Windows adapter names are mutable; Linux bridges can inherit these MACs. -const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i; - export function resolveBoundEndpoints(input: { readonly platform: NodeJS.Platform; readonly host: string | undefined; diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 910584d110b31..61f0ea7645175 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -3,6 +3,7 @@ import * as NodeOS from "node:os"; import { isPrivateNetworkHost, isSharedAddressSpaceHost } from "@t3tools/shared/hostClassification"; import { QrCode } from "@t3tools/shared/qrCode"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { buildPairingAppLink } from "@t3tools/shared/remote"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/http"; @@ -48,6 +49,9 @@ export const formatHostForUrl = (host: string): string => export const VIRTUAL_INTERFACE = /^(docker|br-|veth|virbr|vmnet|vboxnet|vEthernet|podman|cni|flannel|cali|lxcbr|lxdbr|bridge[1-9]\d\d)/; +// VirtualBox and VMware host-only adapters on Windows. Their names are mutable; Linux bridges can inherit these MACs. +export const WINDOWS_HOST_ADAPTER_MAC = /^(0a:00:27|00:50:56:c0:00):/i; + /** * Loopback or private-range IPv4 (LAN, tailnet, CGNAT), not link-local, in * plain or IPv4-mapped IPv6 form. @@ -87,6 +91,7 @@ const resolveHeadlessConnectionHost = ( host: string | undefined, interfaces: NetworkInterfacesMap, privateNetworkOnly: boolean, + platform: NodeJS.Platform | undefined, ): string => { if (!host) { return "localhost"; @@ -103,7 +108,9 @@ const resolveHeadlessConnectionHost = ( (entry) => !entry.internal && isIpv4Family(entry.family) && - (!privateNetworkOnly || isPrivateNetworkAddress(entry.address)), + (!privateNetworkOnly || + (isPrivateNetworkAddress(entry.address) && + !(platform === "win32" && WINDOWS_HOST_ADAPTER_MAC.test(entry.mac)))), ); // A tailnet or other CGNAT address reaches fewer devices than the LAN one. const externalIpv4 = privateNetworkOnly @@ -129,12 +136,14 @@ export const resolveHeadlessConnectionString = ( options: { readonly interfaces?: NetworkInterfacesMap; readonly privateNetworkOnly?: boolean | undefined; + readonly platform?: NodeJS.Platform | undefined; } = {}, ): string => { const connectionHost = resolveHeadlessConnectionHost( host, options.interfaces ?? NodeOS.networkInterfaces(), options.privateNetworkOnly === true, + options.platform, ); return `http://${formatHostForUrl(connectionHost)}:${port}`; }; @@ -200,7 +209,7 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const connectionString = resolveHeadlessConnectionString( serverConfig.host, resolveListeningPort(httpServer.address, serverConfig.port), - { privateNetworkOnly: serverConfig.privateNetworkOnly }, + { privateNetworkOnly: serverConfig.privateNetworkOnly, platform: yield* HostProcess.Platform }, ); const issued = yield* serverAuth.issueStartupPairingCredential();