diff --git a/.env.example b/.env.example index 953c1962fc..d0872ca263 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,11 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# TLS fingerprint spoofing (opt-in) — mimics Chrome 124 TLS handshake via wreq-js +# Reduces risk of JA3/JA4 fingerprint-based blocking by providers (e.g., Google) +# Requires wreq-js to be installed (included in dependencies) +# ENABLE_TLS_FINGERPRINT=true + # Optional CLI runtime overrides (Docker/host integration) # CLI_MODE=auto # CLI_EXTRA_PATHS=/host-cli/bin diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index 5c45b4f715..92a62c7497 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -5,6 +5,14 @@ import { proxyConfigToUrl, proxyUrlForLogs, } from "./proxyDispatcher.js"; +import tlsClient from "./tlsClient.js"; + +function isTlsFingerprintEnabled() { + return process.env.ENABLE_TLS_FINGERPRINT === "true"; +} + +/** Per-request tracking of whether TLS fingerprint was used */ +const tlsFingerprintContext = new AsyncLocalStorage(); const isCloud = typeof caches !== "undefined" && typeof caches === "object"; const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state"); @@ -135,6 +143,21 @@ async function patchedFetch(input, options = {}) { const { source, proxyUrl } = resolved; if (!proxyUrl) { + // TLS fingerprint spoofing for direct connections (no proxy configured) + if (isTlsFingerprintEnabled() && tlsClient.available) { + try { + const store = tlsFingerprintContext.getStore(); + if (store) store.used = true; + return await tlsClient.fetch(targetUrl, options); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + `[ProxyFetch] TLS fingerprint failed, falling back to native fetch: ${message}` + ); + const store = tlsFingerprintContext.getStore(); + if (store) store.used = false; + } + } return originalFetch(input, options); } @@ -153,4 +176,19 @@ if (!isCloud && !patchState.isPatched) { patchState.isPatched = true; } +/** + * Run a function with TLS fingerprint tracking context. + * After fn completes, returns { result, tlsFingerprintUsed }. + */ +export async function runWithTlsTracking(fn) { + const store = { used: false }; + const result = await tlsFingerprintContext.run(store, fn); + return { result, tlsFingerprintUsed: store.used }; +} + +/** Check if TLS fingerprint is enabled and available */ +export function isTlsFingerprintActive() { + return isTlsFingerprintEnabled() && tlsClient.available; +} + export default isCloud ? originalFetch : patchedFetch; diff --git a/open-sse/utils/tlsClient.js b/open-sse/utils/tlsClient.js new file mode 100644 index 0000000000..a8e08b4268 --- /dev/null +++ b/open-sse/utils/tlsClient.js @@ -0,0 +1,94 @@ +import { createRequire } from "module"; + +const require = createRequire(import.meta.url); + +let createSession; +try { + ({ createSession } = require("wreq-js")); +} catch { + createSession = null; +} + +/** + * Get proxy URL from environment variables. + * Priority: HTTPS_PROXY > HTTP_PROXY > ALL_PROXY + */ +function getProxyFromEnv() { + return ( + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy || + process.env.ALL_PROXY || + process.env.all_proxy || + undefined + ); +} + +/** + * TLS Client — Chrome 124 TLS fingerprint spoofing via wreq-js + * Singleton instance used to disguise Node.js TLS handshake as Chrome browser. + * + * wreq-js natively supports proxy — TLS fingerprinting works through proxy. + * Proxy URL is read from environment variables (HTTPS_PROXY, HTTP_PROXY, ALL_PROXY). + */ +class TlsClient { + constructor() { + this.session = null; + this.available = !!createSession; + } + + async getSession() { + if (!this.available) return null; + if (this.session) return this.session; + + const proxy = getProxyFromEnv(); + const sessionOpts = { + browser: "chrome_124", + os: "macos", + }; + if (proxy) { + sessionOpts.proxy = proxy; + console.log(`[TlsClient] Using proxy: ${proxy}`); + } + + this.session = await createSession(sessionOpts); + console.log("[TlsClient] Session created (Chrome 124 TLS fingerprint)"); + return this.session; + } + + /** + * Fetch with Chrome 124 TLS fingerprint. + * wreq-js Response is already fetch-compatible (headers, text(), json(), clone(), body). + */ + async fetch(url, options = {}) { + const session = await this.getSession(); + if (!session) throw new Error("wreq-js not available"); + + const method = (options.method || "GET").toUpperCase(); + + const wreqOptions = { + method, + headers: options.headers, + body: options.body, + redirect: options.redirect === "manual" ? "manual" : "follow", + }; + + // Pass signal through if available + if (options.signal) { + wreqOptions.signal = options.signal; + } + + const response = await session.fetch(url, wreqOptions); + return response; + } + + async exit() { + if (this.session) { + await this.session.close(); + this.session = null; + } + } +} + +export default new TlsClient(); diff --git a/package.json b/package.json index eef30d7b85..548624447a 100644 --- a/package.json +++ b/package.json @@ -83,6 +83,7 @@ "selfsigned": "^5.5.0", "undici": "^7.19.2", "uuid": "^13.0.0", + "wreq-js": "^2.0.1", "zod": "^4.3.6", "zustand": "^5.0.10" }, diff --git a/src/lib/proxyLogger.js b/src/lib/proxyLogger.js index e69b8d7f80..96ac258db2 100644 --- a/src/lib/proxyLogger.js +++ b/src/lib/proxyLogger.js @@ -38,6 +38,7 @@ export function logProxyEvent(entry) { connectionId: entry.connectionId || null, comboId: entry.comboId || null, account: entry.account || null, + tlsFingerprint: entry.tlsFingerprint || false, }; proxyLogs.unshift(log); // newest first diff --git a/src/shared/components/ProxyLogDetail.js b/src/shared/components/ProxyLogDetail.js index bf31f9e11d..5ab8008e71 100644 --- a/src/shared/components/ProxyLogDetail.js +++ b/src/shared/components/ProxyLogDetail.js @@ -139,6 +139,21 @@ export default function ProxyLogDetail({ log, onClose }) {
)} +
+
+ TLS Fingerprint +
+ {log.tlsFingerprint ? ( + + 🔒 Chrome 124 + + ) : ( +
Direct (native)
+ )} +
Target URL diff --git a/src/shared/components/ProxyLogger.js b/src/shared/components/ProxyLogger.js index e47a1cbcdd..052784b5c6 100644 --- a/src/shared/components/ProxyLogger.js +++ b/src/shared/components/ProxyLogger.js @@ -25,6 +25,7 @@ const STATUS_FILTERS = [ const COLUMNS = [ { key: "status", label: "Status" }, { key: "proxy", label: "Proxy" }, + { key: "tls", label: "TLS" }, { key: "type", label: "Type" }, { key: "level", label: "Level" }, { key: "provider", label: "Provider" }, @@ -141,6 +142,7 @@ export default function ProxyLogger() { const errorCount = logs.filter((l) => l.status === "error").length; const timeoutCount = logs.filter((l) => l.status === "timeout").length; const directCount = logs.filter((l) => l.level === "direct").length; + const tlsCount = logs.filter((l) => l.tlsFingerprint).length; return (
@@ -243,6 +245,11 @@ export default function ProxyLogger() { {directCount} direct )} + {tlsCount > 0 && ( + + 🔒 {tlsCount} TLS + + )}
{/* Sort */} @@ -369,6 +376,11 @@ export default function ProxyLogger() { Proxy )} + {visibleColumns.tls && ( + + TLS + + )} {visibleColumns.type && ( Type @@ -443,6 +455,24 @@ export default function ProxyLogger() { {log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"} )} + {visibleColumns.tls && ( + + {log.tlsFingerprint ? ( + + 🔒 TLS + + ) : ( + + )} + + )} {visibleColumns.type && ( + const chatFn = () => runWithProxyContext(proxyInfo?.proxy || null, () => handleChatCore({ body: { ...body, model: `${provider}/${model}` }, modelInfo: { provider, model }, - credentials: refreshedCredentials, log, clientRawRequest, - connectionId: credentials.connectionId, apiKeyInfo, userAgent, comboName, + credentials: refreshedCredentials, + log, + clientRawRequest, + connectionId: credentials.connectionId, + apiKeyInfo, + userAgent, + comboName, onCredentialsRefreshed: async (newCreds) => { await updateProviderCredentials(credentials.connectionId, { - accessToken: newCreds.accessToken, refreshToken: newCreds.refreshToken, - providerSpecificData: newCreds.providerSpecificData, testStatus: "active", + accessToken: newCreds.accessToken, + refreshToken: newCreds.refreshToken, + providerSpecificData: newCreds.providerSpecificData, + testStatus: "active", }); }, onRequestSuccess: async () => { await clearAccountError(credentials.connectionId, credentials); }, }) - ) - ); + ); + + // Wrap with TLS tracking when no proxy and TLS fingerprint is active + if (!proxyInfo?.proxy && isTlsFingerprintActive()) { + const tracked = await breaker.execute(async () => { + return await runWithTlsTracking(chatFn); + }); + result = tracked.result; + tlsFingerprintUsed = tracked.tlsFingerprintUsed; + } else { + result = await breaker.execute(chatFn); + } } catch (cbErr) { if (cbErr instanceof CircuitBreakerOpenError) { log.warn("CIRCUIT", `${provider} circuit open during retry: ${cbErr.message}`); - return unavailableResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, `Provider ${provider} circuit breaker is open`, Math.ceil(cbErr.retryAfterMs / 1000)); + return unavailableResponse( + HTTP_STATUS.SERVICE_UNAVAILABLE, + `Provider ${provider} circuit breaker is open`, + Math.ceil(cbErr.retryAfterMs / 1000) + ); } throw cbErr; } @@ -315,14 +364,27 @@ async function handleSingleModelChat( const proxyLatency = Date.now() - proxyStartTime; // 4. Log proxy + translation events (fire-and-forget) - safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest }); + safeLogEvents({ + result, + proxyInfo, + proxyLatency, + provider, + model, + sourceFormat, + targetFormat, + credentials, + comboName, + clientRawRequest, + tlsFingerprintUsed, + }); if (result.success) { // Pipeline: Record cost on success if (apiKeyInfo?.id) { try { const usage = result.usage || {}; - const estimatedCost = ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; // rough estimate + const estimatedCost = + ((usage.prompt_tokens || 0) + (usage.completion_tokens || 0)) * 0.000001; // rough estimate if (estimatedCost > 0) recordCost(apiKeyInfo.id, estimatedCost); } catch {} } @@ -334,12 +396,18 @@ async function handleSingleModelChat( // Pipeline: Mark model unavailable on repeated failures (429, 503) if (result.status === 429 || result.status === 503) { setModelUnavailable(provider, model, 60000, `HTTP ${result.status}`); - log.info("AVAILABILITY", `${provider}/${model} marked unavailable for 60s (HTTP ${result.status})`); + log.info( + "AVAILABILITY", + `${provider}/${model} marked unavailable for 60s (HTTP ${result.status})` + ); } // 5. Fallback to next account const { shouldFallback } = await markAccountUnavailable( - credentials.connectionId, result.status, result.error, provider + credentials.connectionId, + result.status, + result.error, + provider ); if (shouldFallback) { @@ -356,19 +424,35 @@ async function handleSingleModelChat( // ──── Extracted helpers (T-28) ──── -function handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus) { +function handleNoCredentials( + credentials, + excludeConnectionId, + provider, + model, + lastError, + lastStatus +) { if (credentials?.allRateLimited) { const errorMsg = lastError || credentials.lastError || "Unavailable"; - const status = lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; + const status = + lastStatus || Number(credentials.lastErrorCode) || HTTP_STATUS.SERVICE_UNAVAILABLE; log.warn("CHAT", `[${provider}/${model}] ${errorMsg} (${credentials.retryAfterHuman})`); - return unavailableResponse(status, `[${provider}/${model}] ${errorMsg}`, credentials.retryAfter, credentials.retryAfterHuman); + return unavailableResponse( + status, + `[${provider}/${model}] ${errorMsg}`, + credentials.retryAfter, + credentials.retryAfterHuman + ); } if (!excludeConnectionId) { log.error("AUTH", `No credentials for provider: ${provider}`); return errorResponse(HTTP_STATUS.BAD_REQUEST, `No credentials for provider: ${provider}`); } log.warn("CHAT", "No more accounts available", { provider }); - return errorResponse(lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, lastError || "All accounts unavailable"); + return errorResponse( + lastStatus || HTTP_STATUS.SERVICE_UNAVAILABLE, + lastError || "All accounts unavailable" + ); } async function safeResolveProxy(connectionId) { @@ -380,25 +464,51 @@ async function safeResolveProxy(connectionId) { } } -function safeLogEvents({ result, proxyInfo, proxyLatency, provider, model, sourceFormat, targetFormat, credentials, comboName, clientRawRequest }) { +function safeLogEvents({ + result, + proxyInfo, + proxyLatency, + provider, + model, + sourceFormat, + targetFormat, + credentials, + comboName, + clientRawRequest, + tlsFingerprintUsed = false, +}) { try { logProxyEvent({ - status: result.success ? "success" : result.status === 408 || result.status === 504 ? "timeout" : "error", - proxy: proxyInfo?.proxy || null, level: proxyInfo?.level || "direct", - levelId: proxyInfo?.levelId || null, provider, targetUrl: `${provider}/${model}`, - latencyMs: proxyLatency, error: result.success ? null : result.error || null, - connectionId: credentials.connectionId, comboId: comboName || null, + status: result.success + ? "success" + : result.status === 408 || result.status === 504 + ? "timeout" + : "error", + proxy: proxyInfo?.proxy || null, + level: proxyInfo?.level || "direct", + levelId: proxyInfo?.levelId || null, + provider, + targetUrl: `${provider}/${model}`, + latencyMs: proxyLatency, + error: result.success ? null : result.error || null, + connectionId: credentials.connectionId, + comboId: comboName || null, account: credentials.connectionId?.slice(0, 8) || null, + tlsFingerprint: tlsFingerprintUsed, }); } catch {} try { logTranslationEvent({ - provider, model, sourceFormat, targetFormat, + provider, + model, + sourceFormat, + targetFormat, status: result.success ? "success" : "error", statusCode: result.success ? 200 : result.status || 500, - latency: proxyLatency, endpoint: clientRawRequest?.endpoint || "/v1/chat/completions", - connectionId: credentials.connectionId || null, comboName: comboName || null, + latency: proxyLatency, + endpoint: clientRawRequest?.endpoint || "/v1/chat/completions", + connectionId: credentials.connectionId || null, + comboName: comboName || null, }); } catch {} } -