From bffcc0fae854067665ff10d21561e0798f04dcae Mon Sep 17 00:00:00 2001 From: MarcusHO929 <224567479+MarcusHO929@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:46:50 +0200 Subject: [PATCH] fix(proxy): add opt-in local DNS for SOCKS5 targets --- CHANGELOG.md | 5 + README.md | 2 + docs/socks-dns.md | 26 +++ open-sse/utils/proxyDispatcher.js | 52 ++++++ open-sse/utils/proxyFetch.js | 7 +- package.json | 2 +- src/lib/network/proxyTest.js | 5 +- tests/unit/socks-proxy-dns.test.js | 253 +++++++++++++++++++++++++++++ 8 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 docs/socks-dns.md create mode 100644 open-sse/utils/proxyDispatcher.js create mode 100644 tests/unit/socks-proxy-dns.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cb5f13db2ea..491086a7592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +# Unreleased + +## Fixes +- **Proxy**: add opt-in `?dns=local` for SOCKS5 gateways with domain-target routing issues; preserve remote DNS by default, original TLS/Host identity and proxy rotation. Share the DNS-mode-aware dispatcher with dashboard proxy tests. Local DNS uses the system resolver; see `docs/socks-dns.md` for privacy implications (#4983). + # v0.5.104 (2026-10-15) ## Features diff --git a/README.md b/README.md index c50008e28b3..40e38b6578b 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ Claude Code/Codex/OpenClaw/Cursor/Cline Settings: **That's it!** Start coding with FREE AI models. +Using a SOCKS5 proxy? See [optional local/remote DNS resolution](./docs/socks-dns.md) for gateways that route domain targets differently from numeric targets. Remote DNS remains the default. + **Alternative: run from source (this repository):** This repository package is private (`9router-app`), so source/Docker execution is the expected local development path. diff --git a/docs/socks-dns.md b/docs/socks-dns.md new file mode 100644 index 00000000000..19bb90c244d --- /dev/null +++ b/docs/socks-dns.md @@ -0,0 +1,26 @@ +# SOCKS5 DNS resolution + +By default, 9router keeps the existing SOCKS5 behavior: the proxy receives the destination hostname and resolves it remotely. Some SOCKS gateways handle domain targets differently from numeric targets. If the same request works directly but fails through such a gateway, compare DNS/target addressing before changing provider headers. + +## Explicit DNS mode + +Add a `dns` query parameter to a **SOCKS5 proxy URL** in Proxy Pools, a connection proxy, or the global outbound proxy setting: + +```text +socks5://127.0.0.1:1080 # unchanged: proxy/remote DNS +socks5://127.0.0.1:1080?dns=remote # explicit proxy/remote DNS +socks5://127.0.0.1:1080?dns=local # opt in to local system DNS +socks5://user:password@proxy:1080?dns=local +``` + +With `dns=local`, the local system resolver chooses an IPv4 or IPv6 address, which is sent through the same SOCKS5 tunnel. IP-literal destinations do not need a DNS lookup. The request URL, HTTP Host, TLS SNI and certificate hostname verification still use the original destination. No destination IP is hardcoded, and proxy-pool rotation is unchanged. The dashboard's proxy test uses the same mode as model traffic. Different DNS-mode URLs have separate dispatcher-cache entries. + +Local DNS is bounded to five seconds and lookup errors propagate. Unknown or duplicated DNS modes, or a DNS mode on a non-`socks5:` URL, are configuration errors; they do not trigger a direct fallback. Existing proxy failure handling is otherwise unchanged: enable **Strict Proxy** if failed proxy requests must never be retried directly. Existing `STRICT_SSL` policy is unchanged as well. + +## Privacy and limitations + +**Opting in exposes destination DNS lookups to the local/system resolver instead of the proxy resolver.** This can also change split-DNS results and which gateway route is selected. Choose the mode deliberately; remote DNS remains the default. The option applies to every destination using that proxy URL, not just one provider. If only one provider needs it, use a dedicated proxy-pool entry rather than changing a shared global proxy. + +No provider identity, headers, account access, TLS fingerprint or certificate checks are changed by the DNS option. It is a transport diagnostic/workaround, not a guarantee that every provider-side 403 has a DNS cause. SOCKS5 support in the required Undici version is experimental. A caller can cancel a pending request. The five-second deadline bounds how long the dispatcher waits for DNS, not the underlying system lookup: Node's system DNS operation itself is not cancellable and may continue in the background. Cancellation prevents target HTTP delivery, but does not promise that an already-pending lookup cannot initiate a later SOCKS handshake. + +Related report: [#4983](https://github.com/decolua/9router/issues/4983). In one AdGuard SOCKS setup, domain targets returned OpenCode Free `403 FreeTierError` while locally resolved numeric targets worked with identical request data. The local correction restored 16 existing pools in strict-proxy tests. The gateway's internal routing decision was not identified; this should not be generalized to all OpenCode failures. diff --git a/open-sse/utils/proxyDispatcher.js b/open-sse/utils/proxyDispatcher.js new file mode 100644 index 00000000000..76a29a018dd --- /dev/null +++ b/open-sse/utils/proxyDispatcher.js @@ -0,0 +1,52 @@ +import dns from "node:dns"; +import { isIP } from "node:net"; + +const LOCAL_DNS_TIMEOUT_MS = 5000; + +function dnsModeError() { + return Object.assign(new Error("Invalid proxy DNS mode: use dns=local or dns=remote on a SOCKS5 URL"), { + code: "ERR_PROXY_DNS_MODE", + }); +} + +async function lookupLocal(hostname) { + let timer; + try { + return await Promise.race([ + dns.promises.lookup(hostname), + new Promise((_, reject) => { + timer = setTimeout(() => reject(Object.assign(new Error("Local proxy DNS lookup timed out"), { + code: "ETIMEDOUT", + })), LOCAL_DNS_TIMEOUT_MS); + }), + ]); + } finally { + clearTimeout(timer); + } +} + +/** + * dns=local is opt-in: it exposes target lookups to the system resolver. + * Keep the original request origin for HTTP Host, TLS SNI and certificate checks. + */ +export async function createProxyDispatcher(options) { + const url = new URL(options.uri); + const modes = url.searchParams.getAll("dns"); + const socks5 = url.protocol === "socks5:"; + if (modes.length && (!socks5 || modes.length !== 1 || !["local", "remote"].includes(modes[0]))) { + throw dnsModeError(); + } + const { ProxyAgent, Socks5ProxyAgent } = await import("undici"); + if (modes[0] !== "local") return new ProxyAgent(options); + + // Override only the tunnel destination, not the origin used by Undici's pool/TLS. + class LocalDnsSocksProxyAgent extends Socks5ProxyAgent { + async createSocks5Connection(hostname, port) { + const host = hostname.replace(/^\[|\]$/g, ""); + const target = isIP(host) ? host : (await lookupLocal(host)).address; + return super.createSocks5Connection(target, port); + } + } + url.searchParams.delete("dns"); + return new LocalDnsSocksProxyAgent(url, options); +} diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index af819cc5b20..5e45f187c0a 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -1,6 +1,7 @@ import { Readable } from "stream"; import { MEMORY_CONFIG } from "../config/runtimeConfig.js"; import { dbg } from "./debugLog.js"; +import { createProxyDispatcher } from "./proxyDispatcher.js"; const originalFetch = globalThis.fetch; const proxyDispatchers = new Map(); @@ -240,10 +241,10 @@ async function getDispatcher(proxyUrl, insecure = false) { if (proxyDispatchers.size >= MEMORY_CONFIG.proxyDispatchersMaxSize) { proxyDispatchers.delete(proxyDispatchers.keys().next().value); } - const { Agent, ProxyAgent } = await import("undici"); + const { Agent } = await import("undici"); const connect = insecure ? { rejectUnauthorized: false } : undefined; const dispatcher = normalized - ? new ProxyAgent({ uri: normalized, ...(insecure ? { requestTls: connect } : {}) }) + ? await createProxyDispatcher({ uri: normalized, ...(insecure ? { requestTls: connect } : {}) }) : new Agent({ connect }); proxyDispatchers.set(key, dispatcher); } @@ -358,6 +359,7 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { try { return await fetchWithTlsFallback(url, options, proxyUrl); } catch (proxyError) { + if (proxyError?.code === "ERR_PROXY_DNS_MODE") throw proxyError; if (proxyOptions?.strictProxy === true) { throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); } @@ -378,6 +380,7 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) { try { return await fetchWithTlsFallback(url, options, proxyUrl); } catch (proxyError) { + if (proxyError?.code === "ERR_PROXY_DNS_MODE") throw proxyError; // If strictProxy is enabled, fail hard instead of falling back to direct if (proxyOptions?.strictProxy === true) { throw new Error(`[ProxyFetch] Proxy required but failed (strictProxy=true): ${proxyError.message}`); diff --git a/package.json b/package.json index 09c8e20a9c9..09b097b6f67 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "selfsigned": "^5.5.0", "socks-proxy-agent": "^8.0.5", "sql.js": "^1.14.1", - "undici": "^7.19.2", + "undici": "^7.30.1", "uuid": "^13.0.0", "zustand": "^5.0.10" }, diff --git a/src/lib/network/proxyTest.js b/src/lib/network/proxyTest.js index 402e679f79f..81ea117466b 100644 --- a/src/lib/network/proxyTest.js +++ b/src/lib/network/proxyTest.js @@ -1,4 +1,5 @@ -import { ProxyAgent, fetch as undiciFetch } from "undici"; +import { fetch as undiciFetch } from "undici"; +import { createProxyDispatcher } from "../../../open-sse/utils/proxyDispatcher.js"; const DEFAULT_TEST_URL = "https://google.com/"; const DEFAULT_TIMEOUT_MS = 8000; @@ -42,7 +43,7 @@ export async function testProxyUrl({ proxyUrl, testUrl, timeoutMs } = {}) { try { try { - dispatcher = new ProxyAgent({ uri: normalizedProxyUrl }); + dispatcher = await createProxyDispatcher({ uri: normalizedProxyUrl }); } catch (err) { return { ok: false, diff --git a/tests/unit/socks-proxy-dns.test.js b/tests/unit/socks-proxy-dns.test.js new file mode 100644 index 00000000000..7f50ebefa05 --- /dev/null +++ b/tests/unit/socks-proxy-dns.test.js @@ -0,0 +1,253 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import dns from "node:dns"; +import net from "node:net"; +import https from "node:https"; +import { once } from "node:events"; +import selfsigned from "selfsigned"; +import { fetch as undiciFetch, ProxyAgent } from "undici"; + +// Make proxyAwareFetch use the trusted fixture CA without changing production TLS policy. +const caForFetch = vi.hoisted(() => ({ value: undefined })); +vi.mock("../../open-sse/utils/proxyDispatcher.js", async importOriginal => { + const actual = await importOriginal(); + return { + createProxyDispatcher: options => actual.createProxyDispatcher({ + ...options, + ...(caForFetch.value ? { requestTls: { ...options.requestTls, ca: caForFetch.value } } : {}), + }), + }; +}); + +const targetHost = "socks-dns.example.test"; +const dispatchers = []; +const sockets = new Set(); +const servers = []; + +async function fixture(requireAuth = false) { + const certificate = await selfsigned.generate([{ name: "commonName", value: targetHost }], { + keySize: 2048, + days: 1, + extensions: [{ name: "subjectAltName", altNames: [{ type: 2, value: targetHost }] }], + }); + const received = []; + const targets = []; + const credentials = []; + const tlsServer = https.createServer({ key: certificate.private, cert: certificate.cert }, (req, res) => { + received.push({ host: req.headers.host, servername: req.socket.servername, method: req.method }); + res.end("OK"); + }); + const track = socket => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + socket.on("error", () => {}); + return socket; + }; + tlsServer.on("connection", track); + await listen(tlsServer); + const proxy = net.createServer(socket => { + track(socket); + let buffer = Buffer.alloc(0); + let state = "greeting"; + socket.on("data", function read(chunk) { + buffer = Buffer.concat([buffer, chunk]); + if (state === "greeting") { + if (buffer.length < 2 || buffer.length < buffer[1] + 2) return; + buffer = buffer.subarray(buffer[1] + 2); + socket.write(Buffer.from([5, requireAuth ? 2 : 0])); + state = requireAuth ? "auth" : "connect"; + } + if (state === "auth") { + if (buffer.length < 2 || buffer.length < 3 + buffer[1]) return; + const userEnd = 2 + buffer[1]; + const end = userEnd + 1 + buffer[userEnd]; + if (buffer.length < end) return; + credentials.push({ username: buffer.subarray(2, userEnd).toString(), password: buffer.subarray(userEnd + 1, end).toString() }); + buffer = buffer.subarray(end); + socket.write(Buffer.from([1, 0])); + state = "connect"; + } + if (buffer.length < 5) return; + const type = buffer[3]; + const length = type === 1 ? 10 : type === 3 ? 7 + buffer[4] : 22; + if (buffer.length < length) return; + const host = type === 1 ? [...buffer.subarray(4, 8)].join(".") + : type === 3 ? buffer.subarray(5, length - 2).toString() : "ipv6"; + targets.push({ type, host }); + socket.removeListener("data", read); + socket.pause(); + const upstream = track(net.connect(tlsServer.address().port, "127.0.0.1")); + upstream.once("connect", () => { + socket.write(Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 0])); + if (buffer.length > length) upstream.write(buffer.subarray(length)); + socket.pipe(upstream); + upstream.pipe(socket); + socket.resume(); + }); + socket.once("close", () => upstream.destroy()); + upstream.once("error", () => socket.destroy()); + }); + }); + await listen(proxy); + return { + proxyUrl: `socks5://127.0.0.1:${proxy.address().port}`, + url: `https://${targetHost}:${tlsServer.address().port}/probe`, + ca: certificate.cert, + targets, + received, + credentials, + }; +} +async function listen(server) { + servers.push(server); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); +} +async function factory(options) { + const { createProxyDispatcher } = await import("../../open-sse/utils/proxyDispatcher.js"); + const dispatcher = await createProxyDispatcher(options); + dispatchers.push(dispatcher); + return dispatcher; +} +afterEach(async () => { + caForFetch.value = undefined; + vi.restoreAllMocks(); + for (const dispatcher of dispatchers.splice(0)) await dispatcher.destroy(); + for (const socket of sockets) socket.destroy(); + await Promise.all(servers.splice(0).map(server => new Promise(resolve => server.close(resolve)))); +}); + +describe("optional SOCKS DNS mode", () => { + it("keeps proxy DNS by default and with dns=remote", async () => { + const f = await fixture(); + const lookup = vi.spyOn(dns.promises, "lookup"); + for (const suffix of ["", "?dns=remote"]) { + const dispatcher = await factory({ uri: f.proxyUrl + suffix, requestTls: { ca: f.ca } }); + const response = await undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(5000) }); + expect(await response.text()).toBe("OK"); + } + expect(f.targets).toEqual([{ type: 3, host: targetHost }, { type: 3, host: targetHost }]); + expect(lookup.mock.calls.some(([host]) => host === targetHost)).toBe(false); + }); + + it("resolves locally only when opted in, preserving TLS verification, Host and SNI", async () => { + const f = await fixture(); + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 }); + const dispatcher = await factory({ uri: f.proxyUrl + "?dns=local", requestTls: { ca: f.ca } }); + const response = await undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(5000) }); + expect(await response.text()).toBe("OK"); + expect(f.targets).toEqual([{ type: 1, host: "127.0.0.1" }]); + expect(f.received).toEqual([{ host: new URL(f.url).host, servername: targetHost, method: "GET" }]); + const untrusted = await factory({ uri: f.proxyUrl + "?dns=local" }); + await expect(undiciFetch(f.url, { dispatcher: untrusted, signal: AbortSignal.timeout(5000) })).rejects.toThrow(); + }); + + it("accepts an IPv6 result from the local resolver", async () => { + const f = await fixture(); + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "::1", family: 6 }); + const dispatcher = await factory({ uri: f.proxyUrl + "?dns=local", requestTls: { ca: f.ca } }); + const response = await undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(5000) }); + expect(await response.text()).toBe("OK"); + expect(f.targets).toEqual([{ type: 4, host: "ipv6" }]); + }); + + it("skips local DNS when the destination is already an IP literal", async () => { + const f = await fixture(); + const lookup = vi.spyOn(dns.promises, "lookup"); + const dispatcher = await factory({ + uri: f.proxyUrl + "?dns=local", + requestTls: { ca: f.ca, servername: targetHost }, + }); + const target = new URL(f.url); + target.hostname = "127.0.0.1"; + const response = await undiciFetch(target, { dispatcher, signal: AbortSignal.timeout(5000) }); + expect(await response.text()).toBe("OK"); + expect(lookup).not.toHaveBeenCalled(); + expect(f.targets).toEqual([{ type: 1, host: "127.0.0.1" }]); + }); + + it("propagates lookup failures without sending a SOCKS domain fallback", async () => { + const f = await fixture(); + vi.spyOn(dns.promises, "lookup").mockRejectedValue(Object.assign(new Error("synthetic DNS failure"), { code: "ENOTFOUND" })); + const dispatcher = await factory({ uri: f.proxyUrl + "?dns=local", requestTls: { ca: f.ca } }); + await expect(undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(5000) })).rejects.toThrow(); + expect(f.targets).toEqual([]); + }); + + it("retains percent-encoded SOCKS credentials", async () => { + const f = await fixture(true); + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 }); + const proxy = new URL(f.proxyUrl); + proxy.username = "test%40user"; + proxy.password = "pass%3Aword"; + proxy.search = "dns=local"; + const dispatcher = await factory({ uri: proxy.href, requestTls: { ca: f.ca } }); + const response = await undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(5000) }); + expect(await response.text()).toBe("OK"); + expect(f.credentials).toEqual([{ username: "test@user", password: "pass:word" }]); + }); + + it("bounds a pending local DNS lookup", async () => { + const f = await fixture(); + vi.spyOn(dns.promises, "lookup").mockImplementation(() => new Promise(() => {})); + const dispatcher = await factory({ uri: f.proxyUrl + "?dns=local", requestTls: { ca: f.ca } }); + await expect(undiciFetch(f.url, { dispatcher, signal: AbortSignal.timeout(6500) })).rejects.toMatchObject({ + cause: { code: "ETIMEDOUT" }, + }); + expect(f.targets).toEqual([]); + }, 8000); + + it("preserves caller cancellation while DNS is pending", async () => { + const f = await fixture(); + let finishLookup; + vi.spyOn(dns.promises, "lookup").mockImplementation(() => new Promise(resolve => { finishLookup = resolve; })); + const dispatcher = await factory({ uri: f.proxyUrl + "?dns=local", requestTls: { ca: f.ca } }); + const controller = new AbortController(); + const request = undiciFetch(f.url, { dispatcher, signal: controller.signal }); + const rejected = expect(request).rejects.toMatchObject({ name: "AbortError" }); + await vi.waitFor(() => expect(finishLookup).toBeTypeOf("function")); + controller.abort(); + await rejected; + finishLookup({ address: "127.0.0.1", family: 4 }); + await dispatcher.destroy(); + expect(f.received).toEqual([]); + }); + + it("rejects unknown, duplicated and non-SOCKS DNS modes", async () => { + for (const uri of ["socks5://127.0.0.1:1?dns=invalid", "socks5://127.0.0.1:1?dns=local&dns=remote", "http://127.0.0.1:1?dns=local"]) { + await expect(factory({ uri })).rejects.toThrow(/DNS mode/); + } + }); + + it("uses the same DNS mode in the model transport and dashboard proxy test", async () => { + const f = await fixture(); + caForFetch.value = f.ca; + vi.spyOn(dns.promises, "lookup").mockResolvedValue({ address: "127.0.0.1", family: 4 }); + const { proxyAwareFetch } = await import("../../open-sse/utils/proxyFetch.js"); + const { testProxyUrl } = await import("../../src/lib/network/proxyTest.js"); + const local = f.proxyUrl + "?dns=local"; + const response = await proxyAwareFetch(f.url, { signal: AbortSignal.timeout(5000) }, { + enabled: true, url: local, strictProxy: true, + }); + expect(await response.text()).toBe("OK"); + const remote = await proxyAwareFetch(f.url, { signal: AbortSignal.timeout(5000) }, { + enabled: true, url: f.proxyUrl, strictProxy: true, + }); + expect(await remote.text()).toBe("OK"); + expect(await testProxyUrl({ proxyUrl: local, testUrl: f.url })).toMatchObject({ ok: true, status: 200 }); + expect(f.targets).toEqual([ + { type: 1, host: "127.0.0.1" }, { type: 3, host: targetHost }, { type: 1, host: "127.0.0.1" }, + ]); + }); + + it("fails closed for an invalid DNS mode even when strictProxy is off", async () => { + const { proxyAwareFetch } = await import("../../open-sse/utils/proxyFetch.js"); + await expect(proxyAwareFetch("https://nonexistent.invalid/", {}, { + enabled: true, url: "socks5://127.0.0.1:1?dns=typo", strictProxy: false, + })).rejects.toMatchObject({ code: "ERR_PROXY_DNS_MODE" }); + }); + + it("leaves HTTP proxy creation unchanged", async () => { + const dispatcher = await factory({ uri: "http://127.0.0.1:1234" }); + expect(dispatcher).toBeInstanceOf(ProxyAgent); + }); +});