From 8dab07c18508ea549089692c704b8492d2aa4a2a Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 5 May 2026 23:32:21 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=A4=96=20feat:=20add=20Tailscale=20bi?= =?UTF-8?q?nd=20host=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose detected local Tailscale addresses in API server status and allow the remote access experiment to bind mux to one of them. Generated with mux • Model: openai:gpt-5.5 • Thinking: xhigh • Cost: $0.00 --- .../Settings/Sections/ExperimentsSection.tsx | 54 ++++++++- src/common/orpc/schemas/api.ts | 8 ++ src/common/types/project.ts | 1 + src/node/orpc/router.ts | 2 + src/node/services/serverService.test.ts | 81 ++++++++++++- src/node/services/serverService.ts | 111 +++++++++++++++++- 6 files changed, 252 insertions(+), 5 deletions(-) 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..bceb76718e 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,85 @@ 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 address ranges", () => { + 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.128.0.2", + netmask: "255.192.0.0", + family: "IPv4", + mac: "aa:bb:cc:dd:ee:ff", + internal: false, + cidr: "100.128.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)).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" }, + ]); + }); +}); + 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..25673393ce 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -47,6 +47,12 @@ export interface StartServerOptions { type NetworkInterfaces = NodeJS.Dict; +export interface TailscaleBindHost { + interfaceName: string; + address: string; + family: "IPv4" | "IPv6"; +} + function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); @@ -124,6 +130,104 @@ 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:"); +} + +export function getTailscaleBindHosts( + networkInterfaces: NetworkInterfaces = os.networkInterfaces() +): 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; + } + + const isTailscaleAddress = + family === "IPv4" ? isTailscaleIpv4Address(address) : isTailscaleIpv6Address(address); + if (!isTailscaleInterfaceName(interfaceName) && !isTailscaleAddress) { + 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 +432,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 +460,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. From e4ff4b53276e9a07bb3dfaadef29d5031595289b Mon Sep 17 00:00:00 2001 From: Colin Adler Date: Tue, 5 May 2026 23:41:04 -0500 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=A4=96=20fix:=20require=20Tailscale?= =?UTF-8?q?=20proof=20for=20CGNAT=20bind=20hosts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid labeling generic 100.64.0.0/10 addresses as Tailscale unless the interface name or Tailscale CLI output proves the address belongs to Tailscale. Generated with mux • Model: openai:gpt-5.5 • Thinking: xhigh • Cost: $0.00 --- src/node/services/serverService.test.ts | 25 ++++++++++++++++---- src/node/services/serverService.ts | 31 +++++++++++++++++++++---- 2 files changed, 48 insertions(+), 8 deletions(-) diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index bceb76718e..5091562dc8 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -225,7 +225,7 @@ 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 address ranges", () => { + test("detects Tailscale bind addresses from interface names and Tailscale CLI output", () => { const networkInterfaces: ReturnType = { lo0: [ { @@ -247,12 +247,12 @@ describe("getTailscaleBindHosts", () => { cidr: "192.168.1.10/24", }, { - address: "100.128.0.2", + address: "100.80.0.2", netmask: "255.192.0.0", family: "IPv4", mac: "aa:bb:cc:dd:ee:ff", internal: false, - cidr: "100.128.0.2/10", + cidr: "100.80.0.2/10", }, ], tailscale0: [ @@ -295,12 +295,29 @@ describe("getTailscaleBindHosts", () => { ], }; - expect(getTailscaleBindHosts(networkInterfaces)).toEqual([ + 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", () => { diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index 25673393ce..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"; @@ -53,6 +54,8 @@ export interface TailscaleBindHost { family: "IPv4" | "IPv6"; } +const TAILSCALE_IP_COMMAND_TIMEOUT_MS = 1_000; + function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); @@ -181,8 +184,28 @@ function isLinkLocalAddress(address: string, family: "IPv4" | "IPv6"): boolean { 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() + networkInterfaces: NetworkInterfaces = os.networkInterfaces(), + tailscaleAddresses: ReadonlySet = getTailscaleCliAddresses() ): TailscaleBindHost[] { const hostsByAddress = new Map(); const emptyInfos: os.NetworkInterfaceInfo[] = []; @@ -200,9 +223,9 @@ export function getTailscaleBindHosts( continue; } - const isTailscaleAddress = - family === "IPv4" ? isTailscaleIpv4Address(address) : isTailscaleIpv6Address(address); - if (!isTailscaleInterfaceName(interfaceName) && !isTailscaleAddress) { + // 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; }