diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js index 631f703d..6ab089d3 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -17,6 +17,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } const [pendingAction, setPendingAction] = useState(null); // "start" | "stop" const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); + const isAdmin = status?.isAdmin !== false; // default true until status loaded useEffect(() => { if (apiKeys?.length > 0 && !selectedApiKey) { @@ -188,7 +189,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange } {/* Action button */}
{/* Trust Cert button — only when cert exists but not trusted */} - {status?.certExists && !status?.certTrusted && !isRunning && ( + {status?.certExists && !status?.certTrusted && (
{/* Windows admin warning */} - {!isRunning && isWindows && ( -
- warning - Windows: Run 9Router terminal as Administrator + {isWindows && !isAdmin && ( +
+ shield_lock + Administrator required — restart 9Router as Administrator to use MITM
)}
diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js index fd79162d..55eaa162 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -23,6 +23,16 @@ function getPassword(provided) { return provided || getCachedPassword() || null; } +function checkIsAdmin() { + if (!isWin) return true; + try { + require("child_process").execSync("net session >nul 2>&1", { windowsHide: true }); + return true; + } catch { + return false; + } +} + // GET - Full MITM status (server + per-tool DNS) export async function GET() { try { @@ -34,6 +44,7 @@ export async function GET() { certTrusted: status.certTrusted || false, dnsStatus: status.dnsStatus || {}, hasCachedPassword: !!getCachedPassword(), + isAdmin: checkIsAdmin(), }); } catch (error) { console.log("Error getting MITM status:", error.message); diff --git a/src/app/api/oauth/cursor/auto-import/route.js b/src/app/api/oauth/cursor/auto-import/route.js index 91f9fb65..c638abfa 100644 --- a/src/app/api/oauth/cursor/auto-import/route.js +++ b/src/app/api/oauth/cursor/auto-import/route.js @@ -162,7 +162,7 @@ export async function GET() { } catch { // Try loading from global node_modules (user ran: npm i better-sqlite3 -g) try { - const globalRoot = execSync("npm root -g", { timeout: 5000 }).toString().trim(); + const globalRoot = execSync("npm root -g", { timeout: 5000, windowsHide: true }).toString().trim(); const requireGlobal = createRequire(join(globalRoot, "better-sqlite3", "package.json")); Database = requireGlobal("better-sqlite3"); } catch { /* fall through to sqlite3 CLI strategy */ } diff --git a/src/lib/tunnel/cloudflared.js b/src/lib/tunnel/cloudflared.js index 2188521f..13fcc3e9 100644 --- a/src/lib/tunnel/cloudflared.js +++ b/src/lib/tunnel/cloudflared.js @@ -125,6 +125,7 @@ export async function spawnCloudflared(tunnelToken) { const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], { detached: false, + windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js index 896417e3..52159874 100644 --- a/src/mitm/cert/install.js +++ b/src/mitm/cert/install.js @@ -45,7 +45,7 @@ function checkCertInstalledMac(certPath) { function checkCertInstalledWindows(certPath) { return new Promise((resolve) => { // Check Root store for our Root CA by common name - exec("certutil -store Root \"9Router MITM Root CA\"", (error) => { + exec("certutil -store Root \"9Router MITM Root CA\"", { windowsHide: true }, (error) => { resolve(!error); }); }); @@ -88,11 +88,10 @@ async function installCertMac(sudoPassword, certPath) { } async function installCertWindows(certPath) { - const escaped = certPath.replace(/'/g, "''"); - const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${escaped}' -Verb RunAs -Wait -WindowStyle Hidden`; + // Process already has admin rights — run certutil directly, no UAC needed return new Promise((resolve, reject) => { exec( - `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, + `certutil -addstore Root "${certPath}"`, { windowsHide: true }, (error) => { if (error) reject(new Error(`Failed to install certificate: ${error.message}`)); @@ -133,10 +132,10 @@ async function uninstallCertMac(sudoPassword, certPath) { } async function uninstallCertWindows() { - const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','9Router MITM Root CA' -Verb RunAs -Wait -WindowStyle Hidden`; + // Process already has admin rights — run certutil directly, no UAC needed return new Promise((resolve, reject) => { exec( - `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, + `certutil -delstore Root "9Router MITM Root CA"`, { windowsHide: true }, (error) => { if (error) reject(new Error(`Failed to uninstall certificate: ${error.message}`)); diff --git a/src/mitm/cert/rootCA.js b/src/mitm/cert/rootCA.js index 014840f5..d5e73d89 100644 --- a/src/mitm/cert/rootCA.js +++ b/src/mitm/cert/rootCA.js @@ -7,14 +7,33 @@ const ROOT_CA_KEY_PATH = path.join(MITM_DIR, "rootCA.key"); const ROOT_CA_CERT_PATH = path.join(MITM_DIR, "rootCA.crt"); /** - * Generate Root CA certificate (only once) + * Check if cert file is expired or expiring within 30 days + */ +function isCertExpired(certPath) { + try { + const cert = forge.pki.certificateFromPem(fs.readFileSync(certPath, "utf8")); + const expiryThreshold = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + return cert.validity.notAfter < expiryThreshold; + } catch { + return true; // treat unreadable cert as expired + } +} + +/** + * Generate Root CA certificate (only once, auto-regenerate if expired) * This Root CA will sign all dynamic leaf certificates */ async function generateRootCA() { - if (fs.existsSync(ROOT_CA_KEY_PATH) && fs.existsSync(ROOT_CA_CERT_PATH)) { + const exists = fs.existsSync(ROOT_CA_KEY_PATH) && fs.existsSync(ROOT_CA_CERT_PATH); + if (exists && !isCertExpired(ROOT_CA_CERT_PATH)) { console.log("✅ Root CA already exists"); return { key: ROOT_CA_KEY_PATH, cert: ROOT_CA_CERT_PATH }; } + if (exists) { + console.log("🔐 Root CA expired or expiring soon — regenerating..."); + try { fs.unlinkSync(ROOT_CA_KEY_PATH); } catch { /* ignore */ } + try { fs.unlinkSync(ROOT_CA_CERT_PATH); } catch { /* ignore */ } + } if (!fs.existsSync(MITM_DIR)) { fs.mkdirSync(MITM_DIR, { recursive: true }); @@ -148,6 +167,7 @@ module.exports = { generateRootCA, loadRootCA, generateLeafCert, + isCertExpired, ROOT_CA_CERT_PATH, ROOT_CA_KEY_PATH }; diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index 96299fba..9de77b15 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -140,38 +140,10 @@ async function addDNSEntry(tool, sudoPassword) { try { if (IS_WIN) { - const hostsPath = HOSTS_FILE.replace(/'/g, "''"); - - // Build PowerShell script with proper error handling - const scriptLines = []; - scriptLines.push(`$ErrorActionPreference = 'Stop'`); - scriptLines.push(`$hostsPath = '${hostsPath}'`); - scriptLines.push(`try {`); - scriptLines.push(` $hostsContent = Get-Content -Path $hostsPath -Raw -ErrorAction SilentlyContinue`); - scriptLines.push(` if (-not $hostsContent) { $hostsContent = '' }`); - - for (const host of entriesToAdd) { - // Escape special regex chars in hostname - const escapedHost = host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - scriptLines.push(` if ($hostsContent -notmatch '${escapedHost}') {`); - scriptLines.push(` Add-Content -Path $hostsPath -Value '127.0.0.1 ${host}' -Encoding UTF8 -ErrorAction Stop`); - scriptLines.push(` Write-Host "Added DNS entry: ${host}"`); - scriptLines.push(` } else {`); - scriptLines.push(` Write-Host "DNS entry already exists: ${host}"`); - scriptLines.push(` }`); - } - - scriptLines.push(` ipconfig /flushdns | Out-Null`); - scriptLines.push(`} catch {`); - scriptLines.push(` Write-Error "Failed to add DNS: $_"`); - scriptLines.push(` exit 1`); - scriptLines.push(`}`); - - const psScript = scriptLines.join("\n"); - const tmpPs1 = path.join(os.tmpdir(), `mitm_dns_add_${Date.now()}.ps1`); - fs.writeFileSync(tmpPs1, psScript, "utf8"); - - await executeElevatedPowerShell(tmpPs1, 30000); + // Process already has admin rights — edit hosts file directly + const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n") + "\r\n"; + fs.appendFileSync(HOSTS_FILE, toAppend, "utf8"); + require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); } else { await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword); await flushDNS(sudoPassword); @@ -198,37 +170,11 @@ async function removeDNSEntry(tool, sudoPassword) { try { if (IS_WIN) { + // Process already has admin rights — edit hosts file directly const content = fs.readFileSync(HOSTS_FILE, "utf8"); const filtered = content.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n"); - const tmpFile = path.join(os.tmpdir(), `hosts_filtered_${Date.now()}.tmp`); - fs.writeFileSync(tmpFile, filtered, "utf8"); - - const tmpEsc = tmpFile.replace(/'/g, "''"); - const hostsEsc = HOSTS_FILE.replace(/'/g, "''"); - - // Build PowerShell script with proper error handling - const scriptLines = []; - scriptLines.push(`$ErrorActionPreference = 'Stop'`); - scriptLines.push(`try {`); - scriptLines.push(` Copy-Item -Path '${tmpEsc}' -Destination '${hostsEsc}' -Force -ErrorAction Stop`); - scriptLines.push(` Write-Host "Hosts file updated successfully"`); - scriptLines.push(` ipconfig /flushdns | Out-Null`); - scriptLines.push(` Write-Host "DNS cache flushed"`); - scriptLines.push(` Remove-Item '${tmpEsc}' -ErrorAction SilentlyContinue`); - scriptLines.push(`} catch {`); - scriptLines.push(` Write-Error "Failed to remove DNS: $_"`); - scriptLines.push(` Remove-Item '${tmpEsc}' -ErrorAction SilentlyContinue`); - scriptLines.push(` exit 1`); - scriptLines.push(`}`); - - const psScript = scriptLines.join("\n"); - const tmpPs1 = path.join(os.tmpdir(), `mitm_dns_remove_${Date.now()}.ps1`); - fs.writeFileSync(tmpPs1, psScript, "utf8"); - - await executeElevatedPowerShell(tmpPs1, 30000); - - // Cleanup temp file if still exists - try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } + fs.writeFileSync(HOSTS_FILE, filtered, "utf8"); + require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); } else { for (const host of entriesToRemove) { const sedCmd = IS_MAC diff --git a/src/mitm/manager.js b/src/mitm/manager.js index 96fcceda..f24a7c35 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -5,11 +5,12 @@ const os = require("os"); const net = require("net"); const https = require("https"); const crypto = require("crypto"); -const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, executeElevatedPowerShell, TOOL_HOSTS } = require("./dns/dnsConfig"); +const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS } = require("./dns/dnsConfig"); const IS_WIN = process.platform === "win32"; const { generateCert } = require("./cert/generate"); -const { installCert } = require("./cert/install"); +const { installCert, uninstallCert } = require("./cert/install"); +const { isCertExpired } = require("./cert/rootCA"); const { MITM_DIR } = require("./paths"); const { log, err } = require("./logger"); @@ -81,7 +82,7 @@ function isProcessAlive(pid) { function killProcess(pid, force = false, sudoPassword = null) { if (IS_WIN) { const flag = force ? "/F " : ""; - exec(`taskkill ${flag}/PID ${pid}`, () => { }); + exec(`taskkill ${flag}/PID ${pid}`, { windowsHide: true }, () => { }); } else { const sig = force ? "SIGKILL" : "SIGTERM"; const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; @@ -375,12 +376,19 @@ async function startServer(apiKey, sudoPassword) { } } - // Step 1: Auto-migration - Generate Root CA if not exists + // Step 1: Generate Root CA if missing or expired const rootCACertPath = path.join(MITM_DIR, "rootCA.crt"); const rootCAKeyPath = path.join(MITM_DIR, "rootCA.key"); + const certExists = fs.existsSync(rootCACertPath) && fs.existsSync(rootCAKeyPath); - if (!fs.existsSync(rootCACertPath) || !fs.existsSync(rootCAKeyPath)) { - log("🔐 Generating Root CA (first time)..."); + if (!certExists || isCertExpired(rootCACertPath)) { + if (certExists) { + // Uninstall expired cert from system store before regenerating + log("🔐 Cert expired — uninstalling old cert..."); + const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); + try { await uninstallCert(password, rootCACertPath); } catch { /* best effort */ } + } + log("🔐 Generating Root CA..."); await generateCert(); } @@ -389,13 +397,16 @@ async function startServer(apiKey, sudoPassword) { const rootCATrusted = await checkCertInstalled(rootCACertPath); if (!rootCATrusted) { log("🔐 Cert: not trusted → installing..."); - // Use provided password or cached/stored password const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword(); if (!password && !IS_WIN) { throw new Error("Sudo password required to install Root CA certificate"); } - await installCert(password, rootCACertPath); - log("🔐 Cert: ✅ trusted"); + try { + await installCert(password, rootCACertPath); + log("🔐 Cert: ✅ trusted"); + } catch (e) { + throw new Error(`Failed to trust certificate: ${e.message}`); + } } else { log("🔐 Cert: already trusted ✅"); } @@ -403,23 +414,24 @@ async function startServer(apiKey, sudoPassword) { // Step 2: Spawn server (Root CA already installed in Step 1.5) log("🚀 Starting server..."); if (IS_WIN) { - const psSQ = (s) => s.replace(/'/g, "''"); - const nodePs = psSQ(process.execPath); - const serverPs = psSQ(SERVER_PATH); + // Kill any process using port 443 before spawning + try { + const psKill = `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c -and $c.OwningProcess -gt 4) { Stop-Process -Id $c.OwningProcess -Force -ErrorAction SilentlyContinue }`; + execSync(`powershell -NonInteractive -WindowStyle Hidden -Command "${psKill}"`, { windowsHide: true }); + await new Promise(r => setTimeout(r, 500)); + } catch { /* best effort */ } - const psScript = [ - `$ErrorActionPreference = 'Stop'`, - `$conn = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1`, - `if ($conn -and $conn.OwningProcess -gt 4) { Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue }`, - `Start-Sleep -Milliseconds 500`, - `$nodeCmd = 'set ROUTER_API_KEY=${psSQ(apiKey)}&& set NODE_ENV=production&& "${nodePs}" "${serverPs}"'`, - `Start-Process cmd -ArgumentList '/c',$nodeCmd -WindowStyle Hidden`, - `Start-Sleep -Milliseconds 500`, - ].join("\n"); - - const tmpPs1 = path.join(os.tmpdir(), `mitm_start_${Date.now()}.ps1`); - fs.writeFileSync(tmpPs1, psScript, "utf8"); - await executeElevatedPowerShell(tmpPs1, 90000); + // Spawn directly — process already has admin rights + serverProcess = spawn( + process.execPath, + [SERVER_PATH], + { + detached: false, + windowsHide: true, + stdio: ["ignore", "pipe", "pipe"], + env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" }, + } + ); if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); } else { @@ -433,21 +445,22 @@ async function startServer(apiKey, sudoPassword) { serverProcess.stdin.end(); } - if (!IS_WIN && serverProcess) { + if (serverProcess) { serverPid = serverProcess.pid; fs.writeFileSync(PID_FILE, String(serverPid)); mitmLastStartTime = Date.now(); } let startError = null; - if (!IS_WIN) { + if (serverProcess) { serverProcess.stdout.on("data", (data) => { // server.js already formats its own logs — print as-is process.stdout.write(data); }); serverProcess.stderr.on("data", (data) => { const msg = data.toString().trim(); - if (msg && !msg.includes("Password:") && !msg.includes("password for")) { + // Mac/Linux: filter sudo password prompt noise + if (msg && (IS_WIN || (!msg.includes("Password:") && !msg.includes("password for")))) { err(msg); startError = msg; } @@ -462,20 +475,16 @@ async function startServer(apiKey, sudoPassword) { }); } - const health = await pollMitmHealth(IS_WIN ? 15000 : 8000, MITM_PORT); + const health = await pollMitmHealth(8000, MITM_PORT); if (!health) { - if (IS_WIN) serverProcess = null; + if (serverProcess && !serverProcess.killed) { try { serverProcess.kill(); } catch { /* ignore */ } serverProcess = null; } const processUsing443 = getProcessUsingPort443(); const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; const reason = startError || `Check sudo password or port 443 access.${portInfo}`; throw new Error(`MITM server failed to start. ${reason}`); } - if (IS_WIN && _updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); - if (IS_WIN && health.pid) { - serverPid = health.pid; - fs.writeFileSync(PID_FILE, String(serverPid)); - } + if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); log(`✅ Server healthy (PID: ${serverPid || health.pid})`); @@ -516,33 +525,15 @@ async function stopServer(sudoPassword) { serverPid = null; if (IS_WIN) { - // Single elevated script: clean DNS + flush — 1 UAC prompt only + // Process already has admin rights — edit hosts file directly const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); - const psSQ = (s) => s.replace(/'/g, "''"); const allHosts = Object.values(TOOL_HOSTS).flat(); - - let hostsContent = ""; - try { hostsContent = fs.readFileSync(hostsFile, "utf8"); } catch { /* ignore */ } - const filtered = hostsContent.split(/\r?\n/) - .filter(l => !allHosts.some(h => l.includes(h))) - .join("\r\n"); - const tmpHosts = path.join(os.tmpdir(), `mitm_hosts_clean_${Date.now()}.tmp`); - fs.writeFileSync(tmpHosts, filtered, "utf8"); - - const psScript = [ - `$ErrorActionPreference = 'Stop'`, - `try {`, - ` Copy-Item -Path '${psSQ(tmpHosts)}' -Destination '${psSQ(hostsFile)}' -Force -ErrorAction Stop`, - ` ipconfig /flushdns | Out-Null`, - ` Remove-Item '${psSQ(tmpHosts)}' -ErrorAction SilentlyContinue`, - `} catch {`, - ` Remove-Item '${psSQ(tmpHosts)}' -ErrorAction SilentlyContinue`, - `}`, - ].join("\n"); - - const tmpPs1 = path.join(os.tmpdir(), `mitm_stop_${Date.now()}.ps1`); - fs.writeFileSync(tmpPs1, psScript, "utf8"); - await executeElevatedPowerShell(tmpPs1, 30000); + try { + const hostsContent = fs.readFileSync(hostsFile, "utf8"); + const filtered = hostsContent.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join("\r\n"); + fs.writeFileSync(hostsFile, filtered, "utf8"); + require("child_process").execSync("ipconfig /flushdns", { windowsHide: true }); + } catch (e) { err(`Failed to clean hosts: ${e.message}`); } } else { await removeAllDNSEntries(sudoPassword); }