- Introduce default MITM router base URL and update related components to handle it.

- Add input for MITM router base URL in MitmServerCard component.
This commit is contained in:
decolua
2026-04-04 11:25:58 +07:00
parent 2e740ad7e4
commit d84489dba4
11 changed files with 559 additions and 266 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "9router-app",
"version": "0.3.72",
"version": "0.3.73",
"description": "9Router web dashboard",
"private": true,
"scripts": {

View File

@@ -3,6 +3,8 @@
import { useState, useEffect } from "react";
import { Card, Button, Badge, Input } from "@/shared/components";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
/**
* Shared MITM infrastructure card — manages SSL cert + server start/stop.
* DNS per-tool is handled separately in MitmToolCard.
@@ -15,6 +17,7 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
const [selectedApiKey, setSelectedApiKey] = useState("");
const [pendingAction, setPendingAction] = useState(null);
const [modalError, setModalError] = useState(null);
const [mitmRouterBaseUrl, setMitmRouterBaseUrl] = useState(DEFAULT_MITM_ROUTER_BASE);
const isWindows = typeof navigator !== "undefined" && navigator.userAgent?.includes("Windows");
const isAdmin = status?.isAdmin !== false;
@@ -35,6 +38,9 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
if (res.ok) {
const data = await res.json();
setStatus(data);
if (data.mitmRouterBaseUrl) {
setMitmRouterBaseUrl(data.mitmRouterBaseUrl);
}
onStatusChange?.(data);
}
} catch {
@@ -68,7 +74,11 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
await fetch("/api/cli-tools/antigravity-mitm", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ apiKey: keyToUse, sudoPassword: password }),
body: JSON.stringify({
apiKey: keyToUse,
sudoPassword: password,
mitmRouterBaseUrl: mitmRouterBaseUrl.trim() || DEFAULT_MITM_ROUTER_BASE,
}),
});
} else {
await fetch("/api/cli-tools/antigravity-mitm", {
@@ -137,25 +147,44 @@ export default function MitmServerCard({ apiKeys, cloudEnabled, onStatusChange }
</p>
</div>
{/* API Key selector (only when stopped) */}
{!isRunning && (
{/* Base URL + API Key — same row pattern as Claude Code / cli-tools */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<span className="text-xs text-text-muted shrink-0">API Key</span>
{apiKeys?.length > 0 ? (
<select
value={selectedApiKey}
onChange={(e) => setSelectedApiKey(e.target.value)}
className="flex-1 px-2 py-1 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="text-xs text-text-muted">
{cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"}
</span>
)}
<span className="w-32 shrink-0 text-sm font-semibold text-text-main text-right">9Router Base URL</span>
<span className="material-symbols-outlined text-text-muted text-[14px]">arrow_forward</span>
<input
type="text"
value={mitmRouterBaseUrl}
onChange={(e) => setMitmRouterBaseUrl(e.target.value)}
placeholder={DEFAULT_MITM_ROUTER_BASE}
disabled={isRunning}
className="flex-1 min-w-0 px-2 py-1.5 bg-surface rounded border border-border text-xs text-text-main focus:outline-none focus:ring-1 focus:ring-primary/50 disabled:opacity-50"
/>
</div>
)}
{!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 min-w-0 px-2 py-1.5 bg-surface rounded text-xs border border-border text-text-main 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 px-2 py-1.5 text-xs text-text-muted">
{cloudEnabled ? "No API keys — create one in Keys page" : "sk_9router (default)"}
</span>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-2 flex-wrap" data-i18n-skip="true">

View File

@@ -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, IFlowCookieModal, GitLabAuthModal, Toggle, Select } from "@/shared/components";
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
import { getModelsByProviderId } from "@/shared/constants/models";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
@@ -1873,199 +1873,6 @@ AddApiKeyModal.propTypes = {
onClose: PropTypes.func.isRequired,
};
function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
priority: 1,
apiKey: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (connection) {
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
});
setTestResult(null);
setValidationResult(null);
}
}, [connection]);
const handleTest = async () => {
if (!connection?.provider) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data.valid ? "success" : "failed");
} catch {
setTestResult("failed");
} finally {
setTesting(false);
}
};
const handleValidate = async () => {
if (!connection?.provider || !formData.apiKey) return;
setValidating(true);
setValidationResult(null);
try {
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
const handleSubmit = async () => {
setSaving(true);
try {
const updates = {
name: formData.name,
priority: formData.priority,
};
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
if (!isValid) {
try {
setValidating(true);
setValidationResult(null);
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
});
const data = await res.json();
isValid = !!data.valid;
setValidationResult(isValid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
}
if (isValid) {
updates.testStatus = "active";
updates.lastError = null;
updates.lastErrorAt = null;
}
}
await onSave(updates);
} finally {
setSaving(false);
}
};
if (!connection) return null;
const isOAuth = connection.authType === "oauth";
const isCompatible = isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider);
return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? "Account name" : "Production Key"}
/>
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">Email</p>
<p className="font-medium">{connection.email}</p>
</div>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
/>
{!isOAuth && (
<>
<div className="flex gap-2">
<Input
label="API Key"
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter new API key"
hint="Leave blank to keep the current API key."
className="flex-1"
/>
<div className="pt-6">
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
{validating ? "Checking..." : "Check"}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
</>
)}
{/* Test Connection */}
{!isCompatible && (
<div className="flex items-center gap-3">
<Button onClick={handleTest} variant="secondary" disabled={testing}>
{testing ? "Testing..." : "Test Connection"}
</Button>
{testResult && (
<Badge variant={testResult === "success" ? "success" : "error"}>
{testResult === "success" ? "Valid" : "Failed"}
</Badge>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
);
}
EditConnectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
connection: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
priority: PropTypes.number,
authType: PropTypes.string,
provider: PropTypes.string,
providerSpecificData: PropTypes.object,
}),
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};
function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) {
const [formData, setFormData] = useState({
name: "",

View File

@@ -70,14 +70,19 @@ function getColorClasses(remainingPercentage) {
/**
* Quota Table Component - Table-based display for quota data
*/
export default function QuotaTable({ quotas = [] }) {
export default function QuotaTable({ quotas = [], compact = false }) {
if (!quotas || quotas.length === 0) {
return null;
}
const cellPad = compact ? "py-1.5 px-2" : "py-2 px-3";
const nameText = compact ? "text-xs" : "text-sm";
const resetPrimary = compact ? "text-xs" : "text-sm";
const resetSecondary = compact ? "text-[10px] leading-tight" : "text-xs";
return (
<div className="overflow-x-auto">
<table className="w-full table-fixed">
<table className="w-full table-fixed text-left">
<colgroup>
<col className="w-[30%]" /> {/* Model Name */}
<col className="w-[45%]" /> {/* Limit Progress */}
@@ -99,18 +104,20 @@ export default function QuotaTable({ quotas = [] }) {
className="border-b border-black/5 dark:border-white/5 hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors"
>
{/* Model Name with Status Emoji */}
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<span className="text-xs">{colors.emoji}</span>
<span className="text-sm font-medium text-text-primary">{quota.name}</span>
<td className={cellPad}>
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] shrink-0">{colors.emoji}</span>
<span className={`${nameText} font-medium text-text-primary truncate`}>
{quota.name}
</span>
</div>
</td>
{/* Limit (Progress + Numbers) */}
<td className="py-2 px-3">
<div className="space-y-1.5">
<td className={cellPad}>
<div className={compact ? "space-y-1" : "space-y-1.5"}>
{/* Progress bar - always show with border for visibility */}
<div className={`h-1.5 rounded-full overflow-hidden border ${colors.bgLight} ${
<div className={`${compact ? "h-1" : "h-1.5"} rounded-full overflow-hidden border ${colors.bgLight} ${
remaining === 0 ? 'border-black/10 dark:border-white/10' : 'border-transparent'
}`}>
<div
@@ -120,7 +127,7 @@ export default function QuotaTable({ quotas = [] }) {
</div>
{/* Numbers */}
<div className="flex items-center justify-between text-xs">
<div className={`flex items-center justify-between ${compact ? "text-[10px]" : "text-xs"}`}>
<span className="text-text-muted">
{quota.used.toLocaleString()} / {quota.total > 0 ? quota.total.toLocaleString() : "∞"}
</span>
@@ -132,22 +139,22 @@ export default function QuotaTable({ quotas = [] }) {
</td>
{/* Reset Time */}
<td className="py-2 px-3">
<td className={cellPad}>
{countdown !== "-" || resetDisplay ? (
<div className="space-y-0.5">
{countdown !== "-" && (
<div className="text-sm text-text-primary font-medium">
<div className={`${resetPrimary} text-text-primary font-medium`}>
in {countdown}
</div>
)}
{resetDisplay && (
<div className="text-xs text-text-muted">
<div className={`${resetSecondary} text-text-muted`}>
{resetDisplay}
</div>
)}
</div>
) : (
<div className="text-sm text-text-muted italic">N/A</div>
<div className={`${resetPrimary} text-text-muted italic`}>N/A</div>
)}
</td>
</tr>

View File

@@ -2,11 +2,12 @@
import { useState, useEffect, useCallback, useRef } from "react";
import ProviderIcon from "@/shared/components/ProviderIcon";
import ProviderLimitCard from "./ProviderLimitCard";
import QuotaTable from "./QuotaTable";
import Toggle from "@/shared/components/Toggle";
import { parseQuotaData, calculatePercentage } from "./utils";
import Card from "@/shared/components/Card";
import Button from "@/shared/components/Button";
import { EditConnectionModal } from "@/shared/components";
import { USAGE_SUPPORTED_PROVIDERS } from "@/shared/constants/providers";
const REFRESH_INTERVAL_MS = 60000; // 60 seconds
@@ -21,6 +22,11 @@ export default function ProviderLimits() {
const [refreshingAll, setRefreshingAll] = useState(false);
const [countdown, setCountdown] = useState(60);
const [connectionsLoading, setConnectionsLoading] = useState(true);
const [deletingId, setDeletingId] = useState(null);
const [togglingId, setTogglingId] = useState(null);
const [showEditModal, setShowEditModal] = useState(false);
const [selectedConnection, setSelectedConnection] = useState(null);
const [proxyPools, setProxyPools] = useState([]);
const intervalRef = useRef(null);
const countdownRef = useRef(null);
@@ -123,6 +129,97 @@ export default function ProviderLimits() {
[fetchQuota],
);
const handleDeleteConnection = useCallback(async (id) => {
if (!confirm("Delete this connection?")) return;
setDeletingId(id);
try {
const res = await fetch(`/api/providers/${id}`, { method: "DELETE" });
if (res.ok) {
setConnections((prev) => prev.filter((c) => c.id !== id));
setQuotaData((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setLoading((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
setErrors((prev) => {
const next = { ...prev };
delete next[id];
return next;
});
}
} catch (error) {
console.error("Error deleting connection:", error);
} finally {
setDeletingId(null);
}
}, []);
const handleToggleConnectionActive = useCallback(async (id, isActive) => {
setTogglingId(id);
try {
const res = await fetch(`/api/providers/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ isActive }),
});
if (res.ok) {
setConnections((prev) =>
prev.map((c) => (c.id === id ? { ...c, isActive } : c)),
);
}
} catch (error) {
console.error("Error updating connection status:", error);
} finally {
setTogglingId(null);
}
}, []);
const handleUpdateConnection = useCallback(
async (formData) => {
if (!selectedConnection?.id) return;
const connectionId = selectedConnection.id;
const provider = selectedConnection.provider;
try {
const res = await fetch(`/api/providers/${connectionId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(formData),
});
if (res.ok) {
await fetchConnections();
setShowEditModal(false);
setSelectedConnection(null);
if (USAGE_SUPPORTED_PROVIDERS.includes(provider)) {
await fetchQuota(connectionId, provider);
}
}
} catch (error) {
console.error("Error saving connection:", error);
}
},
[selectedConnection, fetchConnections, fetchQuota],
);
useEffect(() => {
let cancelled = false;
fetch("/api/proxy-pools?isActive=true", { cache: "no-store" })
.then((res) => res.json())
.then((data) => {
if (!cancelled && data?.proxyPools) {
setProxyPools(data.proxyPools);
}
})
.catch(() => {});
return () => {
cancelled = true;
};
}, []);
// Refresh all providers
const refreshAll = useCallback(async () => {
if (refreshingAll) return;
@@ -358,81 +455,148 @@ export default function ProviderLimits() {
</div>
</div>
{/* Provider Cards Grid */}
<div className="flex flex-col gap-4">
{/* Provider cards: 2 columns, compact */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{sortedConnections.map((conn) => {
const quota = quotaData[conn.id];
const isLoading = loading[conn.id];
const error = errors[conn.id];
// Use table layout for all providers
const isInactive = conn.isActive === false;
const rowBusy = deletingId === conn.id || togglingId === conn.id;
return (
<Card key={conn.id} padding="none">
<div className="p-6 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center overflow-hidden">
<Card
key={conn.id}
padding="none"
className={`min-w-0 ${isInactive ? "opacity-60" : ""}`}
>
<div className="px-4 py-3 border-b border-black/10 dark:border-white/10">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="w-8 h-8 shrink-0 rounded-md flex items-center justify-center overflow-hidden">
<ProviderIcon
src={`/providers/${conn.provider}.png`}
alt={conn.provider}
size={40}
size={32}
className="object-contain"
fallbackText={
conn.provider?.slice(0, 2).toUpperCase() || "PR"
}
/>
</div>
<div>
<h3 className="text-base font-semibold text-text-primary capitalize">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-text-primary capitalize truncate">
{conn.provider}
</h3>
{conn.name && (
<p className="text-sm text-text-muted">{conn.name}</p>
<p className="text-xs text-text-muted truncate">
{conn.name}
</p>
)}
</div>
</div>
<button
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading}
className="p-2 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
<span
className={`material-symbols-outlined text-[20px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={() => refreshProvider(conn.id, conn.provider)}
disabled={isLoading || rowBusy}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 transition-colors disabled:opacity-50"
title="Refresh quota"
>
refresh
</span>
</button>
<span
className={`material-symbols-outlined text-[18px] text-text-muted ${isLoading ? "animate-spin" : ""}`}
>
refresh
</span>
</button>
<button
type="button"
onClick={() => {
setSelectedConnection(conn);
setShowEditModal(true);
}}
disabled={rowBusy}
className="p-1.5 rounded-lg hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary transition-colors disabled:opacity-50"
title="Edit connection"
>
<span className="material-symbols-outlined text-[18px]">
edit
</span>
</button>
<button
type="button"
onClick={() => handleDeleteConnection(conn.id)}
disabled={rowBusy}
className="p-1.5 rounded-lg hover:bg-red-500/10 text-red-500 transition-colors disabled:opacity-50"
title="Delete connection"
>
<span
className={`material-symbols-outlined text-[18px] ${deletingId === conn.id ? "animate-pulse" : ""}`}
>
delete
</span>
</button>
<div
className="inline-flex items-center pl-0.5"
title={
(conn.isActive ?? true)
? "Disable connection"
: "Enable connection"
}
>
<Toggle
size="sm"
checked={conn.isActive ?? true}
disabled={rowBusy}
onChange={(nextActive) =>
handleToggleConnectionActive(conn.id, nextActive)
}
/>
</div>
</div>
</div>
</div>
<div className="p-6">
<div className="px-3 py-3">
{isLoading ? (
<div className="text-center py-8 text-text-muted">
<span className="material-symbols-outlined text-[32px] animate-spin">
<div className="text-center py-5 text-text-muted">
<span className="material-symbols-outlined text-[28px] animate-spin">
progress_activity
</span>
</div>
) : error ? (
<div className="text-center py-8">
<span className="material-symbols-outlined text-[32px] text-red-500">
<div className="text-center py-5">
<span className="material-symbols-outlined text-[28px] text-red-500">
error
</span>
<p className="mt-2 text-sm text-text-muted">{error}</p>
<p className="mt-1.5 text-xs text-text-muted">{error}</p>
</div>
) : quota?.message ? (
<div className="text-center py-8">
<p className="text-sm text-text-muted">{quota.message}</p>
<div className="text-center py-5">
<p className="text-xs text-text-muted">{quota.message}</p>
</div>
) : (
<QuotaTable quotas={quota?.quotas} />
<QuotaTable quotas={quota?.quotas} compact />
)}
</div>
</Card>
);
})}
</div>
<EditConnectionModal
isOpen={showEditModal}
connection={selectedConnection}
proxyPools={proxyPools}
onSave={handleUpdateConnection}
onClose={() => {
setShowEditModal(false);
setSelectedConnection(null);
}}
/>
</div>
);
}

