mirror of
https://github.com/Nezumi-2711/9router.git
synced 2026-09-22 20:00:47 +00:00
feat(antigravity): integrate Antigravity tool with MITM support and update CLI tools
This commit is contained in:
@@ -4,7 +4,7 @@ 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 } from "./components";
|
||||
import { ClaudeToolCard, CodexToolCard, DroidToolCard, OpenClawToolCard, DefaultToolCard, AntigravityToolCard } from "./components";
|
||||
|
||||
const CLOUD_URL = process.env.NEXT_PUBLIC_CLOUD_URL;
|
||||
|
||||
@@ -160,6 +160,8 @@ export default function CLIToolsPageClient({ machineId }) {
|
||||
return <DroidToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "openclaw":
|
||||
return <OpenClawToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
case "antigravity":
|
||||
return <AntigravityToolCard key={toolId} {...commonProps} activeProviders={getActiveProviders()} hasActiveProviders={hasActiveProviders} cloudEnabled={cloudEnabled} />;
|
||||
default:
|
||||
return <DefaultToolCard key={toolId} toolId={toolId} {...commonProps} activeProviders={getActiveProviders()} cloudEnabled={cloudEnabled} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,414 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { Card, Button, Badge, Modal, Input, ModelSelectModal } from "@/shared/components";
|
||||
import Image from "next/image";
|
||||
|
||||
export default function AntigravityToolCard({
|
||||
tool,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
baseUrl,
|
||||
apiKeys,
|
||||
activeProviders,
|
||||
hasActiveProviders,
|
||||
cloudEnabled,
|
||||
}) {
|
||||
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 [modelMappings, setModelMappings] = useState({});
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [currentEditingAlias, setCurrentEditingAlias] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (apiKeys?.length > 0 && !selectedApiKey) {
|
||||
setSelectedApiKey(apiKeys[0].key);
|
||||
}
|
||||
}, [apiKeys, selectedApiKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isExpanded && !status) {
|
||||
fetchStatus();
|
||||
loadSavedMappings();
|
||||
}
|
||||
}, [isExpanded, status]);
|
||||
|
||||
const loadSavedMappings = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm/alias?tool=antigravity");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const aliases = data.aliases || {};
|
||||
|
||||
if (Object.keys(aliases).length > 0) {
|
||||
setModelMappings(aliases);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error loading saved mappings:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchStatus = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setStatus(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching status:", error);
|
||||
setStatus({ running: false });
|
||||
}
|
||||
};
|
||||
|
||||
// Windows uses UAC dialog, no sudo needed
|
||||
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
|
||||
|
||||
const handleStart = () => {
|
||||
if (isWindows || status?.hasCachedPassword) {
|
||||
doStart("");
|
||||
} else {
|
||||
setShowPasswordModal(true);
|
||||
setMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = () => {
|
||||
if (isWindows || status?.hasCachedPassword) {
|
||||
doStop("");
|
||||
} else {
|
||||
setShowPasswordModal(true);
|
||||
setMessage(null);
|
||||
}
|
||||
};
|
||||
|
||||
const doStart = async (password) => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
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: "MITM started" });
|
||||
setShowPasswordModal(false);
|
||||
setSudoPassword("");
|
||||
fetchStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to start" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const doStop = async (password) => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
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: "MITM stopped" });
|
||||
setShowPasswordModal(false);
|
||||
setSudoPassword("");
|
||||
fetchStatus();
|
||||
} else {
|
||||
setMessage({ type: "error", text: data.error || "Failed to stop" });
|
||||
}
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmPassword = () => {
|
||||
if (!sudoPassword.trim()) {
|
||||
setMessage({ type: "error", text: "Sudo password is required" });
|
||||
return;
|
||||
}
|
||||
if (status?.running) {
|
||||
doStop(sudoPassword);
|
||||
} else {
|
||||
doStart(sudoPassword);
|
||||
}
|
||||
};
|
||||
|
||||
const openModelSelector = (alias) => {
|
||||
setCurrentEditingAlias(alias);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleModelSelect = (model) => {
|
||||
if (currentEditingAlias) {
|
||||
setModelMappings(prev => ({
|
||||
...prev,
|
||||
[currentEditingAlias]: model.value,
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelMappingChange = (alias, value) => {
|
||||
setModelMappings(prev => ({
|
||||
...prev,
|
||||
[alias]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSaveMappings = async () => {
|
||||
setLoading(true);
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/cli-tools/antigravity-mitm/alias", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tool: "antigravity", mappings: modelMappings }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json();
|
||||
throw new Error(data.error || "Failed to save mappings");
|
||||
}
|
||||
|
||||
setMessage({ type: "success", text: "Mappings saved!" });
|
||||
} catch (error) {
|
||||
setMessage({ type: "error", text: error.message });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = status?.running;
|
||||
|
||||
return (
|
||||
<Card padding="sm" className="overflow-hidden">
|
||||
<div className="flex items-center justify-between hover:cursor-pointer" onClick={onToggle}>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="size-8 flex items-center justify-center shrink-0">
|
||||
<Image
|
||||
src="/providers/antigravity.png"
|
||||
alt={tool.name}
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 object-contain rounded-lg"
|
||||
sizes="32px"
|
||||
onError={(e) => { e.target.style.display = "none"; }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-medium text-sm">{tool.name}</h3>
|
||||
{isRunning ? (
|
||||
<Badge variant="success" size="sm">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="default" size="sm">Inactive</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted truncate">{tool.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`material-symbols-outlined text-text-muted text-[20px] transition-transform ${isExpanded ? "rotate-180" : ""}`}>expand_more</span>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-border flex flex-col gap-4">
|
||||
{/* Start/Stop Button - always on top */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isRunning ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 rounded-lg bg-red-500/10 border border-red-500/30 text-red-500 font-medium text-sm flex items-center gap-2 hover:bg-red-500/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">stop_circle</span>
|
||||
Stop MITM
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleStart}
|
||||
disabled={loading || !hasActiveProviders}
|
||||
className="px-4 py-2 rounded-lg bg-primary/10 border border-primary/30 text-primary font-medium text-sm flex items-center gap-2 hover:bg-primary/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">play_circle</span>
|
||||
Start MITM
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{message?.type === "error" && (
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 rounded text-xs bg-red-500/10 text-red-600">
|
||||
<span className="material-symbols-outlined text-[14px]">error</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* When running: API Key + Model Mappings */}
|
||||
{isRunning && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">API Key</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
{apiKeys.length > 0 ? (
|
||||
<select
|
||||
value={selectedApiKey}
|
||||
onChange={(e) => setSelectedApiKey(e.target.value)}
|
||||
className="flex-1 px-2 py-1.5 bg-surface rounded text-xs border border-border focus:outline-none focus:ring-1 focus:ring-primary/50"
|
||||
>
|
||||
{apiKeys.map((key) => <option key={key.id} value={key.key}>{key.key}</option>)}
|
||||
</select>
|
||||
) : (
|
||||
<span className="flex-1 text-xs text-text-muted px-2 py-1.5">
|
||||
{cloudEnabled ? "No API keys - Create one in Keys page" : "sk_9router (default)"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{tool.defaultModels.map((model) => (
|
||||
<div key={model.alias} className="flex items-center gap-2">
|
||||
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">{model.name}</span>
|
||||
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
|
||||
<input
|
||||
type="text"
|
||||
value={modelMappings[model.alias] || ""}
|
||||
onChange={(e) => handleModelMappingChange(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"
|
||||
/>
|
||||
<button
|
||||
onClick={() => openModelSelector(model.alias)}
|
||||
disabled={!hasActiveProviders}
|
||||
className={`px-2 py-1.5 rounded border text-xs transition-colors shrink-0 whitespace-nowrap ${hasActiveProviders ? "bg-surface border-border text-text-main hover:border-primary cursor-pointer" : "opacity-50 cursor-not-allowed border-border"}`}
|
||||
>
|
||||
Select
|
||||
</button>
|
||||
{modelMappings[model.alias] && (
|
||||
<button
|
||||
onClick={() => handleModelMappingChange(model.alias, "")}
|
||||
className="p-1 text-text-muted hover:text-red-500 rounded transition-colors"
|
||||
title="Clear"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleSaveMappings}
|
||||
disabled={loading || Object.keys(modelMappings).length === 0}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px] mr-1">save</span>
|
||||
Save Mappings
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* When stopped: how it works */}
|
||||
{!isRunning && (
|
||||
<div className="flex flex-col gap-1.5 px-1">
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="font-medium text-text-main">How it works:</span> Intercepts Antigravity traffic via DNS redirect, letting you reroute models through 9Router.
|
||||
</p>
|
||||
<div className="flex flex-col gap-0.5 text-[11px] text-text-muted">
|
||||
<span>1. Generates SSL cert & adds to system keychain</span>
|
||||
<span>2. Redirects <code className="text-[10px] bg-surface px-1 rounded">daily-cloudcode-pa.googleapis.com</code> → localhost</span>
|
||||
<span>3. Maps Antigravity models to any provider via 9Router</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password Modal */}
|
||||
<Modal
|
||||
isOpen={showPasswordModal}
|
||||
onClose={() => {
|
||||
setShowPasswordModal(false);
|
||||
setSudoPassword("");
|
||||
setMessage(null);
|
||||
}}
|
||||
title="Sudo Password Required"
|
||||
size="sm"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
|
||||
<span className="material-symbols-outlined text-yellow-500 text-[20px]">warning</span>
|
||||
<p className="text-xs text-text-muted">Required for SSL certificate and DNS configuration</p>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Enter sudo password"
|
||||
value={sudoPassword}
|
||||
onChange={(e) => setSudoPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !loading) handleConfirmPassword();
|
||||
}}
|
||||
/>
|
||||
|
||||
{message && (
|
||||
<div className={`flex items-center gap-2 px-2 py-1.5 rounded text-xs ${message.type === "success" ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600"}`}>
|
||||
<span className="material-symbols-outlined text-[14px]">{message.type === "success" ? "check_circle" : "error"}</span>
|
||||
<span>{message.text}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setShowPasswordModal(false); setSudoPassword(""); setMessage(null); }}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={handleConfirmPassword}
|
||||
loading={loading}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Model Select Modal */}
|
||||
<ModelSelectModal
|
||||
isOpen={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSelect={handleModelSelect}
|
||||
selectedModel={currentEditingAlias ? modelMappings[currentEditingAlias] : null}
|
||||
activeProviders={activeProviders}
|
||||
title={`Select model for ${currentEditingAlias}`}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -3,4 +3,5 @@ export { default as CodexToolCard } from "./CodexToolCard";
|
||||
export { default as DroidToolCard } from "./DroidToolCard";
|
||||
export { default as OpenClawToolCard } from "./OpenClawToolCard";
|
||||
export { default as DefaultToolCard } from "./DefaultToolCard";
|
||||
export { default as AntigravityToolCard } from "./AntigravityToolCard";
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMitmAlias, setMitmAliasAll } from "@/models";
|
||||
|
||||
// GET - Get MITM aliases for a tool
|
||||
export async function GET(request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const toolName = searchParams.get("tool");
|
||||
const aliases = await getMitmAlias(toolName || undefined);
|
||||
return NextResponse.json({ aliases });
|
||||
} catch (error) {
|
||||
console.log("Error fetching MITM aliases:", error.message);
|
||||
return NextResponse.json({ error: "Failed to fetch aliases" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// PUT - Save MITM aliases for a specific tool
|
||||
export async function PUT(request) {
|
||||
try {
|
||||
const { tool, mappings } = await request.json();
|
||||
|
||||
if (!tool || !mappings || typeof mappings !== "object") {
|
||||
return NextResponse.json({ error: "tool and mappings required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const filtered = {};
|
||||
for (const [alias, model] of Object.entries(mappings)) {
|
||||
if (model && model.trim()) {
|
||||
filtered[alias] = model.trim();
|
||||
}
|
||||
}
|
||||
|
||||
await setMitmAliasAll(tool, filtered);
|
||||
return NextResponse.json({ success: true, aliases: filtered });
|
||||
} catch (error) {
|
||||
console.log("Error saving MITM aliases:", error.message);
|
||||
return NextResponse.json({ error: "Failed to save aliases" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use server";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
import { getMitmStatus, startMitm, stopMitm, getCachedPassword, setCachedPassword } from "@/mitm/manager";
|
||||
|
||||
// GET - Check MITM status
|
||||
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,
|
||||
hasCachedPassword: !!getCachedPassword(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error getting MITM status:", error.message);
|
||||
return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// POST - Start MITM proxy
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const { apiKey, sudoPassword } = await request.json();
|
||||
const isWin = process.platform === "win32";
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
|
||||
if (!apiKey || (!isWin && !pwd)) {
|
||||
return NextResponse.json(
|
||||
{ error: isWin ? "Missing apiKey" : "Missing apiKey or sudoPassword" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const result = await startMitm(apiKey, pwd);
|
||||
if (!isWin) setCachedPassword(pwd);
|
||||
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE - Stop MITM proxy
|
||||
export async function DELETE(request) {
|
||||
try {
|
||||
const { sudoPassword } = await request.json();
|
||||
const isWin = process.platform === "win32";
|
||||
const pwd = sudoPassword || getCachedPassword() || "";
|
||||
|
||||
if (!isWin && !pwd) {
|
||||
return NextResponse.json({ error: "Missing sudoPassword" }, { status: 400 });
|
||||
}
|
||||
|
||||
await stopMitm(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 });
|
||||
}
|
||||
}
|
||||
@@ -24,22 +24,6 @@ export async function PUT(request) {
|
||||
return NextResponse.json({ error: "Model and alias required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const aliases = await getModelAliases();
|
||||
|
||||
// Check if alias already used by different model
|
||||
const existingModel = aliases[alias];
|
||||
if (existingModel && existingModel !== model) {
|
||||
return NextResponse.json({
|
||||
error: `Alias '${alias}' already in use for model '${existingModel}'`
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
// Delete old alias for this model (if exists and different from new alias)
|
||||
const oldAlias = Object.entries(aliases).find(([a, m]) => m === model && a !== alias)?.[0];
|
||||
if (oldAlias) {
|
||||
await deleteModelAlias(oldAlias);
|
||||
}
|
||||
|
||||
await setModelAlias(alias, model);
|
||||
await syncToCloudIfEnabled();
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ const defaultData = {
|
||||
providerConnections: [],
|
||||
providerNodes: [],
|
||||
modelAliases: {},
|
||||
mitmAlias: {},
|
||||
combos: [],
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
@@ -59,6 +60,7 @@ function cloneDefaultData() {
|
||||
providerConnections: [],
|
||||
providerNodes: [],
|
||||
modelAliases: {},
|
||||
mitmAlias: {},
|
||||
combos: [],
|
||||
apiKeys: [],
|
||||
settings: {
|
||||
@@ -495,6 +497,22 @@ export async function deleteModelAlias(alias) {
|
||||
await db.write();
|
||||
}
|
||||
|
||||
// ============ MITM Alias ============
|
||||
|
||||
export async function getMitmAlias(toolName) {
|
||||
const db = await getDb();
|
||||
const all = db.data.mitmAlias || {};
|
||||
if (toolName) return all[toolName] || {};
|
||||
return all;
|
||||
}
|
||||
|
||||
export async function setMitmAliasAll(toolName, mappings) {
|
||||
const db = await getDb();
|
||||
if (!db.data.mitmAlias) db.data.mitmAlias = {};
|
||||
db.data.mitmAlias[toolName] = mappings || {};
|
||||
await db.write();
|
||||
}
|
||||
|
||||
// ============ Combos ============
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
|
||||
const TARGET_HOST = "daily-cloudcode-pa.googleapis.com";
|
||||
|
||||
/**
|
||||
* Generate self-signed SSL certificate using selfsigned (pure JS, no openssl needed)
|
||||
*/
|
||||
async function generateCert() {
|
||||
const certDir = path.join(os.homedir(), ".9router", "mitm");
|
||||
const keyPath = path.join(certDir, "server.key");
|
||||
const certPath = path.join(certDir, "server.crt");
|
||||
|
||||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) {
|
||||
console.log("✅ SSL certificate already exists");
|
||||
return { key: keyPath, cert: certPath };
|
||||
}
|
||||
|
||||
if (!fs.existsSync(certDir)) {
|
||||
fs.mkdirSync(certDir, { recursive: true });
|
||||
}
|
||||
|
||||
const selfsigned = require("selfsigned");
|
||||
const attrs = [{ name: "commonName", value: TARGET_HOST }];
|
||||
const notAfter = new Date();
|
||||
notAfter.setFullYear(notAfter.getFullYear() + 1);
|
||||
const pems = await selfsigned.generate(attrs, {
|
||||
keySize: 2048,
|
||||
algorithm: "sha256",
|
||||
notAfterDate: notAfter,
|
||||
extensions: [
|
||||
{ name: "subjectAltName", altNames: [{ type: 2, value: TARGET_HOST }] }
|
||||
]
|
||||
});
|
||||
|
||||
fs.writeFileSync(keyPath, pems.private);
|
||||
fs.writeFileSync(certPath, pems.cert);
|
||||
|
||||
console.log(`✅ Generated SSL certificate for ${TARGET_HOST}`);
|
||||
return { key: keyPath, cert: certPath };
|
||||
}
|
||||
|
||||
module.exports = { generateCert };
|
||||
@@ -0,0 +1,136 @@
|
||||
const fs = require("fs");
|
||||
const crypto = require("crypto");
|
||||
const { exec } = require("child_process");
|
||||
const { execWithPassword } = require("../dns/dnsConfig.js");
|
||||
|
||||
const IS_WIN = process.platform === "win32";
|
||||
|
||||
// Get SHA1 fingerprint from cert file using Node.js crypto
|
||||
function getCertFingerprint(certPath) {
|
||||
const pem = fs.readFileSync(certPath, "utf-8");
|
||||
const der = Buffer.from(pem.replace(/-----[^-]+-----/g, "").replace(/\s/g, ""), "base64");
|
||||
return crypto.createHash("sha1").update(der).digest("hex").toUpperCase().match(/.{2}/g).join(":");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is already installed in system store
|
||||
*/
|
||||
async function checkCertInstalled(certPath) {
|
||||
if (IS_WIN) {
|
||||
return checkCertInstalledWindows(certPath);
|
||||
}
|
||||
return checkCertInstalledMac(certPath);
|
||||
}
|
||||
|
||||
function checkCertInstalledMac(certPath) {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const fingerprint = getCertFingerprint(certPath);
|
||||
exec(`security find-certificate -a -Z /Library/Keychains/System.keychain | grep -i "${fingerprint}"`, (error) => {
|
||||
resolve(!error);
|
||||
});
|
||||
} catch {
|
||||
resolve(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function checkCertInstalledWindows(certPath) {
|
||||
return new Promise((resolve) => {
|
||||
// Check Root store for our cert by subject name
|
||||
exec("certutil -store Root daily-cloudcode-pa.googleapis.com", (error) => {
|
||||
resolve(!error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Install SSL certificate to system trust store
|
||||
*/
|
||||
async function installCert(sudoPassword, certPath) {
|
||||
if (!fs.existsSync(certPath)) {
|
||||
throw new Error(`Certificate file not found: ${certPath}`);
|
||||
}
|
||||
|
||||
const isInstalled = await checkCertInstalled(certPath);
|
||||
if (isInstalled) {
|
||||
console.log("✅ Certificate already installed");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IS_WIN) {
|
||||
await installCertWindows(certPath);
|
||||
} else {
|
||||
await installCertMac(sudoPassword, certPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function installCertMac(sudoPassword, certPath) {
|
||||
const command = `sudo -S security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain "${certPath}"`;
|
||||
try {
|
||||
await execWithPassword(command, sudoPassword);
|
||||
console.log(`✅ Installed certificate to system keychain: ${certPath}`);
|
||||
} catch (error) {
|
||||
const msg = error.message?.includes("canceled") ? "User canceled authorization" : "Certificate install failed";
|
||||
throw new Error(msg);
|
||||
}
|
||||
}
|
||||
|
||||
async function installCertWindows(certPath) {
|
||||
// Use PowerShell elevated to add cert to Root store
|
||||
const psCommand = `Start-Process certutil -ArgumentList '-addstore','Root','${certPath.replace(/'/g, "''")}' -Verb RunAs -Wait`;
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`powershell -Command "${psCommand}"`, (error) => {
|
||||
if (error) {
|
||||
reject(new Error(`Failed to install certificate: ${error.message}`));
|
||||
} else {
|
||||
console.log(`✅ Installed certificate to Windows Root store`);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Uninstall SSL certificate from system store
|
||||
*/
|
||||
async function uninstallCert(sudoPassword, certPath) {
|
||||
const isInstalled = await checkCertInstalled(certPath);
|
||||
if (!isInstalled) {
|
||||
console.log("Certificate not found in system store");
|
||||
return;
|
||||
}
|
||||
|
||||
if (IS_WIN) {
|
||||
await uninstallCertWindows();
|
||||
} else {
|
||||
await uninstallCertMac(sudoPassword, certPath);
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallCertMac(sudoPassword, certPath) {
|
||||
const fingerprint = getCertFingerprint(certPath).replace(/:/g, "");
|
||||
const command = `sudo -S security delete-certificate -Z "${fingerprint}" /Library/Keychains/System.keychain`;
|
||||
try {
|
||||
await execWithPassword(command, sudoPassword);
|
||||
console.log("✅ Uninstalled certificate from system keychain");
|
||||
} catch (err) {
|
||||
throw new Error("Failed to uninstall certificate");
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallCertWindows() {
|
||||
const psCommand = `Start-Process certutil -ArgumentList '-delstore','Root','daily-cloudcode-pa.googleapis.com' -Verb RunAs -Wait`;
|
||||
return new Promise((resolve, reject) => {
|
||||
exec(`powershell -Command "${psCommand}"`, (error) => {
|
||||
if (error) {
|
||||
reject(new Error(`Failed to uninstall certificate: ${error.message}`));
|
||||
} else {
|
||||
console.log("✅ Uninstalled certificate from Windows Root store");
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { installCert, uninstallCert, checkCertInstalled };
|
||||
@@ -0,0 +1,111 @@
|
||||
const { exec } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const TARGET_HOST = "daily-cloudcode-pa.googleapis.com";
|
||||
const IS_WIN = process.platform === "win32";
|
||||
const HOSTS_FILE = IS_WIN
|
||||
? path.join(process.env.SystemRoot || "C:\\Windows", "System32", "drivers", "etc", "hosts")
|
||||
: "/etc/hosts";
|
||||
|
||||
/**
|
||||
* Execute command with sudo password via stdin (macOS/Linux only)
|
||||
*/
|
||||
function execWithPassword(command, password) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = exec(command, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Command failed: ${error.message}\n${stderr}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
child.stdin.write(`${password}\n`);
|
||||
child.stdin.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute elevated command on Windows via PowerShell RunAs
|
||||
*/
|
||||
function execElevatedWindows(command) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const psCommand = `Start-Process cmd -ArgumentList '/c','${command.replace(/'/g, "''")}' -Verb RunAs -Wait`;
|
||||
exec(`powershell -Command "${psCommand}"`, (error, stdout, stderr) => {
|
||||
if (error) {
|
||||
reject(new Error(`Elevated command failed: ${error.message}\n${stderr}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if DNS entry already exists
|
||||
*/
|
||||
function checkDNSEntry() {
|
||||
try {
|
||||
const hostsContent = fs.readFileSync(HOSTS_FILE, "utf8");
|
||||
return hostsContent.includes(TARGET_HOST);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add DNS entry to hosts file
|
||||
*/
|
||||
async function addDNSEntry(sudoPassword) {
|
||||
if (checkDNSEntry()) {
|
||||
console.log(`DNS entry for ${TARGET_HOST} already exists`);
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = `127.0.0.1 ${TARGET_HOST}`;
|
||||
|
||||
try {
|
||||
if (IS_WIN) {
|
||||
// Windows: use elevated echo >> hosts
|
||||
await execElevatedWindows(`echo ${entry} >> "${HOSTS_FILE}"`);
|
||||
} else {
|
||||
const command = `echo "${entry}" | sudo -S tee -a ${HOSTS_FILE} > /dev/null`;
|
||||
await execWithPassword(command, sudoPassword);
|
||||
}
|
||||
console.log(`✅ Added DNS entry: ${entry}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to add DNS entry: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove DNS entry from hosts file
|
||||
*/
|
||||
async function removeDNSEntry(sudoPassword) {
|
||||
if (!checkDNSEntry()) {
|
||||
console.log(`DNS entry for ${TARGET_HOST} does not exist`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (IS_WIN) {
|
||||
// Windows: read, filter, write back via elevated PowerShell
|
||||
const psScript = `(Get-Content '${HOSTS_FILE}') | Where-Object { $_ -notmatch '${TARGET_HOST}' } | Set-Content '${HOSTS_FILE}'`;
|
||||
const psCommand = `Start-Process powershell -ArgumentList '-Command','${psScript.replace(/'/g, "''")}' -Verb RunAs -Wait`;
|
||||
await new Promise((resolve, reject) => {
|
||||
exec(`powershell -Command "${psCommand}"`, (error) => {
|
||||
if (error) reject(new Error(`Failed to remove DNS entry: ${error.message}`));
|
||||
else resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
const command = `sudo -S sed -i '' '/${TARGET_HOST}/d' ${HOSTS_FILE}`;
|
||||
await execWithPassword(command, sudoPassword);
|
||||
}
|
||||
console.log(`✅ Removed DNS entry for ${TARGET_HOST}`);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to remove DNS entry: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { addDNSEntry, removeDNSEntry, execWithPassword, checkDNSEntry };
|
||||
@@ -0,0 +1,227 @@
|
||||
const { spawn } = require("child_process");
|
||||
const path = require("path");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const { addDNSEntry, removeDNSEntry } = require("./dns/dnsConfig");
|
||||
const { generateCert } = require("./cert/generate");
|
||||
const { installCert } = require("./cert/install");
|
||||
|
||||
// Store server process
|
||||
let serverProcess = null;
|
||||
let serverPid = null;
|
||||
// Persist across Next.js hot reloads
|
||||
function getCachedPassword() { return globalThis.__mitmSudoPassword || null; }
|
||||
function setCachedPassword(pwd) { globalThis.__mitmSudoPassword = pwd; }
|
||||
|
||||
// server.js is in same directory as this file
|
||||
const PID_FILE = path.join(os.homedir(), ".9router", "mitm", ".mitm.pid");
|
||||
|
||||
// Check if a PID is alive
|
||||
function isProcessAlive(pid) {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MITM status
|
||||
*/
|
||||
async function getMitmStatus() {
|
||||
// Check in-memory process first, then fallback to PID file
|
||||
let running = serverProcess !== null && !serverProcess.killed;
|
||||
let pid = serverPid;
|
||||
|
||||
if (!running) {
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
||||
if (savedPid && isProcessAlive(savedPid)) {
|
||||
running = true;
|
||||
pid = savedPid;
|
||||
} else {
|
||||
// Stale PID file, clean up
|
||||
fs.unlinkSync(PID_FILE);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Check DNS configuration
|
||||
let dnsConfigured = false;
|
||||
try {
|
||||
const hostsContent = fs.readFileSync("/etc/hosts", "utf-8");
|
||||
dnsConfigured = hostsContent.includes("daily-cloudcode-pa.googleapis.com");
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
// Check cert
|
||||
const certDir = path.join(os.homedir(), ".9router", "mitm");
|
||||
const certExists = fs.existsSync(path.join(certDir, "server.crt"));
|
||||
|
||||
return { running, pid, dnsConfigured, certExists };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start MITM proxy
|
||||
* @param {string} apiKey - 9Router API key
|
||||
* @param {string} sudoPassword - Sudo password for DNS/cert operations
|
||||
*/
|
||||
async function startMitm(apiKey, sudoPassword) {
|
||||
// Check if already running
|
||||
if (serverProcess && !serverProcess.killed) {
|
||||
throw new Error("MITM proxy is already running");
|
||||
}
|
||||
|
||||
// 1. Generate SSL certificate if not exists
|
||||
const certPath = path.join(os.homedir(), ".9router", "mitm", "server.crt");
|
||||
if (!fs.existsSync(certPath)) {
|
||||
console.log("Generating SSL certificate...");
|
||||
await generateCert();
|
||||
}
|
||||
|
||||
// 2. Install certificate to system keychain
|
||||
await installCert(sudoPassword, certPath);
|
||||
|
||||
// 3. Add DNS entry
|
||||
console.log("Adding DNS entry...");
|
||||
await addDNSEntry(sudoPassword);
|
||||
|
||||
// 4. Start MITM server
|
||||
console.log("Starting MITM server...");
|
||||
const serverPath = path.join(process.cwd(), "src/mitm/server.js");
|
||||
serverProcess = spawn("node", [serverPath], {
|
||||
env: {
|
||||
...process.env,
|
||||
ROUTER_API_KEY: apiKey,
|
||||
NODE_ENV: "production"
|
||||
},
|
||||
detached: false,
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
serverPid = serverProcess.pid;
|
||||
|
||||
// Save PID to file
|
||||
fs.writeFileSync(PID_FILE, String(serverPid));
|
||||
|
||||
// Log server output
|
||||
serverProcess.stdout.on("data", (data) => {
|
||||
console.log(`[MITM Server] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
console.error(`[MITM Server Error] ${data.toString().trim()}`);
|
||||
});
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
console.log(`MITM server exited with code ${code}`);
|
||||
serverProcess = null;
|
||||
serverPid = null;
|
||||
|
||||
// Remove PID file
|
||||
try {
|
||||
fs.unlinkSync(PID_FILE);
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Wait and verify server actually started
|
||||
const started = await new Promise((resolve) => {
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) { resolved = true; resolve(true); }
|
||||
}, 2000);
|
||||
|
||||
serverProcess.on("exit", (code) => {
|
||||
clearTimeout(timeout);
|
||||
if (!resolved) { resolved = true; resolve(false); }
|
||||
});
|
||||
|
||||
// Check stderr for error messages
|
||||
serverProcess.stderr.on("data", (data) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg.includes("Port") && msg.includes("already in use")) {
|
||||
clearTimeout(timeout);
|
||||
if (!resolved) { resolved = true; resolve(false); }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!started) {
|
||||
throw new Error("MITM server failed to start (port 443 may be in use)");
|
||||
}
|
||||
|
||||
return {
|
||||
running: true,
|
||||
pid: serverPid
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop MITM proxy
|
||||
* @param {string} sudoPassword - Sudo password for DNS cleanup
|
||||
*/
|
||||
async function stopMitm(sudoPassword) {
|
||||
// 1. Kill server process (in-memory or from PID file)
|
||||
const proc = serverProcess;
|
||||
if (proc && !proc.killed) {
|
||||
console.log("Stopping MITM server...");
|
||||
proc.kill("SIGTERM");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (!proc.killed) {
|
||||
proc.kill("SIGKILL");
|
||||
}
|
||||
serverProcess = null;
|
||||
serverPid = null;
|
||||
} else {
|
||||
// Fallback: kill by PID file
|
||||
try {
|
||||
if (fs.existsSync(PID_FILE)) {
|
||||
const savedPid = parseInt(fs.readFileSync(PID_FILE, "utf-8").trim(), 10);
|
||||
if (savedPid && isProcessAlive(savedPid)) {
|
||||
console.log(`Killing MITM server (PID: ${savedPid})...`);
|
||||
process.kill(savedPid, "SIGTERM");
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (isProcessAlive(savedPid)) {
|
||||
process.kill(savedPid, "SIGKILL");
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
serverProcess = null;
|
||||
serverPid = null;
|
||||
}
|
||||
|
||||
// 2. Remove DNS entry
|
||||
console.log("Removing DNS entry...");
|
||||
await removeDNSEntry(sudoPassword);
|
||||
|
||||
// 3. Remove PID file
|
||||
try {
|
||||
fs.unlinkSync(PID_FILE);
|
||||
} catch (error) {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
return {
|
||||
running: false,
|
||||
pid: null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getMitmStatus,
|
||||
startMitm,
|
||||
stopMitm,
|
||||
getCachedPassword,
|
||||
setCachedPassword
|
||||
};
|
||||
@@ -0,0 +1,214 @@
|
||||
const https = require("https");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const dns = require("dns");
|
||||
const { promisify } = require("util");
|
||||
const os = require("os");
|
||||
|
||||
// Configuration
|
||||
const TARGET_HOST = "daily-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;
|
||||
const DB_FILE = path.join(os.homedir(), ".9router", "db.json");
|
||||
|
||||
// Toggle logging (set true to enable file logging for debugging)
|
||||
const ENABLE_FILE_LOG = false;
|
||||
|
||||
if (!API_KEY) {
|
||||
console.error("❌ ROUTER_API_KEY required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load SSL certificates
|
||||
const certDir = path.join(os.homedir(), ".9router", "mitm");
|
||||
const sslOptions = {
|
||||
key: fs.readFileSync(path.join(certDir, "server.key")),
|
||||
cert: fs.readFileSync(path.join(certDir, "server.crt"))
|
||||
};
|
||||
|
||||
// Chat endpoints that should be intercepted
|
||||
const CHAT_URL_PATTERNS = [":generateContent", ":streamGenerateContent"];
|
||||
|
||||
// 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 });
|
||||
|
||||
function saveRequestLog(url, bodyBuffer) {
|
||||
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}.json`);
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
fs.writeFileSync(filePath, JSON.stringify(body, null, 2));
|
||||
console.log(`💾 Saved request: ${filePath}`);
|
||||
} 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)
|
||||
let cachedTargetIP = null;
|
||||
async function resolveTargetIP() {
|
||||
if (cachedTargetIP) return cachedTargetIP;
|
||||
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;
|
||||
}
|
||||
|
||||
function collectBodyRaw(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", chunk => chunks.push(chunk));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function extractModel(body) {
|
||||
try {
|
||||
return JSON.parse(body.toString()).model || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getMappedModel(model) {
|
||||
if (!model) return null;
|
||||
try {
|
||||
const db = JSON.parse(fs.readFileSync(DB_FILE, "utf-8"));
|
||||
return db.mitmAlias?.antigravity?.[model] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function passthrough(req, res, bodyBuffer) {
|
||||
const targetIP = await resolveTargetIP();
|
||||
|
||||
const forwardReq = https.request({
|
||||
hostname: targetIP,
|
||||
port: 443,
|
||||
path: req.url,
|
||||
method: req.method,
|
||||
headers: { ...req.headers, host: TARGET_HOST },
|
||||
servername: TARGET_HOST,
|
||||
rejectUnauthorized: false
|
||||
}, (forwardRes) => {
|
||||
res.writeHead(forwardRes.statusCode, forwardRes.headers);
|
||||
forwardRes.pipe(res);
|
||||
});
|
||||
|
||||
forwardReq.on("error", (err) => {
|
||||
console.error(`❌ Passthrough error: ${err.message}`);
|
||||
if (!res.headersSent) res.writeHead(502);
|
||||
res.end("Bad Gateway");
|
||||
});
|
||||
|
||||
if (bodyBuffer.length > 0) forwardReq.write(bodyBuffer);
|
||||
forwardReq.end();
|
||||
}
|
||||
|
||||
async function intercept(req, res, bodyBuffer, mappedModel) {
|
||||
try {
|
||||
const body = JSON.parse(bodyBuffer.toString());
|
||||
body.model = mappedModel;
|
||||
|
||||
const response = await fetch(ROUTER_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${API_KEY}`
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text().catch(() => "");
|
||||
throw new Error(`9Router ${response.status}: ${errText}`);
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no"
|
||||
});
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) { res.end(); break; }
|
||||
res.write(decoder.decode(value, { stream: true }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ ${error.message}`);
|
||||
if (!res.headersSent) res.writeHead(500, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: error.message, type: "mitm_error" } }));
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
if (req.headers["x-9router-source"] === "9router") {
|
||||
return passthrough(req, res, bodyBuffer);
|
||||
}
|
||||
|
||||
const isChatRequest = CHAT_URL_PATTERNS.some(p => req.url.includes(p));
|
||||
|
||||
if (!isChatRequest) {
|
||||
return passthrough(req, res, bodyBuffer);
|
||||
}
|
||||
|
||||
const model = extractModel(bodyBuffer);
|
||||
const mappedModel = getMappedModel(model);
|
||||
|
||||
if (!mappedModel) {
|
||||
return passthrough(req, res, bodyBuffer);
|
||||
}
|
||||
|
||||
console.log(`🔀 ${model} → ${mappedModel}`);
|
||||
return intercept(req, res, bodyBuffer, mappedModel);
|
||||
});
|
||||
|
||||
server.listen(LOCAL_PORT, () => {
|
||||
console.log(`🚀 MITM ready on :${LOCAL_PORT} → ${ROUTER_URL}`);
|
||||
});
|
||||
|
||||
server.on("error", (error) => {
|
||||
if (error.code === "EADDRINUSE") {
|
||||
console.error(`❌ Port ${LOCAL_PORT} already in use`);
|
||||
} else if (error.code === "EACCES") {
|
||||
console.error(`❌ Permission denied for port ${LOCAL_PORT}`);
|
||||
} else {
|
||||
console.error(`❌ ${error.message}`);
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("SIGTERM", () => { server.close(() => process.exit(0)); });
|
||||
process.on("SIGINT", () => { server.close(() => process.exit(0)); });
|
||||
@@ -14,6 +14,8 @@ export {
|
||||
getModelAliases,
|
||||
setModelAlias,
|
||||
deleteModelAlias,
|
||||
getMitmAlias,
|
||||
setMitmAliasAll,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
deleteApiKey,
|
||||
|
||||
@@ -121,6 +121,22 @@ export const CLI_TOOLS = {
|
||||
}`,
|
||||
},
|
||||
},
|
||||
antigravity: {
|
||||
id: "antigravity",
|
||||
name: "Antigravity",
|
||||
image: "/providers/antigravity.png",
|
||||
color: "#4285F4",
|
||||
description: "Google Antigravity IDE with MITM",
|
||||
configType: "mitm",
|
||||
modelAliases: ["claude-opus-4-5-thinking", "claude-sonnet-4-5-thinking", "claude-sonnet-4-5", "gemini-3-pro-high"],
|
||||
defaultModels: [
|
||||
{ id: "claude-opus-4-5-thinking", name: "Claude Opus 4.5 Thinking", alias: "claude-opus-4-5-thinking" },
|
||||
{ id: "claude-sonnet-4-5-thinking", name: "Claude Sonnet 4.5 Thinking", alias: "claude-sonnet-4-5-thinking" },
|
||||
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", alias: "claude-sonnet-4-5" },
|
||||
{ id: "gemini-3-pro-high", name: "Gemini 3 Pro High", alias: "gemini-3-pro-high" },
|
||||
{ id: "gemini-3-flash", name: "Gemini 3 Flash", alias: "gemini-3-flash" },
|
||||
],
|
||||
},
|
||||
// HIDDEN: gemini-cli
|
||||
// "gemini-cli": {
|
||||
// id: "gemini-cli",
|
||||
|
||||
Reference in New Issue
Block a user