Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
26 changes: 26 additions & 0 deletions docs/socks-dns.md
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions open-sse/utils/proxyDispatcher.js
Original file line number Diff line number Diff line change
@@ -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);
}
7 changes: 5 additions & 2 deletions open-sse/utils/proxyFetch.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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}`);
}
Expand All @@ -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}`);
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
5 changes: 3 additions & 2 deletions src/lib/network/proxyTest.js
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Loading