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
8 changes: 6 additions & 2 deletions apps/server/src/cli/pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,14 @@ export class ServePortOccupiedError extends Schema.TaggedError<ServePortOccupied
}
}

const resolveDirectPairingBaseUrl = (state: PersistedServerRuntimeState): string =>
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<DevServerNotProxiableError>()(
Expand Down Expand Up @@ -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.",
Expand Down
4 changes: 1 addition & 3 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
isPrivateNetworkAddress,
isWildcardHost,
VIRTUAL_INTERFACE,
WINDOWS_HOST_ADAPTER_MAC,
} from "../startupAccess.ts";

type NetworkInterfacesMap = ReturnType<typeof NodeOS.networkInterfaces>;
Expand Down Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions apps/server/src/startupAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
13 changes: 11 additions & 2 deletions apps/server/src/startupAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -87,6 +91,7 @@ const resolveHeadlessConnectionHost = (
host: string | undefined,
interfaces: NetworkInterfacesMap,
privateNetworkOnly: boolean,
platform: NodeJS.Platform | undefined,
): string => {
if (!host) {
return "localhost";
Expand All @@ -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
Expand All @@ -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}`;
};
Expand Down Expand Up @@ -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();

Expand Down
Loading