Fix : MITM

This commit is contained in:
decolua
2026-03-14 23:59:22 +07:00
parent f264bb9a23
commit 1dd5d60724
10 changed files with 151 additions and 65 deletions

View File

@@ -1,6 +1,2 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
updates: []

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.3.53",
"version": "0.3.54",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -55,7 +55,19 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
setLoading(true);
setMessage(null);
try {
if (action === "start") {
if (action === "trust-cert") {
const res = await fetch("/api/cli-tools/antigravity-mitm", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "trust-cert", sudoPassword: password }),
});
const data = await res.json();
if (res.ok) {
setMessage({ type: "success", text: "Certificate trusted successfully" });
} else {
setMessage({ type: "error", text: data.error || "Failed to trust certificate" });
}
} else if (action === "start") {
const keyToUse = selectedApiKey?.trim()
|| (apiKeys?.length > 0 ? apiKeys[0].key : null)
|| (!cloudEnabled ? "sk_9router" : null);
@@ -120,14 +132,15 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
<Badge variant="default" size="sm">Stopped</Badge>
)}
</div>
<div className="flex items-center gap-1 text-xs text-text-muted">
<div className="flex items-center gap-1 text-xs text-text-muted" data-i18n-skip="true">
{[
{ label: "Cert", ok: status?.certExists },
{ label: "Trusted", ok: status?.certTrusted },
{ label: "Server", ok: isRunning },
].map(({ label, ok }) => (
<span key={label} className={`flex items-center gap-0.5 px-1.5 py-0.5 rounded ${ok ? "text-green-600" : "text-text-muted"}`}>
<span className={`material-symbols-outlined text-[12px]`}>
{ok ? "check_circle" : "radio_button_unchecked"}
<span className="material-symbols-outlined text-[12px]">
{ok ? "check_circle" : "cancel"}
</span>
{label}
</span>
@@ -173,7 +186,18 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
)}
{/* Action button */}
<div className="flex items-center gap-2" data-i18n-skip="true">
<div className="flex items-center gap-2 flex-wrap" data-i18n-skip="true">
{/* Trust Cert button — only when cert exists but not trusted */}
{status?.certExists && !status?.certTrusted && !isRunning && (
<button
onClick={() => handleAction("trust-cert")}
disabled={loading}
className="px-4 py-1.5 rounded-lg bg-yellow-500/10 border border-yellow-500/30 text-yellow-600 font-medium text-xs flex items-center gap-1.5 hover:bg-yellow-500/20 transition-colors disabled:opacity-50"
>
<span className="material-symbols-outlined text-[16px]">verified_user</span>
Trust Cert
</button>
)}
{isRunning ? (
<button
onClick={() => handleAction("stop")}

View File

@@ -109,7 +109,8 @@ export default function MitmToolCard({
if (action === "enable") {
setMessage({
type: "success",
text: `DNS enabled successfully. Please restart ${tool.name} to apply changes.`,
text: "DNS enabled successfully.",
warning: `Please restart ${tool.name} to apply changes.`,
});
} else {
setMessage({
@@ -185,9 +186,17 @@ export default function MitmToolCard({
</div>
{message && (
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
<div className="flex flex-col gap-1">
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
<span>{message.text}</span>
</div>
{message.warning && (
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-amber-500/10 text-amber-600 border border-amber-500/20">
<span className="material-symbols-outlined text-[14px]">warning</span>
<span className="font-medium">{message.warning}</span>
</div>
)}
</div>
)}

View File

@@ -7,6 +7,7 @@ import {
stopServer,
enableToolDNS,
disableToolDNS,
trustCert,
getCachedPassword,
setCachedPassword,
loadEncryptedPassword,
@@ -30,6 +31,7 @@ export async function GET() {
running: status.running,
pid: status.pid || null,
certExists: status.certExists || false,
certTrusted: status.certTrusted || false,
dnsStatus: status.dnsStatus || {},
hasCachedPassword: !!getCachedPassword(),
});
@@ -100,8 +102,13 @@ export async function PATCH(request) {
await enableToolDNS(tool, pwd);
} else if (action === "disable") {
await disableToolDNS(tool, pwd);
} else if (action === "trust-cert") {
await trustCert(pwd);
if (!isWin && sudoPassword) setCachedPassword(sudoPassword);
const status = await getMitmStatus();
return NextResponse.json({ success: true, certTrusted: status.certTrusted });
} else {
return NextResponse.json({ error: "action must be enable or disable" }, { status: 400 });
return NextResponse.json({ error: "action must be enable, disable, or trust-cert" }, { status: 400 });
}
if (!isWin && sudoPassword) setCachedPassword(sudoPassword);

View File

@@ -2,6 +2,7 @@ const fs = require("fs");
const crypto = require("crypto");
const { exec } = require("child_process");
const { execWithPassword } = require("../dns/dnsConfig.js");
const { log, err } = require("../logger");
const IS_WIN = process.platform === "win32";
const IS_MAC = process.platform === "darwin";
@@ -60,7 +61,7 @@ async function installCert(sudoPassword, certPath) {
const isInstalled = await checkCertInstalled(certPath);
if (isInstalled) {
console.log(" Certificate already installed");
log("🔐 Cert: already trusted ✅");
return;
}
@@ -79,7 +80,7 @@ async function installCertMac(sudoPassword, certPath) {
const install = `security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`;
try {
await execWithPassword(`${deleteOld} && ${install}`, sudoPassword);
console.log(`Installed certificate to system keychain: ${certPath}`);
log("🔐 Cert: installed to system keychain");
} catch (error) {
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";
throw new Error(msg);
@@ -95,7 +96,7 @@ async function installCertWindows(certPath) {
{ windowsHide: true },
(error) => {
if (error) reject(new Error(`Failed to install certificate: ${error.message}`));
else { console.log("✅ Installed certificate to Windows Root store"); resolve(); }
else { log("🔐 Cert: installed to Windows Root store"); resolve(); }
}
);
});
@@ -107,7 +108,7 @@ async function installCertWindows(certPath) {
async function uninstallCert(sudoPassword, certPath) {
const isInstalled = await checkCertInstalled(certPath);
if (!isInstalled) {
console.log("Certificate not found in system store");
log("🔐 Cert: not found in system store");
return;
}
@@ -125,7 +126,7 @@ async function uninstallCertMac(sudoPassword, certPath) {
const command = `security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`;
try {
await execWithPassword(command, sudoPassword);
console.log("✅ Uninstalled certificate from system keychain");
log("🔐 Cert: uninstalled from system keychain");
} catch (err) {
throw new Error("Failed to uninstall certificate");
}
@@ -139,7 +140,7 @@ async function uninstallCertWindows() {
{ windowsHide: true },
(error) => {
if (error) reject(new Error(`Failed to uninstall certificate: ${error.message}`));
else { console.log("✅ Uninstalled certificate from Windows Root store"); resolve(); }
else { log("🔐 Cert: uninstalled from Windows Root store"); resolve(); }
}
);
});
@@ -156,7 +157,7 @@ async function installCertLinux(sudoPassword, certPath) {
const cmd = `cp "${certPath}" "${destFile}" && (update-ca-certificates 2>/dev/null || update-ca-trust 2>/dev/null || true)`;
try {
await execWithPassword(cmd, sudoPassword);
console.log("✅ Installed certificate to Linux trust store");
log("🔐 Cert: installed to Linux trust store");
} catch (error) {
throw new Error("Certificate install failed");
}
@@ -167,7 +168,7 @@ async function uninstallCertLinux(sudoPassword) {
const cmd = `rm -f "${destFile}" && (update-ca-certificates 2>/dev/null || update-ca-trust 2>/dev/null || true)`;
try {
await execWithPassword(cmd, sudoPassword);
console.log("✅ Uninstalled certificate from Linux trust store");
log("🔐 Cert: uninstalled from Linux trust store");
} catch (error) {
throw new Error("Failed to uninstall certificate");
}

View File

@@ -2,6 +2,7 @@ const { exec, spawn } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
const { log, err } = require("../logger");
// Per-tool DNS hosts mapping
const TOOL_HOSTS = {
@@ -131,7 +132,7 @@ async function addDNSEntry(tool, sudoPassword) {
const entriesToAdd = hosts.filter(h => !checkDNSEntry(h));
if (entriesToAdd.length === 0) {
console.log(`DNS entries for ${tool} already exist`);
log(`🌐 DNS ${tool}: already active`);
return;
}
@@ -175,7 +176,7 @@ async function addDNSEntry(tool, sudoPassword) {
await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword);
await flushDNS(sudoPassword);
}
console.log(`✅ Added DNS entries for ${tool}: ${entriesToAdd.join(", ")}`);
log(`🌐 DNS ${tool}: ✅ added ${entriesToAdd.join(", ")}`);
} catch (error) {
const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to add DNS entry";
throw new Error(msg);
@@ -191,7 +192,7 @@ async function removeDNSEntry(tool, sudoPassword) {
const entriesToRemove = hosts.filter(h => checkDNSEntry(h));
if (entriesToRemove.length === 0) {
console.log(`DNS entries for ${tool} do not exist`);
log(`🌐 DNS ${tool}: already inactive`);
return;
}
@@ -237,7 +238,7 @@ async function removeDNSEntry(tool, sudoPassword) {
}
await flushDNS(sudoPassword);
}
console.log(`✅ Removed DNS entries for ${tool}: ${entriesToRemove.join(", ")}`);
log(`🌐 DNS ${tool}: ✅ removed ${entriesToRemove.join(", ")}`);
} catch (error) {
const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to remove DNS entry";
throw new Error(msg);
@@ -252,7 +253,7 @@ async function removeAllDNSEntries(sudoPassword) {
try {
await removeDNSEntry(tool, sudoPassword);
} catch (e) {
console.log(`[MITM] Warning: failed to remove DNS for ${tool}: ${e.message}`);
err(`DNS ${tool}: failed to remove — ${e.message}`);
}
}
}

8
src/mitm/logger.js Normal file
View File

@@ -0,0 +1,8 @@
function time() {
return new Date().toLocaleTimeString("en-US", { hour12: false });
}
const log = (msg) => console.log(`[${time()}] [MITM] ${msg}`);
const err = (msg) => console.error(`[${time()}] ❌ [MITM] ${msg}`);
module.exports = { log, err };

View File

@@ -11,6 +11,7 @@ const IS_WIN = process.platform === "win32";
const { generateCert } = require("./cert/generate");
const { installCert } = require("./cert/install");
const { MITM_DIR } = require("./paths");
const { log, err } = require("./logger");
const MITM_PORT = 443;
const MITM_WIN_NODE_PORT = 8443;
@@ -140,7 +141,7 @@ async function saveMitmSettings(enabled, password) {
if (password) updates.mitmSudoEncrypted = encryptPassword(password);
await _updateSettings(updates);
} catch (e) {
console.log("[MITM] Failed to save settings:", e.message);
err(`Failed to save settings: ${e.message}`);
}
}
@@ -277,8 +278,10 @@ async function getMitmStatus() {
const dnsStatus = checkAllDNSStatus();
const rootCACertPath = path.join(MITM_DIR, "rootCA.crt");
const certExists = fs.existsSync(rootCACertPath);
const { checkCertInstalled } = require("./cert/install");
const certTrusted = certExists ? await checkCertInstalled(rootCACertPath) : false;
return { running, pid, certExists, dnsStatus };
return { running, pid, certExists, certTrusted, dnsStatus };
}
async function scheduleMitmRestart(apiKey) {
@@ -288,7 +291,7 @@ async function scheduleMitmRestart(apiKey) {
if (aliveMs >= MITM_RESTART_RESET_MS) mitmRestartCount = 0;
if (mitmRestartCount >= MITM_MAX_RESTARTS) {
console.error("[MITM] Max restart attempts reached. Giving up.");
err("Max restart attempts reached. Giving up.");
return;
}
@@ -297,28 +300,28 @@ async function scheduleMitmRestart(apiKey) {
mitmRestartCount++;
mitmIsRestarting = true;
console.log(`[MITM] Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`);
log(`Restarting in ${delay / 1000}s... (${mitmRestartCount}/${MITM_MAX_RESTARTS})`);
await new Promise((r) => setTimeout(r, delay));
try {
const settings = _getSettings ? await _getSettings() : null;
if (settings && !settings.mitmEnabled) {
console.log("[MITM] MITM disabled, skipping restart");
log("MITM disabled, skipping restart");
mitmIsRestarting = false;
return;
}
const password = getCachedPassword() || await loadEncryptedPassword();
if (!password && !IS_WIN) {
console.error("[MITM] No cached password, cannot auto-restart");
err("No cached password, cannot auto-restart");
mitmIsRestarting = false;
return;
}
await startServer(apiKey, password);
console.log("[MITM] Restarted successfully");
log("🔄 Restarted successfully");
mitmRestartCount = 0;
mitmIsRestarting = false;
} catch (err) {
console.error(`[MITM] Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed:`, err.message);
} catch (e) {
err(`Restart attempt ${mitmRestartCount}/${MITM_MAX_RESTARTS} failed: ${e.message}`);
mitmIsRestarting = false;
// Schedule next retry
scheduleMitmRestart(apiKey);
@@ -335,7 +338,7 @@ async function startServer(apiKey, sudoPassword) {
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
if (savedPid && isProcessAlive(savedPid)) {
serverPid = savedPid;
console.log(`[MITM] Reusing existing process PID ${savedPid}`);
log(`♻️ Reusing existing process (PID: ${savedPid})`);
await saveMitmSettings(true, sudoPassword);
if (sudoPassword) setCachedPassword(sudoPassword);
return { running: true, pid: savedPid };
@@ -357,7 +360,7 @@ async function startServer(apiKey, sudoPassword) {
if (portStatus === "in-use" || portStatus === "no-permission") {
const owner = await getPort443Owner(sudoPassword);
if (owner && owner.name === "node") {
console.log(`[MITM] Killing orphan node process on port 443 (PID ${owner.pid})...`);
log(`Killing orphan node process on port 443 (PID ${owner.pid})...`);
try {
const { execWithPassword } = require("./dns/dnsConfig");
await execWithPassword(`kill -9 ${owner.pid}`, sudoPassword);
@@ -377,7 +380,7 @@ async function startServer(apiKey, sudoPassword) {
const rootCAKeyPath = path.join(MITM_DIR, "rootCA.key");
if (!fs.existsSync(rootCACertPath) || !fs.existsSync(rootCAKeyPath)) {
console.log("[MITM] Generating Root CA certificate (first time or migration)...");
log("🔐 Generating Root CA (first time)...");
await generateCert();
}
@@ -385,17 +388,20 @@ async function startServer(apiKey, sudoPassword) {
const { checkCertInstalled } = require("./cert/install");
const rootCATrusted = await checkCertInstalled(rootCACertPath);
if (!rootCATrusted) {
console.log("[MITM] Installing Root CA to system trust store...");
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);
console.log("✅ Root CA installed successfully");
log("🔐 Cert: ✅ trusted");
} else {
log("🔐 Cert: already trusted ✅");
}
// 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);
@@ -436,17 +442,18 @@ async function startServer(apiKey, sudoPassword) {
let startError = null;
if (!IS_WIN) {
serverProcess.stdout.on("data", (data) => {
console.log(`[MITM Server] ${data.toString().trim()}`);
// 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")) {
console.error(`[MITM Server Error] ${msg}`);
err(msg);
startError = msg;
}
});
serverProcess.on("exit", (code) => {
console.log(`MITM server exited with code ${code}`);
log(`Server exited (code: ${code})`);
serverProcess = null;
serverPid = null;
try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ }
@@ -470,6 +477,14 @@ async function startServer(apiKey, sudoPassword) {
fs.writeFileSync(PID_FILE, String(serverPid));
}
log(`✅ Server healthy (PID: ${serverPid || health.pid})`);
// Log DNS status per tool
const dnsStatus = checkAllDNSStatus();
for (const [tool, active] of Object.entries(dnsStatus)) {
log(`🌐 DNS ${tool}: ${active ? "✅ active" : "❌ inactive"}`);
}
await saveMitmSettings(true, sudoPassword);
if (sudoPassword) setCachedPassword(sudoPassword);
@@ -483,7 +498,7 @@ async function stopServer(sudoPassword) {
// Prevent auto-restart from triggering on intentional stop
mitmIsRestarting = true;
mitmRestartCount = 0;
console.log("[MITM] Stopping server...");
log(" Stopping server...");
// Kill server process
const proc = serverProcess;
@@ -492,7 +507,7 @@ async function stopServer(sudoPassword) {
: (() => { try { return parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); } catch { return null; } })();
if (pidToKill && isProcessAlive(pidToKill)) {
console.log(`Killing MITM server (PID: ${pidToKill})...`);
log(`Killing server (PID: ${pidToKill})...`);
killProcess(pidToKill, false, sudoPassword);
await new Promise(r => setTimeout(r, 1000));
if (isProcessAlive(pidToKill)) killProcess(pidToKill, true, sudoPassword);
@@ -562,6 +577,19 @@ async function disableToolDNS(tool, sudoPassword) {
return { success: true };
}
/**
* Install Root CA to system trust store (standalone, no server start)
*/
async function trustCert(sudoPassword) {
const rootCACertPath = path.join(MITM_DIR, "rootCA.crt");
if (!fs.existsSync(rootCACertPath)) throw new Error("Root CA not found. Start server first to generate it.");
const { installCert } = require("./cert/install");
const password = sudoPassword || getCachedPassword() || await loadEncryptedPassword();
if (!password && !IS_WIN) throw new Error("Sudo password required to trust certificate");
await installCert(password, rootCACertPath);
if (password) setCachedPassword(password);
}
// Legacy aliases for backward compatibility
const startMitm = startServer;
const stopMitm = stopServer;
@@ -572,6 +600,7 @@ module.exports = {
stopServer,
enableToolDNS,
disableToolDNS,
trustCert,
// Legacy
startMitm,
stopMitm,

View File

@@ -3,6 +3,7 @@ const fs = require("fs");
const path = require("path");
const dns = require("dns");
const { promisify } = require("util");
const { log, err } = require("./logger");
// Allow self-signed certs from MITM root CA when fetching external hosts
@@ -25,7 +26,7 @@ const DB_FILE = path.join(DATA_DIR, "db.json");
const ENABLE_FILE_LOG = false;
if (!API_KEY) {
console.error("ROUTER_API_KEY required");
err("ROUTER_API_KEY required");
process.exit(1);
}
@@ -57,11 +58,11 @@ function sniCallback(servername, cb) {
// Cache it
certCache.set(servername, ctx);
console.log(`✅ Generated cert for: ${servername}`);
log(`🔐 Cert generated: ${servername}`);
cb(null, ctx);
} catch (error) {
console.error(`SNI error for ${servername}:`, error.message);
err(`SNI error for ${servername}: ${error.message}`);
cb(error);
}
}
@@ -79,7 +80,7 @@ try {
SNICallback: sniCallback
};
} catch (e) {
console.error(`Root CA not found in ${certDir}: ${e.message}`);
err(`Root CA not found in ${certDir}: ${e.message}`);
process.exit(1);
}
@@ -134,7 +135,13 @@ function getMappedModel(tool, model) {
try {
if (!fs.existsSync(DB_FILE)) return null;
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
return db.mitmAlias?.[tool]?.[model] || null;
const aliases = db.mitmAlias?.[tool];
if (!aliases) return null;
// Exact match first
if (aliases[model]) return aliases[model];
// Prefix match fallback: find alias key that starts with model or model starts with key
const prefixKey = Object.keys(aliases).find(k => k && aliases[k] && (model.startsWith(k) || k.startsWith(model)));
return prefixKey ? aliases[prefixKey] : null;
} catch {
return null;
}
@@ -167,8 +174,8 @@ async function passthrough(req, res, bodyBuffer) {
forwardRes.pipe(res);
});
forwardReq.on("error", (err) => {
console.error(`Passthrough error: ${err.message}`);
forwardReq.on("error", (e) => {
err(`Passthrough error: ${e.message}`);
if (!res.headersSent) res.writeHead(502);
res.end("Bad Gateway");
});
@@ -216,7 +223,7 @@ async function intercept(req, res, bodyBuffer, mappedModel) {
res.write(decoder.decode(value, { stream: true }));
}
} catch (error) {
console.error(` ${error.message}`);
err(`Intercept error: ${error.message}`);
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
}
@@ -250,30 +257,34 @@ const server = https.createServer(sslOptions, async (req, res) => {
if (!isChat) return passthrough(req, res, bodyBuffer);
const model = extractModel(req.url, bodyBuffer);
console.log("Extracted model:", model);
log(`🔍 model="${model}" url=${req.url}`);
const mappedModel = getMappedModel(tool, model);
if (!mappedModel) return passthrough(req, res, bodyBuffer);
if (!mappedModel) {
log(`⏩ passthrough | no mapping | ${tool} | ${model || "unknown"}`);
return passthrough(req, res, bodyBuffer);
}
log(`⚡ intercept | ${tool} | ${model}${mappedModel}`);
return intercept(req, res, bodyBuffer, mappedModel);
} catch (err) {
console.error(`Unhandled request error: ${err.message}`);
} catch (e) {
err(`Unhandled request error: ${e.message}`);
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: { message: err.message, type: "mitm_error" } }));
res.end(JSON.stringify({ error: { message: e.message, type: "mitm_error" } }));
}
});
server.listen(LOCAL_PORT, () => {
console.log(`🚀 MITM ready on :${LOCAL_PORT}`);
log(`🚀 Server ready on :${LOCAL_PORT}`);
});
server.on("error", (error) => {
if (error.code === "EADDRINUSE") {
console.error(`Port ${LOCAL_PORT} already in use`);
err(`Port ${LOCAL_PORT} already in use`);
} else if (error.code === "EACCES") {
console.error(`Permission denied for port ${LOCAL_PORT}`);
err(`Permission denied for port ${LOCAL_PORT}`);
} else {
console.error(`${error.message}`);
err(error.message);
}
process.exit(1);
});