diff --git a/src/browser/features/Settings/Sections/ExperimentsSection.tsx b/src/browser/features/Settings/Sections/ExperimentsSection.tsx index 903ddd8827..1f6f87ea1b 100644 --- a/src/browser/features/Settings/Sections/ExperimentsSection.tsx +++ b/src/browser/features/Settings/Sections/ExperimentsSection.tsx @@ -216,9 +216,33 @@ export function PortableDesktopExperimentWarning() { ); } -type BindHostMode = "localhost" | "all" | "custom"; +const TAILSCALE_BIND_HOST_MODE_PREFIX = "tailscale:"; + +type BindHostMode = + | "localhost" + | "all" + | "custom" + | `${typeof TAILSCALE_BIND_HOST_MODE_PREFIX}${string}`; type PortMode = "random" | "fixed"; +function getTailscaleBindHostMode(address: string): BindHostMode { + return `${TAILSCALE_BIND_HOST_MODE_PREFIX}${address}`; +} + +function getTailscaleBindHostAddress(mode: BindHostMode): string | null { + if (!mode.startsWith(TAILSCALE_BIND_HOST_MODE_PREFIX)) { + return null; + } + + const address = mode.slice(TAILSCALE_BIND_HOST_MODE_PREFIX.length).trim(); + return address ? address : null; +} + +function formatTailscaleBindHostLabel(host: ApiServerStatus["tailscaleBindHosts"][number]): string { + const protocol = host.family === "IPv6" ? "IPv6" : "IPv4"; + return `Tailscale ${host.interfaceName} (${host.address}, ${protocol})`; +} + function ConfigurableBindUrlControls() { const enabled = useExperimentValue(EXPERIMENT_IDS.CONFIGURABLE_BIND_URL); const { api } = useAPI(); @@ -246,8 +270,14 @@ function ConfigurableBindUrlControls() { setHostMode("all"); setCustomHost(""); } else { - setHostMode("custom"); - setCustomHost(configuredHost); + const tailscaleHost = next.tailscaleBindHosts.find((host) => host.address === configuredHost); + if (tailscaleHost) { + setHostMode(getTailscaleBindHostMode(tailscaleHost.address)); + setCustomHost(""); + } else { + setHostMode("custom"); + setCustomHost(configuredHost); + } } setServeWebUi(next.configuredServeWebUi); @@ -311,10 +341,13 @@ function ConfigurableBindUrlControls() { setError(null); let bindHost: string | null; + const tailscaleBindHost = getTailscaleBindHostAddress(hostMode); if (hostMode === "localhost") { bindHost = null; } else if (hostMode === "all") { bindHost = "0.0.0.0"; + } else if (tailscaleBindHost) { + bindHost = tailscaleBindHost; } else { const trimmed = customHost.trim(); if (!trimmed) { @@ -377,6 +410,7 @@ function ConfigurableBindUrlControls() { ); } + const tailscaleBindHosts = status?.tailscaleBindHosts ?? []; const encodedToken = status?.token ? encodeURIComponent(status.token) : null; const localWebUiUrl = status?.baseUrl ? `${status.baseUrl}/` : null; const localWebUiUrlWithToken = @@ -409,6 +443,20 @@ function ConfigurableBindUrlControls() { Localhost only (127.0.0.1) All interfaces (0.0.0.0) + {tailscaleBindHosts.length > 0 ? ( + tailscaleBindHosts.map((host) => ( + + {formatTailscaleBindHostLabel(host)} + + )) + ) : ( + + {loading ? "Loading Tailscale devices…" : "Tailscale device not detected"} + + )} Custom… diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index cf6669549c..027d461cb8 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1712,6 +1712,12 @@ export const terminal = { // Server +export const TailscaleBindHostSchema = z.object({ + interfaceName: z.string(), + address: z.string(), + family: z.enum(["IPv4", "IPv6"]), +}); + export const ApiServerStatusSchema = z.object({ running: z.boolean(), /** Base URL that is always connectable from the local machine (loopback for wildcard binds). */ @@ -1722,6 +1728,8 @@ export const ApiServerStatusSchema = z.object({ port: z.number().int().min(0).max(65535).nullable(), /** Additional base URLs that may be reachable from other devices (LAN/VPN). */ networkBaseUrls: z.array(z.url()), + /** Local Tailscale interface addresses that can be selected as bind hosts. */ + tailscaleBindHosts: z.array(TailscaleBindHostSchema), /** Auth token required for HTTP/WS API access. */ token: z.string().nullable(), /** Configured bind host from ~/.mux/config.json (if set). */ diff --git a/src/common/types/project.ts b/src/common/types/project.ts index 949c9b4f95..4e2797f172 100644 --- a/src/common/types/project.ts +++ b/src/common/types/project.ts @@ -35,6 +35,7 @@ export interface ProjectsConfig { * * When unset, mux binds to 127.0.0.1 (localhost only). * When set to 0.0.0.0 or ::, mux can be reachable from other devices on your LAN/VPN. + * When set to a Tailscale interface address, mux listens only on that tailnet device. */ apiServerBindHost?: string; /** diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 00ab529213..15f31754df 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -465,6 +465,7 @@ export const router = (authToken?: string) => { bindHost: info?.bindHost ?? null, port: info?.port ?? null, networkBaseUrls: info?.networkBaseUrls ?? [], + tailscaleBindHosts: context.serverService.getTailscaleBindHosts(), token: info?.token ?? null, configuredBindHost, configuredPort, @@ -565,6 +566,7 @@ export const router = (authToken?: string) => { bindHost: info?.bindHost ?? null, port: info?.port ?? null, networkBaseUrls: info?.networkBaseUrls ?? [], + tailscaleBindHosts: context.serverService.getTailscaleBindHosts(), token: info?.token ?? null, configuredBindHost, configuredPort, diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index 95b54f2f75..5091562dc8 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import * as os from "os"; import * as net from "net"; -import { ServerService, computeNetworkBaseUrls } from "./serverService"; +import { ServerService, computeNetworkBaseUrls, getTailscaleBindHosts } from "./serverService"; import type { ORPCContext } from "@/node/orpc/context"; import { Config } from "@/node/config"; import { ServerLockDataSchema } from "./serverLockfile"; @@ -224,6 +224,102 @@ test("supports non-CLI allow-http-origin opt-in via MUX_SERVER_ALLOW_HTTP_ORIGIN } }); +describe("getTailscaleBindHosts", () => { + test("detects Tailscale bind addresses from interface names and Tailscale CLI output", () => { + const networkInterfaces: ReturnType = { + lo0: [ + { + address: "100.64.0.1", + netmask: "255.192.0.0", + family: "IPv4", + mac: "00:00:00:00:00:00", + internal: true, + cidr: "100.64.0.1/10", + }, + ], + en0: [ + { + address: "192.168.1.10", + netmask: "255.255.255.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "192.168.1.10/24", + }, + { + address: "100.80.0.2", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "100.80.0.2/10", + }, + ], + tailscale0: [ + { + address: "100.64.0.2", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:01", + internal: false, + cidr: "100.64.0.2/10", + }, + { + address: "fd7a:115c:a1e0::2", + netmask: "ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:01", + internal: false, + cidr: "fd7a:115c:a1e0::2/48", + scopeid: 0, + }, + { + address: "fe80::1", + netmask: "ffff:ffff:ffff:ffff::", + family: "IPv6", + mac: "aa:bb:cc:dd:ee:01", + internal: false, + cidr: "fe80::1/64", + scopeid: 0, + }, + ], + utun5: [ + { + address: "100.100.10.20", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:02", + internal: false, + cidr: "100.100.10.20/10", + }, + ], + }; + + expect(getTailscaleBindHosts(networkInterfaces, new Set(["100.100.10.20"]))).toEqual([ + { interfaceName: "tailscale0", address: "100.64.0.2", family: "IPv4" }, + { interfaceName: "utun5", address: "100.100.10.20", family: "IPv4" }, + { interfaceName: "tailscale0", address: "fd7a:115c:a1e0::2", family: "IPv6" }, + ]); + }); + + test("does not label generic CGNAT addresses as Tailscale without proof", () => { + const networkInterfaces: ReturnType = { + en0: [ + { + address: "100.80.0.2", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "100.80.0.2/10", + }, + ], + }; + + expect(getTailscaleBindHosts(networkInterfaces, new Set())).toEqual([]); + }); +}); + describe("computeNetworkBaseUrls", () => { test("returns empty for loopback binds", () => { expect(computeNetworkBaseUrls({ bindHost: "127.0.0.1", port: 3000 })).toEqual([]); diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index fc63cc088c..ba735e2341 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -5,6 +5,7 @@ import * as fs from "fs/promises"; import * as path from "path"; import { log } from "./log"; import * as os from "os"; +import * as childProcess from "node:child_process"; import { VERSION } from "@/version"; import { buildMuxMdnsServiceOptions, MdnsAdvertiserService } from "./mdnsAdvertiserService"; import type { AppRouter } from "@/node/orpc/router"; @@ -47,6 +48,14 @@ export interface StartServerOptions { type NetworkInterfaces = NodeJS.Dict; +export interface TailscaleBindHost { + interfaceName: string; + address: string; + family: "IPv4" | "IPv6"; +} + +const TAILSCALE_IP_COMMAND_TIMEOUT_MS = 1_000; + function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); @@ -124,6 +133,124 @@ function getNonInternalInterfaceAddresses( return Array.from(new Set(addresses)).sort(); } +function parseIpv4Octets(address: string): [number, number, number, number] | null { + const parts = address.split("."); + if (parts.length !== 4) { + return null; + } + + if (parts.some((part) => !/^\d+$/.test(part))) { + return null; + } + + const octets: [number, number, number, number] = [ + Number.parseInt(parts[0] ?? "", 10), + Number.parseInt(parts[1] ?? "", 10), + Number.parseInt(parts[2] ?? "", 10), + Number.parseInt(parts[3] ?? "", 10), + ]; + if (octets.some((octet) => octet < 0 || octet > 255)) { + return null; + } + + return octets; +} + +function isTailscaleIpv4Address(address: string): boolean { + const octets = parseIpv4Octets(address); + if (!octets) { + return false; + } + + // Tailscale IPv4 addresses live in 100.64.0.0/10. This lets macOS utun devices show + // as a Tailscale choice even when the OS exposes only a generic interface name. + return octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127; +} + +function isTailscaleIpv6Address(address: string): boolean { + const normalized = address.toLowerCase(); + return normalized === "fd7a:115c:a1e0::" || normalized.startsWith("fd7a:115c:a1e0:"); +} + +function isTailscaleInterfaceName(interfaceName: string): boolean { + return interfaceName.toLowerCase().includes("tailscale"); +} + +function isLinkLocalAddress(address: string, family: "IPv4" | "IPv6"): boolean { + if (family === "IPv4") { + return address.startsWith("169.254."); + } + + return address.toLowerCase().startsWith("fe80:"); +} + +function getTailscaleCliAddresses(): ReadonlySet { + try { + const output = childProcess.execFileSync("tailscale", ["ip"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + timeout: TAILSCALE_IP_COMMAND_TIMEOUT_MS, + }); + + return new Set( + output + .split(/\s+/) + .map((address) => address.trim()) + .filter((address) => isTailscaleIpv4Address(address) || isTailscaleIpv6Address(address)) + ); + } catch { + return new Set(); + } +} + +export function getTailscaleBindHosts( + networkInterfaces: NetworkInterfaces = os.networkInterfaces(), + tailscaleAddresses: ReadonlySet = getTailscaleCliAddresses() +): TailscaleBindHost[] { + const hostsByAddress = new Map(); + const emptyInfos: os.NetworkInterfaceInfo[] = []; + + for (const interfaceName of Object.keys(networkInterfaces)) { + const infos = networkInterfaces[interfaceName] ?? emptyInfos; + for (const info of infos) { + const family = info.family; + if (family !== "IPv4" && family !== "IPv6") { + continue; + } + + const address = info.address.trim(); + if (!address || info.internal || isLinkLocalAddress(address, family)) { + continue; + } + + // A 100.64.0.0/10 address alone can be ordinary RFC6598 CGNAT, so only generic + // interface names become Tailscale choices when the Tailscale CLI proves the address. + if (!isTailscaleInterfaceName(interfaceName) && !tailscaleAddresses.has(address)) { + continue; + } + + hostsByAddress.set(`${family}:${address}`, { + interfaceName, + address, + family, + }); + } + } + + return Array.from(hostsByAddress.values()).sort((a, b) => { + if (a.family !== b.family) { + return a.family === "IPv4" ? -1 : 1; + } + + const addressComparison = a.address.localeCompare(b.address, undefined, { numeric: true }); + if (addressComparison !== 0) { + return addressComparison; + } + + return a.interfaceName.localeCompare(b.interfaceName, undefined, { numeric: true }); + }); +} + /** * Compute base URLs that are reachable from other devices (LAN/VPN). * @@ -328,7 +455,7 @@ export class ServerService { } else if (mdnsAdvertisementEnabled === true && isLoopbackHost(bindHost)) { log.warn( "mDNS advertisement requested, but the API server is loopback-only. " + - "Set apiServerBindHost to 0.0.0.0 (or a LAN IP) to enable LAN discovery." + "Set apiServerBindHost to 0.0.0.0 (or a LAN/Tailscale IP) to enable LAN discovery." ); } @@ -356,6 +483,11 @@ export class ServerService { this.serverInfo = null; } + /** Return Tailscale-backed local addresses users can bind the remote-access server to. */ + getTailscaleBindHosts(): TailscaleBindHost[] { + return getTailscaleBindHosts(); + } + /** * Get information about the running server. * Returns null if no server is running in this process.