diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index f005a21bd3..9d2d8b76b3 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -63,6 +63,7 @@ SERVER_PORT="${SERVER_PORT:-3000}" SERVER_HOST="${SERVER_HOST:-localhost}" STARTUP_TIMEOUT="${STARTUP_TIMEOUT:-30}" HEALTHCHECK_TIMEOUT="${HEALTHCHECK_TIMEOUT:-10}" +AUTH_TOKEN="smoke-test-token-$(date +%s)" # When set to "1", strip npm-shrinkwrap.json from the package before installing. # This simulates package managers like `bun x` that ignore shrinkwrap, catching # dependency resolution issues that the lockfile would otherwise mask. @@ -148,7 +149,7 @@ log_info "✅ mux api subcommand works" # Start the server in background log_info "Starting mux server on $SERVER_HOST:$SERVER_PORT..." -node_modules/.bin/mux server --host "$SERVER_HOST" --port "$SERVER_PORT" >server.log 2>&1 & +node_modules/.bin/mux server --host "$SERVER_HOST" --port "$SERVER_PORT" --auth-token "$AUTH_TOKEN" >server.log 2>&1 & SERVER_PID=$! log_info "Server started with PID: $SERVER_PID" @@ -238,7 +239,10 @@ const PROJECT_DIR = '$PROJECT_DIR'; async function runTests() { // Test 1: HTTP oRPC client - create project console.log('Testing oRPC project creation via HTTP...'); - const httpLink = new RPCLink({ url: ORPC_URL }); + const httpLink = new RPCLink({ + url: ORPC_URL, + headers: { 'Authorization': 'Bearer ${AUTH_TOKEN}' } + }); const client = createORPCClient(httpLink); const projectResult = await client.projects.create({ projectPath: PROJECT_DIR }); @@ -262,7 +266,7 @@ async function runTests() { // Test 3: WebSocket connection console.log('Testing oRPC WebSocket connection...'); await new Promise((resolve, reject) => { - const ws = new WebSocket(WS_URL); + const ws = new WebSocket(WS_URL, { headers: { 'Authorization': 'Bearer ${AUTH_TOKEN}' } }); const timeout = setTimeout(() => { ws.close(); reject(new Error('WebSocket connection timed out')); diff --git a/src/cli/run.test.ts b/src/cli/run.test.ts index 72baf539aa..65f8f9e1b0 100644 --- a/src/cli/run.test.ts +++ b/src/cli/run.test.ts @@ -242,6 +242,8 @@ describe("mux CLI", () => { expect(result.stdout).toContain("--host"); expect(result.stdout).toContain("--port"); expect(result.stdout).toContain("--auth-token"); + expect(result.stdout).toContain("--no-auth"); + expect(result.stdout).toContain("--print-auth-token"); expect(result.stdout).toContain("--add-project"); }); }); diff --git a/src/cli/server.ts b/src/cli/server.ts index 43b90b7c7a..d21081dc47 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -1,22 +1,17 @@ /** * CLI entry point for the mux oRPC server. - * Uses createOrpcServer from ./orpcServer.ts for the actual server logic. + * Uses ServerService for server lifecycle management. */ import { Config } from "@/node/config"; import { ServiceContainer } from "@/node/services/serviceContainer"; -import { ServerLockfile } from "@/node/services/serverLockfile"; import { getMuxHome, migrateLegacyMuxHome } from "@/common/constants/paths"; +import { ServerLockfile } from "@/node/services/serverLockfile"; import type { BrowserWindow } from "electron"; import { Command } from "commander"; import { validateProjectPath } from "@/node/utils/pathUtils"; -import { createOrpcServer } from "@/node/orpc/server"; import { VERSION } from "@/version"; -import { - buildMuxMdnsServiceOptions, - MdnsAdvertiserService, -} from "@/node/services/mdnsAdvertiserService"; -import * as os from "os"; import { getParseOptions } from "./argv"; +import { resolveServerAuthToken } from "./serverAuthToken"; const program = new Command(); program @@ -24,7 +19,9 @@ program .description("HTTP/WebSocket ORPC server for mux") .option("-h, --host ", "bind to specific host", "localhost") .option("-p, --port ", "bind to specific port", "3000") - .option("--auth-token ", "optional bearer token for HTTP/WS auth") + .option("--auth-token ", "bearer token for HTTP/WS auth (default: auto-generated)") + .option("--no-auth", "disable authentication (server is open to anyone who can reach it)") + .option("--print-auth-token", "always print the auth token on startup") .option("--ssh-host ", "SSH hostname/alias for editor deep links (e.g., devbox)") .option("--add-project ", "add and open project at the specified path (idempotent)") .parse(process.argv, getParseOptions()); @@ -32,8 +29,11 @@ program const options = program.opts(); const HOST = options.host as string; const PORT = Number.parseInt(String(options.port), 10); -const rawAuthToken = (options.authToken as string | undefined) ?? process.env.MUX_SERVER_AUTH_TOKEN; -const AUTH_TOKEN = rawAuthToken?.trim() ? rawAuthToken.trim() : undefined; +const resolved = resolveServerAuthToken({ + noAuth: options.noAuth === true || options.auth === false, + cliToken: options.authToken as string | undefined, + envToken: process.env.MUX_SERVER_AUTH_TOKEN, +}); const ADD_PROJECT_PATH = options.addProject as string | undefined; // SSH host for editor deep links (CLI flag > env var > config file, resolved later) const CLI_SSH_HOST = options.sshHost as string | undefined; @@ -41,17 +41,6 @@ const CLI_SSH_HOST = options.sshHost as string | undefined; // Track the launch project path for initial navigation let launchProjectPath: string | null = null; -function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - - // IPv4 loopback range (RFC 1122): 127.0.0.0/8 - if (normalized.startsWith("127.")) { - return true; - } - - return normalized === "localhost" || normalized === "::1"; -} - // Minimal BrowserWindow stub for services that expect one const mockWindow: BrowserWindow = { isDestroyed: () => false, @@ -75,9 +64,13 @@ const mockWindow: BrowserWindow = { migrateLegacyMuxHome(); - // Check for existing server (Electron or another mux server instance) - const lockfile = new ServerLockfile(getMuxHome()); - const existing = await lockfile.read(); + // Early lockfile check: detect an existing server BEFORE initializing services. + // serviceContainer.initialize() resumes queued/running tasks (via TaskService), + // so we must fail fast here to avoid orphaned side effects when another server + // already holds the lock. ServerService.startServer() re-checks as defense-in-depth. + const muxHome = getMuxHome(); + const earlyLockfile = new ServerLockfile(muxHome); + const existing = await earlyLockfile.read(); if (existing) { console.error(`Error: mux API server is already running at ${existing.baseUrl}`); console.error(`Use 'mux api' commands to interact with the running instance.`); @@ -102,45 +95,63 @@ const mockWindow: BrowserWindow = { const context = serviceContainer.toORPCContext(); - const mdnsAdvertiser = new MdnsAdvertiserService(); - const server = await createOrpcServer({ + // Start server via ServerService (handles lockfile, mDNS, network URLs) + const serverInfo = await serviceContainer.serverService.startServer({ + muxHome: serviceContainer.config.rootDir, + context, host: HOST, port: PORT, - authToken: AUTH_TOKEN, - context, + authToken: resolved.token, serveStatic: true, }); // Server is now listening - clear the startup keepalive since httpServer keeps the loop alive clearInterval(startupKeepalive); - // Acquire lockfile so other instances know we're running - await lockfile.acquire(server.baseUrl, AUTH_TOKEN ?? ""); - - const mdnsAdvertisementEnabled = config.getMdnsAdvertisementEnabled(); - if (mdnsAdvertisementEnabled !== false && !isLoopbackHost(HOST)) { - const instanceName = config.getMdnsServiceName() ?? `mux-${os.hostname()}`; - const serviceOptions = buildMuxMdnsServiceOptions({ - bindHost: HOST, - port: server.port, - instanceName, - version: VERSION.git_describe, - authRequired: AUTH_TOKEN?.trim().length ? true : false, - }); - - try { - await mdnsAdvertiser.start(serviceOptions); - } catch (err) { - console.warn("Failed to advertise mux API server via mDNS:", err); + // --- Startup output --- + console.log(`\nmux server v${VERSION.git_describe}`); + console.log(` URL: ${serverInfo.baseUrl}`); + if (serverInfo.networkBaseUrls.length > 0) { + for (const url of serverInfo.networkBaseUrls) { + console.log(` LAN: ${url}`); } - } else if (mdnsAdvertisementEnabled === true && isLoopbackHost(HOST)) { + } + console.log(` Docs: ${serverInfo.baseUrl}/api/docs`); + + if (resolved.mode === "disabled") { console.warn( - "mDNS advertisement requested, but the API server is loopback-only. " + - "Set --host 0.0.0.0 (or a LAN IP) to enable LAN discovery." + "\nWARNING: Authentication is DISABLED (--no-auth). The server is open to anyone who can reach it." ); - } + } else { + console.log(`\n Auth: enabled (token source: ${resolved.source})`); + + // Print token when explicitly requested or when network-accessible + const showToken = + options.printAuthToken === true || + serverInfo.networkBaseUrls.length > 0 || + resolved.source === "generated"; + if (showToken) { + // Use a LAN-reachable URL for remote connection instructions when available, + // since baseUrl is loopback (127.0.0.1) even when binding to 0.0.0.0. + const remoteUrl = + serverInfo.networkBaseUrls.length > 0 ? serverInfo.networkBaseUrls[0] : serverInfo.baseUrl; + // Shell-quote the token to handle metacharacters ($, &, spaces, etc.) + const shellToken = `'${resolved.token.replace(/'/g, "'\\''")}'`; + const urlToken = encodeURIComponent(resolved.token); + + console.log(`\n # Connect from another machine:`); + console.log(` export MUX_SERVER_URL=${remoteUrl}`); + console.log(` export MUX_SERVER_AUTH_TOKEN=${shellToken}`); + console.log(`\n # Open in browser:`); + console.log(` ${remoteUrl}/?token=${urlToken}`); + } - console.log(`Server is running on ${server.baseUrl}`); + const lockfilePath = serviceContainer.serverService.getLockfilePath(); + if (lockfilePath) { + console.log(`\n Token stored in: ${lockfilePath}`); + } + } + console.log(""); // blank line // Cleanup on shutdown let cleanupInProgress = false; @@ -157,21 +168,14 @@ const mockWindow: BrowserWindow = { }, 5000); try { - // Close all PTY sessions first (these are the "sub-processes" nodemon sees) + // Close all PTY sessions first serviceContainer.terminalService.closeAllSessions(); // Dispose background processes await serviceContainer.dispose(); - // Release lockfile and close server - try { - await mdnsAdvertiser.stop(); - } catch (err) { - console.warn("Failed to stop mDNS advertiser:", err); - } - - await lockfile.release(); - await server.close(); + // Stop server (releases lockfile, stops mDNS, closes HTTP server) + await serviceContainer.serverService.stopServer(); clearTimeout(forceExitTimer); process.exit(0); diff --git a/src/cli/serverAuthToken.test.ts b/src/cli/serverAuthToken.test.ts new file mode 100644 index 0000000000..b69c27db8e --- /dev/null +++ b/src/cli/serverAuthToken.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from "bun:test"; +import { resolveServerAuthToken } from "./serverAuthToken"; + +describe("resolveServerAuthToken", () => { + test("returns disabled mode when noAuth is true regardless of other values", () => { + expect( + resolveServerAuthToken({ + noAuth: true, + cliToken: "abc", + envToken: "env-token", + }) + ).toEqual({ mode: "disabled", token: "" }); + }); + + test("uses cliToken when provided", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: "abc", + envToken: "env-token", + }) + ).toEqual({ mode: "enabled", token: "abc", source: "cli" }); + }); + + test("trims cliToken", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: " abc ", + envToken: "env-token", + }) + ).toEqual({ mode: "enabled", token: "abc", source: "cli" }); + }); + + test("falls through to envToken when cliToken is empty", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: "", + envToken: "env-token", + }) + ).toEqual({ mode: "enabled", token: "env-token", source: "env" }); + }); + + test("falls through to envToken when cliToken is whitespace only", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: " ", + envToken: "env-token", + }) + ).toEqual({ mode: "enabled", token: "env-token", source: "env" }); + }); + + test("uses envToken when cliToken is not provided", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: undefined, + envToken: "env-token", + }) + ).toEqual({ mode: "enabled", token: "env-token", source: "env" }); + }); + + test("trims envToken", () => { + expect( + resolveServerAuthToken({ + noAuth: false, + cliToken: undefined, + envToken: " env-token ", + }) + ).toEqual({ mode: "enabled", token: "env-token", source: "env" }); + }); + + test("uses generated token when neither cliToken nor envToken is provided", () => { + const generated = resolveServerAuthToken({ + noAuth: false, + cliToken: undefined, + envToken: undefined, + randomBytesFn: () => Buffer.from("a".repeat(32)), + }); + + expect(generated).toEqual({ + mode: "enabled", + token: Buffer.from("a".repeat(32)).toString("hex"), + source: "generated", + }); + }); + + test("noAuth still wins when cliToken is set", () => { + expect( + resolveServerAuthToken({ + noAuth: true, + cliToken: "abc", + envToken: undefined, + }) + ).toEqual({ mode: "disabled", token: "" }); + }); +}); diff --git a/src/cli/serverAuthToken.ts b/src/cli/serverAuthToken.ts new file mode 100644 index 0000000000..8f2487e3db --- /dev/null +++ b/src/cli/serverAuthToken.ts @@ -0,0 +1,35 @@ +import { randomBytes } from "crypto"; + +export type ResolvedAuthToken = + | { mode: "disabled"; token: "" } + | { mode: "enabled"; token: string; source: "cli" | "env" | "generated" }; + +/** + * Resolve the auth token for `mux server` from CLI flags, env vars, or generate one. + * + * Precedence: --no-auth > --auth-token > MUX_SERVER_AUTH_TOKEN > auto-generated. + */ +export function resolveServerAuthToken(opts: { + noAuth: boolean; + cliToken: string | undefined; + envToken: string | undefined; + /** Injectable for deterministic testing. */ + randomBytesFn?: (n: number) => Buffer; +}): ResolvedAuthToken { + if (opts.noAuth) { + return { mode: "disabled", token: "" }; + } + + const cli = opts.cliToken?.trim(); + if (cli) { + return { mode: "enabled", token: cli, source: "cli" }; + } + + const env = opts.envToken?.trim(); + if (env) { + return { mode: "enabled", token: env, source: "env" }; + } + + const gen = (opts.randomBytesFn ?? randomBytes)(32).toString("hex"); + return { mode: "enabled", token: gen, source: "generated" }; +} diff --git a/src/node/services/serverService.ts b/src/node/services/serverService.ts index 842cdd4f2f..7ee7271e33 100644 --- a/src/node/services/serverService.ts +++ b/src/node/services/serverService.ts @@ -335,4 +335,12 @@ export class ServerService { isServerRunning(): boolean { return this.server !== null; } + + /** + * Get the path to the server lockfile (for displaying to users). + * Returns null if no server lockfile has been acquired yet. + */ + getLockfilePath(): string | null { + return this.lockfile?.getLockPath() ?? null; + } }