diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 7f5cb4f..0000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,63 +0,0 @@ -name: Build and Push Docker Image - -on: - push: - branches: - - master - tags: - - 'v*' - pull_request: - branches: - - master - workflow_dispatch: - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }} - -jobs: - build-and-push: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Container Registry - if: github.event_name != 'pull_request' - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata (tags, labels) - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=ref,event=branch - type=ref,event=pr - type=semver,pattern={{version}} - type=semver,pattern={{major}}.{{minor}} - type=semver,pattern={{major}} - type=sha,prefix= - type=raw,value=latest,enable={{is_default_branch}} - - - name: Build and push Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - platforms: linux/amd64,linux/arm64 diff --git a/open-sse/config/constants.js b/open-sse/config/constants.js index 39b2e32..e499e6b 100644 --- a/open-sse/config/constants.js +++ b/open-sse/config/constants.js @@ -164,8 +164,8 @@ export const PROVIDERS = { }, antigravity: { baseUrls: [ - "https://daily-cloudcode-pa.googleapis.com", "https://cloudcode-pa.googleapis.com", + "https://daily-cloudcode-pa.googleapis.com" ], format: "antigravity", headers: { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index dbda60a..2ed4797 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -8,6 +8,14 @@ import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, Default const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +const STATUS_ENDPOINTS = { + claude: "/api/cli-tools/claude-settings", + codex: "/api/cli-tools/codex-settings", + droid: "/api/cli-tools/droid-settings", + openclaw: "/api/cli-tools/openclaw-settings", + antigravity: "/api/cli-tools/antigravity-mitm", +}; + export default function CLIToolsPageClient({ machineId }) { const [connections, setConnections] = useState([]); const [loading, setLoading] = useState(true); @@ -17,13 +25,34 @@ export default function CLIToolsPageClient({ machineId }) { const [tunnelEnabled, setTunnelEnabled] = useState(false); const [tunnelUrl, setTunnelUrl] = useState(""); const [apiKeys, setApiKeys] = useState([]); + const [toolStatuses, setToolStatuses] = useState({}); useEffect(() => { fetchConnections(); loadCloudSettings(); fetchApiKeys(); + fetchAllStatuses(); }, []); + const fetchAllStatuses = async () => { + try { + const entries = await Promise.all( + Object.entries(STATUS_ENDPOINTS).map(async ([toolId, url]) => { + try { + const res = await fetch(url); + const data = await res.json(); + return [toolId, data]; + } catch { + return [toolId, null]; + } + }) + ); + setToolStatuses(Object.fromEntries(entries)); + } catch (error) { + console.log("Error fetching tool statuses:", error); + } + }; + const loadCloudSettings = async () => { try { const [settingsRes, tunnelRes] = await Promise.all([ @@ -165,16 +194,17 @@ export default function CLIToolsPageClient({ machineId }) { onModelMappingChange={(alias, target) => handleModelMappingChange(toolId, alias, target)} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} + initialStatus={toolStatuses.claude} /> ); case "codex": - return ; + return ; case "droid": - return ; + return ; case "openclaw": - return ; + return ; case "antigravity": - return ; + return ; default: return ; } diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js index 5f99bc6..d4462ea 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/AntigravityToolCard.js @@ -13,8 +13,9 @@ export default function AntigravityToolCard({ activeProviders, hasActiveProviders, cloudEnabled, + initialStatus, }) { - const [status, setStatus] = useState(null); + const [status, setStatus] = useState(initialStatus || null); const [loading, setLoading] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false); const [sudoPassword, setSudoPassword] = useState(""); @@ -30,12 +31,17 @@ export default function AntigravityToolCard({ } }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (initialStatus) setStatus(initialStatus); + }, [initialStatus]); + useEffect(() => { if (isExpanded && !status) { fetchStatus(); loadSavedMappings(); } - }, [isExpanded, status]); + if (isExpanded) loadSavedMappings(); + }, [isExpanded]); const loadSavedMappings = async () => { try { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js index d373fb6..6d44841 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/ClaudeToolCard.js @@ -17,8 +17,9 @@ export default function ClaudeToolCard({ hasActiveProviders, apiKeys, cloudEnabled, + initialStatus, }) { - const [claudeStatus, setClaudeStatus] = useState(null); + const [claudeStatus, setClaudeStatus] = useState(initialStatus || null); const [checkingClaude, setCheckingClaude] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); @@ -51,12 +52,17 @@ export default function ClaudeToolCard({ } }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (initialStatus) setClaudeStatus(initialStatus); + }, [initialStatus]); + useEffect(() => { if (isExpanded && !claudeStatus) { checkClaudeStatus(); fetchModelAliases(); } - }, [isExpanded, claudeStatus]); + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); const fetchModelAliases = async () => { try { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js index a268086..aac9346 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/CodexToolCard.js @@ -4,8 +4,8 @@ import { useState, useEffect } from "react"; import { Card, Button, ModelSelectModal, ManualConfigModal } from "@/shared/components"; import Image from "next/image"; -export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled }) { - const [codexStatus, setCodexStatus] = useState(null); +export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, apiKeys, activeProviders, cloudEnabled, initialStatus }) { + const [codexStatus, setCodexStatus] = useState(initialStatus || null); const [checkingCodex, setCheckingCodex] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); @@ -24,12 +24,17 @@ export default function CodexToolCard({ tool, isExpanded, onToggle, baseUrl, api } }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (initialStatus) setCodexStatus(initialStatus); + }, [initialStatus]); + useEffect(() => { if (isExpanded && !codexStatus) { checkCodexStatus(); fetchModelAliases(); } - }, [isExpanded, codexStatus]); + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); const fetchModelAliases = async () => { try { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js index 0641c91..799f44f 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/DroidToolCard.js @@ -15,8 +15,9 @@ export default function DroidToolCard({ apiKeys, activeProviders, cloudEnabled, + initialStatus, }) { - const [droidStatus, setDroidStatus] = useState(null); + const [droidStatus, setDroidStatus] = useState(initialStatus || null); const [checkingDroid, setCheckingDroid] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); @@ -48,12 +49,17 @@ export default function DroidToolCard({ } }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (initialStatus) setDroidStatus(initialStatus); + }, [initialStatus]); + useEffect(() => { if (isExpanded && !droidStatus) { checkDroidStatus(); fetchModelAliases(); } - }, [isExpanded, droidStatus]); + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); const fetchModelAliases = async () => { try { diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js index fc1bf36..1aa5af5 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/OpenClawToolCard.js @@ -13,8 +13,9 @@ export default function OpenClawToolCard({ apiKeys, activeProviders, cloudEnabled, + initialStatus, }) { - const [openclawStatus, setOpenclawStatus] = useState(null); + const [openclawStatus, setOpenclawStatus] = useState(initialStatus || null); const [checkingOpenclaw, setCheckingOpenclaw] = useState(false); const [applying, setApplying] = useState(false); const [restoring, setRestoring] = useState(false); @@ -45,12 +46,17 @@ export default function OpenClawToolCard({ } }, [apiKeys, selectedApiKey]); + useEffect(() => { + if (initialStatus) setOpenclawStatus(initialStatus); + }, [initialStatus]); + useEffect(() => { if (isExpanded && !openclawStatus) { checkOpenclawStatus(); fetchModelAliases(); } - }, [isExpanded, openclawStatus]); + if (isExpanded) fetchModelAliases(); + }, [isExpanded]); const fetchModelAliases = async () => { try { diff --git a/src/app/api/providers/[id]/test/testUtils.js b/src/app/api/providers/[id]/test/testUtils.js index d84f2dd..4049426 100644 --- a/src/app/api/providers/[id]/test/testUtils.js +++ b/src/app/api/providers/[id]/test/testUtils.js @@ -5,11 +5,13 @@ import { ANTIGRAVITY_CONFIG, CODEX_CONFIG, KIRO_CONFIG, + QWEN_CONFIG, + CLAUDE_CONFIG, } from "@/lib/oauth/constants/oauth"; // OAuth provider test endpoints const OAUTH_TEST_CONFIG = { - claude: { checkExpiry: true }, + claude: { checkExpiry: true, refreshable: true }, codex: { checkExpiry: true, refreshable: true }, "gemini-cli": { url: "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", @@ -33,18 +35,14 @@ const OAUTH_TEST_CONFIG = { extraHeaders: { "User-Agent": "9Router", "Accept": "application/vnd.github+json" }, }, iflow: { - url: "https://iflow.cn/api/oauth/getUserInfo", + // iFlow getUserInfo requires accessToken as query param, not header + buildUrl: (token) => `https://iflow.cn/api/oauth/getUserInfo?accessToken=${encodeURIComponent(token)}`, method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", - }, - qwen: { - url: "https://portal.qwen.ai/v1/models", - method: "GET", - authHeader: "Authorization", - authPrefix: "Bearer ", + noAuth: true, }, + qwen: { checkExpiry: true, refreshable: true }, kiro: { checkExpiry: true, refreshable: true }, + cursor: { tokenExists: true }, }; async function refreshOAuthToken(connection) { @@ -85,8 +83,26 @@ async function refreshOAuthToken(connection) { return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; } + if (provider === "claude") { + const response = await fetch(CLAUDE_CONFIG.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/json", "Accept": "application/json" }, + body: JSON.stringify({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLAUDE_CONFIG.clientId, + }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + } + if (provider === "kiro") { - const { clientId, clientSecret, region } = connection; + const psd = connection.providerSpecificData || {}; + const clientId = psd.clientId || connection.clientId; + const clientSecret = psd.clientSecret || connection.clientSecret; + const region = psd.region || connection.region; if (clientId && clientSecret) { const endpoint = `https://oidc.${region || "us-east-1"}.amazonaws.com/token`; const response = await fetch(endpoint, { @@ -100,7 +116,7 @@ async function refreshOAuthToken(connection) { } const response = await fetch(KIRO_CONFIG.socialRefreshUrl, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { "Content-Type": "application/json", "User-Agent": "kiro-cli/1.0.0" }, body: JSON.stringify({ refreshToken }), }); if (!response.ok) return null; @@ -108,6 +124,21 @@ async function refreshOAuthToken(connection) { return { accessToken: data.accessToken, expiresIn: data.expiresIn || 3600, refreshToken: data.refreshToken || refreshToken }; } + if (provider === "qwen") { + const response = await fetch(QWEN_CONFIG.tokenUrl, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded", "Accept": "application/json" }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: QWEN_CONFIG.clientId, + }), + }); + if (!response.ok) return null; + const data = await response.json(); + return { accessToken: data.access_token, expiresIn: data.expires_in, refreshToken: data.refresh_token || refreshToken }; + } + return null; } catch (err) { console.log(`Error refreshing ${provider} token:`, err.message); @@ -127,6 +158,11 @@ async function testOAuthConnection(connection) { if (!config) return { valid: false, error: "Provider test not supported", refreshed: false }; if (!connection.accessToken) return { valid: false, error: "No access token", refreshed: false }; + // Cursor uses protobuf API - can only verify token exists, not test endpoint + if (config.tokenExists) { + return { valid: true, error: null, refreshed: false, newTokens: null }; + } + let accessToken = connection.accessToken; let refreshed = false; let newTokens = null; @@ -150,17 +186,24 @@ async function testOAuthConnection(connection) { } try { - const headers = { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders }; - const res = await fetch(config.url, { method: config.method, headers }); + const testUrl = config.buildUrl ? config.buildUrl(accessToken) : config.url; + const headers = config.noAuth + ? { ...config.extraHeaders } + : { [config.authHeader]: `${config.authPrefix}${accessToken}`, ...config.extraHeaders }; + const res = await fetch(testUrl, { method: config.method, headers }); if (res.ok) return { valid: true, error: null, refreshed, newTokens }; if (res.status === 401 && config.refreshable && !refreshed && connection.refreshToken) { const tokens = await refreshOAuthToken(connection); if (tokens) { - const retryRes = await fetch(config.url, { + const retryUrl = config.buildUrl ? config.buildUrl(tokens.accessToken) : testUrl; + const retryHeaders = config.noAuth + ? { ...config.extraHeaders } + : { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders }; + const retryRes = await fetch(retryUrl, { method: config.method, - headers: { [config.authHeader]: `${config.authPrefix}${tokens.accessToken}`, ...config.extraHeaders }, + headers: retryHeaders, }); if (retryRes.ok) return { valid: true, error: null, refreshed: true, newTokens: tokens }; } diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index b48433a..9cafc91 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -3,7 +3,10 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const TARGET_HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com" +]; const IS_WIN = process.platform === "win32"; const IS_MAC = process.platform === "darwin"; const HOSTS_FILE = IS_WIN @@ -51,12 +54,16 @@ function execElevatedWindows(command) { } /** - * Check if DNS entry already exists + * Check if DNS entry already exists for a specific host */ -function checkDNSEntry() { +function checkDNSEntry(host = null) { try { const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); - return hostsContent.includes(TARGET_HOST); + if (host) { + return hostsContent.includes(host); + } + // Check if all target hosts exist + return TARGET_HOSTS.every(h => hostsContent.includes(h)); } catch { return false; } @@ -66,19 +73,24 @@ function checkDNSEntry() { * Add DNS entry to hosts file */ async function addDNSEntry(sudoPassword) { - if (checkDNSEntry()) { - console.log(`DNS entry for ${TARGET_HOST} already exists`); + const entriesToAdd = TARGET_HOSTS.filter(host => !checkDNSEntry(host)); + + if (entriesToAdd.length === 0) { + console.log(`DNS entries for all target hosts already exist`); return; } - const entry = `127.0.0.1 ${TARGET_HOST}`; + const entries = entriesToAdd.map(host => `127.0.0.1 ${host}`).join("\n"); try { if (IS_WIN) { - // Windows: use elevated echo >> hosts - await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`); + // Windows: add each entry separately + for (const host of entriesToAdd) { + const entry = `127.0.0.1 ${host}`; + await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`); + } } else { - await execWithPassword(`echo "${entry}" >> ${HOSTS_FILE}`, sudoPassword); + await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword); } // Flush DNS cache if (IS_WIN) { @@ -89,7 +101,7 @@ async function addDNSEntry(sudoPassword) { // Linux: try systemd-resolved, fall back silently await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); } - console.log(`✅ Added DNS entry: ${entry}`); + console.log(`✅ Added DNS entries: ${entriesToAdd.join(", ")}`); } catch (error) { const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to add DNS entry"; throw new Error(msg); @@ -100,8 +112,10 @@ async function addDNSEntry(sudoPassword) { * Remove DNS entry from hosts file */ async function removeDNSEntry(sudoPassword) { - if (!checkDNSEntry()) { - console.log(`DNS entry for ${TARGET_HOST} does not exist`); + const entriesToRemove = TARGET_HOSTS.filter(host => checkDNSEntry(host)); + + if (entriesToRemove.length === 0) { + console.log(`DNS entries for target hosts do not exist`); return; } @@ -109,7 +123,7 @@ async function removeDNSEntry(sudoPassword) { if (IS_WIN) { // Read in Node, filter, write to temp file, then elevated-copy over hosts const content = fs.readFileSync(HOSTS_FILE, "utf8"); - const filtered = content.split(/\r?\n/).filter(l => !l.includes(TARGET_HOST)).join("\r\n"); + const filtered = content.split(/\r?\n/).filter(l => !TARGET_HOSTS.some(host => l.includes(host))).join("\r\n"); if (!filtered.trim() && content.trim()) { throw new Error("Filtered hosts content is empty, aborting to prevent data loss"); } @@ -125,11 +139,13 @@ async function removeDNSEntry(sudoPassword) { }); }); } else { - // sed -i '' is macOS syntax; Linux uses sed -i without the empty string arg - const sedCmd = IS_MAC - ? `sed -i '' '/${TARGET_HOST}/d' ${HOSTS_FILE}` - : `sed -i '/${TARGET_HOST}/d' ${HOSTS_FILE}`; - await execWithPassword(sedCmd, sudoPassword); + // Remove all target hosts using sed + for (const host of entriesToRemove) { + const sedCmd = IS_MAC + ? `sed -i '' '/${host}/d' ${HOSTS_FILE}` + : `sed -i '/${host}/d' ${HOSTS_FILE}`; + await execWithPassword(sedCmd, sudoPassword); + } } // Flush DNS cache if (IS_WIN) { @@ -139,7 +155,7 @@ async function removeDNSEntry(sudoPassword) { } else { await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); } - console.log(`✅ Removed DNS entry for ${TARGET_HOST}`); + console.log(`✅ Removed DNS entries for ${entriesToRemove.join(", ")}`); } catch (error) { const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to remove DNS entry"; throw new Error(msg); diff --git a/src/mitm/manager.js b/src/mitm/manager.js index d19d7a3..38091d2 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -1,5 +1,4 @@ -const cp = require("child_process"); -const { exec } = cp; +const { exec, spawn, execSync } = require("child_process"); const path = require("path"); const fs = require("fs"); const os = require("os"); @@ -42,6 +41,43 @@ const SERVER_PATH = resolveServerPath(); const ENCRYPT_ALGO = "aes-256-gcm"; const ENCRYPT_SALT = "9router-mitm-pwd"; +/** + * Get process name using port 443 + * @returns {string|null} Process name or null if not found + */ +function getProcessUsingPort443() { + try { + if (IS_WIN) { + // Windows: use netstat to find PID, then tasklist to get process name + const netstatResult = execSync("netstat -ano | findstr :443", { encoding: "utf8" }); + const lines = netstatResult.trim().split("\n"); + if (lines.length > 0) { + // Extract PID from last column (format: TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 1234) + const pidMatch = lines[0].match(/\s+(\d+)\s*$/); + if (pidMatch) { + const pid = pidMatch[1]; + const tasklistResult = execSync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { encoding: "utf8" }); + const processMatch = tasklistResult.match(/"([^"]+)"/); + if (processMatch) { + return processMatch[1].replace(".exe", ""); + } + } + } + } else { + // macOS/Linux: use lsof + const result = execSync("lsof -i :443", { encoding: "utf8" }); + const lines = result.trim().split("\n"); + if (lines.length > 1) { + const processName = lines[1].split(/\s+/)[0]; + return processName; + } + } + } catch (error) { + return null; + } + return null; +} + // Store server process in-memory let serverProcess = null; let serverPid = null; @@ -441,7 +477,9 @@ async function startMitm(apiKey, sudoPassword) { if (!health) { if (IS_WIN) serverProcess = null; try { await removeDNSEntry(sudoPassword); } catch { /* best effort */ } - const reason = startError || "Check sudo password or port 443 access."; + const processUsing443 = getProcessUsingPort443(); + const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; + const reason = startError || `Check sudo password or port 443 access.${portInfo}`; throw new Error(`MITM server failed to start. ${reason}`); } diff --git a/src/mitm/server.js b/src/mitm/server.js index 70f5ab9..d982325 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -7,7 +7,10 @@ const os = require("os"); // Configuration const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; -const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +const TARGET_HOSTS = [ + "daily-cloudcode-pa.googleapis.com", + "cloudcode-pa.googleapis.com" +]; const LOCAL_PORT = 443; const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; const API_KEY = process.env.ROUTER_API_KEY; @@ -69,15 +72,15 @@ function saveResponseLog(url, data) { } // Resolve real IP of target host (bypass /etc/hosts) -let cachedTargetIP = null; -async function resolveTargetIP() { - if (cachedTargetIP) return cachedTargetIP; +const cachedTargetIPs = {}; +async function resolveTargetIP(hostname) { + if (cachedTargetIPs[hostname]) return cachedTargetIPs[hostname]; const resolver = new dns.Resolver(); resolver.setServers(["8.8.8.8"]); const resolve4 = promisify(resolver.resolve4.bind(resolver)); - const addresses = await resolve4(TARGET_HOST); - cachedTargetIP = addresses[0]; - return cachedTargetIP; + const addresses = await resolve4(hostname); + cachedTargetIPs[hostname] = addresses[0]; + return cachedTargetIPs[hostname]; } function collectBodyRaw(req) { @@ -108,15 +111,16 @@ function getMappedModel(model) { } async function passthrough(req, res, bodyBuffer) { - const targetIP = await resolveTargetIP(); + const targetHost = req.headers.host || TARGET_HOSTS[0]; + const targetIP = await resolveTargetIP(targetHost); const forwardReq = https.request({ hostname: targetIP, port: 443, path: req.url, method: req.method, - headers: { ...req.headers, host: TARGET_HOST }, - servername: TARGET_HOST, + headers: { ...req.headers, host: targetHost }, + servername: targetHost, rejectUnauthorized: false }, (forwardRes) => { res.writeHead(forwardRes.statusCode, forwardRes.headers); @@ -210,6 +214,7 @@ const server = https.createServer(sslOptions, async (req, res) => { server.listen(LOCAL_PORT, () => { console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`); + console.log(`📡 Intercepting: ${TARGET_HOSTS.join(", ")}`); }); server.on("error", (error) => { diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index a782d97..a1bd7dd 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -38,8 +38,11 @@ let watchdogInterval = null; let networkMonitorInterval = null; let lastNetworkFingerprint = null; let lastWatchdogTick = Date.now(); +let lastTunnelRestartAt = 0; +let tunnelRestartInProgress = false; const WATCHDOG_INTERVAL_MS = 60000; const NETWORK_CHECK_INTERVAL_MS = 5000; +const NETWORK_RESTART_COOLDOWN_MS = 30000; /** * Initialize app on startup @@ -180,15 +183,25 @@ function startNetworkMonitor() { if (!networkChanged && !wasSleep) return; + // Skip if restart already in progress or restarted recently + if (tunnelRestartInProgress) return; + if (now - lastTunnelRestartAt < NETWORK_RESTART_COOLDOWN_MS) return; + const reason = wasSleep && networkChanged ? "sleep/wake + network change" : wasSleep ? "sleep/wake" : "network change"; console.log(`[NetworkMonitor] ${reason} detected, restarting tunnel...`); - killCloudflared(); - await new Promise(r => setTimeout(r, 2000)); - await enableTunnel(); - console.log("[NetworkMonitor] Tunnel restarted"); - lastNetworkFingerprint = getNetworkFingerprint(); + tunnelRestartInProgress = true; + lastTunnelRestartAt = now; + try { + killCloudflared(); + await new Promise(r => setTimeout(r, 2000)); + await enableTunnel(); + console.log("[NetworkMonitor] Tunnel restarted"); + lastNetworkFingerprint = getNetworkFingerprint(); + } finally { + tunnelRestartInProgress = false; + } } catch (err) { console.log("[NetworkMonitor] Tunnel restart failed:", err.message); }