c34b3f41bd
Build Electron Desktop App / Validate version (push) Failing after 38s
Build Electron Desktop App / Build Electron (macos-arm64) (push) Has been skipped
Build Electron Desktop App / Build Electron (linux) (push) Has been skipped
Build Electron Desktop App / Build Electron (macos-intel) (push) Has been skipped
Build Electron Desktop App / Build Electron (windows) (push) Has been skipped
Build Electron Desktop App / Create Release (push) Has been skipped
233 lines
6.8 KiB
TypeScript
233 lines
6.8 KiB
TypeScript
import { AsyncLocalStorage } from "node:async_hooks";
|
|
import {
|
|
createProxyDispatcher,
|
|
normalizeProxyUrl,
|
|
proxyConfigToUrl,
|
|
proxyUrlForLogs,
|
|
} from "./proxyDispatcher.ts";
|
|
import tlsClient from "./tlsClient.ts";
|
|
import { isProxyReachable } from "@/lib/proxyHealth";
|
|
|
|
function isTlsFingerprintEnabled() {
|
|
return process.env.ENABLE_TLS_FINGERPRINT === "true";
|
|
}
|
|
|
|
/** Per-request tracking of whether TLS fingerprint was used */
|
|
type TlsFingerprintStore = { used: boolean };
|
|
const tlsFingerprintContext = new AsyncLocalStorage<TlsFingerprintStore>();
|
|
|
|
type FetchWithDispatcherOptions = RequestInit & { dispatcher?: unknown };
|
|
type FetchWithDispatcher = (
|
|
input: RequestInfo | URL,
|
|
init?: FetchWithDispatcherOptions
|
|
) => Promise<Response>;
|
|
|
|
type PatchState = {
|
|
originalFetch: typeof globalThis.fetch;
|
|
proxyContext: AsyncLocalStorage<unknown>;
|
|
isPatched: boolean;
|
|
};
|
|
|
|
const isCloud = typeof caches !== "undefined" && typeof caches === "object";
|
|
const PATCH_STATE_KEY = Symbol.for("omniroute.proxyFetch.state");
|
|
|
|
function getPatchState(): PatchState {
|
|
const scopedGlobal = globalThis as typeof globalThis & {
|
|
[PATCH_STATE_KEY]?: PatchState;
|
|
};
|
|
|
|
if (!scopedGlobal[PATCH_STATE_KEY]) {
|
|
scopedGlobal[PATCH_STATE_KEY] = {
|
|
originalFetch: globalThis.fetch,
|
|
proxyContext: new AsyncLocalStorage(),
|
|
isPatched: false,
|
|
};
|
|
}
|
|
return scopedGlobal[PATCH_STATE_KEY];
|
|
}
|
|
|
|
const patchState = getPatchState();
|
|
const originalFetch = patchState.originalFetch;
|
|
const originalFetchWithDispatcher = originalFetch as FetchWithDispatcher;
|
|
const proxyContext = patchState.proxyContext;
|
|
|
|
function noProxyMatch(targetUrl) {
|
|
const noProxy = process.env.NO_PROXY || process.env.no_proxy;
|
|
if (!noProxy) return false;
|
|
|
|
let target;
|
|
try {
|
|
target = new URL(targetUrl);
|
|
} catch {
|
|
return false;
|
|
}
|
|
|
|
const hostname = target.hostname.toLowerCase();
|
|
const port = target.port || (target.protocol === "https:" ? "443" : "80");
|
|
const patterns = noProxy
|
|
.split(",")
|
|
.map((p) => p.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
|
|
return patterns.some((pattern) => {
|
|
if (pattern === "*") return true;
|
|
|
|
const [patternHost, patternPort] = pattern.split(":");
|
|
if (patternPort && patternPort !== port) return false;
|
|
|
|
if (!patternHost) return false;
|
|
if (patternHost.startsWith(".")) {
|
|
return hostname.endsWith(patternHost) || hostname === patternHost.slice(1);
|
|
}
|
|
return hostname === patternHost || hostname.endsWith(`.${patternHost}`);
|
|
});
|
|
}
|
|
|
|
function resolveEnvProxyUrl(targetUrl) {
|
|
if (noProxyMatch(targetUrl)) return null;
|
|
|
|
let protocol;
|
|
try {
|
|
protocol = new URL(targetUrl).protocol;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const proxyUrl =
|
|
protocol === "https:"
|
|
? process.env.HTTPS_PROXY ||
|
|
process.env.https_proxy ||
|
|
process.env.ALL_PROXY ||
|
|
process.env.all_proxy
|
|
: process.env.HTTP_PROXY ||
|
|
process.env.http_proxy ||
|
|
process.env.ALL_PROXY ||
|
|
process.env.all_proxy;
|
|
|
|
if (!proxyUrl) return null;
|
|
return normalizeProxyUrl(proxyUrl, "environment proxy");
|
|
}
|
|
|
|
function resolveProxyForRequest(targetUrl) {
|
|
const contextProxy = proxyContext.getStore();
|
|
if (contextProxy) {
|
|
return { source: "context", proxyUrl: proxyConfigToUrl(contextProxy) };
|
|
}
|
|
|
|
const envProxyUrl = resolveEnvProxyUrl(targetUrl);
|
|
if (envProxyUrl) {
|
|
return { source: "env", proxyUrl: envProxyUrl };
|
|
}
|
|
|
|
return { source: "direct", proxyUrl: null };
|
|
}
|
|
|
|
function getTargetUrl(input) {
|
|
if (typeof input === "string") return input;
|
|
if (input && typeof input.url === "string") return input.url;
|
|
return String(input);
|
|
}
|
|
|
|
export async function runWithProxyContext(proxyConfig, fn) {
|
|
if (typeof fn !== "function") {
|
|
throw new TypeError("runWithProxyContext requires a callback function");
|
|
}
|
|
|
|
const resolvedProxyUrl = proxyConfig ? proxyConfigToUrl(proxyConfig) : null;
|
|
|
|
// T14: Proxy Fast-Fail
|
|
// Perform a short TCP reachability check before issuing upstream requests.
|
|
if (resolvedProxyUrl) {
|
|
const reachable = await isProxyReachable(resolvedProxyUrl);
|
|
if (!reachable) {
|
|
const proxyLabel = proxyUrlForLogs(resolvedProxyUrl);
|
|
const err = new Error(`[Proxy Fast-Fail] Proxy unreachable: ${proxyLabel}`) as Error & {
|
|
code?: string;
|
|
statusCode?: number;
|
|
};
|
|
err.code = "PROXY_UNREACHABLE";
|
|
err.statusCode = 503;
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
return proxyContext.run(proxyConfig || null, async () => {
|
|
if (resolvedProxyUrl) {
|
|
console.log(
|
|
`[ProxyFetch] Applied request proxy context: ${proxyUrlForLogs(resolvedProxyUrl)}`
|
|
);
|
|
}
|
|
return fn();
|
|
});
|
|
}
|
|
|
|
async function patchedFetch(input: RequestInfo | URL, options: FetchWithDispatcherOptions = {}) {
|
|
if (options?.dispatcher) {
|
|
return originalFetchWithDispatcher(input, options);
|
|
}
|
|
|
|
const targetUrl = getTargetUrl(input);
|
|
let resolved;
|
|
try {
|
|
resolved = resolveProxyForRequest(targetUrl);
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`[ProxyFetch] Proxy configuration error: ${message}`);
|
|
throw error;
|
|
}
|
|
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,
|
|
headers: options.headers,
|
|
});
|
|
} 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 originalFetchWithDispatcher(input, options);
|
|
}
|
|
|
|
try {
|
|
const dispatcher = createProxyDispatcher(proxyUrl);
|
|
return await originalFetchWithDispatcher(input, { ...options, dispatcher });
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
console.error(`[ProxyFetch] Proxy request failed (${source}, fail-closed): ${message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
if (!isCloud && !patchState.isPatched) {
|
|
globalThis.fetch = patchedFetch;
|
|
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;
|