feat(proxy): implement TLS fingerprint spoofing via wreq-js

- Add TlsClient module (Chrome 124 fingerprint via wreq-js)
- Integrate TLS client as opt-in layer in proxyFetch.js (ENABLE_TLS_FINGERPRINT)
- Add per-request TLS tracking via AsyncLocalStorage
- Add TLS fingerprint column and badge to Proxy Logger dashboard
- Add TLS Fingerprint section to ProxyLogDetail modal
- Add wreq-js dependency
- Document ENABLE_TLS_FINGERPRINT in .env.example

Adapted from upstream PR decolua/9router#137, preserving all
OmniRoute-specific proxy features (AsyncLocalStorage context,
proxyDispatcher, SOCKS5, Symbol-based patch state).
This commit is contained in:
diegosouzapw
2026-02-16 17:12:24 -03:00
parent a1137c1fa1
commit 127cdc9143
8 changed files with 325 additions and 31 deletions

View File

@@ -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

View File

@@ -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;

View File

@@ -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();

View File

@@ -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"
},

View File

@@ -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

View File

@@ -139,6 +139,21 @@ export default function ProxyLogDetail({ log, onClose }) {
<div className="text-sm text-text-muted"></div>
)}
</div>
<div>
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
TLS Fingerprint
</div>
{log.tlsFingerprint ? (
<span
className="inline-flex items-center gap-1 px-2.5 py-1 rounded text-[10px] font-bold uppercase"
style={{ backgroundColor: "rgba(6, 182, 212, 0.15)", color: "#22d3ee" }}
>
<span style={{ fontSize: "12px" }}>🔒</span> Chrome 124
</span>
) : (
<div className="text-sm text-text-muted">Direct (native)</div>
)}
</div>
<div className="col-span-2">
<div className="text-[10px] text-text-muted uppercase tracking-wider mb-1">
Target URL

View File

@@ -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 (
<div className="flex flex-col gap-4">
@@ -243,6 +245,11 @@ export default function ProxyLogger() {
{directCount} direct
</span>
)}
{tlsCount > 0 && (
<span className="px-2 py-1 rounded bg-cyan-500/10 text-cyan-400 font-mono">
🔒 {tlsCount} TLS
</span>
)}
</div>
{/* Sort */}
@@ -369,6 +376,11 @@ export default function ProxyLogger() {
Proxy
</th>
)}
{visibleColumns.tls && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
TLS
</th>
)}
{visibleColumns.type && (
<th className="px-3 py-2.5 font-semibold text-text-muted uppercase tracking-wider text-[10px]">
Type
@@ -443,6 +455,24 @@ export default function ProxyLogger() {
{log.proxy ? `${log.proxy.host}:${log.proxy.port}` : "—"}
</td>
)}
{visibleColumns.tls && (
<td className="px-3 py-2">
{log.tlsFingerprint ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[9px] font-bold uppercase"
style={{
backgroundColor: "rgba(6, 182, 212, 0.15)",
color: "#22d3ee",
}}
title="Chrome 124 TLS Fingerprint"
>
<span style={{ fontSize: "10px" }}>🔒</span> TLS
</span>
) : (
<span className="text-text-muted text-[10px]"></span>
)}
</td>
)}
{visibleColumns.type && (
<td className="px-3 py-2">
<span

View File

@@ -16,7 +16,11 @@ import {
getModelTargetFormat,
PROVIDER_ID_TO_ALIAS,
} from "@omniroute/open-sse/config/providerModels.js";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.js";
import {
runWithProxyContext,
runWithTlsTracking,
isTlsFingerprintActive,
} from "@omniroute/open-sse/utils/proxyFetch.js";
import * as log from "../utils/logger.js";
import { updateProviderCredentials, checkAndRefreshToken } from "../services/tokenRefresh.js";
import { getSettings, getCombos, getApiKeyMetadata } from "@/lib/localDb.js";
@@ -192,7 +196,15 @@ export async function handleChat(request, clientRawRequest = null) {
telemetry.endPhase();
// Single model request
const response = await handleSingleModelChat(body, modelStr, clientRawRequest, request, null, apiKeyInfo, telemetry);
const response = await handleSingleModelChat(
body,
modelStr,
clientRawRequest,
request,
null,
apiKeyInfo,
telemetry
);
recordTelemetry(telemetry);
return response;
}
@@ -244,7 +256,11 @@ async function handleSingleModelChat(
// Pipeline: Check model availability (TTL cooldown)
if (!isModelAvailable(provider, model)) {
log.warn("AVAILABILITY", `${provider}/${model} is in cooldown, rejecting request`);
return unavailableResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, `Model ${provider}/${model} is temporarily unavailable (cooldown)`, 30);
return unavailableResponse(
HTTP_STATUS.SERVICE_UNAVAILABLE,
`Model ${provider}/${model} is temporarily unavailable (cooldown)`,
30
);
}
// Pipeline: Check circuit breaker for this provider
@@ -255,7 +271,11 @@ async function handleSingleModelChat(
});
if (!breaker.canExecute()) {
log.warn("CIRCUIT", `Circuit breaker OPEN for ${provider}, rejecting request`);
return unavailableResponse(HTTP_STATUS.SERVICE_UNAVAILABLE, `Provider ${provider} circuit breaker is open`, 30);
return unavailableResponse(
HTTP_STATUS.SERVICE_UNAVAILABLE,
`Provider ${provider} circuit breaker is open`,
30
);
}
const userAgent = request?.headers?.get("user-agent") || "";
@@ -270,7 +290,14 @@ async function handleSingleModelChat(
// All accounts unavailable — return error
if (!credentials || credentials.allRateLimited) {
return handleNoCredentials(credentials, excludeConnectionId, provider, model, lastError, lastStatus);
return handleNoCredentials(
credentials,
excludeConnectionId,
provider,
model,
lastError,
lastStatus
);
}
const accountId = credentials.connectionId.slice(0, 8);
@@ -283,30 +310,52 @@ async function handleSingleModelChat(
// 3. Execute chat via core (with circuit breaker)
if (telemetry) telemetry.startPhase("connect");
let result;
let tlsFingerprintUsed = false;
try {
result = await breaker.execute(() =>
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 {}
}