Skip to content
Open
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
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
98 changes: 97 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,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<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.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<typeof os.networkInterfaces> = {
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([]);
Expand Down
Loading
Loading