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);
}