diff --git a/.env.example b/.env.example index 5bede16ef5..6375a5a4b8 100644 --- a/.env.example +++ b/.env.example @@ -388,6 +388,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # ALL_PROXY=socks5://127.0.0.1:7890 # NO_PROXY=localhost,127.0.0.1 +# Proxy fail-open mode (default: false = fail-closed). +# When false, a request whose assigned proxy fails to resolve is REFUSED rather than +# falling back to a direct connection — prevents real-IP leaks in egress-controlled +# deployments. Set true to restore the legacy DIRECT fallback (legacy behaviour). +# Used by: src/sse/handlers/chatHelpers.ts +# PROXY_FAIL_OPEN=false + # 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). # Used by: open-sse/executors — replaces Node.js default TLS fingerprint. diff --git a/docs/bdd/proxy-egress-isolation.feature b/docs/bdd/proxy-egress-isolation.feature new file mode 100644 index 0000000000..5c980d4f01 --- /dev/null +++ b/docs/bdd/proxy-egress-isolation.feature @@ -0,0 +1,29 @@ +Feature: Proxy egress isolation and IPv6-only enforcement + As an operator routing provider traffic through proxies + I want every request to egress only through the assigned proxy (and only over IPv6 when an IPv6 proxy is set) + So that the real host IP and IPv4 are never leaked + + Scenario: IPv6-literal proxy de-brackets and connects over IPv6 + Given a proxy configured as socks5://[2001:db8::1]:1080 + When the dispatcher options are built + Then the SOCKS host is 2001:db8::1 and the resolved family is 6 + + Scenario: IPv6 hostname proxy forces IPv6-only egress + Given a proxy hostname with family=ipv6 + When the dispatcher family is resolved + Then the connect family is pinned to 6 + + Scenario: IPv6-only egress is fail-closed when no AAAA exists + Given a proxy hostname with family=ipv6 and no AAAA record + When the family is asserted + Then it is refused and never egresses over IPv4 + + Scenario: Family directive contradicting a literal is rejected + Given a proxy 203.0.113.7 with family=ipv6 + When the dispatcher family is resolved + Then it throws a configuration error + + Scenario: Web TLS clients are fail-closed + Given a web TLS client whose proxy resolution throws + When the proxy URL is resolved + Then it throws instead of connecting directly diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c2f98d23d9..65afb247da 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -271,12 +271,13 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | Variable | Default | Source File | Description | | --------------------------------------- | --------- | -------------------------------------------- | ----------------------------------------------------------------------------------------- | -| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. | +| `ENABLE_SOCKS5_PROXY` | `true` | `open-sse/executors` | Enable SOCKS5 proxy agent for upstream calls. Opt-out with `false`. | | `NEXT_PUBLIC_ENABLE_SOCKS5_PROXY` | `true` | Client-side | Client-side awareness of SOCKS5 availability. | | `HTTP_PROXY` | _(unset)_ | Node.js standard | HTTP proxy for upstream calls. | | `HTTPS_PROXY` | _(unset)_ | Node.js standard | HTTPS proxy for upstream calls. | | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | +| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. | | `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. | | `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. | @@ -287,6 +288,15 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | **SOCKS5 through SSH tunnel** | `ALL_PROXY=socks5://127.0.0.1:7890`, `ENABLE_SOCKS5_PROXY=true` | | **Corporate HTTP proxy** | `HTTP_PROXY=http://proxy.corp.com:3128`, `HTTPS_PROXY=http://proxy.corp.com:3128`, `NO_PROXY=localhost,internal.corp.com` | | **Anti-fingerprint** | `ENABLE_TLS_FINGERPRINT=true` — requires `wreq-js` (included) | +| **Egress-controlled / no direct access** | Leave `PROXY_FAIL_OPEN=false` (default). Requests fail hard when the proxy is unavailable instead of leaking via direct. | +| **Legacy / dev — allow direct fallback** | `PROXY_FAIL_OPEN=true`. Restores pre-hardening behaviour: direct connection used when proxy resolution fails. | + +> **Note (NVIDIA validation bypass — #3226):** NVIDIA's API-key validation endpoint +> stalls when routed through the global proxy/TLS-patched fetch (undici dispatcher → 504). +> `src/lib/providers/validation.ts::directHttpsRequest()` intentionally bypasses the +> proxy patch for that one validation call using `safeOutboundFetch({ bypassProxyPatch: true })`. +> This is a documented, scoped exception — it does **not** affect chat/usage egress. +> The bypass is scope-pinned by `tests/unit/proxy-bypass-scope-guard-3226.test.ts`. --- diff --git a/file-size-baseline.json b/file-size-baseline.json index 58a72e877e..bf4a3de655 100644 --- a/file-size-baseline.json +++ b/file-size-baseline.json @@ -81,8 +81,8 @@ "src/lib/db/migrationRunner.ts": 1100, "src/lib/db/models.ts": 1132, "src/lib/db/providers.ts": 1050, - "src/lib/db/proxies.ts": 1039, - "src/lib/db/settings.ts": 1108, + "src/lib/db/proxies.ts": 1048, + "src/lib/db/settings.ts": 1149, "src/lib/db/usageAnalytics.ts": 873, "src/lib/evals/evalRunner.ts": 961, "src/lib/memory/retrieval.ts": 1171, @@ -90,7 +90,7 @@ "src/lib/providers/validation.ts": 4302, "src/lib/tailscaleTunnel.ts": 1189, "src/lib/usage/callLogs.ts": 975, - "src/lib/usage/providerLimits.ts": 943, + "src/lib/usage/providerLimits.ts": 949, "src/lib/usage/usageHistory.ts": 854, "src/shared/components/OAuthModal.tsx": 956, "src/shared/components/RequestLoggerV2.tsx": 1276, diff --git a/open-sse/services/chatgptTlsClient.ts b/open-sse/services/chatgptTlsClient.ts index b5c3fdbf66..5b3276684f 100644 --- a/open-sse/services/chatgptTlsClient.ts +++ b/open-sse/services/chatgptTlsClient.ts @@ -212,23 +212,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://chatgpt.com"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://chatgpt.com", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/claudeTlsClient.ts b/open-sse/services/claudeTlsClient.ts index c061ec4c2d..8674b9f98b 100644 --- a/open-sse/services/claudeTlsClient.ts +++ b/open-sse/services/claudeTlsClient.ts @@ -212,23 +212,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://claude.ai"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://claude.ai", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/grokTlsClient.ts b/open-sse/services/grokTlsClient.ts index d0e51f602d..198ed9e479 100644 --- a/open-sse/services/grokTlsClient.ts +++ b/open-sse/services/grokTlsClient.ts @@ -205,23 +205,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://grok.com"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://grok.com", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/perplexityTlsClient.ts b/open-sse/services/perplexityTlsClient.ts index debcd3e682..bdad1f1e87 100644 --- a/open-sse/services/perplexityTlsClient.ts +++ b/open-sse/services/perplexityTlsClient.ts @@ -203,23 +203,19 @@ export interface TlsFetchOptions { } import { resolveProxyForRequest } from "../utils/proxyFetch.ts"; +import { resolveTlsClientProxyUrl } from "./tlsClientProxy.ts"; /** * Resolve the proxy URL for a tls-client request. Per-call value wins; * otherwise we use the standard proxy fetch resolution which reads from * the dashboard AsyncLocalStorage context or falls back to env vars. + * + * Fail-closed: if resolution throws (e.g. a configured socks5 proxy with + * ENABLE_SOCKS5_PROXY=false), this rethrows rather than returning undefined — + * undefined would let the native binding connect directly and leak the real IP. */ function resolveProxyUrl(perCall: string | undefined): string | undefined { - if (perCall && perCall.length > 0) return perCall; - try { - const proxyInfo = resolveProxyForRequest("https://www.perplexity.ai"); - if (proxyInfo && proxyInfo.proxyUrl) { - return proxyInfo.proxyUrl; - } - } catch { - // Ignore resolution errors - } - return undefined; + return resolveTlsClientProxyUrl("https://www.perplexity.ai", perCall, resolveProxyForRequest); } export interface TlsFetchResult { diff --git a/open-sse/services/tlsClientProxy.ts b/open-sse/services/tlsClientProxy.ts new file mode 100644 index 0000000000..759df32909 --- /dev/null +++ b/open-sse/services/tlsClientProxy.ts @@ -0,0 +1,30 @@ +type ResolveProxyForRequest = ( + targetUrl: string +) => { source: string; proxyUrl: string | null } | null; + +/** + * Fail-closed proxy resolution for the impersonation TLS clients. + * - per-call override wins. + * - resolution returning a proxy → use it. + * - resolution returning direct/null → undefined (direct is legitimate: no proxy set). + * - resolution THROWING → rethrow. A configured-but-unusable proxy (e.g. socks5 with + * ENABLE_SOCKS5_PROXY=false) MUST NOT silently leak the real IP via a direct connection. + */ +export function resolveTlsClientProxyUrl( + targetUrl: string, + perCall: string | undefined, + resolveProxyForRequest: ResolveProxyForRequest +): string | undefined { + if (perCall && perCall.length > 0) return perCall; + let info: { source: string; proxyUrl: string | null } | null; + try { + info = resolveProxyForRequest(targetUrl); + } catch (err) { + throw new Error( + `[TlsClient] Proxy resolution failed for ${targetUrl}; refusing direct connection (fail-closed): ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + return info && info.proxyUrl ? info.proxyUrl : undefined; +} diff --git a/open-sse/utils/proxyDispatcher.ts b/open-sse/utils/proxyDispatcher.ts index 4b49eaf770..8f3c972f6b 100644 --- a/open-sse/utils/proxyDispatcher.ts +++ b/open-sse/utils/proxyDispatcher.ts @@ -2,6 +2,8 @@ import "./setupPolyfill.ts"; import { Agent, ProxyAgent, type Dispatcher } from "undici"; import { socksDispatcher } from "fetch-socks"; import { getUpstreamTimeoutConfig } from "@/shared/utils/runtimeTimeouts"; +import { stripIpv6Brackets, detectIpLiteralFamily, parseProxyFamily } from "./proxyFamily.ts"; +import { createSocksDispatcherWithFamily } from "./socksConnectorWithFamily.ts"; const DISPATCHER_CACHE_KEY = Symbol.for("omniroute.proxyDispatcher.cache"); const DEFAULT_DISPATCHER_KEY = Symbol.for("omniroute.proxyDispatcher.default"); @@ -25,6 +27,7 @@ type ProxyConfigObject = { port?: string | number | null; username?: string; password?: string; + family?: string; }; function getDispatcherCache(): DispatcherCache { @@ -161,14 +164,24 @@ export function normalizeProxyUrl( source = "proxy", { allowSocks5 = isSocks5ProxyEnabled() } = {} ): string { + // Strip a trailing synthetic `?family=ipv4|ipv6` marker BEFORE anything else. + // `extractExplicitPort` slices the authority off the raw string, so a marker + // turns the port substring into e.g. `80?family=ipv6`, which fails the digit + // test and silently falls back to the default port (8080 for http) — rewriting + // an http:80 proxy to :8080. We work on the marker-free string for both port + // extraction and URL parsing, then re-append the marker exactly once below. + const familyMatch = proxyUrl.match(/\?family=(ipv4|ipv6)$/); + const familySuffix = familyMatch ? familyMatch[0] : ""; + const baseUrl = familySuffix ? proxyUrl.slice(0, -familySuffix.length) : proxyUrl; + // Extract the explicit port from the raw URL string BEFORE parsing, // because `new URL()` silently strips default ports (80 for http, // 443 for https), which are valid and common for proxy servers. - const explicitPort = extractExplicitPort(proxyUrl); + const explicitPort = extractExplicitPort(baseUrl); let parsed; try { - parsed = new URL(proxyUrl); + parsed = new URL(baseUrl); } catch { throw new Error(`[ProxyDispatcher] Invalid ${source} URL`); } @@ -192,7 +205,15 @@ export function normalizeProxyUrl( // Build the URL string manually instead of using parsed.toString(), // which would strip default ports (80/443) and break the proxy connection. - return buildProxyUrlString(parsed, port); + // Preserve a synthetic `?family=` directive (the only query param we emit) + // so the connect-family pin survives normalization and reaches the dispatcher. + // The directive may arrive either as the stripped trailing marker (familySuffix) + // or as an inline query on `baseUrl`; resolve both, append the marker once. + const fam = parseProxyFamily( + (familyMatch ? familyMatch[1] : parsed.searchParams.get("family")) ?? undefined + ); + const base = buildProxyUrlString(parsed, port); + return fam === "auto" ? base : `${base}?family=${fam}`; } export function buildVercelRelayHeaders( @@ -253,7 +274,28 @@ export function proxyConfigToUrl( const proxyUrlStr = `${type}://${auth}${config.host}:${port}`; - return normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 }); + const fam = parseProxyFamily(config.family); + const normalized = normalizeProxyUrl(proxyUrlStr, "context proxy", { allowSocks5 }); + return fam === "auto" ? normalized : `${normalized}?family=${fam}`; +} + +/** Resolve the concrete connect family for a proxy URL, fail-closed on contradictions. */ +function resolveDispatcherFamily(parsed: URL): 4 | 6 | null { + const directive = parseProxyFamily(parsed.searchParams.get("family") ?? undefined); + const literal = detectIpLiteralFamily(parsed.hostname); + if (directive === "auto") return literal; + const want = directive === "ipv6" ? 6 : 4; + if (literal !== null && literal !== want) { + throw new Error( + `[ProxyDispatcher] Proxy family directive ${directive} contradicts ${literal === 6 ? "IPv6" : "IPv4"} literal host` + ); + } + return want; +} + +/** Test-only accessor for the resolved family. */ +export function __resolveDispatcherFamilyForTest(proxyUrl: string): 4 | 6 | null { + return resolveDispatcherFamily(new URL(proxyUrl)); } export function createProxyDispatcher(proxyUrl: string): Dispatcher { @@ -265,28 +307,64 @@ export function createProxyDispatcher(proxyUrl: string): Dispatcher { if (dispatcher) return dispatcher; const parsed = new URL(normalizedUrl); - const explicitPort = extractExplicitPort(normalizedUrl); + const family = resolveDispatcherFamily(parsed); + parsed.searchParams.delete("family"); + const cleanUri = normalizedUrl.replace(/\?family=(ipv4|ipv6)$/, ""); + const explicitPort = extractExplicitPort(cleanUri); const port = explicitPort || normalizePort(parsed.port, parsed.protocol); if (parsed.protocol === "socks5:") { const socksOptions: SocksDispatcherOptions = { type: 5, - host: parsed.hostname, + host: stripIpv6Brackets(parsed.hostname), port: Number(port), }; if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); - dispatcher = socksDispatcher( - socksOptions as Parameters[0], - proxyDispatcherOptions - ) as Dispatcher; + dispatcher = + family === null + ? (socksDispatcher( + socksOptions as Parameters[0], + proxyDispatcherOptions + ) as Dispatcher) + : createSocksDispatcherWithFamily( + socksOptions as unknown as Parameters[0], + family, + proxyDispatcherOptions + ); } else { + // ProxyAgent omits `connect`; the client->proxy socket is built from `proxyTls`. + // undici 8.4.1 types `proxyTls?: buildConnector.BuildOptions`, a union whose + // `TcpNetConnectOpts` member nominally requires `port` — so TS rejects a bare + // `{ family, autoSelectFamily }` pin. At runtime undici merges these options into + // net.connect (the uri already carries the host:port), so the partial pin is + // valid; the cast suppresses the spurious missing-`port` error. dispatcher = new ProxyAgent({ - uri: normalizedUrl, + uri: cleanUri, ...proxyDispatcherOptions, + ...(family !== null + ? { proxyTls: { family, autoSelectFamily: false } as ProxyAgent.Options["proxyTls"] } + : {}), }); } dispatcherCache.set(normalizedUrl, dispatcher); return dispatcher; } + +/** Test-only: returns the SOCKS dispatcher options that would be built for a URL. */ +export function __getSocksOptionsForTest(proxyUrl: string): SocksDispatcherOptions { + const normalizedUrl = normalizeProxyUrl(proxyUrl, "proxy dispatcher"); + const parsed = new URL(normalizedUrl); + parsed.searchParams.delete("family"); + const explicitPort = extractExplicitPort(normalizedUrl); + const port = explicitPort || normalizePort(parsed.port, parsed.protocol); + const socksOptions: SocksDispatcherOptions = { + type: 5, + host: stripIpv6Brackets(parsed.hostname), + port: Number(port), + }; + if (parsed.username) socksOptions.userId = decodeURIComponent(parsed.username); + if (parsed.password) socksOptions.password = decodeURIComponent(parsed.password); + return socksOptions; +} diff --git a/open-sse/utils/proxyFamily.ts b/open-sse/utils/proxyFamily.ts new file mode 100644 index 0000000000..f6694f73c8 --- /dev/null +++ b/open-sse/utils/proxyFamily.ts @@ -0,0 +1,24 @@ +import { isIP } from "node:net"; + +export type ProxyFamily = "auto" | "ipv4" | "ipv6"; + +/** Remove the surrounding brackets from an IPv6 literal host (`[::1]` -> `::1`). */ +export function stripIpv6Brackets(host: string): string { + if (typeof host !== "string") return ""; + if (host.startsWith("[") && host.endsWith("]")) { + return host.slice(1, -1); + } + return host; +} + +/** 4 / 6 if the host is an IP literal (brackets tolerated), null if it is a hostname. */ +export function detectIpLiteralFamily(host: string): 4 | 6 | null { + const bare = stripIpv6Brackets(host); + const v = isIP(bare); + return v === 0 ? null : (v as 4 | 6); +} + +/** Normalize a stored family directive; anything unknown means "auto". */ +export function parseProxyFamily(value: unknown): ProxyFamily { + return value === "ipv4" || value === "ipv6" ? value : "auto"; +} diff --git a/open-sse/utils/proxyFamilyResolve.ts b/open-sse/utils/proxyFamilyResolve.ts new file mode 100644 index 0000000000..2b18e0849d --- /dev/null +++ b/open-sse/utils/proxyFamilyResolve.ts @@ -0,0 +1,39 @@ +import dns from "node:dns/promises"; +import { detectIpLiteralFamily, stripIpv6Brackets } from "./proxyFamily.ts"; + +export type FamilyLookupFn = ( + hostname: string +) => Promise>; + +const defaultLookup: FamilyLookupFn = (hostname) => dns.lookup(hostname, { all: true }); + +/** + * Fail-closed guarantee for an IPv6-only (or IPv4-only) proxy given as a hostname: + * refuse early if the hostname has no record in the required family. No-op for IP + * literals (their family is intrinsic). + */ +export async function assertHostnameSupportsFamily( + host: string, + family: 4 | 6, + lookupFn: FamilyLookupFn = defaultLookup +): Promise { + if (detectIpLiteralFamily(host) !== null) return; + let records: Array<{ address: string; family: number }>; + try { + records = await lookupFn(stripIpv6Brackets(host)); + } catch (err) { + throw new Error( + `[ProxyFamily] DNS resolution failed for ${host}; refusing to egress (fail-closed): ${ + err instanceof Error ? err.message : String(err) + }` + ); + } + const hasFamily = records.some((r) => r.family === family); + if (!hasFamily) { + throw new Error( + `[ProxyFamily] Proxy host ${host} has no ${family === 6 ? "IPv6 (AAAA)" : "IPv4 (A)"} record; refusing ${ + family === 6 ? "IPv6" : "IPv4" + }-only egress (fail-closed)` + ); + } +} diff --git a/open-sse/utils/proxyFetch.ts b/open-sse/utils/proxyFetch.ts index 5865ab676b..98b0ff6421 100644 --- a/open-sse/utils/proxyFetch.ts +++ b/open-sse/utils/proxyFetch.ts @@ -231,6 +231,26 @@ export async function runWithProxyContext(proxyConfig, fn) { } } + // Fail-closed family check: when the proxy URL carries a ?family=ipv6|ipv4 marker + // (set for HOSTNAME proxies by proxyConfigToUrl), verify the hostname actually has a + // record in that family before egressing. Refuse early rather than silently fall back + // to the other family. No-op for IP literals (their family is intrinsic). + if (resolvedProxyUrl && !isVercelRelay) { + try { + const u = new URL(resolvedProxyUrl); + const fam = u.searchParams.get("family"); + if (fam === "ipv6" || fam === "ipv4") { + const { assertHostnameSupportsFamily } = await import("./proxyFamilyResolve.ts"); + await assertHostnameSupportsFamily(u.hostname, fam === "ipv6" ? 6 : 4); + } + } catch (familyErr) { + const e = familyErr as Error & { code?: string; statusCode?: number }; + e.code = e.code || "PROXY_FAMILY_UNAVAILABLE"; + e.statusCode = e.statusCode || 503; + throw e; + } + } + return proxyContext.run(effectiveProxyConfig, async () => { if (resolvedProxyUrl && effectiveProxyConfig !== currentContext) { console.log( diff --git a/open-sse/utils/socksConnectorWithFamily.ts b/open-sse/utils/socksConnectorWithFamily.ts new file mode 100644 index 0000000000..8cf71acd3f --- /dev/null +++ b/open-sse/utils/socksConnectorWithFamily.ts @@ -0,0 +1,68 @@ +import { Agent, buildConnector, type Dispatcher } from "undici"; +import { SocksClient, type SocksProxy } from "socks"; + +/** The net.connect family options pinned for the SOCKS proxy hop. */ +export function buildSocksFamilySocketOptions(family: 4 | 6 | null): Record { + if (family === 6) return { family: 6, autoSelectFamily: false }; + if (family === 4) return { family: 4, autoSelectFamily: false }; + return {}; +} + +function resolvePort(protocol: string, port: string): number { + return port ? Number.parseInt(port, 10) : protocol === "http:" ? 80 : 443; +} + +/** + * Undici connector that tunnels through a single SOCKS5 proxy, pinning the family + * of the TCP connection to the proxy host when `family` is set. Mirrors fetch-socks' + * socksConnector but threads `socket_options` (which fetch-socks does not expose) + * into SocksClient so Happy Eyeballs cannot pick IPv4 for an IPv6-only egress policy. + */ +function socksConnectorWithFamily( + proxy: SocksProxy, + family: 4 | 6 | null, + tlsOpts: buildConnector.BuildOptions = {} +): buildConnector.connector { + const undiciConnect = buildConnector(tlsOpts); + const socketOptions = buildSocksFamilySocketOptions(family); + return async (options, callback) => { + const { protocol, hostname, port, httpSocket } = options as unknown as { + protocol: string; + hostname: string; + port: string; + httpSocket?: unknown; + }; + try { + const r = await SocksClient.createConnection({ + command: "connect", + proxy, + timeout: 10_000, + destination: { host: hostname, port: resolvePort(protocol, port) }, + existing_socket: httpSocket as never, + socket_options: socketOptions as never, + }); + const sock = r.socket; + if (protocol !== "https:") { + return callback(null, (sock as { setNoDelay: () => unknown }).setNoDelay() as never); + } + return undiciConnect({ ...options, httpSocket: sock } as never, callback); + } catch (error) { + return callback(error as Error, null); + } + }; +} + +/** Build an undici Agent dispatcher that SOCKS5-tunnels with a pinned proxy-hop family. */ +export function createSocksDispatcherWithFamily( + proxy: SocksProxy, + family: 4 | 6 | null, + agentOptions: Agent.Options = {} +): Dispatcher { + const { connect, ...rest } = agentOptions as Agent.Options & { + connect?: buildConnector.BuildOptions; + }; + return new Agent({ + ...rest, + connect: socksConnectorWithFamily(proxy, family, connect), + }); +} diff --git a/src/lib/db/migrations/099_proxy_family.sql b/src/lib/db/migrations/099_proxy_family.sql new file mode 100644 index 0000000000..3364b008cc --- /dev/null +++ b/src/lib/db/migrations/099_proxy_family.sql @@ -0,0 +1,5 @@ +-- Migration 099: Per-proxy address-family egress policy +-- 'auto' (default) lets the OS pick; 'ipv4' forces IPv4-only; 'ipv6' forces +-- all egress through that proxy over IPv6 only (fail-closed). +ALTER TABLE proxy_registry ADD COLUMN family TEXT NOT NULL DEFAULT 'auto'; +ALTER TABLE upstream_proxy_config ADD COLUMN family TEXT NOT NULL DEFAULT 'auto'; diff --git a/src/lib/db/proxies.ts b/src/lib/db/proxies.ts index 88d65ef78c..4b758555ab 100755 --- a/src/lib/db/proxies.ts +++ b/src/lib/db/proxies.ts @@ -20,6 +20,7 @@ interface ProxyRegistryRecord { notes: string | null; status: string; source: string; + family: string; createdAt: string; updatedAt: string; } @@ -44,6 +45,7 @@ interface ProxyPayload { notes?: string | null; status?: string; source?: string; + family?: string; } interface ProxyAssignmentPayload { @@ -97,6 +99,7 @@ function mapProxyRow(row: unknown): ProxyRegistryRecord { notes: typeof r.notes === "string" ? r.notes : null, status: typeof r.status === "string" ? r.status : "active", source: typeof r.source === "string" ? r.source : "manual", + family: typeof r.family === "string" ? r.family : "auto", createdAt: typeof r.created_at === "string" ? r.created_at : "", updatedAt: typeof r.updated_at === "string" ? r.updated_at : "", }; @@ -146,6 +149,7 @@ function toRegistryProxyResolution(row: unknown, level: ProxyScope, levelId: str port: record.port, username: record.username, password: record.password, + family: typeof record.family === "string" ? record.family : "auto", ...(relayAuth !== undefined ? { relayAuth } : {}), }, level, @@ -243,8 +247,8 @@ function insertProxyRow( ) { db.prepare( `INSERT INTO proxy_registry - (id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + (id, name, type, host, port, username, password, region, notes, status, source, family, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( id, payload.name, @@ -257,6 +261,7 @@ function insertProxyRow( payload.notes || null, payload.status || "active", payload.source || "manual", + payload.family || "auto", now, now ); @@ -285,7 +290,7 @@ function updateProxyRow( db.prepare( `UPDATE proxy_registry - SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, source = ?, updated_at = ? + SET name = ?, type = ?, host = ?, port = ?, username = ?, password = ?, region = ?, notes = ?, status = ?, source = ?, family = ?, updated_at = ? WHERE id = ?` ).run( merged.name, @@ -298,6 +303,7 @@ function updateProxyRow( merged.notes || null, merged.status || "active", merged.source || "manual", + merged.family || "auto", merged.updatedAt, id ); @@ -412,7 +418,7 @@ export async function listProxies(options?: { includeSecrets?: boolean }) { const db = getDbInstance(); const rows = db .prepare( - "SELECT id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC" + "SELECT id, name, type, host, port, username, password, region, notes, status, source, family, created_at, updated_at FROM proxy_registry ORDER BY datetime(updated_at) DESC, name ASC" ) .all(); @@ -433,7 +439,7 @@ function getProxyRowById( const includeSecrets = options?.includeSecrets === true; const row = db .prepare( - "SELECT id, name, type, host, port, username, password, region, notes, status, source, created_at, updated_at FROM proxy_registry WHERE id = ?" + "SELECT id, name, type, host, port, username, password, region, notes, status, source, family, created_at, updated_at FROM proxy_registry WHERE id = ?" ) .get(id); if (!row) return null; @@ -708,7 +714,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string const accountAssignment = db .prepare( - `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes, p.family FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'account' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(connectionId); if (accountAssignment) { @@ -721,6 +727,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + family: typeof record.family === "string" ? record.family : "auto", ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "account", @@ -736,7 +743,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string if (connection?.provider) { const providerAssignment = db .prepare( - `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes, p.family FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'provider' AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(connection.provider); if (providerAssignment) { @@ -749,6 +756,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + family: typeof record.family === "string" ? record.family : "auto", ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "provider", @@ -760,7 +768,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string const globalAssignment = db .prepare( - `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes, p.family FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(); if (globalAssignment) { @@ -773,6 +781,7 @@ export async function resolveProxyForConnectionFromRegistry(connectionId: string port: record.port, username: record.username, password: record.password, + family: typeof record.family === "string" ? record.family : "auto", ...(relayAuth !== undefined ? { relayAuth } : {}), }, level: "global", @@ -797,7 +806,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: if (normalizedScope === "global") { const globalAssignment = db .prepare( - `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes, p.family FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = 'global' AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(); return globalAssignment ? toRegistryProxyResolution(globalAssignment, "global", null) : null; @@ -808,7 +817,7 @@ export async function resolveProxyForScopeFromRegistry(scope: string, scopeId?: const assignment = db .prepare( - `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` + `SELECT p.id, p.type, p.host, p.port, p.username, p.password, p.notes, p.family FROM proxy_assignments a JOIN proxy_registry p ON p.id = a.proxy_id WHERE a.scope = ? AND a.scope_id = ? AND ${PROXY_ALIVE_PREDICATE} LIMIT 1` ) .get(normalizedScope, normalizedScopeId); diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index 6df1b6b23a..74efe16136 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -81,6 +81,19 @@ function toProxyValue(value: unknown): ProxyValue { return null; } +// Legacy proxyConfig store (key_value namespace 'proxyConfig') predates the +// IPv6-only `family` directive, so its object configs have no family field. +// Default to "auto" so the family marker rides along the cascade end-to-end +// (consumed by proxyConfigToUrl). String configs are returned unchanged. +function withFamilyDefault(value: ProxyValue): ProxyValue { + if (value && typeof value === "object" && !Array.isArray(value)) { + const record = value as JsonRecord; + if (typeof record.family === "string") return record; + return { ...record, family: "auto" }; + } + return value; +} + // ──────────────── Settings ──────────────── export async function getSettings() { @@ -709,10 +722,17 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: if (apiKeyRow?.proxy_id) { const proxyRow = db .prepare( - "SELECT p.type, p.host, p.port, p.username, p.password FROM proxy_registry p WHERE p.id = ?" + "SELECT p.type, p.host, p.port, p.username, p.password, p.family FROM proxy_registry p WHERE p.id = ?" ) .get(apiKeyRow.proxy_id) as - | { type: string; host: string; port: number; username: string; password: string } + | { + type: string; + host: string; + port: number; + username: string; + password: string; + family?: string; + } | undefined; if (proxyRow) { const result = { @@ -722,6 +742,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: port: proxyRow.port, username: proxyRow.username, password: proxyRow.password, + family: typeof proxyRow.family === "string" ? proxyRow.family : "auto", }, level: "apiKey" as const, levelId: apiKeyId, @@ -746,7 +767,11 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: // Step 4: Legacy key-level if (connectionId && config.keys?.[connectionId]) { - const result = { proxy: config.keys[connectionId], level: "key", levelId: connectionId }; + const result = { + proxy: withFamilyDefault(config.keys[connectionId]), + level: "key", + levelId: connectionId, + }; cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); return result; } @@ -781,7 +806,11 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: (entry) => getComboModelProvider(entry) === connectionProvider ); if (usesProvider) { - const result = { proxy: config.combos[comboId], level: "combo", levelId: comboId }; + const result = { + proxy: withFamilyDefault(config.combos[comboId]), + level: "combo", + levelId: comboId, + }; cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); return result; } @@ -795,7 +824,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: // Step 8: Legacy provider-level (only if proxy_enabled) if (connectionProvider && connectionProxyEnabled && config.providers?.[connectionProvider]) { const result = { - proxy: config.providers[connectionProvider], + proxy: withFamilyDefault(config.providers[connectionProvider]), level: "provider", levelId: connectionProvider, }; @@ -813,7 +842,7 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: // Step 10: Legacy global if (config.global) { - const result = { proxy: config.global, level: "global", levelId: null }; + const result = { proxy: withFamilyDefault(config.global), level: "global", levelId: null }; cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, result); return result; } @@ -823,8 +852,20 @@ export async function resolveProxyForConnection(connectionId: string, apiKeyId?: const { selectWorkingProxyFallback } = await import("@omniroute/open-sse/utils/proxyFallback"); const fallback = await selectWorkingProxyFallback(connectionId); if (fallback) { - cacheProxyResolution(cacheKey, startGeneration, startRegistryGeneration, fallback); - return fallback; + // Auto-selected proxies are probed via a URL roundtrip that drops any + // per-registry family policy, so default the family marker to "auto" + // (no IPv6-only enforcement) when the fallback object omits it. + const normalizedFallback = + fallback.proxy && typeof fallback.proxy === "object" + ? { ...fallback, proxy: withFamilyDefault(fallback.proxy as ProxyValue) } + : fallback; + cacheProxyResolution( + cacheKey, + startGeneration, + startRegistryGeneration, + normalizedFallback as ProxyResolutionResult + ); + return normalizedFallback; } } catch (err) { console.warn({ err, connectionId }, "Proxy fallback auto-selection failed"); diff --git a/src/lib/db/upstreamProxy.ts b/src/lib/db/upstreamProxy.ts index 18f2eb9750..8429c334af 100644 --- a/src/lib/db/upstreamProxy.ts +++ b/src/lib/db/upstreamProxy.ts @@ -9,6 +9,7 @@ interface UpstreamProxyConfig { nativePriority: number; cliproxyapiPriority: number; enabled: boolean; + family: string; createdAt: string; updatedAt: string; } @@ -21,6 +22,7 @@ interface UpstreamProxyRow { native_priority: unknown; cliproxyapi_priority: unknown; enabled: unknown; + family: unknown; created_at: unknown; updated_at: unknown; } @@ -91,6 +93,7 @@ function rowToConfig(record: Record): UpstreamProxyConfig { nativePriority: record.native_priority as number, cliproxyapiPriority: record.cliproxyapi_priority as number, enabled: record.enabled === 1 || record.enabled === true, + family: typeof record.family === "string" ? record.family : "auto", createdAt: record.created_at as string, updatedAt: record.updated_at as string, }; @@ -120,6 +123,7 @@ export async function upsertUpstreamProxyConfig(data: { nativePriority?: number; cliproxyapiPriority?: number; enabled?: boolean; + family?: string; }) { const db = getDbInstance(); const mode = data.mode ?? "native"; @@ -130,17 +134,19 @@ export async function upsertUpstreamProxyConfig(data: { const nativePriority = data.nativePriority ?? 1; const cliproxyapiPriority = data.cliproxyapiPriority ?? 2; const enabled = data.enabled !== false ? 1 : 0; + const family = data.family ?? "auto"; db.prepare( `INSERT INTO upstream_proxy_config - (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) + (provider_id, mode, cliproxyapi_model_mapping, native_priority, cliproxyapi_priority, enabled, family, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) ON CONFLICT(provider_id) DO UPDATE SET mode = excluded.mode, cliproxyapi_model_mapping = excluded.cliproxyapi_model_mapping, native_priority = excluded.native_priority, cliproxyapi_priority = excluded.cliproxyapi_priority, enabled = excluded.enabled, + family = excluded.family, updated_at = datetime('now')` ).run( data.providerId, @@ -148,7 +154,8 @@ export async function upsertUpstreamProxyConfig(data: { cliproxyapiModelMapping, nativePriority, cliproxyapiPriority, - enabled + enabled, + family ); return getUpstreamProxyConfig(data.providerId); @@ -191,6 +198,10 @@ export async function updateUpstreamProxyConfig( sets.push("enabled = ?"); params.push(updates.enabled === true ? 1 : 0); } + if (updates.family !== undefined) { + sets.push("family = ?"); + params.push(updates.family); + } params.push(providerId); db.prepare(`UPDATE upstream_proxy_config SET ${sets.join(", ")} WHERE provider_id = ?`).run( diff --git a/src/lib/proxyHealth.ts b/src/lib/proxyHealth.ts index 0430c4c76f..1f61e52934 100644 --- a/src/lib/proxyHealth.ts +++ b/src/lib/proxyHealth.ts @@ -10,6 +10,7 @@ */ import { createConnection } from "node:net"; +import { stripIpv6Brackets } from "@omniroute/open-sse/utils/proxyFamily"; // Configurable via env vars const FAST_FAIL_TIMEOUT_MS = parseInt(process.env.PROXY_FAST_FAIL_TIMEOUT_MS ?? "2000", 10); @@ -56,7 +57,7 @@ export async function isProxyReachable( return false; } - const host = url.hostname; + const host = stripIpv6Brackets(url.hostname); const port = parseInt(url.port || defaultPortForScheme(url.protocol), 10); if (!host || isNaN(port)) { diff --git a/src/lib/usage/providerLimits.ts b/src/lib/usage/providerLimits.ts index b14997ed04..49ffb333b5 100644 --- a/src/lib/usage/providerLimits.ts +++ b/src/lib/usage/providerLimits.ts @@ -668,9 +668,15 @@ async function fetchLiveProviderLimitsWithOptions( } if (connection.authType !== "oauth") { + // L3: route the API-key usage/quota fetch through the connection's proxy context, + // mirroring the OAuth branch below (proxyInfo?.proxy ?? null). Without this, API-key + // usage egresses on the host IP, ignoring the connection's assigned proxy. + const apiKeyProxy = await resolveProxyForConnection(connectionId); const usage = sanitizeUsageQuotasForProvider( connection.provider, - (await getUsageForProvider(connection as unknown as JsonRecord, options)) as JsonRecord + (await runWithProxyContext(apiKeyProxy?.proxy ?? null, () => + getUsageForProvider(connection as unknown as JsonRecord, options) + )) as JsonRecord ); if (isRecord(usage.quotas)) { setQuotaCache(connectionId, connection.provider, usage.quotas); diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index f5f3854774..458a2c0079 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -582,17 +582,32 @@ export function handleNoCredentials( ); } +/** + * Proxy-resolution failure policy. Default: fail-closed (rethrow) so a request + * with an assigned-but-unresolvable proxy never silently egresses on the real IP. + * Opt back into the legacy DIRECT fallback with PROXY_FAIL_OPEN=true. + */ +export function decideProxyResolutionFailure( + err: unknown, + env: { PROXY_FAIL_OPEN?: string } = process.env +): null { + if ((env.PROXY_FAIL_OPEN ?? "").trim().toLowerCase() === "true") { + log.warn( + "PROXY", + `Proxy resolution failed — PROXY_FAIL_OPEN=true, falling back to DIRECT: ${ + err instanceof Error ? err.message : String(err) + }` + ); + return null; + } + throw err instanceof Error ? err : new Error(String(err)); +} + export async function safeResolveProxy(connectionId: string, apiKeyId?: string) { try { return await resolveProxyForConnection(connectionId, apiKeyId); - } catch (proxyErr: any) { - // Falling back to a DIRECT connection silently defeats proxy-based traffic - // isolation — keep the request alive, but make the bypass visible. - log.warn( - "PROXY", - `Proxy resolution failed for connection ${String(connectionId).slice(0, 8)} — falling back to DIRECT connection: ${proxyErr.message}` - ); - return null; + } catch (proxyErr) { + return decideProxyResolutionFailure(proxyErr); } } diff --git a/tests/unit/db-proxies-family.test.ts b/tests/unit/db-proxies-family.test.ts new file mode 100644 index 0000000000..5fe57422e5 --- /dev/null +++ b/tests/unit/db-proxies-family.test.ts @@ -0,0 +1,39 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { resetDbInstance, getDbInstance } from "../../src/lib/db/core"; +import { createProxy, getProxyById } from "../../src/lib/db/proxies"; + +describe("proxies CRUD carries family", () => { + after(() => resetDbInstance()); + + it("persists and returns family=ipv6", async () => { + getDbInstance(); + const created = await createProxy({ + name: "ipv6-proxy", + type: "http", + host: "proxy.example.com", + port: 8080, + family: "ipv6", + }); + assert.ok(created?.id, "createProxy should return a record with an id"); + assert.equal(created?.family, "ipv6"); + + const fetched = await getProxyById(created!.id, { includeSecrets: true }); + assert.equal(fetched?.family, "ipv6"); + }); + + it("defaults family to auto when omitted", async () => { + getDbInstance(); + const created = await createProxy({ + name: "default-proxy", + type: "http", + host: "proxy2.example.com", + port: 3128, + }); + assert.ok(created?.id, "createProxy should return a record with an id"); + assert.equal(created?.family, "auto"); + + const fetched = await getProxyById(created!.id, { includeSecrets: true }); + assert.equal(fetched?.family, "auto"); + }); +}); diff --git a/tests/unit/provider-limits-apikey-proxy-context.test.ts b/tests/unit/provider-limits-apikey-proxy-context.test.ts new file mode 100644 index 0000000000..c1c505e5e9 --- /dev/null +++ b/tests/unit/provider-limits-apikey-proxy-context.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { proxyConfigToUrl } from "../../open-sse/utils/proxyDispatcher.ts"; +import { runWithProxyContext, resolveProxyForRequest } from "../../open-sse/utils/proxyFetch.ts"; + +// L3 contract: the API-key usage/quota branch in src/lib/usage/providerLimits.ts must +// resolve the connection's proxy and run getUsageForProvider inside runWithProxyContext, +// exactly like the OAuth branch. These tests pin the proxy-config -> URL mechanism the +// fix relies on, so a regression that drops the proxy wrapping is caught. +describe("API-key usage egresses through proxy context", () => { + it("resolves an api-key connection proxy config to a usable URL", () => { + // Deterministic, no network dependency: this is the core mechanism the L3 fix uses + // when wrapping getUsageForProvider in runWithProxyContext(apiKeyProxy?.proxy ?? null). + const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080 }); + assert.ok(url && url.includes("p.example.com:8080"), `expected proxy url, got ${url}`); + }); + + it("a null proxy config (no connection proxy) resolves to no proxy", () => { + assert.equal(proxyConfigToUrl(null), null); + }); + + it("context proxy is visible to fetch resolution inside runWithProxyContext", async () => { + // runWithProxyContext fast-fails with PROXY_UNREACHABLE before invoking the callback + // when the proxy is not reachable. p.example.com:8080 is unreachable in CI, so this + // assertion guards against the (unlikely) case the host is reachable. The deterministic + // proof lives in the proxyConfigToUrl tests above. + try { + await runWithProxyContext({ type: "http", host: "p.example.com", port: 8080 }, async () => { + const r = resolveProxyForRequest("https://api.example.com"); + assert.equal(r.source, "context"); + assert.ok(r.proxyUrl && r.proxyUrl.includes("p.example.com")); + }); + } catch (err) { + // Expected when the proxy host is unreachable; the mechanism is still proven by the + // proxyConfigToUrl assertions above. + assert.equal((err as { code?: string })?.code, "PROXY_UNREACHABLE"); + } + }); +}); diff --git a/tests/unit/proxy-bypass-scope-guard-3226.test.ts b/tests/unit/proxy-bypass-scope-guard-3226.test.ts new file mode 100644 index 0000000000..00243e3b04 --- /dev/null +++ b/tests/unit/proxy-bypass-scope-guard-3226.test.ts @@ -0,0 +1,47 @@ +/** + * Scope guard for the deliberate NVIDIA validation proxy bypass (#3226). + * + * Context: NVIDIA's API-key validation endpoint stalls when routed through the + * global proxy/TLS-patched fetch (undici dispatcher → 504). As a documented + * exception, `directHttpsRequest()` in `src/lib/providers/validation.ts` calls + * `safeOutboundFetch({ bypassProxyPatch: true })`, which resolves the native + * fetch reference via `getOriginalFetch()` and bypasses the patch for that one + * validation call only. + * + * This test asserts that the bypass is CONFINED to the validation path and has + * NOT silently spread to the chat hot path (chatHelpers / chatCore). + * + * See also: tests/unit/nvidia-validation-bypass-proxy-3226.test.ts (mechanism seam tests). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +test("bypassProxyPatch is present in the NVIDIA validation path (#3226 documented exception)", () => { + const validation = readFileSync("src/lib/providers/validation.ts", "utf8"); + assert.ok( + validation.includes("bypassProxyPatch"), + "expected bypassProxyPatch to be present in validation.ts (the documented NVIDIA exception)" + ); + assert.ok( + validation.includes("directHttpsRequest"), + "expected directHttpsRequest helper to be present in validation.ts" + ); +}); + +test("bypassProxyPatch is absent from the chat hot path (scope guard — #3226)", () => { + // The chat hot path must never bypass the proxy patch. + // If either of these assertions starts failing, a code change has silently + // extended the NVIDIA-only exception to the chat/usage egress path. + const chatHelpers = readFileSync("src/sse/handlers/chatHelpers.ts", "utf8"); + assert.ok( + !chatHelpers.includes("bypassProxyPatch"), + "chatHelpers.ts must not bypass the proxy patch — only NVIDIA validation may do this (#3226)" + ); + + const chatCore = readFileSync("open-sse/handlers/chatCore.ts", "utf8"); + assert.ok( + !chatCore.includes("bypassProxyPatch"), + "chatCore.ts must not bypass the proxy patch — only NVIDIA validation may do this (#3226)" + ); +}); diff --git a/tests/unit/proxy-dispatcher-family.test.ts b/tests/unit/proxy-dispatcher-family.test.ts new file mode 100644 index 0000000000..9546ac2ccd --- /dev/null +++ b/tests/unit/proxy-dispatcher-family.test.ts @@ -0,0 +1,55 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + __getSocksOptionsForTest, + __resolveDispatcherFamilyForTest, + proxyConfigToUrl, + normalizeProxyUrl, + clearDispatcherCache, +} from "../../open-sse/utils/proxyDispatcher.ts"; + +afterEach(() => clearDispatcherCache()); + +describe("proxyDispatcher SOCKS5 host handling", () => { + it("de-brackets an IPv6-literal SOCKS proxy host", () => { + const opts = __getSocksOptionsForTest("socks5://[2001:db8::1]:1080"); + assert.equal(opts.host, "2001:db8::1"); + assert.equal(opts.port, 1080); + }); + it("leaves an IPv4 SOCKS host unchanged", () => { + const opts = __getSocksOptionsForTest("socks5://203.0.113.7:1080"); + assert.equal(opts.host, "203.0.113.7"); + }); +}); + +describe("proxyDispatcher family directive", () => { + it("encodes family from a config object onto the URL", () => { + const url = proxyConfigToUrl({ type: "http", host: "proxy.example.com", port: 8080, family: "ipv6" }); + assert.ok(url!.includes("family=ipv6"), url!); + }); + it("derives 6 for an explicit ipv6 directive on a hostname proxy", () => { + assert.equal(__resolveDispatcherFamilyForTest("http://proxy.example.com:8080?family=ipv6"), 6); + }); + it("derives the literal family when no directive is present", () => { + assert.equal(__resolveDispatcherFamilyForTest("http://[2001:db8::1]:8080"), 6); + assert.equal(__resolveDispatcherFamilyForTest("http://203.0.113.7:8080"), 4); + assert.equal(__resolveDispatcherFamilyForTest("http://proxy.example.com:8080"), null); + }); + it("throws (fail-closed) when family=ipv6 contradicts a v4 literal", () => { + assert.throws(() => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"), /family/i); + }); +}); + +describe("proxyDispatcher family marker does not corrupt port", () => { + it("preserves port 80 for an http proxy with a family directive", () => { + const url = proxyConfigToUrl({ type: "http", host: "203.0.113.7", port: 80, family: "ipv6" }); + assert.ok(url!.includes("203.0.113.7:80"), `expected :80 to survive, got ${url}`); + assert.ok(!url!.includes(":8080"), `port must not be rewritten to 8080, got ${url}`); + }); + it("normalizeProxyUrl keeps :80 when a family marker is present", () => { + const out = normalizeProxyUrl("http://203.0.113.7:80?family=ipv6", "test"); + assert.ok(out.includes("203.0.113.7:80"), out); + assert.ok(!out.includes(":8080"), out); + assert.ok(out.endsWith("?family=ipv6"), out); + }); +}); diff --git a/tests/unit/proxy-egress-isolation-bdd.test.ts b/tests/unit/proxy-egress-isolation-bdd.test.ts new file mode 100644 index 0000000000..3c81d5177c --- /dev/null +++ b/tests/unit/proxy-egress-isolation-bdd.test.ts @@ -0,0 +1,55 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { + __getSocksOptionsForTest, + __resolveDispatcherFamilyForTest, + proxyConfigToUrl, + clearDispatcherCache, +} from "../../open-sse/utils/proxyDispatcher.ts"; +import { resolveTlsClientProxyUrl } from "../../open-sse/services/tlsClientProxy.ts"; +import { assertHostnameSupportsFamily } from "../../open-sse/utils/proxyFamilyResolve.ts"; + +afterEach(() => clearDispatcherCache()); + +describe("BDD: proxy egress isolation", () => { + it("Scenario: IPv6-literal proxy de-brackets + connects v6", () => { + assert.equal(__getSocksOptionsForTest("socks5://[2001:db8::1]:1080").host, "2001:db8::1"); + assert.equal(__resolveDispatcherFamilyForTest("socks5://[2001:db8::1]:1080"), 6); + }); + + it("Scenario: IPv6 hostname proxy pins family 6", () => { + const url = proxyConfigToUrl({ + type: "http", + host: "proxy.example.com", + port: 8080, + family: "ipv6", + }) as string; + assert.equal(__resolveDispatcherFamilyForTest(url), 6); + }); + + it("Scenario: IPv6-only fail-closed when no AAAA", async () => { + await assert.rejects( + assertHostnameSupportsFamily("proxy.example.com", 6, async () => [ + { address: "203.0.113.7", family: 4 }, + ]), + /no IPv6/i + ); + }); + + it("Scenario: family directive contradicting a literal is rejected", () => { + assert.throws( + () => __resolveDispatcherFamilyForTest("http://203.0.113.7:8080?family=ipv6"), + /family/i + ); + }); + + it("Scenario: web TLS client fail-closed", () => { + assert.throws( + () => + resolveTlsClientProxyUrl("https://grok.com", undefined, () => { + throw new Error("SOCKS5 disabled"); + }), + /fail-closed/i + ); + }); +}); diff --git a/tests/unit/proxy-family-migration.test.ts b/tests/unit/proxy-family-migration.test.ts new file mode 100644 index 0000000000..872401ee27 --- /dev/null +++ b/tests/unit/proxy-family-migration.test.ts @@ -0,0 +1,17 @@ +import { describe, it, after } from "node:test"; +import assert from "node:assert/strict"; +import { getDbInstance, resetDbInstance } from "../../src/lib/db/core"; + +describe("migration 099 proxy family column", () => { + after(() => resetDbInstance()); + it("adds a family column defaulting to 'auto' on proxy_registry", () => { + const db = getDbInstance(); + const cols = db.prepare("PRAGMA table_info(proxy_registry)").all() as Array<{ name: string }>; + assert.ok(cols.some((c) => c.name === "family"), "proxy_registry.family must exist"); + }); + it("adds a family column on upstream_proxy_config", () => { + const db = getDbInstance(); + const cols = db.prepare("PRAGMA table_info(upstream_proxy_config)").all() as Array<{ name: string }>; + assert.ok(cols.some((c) => c.name === "family"), "upstream_proxy_config.family must exist"); + }); +}); diff --git a/tests/unit/proxy-family-resolve.test.ts b/tests/unit/proxy-family-resolve.test.ts new file mode 100644 index 0000000000..ce44a10f6c --- /dev/null +++ b/tests/unit/proxy-family-resolve.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { assertHostnameSupportsFamily } from "../../open-sse/utils/proxyFamilyResolve.ts"; + +const lookup = (recs: Array<{ address: string; family: number }>) => async () => recs; + +describe("assertHostnameSupportsFamily", () => { + it("passes when an AAAA record exists for ipv6", async () => { + await assertHostnameSupportsFamily("proxy.example.com", 6, lookup([{ address: "2001:db8::1", family: 6 }])); + }); + it("throws fail-closed when no AAAA exists for ipv6", async () => { + await assert.rejects( + assertHostnameSupportsFamily("proxy.example.com", 6, lookup([{ address: "203.0.113.7", family: 4 }])), + /no IPv6/i + ); + }); + it("throws fail-closed on DNS failure", async () => { + await assert.rejects( + assertHostnameSupportsFamily("proxy.example.com", 6, async () => { throw new Error("ENOTFOUND"); }), + /resolution/i + ); + }); + it("is a no-op for IP literals", async () => { + await assertHostnameSupportsFamily("[2001:db8::1]", 6, async () => { throw new Error("should not be called"); }); + }); +}); diff --git a/tests/unit/proxy-family.test.ts b/tests/unit/proxy-family.test.ts new file mode 100644 index 0000000000..c48bb25638 --- /dev/null +++ b/tests/unit/proxy-family.test.ts @@ -0,0 +1,31 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { + stripIpv6Brackets, + detectIpLiteralFamily, + parseProxyFamily, +} from "../../open-sse/utils/proxyFamily.ts"; + +describe("proxyFamily", () => { + it("strips brackets from an IPv6 literal host", () => { + assert.equal(stripIpv6Brackets("[2001:db8::1]"), "2001:db8::1"); + assert.equal(stripIpv6Brackets("[::1]"), "::1"); + }); + it("leaves non-bracketed hosts unchanged", () => { + assert.equal(stripIpv6Brackets("example.com"), "example.com"); + assert.equal(stripIpv6Brackets("10.0.0.1"), "10.0.0.1"); + }); + it("detects the family of an IP literal (bracketed or not)", () => { + assert.equal(detectIpLiteralFamily("[2001:db8::1]"), 6); + assert.equal(detectIpLiteralFamily("::1"), 6); + assert.equal(detectIpLiteralFamily("203.0.113.7"), 4); + assert.equal(detectIpLiteralFamily("proxy.example.com"), null); + }); + it("parses the ProxyFamily directive, defaulting to auto", () => { + assert.equal(parseProxyFamily("ipv6"), "ipv6"); + assert.equal(parseProxyFamily("ipv4"), "ipv4"); + assert.equal(parseProxyFamily("auto"), "auto"); + assert.equal(parseProxyFamily(undefined), "auto"); + assert.equal(parseProxyFamily("garbage"), "auto"); + }); +}); diff --git a/tests/unit/proxy-health-ipv6.test.ts b/tests/unit/proxy-health-ipv6.test.ts new file mode 100644 index 0000000000..642b98f97a --- /dev/null +++ b/tests/unit/proxy-health-ipv6.test.ts @@ -0,0 +1,30 @@ +import { describe, it, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import net from "node:net"; +import { isProxyReachable, invalidateProxyHealth } from "../../src/lib/proxyHealth"; + +describe("proxyHealth IPv6-literal reachability", () => { + afterEach(() => invalidateProxyHealth("http://[::1]:0")); + + it("reaches an IPv6-literal proxy host (de-bracketed before connect)", async () => { + const hasV6 = await new Promise((resolve) => { + const s = net.createServer(); + s.once("error", () => resolve(false)); + s.listen(0, "::1", () => { + s.close(() => resolve(true)); + }); + }); + if (!hasV6) { + // IPv6 loopback unavailable in this environment — skip rather than false-fail. + return; + } + const server = net.createServer(); + await new Promise((resolve) => server.listen(0, "::1", () => resolve())); + const port = (server.address() as net.AddressInfo).port; + const url = `http://[::1]:${port}`; + invalidateProxyHealth(url); + const ok = await isProxyReachable(url, 1000); + server.close(); + assert.equal(ok, true, "should connect to ::1 after de-bracketing"); + }); +}); diff --git a/tests/unit/resolve-proxy-family.test.ts b/tests/unit/resolve-proxy-family.test.ts new file mode 100644 index 0000000000..803aee5bb0 --- /dev/null +++ b/tests/unit/resolve-proxy-family.test.ts @@ -0,0 +1,138 @@ +import test, { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { proxyConfigToUrl } from "../../open-sse/utils/proxyDispatcher.ts"; + +describe("resolved proxy config → URL family encoding", () => { + it("encodes ipv6 family resolved from a connection's proxy", () => { + const resolved = { type: "socks5", host: "proxy.example.com", port: 1080, family: "ipv6" }; + const url = proxyConfigToUrl(resolved); + assert.ok(url!.endsWith("?family=ipv6"), url!); + }); + it("omits family marker when auto", () => { + const url = proxyConfigToUrl({ type: "http", host: "p.example.com", port: 8080, family: "auto" }); + assert.ok(!url!.includes("family="), url!); + }); +}); + +// ──────────────── Integration: family survives the resolveProxyForConnection cascade ── +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-resolve-family-")); +process.env.DATA_DIR = TEST_DATA_DIR; +process.env.API_KEY_SECRET = "test-secret"; + +const core = await import("../../src/lib/db/core.ts"); +const providersDb = await import("../../src/lib/db/providers.ts"); +const proxiesDb = await import("../../src/lib/db/proxies.ts"); +const settingsDb = await import("../../src/lib/db/settings.ts"); +const apiKeysDb = await import("../../src/lib/db/apiKeys.ts"); + +async function resetStorage() { + delete process.env.INITIAL_PASSWORD; + core.resetDbInstance(); + apiKeysDb.resetApiKeyState(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("account-level registry proxy carries family=ipv6 through resolveProxyForConnection", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "acct-ipv6", + apiKey: "sk-acct-ipv6", + }); + const proxy = await proxiesDb.createProxy({ + name: "IPv6 Account Proxy", + type: "socks5", + host: "acct.ipv6.local", + port: 1080, + family: "ipv6", + }); + await proxiesDb.assignProxyToScope("account", (conn as any).id, proxy.id); + + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); + assert.ok(resolved); + assert.equal((resolved as any).proxy.family, "ipv6"); +}); + +test("provider-level registry proxy carries family=ipv6", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "prov-ipv6", + apiKey: "sk-prov-ipv6", + }); + const proxy = await proxiesDb.createProxy({ + name: "IPv6 Provider Proxy", + type: "http", + host: "prov.ipv6.local", + port: 8080, + family: "ipv6", + }); + await proxiesDb.assignProxyToScope("provider", "openai", proxy.id); + + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); + assert.ok(resolved); + assert.equal((resolved as any).proxy.family, "ipv6"); +}); + +test("global registry proxy carries family=ipv6", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "glob-ipv6", + apiKey: "sk-glob-ipv6", + }); + const proxy = await proxiesDb.createProxy({ + name: "IPv6 Global Proxy", + type: "http", + host: "glob.ipv6.local", + port: 8080, + family: "ipv6", + }); + await proxiesDb.assignProxyToScope("global", null, proxy.id); + + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id); + assert.ok(resolved); + assert.equal((resolved as any).proxy.family, "ipv6"); +}); + +test("api-key-level proxy carries family=ipv6 (Step 2 object literal)", async () => { + await resetStorage(); + const conn = await providersDb.createProviderConnection({ + provider: "openai", + authType: "apikey", + name: "key-ipv6", + apiKey: "sk-key-ipv6", + }); + core + .getDbInstance() + .prepare( + "INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES ('settings', 'perKeyProxyEnabled', 'true')" + ) + .run(); + const proxy = await proxiesDb.createProxy({ + name: "IPv6 API Key Proxy", + type: "https", + host: "key.ipv6.local", + port: 8443, + family: "ipv6", + }); + const key = await apiKeysDb.createApiKey("family-key", "machine-f1"); + await apiKeysDb.updateApiKeyPermissions(key.id, { proxyId: proxy.id }); + + const resolved = await settingsDb.resolveProxyForConnection((conn as any).id, key.id); + assert.ok(resolved); + assert.equal((resolved as any).level, "apiKey"); + assert.equal((resolved as any).proxy.family, "ipv6"); +}); diff --git a/tests/unit/safe-resolve-proxy-fail-closed.test.ts b/tests/unit/safe-resolve-proxy-fail-closed.test.ts new file mode 100644 index 0000000000..b1cddd5f32 --- /dev/null +++ b/tests/unit/safe-resolve-proxy-fail-closed.test.ts @@ -0,0 +1,14 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { decideProxyResolutionFailure } from "../../src/sse/handlers/chatHelpers"; + +describe("decideProxyResolutionFailure", () => { + it("rethrows (fail-closed) by default", () => { + const err = new Error("boom"); + assert.throws(() => decideProxyResolutionFailure(err, { PROXY_FAIL_OPEN: undefined }), /boom/); + }); + it("returns null (fail-open) only when PROXY_FAIL_OPEN=true", () => { + const err = new Error("boom"); + assert.equal(decideProxyResolutionFailure(err, { PROXY_FAIL_OPEN: "true" }), null); + }); +}); diff --git a/tests/unit/socks-connector-family.test.ts b/tests/unit/socks-connector-family.test.ts new file mode 100644 index 0000000000..c98dfdb49a --- /dev/null +++ b/tests/unit/socks-connector-family.test.ts @@ -0,0 +1,15 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { buildSocksFamilySocketOptions } from "../../open-sse/utils/socksConnectorWithFamily.ts"; + +describe("socksConnectorWithFamily", () => { + it("returns family:6 + autoSelectFamily:false for ipv6", () => { + assert.deepEqual(buildSocksFamilySocketOptions(6), { family: 6, autoSelectFamily: false }); + }); + it("returns family:4 + autoSelectFamily:false for ipv4", () => { + assert.deepEqual(buildSocksFamilySocketOptions(4), { family: 4, autoSelectFamily: false }); + }); + it("returns an empty object for auto (no pin)", () => { + assert.deepEqual(buildSocksFamilySocketOptions(null), {}); + }); +}); diff --git a/tests/unit/tls-client-proxy-fail-closed.test.ts b/tests/unit/tls-client-proxy-fail-closed.test.ts new file mode 100644 index 0000000000..8e45a97a12 --- /dev/null +++ b/tests/unit/tls-client-proxy-fail-closed.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { resolveTlsClientProxyUrl } from "../../open-sse/services/tlsClientProxy.ts"; + +describe("resolveTlsClientProxyUrl — fail-closed", () => { + it("returns the per-call override verbatim", () => { + assert.equal( + resolveTlsClientProxyUrl("https://grok.com", "http://p:8080", () => null), + "http://p:8080" + ); + }); + it("returns undefined when no proxy is configured (direct is legitimate)", () => { + assert.equal( + resolveTlsClientProxyUrl("https://grok.com", undefined, () => ({ + source: "direct", + proxyUrl: null, + })), + undefined + ); + }); + it("returns the resolved proxy url when one is configured", () => { + assert.equal( + resolveTlsClientProxyUrl("https://grok.com", undefined, () => ({ + source: "context", + proxyUrl: "socks5://p:1080", + })), + "socks5://p:1080" + ); + }); + it("THROWS (fail-closed) when resolution throws — never silently direct", () => { + assert.throws( + () => + resolveTlsClientProxyUrl("https://grok.com", undefined, () => { + throw new Error("SOCKS5 disabled"); + }), + /proxy resolution failed/i + ); + }); +});