From 1677ef55e1092cc8a10d2534055d06c7e356c5fc Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 01:14:38 -0300 Subject: [PATCH] fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137) --- .env.example | 7 +++++ CHANGELOG.md | 1 + docs/reference/ENVIRONMENT.md | 1 + open-sse/utils/socksConnectorWithFamily.ts | 23 ++++++++++++++- .../unit/socks-handshake-timeout-5109.test.ts | 28 +++++++++++++++++++ 5 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/unit/socks-handshake-timeout-5109.test.ts diff --git a/.env.example b/.env.example index 8f0fccb559..9e11926efe 100644 --- a/.env.example +++ b/.env.example @@ -438,6 +438,13 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # Set to 1 only for legacy diagnostics. Values above 256 are capped. # OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS=32 +# SOCKS5 handshake (connect) timeout in ms (default 10000, capped at 120000). +# Raise it when a single residential gateway host is hit by high concurrency +# (e.g. 100 simultaneous requests): the real SOCKS5 handshake can exceed 10s +# under a saturated pool even though the proxy is reachable, which otherwise +# surfaces as a false "[Proxy Fast-Fail] Proxy unreachable". +# SOCKS_HANDSHAKE_TIMEOUT_MS=10000 + # 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99849f89d5..6042520251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(proxy): make the SOCKS5 handshake timeout operator-tunable (`SOCKS_HANDSHAKE_TIMEOUT_MS`)** — under high concurrency against a single residential gateway host, the SOCKS5 connect handshake could exceed the hardcoded 10s even though the proxy was reachable, surfacing as a false `[Proxy Fast-Fail] Proxy unreachable` (the pool size is already tunable via `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS`). The handshake timeout now reads `SOCKS_HANDSHAKE_TIMEOUT_MS` (default unchanged at `10000`, capped at `120000`) so a concurrency-heavy deployment can raise it without a code change. Mitigation for #5109 (the full concurrency-100 collapse still needs the reporter's live load-test confirmation). ([#5109](https://github.com/diegosouzapw/OmniRoute/issues/5109)) - **fix(api): resolve `GET /v1/models/{id}` case-insensitively** — clients that normalise the model id (e.g. OpenCode requesting `minimax/minimax-m3` for the canonical catalog entry `minimax/MiniMax-M3`) missed the single-model lookup, which is case-sensitive, and fell back to advertising `context_length: 0`. `findModelById` now prefers an exact-case match and falls back to a case-insensitive match, so the real entry (and its context window) is returned regardless of casing. ([#5082](https://github.com/diegosouzapw/OmniRoute/issues/5082)) - **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) - **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index c48fb8c520..6008d3f8c6 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -295,6 +295,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con | `ALL_PROXY` | _(unset)_ | Node.js standard | Universal proxy (supports `socks5://`). | | `NO_PROXY` | _(unset)_ | Node.js standard | Comma-separated hostnames/IPs to bypass the proxy. | | `OMNIROUTE_PROXY_DISPATCHER_CONNECTIONS` | `32` | `open-sse/utils/proxyDispatcher.ts` | Max concurrent sockets per cached HTTP/SOCKS proxy dispatcher. Long-lived SSE streams such as Codex `/v1/responses` need more than one connection when several requests share the same account-level proxy. Values above `256` are capped. | +| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. | | `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. | diff --git a/open-sse/utils/socksConnectorWithFamily.ts b/open-sse/utils/socksConnectorWithFamily.ts index 8cf71acd3f..37f982ba5e 100644 --- a/open-sse/utils/socksConnectorWithFamily.ts +++ b/open-sse/utils/socksConnectorWithFamily.ts @@ -1,6 +1,27 @@ import { Agent, buildConnector, type Dispatcher } from "undici"; import { SocksClient, type SocksProxy } from "socks"; +const DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS = 10_000; +const MAX_SOCKS_HANDSHAKE_TIMEOUT_MS = 120_000; + +/** + * Resolve the SOCKS5 handshake (connect) timeout, operator-tunable via + * `SOCKS_HANDSHAKE_TIMEOUT_MS` (#5109). Under a saturated per-host pool the real + * handshake to a residential gateway can exceed the 10s default even though the + * proxy is reachable, so high-concurrency deployments can raise it without a + * code change. Invalid / non-positive values fall back to the default; values + * above the ceiling are clamped. + */ +export function resolveSocksHandshakeTimeoutMs( + env: Record = process.env +): number { + const raw = env.SOCKS_HANDSHAKE_TIMEOUT_MS; + if (raw == null || raw.trim() === "") return DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_SOCKS_HANDSHAKE_TIMEOUT_MS; + return Math.min(Math.floor(parsed), MAX_SOCKS_HANDSHAKE_TIMEOUT_MS); +} + /** 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 }; @@ -36,7 +57,7 @@ function socksConnectorWithFamily( const r = await SocksClient.createConnection({ command: "connect", proxy, - timeout: 10_000, + timeout: resolveSocksHandshakeTimeoutMs(), destination: { host: hostname, port: resolvePort(protocol, port) }, existing_socket: httpSocket as never, socket_options: socketOptions as never, diff --git a/tests/unit/socks-handshake-timeout-5109.test.ts b/tests/unit/socks-handshake-timeout-5109.test.ts new file mode 100644 index 0000000000..6c2a497bc3 --- /dev/null +++ b/tests/unit/socks-handshake-timeout-5109.test.ts @@ -0,0 +1,28 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { resolveSocksHandshakeTimeoutMs } from "../../open-sse/utils/socksConnectorWithFamily.ts"; + +// #5109 — SOCKS5 residential proxies that work in curl fail under concurrency-100 +// with "[Proxy Fast-Fail] Proxy unreachable" while the egress IP is valid. One +// contributing factor is the SOCKS5 handshake timeout being hardcoded at 10s: +// under a saturated per-host connection pool the real handshake can exceed 10s +// even though the proxy is reachable. The timeout is now operator-tunable so a +// high-concurrency deployment can raise it without a code change. +test("#5109: defaults to 10s when the env var is unset", () => { + assert.equal(resolveSocksHandshakeTimeoutMs({}), 10_000); +}); + +test("#5109: honours a valid SOCKS_HANDSHAKE_TIMEOUT_MS override", () => { + assert.equal(resolveSocksHandshakeTimeoutMs({ SOCKS_HANDSHAKE_TIMEOUT_MS: "30000" }), 30_000); +}); + +test("#5109: falls back to the default for non-numeric / non-positive values", () => { + assert.equal(resolveSocksHandshakeTimeoutMs({ SOCKS_HANDSHAKE_TIMEOUT_MS: "abc" }), 10_000); + assert.equal(resolveSocksHandshakeTimeoutMs({ SOCKS_HANDSHAKE_TIMEOUT_MS: "0" }), 10_000); + assert.equal(resolveSocksHandshakeTimeoutMs({ SOCKS_HANDSHAKE_TIMEOUT_MS: "-5" }), 10_000); +}); + +test("#5109: clamps an excessive override to the 120s ceiling", () => { + assert.equal(resolveSocksHandshakeTimeoutMs({ SOCKS_HANDSHAKE_TIMEOUT_MS: "999999" }), 120_000); +});