mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
feat(proxy): IPv6-only egress enforcement + close IP-leak paths (#3777)
Implements end-to-end proxy egress isolation with address-family enforcement and closes four real IP-leak paths surfaced by the proxy subsystem audit. IPv6-only egress: - Detect proxy family (IPv6/IPv4 literal, or per-account auto/ipv4/ipv6 directive) and pin the connect family (family:6/4 + autoSelectFamily:false) on both ProxyAgent (proxyTls) and a custom SOCKS connector that threads socket_options.family into SocksClient (fetch-socks can't), so Happy Eyeballs cannot pick IPv4 for an IPv6-only policy. - De-bracket IPv6-literal proxy hosts (socksOptions.host + proxyHealth tcpCheck) — fixes ENOTFOUND on [::1]-style SOCKS proxies and health checks. - Fail-closed when an IPv6-only hostname proxy has no AAAA (PROXY_FAMILY_UNAVAILABLE) instead of leaking over v4. - DB migration 099 adds the proxy family column; family is preserved through the resolveProxyForConnection cascade and proxies/upstreamProxy modules. Leak fixes: - L1: web TLS clients (grok/claude/chatgpt/perplexity) now fail-closed on proxy-resolution error instead of silently going direct (was the highest-risk asymmetry). - L2: safeResolveProxy fail-closed by default (PROXY_FAIL_OPEN opt-out). - L3: API-key usage/quota fetch routed through the connection proxy context. - L4: NVIDIA validation proxy bypass (#3226) documented. Tests: 46 TDD unit tests + BDD egress-isolation matrix. typecheck:core + eslint clean. Gemini 'critical' SOCKS finding verified as a false positive (socks accepts proxy.host; see PR thread). Integrated into release/v3.8.24.
This commit is contained in:
committed by
GitHub
parent
6956edc418
commit
7219f2b38d
@@ -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.
|
||||
|
||||
29
docs/bdd/proxy-egress-isolation.feature
Normal file
29
docs/bdd/proxy-egress-isolation.feature
Normal file
@@ -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
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
open-sse/services/tlsClientProxy.ts
Normal file
30
open-sse/services/tlsClientProxy.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<typeof socksDispatcher>[0],
|
||||
proxyDispatcherOptions
|
||||
) as Dispatcher;
|
||||
dispatcher =
|
||||
family === null
|
||||
? (socksDispatcher(
|
||||
socksOptions as Parameters<typeof socksDispatcher>[0],
|
||||
proxyDispatcherOptions
|
||||
) as Dispatcher)
|
||||
: createSocksDispatcherWithFamily(
|
||||
socksOptions as unknown as Parameters<typeof createSocksDispatcherWithFamily>[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;
|
||||
}
|
||||
|
||||
24
open-sse/utils/proxyFamily.ts
Normal file
24
open-sse/utils/proxyFamily.ts
Normal file
@@ -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";
|
||||
}
|
||||
39
open-sse/utils/proxyFamilyResolve.ts
Normal file
39
open-sse/utils/proxyFamilyResolve.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import dns from "node:dns/promises";
|
||||
import { detectIpLiteralFamily, stripIpv6Brackets } from "./proxyFamily.ts";
|
||||
|
||||
export type FamilyLookupFn = (
|
||||
hostname: string
|
||||
) => Promise<Array<{ address: string; family: number }>>;
|
||||
|
||||
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<void> {
|
||||
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)`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
68
open-sse/utils/socksConnectorWithFamily.ts
Normal file
68
open-sse/utils/socksConnectorWithFamily.ts
Normal file
@@ -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<string, unknown> {
|
||||
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),
|
||||
});
|
||||
}
|
||||
5
src/lib/db/migrations/099_proxy_family.sql
Normal file
5
src/lib/db/migrations/099_proxy_family.sql
Normal file
@@ -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';
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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<string, unknown>): 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(
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
39
tests/unit/db-proxies-family.test.ts
Normal file
39
tests/unit/db-proxies-family.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
39
tests/unit/provider-limits-apikey-proxy-context.test.ts
Normal file
39
tests/unit/provider-limits-apikey-proxy-context.test.ts
Normal file
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
47
tests/unit/proxy-bypass-scope-guard-3226.test.ts
Normal file
47
tests/unit/proxy-bypass-scope-guard-3226.test.ts
Normal file
@@ -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)"
|
||||
);
|
||||
});
|
||||
55
tests/unit/proxy-dispatcher-family.test.ts
Normal file
55
tests/unit/proxy-dispatcher-family.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
55
tests/unit/proxy-egress-isolation-bdd.test.ts
Normal file
55
tests/unit/proxy-egress-isolation-bdd.test.ts
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
17
tests/unit/proxy-family-migration.test.ts
Normal file
17
tests/unit/proxy-family-migration.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
26
tests/unit/proxy-family-resolve.test.ts
Normal file
26
tests/unit/proxy-family-resolve.test.ts
Normal file
@@ -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"); });
|
||||
});
|
||||
});
|
||||
31
tests/unit/proxy-family.test.ts
Normal file
31
tests/unit/proxy-family.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
30
tests/unit/proxy-health-ipv6.test.ts
Normal file
30
tests/unit/proxy-health-ipv6.test.ts
Normal file
@@ -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<boolean>((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<void>((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");
|
||||
});
|
||||
});
|
||||
138
tests/unit/resolve-proxy-family.test.ts
Normal file
138
tests/unit/resolve-proxy-family.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
14
tests/unit/safe-resolve-proxy-fail-closed.test.ts
Normal file
14
tests/unit/safe-resolve-proxy-fail-closed.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
15
tests/unit/socks-connector-family.test.ts
Normal file
15
tests/unit/socks-connector-family.test.ts
Normal file
@@ -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), {});
|
||||
});
|
||||
});
|
||||
39
tests/unit/tls-client-proxy-fail-closed.test.ts
Normal file
39
tests/unit/tls-client-proxy-fail-closed.test.ts
Normal file
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user