## Features - MCP stdio→SSE bridge: expose local stdio MCP plugins over SSE (api/mcp/[plugin]/sse, /message) - Dynamic Linux cert resolution + NSS DB injection (Debian/Arch/Fedora/openSUSE, Chrome/Chromium/Firefox incl. snap) (#1010) - Cowork tool: expanded settings UI & API - GitBook docs (DocsContent, DocsLayout) ## Fixes - OAuth callback postMessage scoped to expected origins (CWE-1385) (#998) - Re-enable TLS verification on DNS-bypass fetch (CWE-295) (#998) - Normalize `developer` role → `system` for OpenAI-format providers (Deepseek, Groq, …) (#1011, closes #773) - Respect `PORT` env in internal model-test fetch (#1014) - Dropdown text readability in dark theme on usage page (#997) ## Improvements - Refactor Claude CLI spoof headers into shared constant - Tool deduper utility in open-sse handlers
252 lines
8.6 KiB
JavaScript
252 lines
8.6 KiB
JavaScript
import os from "os";
|
|
import { fileURLToPath } from "url";
|
|
import { dirname, join } from "path";
|
|
import { existsSync } from "fs";
|
|
import { cleanupProviderConnections, getSettings, updateSettings, getApiKeys } from "@/lib/localDb";
|
|
import {
|
|
enableTunnel, enableTailscale,
|
|
isTunnelManuallyDisabled, isTunnelReconnecting, isTailscaleReconnecting,
|
|
getTunnelService, getTailscaleService,
|
|
} from "@/lib/tunnel/tunnelManager";
|
|
import { killCloudflared, isCloudflaredRunning, ensureCloudflared } from "@/lib/tunnel/cloudflared";
|
|
import { isTailscaleRunning } from "@/lib/tunnel/tailscale";
|
|
import { loadState } from "@/lib/tunnel/state";
|
|
import { checkInternet, probeUrlAlive } from "@/lib/tunnel/networkProbe";
|
|
import {
|
|
RESTART_COOLDOWN_MS, NETWORK_SETTLE_MS,
|
|
WATCHDOG_INTERVAL_MS, NETWORK_CHECK_INTERVAL_MS,
|
|
} from "@/lib/tunnel/tunnelConfig";
|
|
import { getMitmStatus, startMitm, loadEncryptedPassword, initDbHooks, restoreToolDNS, removeAllDNSEntriesSync } from "@/mitm/manager";
|
|
import { syncToJson as syncMitmAliasCache } from "@/lib/mitmAliasCache";
|
|
|
|
// Inject correct paths and DB hooks into manager.js (CJS) from ESM context
|
|
(function bootstrapMitm() {
|
|
if (!process.env.MITM_SERVER_PATH) {
|
|
try {
|
|
const thisFile = fileURLToPath(import.meta.url);
|
|
const appSrc = dirname(dirname(thisFile));
|
|
const candidate = join(appSrc, "mitm", "server.js");
|
|
if (existsSync(candidate)) process.env.MITM_SERVER_PATH = candidate;
|
|
} catch { /* ignore */ }
|
|
}
|
|
try { initDbHooks(getSettings, updateSettings); } catch { /* ignore */ }
|
|
})();
|
|
|
|
process.setMaxListeners(20);
|
|
|
|
// Survive Next.js hot reload
|
|
const g = global.__appSingleton ??= {
|
|
signalHandlersRegistered: false,
|
|
watchdogInterval: null,
|
|
networkMonitorInterval: null,
|
|
lastNetworkFingerprint: null,
|
|
lastWatchdogTick: Date.now(),
|
|
lastOnline: null,
|
|
mitmStartInProgress: false,
|
|
tunnelAutoResumed: false,
|
|
tailscaleAutoResumed: false,
|
|
};
|
|
|
|
export async function initializeApp() {
|
|
try {
|
|
await cleanupProviderConnections();
|
|
const settings = await getSettings();
|
|
|
|
// Auto-resume tunnel (once per process)
|
|
if (settings.tunnelEnabled && !g.tunnelAutoResumed) {
|
|
g.tunnelAutoResumed = true;
|
|
console.log("[InitApp] Tunnel was enabled, auto-resuming...");
|
|
safeRestartTunnel("startup").catch((e) => console.log("[InitApp] Tunnel resume failed:", e.message));
|
|
}
|
|
|
|
// Auto-resume tailscale (once per process)
|
|
if (settings.tailscaleEnabled && !g.tailscaleAutoResumed) {
|
|
g.tailscaleAutoResumed = true;
|
|
console.log("[InitApp] Tailscale was enabled, auto-resuming...");
|
|
safeRestartTailscale("startup").catch((e) => console.log("[InitApp] Tailscale resume failed:", e.message));
|
|
}
|
|
|
|
if (!g.signalHandlersRegistered) {
|
|
const cleanup = () => {
|
|
try { removeAllDNSEntriesSync(); } catch { /* best effort */ }
|
|
killCloudflared();
|
|
process.exit();
|
|
};
|
|
process.on("SIGINT", cleanup);
|
|
process.on("SIGTERM", cleanup);
|
|
process.on("exit", () => { try { removeAllDNSEntriesSync(); } catch { /* ignore */ } });
|
|
g.signalHandlersRegistered = true;
|
|
}
|
|
|
|
ensureCloudflared().catch(() => {});
|
|
|
|
// Sync mitmAlias DB → JSON cache so standalone MITM server can read it
|
|
syncMitmAliasCache().catch(() => {});
|
|
|
|
startWatchdog();
|
|
startNetworkMonitor();
|
|
autoStartMitm();
|
|
} catch (error) {
|
|
console.error("[InitApp] Error:", error);
|
|
}
|
|
}
|
|
|
|
async function autoStartMitm() {
|
|
if (g.mitmStartInProgress) return;
|
|
g.mitmStartInProgress = true;
|
|
try {
|
|
const settings = await getSettings();
|
|
if (!settings.mitmEnabled) return;
|
|
const mitmStatus = await getMitmStatus();
|
|
if (mitmStatus.running) return;
|
|
|
|
const password = await loadEncryptedPassword();
|
|
if (!password && process.platform !== "win32") {
|
|
console.log("[InitApp] MITM was enabled but no saved password found, skipping auto-start");
|
|
return;
|
|
}
|
|
|
|
const keys = await getApiKeys();
|
|
const activeKey = keys.find(k => k.isActive !== false);
|
|
|
|
console.log("[InitApp] MITM was enabled, auto-starting...");
|
|
await startMitm(activeKey?.key || "sk_9router", password);
|
|
console.log("[InitApp] MITM auto-started");
|
|
try {
|
|
await restoreToolDNS(password);
|
|
console.log("[InitApp] DNS restored from saved state");
|
|
} catch (e) {
|
|
console.log("[InitApp] DNS restore failed:", e.message);
|
|
}
|
|
} catch (err) {
|
|
console.log("[InitApp] MITM auto-start failed:", err.message);
|
|
} finally {
|
|
g.mitmStartInProgress = false;
|
|
}
|
|
}
|
|
|
|
// ─── Safe restart (4 guards: spawn / cooldown / alive / internet) ────────────
|
|
|
|
async function safeRestartTunnel(reason) {
|
|
const svc = getTunnelService();
|
|
const settings = await getSettings();
|
|
if (!settings.tunnelEnabled) return;
|
|
if (svc.cancelToken.cancelled) return;
|
|
if (svc.spawnInProgress) return;
|
|
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
|
|
|
// Alive check: process up + URL responds → skip
|
|
if (isCloudflaredRunning()) {
|
|
const state = loadState();
|
|
const publicUrl = state?.shortId ? `https://r${state.shortId}.9router.com` : null;
|
|
if (publicUrl && await probeUrlAlive(publicUrl)) return;
|
|
}
|
|
|
|
if (!await checkInternet()) return;
|
|
|
|
console.log(`[Tunnel] safeRestart (${reason})`);
|
|
try {
|
|
await enableTunnel();
|
|
svc.lastRestartAt = Date.now();
|
|
console.log("[Tunnel] restart success");
|
|
} catch (err) {
|
|
console.log("[Tunnel] restart failed:", err.message);
|
|
}
|
|
}
|
|
|
|
async function safeRestartTailscale(reason) {
|
|
const svc = getTailscaleService();
|
|
const settings = await getSettings();
|
|
if (!settings.tailscaleEnabled) return;
|
|
if (svc.cancelToken.cancelled) return;
|
|
if (svc.spawnInProgress) return;
|
|
if (Date.now() - svc.lastRestartAt < RESTART_COOLDOWN_MS) return;
|
|
|
|
if (isTailscaleRunning() && settings.tailscaleUrl) {
|
|
if (await probeUrlAlive(settings.tailscaleUrl)) return;
|
|
}
|
|
|
|
if (!await checkInternet()) return;
|
|
|
|
console.log(`[Tailscale] safeRestart (${reason})`);
|
|
try {
|
|
await enableTailscale();
|
|
svc.lastRestartAt = Date.now();
|
|
console.log("[Tailscale] restart success");
|
|
} catch (err) {
|
|
console.log("[Tailscale] restart failed:", err.message);
|
|
}
|
|
}
|
|
|
|
// ─── Watchdog: 60s tick check both services ──────────────────────────────────
|
|
|
|
function startWatchdog() {
|
|
if (g.watchdogInterval) return;
|
|
g.watchdogInterval = setInterval(() => {
|
|
safeRestartTunnel("watchdog").catch(() => {});
|
|
safeRestartTailscale("watchdog").catch(() => {});
|
|
}, WATCHDOG_INTERVAL_MS);
|
|
if (g.watchdogInterval.unref) g.watchdogInterval.unref();
|
|
}
|
|
|
|
// ─── Network monitor: detect IPv4 fingerprint change + sleep/wake ────────────
|
|
|
|
function getNetworkFingerprint() {
|
|
const interfaces = os.networkInterfaces();
|
|
const active = [];
|
|
for (const [name, addrs] of Object.entries(interfaces)) {
|
|
if (!addrs) continue;
|
|
for (const addr of addrs) {
|
|
if (!addr.internal && addr.family === "IPv4") {
|
|
active.push(`${name}:${addr.address}`);
|
|
}
|
|
}
|
|
}
|
|
return active.sort().join("|");
|
|
}
|
|
|
|
function startNetworkMonitor() {
|
|
if (g.networkMonitorInterval) return;
|
|
|
|
g.lastNetworkFingerprint = getNetworkFingerprint();
|
|
g.lastWatchdogTick = Date.now();
|
|
g.lastOnline = null;
|
|
|
|
g.networkMonitorInterval = setInterval(async () => {
|
|
try {
|
|
const now = Date.now();
|
|
const elapsed = now - g.lastWatchdogTick;
|
|
g.lastWatchdogTick = now;
|
|
|
|
const currentFingerprint = getNetworkFingerprint();
|
|
const networkChanged = currentFingerprint !== g.lastNetworkFingerprint;
|
|
const wasSleep = elapsed > NETWORK_CHECK_INTERVAL_MS * 6;
|
|
if (networkChanged) g.lastNetworkFingerprint = currentFingerprint;
|
|
|
|
// Real reachability check (TCP 1.1.1.1:443) — not just interface presence
|
|
const online = await checkInternet();
|
|
const wasOffline = g.lastOnline === false;
|
|
g.lastOnline = online;
|
|
|
|
if (!online) return; // no internet → idle, don't restart
|
|
|
|
const onlineEdge = wasOffline; // offline → online transition
|
|
if (!networkChanged && !wasSleep && !onlineEdge) return;
|
|
|
|
// Wait for DHCP/DNS to settle before probing
|
|
await new Promise((r) => setTimeout(r, NETWORK_SETTLE_MS));
|
|
|
|
const reason = onlineEdge ? "online"
|
|
: wasSleep && networkChanged ? "sleep+netchange"
|
|
: wasSleep ? "sleep" : "netchange";
|
|
safeRestartTunnel(reason).catch(() => {});
|
|
safeRestartTailscale(reason).catch(() => {});
|
|
} catch (err) {
|
|
console.log("[NetworkMonitor] error:", err.message);
|
|
}
|
|
}, NETWORK_CHECK_INTERVAL_MS);
|
|
|
|
if (g.networkMonitorInterval.unref) g.networkMonitorInterval.unref();
|
|
}
|
|
|
|
export default initializeApp;
|