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