Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
54 changes: 51 additions & 3 deletions src/browser/features/Settings/Sections/ExperimentsSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -409,6 +443,20 @@ function ConfigurableBindUrlControls() {
<SelectContent>
<SelectItem value="localhost">Localhost only (127.0.0.1)</SelectItem>
<SelectItem value="all">All interfaces (0.0.0.0)</SelectItem>
{tailscaleBindHosts.length > 0 ? (
tailscaleBindHosts.map((host) => (
<SelectItem
key={`${host.family}:${host.address}`}
value={getTailscaleBindHostMode(host.address)}
>
{formatTailscaleBindHostLabel(host)}
</SelectItem>
))
) : (
<SelectItem value="tailscale-unavailable" disabled>
{loading ? "Loading Tailscale devices…" : "Tailscale device not detected"}
</SelectItem>
)}
<SelectItem value="custom">Custom…</SelectItem>
</SelectContent>
</Select>
Expand Down
8 changes: 8 additions & 0 deletions src/common/orpc/schemas/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand All @@ -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). */
Expand Down
1 change: 1 addition & 0 deletions src/common/types/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
2 changes: 2 additions & 0 deletions src/node/orpc/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
81 changes: 80 additions & 1 deletion src/node/services/serverService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<typeof os.networkInterfaces> = {
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([]);
Expand Down
111 changes: 110 additions & 1 deletion src/node/services/serverService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ export interface StartServerOptions {

type NetworkInterfaces = NodeJS.Dict<os.NetworkInterfaceInfo[]>;

export interface TailscaleBindHost {
interfaceName: string;
address: string;
family: "IPv4" | "IPv6";
}

function isLoopbackHost(host: string): boolean {
const normalized = host.trim().toLowerCase();

Expand Down Expand Up @@ -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<string, TailscaleBindHost>();
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;
Comment thread
coadler marked this conversation as resolved.
Outdated
}

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).
*
Expand Down Expand Up @@ -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."
);
}

Expand Down Expand Up @@ -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.
Expand Down
Loading