diff --git a/open-sse/config/providerModels.js b/open-sse/config/providerModels.js index cb7cb30..b167685 100644 --- a/open-sse/config/providerModels.js +++ b/open-sse/config/providerModels.js @@ -145,7 +145,7 @@ export const PROVIDER_MODELS = { // API Key Providers (alias = id) openai: [ { id: "gpt-4o", name: "GPT-4o" }, - { id: "gpt-4o-mini", name: "GPT-4o Mini" }, + { id: "gpt-5-mini", name: "GPT-5 Mini" }, { id: "gpt-4-turbo", name: "GPT-4 Turbo" }, { id: "o1", name: "O1" }, { id: "o1-mini", name: "O1 Mini" }, diff --git a/open-sse/services/provider.js b/open-sse/services/provider.js index 4f7250e..42931ec 100644 --- a/open-sse/services/provider.js +++ b/open-sse/services/provider.js @@ -34,6 +34,16 @@ function buildAnthropicCompatibleUrl(baseUrl) { return `${normalized}/messages`; } +function buildQwenBaseUrl(resourceUrl, fallbackBaseUrl) { + const fallback = (fallbackBaseUrl || "").replace(/\/chat\/completions$/, ""); + const raw = typeof resourceUrl === "string" ? resourceUrl.trim() : ""; + if (!raw) return fallback; + if (raw.startsWith("http://") || raw.startsWith("https://")) { + return raw.replace(/\/$/, ""); + } + return `https://${raw.replace(/\/$/, "")}/v1`; +} + // Detect request format from body structure export function detectFormat(body) { // OpenAI Responses API: has input (array or string) instead of messages[] @@ -178,6 +188,11 @@ export function buildProviderUrl(provider, model, stream = true, options = {}) { case "codex": return config.baseUrl; + case "qwen": { + const baseUrl = buildQwenBaseUrl(options?.qwenResourceUrl, config.baseUrl); + return `${baseUrl}/chat/completions`; + } + case "github": return config.baseUrl; diff --git a/open-sse/services/tokenRefresh.js b/open-sse/services/tokenRefresh.js index 805779e..1d4f628 100644 --- a/open-sse/services/tokenRefresh.js +++ b/open-sse/services/tokenRefresh.js @@ -180,6 +180,9 @@ export async function refreshQwenToken(refreshToken, log) { accessToken: tokens.access_token, refreshToken: tokens.refresh_token || refreshToken, expiresIn: tokens.expires_in, + providerSpecificData: tokens.resource_url + ? { resourceUrl: tokens.resource_url } + : undefined, }; } else { const errorText = await response.text().catch(() => ""); diff --git a/package.json b/package.json index b5b0ff0..1956842 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.3.28", + "version": "0.3.29", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js index 3b30c71..dc07aa8 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js +++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.js @@ -3,19 +3,20 @@ import { useState, useEffect, useCallback } from "react"; import { Card, CardSkeleton } from "@/shared/components"; import { CLI_TOOLS } from "@/shared/constants/cliTools"; -import { PROVIDER_MODELS, getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; -import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard, OpenCodeToolCard, CopilotToolCard } from "./components"; +import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, OpenCodeToolCard } from "./components"; const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL; +// MITM tools are now on /dashboard/mitm — exclude from CLI Tools page +const MITM_TOOL_IDS = ["antigravity", "copilot"]; + const STATUS_ENDPOINTS = { claude: "/api/cli-tools/claude-settings", codex: "/api/cli-tools/codex-settings", opencode: "/api/cli-tools/opencode-settings", - copilot: "/api/cli-tools/copilot-settings", droid: "/api/cli-tools/droid-settings", openclaw: "/api/cli-tools/openclaw-settings", - antigravity: "/api/cli-tools/antigravity-mitm", }; export default function CLIToolsPageClient({ machineId }) { @@ -101,15 +102,12 @@ export default function CLIToolsPageClient({ machineId }) { } }; - const getActiveProviders = () => { - return connections.filter(c => c.isActive !== false); - }; + const getActiveProviders = () => connections.filter(c => c.isActive !== false); const getAllAvailableModels = () => { const activeProviders = getActiveProviders(); const models = []; const seenModels = new Set(); - activeProviders.forEach(conn => { const alias = PROVIDER_ID_TO_ALIAS[conn.provider] || conn.provider; const providerModels = getModelsByProviderId(conn.provider); @@ -117,58 +115,33 @@ export default function CLIToolsPageClient({ machineId }) { const modelValue = `${alias}/${m.id}`; if (!seenModels.has(modelValue)) { seenModels.add(modelValue); - models.push({ - value: modelValue, - label: `${alias}/${m.id}`, - provider: conn.provider, - alias: alias, - connectionName: conn.name, - modelId: m.id, - }); + models.push({ value: modelValue, label: `${alias}/${m.id}`, provider: conn.provider, alias, connectionName: conn.name, modelId: m.id }); } }); }); - return models; }; const handleModelMappingChange = useCallback((toolId, modelAlias, targetModel) => { setModelMappings(prev => { - // Prevent unnecessary updates if value hasn't changed - if (prev[toolId]?.[modelAlias] === targetModel) { - return prev; - } - return { - ...prev, - [toolId]: { - ...prev[toolId], - [modelAlias]: targetModel, - }, - }; + if (prev[toolId]?.[modelAlias] === targetModel) return prev; + return { ...prev, [toolId]: { ...prev[toolId], [modelAlias]: targetModel } }; }); }, []); const getBaseUrl = () => { - if (tunnelEnabled && tunnelUrl) { - return tunnelUrl; - } - if (cloudEnabled && CLOUD_URL) { - return CLOUD_URL; - } - if (typeof window !== "undefined") { - return window.location.origin; - } + if (tunnelEnabled && tunnelUrl) return tunnelUrl; + if (cloudEnabled && CLOUD_URL) return CLOUD_URL; + if (typeof window !== "undefined") return window.location.origin; return "http://localhost:20128"; }; if (loading) { return ( -
-
- - - -
+
+ + +
); } @@ -203,19 +176,17 @@ export default function CLIToolsPageClient({ machineId }) { return ; case "opencode": return ; - case "copilot": - return ; case "droid": return ; case "openclaw": return ; - case "antigravity": - return ; default: return ; } }; + const regularTools = Object.entries(CLI_TOOLS).filter(([id]) => !MITM_TOOL_IDS.includes(id)); + return (
{!hasActiveProviders && ( @@ -229,9 +200,8 @@ export default function CLIToolsPageClient({ machineId }) {
)} -
- {Object.entries(CLI_TOOLS).map(([toolId, tool]) => renderToolCard(toolId, tool))} + {regularTools.map(([toolId, tool]) => renderToolCard(toolId, tool))}
); diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js new file mode 100644 index 0000000..592a66d --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmServerCard.js @@ -0,0 +1,245 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { Card, Button, Badge, Input } from "@/shared/components"; + +/** + * Shared MITM infrastructure card — manages SSL cert + server start/stop. + * DNS per-tool is handled separately in MitmToolCard. + */ +export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [sudoPassword, setSudoPassword] = useState(""); + const [selectedApiKey, setSelectedApiKey] = useState(""); + const [message, setMessage] = useState(null); + const [pendingAction, setPendingAction] = useState(null); // "start" | "stop" + + const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); + + useEffect(() => { + if (apiKeys?.length > 0 && !selectedApiKey) { + setSelectedApiKey(apiKeys[0].key); + } + }, [apiKeys, selectedApiKey]); + + useEffect(() => { + fetchStatus(); + }, []); + + const fetchStatus = async () => { + try { + const res = await fetch("/api/cli-tools/antigravity-mitm"); + if (res.ok) { + const data = await res.json(); + setStatus(data); + onStatusChange?.(data); + } + } catch { + setStatus({ running: false, certExists: false, dnsStatus: {} }); + } + }; + + const handleAction = (action) => { + if (isWindows || status?.hasCachedPassword) { + doAction(action, ""); + } else { + setPendingAction(action); + setShowPasswordModal(true); + setMessage(null); + } + }; + + const doAction = async (action, password) => { + setLoading(true); + setMessage(null); + try { + if (action === "start") { + const keyToUse = selectedApiKey?.trim() + || (apiKeys?.length > 0 ? apiKeys[0].key : null) + || (!cloudEnabled ? "sk_9router" : null); + + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Server started" }); + } else { + setMessage({ type: "error", text: data.error || "Failed to start server" }); + } + } else { + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sudoPassword: password }), + }); + const data = await res.json(); + if (res.ok) { + setMessage({ type: "success", text: "Server stopped — all DNS cleared" }); + } else { + setMessage({ type: "error", text: data.error || "Failed to stop server" }); + } + } + setShowPasswordModal(false); + setSudoPassword(""); + await fetchStatus(); + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + setPendingAction(null); + } + }; + + const handleConfirmPassword = () => { + if (!sudoPassword.trim()) { + setMessage({ type: "error", text: "Sudo password is required" }); + return; + } + doAction(pendingAction, sudoPassword); + }; + + const isRunning = status?.running; + + return ( + <> + +
+ {/* Header */} +
+
+ security + MITM Server + {isRunning ? ( + Running + ) : ( + Stopped + )} +
+
+ {[ + { label: "Cert", ok: status?.certExists }, + { label: "Server", ok: isRunning }, + ].map(({ label, ok }) => ( + + + {ok ? "check_circle" : "radio_button_unchecked"} + + {label} + + ))} +
+
+ + {/* Mechanism explanation */} +
+

+ How it works: MITM server runs an HTTPS proxy on port 443. + When you enable DNS for a tool, its API domain redirects to localhost. + The proxy intercepts requests, applies your model mappings, and forwards to 9Router. +

+
+ + {/* API Key selector (only when stopped, to pick key for start) */} + {!isRunning && ( +
+ API Key + {apiKeys?.length > 0 ? ( + + ) : ( + + {cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"} + + )} +
+ )} + + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + + {/* Action button */} +
+ {isRunning ? ( + + ) : ( + + )} + {isRunning && ( +

Enable DNS per tool below to activate interception

+ )} +
+ + {/* Windows admin warning */} + {!isRunning && isWindows && ( +
+ warning + Windows: Run 9Router terminal as Administrator +
+ )} +
+
+ + {/* Password Modal */} + {showPasswordModal && ( +
+
+

Sudo Password Required

+
+ warning +

Required for SSL certificate and server startup

+
+ setSudoPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !loading) handleConfirmPassword(); }} + /> + {message && ( +
+ error + {message.text} +
+ )} +
+ + +
+
+
+ )} + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js new file mode 100644 index 0000000..af0d9b2 --- /dev/null +++ b/src/app/(dashboard)/dashboard/cli-tools/components/MitmToolCard.js @@ -0,0 +1,307 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, Button, Badge, Input, ModelSelectModal } from "@/shared/components"; +import Image from "next/image"; + +/** + * Per-tool MITM card — shows DNS status + model mappings. + * - Auto-saves model mapping on blur or modal select + * - Start/Stop DNS replaces Save Mappings button + * - Toggle switch removed; status badge is display-only + * - Skips sudo modal if password is already cached + */ +export default function MitmToolCard({ + tool, + isExpanded, + onToggle, + serverRunning, + dnsActive, + certCovered, + hasCachedPassword, + apiKeys, + activeProviders, + hasActiveProviders, + cloudEnabled, + onDnsChange, +}) { + const [loading, setLoading] = useState(false); + const [message, setMessage] = useState(null); + const [showPasswordModal, setShowPasswordModal] = useState(false); + const [sudoPassword, setSudoPassword] = useState(""); + const [pendingDnsAction, setPendingDnsAction] = useState(null); + const [modelMappings, setModelMappings] = useState({}); + const [modalOpen, setModalOpen] = useState(false); + const [currentEditingAlias, setCurrentEditingAlias] = useState(null); + + const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows"); + + useEffect(() => { + if (isExpanded) loadSavedMappings(); + }, [isExpanded]); + + const loadSavedMappings = async () => { + try { + const res = await fetch(`/api/cli-tools/antigravity-mitm/alias?tool=${tool.id}`); + if (res.ok) { + const data = await res.json(); + if (Object.keys(data.aliases || {}).length > 0) setModelMappings(data.aliases); + } + } catch { /* ignore */ } + }; + + const saveMappings = useCallback(async (mappings) => { + try { + await fetch("/api/cli-tools/antigravity-mitm/alias", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: tool.id, mappings }), + }); + } catch { /* ignore */ } + }, [tool.id]); + + const handleMappingBlur = (alias, value) => { + saveMappings({ ...modelMappings, [alias]: value }); + }; + + const handleModelMappingChange = (alias, value) => { + setModelMappings(prev => ({ ...prev, [alias]: value })); + }; + + const openModelSelector = (alias) => { + setCurrentEditingAlias(alias); + setModalOpen(true); + }; + + const handleModelSelect = (model) => { + if (!currentEditingAlias) return; + const updated = { ...modelMappings, [currentEditingAlias]: model.value }; + setModelMappings(updated); + saveMappings(updated); + }; + + // DNS toggle logic + const handleDnsToggle = () => { + if (!serverRunning) return; + const action = dnsActive ? "disable" : "enable"; + if (isWindows || hasCachedPassword) { + doDnsAction(action, ""); + } else { + setPendingDnsAction(action); + setShowPasswordModal(true); + setMessage(null); + } + }; + + const doDnsAction = async (action, password) => { + setLoading(true); + setMessage(null); + try { + const res = await fetch("/api/cli-tools/antigravity-mitm", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ tool: tool.id, action, sudoPassword: password }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Failed to toggle DNS"); + setMessage({ + type: "success", + text: action === "enable" ? "DNS enabled — traffic intercepted" : "DNS disabled — traffic restored", + }); + setShowPasswordModal(false); + setSudoPassword(""); + onDnsChange?.(data); + } catch (error) { + setMessage({ type: "error", text: error.message }); + } finally { + setLoading(false); + setPendingDnsAction(null); + } + }; + + const handleConfirmPassword = () => { + if (!sudoPassword.trim()) { + setMessage({ type: "error", text: "Sudo password is required" }); + return; + } + doDnsAction(pendingDnsAction, sudoPassword); + }; + + return ( + <> + +
+
+
+ {tool.name} { e.target.style.display = "none"; }} + /> +
+
+
+

