mirror of
https://github.com/decolua/9router.git
synced 2026-05-08 12:01:28 +00:00
- Add claudeHeaderCache.js to intercept and cache live Claude Code client headers - Forward cached headers dynamically to api.anthropic.com via default.js - Strip first-party identity headers (x-app, claude-code-* beta) for non-Anthropic upstreams - Validate and sanitize tool call IDs to match Anthropic pattern (^[a-zA-Z0-9_-]+$) - Skip thinking blocks when applying cache_control; fix max_tokens buffer (+1024) - Strip cache_control from thinking blocks in openai-to-claude translator - Comment out thoughtSignature in Gemini translator (kept for reference) - Expand .gitignore to match all deploy*.sh variants Co-authored-by: kwanLeeFrmVi <quanle96@outlook.com> Closes #433 Made-with: Cursor
259 lines
8.5 KiB
JavaScript
259 lines
8.5 KiB
JavaScript
import { Readable } from "stream";
|
|
import { MEMORY_CONFIG } from "../config/runtimeConfig.js";
|
|
|
|
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
|
|
|
const originalFetch = globalThis.fetch;
|
|
const proxyDispatchers = new Map();
|
|
|
|
// 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(",").map((p) => p.trim().toLowerCase()).filter(Boolean);
|
|
|
|
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 = await import("https");
|
|
const netModule = await 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,
|
|
servername: parsedUrl.hostname,
|
|
rejectUnauthorized: false,
|
|
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();
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
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 (!isCloud && globalThis.fetch !== patchedFetch) {
|
|
globalThis.fetch = patchedFetch;
|
|
}
|
|
|
|
export default isCloud ? originalFetch : patchedFetch;
|