Skip to content

Commit 21848f5

Browse files
authored
🤖 feat: configurable API server bind host experiment (#1288)
Adds an opt-in **Settings → Experiments** feature to expose mux's API server on LAN/VPN. - Persists bind host/port in `~/.mux/config.json` (`apiServerBindHost`, `apiServerPort`). - Computes and surfaces `networkBaseUrls` for copy/paste (LAN/VPN IPs) + auth token. - Adds RPC to view/update settings and restart the HTTP/WS server. Validation: - `make static-check` - `bun test src/node/services/serverService.test.ts src/node/services/serverLockfile.test.ts src/node/config.test.ts` --- <details> <summary>📋 Implementation Plan</summary> # Configurable bind URL for the local API server (Experiment) ## Answer (current behavior) - **Electron desktop always binds the HTTP/WS API server to `127.0.0.1`** (`src/node/services/serverService.ts`). - You can currently override: - `MUX_SERVER_PORT` (port; default is random) - `MUX_SERVER_AUTH_TOKEN` (auth token) - `MUX_NO_API_SERVER=1` (disable the server) - The standalone CLI server supports `--host`, but **the desktop app does not expose a bind-host setting today**. **Consequence:** if mux only listens on loopback, your phone (LAN) and VPN clients (e.g., Tailscale) can’t connect. --- ## Goal Add an **opt-in experiment** that lets users configure the **bind host/interface** (and ideally a stable port) for the desktop app’s HTTP/WS API server, with UI text that clearly explains: - enabling it makes mux listen beyond localhost, - other devices on your LAN/VPN can connect, - this has security implications. --- ## Recommended approach (config-backed experiment + connection info) **Net estimate:** ~350–500 LoC product code. ### What the experiment does (user-facing) When enabled, mux can bind its local API server to a non-loopback interface (e.g. `0.0.0.0` or a specific IP), making it reachable from: - **devices on your local network** (phone on the same Wi‑Fi), and - **VPN interfaces** (e.g. Tailscale). It should also surface **copy/paste connection info**: - connect URL(s) for detected interfaces (LAN IP, Tailscale IP, etc.) - the auth token required to access `/api` + `/orpc` ### Security/UX requirements - The experiment description must explicitly warn that: - **devices on your local network can connect** to your mux instance (with the token), and - requests are over **unencrypted HTTP** (token can be sniffed on untrusted networks). - Recommend: **only enable on trusted networks** and prefer **Tailscale** for encrypted transport. --- ## Implementation plan ### 1) Add a new experiment entry (UI discoverability) **Files:** - `src/common/constants/experiments.ts` **Steps:** - Add a new ID, e.g. `EXPERIMENT_IDS.CONFIGURABLE_BIND_URL = "configurable-bind-url"`. - Add a definition with clear copy, e.g.: - **name:** `"Expose API server on LAN/VPN"` - **description (draft):** - `"Allow mux to listen on a non-localhost address so other devices on your LAN/VPN can connect. Anyone on your network with the auth token can access your mux API. HTTP only; use only on trusted networks (Tailscale recommended)."` - `enabledByDefault: false` - `userOverridable: true` - `showInSettings: true` ### 2) Persist API server bind settings in `~/.mux/config.json` **Why:** main process needs this at app launch; localStorage isn’t available. **Files:** - `src/common/types/project.ts` (extend `ProjectsConfig`) - `src/node/config.ts` (load/save) **Add config fields (proposal):** - `apiServerBindHost?: string` (default: unset → `127.0.0.1`) - `apiServerPort?: number` (default: unset → current behavior; env var still overrides) Notes: - Keep behavior backward-compatible by treating missing fields as defaults. - Keep `MUX_SERVER_PORT` as highest-precedence override (useful for CI / power users). ### 3) Update server startup to respect the configured bind host **Files:** - `src/node/services/serverService.ts` - `src/desktop/main.ts` **Steps:** - Extend `StartServerOptions` to accept `host?: string`. - Pass `host` through to `createOrpcServer({ host, port, ... })`. - In `src/desktop/main.ts`, decide host/port in this precedence order: 1. `MUX_SERVER_PORT` env var (existing) 2. config `apiServerPort` 3. default `0` (random) and for host: 1. config `apiServerBindHost` 2. default `127.0.0.1` ### 4) Make connection URLs discoverable (LAN/Tailscale) Today `createOrpcServer` rewrites wildcard bind hosts (`0.0.0.0` / `::`) to `127.0.0.1` for `baseUrl`, which is fine for the desktop app + CLI discovery. To support phones/remote clients, add *additional* URLs rather than changing `baseUrl`: **Files:** - `src/node/services/serverLockfile.ts` - `src/node/services/serverService.ts` - (optional) `src/node/orpc/server.ts` (only if we want server.ts to return more metadata) **Steps:** - Extend `ServerLockDataSchema` to include optional fields, e.g.: - `bindHost?: string` - `port?: number` - `networkBaseUrls?: string[]` (derived from `os.networkInterfaces()`, filtered to non-internal addresses) - Update `ServerLockfile.acquire(...)` to accept and persist these optional fields. - In `ServerService.startServer`, after the server is listening (we know `actualPort`), compute: - `networkBaseUrls` for each relevant interface IP: `http://<ip>:<port>` - (optionally) include `ws://<ip>:<port>/orpc/ws` as well if needed later ### 5) Add backend RPC for viewing/updating bind settings (+ restarting server) We want the experiment UI to be able to: - read current bind settings - update them - restart the HTTP/WS server so changes apply immediately **Files:** - `src/common/orpc/schemas/api.ts` (add schemas under `server`) - `src/node/orpc/router.ts` (implement handlers) **Proposed API surface:** - `server.getApiServerStatus` → returns current: - `running: boolean` - `baseUrl` (loopback) - `networkBaseUrls` (if any) - `token` (so user can copy it for phone usage) - `bindHost`, `port` - `server.setApiServerSettings` → persists config and restarts the HTTP server. **Restart semantics:** - Restart only the HTTP/WS server (`ServerService.stopServer()` + `startServer()`); this should not break the Electron renderer since it uses MessagePort. - Defensive behavior: - Validate host string (non-empty) and port range before writing. - If restart fails, attempt to revert to the previous working settings. ### 6) Update Settings → Experiments UI to include controls + warnings **Files:** - `src/browser/components/Settings/sections/ExperimentsSection.tsx` **Approach:** - Keep the experiment toggle in the experiments list, but for this specific ID render an “expanded” row beneath it that: - chooses bind host: - `127.0.0.1` (localhost only) - `0.0.0.0` (all interfaces: LAN + VPN) - optional: “Custom…” text input - chooses port: - default “Random (changes each start)” vs a numeric input - shows computed `networkBaseUrls` + a “Copy” button - shows the token + “Copy token” button - shows a bold warning blurb (see copy below) **Suggested warning copy (UI):** > Exposes mux’s API server to your LAN/VPN. Devices on your local network can connect if they have the auth token. Traffic is unencrypted HTTP; enable only on trusted networks (Tailscale recommended). --- ## Validation - Unit tests for: - `ServerLockDataSchema` backward compatibility (old lock files still parse) - network URL computation (filters out internal interfaces; stable ordering) - Manual checks: - With bind host `0.0.0.0` and fixed port, confirm phone can load `http://<LAN-IP>:<port>/api/docs`. - Confirm `/api/*` requests require the auth token. --- <details> <summary>Alternatives considered</summary> ### A) Environment variable only (`MUX_SERVER_HOST`) **Net estimate:** ~20–40 LoC. - Add `MUX_SERVER_HOST` env var read in `src/desktop/main.ts` and pass into `ServerService.startServer`. - No experiments UI. Pros: trivial. Cons: doesn’t satisfy “Experiments tab” UX + doesn’t help discoverability. ### B) Make the existing experiments system config-backed **Net estimate:** ~250–400 LoC *in addition* to the feature. - Today experiment overrides are stored in localStorage; main process can’t read them. - We could generalize experiments to optionally persist overrides in `~/.mux/config.json` and expose them to both main + renderer. Pros: more consistent long-term. Cons: larger cross-cutting refactor than necessary for a single setting. </details> </details> --- _Generated with `mux` • Model: `openai:gpt-5.2` • Thinking: `xhigh`_ --------- Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent c7f7323 commit 21848f5

File tree

18 files changed

+1211
-33
lines changed

18 files changed

+1211
-33
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import React from "react";
2+
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
3+
import { GlobalWindow } from "happy-dom";
4+
import { cleanup, render } from "@testing-library/react";
5+
6+
void mock.module("@/browser/contexts/API", () => ({
7+
APIProvider: (props: { children: React.ReactNode }) => props.children,
8+
useAPI: () => ({
9+
api: null,
10+
status: "auth_required" as const,
11+
error: "Authentication required",
12+
authenticate: () => undefined,
13+
retry: () => undefined,
14+
}),
15+
}));
16+
17+
void mock.module("@/browser/components/AuthTokenModal", () => ({
18+
AuthTokenModal: (props: { error?: string | null }) => (
19+
<div data-testid="AuthTokenModalMock">{props.error ?? "no-error"}</div>
20+
),
21+
}));
22+
23+
import { AppLoader } from "./AppLoader";
24+
25+
describe("AppLoader", () => {
26+
beforeEach(() => {
27+
const dom = new GlobalWindow();
28+
globalThis.window = dom as unknown as Window & typeof globalThis;
29+
globalThis.document = globalThis.window.document;
30+
});
31+
32+
afterEach(() => {
33+
cleanup();
34+
globalThis.window = undefined as unknown as Window & typeof globalThis;
35+
globalThis.document = undefined as unknown as Document;
36+
});
37+
38+
test("renders AuthTokenModal when API status is auth_required (before workspaces load)", () => {
39+
const { getByTestId, queryByText } = render(<AppLoader />);
40+
41+
expect(queryByText("Loading workspaces...")).toBeNull();
42+
expect(getByTestId("AuthTokenModalMock").textContent).toContain("Authentication required");
43+
});
44+
});

src/browser/components/AppLoader.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect } from "react";
22
import App from "../App";
3+
import { AuthTokenModal } from "./AuthTokenModal";
34
import { LoadingScreen } from "./LoadingScreen";
45
import { useWorkspaceStoreRaw } from "../stores/WorkspaceStore";
56
import { useGitStatusStoreRaw } from "../stores/GitStatusStore";
@@ -42,7 +43,8 @@ export function AppLoader(props: AppLoaderProps) {
4243
function AppLoaderInner() {
4344
const workspaceContext = useWorkspaceContext();
4445
const projectContext = useProjectContext();
45-
const { api } = useAPI();
46+
const apiState = useAPI();
47+
const api = apiState.api;
4648

4749
// Get store instances
4850
const workspaceStore = useWorkspaceStoreRaw();
@@ -73,6 +75,11 @@ function AppLoaderInner() {
7375
api,
7476
]);
7577

78+
// If we're in browser mode and auth is required, show the token prompt before any data loads.
79+
if (apiState.status === "auth_required") {
80+
return <AuthTokenModal isOpen={true} onSubmit={apiState.authenticate} error={apiState.error} />;
81+
}
82+
7683
// Show loading screen until both projects and workspaces are loaded and stores synced
7784
if (projectContext.loading || workspaceContext.loading || !storesSynced) {
7885
return <LoadingScreen />;

0 commit comments

Comments
 (0)