View File

@@ -17,6 +17,25 @@ import { getSettings, updateSettings } from "@/lib/localDb";
initDbHooks(getSettings, updateSettings);
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
function normalizeMitmRouterBaseUrlInput(input) {
if (input == null || String(input).trim() === "") {
return DEFAULT_MITM_ROUTER_BASE;
}
const t = String(input).trim().replace(/\/+$/, "");
let u;
try {
u = new URL(t);
} catch {
throw new Error("Invalid MITM router URL");
}
if (u.protocol !== "http:" && u.protocol !== "https:") {
throw new Error("MITM router URL must use http or https");
}
return t;
}
const isWin = process.platform === "win32";
function getPassword(provided) {
@@ -37,6 +56,7 @@ function checkIsAdmin() {
export async function GET() {
try {
const status = await getMitmStatus();
const settings = await getSettings();
return NextResponse.json({
running: status.running,
pid: status.pid || null,
@@ -45,6 +65,9 @@ export async function GET() {
dnsStatus: status.dnsStatus || {},
hasCachedPassword: !!getCachedPassword() || !!(await loadEncryptedPassword()),
isAdmin: checkIsAdmin(),
mitmRouterBaseUrl:
(settings.mitmRouterBaseUrl && String(settings.mitmRouterBaseUrl).trim()) ||
DEFAULT_MITM_ROUTER_BASE,
});
} catch (error) {
console.log("Error getting MITM status:", error.message);
@@ -55,7 +78,7 @@ export async function GET() {
// POST - Start MITM server (cert + server, no DNS)
export async function POST(request) {
try {
const { apiKey, sudoPassword } = await request.json();
const { apiKey, sudoPassword, mitmRouterBaseUrl } = await request.json();
const pwd = getPassword(sudoPassword) || await loadEncryptedPassword() || "";
if (!apiKey || (!isWin && !pwd)) {
@@ -65,6 +88,18 @@ export async function POST(request) {
);
}
if (mitmRouterBaseUrl !== undefined && mitmRouterBaseUrl !== null) {
try {
const normalized = normalizeMitmRouterBaseUrlInput(mitmRouterBaseUrl);
await updateSettings({ mitmRouterBaseUrl: normalized });
} catch (e) {
return NextResponse.json(
{ error: e.message || "Invalid MITM router URL" },
{ status: 400 },
);
}
}
const result = await startServer(apiKey, pwd);
if (!isWin) setCachedPassword(pwd);

View File

@@ -6,6 +6,8 @@ import os from "node:os";
import fs from "node:fs";
import lockfile from "proper-lockfile";
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
const isCloud = typeof caches !== 'undefined' || typeof caches === 'object';
// Get app name - fixed constant to avoid Windows path issues in standalone build
@@ -65,7 +67,8 @@ const defaultData = {
observabilityMaxJsonSize: 1024,
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: ""
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
},
pricing: {} // NEW: pricing configuration
};
@@ -101,6 +104,7 @@ function cloneDefaultData() {
outboundProxyEnabled: false,
outboundProxyUrl: "",
outboundNoProxy: "",
mitmRouterBaseUrl: DEFAULT_MITM_ROUTER_BASE,
},
pricing: {},
};

View File

@@ -1,6 +1,9 @@
const { log, err } = require("../logger");
const ROUTER_BASE = "http://localhost:20128";
const DEFAULT_LOCAL_ROUTER = "http://localhost:20128";
const ROUTER_BASE = String(process.env.MITM_ROUTER_BASE || DEFAULT_LOCAL_ROUTER)
.trim()
.replace(/\/+$/, "") || DEFAULT_LOCAL_ROUTER;
const API_KEY = process.env.ROUTER_API_KEY;
/**

View File

@@ -15,6 +15,27 @@ const { isCertExpired } = require("./cert/rootCA");
const { MITM_DIR } = require("./paths");
const { log, err } = require("./logger");
const DEFAULT_MITM_ROUTER_BASE = "http://localhost:20128";
function shellQuoteSingle(str) {
if (str == null || str === "") return "''";
return `'${String(str).replace(/'/g, "'\\''")}'`;
}
async function resolveMitmRouterBaseUrl() {
if (!_getSettings) return DEFAULT_MITM_ROUTER_BASE;
try {
const s = await _getSettings();
const raw = s && s.mitmRouterBaseUrl != null ? String(s.mitmRouterBaseUrl).trim() : "";
if (!raw) return DEFAULT_MITM_ROUTER_BASE;
const u = new URL(raw);
if (u.protocol !== "http:" && u.protocol !== "https:") return DEFAULT_MITM_ROUTER_BASE;
return raw.replace(/\/+$/, "");
} catch {
return DEFAULT_MITM_ROUTER_BASE;
}
}
const MITM_PORT = 443;
const MITM_WIN_NODE_PORT = 8443;
const PID_FILE = path.join(MITM_DIR, ".mitm.pid");
@@ -427,7 +448,8 @@ async function startServer(apiKey, sudoPassword) {
}
// Step 2: Spawn server (Root CA already installed in Step 1.5)
log("🚀 Starting server...");
const mitmRouterBase = await resolveMitmRouterBaseUrl();
log(`🚀 Starting server... (router: ${mitmRouterBase})`);
if (IS_WIN) {
// Kill any process using port 443 before spawning
try {
@@ -444,7 +466,12 @@ async function startServer(apiKey, sudoPassword) {
detached: false,
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" },
env: {
...process.env,
ROUTER_API_KEY: apiKey,
NODE_ENV: "production",
MITM_ROUTER_BASE: mitmRouterBase,
},
}
);
@@ -452,7 +479,14 @@ async function startServer(apiKey, sudoPassword) {
} else if (isSudoAvailable()) {
// Pass HOME explicitly so os.homedir() resolves to the unprivileged user's home
// instead of /root when sudo resets the environment.
const inlineCmd = `HOME='${os.homedir()}' ROUTER_API_KEY='${apiKey}' NODE_ENV='production' '${process.execPath}' '${SERVER_PATH}'`;
const inlineCmd = [
`HOME=${shellQuoteSingle(os.homedir())}`,
`ROUTER_API_KEY=${shellQuoteSingle(apiKey)}`,
`MITM_ROUTER_BASE=${shellQuoteSingle(mitmRouterBase)}`,
"NODE_ENV=production",
shellQuoteSingle(process.execPath),
shellQuoteSingle(SERVER_PATH),
].join(" ");
serverProcess = spawn(
"sudo", ["-S", "-E", "sh", "-c", inlineCmd],
{ detached: false, stdio: ["pipe", "pipe", "pipe"] }
@@ -464,7 +498,12 @@ async function startServer(apiKey, sudoPassword) {
serverProcess = spawn(process.execPath, [SERVER_PATH], {
detached: false,
stdio: ["ignore", "pipe", "pipe"],
env: { ...process.env, ROUTER_API_KEY: apiKey, NODE_ENV: "production" },
env: {
...process.env,
ROUTER_API_KEY: apiKey,
NODE_ENV: "production",
MITM_ROUTER_BASE: mitmRouterBase,
},
});
}

View File

@@ -0,0 +1,204 @@
"use client";
import { useState, useEffect } from "react";
import PropTypes from "prop-types";
import Modal from "@/shared/components/Modal";
import Input from "@/shared/components/Input";
import Button from "@/shared/components/Button";
import Badge from "@/shared/components/Badge";
import { isOpenAICompatibleProvider, isAnthropicCompatibleProvider } from "@/shared/constants/providers";
export default function EditConnectionModal({ isOpen, connection, proxyPools, onSave, onClose }) {
const [formData, setFormData] = useState({
name: "",
priority: 1,
apiKey: "",
});
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState(null);
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (connection) {
setFormData({
name: connection.name || "",
priority: connection.priority || 1,
apiKey: "",
});
setTestResult(null);
setValidationResult(null);
}
}, [connection]);
const isOAuth = connection?.authType === "oauth";
const isCompatible = connection
? (isOpenAICompatibleProvider(connection.provider) || isAnthropicCompatibleProvider(connection.provider))
: false;
const handleTest = async () => {
if (!connection?.provider) return;
setTesting(true);
setTestResult(null);
try {
const res = await fetch(`/api/providers/${connection.id}/test`, { method: "POST" });
const data = await res.json();
setTestResult(data.valid ? "success" : "failed");
} catch {
setTestResult("failed");
} finally {
setTesting(false);
}
};
const handleValidate = async () => {
if (!connection?.provider || !formData.apiKey) return;
setValidating(true);
setValidationResult(null);
try {
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
});
const data = await res.json();
setValidationResult(data.valid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
};
const handleSubmit = async () => {
if (!connection) return;
setSaving(true);
try {
const updates = {
name: formData.name,
priority: formData.priority,
};
if (!isOAuth && formData.apiKey) {
updates.apiKey = formData.apiKey;
let isValid = validationResult === "success";
if (!isValid) {
try {
setValidating(true);
setValidationResult(null);
const res = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider: connection.provider, apiKey: formData.apiKey }),
});
const data = await res.json();
isValid = !!data.valid;
setValidationResult(isValid ? "success" : "failed");
} catch {
setValidationResult("failed");
} finally {
setValidating(false);
}
}
if (isValid) {
updates.testStatus = "active";
updates.lastError = null;
updates.lastErrorAt = null;
}
}
await onSave(updates);
} finally {
setSaving(false);
}
};
if (!connection) return null;
return (
<Modal isOpen={isOpen} title="Edit Connection" onClose={onClose}>
<div className="flex flex-col gap-4">
<Input
label="Name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={isOAuth ? "Account name" : "Production Key"}
/>
{isOAuth && connection.email && (
<div className="bg-sidebar/50 p-3 rounded-lg">
<p className="text-sm text-text-muted mb-1">Email</p>
<p className="font-medium">{connection.email}</p>
</div>
)}
<Input
label="Priority"
type="number"
value={formData.priority}
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })}
/>
{!isOAuth && (
<>
<div className="flex gap-2">
<Input
label="API Key"
type="password"
value={formData.apiKey}
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
placeholder="Enter new API key"
hint="Leave blank to keep the current API key."
className="flex-1"
/>
<div className="pt-6">
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
{validating ? "Checking..." : "Check"}
</Button>
</div>
</div>
{validationResult && (
<Badge variant={validationResult === "success" ? "success" : "error"}>
{validationResult === "success" ? "Valid" : "Invalid"}
</Badge>
)}
</>
)}
{!isCompatible && (
<div className="flex items-center gap-3">
<Button onClick={handleTest} variant="secondary" disabled={testing}>
{testing ? "Testing..." : "Test Connection"}
</Button>
{testResult && (
<Badge variant={testResult === "success" ? "success" : "error"}>
{testResult === "success" ? "Valid" : "Failed"}
</Badge>
)}
</div>
)}
<div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={saving}>{saving ? "Saving..." : "Save"}</Button>
<Button onClick={onClose} variant="ghost" fullWidth>Cancel</Button>
</div>
</div>
</Modal>
);
}
EditConnectionModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
connection: PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
email: PropTypes.string,
priority: PropTypes.number,
authType: PropTypes.string,
provider: PropTypes.string,
providerSpecificData: PropTypes.object,
}),
proxyPools: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string,
name: PropTypes.string,
})),
onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired,
};

View File

@@ -27,6 +27,7 @@ export { default as KiroSocialOAuthModal } from "./KiroSocialOAuthModal";
export { default as CursorAuthModal } from "./CursorAuthModal";
export { default as IFlowCookieModal } from "./IFlowCookieModal";
export { default as GitLabAuthModal } from "./GitLabAuthModal";
export { default as EditConnectionModal } from "./EditConnectionModal";
export { default as SegmentedControl } from "./SegmentedControl";
export { default as Tooltip } from "./Tooltip";