diff --git a/Dockerfile b/Dockerfile index 546463e83..636e33387 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # syntax=docker/dockerfile:1.7 -ARG NODE_IMAGE=node:22-alpine +ARG NODE_IMAGE=node:20.20.2-alpine FROM ${NODE_IMAGE} AS base WORKDIR /app @@ -7,9 +7,9 @@ FROM base AS builder RUN apk --no-cache upgrade && apk --no-cache add python3 make g++ linux-headers -COPY package.json ./ +COPY package.json package-lock.json ./ RUN --mount=type=cache,target=/root/.npm \ - npm install + npm ci COPY . ./ ENV NEXT_TELEMETRY_DISABLED=1 diff --git a/cli/cli.js b/cli/cli.js index 140df2939..e341e6980 100755 --- a/cli/cli.js +++ b/cli/cli.js @@ -4,7 +4,16 @@ const { spawn, exec, execSync } = require("child_process"); const path = require("path"); const fs = require("fs"); const https = require("https"); +const http = require("http"); +const crypto = require("crypto"); const os = require("os"); +const { stopMitmViaManagerSync } = require("./src/cli/mitmManagerStop"); +const { getAppDataDir, getGlobalMitmStateDir } = require("./src/cli/appDataDir"); + +// Resolve once before any worker changes cwd. Every CLI helper and the Next +// worker inherit the same absolute path, so database, PID, CA, and auth-token +// state cannot split across process working directories. +process.env.DATA_DIR = getAppDataDir(); // Native spinner - no external dependency function createSpinner(text) { @@ -85,7 +94,6 @@ if (hasFlag("--version", "-v")) { const { ensureSqliteRuntime, buildEnvWithRuntime } = require("./hooks/sqliteRuntime"); const { ensureTrayRuntime } = require("./hooks/trayRuntime"); -const { cleanupMitmHostsFile } = require("./hooks/cleanupMitmHosts"); // Self-heal SQLite runtime deps (sql.js + better-sqlite3) into ~/.9router/runtime @@ -164,13 +172,6 @@ function compareVersions(a, b) { return 0; } -// Get app data dir (matches app/src/lib/dataDir.js convention) -function getAppDataDir() { - return process.platform === "win32" - ? path.join(process.env.APPDATA || "", "9router") - : path.join(os.homedir(), ".9router"); -} - // Kill PID from file (best-effort, removes file after) function killByPidFile(pidFile) { try { @@ -227,11 +228,14 @@ function killCloudflaredByAppPort(appPort) { // Kill all 9router processes function killAllAppProcesses(appPort) { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { try { - cleanupMitmHostsFile(); - // Kill MIT first (privileged process, needs special handling) - killProxyByPidFile(); + const mitmPidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid"); + const managerStopped = stopMitmViaManagerSync(appPort); + if (!managerStopped && fs.existsSync(mitmPidFile)) { + reject(new Error("MITM manager cleanup could not be confirmed; refusing to orphan system redirect state")); + return; + } // Kill Headroom proxy by PID file — detached process that outlives the main server. // Must stop before npm rename; it holds a handle on the app/ directory on Windows (#2265). killByPidFile(path.join(getAppDataDir(), "headroom", "proxy.pid")); @@ -331,48 +335,6 @@ function sleepSync(ms) { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* ignore */ } } -// Wait until process dies or timeout reached -function waitForExit(pid, timeoutMs) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { process.kill(pid, 0); } catch { return true; } - sleepSync(100); - } - return false; -} - -// Kill MIT server by PID file (runs privileged, needs special handling) -// Sends SIGTERM first so MIT can clean up host entries before dying. -function killProxyByPidFile() { - try { - const pidFile = path.join(getAppDataDir(), "mitm", ".mitm.pid"); - if (!fs.existsSync(pidFile)) return; - const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); - if (!pid) return; - - if (process.platform === "win32") { - // Graceful first (lets server cleanup hosts), then force - try { execSync(`taskkill /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { } - if (!waitForExit(pid, 1500)) { - try { execSync(`taskkill /F /T /PID ${pid}`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } - } - // Last-resort: PowerShell Stop-Process (sometimes succeeds where taskkill fails on admin processes) - if (!waitForExit(pid, 500)) { - try { execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "Stop-Process -Id ${pid} -Force"`, { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } - } - } else { - // SIGTERM via cached sudo token first - try { execSync(`sudo -n kill -TERM ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } - catch { try { process.kill(pid, "SIGTERM"); } catch { } } - if (!waitForExit(pid, 1500)) { - try { execSync(`sudo -n kill -9 ${pid} 2>/dev/null`, { stdio: "ignore", timeout: 2000 }); } - catch { try { process.kill(pid, "SIGKILL"); } catch { } } - } - } - try { fs.unlinkSync(pidFile); } catch { } - } catch { } -} - // Kill any process on specific port function killProcessOnPort(port) { return new Promise((resolve) => { @@ -506,13 +468,100 @@ function openBrowser(url) { }); } -// Find standalone server (bundled in bin/app for published package). -// Prefer custom-server.js (injects real socket IP) when present. +// The owner-aware wrapper is mandatory: starting the bare Next server would +// bypass real-peer, anti-spoofing, and privileged-control proof checks. const standaloneDir = path.join(__dirname, "app"); const customServerPath = path.join(standaloneDir, "custom-server.js"); -const serverPath = fs.existsSync(customServerPath) - ? customServerPath - : path.join(standaloneDir, "server.js"); +if (!fs.existsSync(customServerPath)) { + console.error("Error: owner-aware custom-server.js is missing. Reinstall DurinDoor."); + process.exit(1); +} +const serverPath = customServerPath; +const { INTENTIONAL_HANDOFF_EXIT_CODE } = require( + path.join(standaloneDir, "src", "shared", "constants", "processExitCodes.js"), +); +const { isIntentionalWorkerHandoff } = require("./src/cli/workerExit"); + +function hasStaleMitmOwnership() { + const mitmDir = path.join(getAppDataDir(), "mitm"); + return fs.existsSync(path.join(mitmDir, ".mitm.pid")) + || fs.existsSync(path.join(getGlobalMitmStateDir(), "redirect.json")); +} + +function waitForWorkerIdentity(child, expectedNonce, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs; + return new Promise((resolve, reject) => { + let settled = false; + const finish = (error) => { + if (settled) return; + settled = true; + child.removeListener("exit", onExit); + if (error) reject(error); else resolve(); + }; + const onExit = (code) => finish(new Error(`Recovery worker exited before readiness (code ${code})`)); + child.once("exit", onExit); + const attempt = () => { + if (settled) return; + const req = http.request({ + hostname: "127.0.0.1", + port, + path: "/api/health", + method: "GET", + timeout: 1000, + }, (res) => { + const matches = res.statusCode >= 200 + && res.statusCode < 300 + && res.headers["x-durindoor-worker-nonce"] === expectedNonce; + res.resume(); + res.on("end", () => { + if (matches) finish(); + else if (Date.now() >= deadline) finish(new Error("Recovery worker identity could not be verified")); + else setTimeout(attempt, 250); + }); + }); + req.on("timeout", () => req.destroy()); + req.on("error", () => { + if (Date.now() >= deadline) finish(new Error("Recovery worker did not become ready")); + else setTimeout(attempt, 250); + }); + req.end(); + }; + attempt(); + }); +} + +async function recoverStaleMitmOwnershipBeforeStartup() { + if (!hasStaleMitmOwnership()) return; + if (stopMitmViaManagerSync(port, { preserveDesiredState: true })) return; + + const nonce = crypto.randomBytes(24).toString("hex"); + const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { + cwd: standaloneDir, + // A recovery worker may intentionally outlive this CLI after a failed + // cleanup. Ignore inherited output so no referenced/fillable pipe can keep + // the parent alive or stall that retained worker. + stdio: "ignore", + detached: true, + windowsHide: true, + env: { + ...buildEnvWithRuntime(process.env), + PORT: port.toString(), + HOSTNAME: "127.0.0.1", + DURINDOOR_WORKER_NONCE: nonce, + }, + }); + try { + await waitForWorkerIdentity(child, nonce); + if (!stopMitmViaManagerSync(port, { preserveDesiredState: true })) { + throw new Error("Recovery worker could not clean stale MITM ownership"); + } + try { process.kill(child.pid, "SIGTERM"); } catch { /* already stopped */ } + try { process.kill(-child.pid, "SIGKILL"); } catch { /* platform/process-group fallback */ } + } catch (error) { + child.unref(); + throw new Error(`${error.message}; recovery worker was retained for a safe cleanup retry`); + } +} if (!fs.existsSync(serverPath)) { console.error("Error: Standalone build not found."); @@ -521,12 +570,16 @@ if (!fs.existsSync(serverPath)) { } // Check for updates FIRST, then start server -checkForUpdate().then((latestVersion) => { - killAllAppProcesses(port).then(() => { +checkForUpdate().then(async (latestVersion) => { + await recoverStaleMitmOwnershipBeforeStartup(); + return killAllAppProcesses(port).then(() => { return killProcessOnPort(port); }).then(() => { startServer(latestVersion); }); +}).catch((error) => { + console.error(`Startup cleanup failed: ${error.message}`); + process.exitCode = 1; }); // Show interface selection menu @@ -588,11 +641,12 @@ function startServer(latestVersion) { let restartCount = 0; let serverStartTime = Date.now(); + let recoveryInProgress = false; const CRASH_LOG_LINES = 50; let crashLog = []; - function spawnServer() { + function spawnServer(extraEnv = {}) { serverStartTime = Date.now(); crashLog = []; const child = spawn(RUNTIME, ["--max-old-space-size=6144", serverPath], { @@ -603,7 +657,8 @@ function startServer(latestVersion) { env: { ...buildEnvWithRuntime(process.env), PORT: port.toString(), - HOSTNAME: host + HOSTNAME: host, + ...extraEnv, } }); if (!showLog && child.stderr) { @@ -621,20 +676,21 @@ function startServer(latestVersion) { // Cleanup function - force kill server process let isCleaningUp = false; function cleanup() { - if (isCleaningUp) return; + if (isCleaningUp) return false; isCleaningUp = true; try { - // Parent CLI must clean hosts — Next.js child is SIGKILL'd below and - // never runs initializeApp's removeAllDNSEntriesSync(). - cleanupMitmHostsFile(); + const mitmStopped = stopMitmViaManagerSync(port); + if (!mitmStopped) { + console.error("MITM manager cleanup could not be confirmed; leaving the app worker alive to preserve system redirect ownership."); + isCleaningUp = false; + return false; + } // Kill tray if running try { const { killTray } = require("./src/cli/tray/tray"); killTray(); } catch (e) { } - // Kill MIT server (privileged process) via PID file - killProxyByPidFile(); // Kill Headroom proxy (detached process, holds handle on app/ on Windows) killByPidFile(path.join(getAppDataDir(), "headroom", "proxy.pid")); // Kill cloudflared/tailscale via PID file (only this app's tunnel) @@ -652,7 +708,21 @@ function startServer(latestVersion) { if (server?.pid) { try { process.kill(-server.pid, "SIGKILL"); } catch (e) { } } - } catch (e) { } + return true; + } catch (error) { + console.error(`Cleanup failed: ${error.message}`); + isCleaningUp = false; + return false; + } + } + + function exitAfterCleanup(code = 0, delayMs = 100) { + if (!cleanup()) { + isShuttingDown = false; + return false; + } + setTimeout(() => process.exit(code), delayMs); + return true; } // Suppress all errors during shutdown (systray lib throws JSON parse errors) @@ -667,20 +737,17 @@ function startServer(latestVersion) { if (isShuttingDown) return; isShuttingDown = true; console.log("\nExiting..."); - cleanup(); - setTimeout(() => process.exit(0), 100); + exitAfterCleanup(0); }); process.on("SIGTERM", () => { if (isShuttingDown) return; isShuttingDown = true; - cleanup(); - setTimeout(() => process.exit(0), 100); + exitAfterCleanup(0); }); process.on("SIGHUP", () => { if (isShuttingDown) return; isShuttingDown = true; - cleanup(); - setTimeout(() => process.exit(0), 100); + exitAfterCleanup(0); }); // Initialize tray icon (runs alongside TUI) @@ -692,8 +759,7 @@ function startServer(latestVersion) { onQuit: () => { isShuttingDown = true; console.log("\nšŸ‘‹ Shutting down from tray..."); - cleanup(); - setTimeout(() => process.exit(0), 100); + exitAfterCleanup(0); }, onOpenDashboard: () => openBrowser(url) }); @@ -722,6 +788,7 @@ function startServer(latestVersion) { // Wait for server to be ready, then show interface menu loop + tray setTimeout(async () => { + if (recoveryInProgress) return; // Start tray icon alongside TUI initTrayIcon(); @@ -736,7 +803,10 @@ function startServer(latestVersion) { console.log(`\n⬆ Update v${pkg.version} → v${latestVersion}\n`); console.log(`Run this after exit:\n`); console.log(` \x1b[33m${INSTALL_CMD_LATEST}\x1b[0m\n`); - cleanup(); + if (!cleanup()) { + isShuttingDown = false; + continue; + } await killAllAppProcesses(port); await killProcessOnPort(port); setTimeout(() => process.exit(0), 200); @@ -776,6 +846,14 @@ function startServer(latestVersion) { return; } + // Stop the current worker before creating its replacement. If MITM + // ownership cleanup cannot be confirmed, do not fork and orphan it. + isShuttingDown = true; + if (!cleanup()) { + isShuttingDown = false; + continue; + } + // Windows/Linux: spawn detached bgProcess (systray works fine in child) console.log(`\nā³ Starting background process... (tray icon will appear in ~3s)`); @@ -791,36 +869,49 @@ function startServer(latestVersion) { console.log(` Server: http://${displayHost}:${port}`); console.log(`\nšŸ’” You can close this terminal. Right-click tray icon to quit.\n`); - // cleanup() kills server so bgProcess can claim the port fresh - cleanup(); process.exit(0); } else if (choice === "exit") { isShuttingDown = true; console.log("\nExiting..."); - cleanup(); - setTimeout(() => process.exit(0), 100); + exitAfterCleanup(0); } } } catch (err) { console.error("Error:", err.message); - cleanup(); - process.exit(1); + isShuttingDown = true; + exitAfterCleanup(1, 0); } }, 3000); function attachServerEvents() { server.on("error", (err) => { console.error("Failed to start server:", err.message); + if (recoveryInProgress) return; if (!isShuttingDown) tryRestart(); - else { cleanup(); process.exit(1); } + else if (cleanup()) process.exit(1); }); server.on("close", (code) => { - if (isShuttingDown || code === 0) { + if (recoveryInProgress) return; + if (isIntentionalWorkerHandoff( + code, + INTENTIONAL_HANDOFF_EXIT_CODE, + hasStaleMitmOwnership(), + )) { + isShuttingDown = true; + process.exit(0); + return; + } + if (isShuttingDown) { process.exit(code || 0); return; } - tryRestart(code); + if (code === INTENTIONAL_HANDOFF_EXIT_CODE) { + console.error("Intentional worker handoff was rejected because MITM ownership state remains."); + } + // A clean Next.js exit is still unexpected at the CLI layer. Restart so + // the manager can re-adopt or explicitly clean any active MITM state. + tryRestart(code || 1); }); } @@ -830,18 +921,9 @@ function startServer(latestVersion) { if (aliveMs >= RESTART_RESET_MS) restartCount = 0; if (restartCount >= MAX_RESTARTS) { - console.error(`\nāš ļø Server crashed ${MAX_RESTARTS} times. Disabling MIT and restarting...`); - try { - const dbPath = path.join(os.homedir(), process.platform === "win32" ? path.join("AppData", "Roaming", "9router", "db.json") : path.join(".9router", "db.json")); - if (fs.existsSync(dbPath)) { - const db = JSON.parse(fs.readFileSync(dbPath, "utf-8")); - if (db.settings) db.settings.mitmEnabled = false; - fs.writeFileSync(dbPath, JSON.stringify(db, null, 2)); - } - } catch { /* best effort */ } - restartCount = 0; - server = spawnServer(); - attachServerEvents(); + console.error(`\nāš ļø Server crashed ${MAX_RESTARTS} times. Starting one recovery worker to clean MITM system state...`); + recoveryInProgress = true; + void recoverAfterRestartExhaustion(); return; } @@ -860,5 +942,32 @@ function startServer(latestVersion) { }, delay); } + async function recoverAfterRestartExhaustion() { + try { + const nonce = crypto.randomBytes(24).toString("hex"); + server = spawnServer({ DURINDOOR_WORKER_NONCE: nonce }); + attachServerEvents(); + await waitForWorkerIdentity(server, nonce); + if (!stopMitmViaManagerSync(port, { preserveDesiredState: false })) { + throw new Error("MITM manager cleanup could not be confirmed on the recovery worker"); + } + console.error("MITM system state was cleaned and disabled; exiting instead of continuing the crash loop."); + isShuttingDown = true; + if (!cleanup()) { + isShuttingDown = false; + throw new Error("Recovery worker shutdown cleanup could not be confirmed"); + } + process.exit(1); + } catch (error) { + // Keep a live recovery worker when possible; it retains ownership and + // gives the operator a safe surface from which to retry cleanup. + console.error(`Recovery cleanup failed: ${error.message}`); + if (server?.exitCode != null || server?.signalCode != null) { + recoveryInProgress = false; + } + isShuttingDown = false; + } + } + attachServerEvents(); } diff --git a/cli/package.json b/cli/package.json index c065852da..7bd672bcd 100644 --- a/cli/package.json +++ b/cli/package.json @@ -3,7 +3,6 @@ "version": "1.0.2", "description": "DurinDoor CLI - Start and manage DurinDoor server", "bin": { - "durindoor": "./cli.js", "durindoor": "./cli.js" }, "files": [ @@ -32,7 +31,7 @@ "comment_sqlite": "sql.js + better-sqlite3 are NOT bundled here. They are installed into <<~/.durindoor>>/runtime/node_modules by hooks/postinstall.js (and re-checked at runtime by cli.js). This avoids Windows EBUSY errors when updating the global CLI, since native .node files no longer live under the locked install dir.", "comment_systray": "systray2 is NOT bundled here. It is lazy-installed into <<~/.durindoor>>/runtime/node_modules by hooks/postinstall.js on macOS/Linux only. Windows uses PowerShell NotifyIcon (zero binary). This avoids shipping unsigned Go binaries that trigger antivirus false positives (Kaspersky). We use the systray2 fork because the legacy systray@1.0.5 ships a 2017 x86_64 binary that fails on modern macOS dyld.", "engines": { - "node": ">=18.0.0" + "node": "20.20.2" }, "keywords": [ "durindoor", diff --git a/cli/scripts/build-cli.js b/cli/scripts/build-cli.js index 885b03d56..c4c6bf1a3 100644 --- a/cli/scripts/build-cli.js +++ b/cli/scripts/build-cli.js @@ -175,7 +175,8 @@ if (fs.existsSync(customServerSrc)) { fs.copyFileSync(customServerSrc, path.join(cliAppDir, "custom-server.js")); console.log("āœ… Copied custom-server.js\n"); } else { - console.warn("āš ļø custom-server.js not found — server will run without real-IP injection\n"); + console.error("āŒ custom-server.js is required for socket ownership and anti-spoofing checks"); + process.exit(1); } // Step 3b: Ensure sql.js (pure JS fallback) bundled in app/cli/app/node_modules. diff --git a/cli/scripts/buildMitm.js b/cli/scripts/buildMitm.js index 7c456471a..6a76d22c8 100644 --- a/cli/scripts/buildMitm.js +++ b/cli/scripts/buildMitm.js @@ -6,7 +6,10 @@ const path = require("path"); const BUILD_CONFIG = { bundle: true, minify: true, - cleanPlainFiles: true, + // custom-server.js and the bundled Next manager load peer-owner, control + // proof, and lifecycle modules from this directory at runtime. Preserve the + // copied source closure and overwrite only server.js with its portable bundle. + cleanPlainFiles: false, }; // ───────────────────────────────────────────────────────── @@ -40,7 +43,7 @@ async function buildEntry(entry) { bundle: true, minify: BUILD_CONFIG.minify, platform: "node", - target: "node18", + target: "node20", external: EXTERNALS, plugins: [buildPlugin], outfile: output, diff --git a/cli/src/cli/api/client.js b/cli/src/cli/api/client.js index 257fcd226..8e22805c7 100644 --- a/cli/src/cli/api/client.js +++ b/cli/src/cli/api/client.js @@ -3,8 +3,8 @@ const https = require("https"); const crypto = require("crypto"); const fs = require("node:fs"); const path = require("node:path"); -const os = require("node:os"); const { machineIdSync } = require("node-machine-id"); +const { getAppDataDir } = require("../appDataDir"); // Default configuration const DEFAULT_CONFIG = { @@ -15,18 +15,9 @@ const DEFAULT_CONFIG = { const CLI_TOKEN_HEADER = "x-9r-cli-token"; const CLI_TOKEN_SALT = "9r-cli-auth"; -const APP_NAME = "9router"; - -function getDataDir() { - if (process.env.DATA_DIR) return process.env.DATA_DIR; - if (process.platform === "win32") { - return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), APP_NAME); - } - return path.join(os.homedir(), `.${APP_NAME}`); -} - -const MACHINE_ID_FILE = path.join(getDataDir(), "machine-id"); -const AUTH_DIR = path.join(getDataDir(), "auth"); +const DATA_DIR = getAppDataDir(); +const MACHINE_ID_FILE = path.join(DATA_DIR, "machine-id"); +const AUTH_DIR = path.join(DATA_DIR, "auth"); const CLI_SECRET_FILE = path.join(AUTH_DIR, "cli-secret"); let config = { ...DEFAULT_CONFIG }; @@ -496,6 +487,7 @@ async function disableTunnel() { module.exports = { configure, + getCliToken, // Providers getProviders, diff --git a/cli/src/cli/appDataDir.js b/cli/src/cli/appDataDir.js new file mode 100644 index 000000000..97467a7cb --- /dev/null +++ b/cli/src/cli/appDataDir.js @@ -0,0 +1,44 @@ +const fs = require("fs"); +const os = require("os"); +const path = require("path"); + +/** Match the server data-directory contract, including isolated DATA_DIR. */ +function getAppDataDir({ + env = process.env, + platform = process.platform, + homedir = os.homedir, + cwd = process.cwd, + mkdir = fs.mkdirSync, + warn = console.warn, +} = {}) { + const pathImpl = platform === "win32" ? path.win32 : path; + const fallback = platform === "win32" + ? pathImpl.join(env.APPDATA || pathImpl.join(homedir(), "AppData", "Roaming"), "9router") + : pathImpl.join(homedir(), ".9router"); + const configured = env.DATA_DIR; + if (!configured) return fallback; + if (platform === "win32" && /^\//.test(configured)) { + warn(`[DATA_DIR] '${configured}' is a Unix path on Windows → fallback to default`); + return fallback; + } + const resolved = pathImpl.resolve(cwd(), configured); + try { + mkdir(resolved, { recursive: true }); + return resolved; + } catch (error) { + if (error?.code === "EACCES" || error?.code === "EPERM") { + warn(`[DATA_DIR] '${resolved}' not writable → fallback ~/.9router`); + return fallback; + } + throw error; + } +} + +function getGlobalMitmStateDir({ platform = process.platform, userInfo = os.userInfo } = {}) { + const homedir = userInfo().homedir; + return platform === "win32" + ? path.win32.join(homedir, "AppData", "Local", "DurinDoor", "mitm-state") + : path.join(homedir, ".durindoor-mitm-state"); +} + +module.exports = { getAppDataDir, getGlobalMitmStateDir }; diff --git a/cli/src/cli/mitmManagerStop.js b/cli/src/cli/mitmManagerStop.js new file mode 100644 index 000000000..7a4b4c816 --- /dev/null +++ b/cli/src/cli/mitmManagerStop.js @@ -0,0 +1,51 @@ +const { execFileSync } = require("child_process"); +const { getCliToken } = require("./api/client"); + +const STOP_MANAGER_SCRIPT = String.raw` +const fs = require("fs"); +const http = require("http"); +const input = JSON.parse(fs.readFileSync(0, "utf8")); +const port = Number(input.port); +const token = typeof input.token === "string" ? input.token : ""; +if (!Number.isInteger(port) || port < 1 || port > 65535) process.exit(2); +const body = JSON.stringify({ preserveDesiredState: input.preserveDesiredState === true }); +const req = http.request({ + hostname: "127.0.0.1", + port, + path: "/api/cli-tools/antigravity-mitm", + method: "DELETE", + headers: { + "content-type": "application/json", + "content-length": Buffer.byteLength(body), + "x-9r-cli-token": token, + }, +}, (res) => { + res.resume(); + res.on("end", () => process.exit(res.statusCode >= 200 && res.statusCode < 300 ? 0 : 2)); +}); +req.setTimeout(360000, () => req.destroy()); +req.on("error", () => process.exit(2)); +req.end(body); +`; + +function stopMitmViaManagerSync(port, { + execFile = execFileSync, + nodePath = process.execPath, + cliToken = getCliToken(), + preserveDesiredState = true, +} = {}) { + try { + execFile(nodePath, ["-e", STOP_MANAGER_SCRIPT], { + input: JSON.stringify({ port, token: cliToken, preserveDesiredState }), + stdio: ["pipe", "ignore", "ignore"], + timeout: 370000, + windowsHide: true, + env: { PATH: process.env.PATH || "" }, + }); + return true; + } catch { + return false; + } +} + +module.exports = { STOP_MANAGER_SCRIPT, stopMitmViaManagerSync }; diff --git a/cli/src/cli/workerExit.js b/cli/src/cli/workerExit.js new file mode 100644 index 000000000..6bf56615b --- /dev/null +++ b/cli/src/cli/workerExit.js @@ -0,0 +1,9 @@ +function isIntentionalWorkerHandoff(code, intentionalExitCode, hasStaleOwnership) { + return Number.isInteger(code) + && code === intentionalExitCode + && hasStaleOwnership === false; +} + +module.exports = { + isIntentionalWorkerHandoff, +}; diff --git a/custom-server.js b/custom-server.js index d6edcf922..74ec59736 100644 --- a/custom-server.js +++ b/custom-server.js @@ -1,41 +1,140 @@ +const crypto = require("crypto"); const http = require("http"); +const { createPeerOwnerVerifier } = require("./src/mitm/peerOwner"); +const { + CONTROL_PORT_HEADER, + CONTROL_PROOF_HEADER, + CONTROL_SECRET_ENV, + createControlProof, +} = require("./src/mitm/controlProof"); -// Renaming next-server process to a unique name while keeping "next-server" -// in the name for backward compatibility with existing process-matching whitelists. -process.title = "9router next-server"; -Object.defineProperty(process, "title", { - get: () => "9router next-server", - set: () => {}, - configurable: true -}); - -const origCreate = http.createServer.bind(http); - -// Wrap Next standalone HTTP server: derive client IP from the TCP socket -// (unspoofable) and strip client-supplied forwarding headers so downstream -// rate-limiting keys on the real peer address instead of attacker-controlled XFF. -http.createServer = (...args) => { - const handler = args.find((a) => typeof a === "function"); - const rest = args.filter((a) => typeof a !== "function"); - if (!handler) return origCreate(...args); - const wrapped = (req, res) => { - const socketIp = req.socket && req.socket.remoteAddress ? req.socket.remoteAddress : ""; - const xff = req.headers["x-forwarded-for"]; - const xRealIp = req.headers["x-real-ip"]; - const viaProxy = !!(xff || xRealIp); - const isLoopbackProxy = socketIp === "127.0.0.1" || socketIp === "::1" || socketIp === "::ffff:127.0.0.1"; - // Trust forwarding headers only when the TCP peer is a local reverse proxy. - // Direct/public sockets remain keyed by the unspoofable peer address. - const proxyIp = xRealIp || (xff ? String(xff).split(",")[0].trim() : ""); - const ip = isLoopbackProxy && proxyIp ? proxyIp : socketIp; - delete req.headers["x-9r-real-ip"]; - delete req.headers["x-forwarded-for"]; - delete req.headers["x-9r-via-proxy"]; - req.headers["x-9r-real-ip"] = ip; - if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; - return handler(req, res); +const MITM_CONTROL_PATH = "/api/cli-tools/antigravity-mitm"; +const STANDALONE_ROOT_ENV = "DURINDOOR_STANDALONE_ROOT"; + +function canonicalizeRuntimePaths() { + // Resolve before Next's generated server can change cwd. Every bundled + // subsystem then observes the same absolute data directory, and the MITM + // manager receives an entrypoint root derived from this installed wrapper. + const { DATA_DIR } = require("./src/mitm/paths"); + process.env.DATA_DIR = DATA_DIR; + process.env[STANDALONE_ROOT_ENV] = require("fs").realpathSync(__dirname); + return DATA_DIR; +} + +function isMitmMutation(req) { + const pathname = new URL(req.url || "/", "http://localhost").pathname; + return (pathname === MITM_CONTROL_PATH || pathname.startsWith(`${MITM_CONTROL_PATH}/`)) + && String(req.method || "GET").toUpperCase() !== "GET"; +} + +/** + * Install the standalone-server request wrapper. Besides deriving the real + * socket IP, it stamps mutating MITM requests only after proving that the + * loopback client socket belongs to the same OS user as the dashboard. + */ +function installRequestWrapper({ httpModule = http, secret, verifyPeerOwner } = {}) { + const controlSecret = secret || crypto.randomBytes(32).toString("hex"); + process.env[CONTROL_SECRET_ENV] = controlSecret; + const dashboardPort = Number(process.env.PORT || 20128); + const verifyOwner = verifyPeerOwner || createPeerOwnerVerifier({ targetPorts: [dashboardPort] }); + const origCreate = httpModule.createServer.bind(httpModule); + + httpModule.createServer = (...args) => { + const handler = args.find((a) => typeof a === "function"); + const rest = args.filter((a) => typeof a !== "function"); + if (!handler) return origCreate(...args); + const wrapped = (req, res) => { + const socketIp = req.socket?.remoteAddress || ""; + const xff = req.headers["x-forwarded-for"]; + const xRealIp = req.headers["x-real-ip"]; + const viaProxy = Boolean(xff || xRealIp); + const isLoopbackProxy = socketIp === "127.0.0.1" || socketIp === "::1" || socketIp === "::ffff:127.0.0.1"; + const proxyIp = xRealIp || (xff ? String(xff).split(",")[0].trim() : ""); + const ip = isLoopbackProxy && proxyIp ? proxyIp : socketIp; + delete req.headers["x-9r-real-ip"]; + delete req.headers["x-forwarded-for"]; + delete req.headers["x-9r-via-proxy"]; + delete req.headers[CONTROL_PROOF_HEADER]; + delete req.headers[CONTROL_PORT_HEADER]; + req.headers["x-9r-real-ip"] = ip; + if (viaProxy) req.headers["x-9r-via-proxy"] = "1"; + if (/^[a-f0-9]{48}$/.test(process.env.DURINDOOR_WORKER_NONCE || "")) { + res.setHeader?.("x-durindoor-worker-nonce", process.env.DURINDOOR_WORKER_NONCE); + } + + const stampOwnerProof = async () => { + if (isMitmMutation(req) && await verifyOwner(req.socket)) { + const remotePort = req.socket?.remotePort; + const proof = createControlProof({ + method: req.method, + pathname: req.url, + remotePort, + secret: controlSecret, + }); + if (proof) { + req.headers[CONTROL_PORT_HEADER] = String(remotePort); + req.headers[CONTROL_PROOF_HEADER] = proof; + } + } + }; + const dispatch = () => { + try { + const result = handler(req, res); + if (result && typeof result.catch === "function") { + void result.catch((error) => { + if (!res.headersSent) res.writeHead?.(500, { "content-type": "text/plain" }); + if (!res.writableEnded) res.end?.("Internal Server Error"); + process.stderr.write(`[custom-server] request handler failed: ${error.message}\n`); + }); + } + } catch (error) { + if (!res.headersSent) res.writeHead?.(500, { "content-type": "text/plain" }); + if (!res.writableEnded) res.end?.("Internal Server Error"); + } + }; + // Proof lookup is fail-closed, but the application handler must be + // dispatched exactly once even when lookup fails. In particular, never + // interpret a rejected application promise as a reason to replay a host + // mutation. + void stampOwnerProof().then( + dispatch, + dispatch, + ); + }; + return origCreate(...rest, wrapped); }; - return origCreate(...rest, wrapped); -}; +} -require("./server.js"); +function createOwnerAwareHandler(handler, options = {}) { + const shim = { createServer: (...args) => args.find((value) => typeof value === "function") }; + installRequestWrapper({ ...options, httpModule: shim }); + return shim.createServer(handler); +} + +function setProcessTitle(processImpl = process) { + // Keep "next-server" in the title for backwards-compatible process matching. + processImpl.title = "9router next-server"; + Object.defineProperty(processImpl, "title", { + get: () => "9router next-server", + set: () => {}, + configurable: true, + }); +} + +function run() { + canonicalizeRuntimePaths(); + setProcessTitle(); + installRequestWrapper(); + require("./server.js"); +} + +if (require.main === module) run(); + +module.exports = { + canonicalizeRuntimePaths, + createOwnerAwareHandler, + installRequestWrapper, + isMitmMutation, + run, + setProcessTitle, +}; diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 35b102736..33afd8d37 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -177,6 +177,121 @@ Fixes: 4. If using OIDC, confirm callback URLs and provider configuration. 5. Check `JWT_SECRET` consistency across restarts. +## MITM Proxy, Root CA, Redirect, or Startup Lock Errors + +Symptoms: + +- MITM fails with a Root CA generation or read error on first start. +- `MITM server is already starting` from another request in this process. +- `MITM server is already starting (lock contention)` from another process. +- Startup reports that PF is disabled, an iptables rule failed, Windows + firewall isolation failed, or the authenticated public-port check failed. +- A restart says that a fresh sudo credential or UAC-approved start is needed. +- `MITM_PRIVILEGED_OPERATION_UNCERTAIN` after a sudo/UAC timeout or an + interrupted redirect installation. +- A live legacy integer `.mitm.pid` is refused after an upgrade. + +Fixes: + +1. Run DurinDoor as a standard user. The full Node.js proxy is never elevated. + Only the exact certificate, hosts-file, firewall, or port-redirect mutation is + delegated to sudo/UAC. An already elevated DurinDoor process is refused + before it can create a CA, change trust, kill a process, or install a rule. +2. On macOS and Linux the proxy listens on `127.0.0.1:8443`; an owner-scoped + kernel rule redirects that user's `127.0.0.1:443` traffic and rejects other + users on both ports. macOS PF must already be enabled by system policy; + DurinDoor does not call `pfctl -E` because doing so without retaining its + reference token can leave PF enabled. Linux requires sudo plus iptables with + the `owner`, `multiport`, and `comment` matches. +3. On Windows the standard-user proxy binds `127.0.0.1:443` directly. Windows + does not require Administrator rights for that bind. DurinDoor uses a narrow + UAC operation to install an owner-conditioned outbound firewall rule; it + never creates a machine-wide `netsh portproxy` mapping. +4. The proxy independently verifies the operating-system owner of every new + loopback connection before health checks or model routing. A request from a + different local account is rejected before the proxy can inject the owner's + DurinDoor API key. If owner discovery or the public-port health probe cannot + be verified, startup fails closed and retains cleanup metadata. + Mutating MITM controls additionally require a valid dashboard JWT or the + machine-bound CLI token; loopback, `Origin`, and same-user ownership are not + treated as authentication. When dashboard login is disabled, use the CLI or + enable login before changing MITM state in the browser. +5. The executable startup path creates a missing, invalid, mismatched, or + expiring Root CA before reading TLS files. A valid pair is reused + byte-for-byte. On POSIX its directory is `0700`, its key is `0600`, and its + certificate is `0644`; stored API-key strings are never rewritten or rotated. + During CA rotation, the prior trust entry remains journaled until the exact + replacement is verified. Linux also replaces and verifies the exact + certificate in every discovered Chromium or Firefox NSS database; a failed + NSS update restores the prior entry and leaves the rotation retryable. +6. Hosts entries and system Root CA trust are operating-system-wide even though + proxy access is owner-contained. Enabling MITM can therefore disrupt the + targeted IDE traffic of other logged-in users. Treat this as an + exclusive-interactive-user feature and do not enable it on a shared host. +7. Sudo passwords are memory-only. DurinDoor clears legacy + `mitmSudoEncrypted` settings and never writes a replacement secret. A cold + restart that needs sudo, or any Windows restart that needs UAC, waits for an + explicit user-approved start. Passwordless-sudo Unix installs may restart + automatically. +8. Stop removes only exact, tagged hosts entries while the verified proxy is + still available, + then stops the process, removes only the current user's exact redirect or + firewall identity, and finally removes PID metadata. A clean unexpected + child exit enters the bounded restart policy. If privileged cleanup fails, + PID/rule ownership metadata remains so a later authenticated stop can retry + rather than reporting a false success. Explicit stop writes the disabled + preference first; update and restart handoffs preserve the enabled intent. +9. If the plain `already starting` error appears, let the active request finish + and retry. For `lock contention`, check whether another DurinDoor process is + starting or stopping MITM. Coordination uses exclusive listeners on + `127.0.0.1:20443` (startup) and `127.0.0.1:20444` (Root CA publication); the + operating system releases them automatically on process exit. +10. The redirect journal is global to the operating-system user, not to + `DATA_DIR`: `~/.durindoor-mitm-state/redirect.json` on macOS/Linux and + `%USERPROFILE%\AppData\Local\DurinDoor\mitm-state\redirect.json` on + Windows. This intentionally permits only one MITM transport across all + DurinDoor data directories owned by that user. +11. If contention remains after every DurinDoor process exits, check whether an + unrelated local service owns either coordination port. Preserve + `DATA_DIR/mitm/.mitm.pid` and any `.rootCA.previous.*.crt` recovery journal, + capture the MITM logs, and retry through the dashboard. Do not delete PID + metadata, trust journals, firewall rules, or Root CA files manually. +12. A live integer-only `.mitm.pid` came from the previous privileged-launcher + design and cannot authenticate which process now owns that PID. Stop MITM + with the old DurinDoor version before updating. If that is no longer + possible, close DurinDoor and reboot; the new version will remove the dead + legacy metadata on its next start. Never raw-kill the recorded PID because + it may have been reused by an unrelated process. + +### Recover an uncertain privileged MITM operation + +An `installing` or `uncertain` redirect journal is a quarantine marker. A sudo +or UAC descendant may still be running, so DurinDoor deliberately refuses both +start and inverse cleanup. Do not delete the marker while the machine is still +running. + +1. Close every DurinDoor process and reboot. Reboot is the boundary that proves + the unconfirmed privileged process tree has ended. +2. Inspect and remove only the current user's exact DurinDoor rule: + - macOS: the PF anchor is `com.apple/durindoor.mitm.`. Inspect with + `sudo pfctl -a com.apple/durindoor.mitm.$(id -u) -sn` and `-sr`, then remove + it with `sudo pfctl -a com.apple/durindoor.mitm.$(id -u) -F all`. + - Linux: inspect `sudo iptables-save` for the exact comment + `durindoor-mitm-$(id -u)`. Remove only the matching loopback NAT rule for + port `443` to `8443` and matching owner-isolation OUTPUT rule. Do not flush + either table or delete another user's comment. + - Windows: in an elevated PowerShell, obtain the current SID with + `[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value`, + inspect `DurinDoor-MITM-Isolation-` with `Get-NetFirewallRule`, and + remove that exact name with `Remove-NetFirewallRule` only if its owner, + loopback address, port, and action match. +3. Verify the exact anchor/rules are absent. Only then delete the user's + `redirect.json` path listed in step 10. Preserve `.mitm.pid`, Root CA files, + and trust-rotation journals; DurinDoor reconciles those separately. +4. Start DurinDoor as a standard user and run an authenticated MITM stop/start. + If any verification differs from the values above, preserve the journal and + logs and ask a system administrator to review them. + ## Request Logs Are Empty Possible causes: diff --git a/package.json b/package.json index 10e0db7fe..edf3ce82a 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,12 @@ "npm": "10.8.2" }, "scripts": { - "dev": "next dev --webpack --port 20127", + "dev": "node scripts/next-owner-server.cjs --dev --port 20127", "build": "node scripts/build-app.mjs", - "start": "next start", - "dev:bun": "bun --bun next dev --webpack --port 20127", - "build:bun": "bun --bun next build --webpack", - "start:bun": "bun ./.next/standalone/server.js", + "start": "node .next/standalone/custom-server.js", + "dev:bun": "bun scripts/next-owner-server.cjs --dev --port 20127", + "build:bun": "bun scripts/build-app.mjs", + "start:bun": "bun ./.next/standalone/custom-server.js", "cli:pack": "npm --prefix cli run pack:cli", "cli:publish": "npm --prefix cli run publish:cli", "lint": "eslint src", diff --git a/scripts/build-app.mjs b/scripts/build-app.mjs index 11052c884..c35e0f9ff 100644 --- a/scripts/build-app.mjs +++ b/scripts/build-app.mjs @@ -18,6 +18,26 @@ try { }); if (result.error) throw result.error; status = result.status ?? 1; + if (status === 0) { + const distDir = process.env.NEXT_DIST_DIR || ".next"; + const standaloneRoot = path.join(process.cwd(), distDir, "standalone"); + const standaloneDir = fs.existsSync(path.join(standaloneRoot, "server.js")) + ? standaloneRoot + : fs.readdirSync(standaloneRoot) + .map((name) => path.join(standaloneRoot, name)) + .find((candidate) => fs.existsSync(path.join(candidate, "server.js"))); + if (!standaloneDir) throw new Error(`Standalone server not found under ${standaloneRoot}`); + fs.copyFileSync(path.join(process.cwd(), "custom-server.js"), path.join(standaloneDir, "custom-server.js")); + fs.cpSync(path.join(process.cwd(), "src", "mitm"), path.join(standaloneDir, "src", "mitm"), { + recursive: true, + }); + const sharedConstantsDir = path.join(standaloneDir, "src", "shared", "constants"); + fs.mkdirSync(sharedConstantsDir, { recursive: true }); + fs.copyFileSync( + path.join(process.cwd(), "src", "shared", "constants", "processExitCodes.js"), + path.join(sharedConstantsDir, "processExitCodes.js"), + ); + } } finally { fs.rmSync(buildRoot, { recursive: true, force: true }); } diff --git a/scripts/next-owner-server.cjs b/scripts/next-owner-server.cjs new file mode 100644 index 000000000..5f34545b4 --- /dev/null +++ b/scripts/next-owner-server.cjs @@ -0,0 +1,35 @@ +#!/usr/bin/env node + +const http = require("http"); +const path = require("path"); +const { + canonicalizeRuntimePaths, + createOwnerAwareHandler, + setProcessTitle, +} = require("../custom-server"); + +const dev = process.argv.includes("--dev"); +if (dev) process.env.NODE_ENV = "development"; +canonicalizeRuntimePaths(); +const next = require("next"); +const portFlag = process.argv.indexOf("--port"); +const port = Number(process.env.PORT || (portFlag >= 0 ? process.argv[portFlag + 1] : dev ? 20127 : 20128)); +const hostname = process.env.HOSTNAME || "0.0.0.0"; +if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("Invalid server port"); + +process.env.PORT = String(port); +setProcessTitle(); + +const app = next({ dev, webpack: true, dir: path.resolve(__dirname, ".."), hostname, port }); +const handler = createOwnerAwareHandler(app.getRequestHandler()); + +app.prepare().then(() => { + const server = http.createServer(handler); + server.on("upgrade", app.getUpgradeHandler()); + server.listen(port, hostname, () => { + console.log(`DurinDoor ${dev ? "development" : "production"} server ready on ${hostname}:${port}`); + }); +}).catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index e0a713549..0a30a204e 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -387,11 +387,11 @@ export default function AntigravityToolCard({ )} - {/* Windows admin warning */} + {/* Windows privilege boundary */} {!isRunning && serverIsWindows && (
warning - Windows: Run terminal (DurinDoor) as Administrator to enable MITM + Windows: keep DurinDoor in standard-user mode; UAC is requested only for system configuration.
)} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index dc9f262c4..87bde6f84 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -23,9 +23,6 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } const serverIsWindows = status?.isWin === true; const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false; - const isAdmin = status?.isAdmin !== false; - // No privilege: not admin/root AND (Win OR no cached sudo password) - const noPrivilege = !isAdmin && (serverIsWindows || (!status?.hasCachedPassword && status?.needsSudoPassword !== false)); const fetchStatus = useCallback(async () => { try { @@ -233,8 +230,8 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } ) : (