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