Skip to content
Merged
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 @@ -161,10 +161,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 @@ -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.",
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/environment/DirectEndpoints.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions apps/server/src/environment/DirectEndpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof NodeOS.networkInterfaces>;

Expand Down Expand Up @@ -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 &&
Expand Down
21 changes: 19 additions & 2 deletions apps/server/src/startupAccess.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -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");
});
Expand All @@ -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));
Expand Down
23 changes: 19 additions & 4 deletions apps/server/src/startupAccess.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -86,6 +97,7 @@ const resolveHeadlessConnectionHost = (
host: string | undefined,
interfaces: NetworkInterfacesMap,
privateNetworkOnly: boolean,
platform: NodeJS.Platform,
): string => {
if (!host) {
return "localhost";
Expand All @@ -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) =>
Expand Down Expand Up @@ -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}`;
};
Expand Down Expand Up @@ -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();

Expand Down
Loading