{tool.name}

+ {!serverRunning ? ( + Server off + ) : dnsActive ? ( + Active + ) : ( + DNS off + )} +
+

{tool.mitmDomain}

+
+
+ + expand_more + +
+ + {isExpanded && ( +
+ {/* Info */} +
+

+ Domain:{" "} + {tool.mitmDomain} + {certCovered !== undefined && ( + + + {certCovered ? "verified" : "warning"} + + {certCovered ? " cert OK" : " cert missing domain"} + + )} +

+

Toggle DNS to redirect {tool.name} traffic through 9Router via MITM.

+
+ + {message && ( +
+ {message.type === "success" ? "check_circle" : "error"} + {message.text} +
+ )} + + {/* Model Mappings */} + {tool.defaultModels?.length > 0 && ( +
+ {tool.defaultModels.map((model) => ( +
+ {model.name} + arrow_forward + handleModelMappingChange(model.alias, e.target.value)} + onBlur={(e) => handleMappingBlur(model.alias, e.target.value)} + placeholder="provider/model-id" + className="flex-1 px-2 py-1.5 bg-surface rounded border border-border text-xs focus:outline-none focus:ring-1 focus:ring-primary/50" + /> + + {modelMappings[model.alias] && ( + + )} +
+ ))} +
+ )} + + {tool.defaultModels?.length === 0 && ( +

Model mappings will be available soon.

+ )} + + {/* Start / Stop DNS button */} +
+ {dnsActive ? ( + + ) : ( + + )} +
+
+ )} +
+ + {/* Password Modal */} + {showPasswordModal && ( +
+
+

Sudo Password Required

+
+ warning +

Required to modify /etc/hosts and flush DNS cache

+
+ setSudoPassword(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter" && !loading) handleConfirmPassword(); }} + /> + {message && ( +
+ error + {message.text} +
+ )} +
+ + +
+
+
+ )} + + {/* Model Select Modal */} + setModalOpen(false)} + onSelect={handleModelSelect} + selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null} + activeProviders={activeProviders} + title={`Select model for ${currentEditingAlias}`} + /> + + ); +} diff --git a/src/app/(dashboard)/dashboard/cli-tools/components/index.js b/src/app/(dashboard)/dashboard/cli-tools/components/index.js index 06c29dc..ee0ecc1 100644 --- a/src/app/(dashboard)/dashboard/cli-tools/components/index.js +++ b/src/app/(dashboard)/dashboard/cli-tools/components/index.js @@ -6,4 +6,6 @@ export { default as DefaultToolCard } from "./DefaultToolCard"; export { default as AntigravityToolCard } from "./AntigravityToolCard"; export { default as OpenCodeToolCard } from "./OpenCodeToolCard"; export { default as CopilotToolCard } from "./CopilotToolCard"; +export { default as MitmServerCard } from "./MitmServerCard"; +export { default as MitmToolCard } from "./MitmToolCard"; diff --git a/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js new file mode 100644 index 0000000..8ff00d3 --- /dev/null +++ b/src/app/(dashboard)/dashboard/mitm/MitmPageClient.js @@ -0,0 +1,93 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { CLI_TOOLS } from "@/shared/constants/cliTools"; +import { getModelsByProviderId, PROVIDER_ID_TO_ALIAS } from "@/shared/constants/models"; +import { MitmServerCard, MitmToolCard } from "@/app/(dashboard)/dashboard/cli-tools/components"; + +const MITM_TOOL_IDS = ["antigravity", "copilot"]; + +export default function MitmPageClient() { + const [connections, setConnections] = useState([]); + const [apiKeys, setApiKeys] = useState([]); + const [cloudEnabled, setCloudEnabled] = useState(false); + const [expandedTool, setExpandedTool] = useState(null); + const [mitmStatus, setMitmStatus] = useState({ running: false, certExists: false, dnsStatus: {}, certCoversTools: {}, hasCachedPassword: false }); + + useEffect(() => { + fetchConnections(); + fetchApiKeys(); + fetchCloudSettings(); + }, []); + + const fetchConnections = async () => { + try { + const res = await fetch("/api/providers"); + if (res.ok) { + const data = await res.json(); + setConnections(data.connections || []); + } + } catch { /* ignore */ } + }; + + const fetchApiKeys = async () => { + try { + const res = await fetch("/api/keys"); + if (res.ok) { + const data = await res.json(); + setApiKeys(data.keys || []); + } + } catch { /* ignore */ } + }; + + const fetchCloudSettings = async () => { + try { + const res = await fetch("/api/settings"); + if (res.ok) { + const data = await res.json(); + setCloudEnabled(data.cloudEnabled || false); + } + } catch { /* ignore */ } + }; + + const getActiveProviders = () => connections.filter(c => c.isActive !== false); + + const hasActiveProviders = () => { + const active = getActiveProviders(); + return active.some(conn => getModelsByProviderId(conn.provider).length > 0); + }; + + const mitmTools = Object.entries(CLI_TOOLS).filter(([id]) => MITM_TOOL_IDS.includes(id)); + + return ( +
+ {/* MITM Server Card */} + + + {/* Tool Cards */} +
+ {mitmTools.map(([toolId, tool]) => ( + setExpandedTool(expandedTool === toolId ? null : toolId)} + serverRunning={mitmStatus.running} + dnsActive={mitmStatus.dnsStatus?.[toolId] || false} + certCovered={mitmStatus.certCoversTools?.[toolId] || false} + hasCachedPassword={mitmStatus.hasCachedPassword || false} + apiKeys={apiKeys} + activeProviders={getActiveProviders()} + hasActiveProviders={hasActiveProviders()} + cloudEnabled={cloudEnabled} + onDnsChange={(data) => setMitmStatus(prev => ({ ...prev, dnsStatus: data.dnsStatus ?? prev.dnsStatus }))} + /> + ))} +
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/mitm/page.js b/src/app/(dashboard)/dashboard/mitm/page.js new file mode 100644 index 0000000..03d5f39 --- /dev/null +++ b/src/app/(dashboard)/dashboard/mitm/page.js @@ -0,0 +1,5 @@ +import MitmPageClient from "./MitmPageClient"; + +export default function MitmPage() { + return ; +} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 6a499e8..5c8066d 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -5,7 +5,7 @@ import PropTypes from "prop-types"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; -import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, Toggle, Select } from "@/shared/components"; +import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, Toggle, Select } from "@/shared/components"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers"; import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; @@ -18,6 +18,7 @@ export default function ProviderDetailPage() { const [loading, setLoading] = useState(true); const [providerNode, setProviderNode] = useState(null); const [showOAuthModal, setShowOAuthModal] = useState(false); + const [showIFlowCookieModal, setShowIFlowCookieModal] = useState(false); const [showAddApiKeyModal, setShowAddApiKeyModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); const [showEditNodeModal, setShowEditNodeModal] = useState(false); @@ -25,6 +26,7 @@ export default function ProviderDetailPage() { const [modelAliases, setModelAliases] = useState({}); const [headerImgError, setHeaderImgError] = useState(false); const [modelTestResults, setModelTestResults] = useState({}); + const [modelsTestError, setModelsTestError] = useState(""); const [testingModelId, setTestingModelId] = useState(null); const [showAddCustomModel, setShowAddCustomModel] = useState(false); const { copied, copy } = useCopyToClipboard(); @@ -175,6 +177,11 @@ export default function ProviderDetailPage() { setShowOAuthModal(false); }; + const handleIFlowCookieSuccess = () => { + fetchConnections(); + setShowIFlowCookieModal(false); + }; + const handleSaveApiKey = async (formData) => { try { const res = await fetch("/api/providers", { @@ -270,8 +277,10 @@ export default function ProviderDetailPage() { }); const data = await res.json(); setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" })); + setModelsTestError(data.ok ? "" : (data.error || "Model not reachable")); } catch { setModelTestResults((prev) => ({ ...prev, [modelId]: "error" })); + setModelsTestError("Network error"); } finally { setTestingModelId(null); } @@ -356,6 +365,9 @@ export default function ProviderDetailPage() { onCopy={copy} onSetAlias={() => {}} onDeleteAlias={() => handleDeleteAlias(model.alias)} + testStatus={modelTestResults[model.id]} + onTest={connections.length > 0 ? () => handleTestModel(model.id) : undefined} + isTesting={testingModelId === model.id} isCustom /> ))} @@ -504,13 +516,26 @@ export default function ProviderDetailPage() {

Connections

{!isCompatible && ( - +
+ {providerId === "iflow" && ( + + )} + +
)}
@@ -522,9 +547,16 @@ export default function ProviderDetailPage() {

No connections yet

Add your first connection to get started

{!isCompatible && ( - +
+ {providerId === "iflow" && ( + + )} + +
)} ) : ( @@ -559,6 +591,9 @@ export default function ProviderDetailPage() { {providerInfo.passthroughModels ? "Model Aliases" : "Available Models"} + {!!modelsTestError && ( +

{modelsTestError}

+ )} {renderModelsSection()} @@ -585,6 +620,13 @@ export default function ProviderDetailPage() { onClose={() => setShowOAuthModal(false)} /> )} + {providerId === "iflow" && ( + setShowIFlowCookieModal(false)} + /> + )} - - {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} - - {fullModel} - {onTest && ( - + )} + - )} - - {isCustom && ( - - )} + {isCustom && ( + + )} + ); } diff --git a/src/app/api/cli-tools/antigravity-mitm/route.js b/src/app/api/cli-tools/antigravity-mitm/route.js index 1fc8c05..a891030 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.js +++ b/src/app/api/cli-tools/antigravity-mitm/route.js @@ -1,21 +1,37 @@ "use server"; import { NextResponse } from "next/server"; -import { getMitmStatus, startMitm, stopMitm, getCachedPassword, setCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; +import { + getMitmStatus, + startServer, + stopServer, + enableToolDNS, + disableToolDNS, + getCachedPassword, + setCachedPassword, + loadEncryptedPassword, + initDbHooks, +} from "@/mitm/manager"; import { getSettings, updateSettings } from "@/lib/localDb"; -// Inject DB hooks so manager.js (CJS) can persist settings without dynamic import issues initDbHooks(getSettings, updateSettings); -// GET - Check MITM status +const isWin = process.platform === "win32"; + +function getPassword(provided) { + return provided || getCachedPassword() || null; +} + +// GET - Full MITM status (server + per-tool DNS) export async function GET() { try { const status = await getMitmStatus(); return NextResponse.json({ running: status.running, pid: status.pid || null, - dnsConfigured: status.dnsConfigured || false, certExists: status.certExists || false, + dnsStatus: status.dnsStatus || {}, + certCoversTools: status.certCoversTools || {}, hasCachedPassword: !!getCachedPassword(), }); } catch (error) { @@ -24,13 +40,11 @@ export async function GET() { } } -// POST - Start MITM proxy +// POST - Start MITM server (cert + server, no DNS) export async function POST(request) { try { const { apiKey, sudoPassword } = await request.json(); - const isWin = process.platform === "win32"; - // Priority: request password → in-memory cache → encrypted db - const pwd = sudoPassword || getCachedPassword() || await loadEncryptedPassword() || ""; + const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; if (!apiKey || (!isWin && !pwd)) { return NextResponse.json( @@ -39,38 +53,64 @@ export async function POST(request) { ); } - const result = await startMitm(apiKey, pwd); + const result = await startServer(apiKey, pwd); if (!isWin) setCachedPassword(pwd); - return NextResponse.json({ - success: true, - running: result.running, - pid: result.pid, - steps: result.steps || { cert: true, server: true, dns: true }, - }); + return NextResponse.json({ success: true, running: result.running, pid: result.pid }); } catch (error) { - console.log("Error starting MITM:", error.message); - return NextResponse.json({ error: error.message || "Failed to start MITM proxy" }, { status: 500 }); + console.log("Error starting MITM server:", error.message); + return NextResponse.json({ error: error.message || "Failed to start MITM server" }, { status: 500 }); } } -// DELETE - Stop MITM proxy +// DELETE - Stop MITM server (removes all DNS first, then kills server) export async function DELETE(request) { try { - const { sudoPassword } = await request.json(); - const isWin = process.platform === "win32"; - const pwd = sudoPassword || getCachedPassword() || await loadEncryptedPassword() || ""; + const body = await request.json().catch(() => ({})); + const { sudoPassword } = body; + const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; if (!isWin && !pwd) { return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); } - await stopMitm(pwd); + await stopServer(pwd); if (!isWin && sudoPassword) setCachedPassword(sudoPassword); return NextResponse.json({ success: true, running: false }); } catch (error) { - console.log("Error stopping MITM:", error.message); - return NextResponse.json({ error: error.message || "Failed to stop MITM proxy" }, { status: 500 }); + console.log("Error stopping MITM server:", error.message); + return NextResponse.json({ error: error.message || "Failed to stop MITM server" }, { status: 500 }); + } +} + +// PATCH - Toggle DNS for a specific tool (enable/disable) +export async function PATCH(request) { + try { + const { tool, action, sudoPassword } = await request.json(); + const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || ""; + + if (!tool || !action) { + return NextResponse.json({ error: "tool and action required" }, { status: 400 }); + } + if (!isWin && !pwd) { + return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 }); + } + + if (action === "enable") { + await enableToolDNS(tool, pwd); + } else if (action === "disable") { + await disableToolDNS(tool, pwd); + } else { + return NextResponse.json({ error: "action must be enable or disable" }, { status: 400 }); + } + + if (!isWin && sudoPassword) setCachedPassword(sudoPassword); + + const status = await getMitmStatus(); + return NextResponse.json({ success: true, dnsStatus: status.dnsStatus }); + } catch (error) { + console.log("Error toggling DNS:", error.message); + return NextResponse.json({ error: error.message || "Failed to toggle DNS" }, { status: 500 }); } } diff --git a/src/app/api/models/test/route.js b/src/app/api/models/test/route.js index c258849..69c5475 100644 --- a/src/app/api/models/test/route.js +++ b/src/app/api/models/test/route.js @@ -34,15 +34,55 @@ export async function POST(request) { }); const latencyMs = Date.now() - start; - // 200 = ok; 400 = bad request but auth passed (model reachable) - const ok = res.status === 200 || res.status === 400; - let error = null; - if (!ok) { - const text = await res.text().catch(() => ""); - error = `HTTP ${res.status}${text ? `: ${text.slice(0, 120)}` : ""}`; + const rawText = await res.text().catch(() => ""); + let parsed = null; + try { + parsed = rawText ? JSON.parse(rawText) : null; + } catch {} + + if (!res.ok) { + const detail = parsed?.error?.message || parsed?.msg || parsed?.message || parsed?.error || rawText; + const error = `HTTP ${res.status}${detail ? `: ${String(detail).slice(0, 240)}` : ""}`; + return NextResponse.json({ ok: false, latencyMs, error, status: res.status }); } - return NextResponse.json({ ok, latencyMs, error }); + // Some providers may return HTTP 200 but not a real completion for invalid models. + const providerStatus = parsed?.status; + const providerMsg = parsed?.msg || parsed?.message; + const hasProviderErrorStatus = providerStatus !== undefined + && providerStatus !== null + && String(providerStatus) !== "200" + && String(providerStatus) !== "0"; + if (hasProviderErrorStatus && providerMsg) { + return NextResponse.json({ + ok: false, + latencyMs, + status: res.status, + error: `Provider status ${providerStatus}: ${String(providerMsg).slice(0, 240)}`, + }); + } + + if (parsed?.error) { + const providerError = parsed?.error?.message || parsed?.error || "Provider returned an error"; + return NextResponse.json({ + ok: false, + latencyMs, + status: res.status, + error: String(providerError).slice(0, 240), + }); + } + + const hasChoices = Array.isArray(parsed?.choices) && parsed.choices.length > 0; + if (!hasChoices) { + return NextResponse.json({ + ok: false, + latencyMs, + status: res.status, + error: "Provider returned no completion choices for this model", + }); + } + + return NextResponse.json({ ok: true, latencyMs, error: null, status: res.status }); } catch (err) { return NextResponse.json({ ok: false, error: err.message }, { status: 500 }); } diff --git a/src/app/api/oauth/iflow/cookie/route.js b/src/app/api/oauth/iflow/cookie/route.js new file mode 100644 index 0000000..fe0ea97 --- /dev/null +++ b/src/app/api/oauth/iflow/cookie/route.js @@ -0,0 +1,137 @@ +import { NextResponse } from "next/server"; +import { createProviderConnection } from "@/models"; + +/** + * iFlow Cookie-Based Authentication + * POST /api/oauth/iflow/cookie + * Body: { cookie: "BXAuth=xxx; ..." } + */ +export async function POST(request) { + try { + const { cookie } = await request.json(); + + if (!cookie || typeof cookie !== "string") { + return NextResponse.json({ error: "Cookie is required" }, { status: 400 }); + } + + // Normalize cookie + const trimmed = cookie.trim(); + if (!trimmed.includes("BXAuth=")) { + return NextResponse.json({ error: "Cookie must contain BXAuth field" }, { status: 400 }); + } + + let normalizedCookie = trimmed; + if (!normalizedCookie.endsWith(";")) { + normalizedCookie += ";"; + } + + // Step 1: GET API key info to get the name + const getResponse = await fetch("https://platform.iflow.cn/api/openapi/apikey", { + method: "GET", + headers: { + "Cookie": normalizedCookie, + "Accept": "application/json, text/plain, */*", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "same-origin", + }, + }); + + if (!getResponse.ok) { + const errorText = await getResponse.text(); + return NextResponse.json( + { error: `Failed to fetch API key info: ${errorText}` }, + { status: getResponse.status } + ); + } + + const getResult = await getResponse.json(); + if (!getResult.success) { + return NextResponse.json( + { error: `API key fetch failed: ${getResult.message}` }, + { status: 400 } + ); + } + + const keyData = getResult.data; + if (!keyData.name) { + return NextResponse.json({ error: "Missing name in API key info" }, { status: 400 }); + } + + // Step 2: POST to refresh API key + const postResponse = await fetch("https://platform.iflow.cn/api/openapi/apikey", { + method: "POST", + headers: { + "Cookie": normalizedCookie, + "Content-Type": "application/json", + "Accept": "application/json, text/plain, */*", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Origin": "https://platform.iflow.cn", + "Referer": "https://platform.iflow.cn/", + }, + body: JSON.stringify({ name: keyData.name }), + }); + + if (!postResponse.ok) { + const errorText = await postResponse.text(); + return NextResponse.json( + { error: `Failed to refresh API key: ${errorText}` }, + { status: postResponse.status } + ); + } + + const postResult = await postResponse.json(); + if (!postResult.success) { + return NextResponse.json( + { error: `API key refresh failed: ${postResult.message}` }, + { status: 400 } + ); + } + + const refreshedKey = postResult.data; + if (!refreshedKey.apiKey) { + return NextResponse.json({ error: "Missing API key in response" }, { status: 400 }); + } + + // Extract only BXAuth from cookie + const bxAuthMatch = normalizedCookie.match(/BXAuth=([^;]+)/); + const bxAuth = bxAuthMatch ? bxAuthMatch[1] : ""; + const cookieToSave = bxAuth ? `BXAuth=${bxAuth};` : ""; + + // Save to database + const connection = await createProviderConnection({ + provider: "iflow", + authType: "cookie", + name: refreshedKey.name || keyData.name, + email: refreshedKey.name || keyData.name, + apiKey: refreshedKey.apiKey, + providerSpecificData: { + cookie: cookieToSave, + expireTime: refreshedKey.expireTime, + }, + testStatus: "active", + isActive: true, + }); + + return NextResponse.json({ + success: true, + connection: { + id: connection.id, + provider: connection.provider, + email: connection.email, + apiKey: refreshedKey.apiKey.substring(0, 10) + "...", // masked + expireTime: refreshedKey.expireTime, + }, + }); + } catch (error) { + console.error("iFlow cookie auth error:", error); + return NextResponse.json({ error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/providers/[id]/models/route.js b/src/app/api/providers/[id]/models/route.js index a3c4bcb..dae6b89 100644 --- a/src/app/api/providers/[id]/models/route.js +++ b/src/app/api/providers/[id]/models/route.js @@ -44,6 +44,18 @@ const createOpenAIModelsConfig = (url) => ({ parseResponse: parseOpenAIStyleModels }); +const resolveQwenModelsUrl = (connection) => { + const fallback = "https://portal.qwen.ai/v1/models"; + const raw = connection?.providerSpecificData?.resourceUrl; + if (!raw || typeof raw !== "string") return fallback; + const value = raw.trim(); + if (!value) return fallback; + if (value.startsWith("http://") || value.startsWith("https://")) { + return `${value.replace(/\/$/, "")}/models`; + } + return `https://${value.replace(/\/$/, "")}/v1/models`; +}; + // Provider models endpoints configuration const PROVIDER_MODELS_CONFIG = { claude: { @@ -340,6 +352,9 @@ export async function GET(request, { params }) { // Build request URL let url = config.url; + if (connection.provider === "qwen") { + url = resolveQwenModelsUrl(connection); + } if (config.authQuery) { url += `?${config.authQuery}=${token}`; } diff --git a/src/app/api/translator/send/route.js b/src/app/api/translator/send/route.js index 30a71ae..837d083 100644 --- a/src/app/api/translator/send/route.js +++ b/src/app/api/translator/send/route.js @@ -33,7 +33,8 @@ export async function POST(request) { // Build URL and headers using provider service const url = buildProviderUrl(provider, body.model || "test-model", true, { baseUrlIndex: 0, - baseUrl: connection.providerSpecificData?.baseUrl + baseUrl: connection.providerSpecificData?.baseUrl, + qwenResourceUrl: connection.providerSpecificData?.resourceUrl }); console.log("🚀 ~ POST ~ url:", url) const headers = buildProviderHeaders(provider, credentials, true, body); diff --git a/src/app/api/translator/translate/route.js b/src/app/api/translator/translate/route.js index e38a9e4..ef60bcf 100644 --- a/src/app/api/translator/translate/route.js +++ b/src/app/api/translator/translate/route.js @@ -93,7 +93,8 @@ export async function POST(request) { // Build URL and headers const url = buildProviderUrl(provider, model, true, { baseUrlIndex: 0, - baseUrl: connection.providerSpecificData?.baseUrl + baseUrl: connection.providerSpecificData?.baseUrl, + qwenResourceUrl: connection.providerSpecificData?.resourceUrl }); const headers = buildProviderHeaders(provider, credentials, true, actualBody); diff --git a/src/lib/oauth/providers.js b/src/lib/oauth/providers.js index 4b43713..9878089 100644 --- a/src/lib/oauth/providers.js +++ b/src/lib/oauth/providers.js @@ -377,7 +377,7 @@ const PROVIDERS = { return await response.json(); }, postExchange: async (tokens) => { - // Fetch user info + // Fetch user info (MUST succeed to get API key) const userInfoRes = await fetch( `${IFLOW_CONFIG.userInfoUrl}?accessToken=${encodeURIComponent(tokens.access_token)}`, { @@ -386,8 +386,30 @@ const PROVIDERS = { }, } ); - const result = userInfoRes.ok ? await userInfoRes.json() : {}; - const userInfo = result.success ? result.data : {}; + + if (!userInfoRes.ok) { + const errorText = await userInfoRes.text(); + throw new Error(`Failed to fetch user info: ${errorText}`); + } + + const result = await userInfoRes.json(); + if (!result.success) { + throw new Error(`User info request failed: ${result.message || 'Unknown error'}`); + } + + const userInfo = result.data || {}; + + // Validate API key (critical for iFlow) + if (!userInfo.apiKey || userInfo.apiKey.trim() === "") { + throw new Error("Empty API key returned from iFlow"); + } + + // Validate email/phone + const email = userInfo.email?.trim() || userInfo.phone?.trim(); + if (!email) { + throw new Error("Missing account email/phone in user info"); + } + return { userInfo }; }, mapTokens: (tokens, extra) => ({ diff --git a/src/mitm/cert/generate.js b/src/mitm/cert/generate.js index 31c972a..5a4ff39 100644 --- a/src/mitm/cert/generate.js +++ b/src/mitm/cert/generate.js @@ -2,10 +2,18 @@ const path = require("path"); const fs = require("fs"); const { MITM_DIR } = require("../paths"); -const TARGET_HOST = "daily-cloudcode-pa.googleapis.com"; +// Wildcard domains — covers all subdomains without needing cert update per tool +const WILDCARD_DOMAINS = [ + "*.googleapis.com", + "*.githubcopilot.com", + "*.individual.githubcopilot.com", + "*.business.githubcopilot.com" +]; /** - * Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed) + * Generate self-signed SSL certificate with wildcard SAN. + * Covers all current and future MITM tool domains automatically. + * Uses selfsigned (pure JS, no openssl needed). */ async function generateCert() { const certDir = MITM_DIR; @@ -22,7 +30,7 @@ async function generateCert() { } const selfsigned = require("selfsigned"); - const attrs = [{ name: "commonName", value: TARGET_HOST }]; + const attrs = [{ name: "commonName", value: "9router-mitm" }]; const notAfter = new Date(); notAfter.setFullYear(notAfter.getFullYear() + 1); const pems = await selfsigned.generate(attrs, { @@ -30,14 +38,17 @@ async function generateCert() { algorithm: "sha256", notAfterDate: notAfter, extensions: [ - { name: "subjectAltName", altNames: [{ type: 2, value: TARGET_HOST }] } + { + name: "subjectAltName", + altNames: WILDCARD_DOMAINS.map(domain => ({ type: 2, value: domain })) + } ] }); fs.writeFileSync(keyPath, pems.private); fs.writeFileSync(certPath, pems.cert); - console.log(`✅ Generated SSL certificate for ${TARGET_HOST}`); + console.log(`✅ Generated wildcard SSL certificate: ${WILDCARD_DOMAINS.join(", ")}`); return { key: keyPath, cert: certPath }; } diff --git a/src/mitm/cert/install.js b/src/mitm/cert/install.js index 6613af8..98c04f4 100644 --- a/src/mitm/cert/install.js +++ b/src/mitm/cert/install.js @@ -26,10 +26,14 @@ async function checkCertInstalled(certPath) { function checkCertInstalledMac(certPath) { return new Promise((resolve) => { try { - // security outputs fingerprint without colons (e.g. "078B6B5F..."), strip them before grep const fingerprint = getCertFingerprint(certPath).replace(/:/g, ""); - exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error, stdout) => { - resolve(!error && !!stdout?.trim()); + // security verify-cert returns 0 only if cert is trusted by system policy + exec(`security verify-cert -c "${certPath}" -p ssl -k /Library/Keychains/System.keychain 2>/dev/null`, (error) => { + if (!error) return resolve(true); + // Fallback: check if fingerprint appears in System keychain with trust + exec(`security dump-trust-settings -d 2>/dev/null | grep -i "${fingerprint}"`, (err2, stdout2) => { + resolve(!err2 && !!stdout2?.trim()); + }); }); } catch { resolve(false); diff --git a/src/mitm/dns/dnsConfig.js b/src/mitm/dns/dnsConfig.js index 408f754..42ff3e0 100644 --- a/src/mitm/dns/dnsConfig.js +++ b/src/mitm/dns/dnsConfig.js @@ -3,10 +3,12 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); -const TARGET_HOSTS = [ - "daily-cloudcode-pa.googleapis.com", - "cloudcode-pa.googleapis.com" -]; +// Per-tool DNS hosts mapping +const TOOL_HOSTS = { + antigravity: ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"], + copilot: ["api.individual.githubcopilot.com"], +}; + const IS_WIN = process.platform === "win32"; const IS_MAC = process.platform === "darwin"; const HOSTS_FILE = IS_WIN @@ -38,58 +40,67 @@ function execWithPassword(command, password) { } /** - * Execute elevated command on Windows via PowerShell RunAs (hidden window) + * Flush DNS cache (macOS/Linux) */ -function execElevatedWindows(command) { - return new Promise((resolve, reject) => { - const escaped = command.replace(/'/g, "''"); - const psCommand = `Start-Process cmd -ArgumentList '/c','${escaped}' -Verb RunAs -Wait -WindowStyle Hidden`; - exec( - `powershell -NonInteractive -WindowStyle Hidden -Command "${psCommand}"`, - { windowsHide: true }, - (error, stdout, stderr) => { - if (error) reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`)); - else resolve(stdout); - } - ); - }); +async function flushDNS(sudoPassword) { + if (IS_WIN) return; // Windows flushes inline via ipconfig + if (IS_MAC) { + await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); + } else { + await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); + } } /** - * Check if DNS entry already exists for a specific host + * Check if DNS entry exists for a specific host */ function checkDNSEntry(host = null) { try { const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); - if (host) { - return hostsContent.includes(host); - } - // Check if all target hosts exist - return TARGET_HOSTS.every(h => hostsContent.includes(h)); + if (host) return hostsContent.includes(host); + // Legacy: check all antigravity hosts (backward compat) + return TOOL_HOSTS.antigravity.every(h => hostsContent.includes(h)); } catch { return false; } } /** - * Add DNS entry to hosts file + * Check DNS status per tool — returns { [tool]: boolean } */ -async function addDNSEntry(sudoPassword) { - const entriesToAdd = TARGET_HOSTS.filter(host => !checkDNSEntry(host)); - +function checkAllDNSStatus() { + try { + const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8"); + const result = {}; + for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) { + result[tool] = hosts.every(h => hostsContent.includes(h)); + } + return result; + } catch { + return Object.fromEntries(Object.keys(TOOL_HOSTS).map(t => [t, false])); + } +} + +/** + * Add DNS entries for a specific tool + */ +async function addDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToAdd = hosts.filter(h => !checkDNSEntry(h)); if (entriesToAdd.length === 0) { - console.log(`DNS entries for all target hosts already exist`); + console.log(`DNS entries for ${tool} already exist`); return; } - const entries = entriesToAdd.map(host => `127.0.0.1 ${host}`).join("\n"); + const entries = entriesToAdd.map(h => `127.0.0.1 ${h}`).join("\n"); try { if (IS_WIN) { - // Windows: add all entries + flush in one elevated PowerShell call (single UAC) const hostsPath = HOSTS_FILE.replace(/'/g, "''"); - const addLines = entriesToAdd.map(host => - `$hc = Get-Content -Path '${hostsPath}' -Raw -ErrorAction SilentlyContinue; if ($hc -notmatch '${host}') { Add-Content -Path '${hostsPath}' -Value '127.0.0.1 ${host}' -Encoding UTF8 }` + const addLines = entriesToAdd.map(h => + `$hc = Get-Content -Path '${hostsPath}' -Raw -ErrorAction SilentlyContinue; if ($hc -notmatch '${h}') { Add-Content -Path '${hostsPath}' -Value '127.0.0.1 ${h}' -Encoding UTF8 }` ).join("; "); const psScript = `${addLines}; ipconfig /flushdns | Out-Null`; await new Promise((resolve, reject) => { @@ -102,17 +113,9 @@ async function addDNSEntry(sudoPassword) { }); } else { await execWithPassword(`echo "${entries}" >> ${HOSTS_FILE}`, sudoPassword); + await flushDNS(sudoPassword); } - // Flush DNS cache (non-Windows) - if (IS_WIN) { - // already flushed above - } else if (IS_MAC) { - await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); - } else { - // Linux: try systemd-resolved, fall back silently - await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); - } - console.log(`✅ Added DNS entries: ${entriesToAdd.join(", ")}`); + console.log(`✅ Added DNS entries for ${tool}: ${entriesToAdd.join(", ")}`); } catch (error) { const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to add DNS entry"; throw new Error(msg); @@ -120,29 +123,26 @@ async function addDNSEntry(sudoPassword) { } /** - * Remove DNS entry from hosts file + * Remove DNS entries for a specific tool */ -async function removeDNSEntry(sudoPassword) { - const entriesToRemove = TARGET_HOSTS.filter(host => checkDNSEntry(host)); - +async function removeDNSEntry(tool, sudoPassword) { + const hosts = TOOL_HOSTS[tool]; + if (!hosts) throw new Error(`Unknown tool: ${tool}`); + + const entriesToRemove = hosts.filter(h => checkDNSEntry(h)); if (entriesToRemove.length === 0) { - console.log(`DNS entries for target hosts do not exist`); + console.log(`DNS entries for ${tool} do not exist`); return; } try { if (IS_WIN) { - // Read in Node, filter, write to temp file, then single elevated-copy + flush (1 UAC) const content = fs.readFileSync(HOSTS_FILE, "utf8"); - 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"); - } + const filtered = content.split(/\r?\n/).filter(l => !entriesToRemove.some(h => l.includes(h))).join("\r\n"); const tmpFile = path.join(os.tmpdir(), "hosts_filtered.tmp"); fs.writeFileSync(tmpFile, filtered, "utf8"); const tmpEsc = tmpFile.replace(/'/g, "''"); const hostsEsc = HOSTS_FILE.replace(/'/g, "''"); - // Single UAC: copy temp file over hosts + flush DNS const psScript = `Copy-Item -Path '${tmpEsc}' -Destination '${hostsEsc}' -Force; ipconfig /flushdns | Out-Null; Remove-Item '${tmpEsc}' -ErrorAction SilentlyContinue`; await new Promise((resolve, reject) => { const escaped = psScript.replace(/"/g, '\\"'); @@ -151,33 +151,46 @@ async function removeDNSEntry(sudoPassword) { { windowsHide: true }, (error) => { try { fs.unlinkSync(tmpFile); } catch { /* ignore */ } - if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`)); + if (error) reject(new Error(`Failed to remove DNS: ${error.message}`)); else resolve(); } ); }); } else { - // 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); } + await flushDNS(sudoPassword); } - // Flush DNS cache (non-Windows, already flushed above for Windows) - if (IS_WIN) { - // already flushed above - } else if (IS_MAC) { - await execWithPassword("dscacheutil -flushcache && killall -HUP mDNSResponder", sudoPassword); - } else { - await execWithPassword("resolvectl flush-caches 2>/dev/null || true", sudoPassword); - } - console.log(`✅ Removed DNS entries for ${entriesToRemove.join(", ")}`); + console.log(`✅ Removed DNS entries for ${tool}: ${entriesToRemove.join(", ")}`); } catch (error) { const msg = error.message?.includes("incorrect password") ? "Wrong sudo password" : "Failed to remove DNS entry"; throw new Error(msg); } } -module.exports = { addDNSEntry, removeDNSEntry, execWithPassword, checkDNSEntry }; +/** + * Remove ALL tool DNS entries (used when stopping server) + */ +async function removeAllDNSEntries(sudoPassword) { + for (const tool of Object.keys(TOOL_HOSTS)) { + try { + await removeDNSEntry(tool, sudoPassword); + } catch (e) { + console.log(`[MITM] Warning: failed to remove DNS for ${tool}: ${e.message}`); + } + } +} + +module.exports = { + TOOL_HOSTS, + addDNSEntry, + removeDNSEntry, + removeAllDNSEntries, + execWithPassword, + checkDNSEntry, + checkAllDNSStatus, +}; diff --git a/src/mitm/manager.js b/src/mitm/manager.js index f7f4431..605d650 100644 --- a/src/mitm/manager.js +++ b/src/mitm/manager.js @@ -5,7 +5,7 @@ const os = require("os"); const net = require("net"); const https = require("https"); const crypto = require("crypto"); -const { addDNSEntry, removeDNSEntry, checkDNSEntry } = require("./dns/dnsConfig"); +const { addDNSEntry, removeDNSEntry, removeAllDNSEntries, checkAllDNSStatus } = require("./dns/dnsConfig"); const IS_WIN = process.platform === "win32"; const { generateCert } = require("./cert/generate"); @@ -13,45 +13,27 @@ const { installCert } = require("./cert/install"); const { MITM_DIR } = require("./paths"); const MITM_PORT = 443; -// Windows: node listens on 8443, netsh portproxy forwards 443→8443 const MITM_WIN_NODE_PORT = 8443; const PID_FILE = path.join(MITM_DIR, ".mitm.pid"); -// Resolve server.js path robustly: -// __dirname is unreliable inside Next.js bundles, so we use DATA_DIR env or -// fall back to locating the file relative to the app's source root. function resolveServerPath() { - // 1. Explicit override via env (useful for packaged/standalone builds) if (process.env.MITM_SERVER_PATH) return process.env.MITM_SERVER_PATH; - - // 2. Try sibling of this file (works in dev where __dirname is real) const sibling = path.join(__dirname, "server.js"); if (fs.existsSync(sibling)) return sibling; - - // 3. Fallback: resolve from process.cwd() → src/mitm/server.js const fromCwd = path.join(process.cwd(), "src", "mitm", "server.js"); if (fs.existsSync(fromCwd)) return fromCwd; - - // 4. Standalone build: app root is parent of .next const fromNext = path.join(process.cwd(), "..", "src", "mitm", "server.js"); if (fs.existsSync(fromNext)) return fromNext; - - return fromCwd; // best guess + return fromCwd; } 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) { - // Use PowerShell for precise port 443 owner lookup const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command ` + `"$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; if ($c) { $c.OwningProcess } else { 0 }"`; const pidStr = execSync(psCmd, { encoding: "utf8", windowsHide: true }).trim(); @@ -62,31 +44,22 @@ function getProcessUsingPort443() { 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; - } + if (lines.length > 1) return lines[1].split(/\s+/)[0]; } - } catch (error) { + } catch { return null; } return null; } -// Store server process in-memory let serverProcess = null; let serverPid = null; -// Persist sudo password across Next.js hot reloads (in-memory only) function getCachedPassword() { return globalThis.__mitmSudoPassword || null; } function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; } -// Check if a PID is alive -// EACCES = process exists but no permission (e.g. root process) → still alive -// ESRCH = process does not exist → dead function isProcessAlive(pid) { try { process.kill(pid, 0); @@ -96,51 +69,41 @@ function isProcessAlive(pid) { } } -// Cross-platform process kill function killProcess(pid, force = false, sudoPassword = null) { if (IS_WIN) { const flag = force ? "/F " : ""; exec(`taskkill ${flag}/PID ${pid}`, () => { }); } else { const sig = force ? "SIGKILL" : "SIGTERM"; - // Kill entire process group (sudo parent + child node) const cmd = `pkill -${sig} -P ${pid} 2>/dev/null; kill -${sig} ${pid} 2>/dev/null`; if (sudoPassword) { const { execWithPassword } = require("./dns/dnsConfig"); - execWithPassword(cmd, sudoPassword).catch(() => { - // Fallback without sudo - exec(cmd, () => { }); - }); + execWithPassword(cmd, sudoPassword).catch(() => exec(cmd, () => { })); } else { exec(cmd, () => { }); } } } -/** Derive a 32-byte encryption key from machineId */ function deriveKey() { try { const { machineIdSync } = require("node-machine-id"); const raw = machineIdSync(); return crypto.createHash("sha256").update(raw + ENCRYPT_SALT).digest(); } catch { - // Fallback: fixed key derived from salt (less secure but functional) return crypto.createHash("sha256").update(ENCRYPT_SALT).digest(); } } -/** Encrypt sudo password with AES-256-GCM */ function encryptPassword(plaintext) { const key = deriveKey(); const iv = crypto.randomBytes(12); const cipher = crypto.createCipheriv(ENCRYPT_ALGO, key, iv); const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); - // Store as hex: iv:tag:ciphertext return `${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; } -/** Decrypt sudo password */ function decryptPassword(stored) { try { const [ivHex, tagHex, dataHex] = stored.split(":"); @@ -154,23 +117,16 @@ function decryptPassword(stored) { } } -// DB hooks — injected from ESM context (initializeApp / route handlers) -// to avoid webpack bundling issues with dynamic imports in CJS modules. let _getSettings = null; let _updateSettings = null; -/** Called once from ESM context to inject DB access functions */ function initDbHooks(getSettingsFn, updateSettingsFn) { _getSettings = getSettingsFn; _updateSettings = updateSettingsFn; } -/** Save encrypted sudo password + mitmEnabled to db */ async function saveMitmSettings(enabled, password) { - if (!_updateSettings) { - console.log("[MITM] DB hooks not initialized, skipping save"); - return; - } + if (!_updateSettings) return; try { const updates = { mitmEnabled: enabled }; if (password) updates.mitmSudoEncrypted = encryptPassword(password); @@ -180,7 +136,6 @@ async function saveMitmSettings(enabled, password) { } } -/** Load and decrypt sudo password from db */ async function loadEncryptedPassword() { if (!_getSettings) return null; try { @@ -192,37 +147,27 @@ async function loadEncryptedPassword() { } } -/** - * Check if port 443 is available - * Returns: "free" | "in-use" | "no-permission" - */ function checkPort443Free() { return new Promise((resolve) => { const tester = net.createServer(); tester.once("error", (err) => { if (err.code === "EADDRINUSE") resolve("in-use"); - else resolve("no-permission"); // EACCES or other → port free but needs sudo + else resolve("no-permission"); }); tester.once("listening", () => { tester.close(() => resolve("free")); }); tester.listen(MITM_PORT, "127.0.0.1"); }); } -/** - * Get PID and process name currently holding port 443 - * Returns { pid, name } or null if port is free / cannot determine - */ function getPort443Owner(sudoPassword) { return new Promise((resolve) => { if (IS_WIN) { - // Use PowerShell Get-NetTCPConnection for precise port 443 owner lookup const psCmd = `powershell -NonInteractive -WindowStyle Hidden -Command "` + `$c = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1; ` + `if ($c) { $c.OwningProcess } else { 0 }"`; exec(psCmd, { windowsHide: true }, (err, stdout) => { if (err) return resolve(null); const pid = parseInt(stdout.trim(), 10); - // 0 = no owner, <=4 = System/Idle — not real port owners if (!pid || pid <= 4) return resolve(null); exec(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`, { windowsHide: true }, (e2, out2) => { const m = out2?.match(/"([^"]+)"/); @@ -230,7 +175,6 @@ function getPort443Owner(sudoPassword) { }); }); } else { - // Use ps to find node process running server.js (no sudo needed) exec(`ps aux | grep "[s]erver.js"`, (err, stdout) => { if (!stdout?.trim()) return resolve(null); for (const line of stdout.split("\n")) { @@ -244,19 +188,12 @@ function getPort443Owner(sudoPassword) { }); } -/** - * Kill any leftover MITM server process (from previous failed start) - * Uses sudo to kill the node process that was spawned with sudo - */ async function killLeftoverMitm(sudoPassword) { - // Kill in-memory process if still alive if (serverProcess && !serverProcess.killed) { try { serverProcess.kill("SIGKILL"); } catch { /* ignore */ } serverProcess = null; serverPid = null; } - - // Kill from PID file try { if (fs.existsSync(PID_FILE)) { const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); @@ -267,8 +204,6 @@ async function killLeftoverMitm(sudoPassword) { fs.unlinkSync(PID_FILE); } } catch { /* ignore */ } - - // Also kill any node process running server.js via sudo (belt-and-suspenders) if (!IS_WIN && SERVER_PATH) { try { const escaped = SERVER_PATH.replace(/'/g, "'\\''"); @@ -283,10 +218,6 @@ async function killLeftoverMitm(sudoPassword) { } } -/** - * Poll MITM health endpoint until server is up or timeout. - * Returns { ok, pid } on success, null on timeout. - */ function pollMitmHealth(timeoutMs, port = MITM_PORT) { return new Promise((resolve) => { const deadline = Date.now() + timeoutMs; @@ -315,7 +246,38 @@ function pollMitmHealth(timeoutMs, port = MITM_PORT) { } /** - * Get MITM status + * Check which tools have their domains covered by the installed cert SAN. + * Uses built-in crypto.X509Certificate (Node 15.6+). + */ +function getCertToolCoverage(certPath) { + try { + const pem = fs.readFileSync(certPath, "utf8"); + const cert = new crypto.X509Certificate(pem); + const san = cert.subjectAltName || ""; + // Extract all DNS SANs + const sans = san.split(",").map(s => s.trim().replace(/^DNS:/, "")); + const matchesSan = (domain) => sans.some(s => { + if (s === domain) return true; + // Wildcard: *.foo.com matches bar.foo.com + if (s.startsWith("*.")) { + const suffix = s.slice(1); // .foo.com + return domain.endsWith(suffix) && !domain.slice(0, -suffix.length).includes("."); + } + return false; + }); + const { TOOL_HOSTS } = require("./dns/dnsConfig"); + const coverage = {}; + for (const [tool, hosts] of Object.entries(TOOL_HOSTS)) { + coverage[tool] = hosts.every(matchesSan); + } + return coverage; + } catch { + return {}; + } +} + +/** + * Get full MITM status including per-tool DNS status */ async function getMitmStatus() { let running = serverProcess !== null && !serverProcess.killed; @@ -332,30 +294,26 @@ async function getMitmStatus() { fs.unlinkSync(PID_FILE); } } - } catch { - // Ignore - } + } catch { /* ignore */ } } - const dnsConfigured = checkDNSEntry(); - const certExists = fs.existsSync(path.join(MITM_DIR, "server.crt")); + const dnsStatus = checkAllDNSStatus(); + const certPath = path.join(MITM_DIR, "server.crt"); + const certExists = fs.existsSync(certPath); + const certCoversTools = certExists ? getCertToolCoverage(certPath) : {}; - return { running, pid, dnsConfigured, certExists }; + return { running, pid, certExists, dnsStatus, certCoversTools }; } /** - * Start MITM proxy - * @param {string} apiKey - 9Router API key - * @param {string} sudoPassword - Sudo password for DNS/cert operations + * Start MITM server only (cert + server, no DNS) */ -async function startMitm(apiKey, sudoPassword) { - // Check orphan process from PID file before spawning +async function startServer(apiKey, sudoPassword) { if (!serverProcess || serverProcess.killed) { try { if (fs.existsSync(PID_FILE)) { const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10); if (savedPid && isProcessAlive(savedPid)) { - // Orphan MITM process still alive — reuse it serverPid = savedPid; console.log(`[MITM] Reusing existing process PID ${savedPid}`); await saveMitmSettings(true, sudoPassword); @@ -365,25 +323,20 @@ async function startMitm(apiKey, sudoPassword) { fs.unlinkSync(PID_FILE); } } - } catch { - // Ignore stale PID file errors - } + } catch { /* ignore */ } } if (serverProcess && !serverProcess.killed) { - throw new Error("MITM proxy is already running"); + throw new Error("MITM server is already running"); } - // Kill any leftover MITM server from a previous failed start attempt await killLeftoverMitm(sudoPassword); if (!IS_WIN) { - // Check port 443 availability — Windows handles this inside elevated script const portStatus = await checkPort443Free(); if (portStatus === "in-use" || portStatus === "no-permission") { const owner = await getPort443Owner(sudoPassword); if (owner && owner.name === "node") { - // Orphan MITM node process — kill it and continue console.log(`[MITM] Killing orphan node process on port 443 (PID ${owner.pid})...`); try { const { execWithPassword } = require("./dns/dnsConfig"); @@ -394,76 +347,61 @@ async function startMitm(apiKey, sudoPassword) { const shortName = owner.name.includes("/") ? owner.name.split("/").filter(Boolean).pop() : owner.name; - throw new Error( - `Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first, then retry.` - ); + throw new Error(`Port 443 is already in use by "${shortName}" (PID ${owner.pid}). Stop that process first.`); } } } - const steps = { cert: false, server: false, dns: false }; - - // Step 1: Generate SSL certificate if not exists + // Step 1: Generate SSL certificate if not exists or missing domain coverage const certPath = path.join(MITM_DIR, "server.crt"); + const keyPath = path.join(MITM_DIR, "server.key"); + let needsRegenerate = false; + if (!fs.existsSync(certPath)) { console.log("[MITM] Generating SSL certificate..."); + needsRegenerate = true; + } else { + // Check if cert covers all tool domains + const coverage = getCertToolCoverage(certPath); + const { TOOL_HOSTS } = require("./dns/dnsConfig"); + const allCovered = Object.keys(TOOL_HOSTS).every(tool => coverage[tool] === true); + if (!allCovered) { + console.log("[MITM] Certificate missing domain coverage — regenerating..."); + needsRegenerate = true; + try { + fs.unlinkSync(certPath); + if (fs.existsSync(keyPath)) fs.unlinkSync(keyPath); + } catch { /* ignore */ } + } + } + + if (needsRegenerate) { await generateCert(); } - // Step 2: Spawn MITM server - console.log("[MITM] Starting server..."); - + // Step 2: Install cert + spawn server if (IS_WIN) { - // Windows: single UAC via VBScript → elevated PowerShell script that: - // 1. Installs SSL cert 2. Adds DNS entries 3. Starts node server.js (elevated → can bind 443) 4. Writes flag - // Node polls flag file to know when server is ready, then health-checks port 443 const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); - const TARGET_HOSTS_WIN = ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"]; - - // Use Chr(34) in VBScript for quotes — avoid escaping issues const flagFile = path.join(os.tmpdir(), `mitm_ready_${Date.now()}.flag`); - - // PowerShell uses single-quoted strings — escape single quotes only const psSQ = (s) => s.replace(/'/g, "''"); const certPs = psSQ(certPath); - const hostsPs = psSQ(hostsFile); const nodePs = psSQ(process.execPath); const serverPs = psSQ(SERVER_PATH); const flagPs = psSQ(flagFile); - const dnsLines = TARGET_HOSTS_WIN.map(h => - `$hc = Get-Content -Path '${hostsPs}' -Raw -ErrorAction SilentlyContinue\n` + - `if ($hc -notmatch [regex]::Escape('${h}')) { Add-Content -Path '${hostsPs}' -Value '127.0.0.1 ${h}' -Encoding UTF8 }` - ).join("\n"); - const psScript = [ - `# 0. Kill any orphan node process on port 443`, `$conn = Get-NetTCPConnection -LocalPort 443 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1`, `if ($conn -and $conn.OwningProcess -gt 4) { Stop-Process -Id $conn.OwningProcess -Force -ErrorAction SilentlyContinue }`, `Start-Sleep -Milliseconds 500`, - ``, - `# 1. Install SSL cert to Windows Root store (always run to ensure trust)`, `& certutil -addstore Root '${certPs}' | Out-Null`, - ``, - `# 2. Add DNS entries to hosts file`, - dnsLines, - `& ipconfig /flushdns | Out-Null`, - ``, - `# 3. Start node MITM server elevated (required to bind port 443)`, - `# Use cmd /c to pass env vars inline — Start-Process does not inherit current env`, `$nodeCmd = 'set ROUTER_API_KEY=${psSQ(apiKey)}&& set NODE_ENV=production&& "${nodePs}" "${serverPs}"'`, `Start-Process cmd -ArgumentList '/c',$nodeCmd -WindowStyle Hidden`, - ``, - `# 4. Signal ready`, `Start-Sleep -Milliseconds 500`, `Set-Content -Path '${flagPs}' -Value 'ready' -Encoding UTF8`, ].join("\n"); const tmpPs1 = path.join(os.tmpdir(), `mitm_start_${Date.now()}.ps1`); fs.writeFileSync(tmpPs1, psScript, "utf8"); - - // VBScript uses Shell.Application.ShellExecute to trigger UAC from any context - // Chr(34) = double-quote, avoids VBScript string escaping issues const vbs = [ `Set oShell = CreateObject("Shell.Application")`, `Dim ps`, @@ -474,19 +412,16 @@ async function startMitm(apiKey, sudoPassword) { ].join("\r\n"); const tmpVbs = path.join(os.tmpdir(), `mitm_uac_${Date.now()}.vbs`); fs.writeFileSync(tmpVbs, vbs, "utf8"); - - // Launch VBScript — shows UAC dialog, user confirms, script runs elevated spawn("wscript.exe", [tmpVbs], { stdio: "ignore", windowsHide: false, detached: true }).unref(); - // Poll flag file — resolves when elevated script completes await new Promise((resolve, reject) => { - const deadline = Date.now() + 90000; // 90s: UAC wait + cert install + node start + const deadline = Date.now() + 90000; const poll = () => { if (fs.existsSync(flagFile)) { try { fs.unlinkSync(flagFile); fs.unlinkSync(tmpPs1); fs.unlinkSync(tmpVbs); } catch { /* ignore */ } return resolve(); } - if (Date.now() > deadline) return reject(new Error("Timed out waiting for UAC confirmation. Please try again.")); + if (Date.now() > deadline) return reject(new Error("Timed out waiting for UAC confirmation.")); setTimeout(poll, 500); }; poll(); @@ -494,17 +429,13 @@ async function startMitm(apiKey, sudoPassword) { if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); } else { - // macOS/Linux: Step 1 Cert → Step 2 Server → Step 3 DNS - // Cert first — no side effects on IDE if it fails const { checkCertInstalled } = require("./cert/install"); const certTrusted = await checkCertInstalled(certPath); if (!certTrusted) { await installCert(sudoPassword, certPath); if (_updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); } - steps.cert = true; - // Server second — binds port 443 but DNS not yet redirected, IDE unaffected const inlineCmd = `ROUTER_API_KEY='${apiKey}' NODE_ENV='production' '${process.execPath}' '${SERVER_PATH}'`; serverProcess = spawn( "sudo", ["-S", "-E", "sh", "-c", inlineCmd], @@ -514,7 +445,6 @@ async function startMitm(apiKey, sudoPassword) { serverProcess.stdin.end(); } - // Windows: node was started by elevated script — PID comes from health check later if (!IS_WIN && serverProcess) { serverPid = serverProcess.pid; fs.writeFileSync(PID_FILE, String(serverPid)); @@ -527,7 +457,6 @@ async function startMitm(apiKey, sudoPassword) { }); serverProcess.stderr.on("data", (data) => { const msg = data.toString().trim(); - // Capture meaningful errors (ignore sudo password prompt noise) if (msg && !msg.includes("Password:") && !msg.includes("password for")) { console.error(`[MITM Server Error] ${msg}`); startError = msg; @@ -541,51 +470,35 @@ async function startMitm(apiKey, sudoPassword) { }); } - // Wait for server to be ready by polling health endpoint on port 443 const health = await pollMitmHealth(IS_WIN ? 15000 : 8000, MITM_PORT); - if (!health) { if (IS_WIN) serverProcess = null; const processUsing443 = getProcessUsingPort443(); const portInfo = processUsing443 ? ` Port 443 already in use by ${processUsing443}.` : ""; const reason = startError || `Check sudo password or port 443 access.${portInfo}`; - // Server failed — DNS was NOT added yet (new order), so IDE is unaffected throw new Error(`MITM server failed to start. ${reason}`); } - steps.server = true; - - // On Windows, mark cert as installed after successful start if (IS_WIN && _updateSettings) await _updateSettings({ mitmCertInstalled: true }).catch(() => { }); - - // On Windows, use real PID from health check (launcher exits immediately after UAC) if (IS_WIN && health.pid) { serverPid = health.pid; fs.writeFileSync(PID_FILE, String(serverPid)); } - // Step 3: DNS last — only redirect IDE traffic after server is confirmed healthy - if (!IS_WIN) { - console.log("[MITM] Adding DNS entry..."); - await addDNSEntry(sudoPassword); - steps.dns = true; - } else { - steps.cert = true; - steps.server = true; - steps.dns = true; - } - await saveMitmSettings(true, sudoPassword); if (sudoPassword) setCachedPassword(sudoPassword); - return { running: true, pid: serverPid, steps }; + return { running: true, pid: serverPid }; } /** - * Stop MITM proxy - * @param {string} sudoPassword - Sudo password for DNS cleanup + * Stop MITM server — removes ALL tool DNS entries first, then kills server */ -async function stopMitm(sudoPassword) { +async function stopServer(sudoPassword) { + // Remove all DNS entries first (before killing server) + console.log("[MITM] Removing all DNS entries before stopping server..."); + await removeAllDNSEntries(sudoPassword); + const proc = serverProcess; if (proc && !proc.killed) { console.log("Stopping MITM server..."); @@ -611,16 +524,15 @@ async function stopMitm(sudoPassword) { } if (IS_WIN) { - // Windows stop: remove DNS entries via elevated VBScript (1 UAC) const hostsFile = path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts"); - const TARGET_HOSTS_WIN = ["daily-cloudcode-pa.googleapis.com", "cloudcode-pa.googleapis.com"]; const psSQ = (s) => s.replace(/'/g, "''"); + const { TOOL_HOSTS } = require("./dns/dnsConfig"); + const allHosts = Object.values(TOOL_HOSTS).flat(); - // Filter hosts content in Node (read doesn't need elevation) let hostsContent = ""; try { hostsContent = fs.readFileSync(hostsFile, "utf8"); } catch { /* ignore */ } const filtered = hostsContent.split(/\r?\n/) - .filter(l => !TARGET_HOSTS_WIN.some(h => l.includes(h))) + .filter(l => !allHosts.some(h => l.includes(h))) .join("\r\n"); const tmpHosts = path.join(os.tmpdir(), "mitm_hosts_clean.tmp"); fs.writeFileSync(tmpHosts, filtered, "utf8"); @@ -645,7 +557,6 @@ async function stopMitm(sudoPassword) { fs.writeFileSync(tmpVbs, vbs, "utf8"); spawn("wscript.exe", [tmpVbs], { stdio: "ignore", windowsHide: false, detached: true }).unref(); - // Poll flag — best effort, don't block UI if user cancels UAC await new Promise((resolve) => { const deadline = Date.now() + 30000; const poll = () => { @@ -658,20 +569,43 @@ async function stopMitm(sudoPassword) { }; poll(); }); - } else { - console.log("Removing DNS entry..."); - await removeDNSEntry(sudoPassword); } try { fs.unlinkSync(PID_FILE); } catch { /* ignore */ } - await saveMitmSettings(false, null); return { running: false, pid: null }; } +/** + * Enable DNS for a specific tool (requires server running) + */ +async function enableToolDNS(tool, sudoPassword) { + const status = await getMitmStatus(); + if (!status.running) throw new Error("MITM server is not running. Start the server first."); + await addDNSEntry(tool, sudoPassword); + return { success: true }; +} + +/** + * Disable DNS for a specific tool + */ +async function disableToolDNS(tool, sudoPassword) { + await removeDNSEntry(tool, sudoPassword); + return { success: true }; +} + +// Legacy aliases for backward compatibility +const startMitm = startServer; +const stopMitm = stopServer; + module.exports = { getMitmStatus, + startServer, + stopServer, + enableToolDNS, + disableToolDNS, + // Legacy startMitm, stopMitm, getCachedPassword, diff --git a/src/mitm/server.js b/src/mitm/server.js index 7f9d52a..84ff54c 100644 --- a/src/mitm/server.js +++ b/src/mitm/server.js @@ -3,19 +3,22 @@ const fs = require("fs"); const path = require("path"); const dns = require("dns"); const { promisify } = require("util"); -// Configuration + const INTERNAL_REQUEST_HEADER = { name: "x-request-source", value: "local" }; + +// All intercepted domains across all tools const TARGET_HOSTS = [ "daily-cloudcode-pa.googleapis.com", - "cloudcode-pa.googleapis.com" + "cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", ]; + const LOCAL_PORT = 443; const ROUTER_URL = "http://localhost:20128/v1/chat/completions"; const API_KEY = process.env.ROUTER_API_KEY; const { DATA_DIR, MITM_DIR } = require("./paths"); const DB_FILE = path.join(DATA_DIR, "db.json"); -// Toggle logging (set true to enable file logging for debugging) const ENABLE_FILE_LOG = false; if (!API_KEY) { @@ -23,7 +26,6 @@ if (!API_KEY) { process.exit(1); } -// Load SSL certificates const certDir = MITM_DIR; let sslOptions; try { @@ -36,10 +38,11 @@ try { process.exit(1); } -// Chat endpoints that should be intercepted -const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; +// Antigravity: Gemini generateContent endpoints +const ANTIGRAVITY_URL_PATTERNS = [":generateContent", ":streamGenerateContent"]; +// Copilot: OpenAI-compatible + Anthropic endpoints +const COPILOT_URL_PATTERNS = ["/chat/completions", "/v1/messages", "/responses"]; -// Log directory for request/response dumps const LOG_DIR = path.join(__dirname, "../../logs/mitm"); if (ENABLE_FILE_LOG && !fs.existsSync(LOG_DIR)) fs.mkdirSync(LOG_DIR, { recursive: true }); @@ -51,26 +54,9 @@ function saveRequestLog(url, bodyBuffer) { const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}.json`); const body = JSON.parse(bodyBuffer.toString()); fs.writeFileSync(filePath, JSON.stringify(body, null, 2)); - console.log(`💾 Saved request: ${filePath}`); - } catch { - // Ignore - } + } catch { /* ignore */ } } -function saveResponseLog(url, data) { - if (!ENABLE_FILE_LOG) return; - try { - const ts = new Date().toISOString().replace(/[:.]/g, "-"); - const urlSlug = url.replace(/[^a-zA-Z0-9]/g, "_").substring(0, 60); - const filePath = path.join(LOG_DIR, `${ts}_${urlSlug}_response.txt`); - fs.writeFileSync(filePath, data); - console.log(`💾 Saved response: ${filePath}`); - } catch { - // Ignore - } -} - -// Resolve real IP of target host (bypass /etc/hosts) const cachedTargetIPs = {}; async function resolveTargetIP(hostname) { if (cachedTargetIPs[hostname]) return cachedTargetIPs[hostname]; @@ -91,27 +77,36 @@ function collectBodyRaw(req) { }); } -// Extract model from URL path (Gemini format: /v1beta/models/gemini-2.0-flash:generateContent) -// Fallback to body.model (OpenAI format) +// Extract model from URL path (Gemini) or body (OpenAI/Anthropic) function extractModel(url, body) { const urlMatch = url.match(/\/models\/([^/:]+)/); if (urlMatch) return urlMatch[1]; try { return JSON.parse(body.toString()).model || null; } catch { return null; } } -function getMappedModel(model) { +function getMappedModel(tool, model) { if (!model) return null; try { if (!fs.existsSync(DB_FILE)) return null; const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8")); - return db.mitmAlias?.antigravity?.[model] || null; + return db.mitmAlias?.[tool]?.[model] || null; } catch { return null; } } +/** + * Determine which tool this request belongs to based on hostname + */ +function getToolForHost(host) { + const h = (host || "").split(":")[0]; + if (h === "api.individual.githubcopilot.com") return "copilot"; + if (h === "daily-cloudcode-pa.googleapis.com" || h === "cloudcode-pa.googleapis.com") return "antigravity"; + return null; +} + async function passthrough(req, res, bodyBuffer) { - const targetHost = req.headers.host || TARGET_HOSTS[0]; + const targetHost = (req.headers.host || TARGET_HOSTS[0]).split(":")[0]; const targetIP = await resolveTargetIP(targetHost); const forwardReq = https.request({ @@ -163,7 +158,6 @@ async function intercept(req, res, bodyBuffer, mappedModel) { const reader = response.body.getReader(); const decoder = new TextDecoder(); - while (true) { const { done, value } = await reader.read(); if (done) { res.end(); break; } @@ -177,7 +171,6 @@ async function intercept(req, res, bodyBuffer, mappedModel) { } const server = https.createServer(sslOptions, async (req, res) => { - // Health check endpoint for startup verification if (req.url === "/_mitm_health") { res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ ok: true, pid: process.pid })); @@ -185,27 +178,28 @@ const server = https.createServer(sslOptions, async (req, res) => { } const bodyBuffer = await collectBodyRaw(req); - - // Save request log if enabled if (bodyBuffer.length > 0) saveRequestLog(req.url, bodyBuffer); - // Anti-loop: requests from 9Router bypass interception + // Anti-loop: requests originating from 9Router bypass interception if (req.headers[INTERNAL_REQUEST_HEADER.name] === INTERNAL_REQUEST_HEADER.value) { return passthrough(req, res, bodyBuffer); } - const isChatRequest = CHAT_URL_PATTERNS.some(p => req.url.includes(p)); + const tool = getToolForHost(req.headers.host); + if (!tool) return passthrough(req, res, bodyBuffer); - if (!isChatRequest) { - return passthrough(req, res, bodyBuffer); - } + // Check if this URL should be intercepted based on tool + const isChat = tool === "antigravity" + ? ANTIGRAVITY_URL_PATTERNS.some(p => req.url.includes(p)) + : COPILOT_URL_PATTERNS.some(p => req.url.includes(p)); + + if (!isChat) return passthrough(req, res, bodyBuffer); const model = extractModel(req.url, bodyBuffer); - const mappedModel = getMappedModel(model); + console.log("Extracted model:", model) + const mappedModel = getMappedModel(tool, model); - if (!mappedModel) { - return passthrough(req, res, bodyBuffer); - } + if (!mappedModel) return passthrough(req, res, bodyBuffer); return intercept(req, res, bodyBuffer, mappedModel); }); @@ -225,7 +219,6 @@ server.on("error", (error) => { process.exit(1); }); -// Graceful shutdown (SIGBREAK for Windows, SIGTERM/SIGINT for Unix) const shutdown = () => { server.close(() => process.exit(0)); }; process.on("SIGTERM", shutdown); process.on("SIGINT", shutdown); diff --git a/src/shared/components/Header.js b/src/shared/components/Header.js index cf8bfe6..6569195 100644 --- a/src/shared/components/Header.js +++ b/src/shared/components/Header.js @@ -30,6 +30,7 @@ const getPageInfo = (pathname) => { if (pathname.includes("/providers")) return { title: "Providers", description: "Manage your AI provider connections", breadcrumbs: [] }; if (pathname.includes("/combos")) return { title: "Combos", description: "Model combos with fallback", breadcrumbs: [] }; if (pathname.includes("/usage")) return { title: "Usage & Analytics", description: "Monitor your API usage, token consumption, and request logs", breadcrumbs: [] }; + if (pathname.includes("/mitm")) return { title: "MITM Proxy", description: "Intercept CLI tool traffic and route through 9Router", breadcrumbs: [] }; if (pathname.includes("/cli-tools")) return { title: "CLI Tools", description: "Configure CLI tools", breadcrumbs: [] }; if (pathname.includes("/endpoint")) return { title: "Endpoint", description: "API endpoint configuration", breadcrumbs: [] }; if (pathname.includes("/profile")) return { title: "Settings", description: "Manage your preferences", breadcrumbs: [] }; diff --git a/src/shared/components/IFlowCookieModal.js b/src/shared/components/IFlowCookieModal.js new file mode 100644 index 0000000..b033fc2 --- /dev/null +++ b/src/shared/components/IFlowCookieModal.js @@ -0,0 +1,132 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Modal, Button, Input } from "@/shared/components"; + +/** + * iFlow Cookie Authentication Modal + * User pastes browser cookie to get fresh API key + */ +export default function IFlowCookieModal({ isOpen, onSuccess, onClose }) { + const [cookie, setCookie] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = async () => { + if (!cookie.trim()) { + setError("Please paste your cookie"); + return; + } + + setLoading(true); + setError(null); + + try { + const res = await fetch("/api/oauth/iflow/cookie", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cookie: cookie.trim() }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || "Authentication failed"); + } + + setSuccess(true); + setTimeout(() => { + onSuccess?.(); + handleClose(); + }, 1500); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setCookie(""); + setError(null); + setSuccess(false); + onClose?.(); + }; + + return ( + +
+ {success ? ( +
+
+

Authentication Successful!

+

Fresh API key obtained

+
+ ) : ( + <> +
+

+ To get a fresh API key, paste your browser cookie from{" "} + + platform.iflow.cn + +

+
+

How to get cookie:

+
    +
  1. Open platform.iflow.cn in your browser
  2. +
  3. Login to your account
  4. +
  5. Open DevTools (F12) → Application/Storage → Cookies
  6. +
  7. Copy the entire cookie string (must include BXAuth)
  8. +
  9. Paste it below
  10. +
+
+
+ +
+ +