mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
fix(mitm): gate sudo prompts on server platform (#822)
This commit is contained in:
@@ -88,11 +88,12 @@ export default function AntigravityToolCard({
|
||||
}
|
||||
};
|
||||
|
||||
// Windows uses UAC dialog, no sudo needed
|
||||
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
|
||||
// MITM elevation is decided by the server OS, not by this browser's OS.
|
||||
const serverIsWindows = status?.isWin === true;
|
||||
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
|
||||
|
||||
const handleStart = () => {
|
||||
if (isWindows || status?.hasCachedPassword) {
|
||||
if (canRunWithoutPassword) {
|
||||
doStart("");
|
||||
} else {
|
||||
setShowPasswordModal(true);
|
||||
@@ -101,7 +102,7 @@ export default function AntigravityToolCard({
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (isWindows || status?.hasCachedPassword) {
|
||||
if (canRunWithoutPassword) {
|
||||
doStop("");
|
||||
} else {
|
||||
setShowPasswordModal(true);
|
||||
@@ -385,7 +386,7 @@ export default function AntigravityToolCard({
|
||||
)}
|
||||
|
||||
{/* Windows admin warning */}
|
||||
{!isRunning && isWindows && (
|
||||
{!isRunning && serverIsWindows && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-yellow-500/10 text-yellow-600 border border-yellow-500/20">
|
||||
<span className="material-symbols-outlined text-[14px]">warning</span>
|
||||
<span>Windows: Run terminal (9Router) as Administrator to enable MITM</span>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Card, Button, Badge, Input } from "@/shared/components";
|
||||
|
||||
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
|
||||
@@ -14,26 +14,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPasswordModal, setShowPasswordModal] = useState(false);
|
||||
const [sudoPassword, setSudoPassword] = useState("");
|
||||
const [selectedApiKey, setSelectedApiKey] = useState("");
|
||||
const [selectedApiKey, setSelectedApiKey] = useState(() => apiKeys?.[0]?.key || "");
|
||||
const [pendingAction, setPendingAction] = useState(null);
|
||||
const [modalError, setModalError] = useState(null);
|
||||
const [actionError, setActionError] = useState(null);
|
||||
const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE);
|
||||
|
||||
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
|
||||
const serverIsWindows = status?.isWin === true;
|
||||
const canRunWithoutPassword = serverIsWindows || status?.hasCachedPassword || status?.needsSudoPassword === false;
|
||||
const isAdmin = status?.isAdmin !== false;
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatus();
|
||||
}, []);
|
||||
|
||||
const fetchStatus = async () => {
|
||||
const fetchStatus = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm");
|
||||
if (res.ok) {
|
||||
@@ -47,11 +38,17 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
} catch {
|
||||
setStatus({ running: false, certExists: false, dnsStatus: {} });
|
||||
}
|
||||
};
|
||||
}, [onStatusChange]);
|
||||
|
||||
useEffect(() => {
|
||||
queueMicrotask(() => {
|
||||
fetchStatus();
|
||||
});
|
||||
}, [fetchStatus]);
|
||||
|
||||
const handleAction = (action) => {
|
||||
setActionError(null);
|
||||
if (isWindows || status?.hasCachedPassword) {
|
||||
if (canRunWithoutPassword) {
|
||||
doAction(action, "");
|
||||
} else {
|
||||
setPendingAction(action);
|
||||
@@ -219,7 +216,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleAction("start")}
|
||||
disabled={loading || (isWindows && !isAdmin)}
|
||||
disabled={loading || (serverIsWindows && !isAdmin)}
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-primary/30 bg-primary/10 px-4 py-2 text-xs font-medium text-primary transition-colors hover:bg-primary/20 disabled:opacity-50 sm:w-auto sm:py-1.5"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">play_circle</span>
|
||||
@@ -240,7 +237,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
|
||||
)}
|
||||
|
||||
{/* Windows admin warning */}
|
||||
{isWindows && !isAdmin && (
|
||||
{serverIsWindows && !isAdmin && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600 border border-red-500/20">
|
||||
<span className="material-symbols-outlined text-[14px]">shield_lock</span>
|
||||
<span>Administrator required — restart 9Router as Administrator to use MITM</span>
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function MitmToolCard({
|
||||
serverRunning,
|
||||
dnsActive,
|
||||
hasCachedPassword,
|
||||
needsSudoPassword,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
hasActiveProviders,
|
||||
@@ -36,7 +37,7 @@ export default function MitmToolCard({
|
||||
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
|
||||
|
||||
const mitmHosts = TOOL_HOSTS[tool.id] ?? [];
|
||||
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
|
||||
const canRunWithoutPassword = hasCachedPassword || needsSudoPassword === false;
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded) loadSavedMappings();
|
||||
@@ -85,7 +86,7 @@ export default function MitmToolCard({
|
||||
const handleDnsToggle = () => {
|
||||
if (!serverRunning) return;
|
||||
const action = dnsActive ? "disable" : "enable";
|
||||
if (isWindows || hasCachedPassword) {
|
||||
if (canRunWithoutPassword) {
|
||||
doDnsAction(action, "");
|
||||
} else {
|
||||
setPendingDnsAction(action);
|
||||
|
||||
@@ -98,6 +98,7 @@ export default function MitmPageClient() {
|
||||
serverRunning={mitmStatus.running}
|
||||
dnsActive={mitmStatus.dnsStatus?.[toolId] || false}
|
||||
hasCachedPassword={mitmStatus.hasCachedPassword || false}
|
||||
needsSudoPassword={mitmStatus.needsSudoPassword !== false}
|
||||
apiKeys={apiKeys}
|
||||
activeProviders={getActiveProviders()}
|
||||
hasActiveProviders={hasActiveProviders()}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
getCachedPassword,
|
||||
setCachedPassword,
|
||||
loadEncryptedPassword,
|
||||
isSudoPasswordRequired,
|
||||
initDbHooks,
|
||||
} from "@/mitm/manager";
|
||||
import { getSettings, updateSettings } from "@/lib/localDb";
|
||||
@@ -40,6 +41,10 @@ function getPassword(provided) {
|
||||
return provided || getCachedPassword() || null;
|
||||
}
|
||||
|
||||
function requiresSudoPassword(pwd) {
|
||||
return !isWin && !pwd && isSudoPasswordRequired();
|
||||
}
|
||||
|
||||
function checkIsAdmin() {
|
||||
if (!isWin) return true;
|
||||
try {
|
||||
@@ -55,13 +60,16 @@ export async function GET() {
|
||||
try {
|
||||
const status = await getMitmStatus();
|
||||
const settings = await getSettings();
|
||||
const hasCachedPassword = !!getCachedPassword() || !!(await loadEncryptedPassword());
|
||||
return NextResponse.json({
|
||||
running: status.running,
|
||||
pid: status.pid || null,
|
||||
certExists: status.certExists || false,
|
||||
certTrusted: status.certTrusted || false,
|
||||
dnsStatus: status.dnsStatus || {},
|
||||
hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()),
|
||||
hasCachedPassword,
|
||||
isWin,
|
||||
needsSudoPassword: !isWin && !hasCachedPassword && isSudoPasswordRequired(),
|
||||
isAdmin: checkIsAdmin(),
|
||||
mitmRouterBaseUrl:
|
||||
(settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) ||
|
||||
@@ -79,9 +87,9 @@ export async function POST(request) {
|
||||
const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
|
||||
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
|
||||
|
||||
if (!apiKey || (!isWin && !pwd)) {
|
||||
if (!apiKey || requiresSudoPassword(pwd)) {
|
||||
return NextResponse.json(
|
||||
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" },
|
||||
{ error: !apiKey ? "Missing apiKey" : "Missing sudoPassword" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
@@ -115,7 +123,7 @@ export async function DELETE(request) {
|
||||
const { sudoPassword } = body;
|
||||
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
|
||||
|
||||
if (!isWin && !pwd) {
|
||||
if (requiresSudoPassword(pwd)) {
|
||||
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
|
||||
}
|
||||
|
||||
@@ -138,7 +146,7 @@ export async function PATCH(request) {
|
||||
if (!tool || !action) {
|
||||
return NextResponse.json({ error: "tool and action required" }, { status: 400 });
|
||||
}
|
||||
if (!isWin && !pwd) {
|
||||
if (requiresSudoPassword(pwd)) {
|
||||
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,20 @@ function isSudoAvailable() {
|
||||
}
|
||||
}
|
||||
|
||||
function canRunSudoWithoutPassword() {
|
||||
if (IS_WIN || !isSudoAvailable()) return true;
|
||||
try {
|
||||
execSync("sudo -n true", { stdio: "ignore", windowsHide: true });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isSudoPasswordRequired() {
|
||||
return !IS_WIN && isSudoAvailable() && !canRunSudoWithoutPassword();
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute command with sudo password via stdin (macOS/Linux only).
|
||||
* Without sudo in PATH (containers), runs via sh — same user, no elevation.
|
||||
@@ -216,6 +230,8 @@ module.exports = {
|
||||
removeAllDNSEntriesSync,
|
||||
execWithPassword,
|
||||
isSudoAvailable,
|
||||
canRunSudoWithoutPassword,
|
||||
isSudoPasswordRequired,
|
||||
checkDNSEntry,
|
||||
checkAllDNSStatus,
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ const os = require("os");
|
||||
const net = require("net");
|
||||
const https = require("https");
|
||||
const crypto = require("crypto");
|
||||
const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable } = require("./dns/dnsConfig");
|
||||
const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus, TOOL_HOSTS, isSudoAvailable, isSudoPasswordRequired } = require("./dns/dnsConfig");
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const IS_MAC = process.platform === "darwin";
|
||||
@@ -139,9 +139,9 @@ function killProcess(pid, force = false, sudoPassword = null) {
|
||||
} else {
|
||||
const sig = force ? "SIGKILL" : "SIGTERM";
|
||||
const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`;
|
||||
if (sudoPassword) {
|
||||
if (sudoPassword || isSudoAvailable()) {
|
||||
const { execWithPassword } = require("./dns/dnsConfig");
|
||||
execWithPassword(cmd, sudoPassword).catch(() => exec(cmd, { windowsHide: true }, () => { }));
|
||||
execWithPassword(cmd, sudoPassword || "").catch(() => exec(cmd, { windowsHide: true }, () => { }));
|
||||
} else {
|
||||
exec(cmd, { windowsHide: true }, () => { });
|
||||
}
|
||||
@@ -279,9 +279,9 @@ async function killLeftoverMitm(sudoPassword) {
|
||||
if (!IS_WIN && SERVER_PATH) {
|
||||
try {
|
||||
const escaped = SERVER_PATH.replace(/'/g, "'\\''");
|
||||
if (sudoPassword) {
|
||||
if (sudoPassword || isSudoAvailable()) {
|
||||
const { execWithPassword } = require("./dns/dnsConfig");
|
||||
await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword).catch(() => { });
|
||||
await execWithPassword(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, sudoPassword || "").catch(() => { });
|
||||
} else {
|
||||
exec(`pkill -SIGKILL -f "${escaped}" 2>/dev/null || true`, { windowsHide: true }, () => { });
|
||||
}
|
||||
@@ -465,7 +465,7 @@ async function startServer(apiKey, sudoPassword) {
|
||||
if (linuxNoSystemTrust) {
|
||||
log(`🔐 Cert: skipping system trust (no sudo). Install ${rootCACertPath} as a trusted CA on machines that use this proxy.`);
|
||||
} else {
|
||||
if (!password && !IS_WIN) {
|
||||
if (!password && isSudoPasswordRequired()) {
|
||||
throw new Error("Sudo password required to install Root CA certificate");
|
||||
}
|
||||
try {
|
||||
@@ -698,7 +698,7 @@ async function trustCert(sudoPassword) {
|
||||
return;
|
||||
}
|
||||
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
|
||||
if (!password && !IS_WIN) throw new Error("Sudo password required to trust certificate");
|
||||
if (!password && isSudoPasswordRequired()) throw new Error("Sudo password required to trust certificate");
|
||||
await installCert(password, rootCACertPath);
|
||||
if (password) setCachedPassword(password);
|
||||
}
|
||||
@@ -721,5 +721,6 @@ module.exports = {
|
||||
setCachedPassword,
|
||||
loadEncryptedPassword,
|
||||
clearEncryptedPassword,
|
||||
isSudoPasswordRequired,
|
||||
initDbHooks,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user