From a8cf0bf1070c570f12c65b2c549b7ef4b72c1b3d Mon Sep 17 00:00:00 2001 From: Blessed Date: Wed, 1 Jul 2026 22:17:20 +0800 Subject: [PATCH 01/23] chore: rename package to xscope0 --- README.md | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5611d4d..f27cbf7 100644 --- a/README.md +++ b/README.md @@ -45,14 +45,14 @@ ## Quick Start ```bash -npm install -g xscope0-modifed-router +npm install -g xscope0 xscope0-router ``` Or run directly: ```bash -npx xscope0-modifed-router +npx xscope0 ``` | Endpoint | URL | diff --git a/package.json b/package.json index a28069e..882b428 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "xscope0-modifed-router", + "name": "xscope0", "version": "0.7.4", - "description": "xscope0 Modifed Router - fork of 9router with extra provider automation", + "description": "xScope0 Router - fork of 9router with extra provider automation", "bin": { "9router": "./cli.js", "xscope0-router": "./cli.js" From 2546ca007bfcd46700d83805df7c9ec3e88a0cbd Mon Sep 17 00:00:00 2001 From: Blessed Date: Wed, 1 Jul 2026 22:39:18 +0800 Subject: [PATCH 02/23] fix: allow local backup password reauth --- app/src/dashboardGuard.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/dashboardGuard.js b/app/src/dashboardGuard.js index 193b60b..62bab9e 100644 --- a/app/src/dashboardGuard.js +++ b/app/src/dashboardGuard.js @@ -43,6 +43,10 @@ const ALWAYS_PROTECTED = [ "/api/oauth/cursor/auto-import", "/api/oauth/kiro/auto-import", ]; +const LOCAL_REAUTH_PATHS = [ + "/api/settings/database", +]; + const PROTECTED_API_PATHS = [ "/api/settings", @@ -196,6 +200,10 @@ export async function proxy(request) { return NextResponse.json({ error: "Local only: CLI token required" }, { status: 403 }); } } + // Local backup/import prompts for the dashboard password again in its handler. + if (LOCAL_REAUTH_PATHS.some((p) => pathname.startsWith(p)) && isLocalRequest(request)) { + return NextResponse.next(); + } // Always protected - require valid JWT or local CLI token (machineId-based) if (ALWAYS_PROTECTED.some((p) => pathname.startsWith(p))) { From c99fa4aa9bcdb0e47d91c64174412a8871ad148b Mon Sep 17 00:00:00 2001 From: Blessed Date: Wed, 1 Jul 2026 23:48:56 +0800 Subject: [PATCH 03/23] feat: improve provider proxy rotation --- app/open-sse/handlers/chatCore.js | 11 +- app/open-sse/utils/proxyFetch.js | 756 ++++++------- .../providers/[id]/AddApiKeyModal.js | 73 +- .../dashboard/providers/[id]/page.js | 48 +- .../(dashboard)/dashboard/proxy-pools/page.js | 27 +- .../dashboard/proxy-pools/utils.js | 14 + .../usage/components/ProviderLimits/index.js | 58 +- .../usage/components/ProviderLimits/utils.js | 19 + app/src/lib/network/connectionProxy.js | 383 +++---- app/src/shared/components/Header.js | 2 - app/src/sse/services/auth.js | 992 +++++++++--------- app/tests/auth-proxy-metadata.test.js | 51 + app/tests/dashboard-features.test.js | 55 + app/tests/proxy-config.test.js | 28 + app/tests/proxy-fetch-rotation.test.js | 34 + app/tests/vitest.config.js | 10 + 16 files changed, 1400 insertions(+), 1161 deletions(-) create mode 100644 app/src/app/(dashboard)/dashboard/proxy-pools/utils.js create mode 100644 app/tests/auth-proxy-metadata.test.js create mode 100644 app/tests/dashboard-features.test.js create mode 100644 app/tests/proxy-config.test.js create mode 100644 app/tests/proxy-fetch-rotation.test.js diff --git a/app/open-sse/handlers/chatCore.js b/app/open-sse/handlers/chatCore.js index a3bddbe..7316fe3 100644 --- a/app/open-sse/handlers/chatCore.js +++ b/app/open-sse/handlers/chatCore.js @@ -99,7 +99,7 @@ export function applyLoopGuard(translatedBody, finalFormat, provider, model, log * @param {object} options.credentials - Provider credentials * @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses") */ -export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, terseEnabled, terseLevel, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) { +export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, terseEnabled, terseLevel, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking, externalSignal }) { const { provider, model, accountCount = 0 } = modelInfo; const requestStartTime = Date.now(); @@ -323,9 +323,18 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred log, provider, model }); + if (externalSignal) { + if (externalSignal.aborted) { + streamController.abort(); + } else { + externalSignal.addEventListener("abort", () => streamController.abort(), { once: true }); + } + } + const proxyOptions = { connectionProxyEnabled: credentials?.providerSpecificData?.connectionProxyEnabled === true, connectionProxyUrl: credentials?.providerSpecificData?.connectionProxyUrl || "", + connectionProxyUrls: Array.isArray(credentials?.providerSpecificData?.connectionProxyUrls) ? credentials.providerSpecificData.connectionProxyUrls : [], connectionNoProxy: credentials?.providerSpecificData?.connectionNoProxy || "", vercelRelayUrl: credentials?.providerSpecificData?.vercelRelayUrl || "", }; diff --git a/app/open-sse/utils/proxyFetch.js b/app/open-sse/utils/proxyFetch.js index 28030c7..618421c 100644 --- a/app/open-sse/utils/proxyFetch.js +++ b/app/open-sse/utils/proxyFetch.js @@ -1,367 +1,389 @@ -import { Readable } from "stream"; -import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; -import { dbg } from "./debugLog.js"; - -const originalFetch = globalThis.fetch; -const proxyDispatchers = new Map(); - -// ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── -// Disabled: not in use. Kept commented for future re-enable. -// Restore the original block to re-enable per-host JA3 spoofing. -/* -let _gotScraping = null; -let _gotScrapingChecked = false; -const _gotScrapingLoggedHosts = new Set(); - -async function getGotScraping() { - if (_gotScrapingChecked) return _gotScraping; - _gotScrapingChecked = true; - try { - const mod = await import("got-scraping"); - _gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null; - if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)"); - } catch (e) { - console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`); - _gotScraping = null; - } - return _gotScraping; -} - -async function gotScrapingFetch(url, options) { - const gs = await getGotScraping(); - if (!gs) return null; - - const method = (options.method || "GET").toUpperCase(); - const headersInit = options.headers || {}; - const headers = headersInit instanceof Headers - ? Object.fromEntries(headersInit.entries()) - : { ...headersInit }; - - return new Promise((resolve, reject) => { - let settled = false; - const stream = gs.stream({ - url, - method, - headers, - body: method === "GET" || method === "HEAD" ? undefined : options.body, - throwHttpErrors: false, - retry: { limit: 0 }, - timeout: { request: undefined }, - followRedirect: false, - decompress: true, - }); - - if (options.signal) { - const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; - if (options.signal.aborted) onAbort(); - else options.signal.addEventListener("abort", onAbort, { once: true }); - } - - stream.once("response", (res) => { - if (settled) return; - settled = true; - const resHeaders = new Headers(); - for (const [k, v] of Object.entries(res.headers || {})) { - if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x))); - else if (v != null) resHeaders.set(k, String(v)); - } - const body = Readable.toWeb(stream); - resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders })); - }); - - stream.once("error", (err) => { - if (settled) return; - settled = true; - reject(err); - }); - }); -} - -async function tryGotScrapingFetch(url, options) { - try { - const res = await gotScrapingFetch(url, options); - if (res) { - try { - const host = new URL(typeof url === "string" ? url : url.toString()).hostname; - if (!_gotScrapingLoggedHosts.has(host)) { - _gotScrapingLoggedHosts.add(host); - dbg("TLS", `using got-scraping for ${host}`); - } - } catch { } - } - return res; - } catch (e) { - console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`); - return null; - } -} -*/ - -// DNS cache — use Map to avoid prototype pollution via malformed hostnames -const DNS_CACHE = new Map(); -const MITM_BYPASS_HOSTS = [ - "cloudcode-pa.googleapis.com", - "daily-cloudcode-pa.googleapis.com", - "api.individual.githubcopilot.com", - "q.us-east-1.amazonaws.com", - "codewhisperer.us-east-1.amazonaws.com", - "api2.cursor.sh", -]; -const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"]; -const HTTPS_PORT = 443; -const HTTP_SUCCESS_MIN = 200; -const HTTP_SUCCESS_MAX = 300; - -function normalizeString(value) { - if (value === undefined || value === null) return ""; - return String(value).trim(); -} - -/** - * Resolve real IP using Google DNS (bypass system DNS) - */ -async function resolveRealIP(hostname) { - const cached = DNS_CACHE.get(hostname); - if (cached && Date.now() < cached.expiry) return cached.ip; - - try { - const dns = await import("dns"); - const { promisify } = await import("util"); - const resolver = new dns.Resolver(); - resolver.setServers(GOOGLE_DNS_SERVERS); - const resolve4 = promisify(resolver.resolve4.bind(resolver)); - const addresses = await resolve4(hostname); - DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); - return addresses[0]; - } catch (error) { - console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); - return null; - } -} - -/** - * Check if request should bypass MITM DNS redirect - */ -function shouldBypassMitmDns(url) { - try { - const hostname = new URL(url).hostname; - return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); - } catch { return false; } -} - -function shouldBypassByNoProxy(targetUrl, noProxyValue) { - const noProxy = normalizeString(noProxyValue); - if (!noProxy) return false; - - let hostname; - try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } - const patterns = noProxy.split(",").flatMap((p) => { const t = p.trim().toLowerCase(); return t ? [t] : []; }); - - return patterns.some((pattern) => { - if (pattern === "*") return true; - if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); - return hostname === pattern || hostname.endsWith(`.${pattern}`); - }); -} - -/** - * Get proxy URL from environment - */ -function getEnvProxyUrl(targetUrl) { - const noProxy = process.env.NO_PROXY || process.env.no_proxy; - if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; - - let protocol; - try { protocol = new URL(targetUrl).protocol; } catch { return null; } - - if (protocol === "https:") { - return process.env.HTTPS_PROXY || process.env.https_proxy || - process.env.ALL_PROXY || process.env.all_proxy; - } - - return process.env.HTTP_PROXY || process.env.http_proxy || - process.env.ALL_PROXY || process.env.all_proxy; -} - -/** - * Normalize proxy URL (allow host:port) - */ -function normalizeProxyUrl(proxyUrl) { - const normalizedInput = normalizeString(proxyUrl); - if (!normalizedInput) return null; - - try { - - new URL(normalizedInput); - return normalizedInput; - } catch { - // Allow "127.0.0.1:7890" style values - return `http://${normalizedInput}`; - } -} - -function resolveConnectionProxyUrl(targetUrl, proxyOptions) { - const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; - if (!enabled) return null; - - const proxyUrlRaw = normalizeString(proxyOptions?.url ?? proxyOptions?.connectionProxyUrl); - if (!proxyUrlRaw) return null; - - const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy); - if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return null; - - return normalizeProxyUrl(proxyUrlRaw); -} - -/** - * Create proxy dispatcher lazily (undici-compatible) - */ -async function getDispatcher(proxyUrl) { - const normalized = normalizeProxyUrl(proxyUrl); - if (!normalized) return null; - - if (!proxyDispatchers.has(normalized)) { - // Evict oldest entry if max size reached - if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { - proxyDispatchers.delete(proxyDispatchers.keys().next().value); - } - const { ProxyAgent } = await import("undici"); - proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized })); - } - - return proxyDispatchers.get(normalized); -} - -/** - * Create HTTPS request with manual socket connection (bypass DNS) - */ -async function createBypassRequest(parsedUrl, realIP, options) { - const [httpsModule, netModule] = await Promise.all([import("https"), import("net")]); - // CJS modules expose exports via .default in ESM dynamic import context - const https = httpsModule.default ?? httpsModule; - const net = netModule.default ?? netModule; - - return new Promise((resolve, reject) => { - const socket = new net.Socket(); - - socket.connect(HTTPS_PORT, realIP, () => { - const reqOptions = { - socket, - // SNI + cert hostname are validated against the hostname the caller - // asked for, not the IP we connected to. This keeps the DNS-bypass - // (avoiding /etc/hosts MITM) while still rejecting on-path attackers - // that present a different cert. The MITM_BYPASS_HOSTS targets are - // all public-CA-issued (Google / GitHub / AWS / Cursor) so default - // verification works without any extra trust store. - servername: parsedUrl.hostname, - path: parsedUrl.pathname + parsedUrl.search, - method: options.method || "POST", - headers: { - ...options.headers, - Host: parsedUrl.hostname, - }, - }; - - const req = https.request(reqOptions, (res) => { - const response = { - ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX, - status: res.statusCode, - statusText: res.statusMessage, - headers: new Map(Object.entries(res.headers)), - body: Readable.toWeb(res), - text: async () => { - const chunks = []; - for await (const chunk of res) chunks.push(chunk); - return Buffer.concat(chunks).toString(); - }, - json: async () => JSON.parse(await response.text()), - }; - resolve(response); - }); - - req.on("error", reject); - if (options.body) { - req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body)); - } - req.end(); - }); - - socket.on("error", reject); - }); -} - -export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { - const targetUrl = typeof url === "string" ? url : url.toString(); - - // Vercel relay: forward request via relay headers - const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); - if (vercelRelayUrl) { - const parsed = new URL(targetUrl); - const relayHeaders = { - ...options.headers, - "x-relay-target": `${parsed.protocol}//${parsed.host}`, - "x-relay-path": `${parsed.pathname}${parsed.search}`, - }; - return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); - } - - const connectionProxyUrl = resolveConnectionProxyUrl(targetUrl, proxyOptions); - const envProxyUrl = connectionProxyUrl ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); - const proxyUrl = connectionProxyUrl || envProxyUrl; - - // MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof - if (shouldBypassMitmDns(targetUrl)) { - if (proxyUrl) { - // Proxy resolves DNS externally (not affected by /etc/hosts) — use proxy directly - try { - const dispatcher = await getDispatcher(proxyUrl); - return await originalFetch(url, { ...options, dispatcher }); - } catch (proxyError) { - if (proxyOptions?.strictProxy === true) { - throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); - } - console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${proxyError.message}`); - } - } - // No proxy — manually resolve real IP to bypass DNS spoof - try { - const parsedUrl = new URL(targetUrl); - const realIP = await resolveRealIP(parsedUrl.hostname); - if (realIP) return await createBypassRequest(parsedUrl, realIP, options); - } catch (error) { - console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`); - } - } - - if (proxyUrl) { - try { - const dispatcher = await getDispatcher(proxyUrl); - return await originalFetch(url, { ...options, dispatcher }); - } catch (proxyError) { - // If strictProxy is enabled, fail hard instead of falling back to direct - if (proxyOptions?.strictProxy === true) { - throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); - } - console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${proxyError.message}`); - return originalFetch(url, options); - } - } - - // got-scraping disabled — use native fetch directly - // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) - return originalFetch(url, options); -} - -/** - * Patched global fetch with env-proxy support and MITM DNS bypass - */ -async function patchedFetch(url, options = {}) { - return proxyAwareFetch(url, options, null); -} - -// Idempotency guard — only patch once to avoid wrapping multiple times -if (globalThis.fetch !== patchedFetch) { - globalThis.fetch = patchedFetch; -} - -export default patchedFetch; +import { Readable } from "stream"; +import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; +import { dbg } from "./debugLog.js"; + +const originalFetch = globalThis.fetch; +const proxyDispatchers = new Map(); + +// ─── TLS fingerprinting via got-scraping (browser-like JA3) ─────────────── +// Disabled: not in use. Kept commented for future re-enable. +// Restore the original block to re-enable per-host JA3 spoofing. +/* +let _gotScraping = null; +let _gotScrapingChecked = false; +const _gotScrapingLoggedHosts = new Set(); + +async function getGotScraping() { + if (_gotScrapingChecked) return _gotScraping; + _gotScrapingChecked = true; + try { + const mod = await import("got-scraping"); + _gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null; + if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)"); + } catch (e) { + console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`); + _gotScraping = null; + } + return _gotScraping; +} + +async function gotScrapingFetch(url, options) { + const gs = await getGotScraping(); + if (!gs) return null; + + const method = (options.method || "GET").toUpperCase(); + const headersInit = options.headers || {}; + const headers = headersInit instanceof Headers + ? Object.fromEntries(headersInit.entries()) + : { ...headersInit }; + + return new Promise((resolve, reject) => { + let settled = false; + const stream = gs.stream({ + url, + method, + headers, + body: method === "GET" || method === "HEAD" ? undefined : options.body, + throwHttpErrors: false, + retry: { limit: 0 }, + timeout: { request: undefined }, + followRedirect: false, + decompress: true, + }); + + if (options.signal) { + const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } }; + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + } + + stream.once("response", (res) => { + if (settled) return; + settled = true; + const resHeaders = new Headers(); + for (const [k, v] of Object.entries(res.headers || {})) { + if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x))); + else if (v != null) resHeaders.set(k, String(v)); + } + const body = Readable.toWeb(stream); + resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders })); + }); + + stream.once("error", (err) => { + if (settled) return; + settled = true; + reject(err); + }); + }); +} + +async function tryGotScrapingFetch(url, options) { + try { + const res = await gotScrapingFetch(url, options); + if (res) { + try { + const host = new URL(typeof url === "string" ? url : url.toString()).hostname; + if (!_gotScrapingLoggedHosts.has(host)) { + _gotScrapingLoggedHosts.add(host); + dbg("TLS", `using got-scraping for ${host}`); + } + } catch { } + } + return res; + } catch (e) { + console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`); + return null; + } +} +*/ + +// DNS cache — use Map to avoid prototype pollution via malformed hostnames +const DNS_CACHE = new Map(); +const MITM_BYPASS_HOSTS = [ + "cloudcode-pa.googleapis.com", + "daily-cloudcode-pa.googleapis.com", + "api.individual.githubcopilot.com", + "q.us-east-1.amazonaws.com", + "codewhisperer.us-east-1.amazonaws.com", + "api2.cursor.sh", +]; +const GOOGLE_DNS_SERVERS = ["8.8.8.8", "8.8.4.4"]; +const HTTPS_PORT = 443; +const HTTP_SUCCESS_MIN = 200; +const HTTP_SUCCESS_MAX = 300; + +function normalizeString(value) { + if (value === undefined || value === null) return ""; + return String(value).trim(); +} + +/** + * Resolve real IP using Google DNS (bypass system DNS) + */ +async function resolveRealIP(hostname) { + const cached = DNS_CACHE.get(hostname); + if (cached && Date.now() < cached.expiry) return cached.ip; + + try { + const dns = await import("dns"); + const { promisify } = await import("util"); + const resolver = new dns.Resolver(); + resolver.setServers(GOOGLE_DNS_SERVERS); + const resolve4 = promisify(resolver.resolve4.bind(resolver)); + const addresses = await resolve4(hostname); + DNS_CACHE.set(hostname, { ip: addresses[0], expiry: Date.now() + MEMORY_CONFIG.dnsCacheTtlMs }); + return addresses[0]; + } catch (error) { + console.warn(`[ProxyFetch] DNS resolve failed for ${hostname}:`, error.message); + return null; + } +} + +/** + * Check if request should bypass MITM DNS redirect + */ +function shouldBypassMitmDns(url) { + try { + const hostname = new URL(url).hostname; + return MITM_BYPASS_HOSTS.some(host => hostname.includes(host)); + } catch { return false; } +} + +function shouldBypassByNoProxy(targetUrl, noProxyValue) { + const noProxy = normalizeString(noProxyValue); + if (!noProxy) return false; + + let hostname; + try { hostname = new URL(targetUrl).hostname.toLowerCase(); } catch { return false; } + const patterns = noProxy.split(",").flatMap((p) => { const t = p.trim().toLowerCase(); return t ? [t] : []; }); + + return patterns.some((pattern) => { + if (pattern === "*") return true; + if (pattern.startsWith(".")) return hostname.endsWith(pattern) || hostname === pattern.slice(1); + return hostname === pattern || hostname.endsWith(`.${pattern}`); + }); +} + +/** + * Get proxy URL from environment + */ +function getEnvProxyUrl(targetUrl) { + const noProxy = process.env.NO_PROXY || process.env.no_proxy; + if (shouldBypassByNoProxy(targetUrl, noProxy)) return null; + + let protocol; + try { protocol = new URL(targetUrl).protocol; } catch { return null; } + + if (protocol === "https:") { + return process.env.HTTPS_PROXY || process.env.https_proxy || + process.env.ALL_PROXY || process.env.all_proxy; + } + + return process.env.HTTP_PROXY || process.env.http_proxy || + process.env.ALL_PROXY || process.env.all_proxy; +} + +/** + * Normalize proxy URL (allow host:port) + */ +function normalizeProxyUrl(proxyUrl) { + const normalizedInput = normalizeString(proxyUrl); + if (!normalizedInput) return null; + + try { + + new URL(normalizedInput); + return normalizedInput; + } catch { + // Allow "127.0.0.1:7890" style values + return `http://${normalizedInput}`; + } +} + +export function resolveConnectionProxyUrls(targetUrl, proxyOptions) { + const enabled = proxyOptions?.enabled === true || proxyOptions?.connectionProxyEnabled === true; + if (!enabled) return []; + + const rawUrls = Array.isArray(proxyOptions?.connectionProxyUrls) + ? proxyOptions.connectionProxyUrls + : [proxyOptions?.url ?? proxyOptions?.connectionProxyUrl]; + const noProxy = normalizeString(proxyOptions?.noProxy ?? proxyOptions?.connectionNoProxy); + if (noProxy && shouldBypassByNoProxy(targetUrl, noProxy)) return []; + + return rawUrls + .map((value) => normalizeProxyUrl(normalizeString(value))) + .filter(Boolean); +} + +function resolveConnectionProxyUrl(targetUrl, proxyOptions) { + return resolveConnectionProxyUrls(targetUrl, proxyOptions)[0] || null; +} + +export function shouldRetryProxyResponse(response) { + if (!response) return false; + return response.status === 429 || response.status >= 500; +} + +/** + * Create proxy dispatcher lazily (undici-compatible) + */ +async function getDispatcher(proxyUrl) { + const normalized = normalizeProxyUrl(proxyUrl); + if (!normalized) return null; + + if (!proxyDispatchers.has(normalized)) { + // Evict oldest entry if max size reached + if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { + proxyDispatchers.delete(proxyDispatchers.keys().next().value); + } + const { ProxyAgent } = await import("undici"); + proxyDispatchers.set(normalized, new ProxyAgent({ uri: normalized })); + } + + return proxyDispatchers.get(normalized); +} + +/** + * Create HTTPS request with manual socket connection (bypass DNS) + */ +async function createBypassRequest(parsedUrl, realIP, options) { + const [httpsModule, netModule] = await Promise.all([import("https"), import("net")]); + // CJS modules expose exports via .default in ESM dynamic import context + const https = httpsModule.default ?? httpsModule; + const net = netModule.default ?? netModule; + + return new Promise((resolve, reject) => { + const socket = new net.Socket(); + + socket.connect(HTTPS_PORT, realIP, () => { + const reqOptions = { + socket, + // SNI + cert hostname are validated against the hostname the caller + // asked for, not the IP we connected to. This keeps the DNS-bypass + // (avoiding /etc/hosts MITM) while still rejecting on-path attackers + // that present a different cert. The MITM_BYPASS_HOSTS targets are + // all public-CA-issued (Google / GitHub / AWS / Cursor) so default + // verification works without any extra trust store. + servername: parsedUrl.hostname, + path: parsedUrl.pathname + parsedUrl.search, + method: options.method || "POST", + headers: { + ...options.headers, + Host: parsedUrl.hostname, + }, + }; + + const req = https.request(reqOptions, (res) => { + const response = { + ok: res.statusCode >= HTTP_SUCCESS_MIN && res.statusCode < HTTP_SUCCESS_MAX, + status: res.statusCode, + statusText: res.statusMessage, + headers: new Map(Object.entries(res.headers)), + body: Readable.toWeb(res), + text: async () => { + const chunks = []; + for await (const chunk of res) chunks.push(chunk); + return Buffer.concat(chunks).toString(); + }, + json: async () => JSON.parse(await response.text()), + }; + resolve(response); + }); + + req.on("error", reject); + if (options.body) { + req.write(typeof options.body === "string" ? options.body : JSON.stringify(options.body)); + } + req.end(); + }); + + socket.on("error", reject); + }); +} + +export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { + const targetUrl = typeof url === "string" ? url : url.toString(); + + // Vercel relay: forward request via relay headers + const vercelRelayUrl = normalizeString(proxyOptions?.vercelRelayUrl); + if (vercelRelayUrl) { + const parsed = new URL(targetUrl); + const relayHeaders = { + ...options.headers, + "x-relay-target": `${parsed.protocol}//${parsed.host}`, + "x-relay-path": `${parsed.pathname}${parsed.search}`, + }; + return originalFetch(vercelRelayUrl, { ...options, headers: relayHeaders }); + } + + const connectionProxyUrls = resolveConnectionProxyUrls(targetUrl, proxyOptions); + const envProxyUrl = connectionProxyUrls.length > 0 ? null : normalizeProxyUrl(getEnvProxyUrl(targetUrl)); + const proxyUrls = connectionProxyUrls.length > 0 ? connectionProxyUrls : (envProxyUrl ? [envProxyUrl] : []); + + // MITM DNS bypass: for known MITM-intercepted hosts, resolve real IP to avoid DNS spoof + if (shouldBypassMitmDns(targetUrl)) { + if (proxyUrls.length > 0) { + let lastProxyError = null; + for (const proxyUrl of proxyUrls) { + try { + const dispatcher = await getDispatcher(proxyUrl); + const response = await originalFetch(url, { ...options, dispatcher }); + if (!shouldRetryProxyResponse(response)) return response; + } catch (proxyError) { + lastProxyError = proxyError; + if (proxyUrl === proxyUrls.at(-1)) break; + } + } + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct bypass: ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + // No proxy — manually resolve real IP to bypass DNS spoof + try { + const parsedUrl = new URL(targetUrl); + const realIP = await resolveRealIP(parsedUrl.hostname); + if (realIP) return await createBypassRequest(parsedUrl, realIP, options); + } catch (error) { + console.warn(`[ProxyFetch] MITM bypass failed: ${error.message}`); + } + } + + if (proxyUrls.length > 0) { + let lastProxyError = null; + for (const proxyUrl of proxyUrls) { + try { + const dispatcher = await getDispatcher(proxyUrl); + const response = await originalFetch(url, { ...options, dispatcher }); + if (!shouldRetryProxyResponse(response)) return response; + } catch (proxyError) { + lastProxyError = proxyError; + if (proxyUrl === proxyUrls.at(-1)) break; + } + } + + if (proxyOptions?.strictProxy === true) { + throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${lastProxyError?.message || "all proxies returned retryable responses"}`); + } + console.warn(`[ProxyFetch] Proxy failed, falling back to direct: ${lastProxyError?.message || "all proxies returned retryable responses"}`); + return originalFetch(url, options); + } + + // got-scraping disabled — use native fetch directly + // (Re-enable per-host by wrapping with tryGotScrapingFetch when needed) + return originalFetch(url, options); +} + +/** + * Patched global fetch with env-proxy support and MITM DNS bypass + */ +async function patchedFetch(url, options = {}) { + return proxyAwareFetch(url, options, null); +} + +// Idempotency guard — only patch once to avoid wrapping multiple times +if (globalThis.fetch !== patchedFetch) { + globalThis.fetch = patchedFetch; +} + +export default patchedFetch; diff --git a/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index 6b4184e..52a7542 100644 --- a/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/app/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -4,10 +4,10 @@ import { useState } from "react"; import { Button, Badge, Input, Modal, Select } from "@/shared/components"; import { AI_PROVIDERS } from "@/shared/constants/providers"; -const BULK_PLACEHOLDER = `name1|sk-key1\nname2|sk-key2\nsk-key-only-auto-named`; +const BULK_PLACEHOLDER = `sk-key-1\nsk-key-2\nsk-key-3`; -function CloudflareConfigSection({ cloudflareData, setCloudflareData }) { - return ( +function CloudflareConfigSection({ cloudflareData, setCloudflareData }) { + return (

