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.