fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137)

This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-27 01:14:38 -03:00
committed by GitHub
parent 7b718134e0
commit 1677ef55e1
5 changed files with 59 additions and 1 deletions

View File

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

View File

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

View File

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

View File

@@ -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<string, string | undefined> = 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<string, unknown> {
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,

View File

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