Cloudflare Workers AI

dash.cloudflare.com

- ); -} - - -function AzureConfigSection({ azureData, setAzureData }) { - return ( + ); +} + + +function AzureConfigSection({ azureData, setAzureData }) { + return (

Azure OpenAI Configuration

@@ -55,10 +55,10 @@ function AzureConfigSection({ azureData, setAzureData }) { />
- ); -} - - + ); +} + + export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, authType, authHint, website, proxyPools, error, onSave, onBulkDone, onClose }) { const NONE_PROXY_POOL_VALUE = "__none__"; const isOllamaLocal = provider === "ollama-local"; @@ -133,10 +133,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa const handleSubmit = async () => { if (!provider) return; if (!isOllamaLocal && !formData.apiKey) return; - if (!isOllamaLocal) { - // Non-ollama providers require a name - if (!formData.name) return; - } + const connectionName = formData.name.trim() || `${providerName || provider} Key`; if (isCompatible && !formData.defaultModel.trim()) return; setSaving(true); try { @@ -158,7 +155,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa setValidating(false); } await onSave({ - name: formData.name || (isOllamaLocal ? "Ollama Local" : ""), + name: connectionName, apiKey: formData.apiKey, defaultModel: isCompatible ? formData.defaultModel.trim() : undefined, priority: formData.priority, @@ -213,7 +210,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa {mode === "bulk" && (
-

One key per line. Format: name|apiKey or just apiKey (auto-named by index).

+

One API key per line. Optional: name|apiKey.