mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 13:38:31 +00:00
- Updated global CSS to implement a new brand color palette and improve light/dark theme consistency. - Enhanced the MitmServerCard component to provide clearer user feedback regarding admin privileges. - Filtered LLM combos in the CombosPage to ensure only relevant data is displayed. - Improved APIPageClient layout for better usability and visual consistency. - Added functionality to save and load DNS tool states in the MITM manager. - Updated OAuth configuration URLs for Qwen to reflect the new endpoint structure. - Refined tunnel management logic to improve reliability and user experience.
248 lines
8.2 KiB
JavaScript
248 lines
8.2 KiB
JavaScript
const { exec, spawn, execSync } = require("child_process");
|
|
const fs = require("fs");
|
|
const path = require("path");
|
|
const os = require("os");
|
|
const { log, err } = require("../logger");
|
|
const { TOOL_HOSTS } = require("../../shared/constants/mitmToolHosts");
|
|
const { runElevatedPowerShell, quotePs, isAdmin } = require("../winElevated.js");
|
|
|
|
const IS_WIN = process.platform === "win32";
|
|
const IS_MAC = process.platform === "darwin";
|
|
const HOSTS_FILE = IS_WIN
|
|
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts")
|
|
: "/etc/hosts";
|
|
|
|
/** True when `sudo` exists (e.g. missing on minimal Docker images like Alpine). */
|
|
function isSudoAvailable() {
|
|
if (IS_WIN) return false;
|
|
try {
|
|
execSync("command -v sudo", { stdio: "ignore", windowsHide: true });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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.
|
|
*/
|
|
function execWithPassword(command, password) {
|
|
return new Promise((resolve, reject) => {
|
|
const useSudo = isSudoAvailable();
|
|
const child = useSudo
|
|
? spawn("sudo", ["-S", "sh", "-c", command], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true })
|
|
: spawn("sh", ["-c", command], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
child.stdout.on("data", (d) => { stdout += d; });
|
|
child.stderr.on("data", (d) => { stderr += d; });
|
|
|
|
child.on("close", (code) => {
|
|
if (code === 0) resolve(stdout);
|
|
else reject(new Error(stderr || `Exit code ${code}`));
|
|
});
|
|
|
|
if (useSudo) {
|
|
child.stdin.write(`${password}\n`);
|
|
child.stdin.end();
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Trim trailing blank lines/whitespace, ensure file ends with exactly one newline.
|
|
*/
|
|
function normalizeHostsContent(content) {
|
|
const eol = IS_WIN ? "\r\n" : "\n";
|
|
return content.replace(/[\r\n\s]+$/g, "") + eol;
|
|
}
|
|
|
|
/**
|
|
* Flush DNS cache (macOS/Linux)
|
|
*/
|
|
async function flushDNS(sudoPassword) {
|
|
if (IS_WIN) return; // Windows flushes inline via ipconfig
|
|
if (IS_MAC) {
|
|
await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword);
|
|
} else {
|
|
await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if DNS entry exists for a specific host
|
|
*/
|
|
function checkDNSEntry(host = null) {
|
|
try {
|
|
const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
if (host) return hostsContent.includes(host);
|
|
// Legacy: check all antigravity hosts (backward compat)
|
|
return TOOL_HOSTS.antigravity.every(h => hostsContent.includes(h));
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check DNS status per tool — returns { [tool]: boolean }
|
|
*/
|
|
function checkAllDNSStatus() {
|
|
try {
|
|
const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const result = {};
|
|
for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) {
|
|
result[tool] = hosts.every(h => hostsContent.includes(h));
|
|
}
|
|
return result;
|
|
} catch {
|
|
return Object.fromEntries(Object.keys(TOOL_HOSTS).map(t => [t, false]));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add DNS entries for a specific tool
|
|
*/
|
|
async function addDNSEntry(tool, sudoPassword) {
|
|
const hosts = TOOL_HOSTS[tool];
|
|
if (!hosts) throw new Error(`Unknown tool: ${tool}`);
|
|
|
|
const entriesToAdd = hosts.filter(h => !checkDNSEntry(h));
|
|
if (entriesToAdd.length === 0) {
|
|
log(`🌐 DNS ${tool}: already active`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (IS_WIN) {
|
|
// Read → trim → append → write (avoids stacked blank lines from Add-Content)
|
|
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const trimmed = current.replace(/[\r\n\s]+$/g, "");
|
|
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\r\n");
|
|
const next = `${trimmed}\r\n${toAppend}\r\n`;
|
|
const script = `
|
|
Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(next)} -NoNewline
|
|
ipconfig /flushdns | Out-Null
|
|
`;
|
|
await runElevatedPowerShell(script);
|
|
} else {
|
|
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const trimmed = current.replace(/[\r\n\s]+$/g, "");
|
|
const toAppend = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n");
|
|
const next = `${trimmed}\n${toAppend}\n`;
|
|
// Use tee via sudo to overwrite atomically — escape single quotes in content
|
|
const escaped = next.replace(/'/g, "'\\''");
|
|
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
|
await flushDNS(sudoPassword);
|
|
}
|
|
log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`);
|
|
} catch (error) {
|
|
const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to add DNS entry: ${error.message}`;
|
|
throw new Error(msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove DNS entries for a specific tool
|
|
*/
|
|
async function removeDNSEntry(tool, sudoPassword) {
|
|
const hosts = TOOL_HOSTS[tool];
|
|
if (!hosts) throw new Error(`Unknown tool: ${tool}`);
|
|
|
|
const entriesToRemove = hosts.filter(h => checkDNSEntry(h));
|
|
if (entriesToRemove.length === 0) {
|
|
log(`🌐 DNS ${tool}: already inactive`);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (IS_WIN) {
|
|
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n");
|
|
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\r\n";
|
|
const script = `
|
|
Set-Content -LiteralPath ${quotePs(HOSTS_FILE)} -Value ${quotePs(next)} -NoNewline
|
|
ipconfig /flushdns | Out-Null
|
|
`;
|
|
await runElevatedPowerShell(script);
|
|
} else {
|
|
const current = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const filtered = current.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\n");
|
|
const next = filtered.replace(/[\r\n\s]+$/g, "") + "\n";
|
|
const escaped = next.replace(/'/g, "'\\''");
|
|
await execWithPassword(`printf '%s' '${escaped}' | tee ${HOSTS_FILE} > /dev/null`, sudoPassword);
|
|
await flushDNS(sudoPassword);
|
|
}
|
|
log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`);
|
|
} catch (error) {
|
|
const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : `Failed to remove DNS entry: ${error.message}`;
|
|
throw new Error(msg);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove ALL tool DNS entries (used when stopping server)
|
|
*/
|
|
async function removeAllDNSEntries(sudoPassword) {
|
|
for (const tool of Object.keys(TOOL_HOSTS)) {
|
|
try {
|
|
await removeDNSEntry(tool, sudoPassword);
|
|
} catch (e) {
|
|
err(`DNS ${tool}: failed to remove — ${e.message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sync removal of ALL tool DNS entries — for use during process shutdown
|
|
* when async ops aren't safe. Assumes caller already has root/admin rights.
|
|
*/
|
|
function removeAllDNSEntriesSync() {
|
|
try {
|
|
if (!fs.existsSync(HOSTS_FILE)) return;
|
|
const allHosts = Object.values(TOOL_HOSTS).flat();
|
|
const content = fs.readFileSync(HOSTS_FILE, "utf8");
|
|
const eol = IS_WIN ? "\r\n" : "\n";
|
|
const filtered = content.split(/\r?\n/).filter(l => !allHosts.some(h => l.includes(h))).join(eol);
|
|
const next = filtered.replace(/[\r\n\s]+$/g, "") + eol;
|
|
if (next === content) return;
|
|
fs.writeFileSync(HOSTS_FILE, next, "utf8");
|
|
if (IS_WIN) {
|
|
try { execSync("ipconfig /flushdns", { windowsHide: true, stdio: "ignore" }); } catch { /* ignore */ }
|
|
} else if (IS_MAC) {
|
|
try { execSync("dscacheutil -flushcache && killall -HUP mDNSResponder", { stdio: "ignore" }); } catch { /* ignore */ }
|
|
} else {
|
|
try { execSync("resolvectl flush-caches 2>/dev/null || true", { stdio: "ignore" }); } catch { /* ignore */ }
|
|
}
|
|
} catch { /* best effort during shutdown */ }
|
|
}
|
|
|
|
module.exports = {
|
|
TOOL_HOSTS,
|
|
addDNSEntry,
|
|
removeDNSEntry,
|
|
removeAllDNSEntries,
|
|
removeAllDNSEntriesSync,
|
|
execWithPassword,
|
|
isSudoAvailable,
|
|
canRunSudoWithoutPassword,
|
|
isSudoPasswordRequired,
|
|
checkDNSEntry,
|
|
checkAllDNSStatus,
|
|
};
|