Files
OmniRoute/src/mitm/manager.ts

266 lines
6.9 KiB
TypeScript

import { spawn, type ChildProcess } from "child_process";
import path from "path";
import fs from "fs";
import { resolveMitmDataDir } from "./dataDir.ts";
import { addDNSEntry, removeDNSEntry } from "./dns/dnsConfig.ts";
import { generateCert } from "./cert/generate.ts";
import { installCert } from "./cert/install.ts";
// Store server process
let serverProcess: ChildProcess | null = null;
let serverPid: number | null = null;
// Module-scoped password cache (not exposed on globalThis).
// Cleared automatically when the MITM proxy is stopped.
let _cachedPassword: string | null = null;
export function getCachedPassword(): string | null {
return _cachedPassword;
}
export function setCachedPassword(pwd: string | null | undefined): void {
_cachedPassword = pwd || null;
}
export function clearCachedPassword(): void {
_cachedPassword = null;
}
const PID_FILE = path.join(resolveMitmDataDir(), "mitm", ".mitm.pid");
const MITM_SERVER_URL = new URL("./server.cjs", import.meta.url);
const urlPath =
process.platform === "win32" && MITM_SERVER_URL.pathname.startsWith("/")
? decodeURIComponent(MITM_SERVER_URL.pathname.slice(1))
: decodeURIComponent(MITM_SERVER_URL.pathname);
const cwdPath = path.join(process.cwd(), "src", "mitm", "server.cjs");
const MITM_SERVER_PATH = fs.existsSync(cwdPath) ? cwdPath : urlPath;
// Check if a PID is alive
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Get MITM status
*/
export async function getMitmStatus(): Promise<{
running: boolean;
pid: number | null;
dnsConfigured: boolean;
certExists: boolean;
}> {
// Check in-memory process first, then fallback to PID file
let running = serverProcess !== null && !serverProcess.killed;
let pid = serverPid;
if (!running) {
try {
if (fs.existsSync(PID_FILE)) {
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
if (savedPid && isProcessAlive(savedPid)) {
running = true;
pid = savedPid;
} else {
// Stale PID file, clean up
fs.unlinkSync(PID_FILE);
}
}
} catch {
// Ignore
}
}
// Check DNS configuration
let dnsConfigured = false;
try {
const hostsContent = fs.readFileSync("/etc/hosts", "utf-8");
dnsConfigured = /\bdaily-cloudcode-pa\.googleapis\.com\b/.test(hostsContent);
} catch {
// Ignore
}
// Check cert
const certDir = path.join(resolveMitmDataDir(), "mitm");
const certExists = fs.existsSync(path.join(certDir, "server.crt"));
return { running, pid, dnsConfigured, certExists };
}
/**
* Start MITM proxy
* @param {string} apiKey - OmniRoute API key
* @param {string} sudoPassword - Sudo password for DNS/cert operations
*/
export async function startMitm(
apiKey: string,
sudoPassword: string,
options: { port?: number } = {}
): Promise<{ running: true; pid: number | null }> {
// Check if already running
if (serverProcess && !serverProcess.killed) {
throw new Error("MITM proxy is already running");
}
// 1. Generate SSL certificate if not exists
const certPath = path.join(resolveMitmDataDir(), "mitm", "server.crt");
if (!fs.existsSync(certPath)) {
console.log("Generating SSL certificate...");
await generateCert();
}
// 2. Install certificate to system keychain
await installCert(sudoPassword, certPath);
// 3. Add DNS entry
console.log("Adding DNS entry...");
await addDNSEntry(sudoPassword);
// 4. Start MITM server
console.log("Starting MITM server...");
const port =
typeof options.port === "number" &&
Number.isInteger(options.port) &&
options.port > 0 &&
options.port <= 65535
? options.port
: 443;
serverProcess = spawn(process.execPath, [MITM_SERVER_PATH], {
env: {
...process.env,
ROUTER_API_KEY: apiKey,
MITM_LOCAL_PORT: String(port),
NODE_ENV: "production",
},
detached: false,
stdio: ["ignore", "pipe", "pipe"],
});
const proc = serverProcess;
serverPid = proc.pid ?? null;
// Save PID to file
if (serverPid !== null) {
fs.writeFileSync(PID_FILE, String(serverPid));
}
// Log server output
proc.stdout?.on("data", (data) => {
console.log(`[MITM Server] ${data.toString().trim()}`);
});
proc.stderr?.on("data", (data) => {
console.error(`[MITM Server Error] ${data.toString().trim()}`);
});
proc.on("exit", (code) => {
console.log(`MITM server exited with code ${code}`);
serverProcess = null;
serverPid = null;
// Remove PID file
try {
fs.unlinkSync(PID_FILE);
} catch (error) {
// Ignore
}
});
// Wait and verify server actually started
const started = await new Promise<boolean>((resolve) => {
let resolved = false;
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true;
resolve(true);
}
}, 2000);
proc.on("exit", () => {
clearTimeout(timeout);
if (!resolved) {
resolved = true;
resolve(false);
}
});
// Check stderr for error messages
proc.stderr?.on("data", (data) => {
const msg = data.toString().trim();
if (msg.includes("Port") && msg.includes("already in use")) {
clearTimeout(timeout);
if (!resolved) {
resolved = true;
resolve(false);
}
}
});
});
if (!started) {
throw new Error("MITM server failed to start (port 443 may be in use)");
}
return {
running: true,
pid: serverPid,
};
}
/**
* Stop MITM proxy
* @param {string} sudoPassword - Sudo password for DNS cleanup
*/
export async function stopMitm(sudoPassword: string): Promise<{ running: false; pid: null }> {
// 1. Kill server process (in-memory or from PID file)
const proc = serverProcess;
if (proc && !proc.killed) {
console.log("Stopping MITM server...");
proc.kill("SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
if (!proc.killed) {
proc.kill("SIGKILL");
}
serverProcess = null;
serverPid = null;
} else {
// Fallback: kill by PID file
try {
if (fs.existsSync(PID_FILE)) {
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
if (savedPid && isProcessAlive(savedPid)) {
console.log(`Killing MITM server (PID: ${savedPid})...`);
process.kill(savedPid, "SIGTERM");
await new Promise((resolve) => setTimeout(resolve, 1000));
if (isProcessAlive(savedPid)) {
process.kill(savedPid, "SIGKILL");
}
}
}
} catch {
// Ignore
}
serverProcess = null;
serverPid = null;
}
// 2. Remove DNS entry
console.log("Removing DNS entry...");
await removeDNSEntry(sudoPassword);
// 3. Clean up
clearCachedPassword(); // Clear password from memory when proxy stops
try {
fs.unlinkSync(PID_FILE);
} catch (error) {
// Ignore
}
return {
running: false,
pid: null,
};
}