From e8f72a8f088d56a51ef994d3630447bbf65ffb5b Mon Sep 17 00:00:00 2001 From: cbhandsun Date: Mon, 21 Sep 2026 08:41:59 -0700 Subject: [PATCH] fix(proxy): resolve upstream hostnames through trusted DNS Windows DNS Client intermittently answered chatgpt.com with addresses belonging to an unrelated network, so the TLS handshake failed altname verification and the proxy surfaced 502 to clients. Trusted resolvers (1.1.1.1 / 8.8.8.8 / 223.5.5.5) returned correct addresses throughout, which scopes the fault to the poisoned system resolver chain. Query trusted servers directly instead of the OS resolver, reject non-routable answers, and cache results briefly. Interrogate the connection rather than the error code when deciding whether a failed request may be replayed: TLS buffers application data until the handshake completes, so a reset during the handshake proves no HTTP bytes reached the upstream and replaying is side-effect free. Resets on established or reused connections still require the normal idempotency contract. Certificate and unreachable-address failures additionally drop the cached answer for that host so the retry re-resolves. The invalidator lives on the agent to keep openai-proxy.js a composition root within its line budget. --- docs/proxy-runtime.md | 35 ++ lib/proxy/config.js | 3 + lib/proxy/core-request-handler.js | 14 + lib/proxy/runtime/http-agents.js | 105 +++++- lib/proxy/upstream-dns.js | 466 +++++++++++++++++++++++++ lib/proxy/upstream-retry.js | 75 ++++- lib/proxy/validator.js | 40 +++ proxy.config.example.json | 1 + scripts/release-proxy-sources.json | 1 + test/runtime-http-agents.test.js | 86 ++++- test/upstream-dns.test.js | 468 ++++++++++++++++++++++++++ test/upstream-retry-handshake.test.js | 157 +++++++++ test/upstream-retry.test.js | 156 +++++++++ test/validator.test.js | 66 ++++ 14 files changed, 1661 insertions(+), 12 deletions(-) create mode 100644 lib/proxy/upstream-dns.js create mode 100644 test/upstream-dns.test.js create mode 100644 test/upstream-retry-handshake.test.js diff --git a/docs/proxy-runtime.md b/docs/proxy-runtime.md index 91704914..f7a3fb43 100644 --- a/docs/proxy-runtime.md +++ b/docs/proxy-runtime.md @@ -19,3 +19,38 @@ New modules must use the existing domain prefix or live in `handlers/` when they implement a management route family. Avoid generic names such as `utils2.js`, and keep `openai-proxy.js` as a composition root rather than adding business logic to it. + +## Upstream DNS hardening + +`upstream-dns.js` provides the resolver used by the outbound HTTPS agents. +It queries trusted resolvers directly instead of relying on the OS resolver +chain, keeps a short per-host cache, and only accepts routable public +addresses. + +This exists because the Windows DNS client may hand a poisoned answer from one +configured server (for example a router that resolves `chatgpt.com` to an +unrelated host). TLS then fails with `ERR_TLS_CERT_ALTNAME_INVALID` and the +proxy returns 502 even though the internet connection itself is fine. + +Operational notes: + +- `appSettings.upstreamDnsServers` accepts an ordered array of literals or a + comma-separated string. Order is priority; the first server that answers with + a public address wins. +- An explicitly empty list (`[]` or `""`) disables the feature and restores + system resolution. A missing field keeps the built-in trusted list. +- Single-label names and `.local`/`.internal`/`.lan` style names always use + system resolution, so intranet hosts keep working. +- When all trusted servers fail, the resolver falls back to system resolution + and logs at debug level, so a broken DNS egress degrades instead of failing + the request outright. +- Certificate mismatches and unreachable addresses invalidate the cache entry + for that host, so the retry re-resolves instead of reusing the bad address. +- Pre-handshake TLS failures are retryable even for POST without an + `Idempotency-Key`, because the request body was never transmitted. +- Failures while the TLS handshake is still in progress count as + pre-transmission too, even when the error code is `ECONNRESET`. TLS buffers + application data until the handshake completes, so a reset during the + handshake guarantees no HTTP bytes reached the upstream. This is detected + from the connection state, not the error code, so a reset on an established + or reused connection still requires the normal idempotency contract. diff --git a/lib/proxy/config.js b/lib/proxy/config.js index 63be41c0..f14719f1 100644 --- a/lib/proxy/config.js +++ b/lib/proxy/config.js @@ -175,6 +175,9 @@ const DEFAULT_CONFIG = { upstreamWarmupIntervalMs: 45 * 1000, upstreamWarmupMaxTargets: 4, upstreamWarmupStaggerMs: 200, + // 抗污染 DNS:默认只向列出的受信服务器解析上游域名,避免系统解析器 + // 把 chatgpt.com 交给被投毒的路由器。空数组表示退回系统解析。 + upstreamDnsServers: ['1.1.1.1', '8.8.8.8', '223.5.5.5'], streamDirectRequestBodies: false, }, entryAuth: { diff --git a/lib/proxy/core-request-handler.js b/lib/proxy/core-request-handler.js index 6775cdb8..412549b5 100644 --- a/lib/proxy/core-request-handler.js +++ b/lib/proxy/core-request-handler.js @@ -48,6 +48,8 @@ const { attachPreparedDiagnostics, updateClientDiagnostics, } = require('./handlers/client-diagnostics'); +// 握手阶段被重置时请求体尚未发出,据此判定重放是否安全。 +const { readPreTransmissionContext } = require('./upstream-retry'); const safeLog = createLogger('core-request-handler'); const MAX_PASSTHROUGH_RATE_LIMIT_BODY_BYTES = 64 * 1024; @@ -1725,6 +1727,16 @@ function createCoreRequestHandler(dependencies = {}) { }); proxyReq.on('error', function (err) { + // 证书不匹配/连不上这类失败的根因通常是该域名的 DNS 答案不可信。 + // 丢弃缓存条目,让紧随其后的重试必须重新解析,而不是复用同一个地址。 + // 失效动作挂在 agent 上:配置热更新会整体替换 agent 与其解析器缓存, + // 从当前 agent 取用可保证操作的是正在生效的那份缓存。 + const upstreamAgent = transport === https ? globalHttpsAgent : globalHttpAgent; + if (upstreamAgent && typeof upstreamAgent.invalidateUpstreamAddress === 'function') { + try { + upstreamAgent.invalidateUpstreamAddress(target.hostname, err && err.code); + } catch (_error) {} + } const recovery = resolveUpstreamRecovery({ kind: 'error', attempt, @@ -1733,6 +1745,8 @@ function createCoreRequestHandler(dependencies = {}) { profiles: getProfiles(loadConfig()) || [], method, headers: clientReq.headers, + errorCode: err && err.code, + preTransmissionContext: readPreTransmissionContext(proxyReq), responseStarted: clientRes.headersSent, allowPostFailover: !clientRes.headersSent, }); diff --git a/lib/proxy/runtime/http-agents.js b/lib/proxy/runtime/http-agents.js index c1b1241b..5399789e 100644 --- a/lib/proxy/runtime/http-agents.js +++ b/lib/proxy/runtime/http-agents.js @@ -2,6 +2,11 @@ const http = require('node:http'); const https = require('node:https'); +const { + createUpstreamResolver, + parseDnsServerList, + isUntrustworthyAddressError, +} = require('../upstream-dns'); const AGENT_OPTIONS = Object.freeze({ keepAlive: true, @@ -10,7 +15,34 @@ const AGENT_OPTIONS = Object.freeze({ timeout: 60000, }); -function createRuntimeHttpAgents({ loadConfig, env = process.env } = {}) { +/** + * 读取抗污染 DNS 配置。 + * + * 默认开启(appSettings.upstreamDnsServers 未设置时使用内置受信列表),显式配置空 + * 数组或空字符串表示退回系统解析。配置损坏时保持默认开启,因为静默退回系统解析 + * 正是本次 502 的成因。 + */ +function readUpstreamDnsConfig(loadConfig) { + let config; + try { + config = loadConfig(); + } catch (_error) { + config = null; + } + return upstreamDnsConfigFromConfig(config); +} + +function upstreamDnsConfigFromConfig(config) { + const settings = config && config.appSettings ? config.appSettings : null; + const raw = settings ? settings.upstreamDnsServers : undefined; + const disabled = raw == null ? false : (Array.isArray(raw) ? raw : String(raw)).length === 0; + if (disabled) return { enabled: false, servers: [] }; + const { servers } = parseDnsServerList(raw == null ? undefined : raw); + if (servers.length === 0) return { enabled: true, servers: undefined }; + return { enabled: true, servers }; +} + +function createRuntimeHttpAgents({ loadConfig, env = process.env, logger = null } = {}) { if (typeof loadConfig !== 'function') { throw new TypeError('loadConfig must be a function'); } @@ -18,26 +50,77 @@ function createRuntimeHttpAgents({ loadConfig, env = process.env } = {}) { throw new TypeError('env must be an object'); } - const shouldVerifyUpstreamTls = () => { - if (env.PROXY_ALLOW_UNTRUSTED_UPSTREAM === '1') return false; + // 解析器按服务器列表复用,保留 DNS 缓存;列表没变就不重建。 + let resolverKey = null; + let resolverInstance = null; + + function resolveUpstreamResolver(dnsConfig) { + if (!dnsConfig.enabled) return null; + const key = (dnsConfig.servers || []).join(','); + if (resolverInstance && resolverKey === key) return resolverInstance; + resolverInstance = createUpstreamResolver({ servers: dnsConfig.servers, logger }); + resolverKey = key; + return resolverInstance; + } + + // 最近一次成功读取的配置;环境变量覆盖跳过读取时用它保持 DNS 策略不抖动。 + let lastKnownConfig = null; + + // TLS 校验与 DNS 策略来自同一次配置读取,避免同一次建连出现两套配置视图。 + function readConnectionPolicy() { + if (env.PROXY_ALLOW_UNTRUSTED_UPSTREAM === '1') { + return { + rejectUnauthorized: false, + dnsConfig: upstreamDnsConfigFromConfig(lastKnownConfig), + }; + } + let config; try { - const config = loadConfig(); - return !config.appSettings || config.appSettings.verifyUpstreamTls !== false; + config = loadConfig(); + if (config) lastKnownConfig = config; } catch (_error) { - return true; + config = null; } - }; + return { + rejectUnauthorized: + !config || !config.appSettings || config.appSettings.verifyUpstreamTls !== false, + dnsConfig: upstreamDnsConfigFromConfig(config || lastKnownConfig), + }; + } + + const shouldVerifyUpstreamTls = () => readConnectionPolicy().rejectUnauthorized; - const createHttpsAgent = () => - new https.Agent({ + const createHttpsAgent = () => { + const policy = readConnectionPolicy(); + const resolver = resolveUpstreamResolver(policy.dnsConfig); + const agent = new https.Agent({ ...AGENT_OPTIONS, - rejectUnauthorized: shouldVerifyUpstreamTls(), + rejectUnauthorized: policy.rejectUnauthorized, + ...(resolver ? { lookup: resolver.lookup } : {}), }); + /** + * 证书不匹配/连不上这类失败说明"解析到的机器不是目标机器",最可能是该域名的 + * DNS 答案不可信。把失效动作挂在 agent 上,请求路径只需拿到 agent 即可丢弃 + * 该域名的缓存答案,无需额外接线;普通连接断开不触发。 + */ + agent.invalidateUpstreamAddress = (hostname, errorCode) => { + if (!isUntrustworthyAddressError(errorCode)) return false; + if (!resolver || typeof resolver.invalidate !== 'function') return false; + if (typeof hostname !== 'string' || !hostname.trim()) return false; + try { + return resolver.invalidate(hostname); + } catch (_error) { + return false; + } + }; + return agent; + }; return { createHttpsAgent, httpAgent: new http.Agent(AGENT_OPTIONS), httpsAgent: createHttpsAgent(), + getUpstreamResolver: () => resolverInstance, shouldVerifyUpstreamTls, }; } @@ -45,4 +128,6 @@ function createRuntimeHttpAgents({ loadConfig, env = process.env } = {}) { module.exports = { AGENT_OPTIONS, createRuntimeHttpAgents, + readUpstreamDnsConfig, + upstreamDnsConfigFromConfig, }; diff --git a/lib/proxy/upstream-dns.js b/lib/proxy/upstream-dns.js new file mode 100644 index 00000000..c4d60b70 --- /dev/null +++ b/lib/proxy/upstream-dns.js @@ -0,0 +1,466 @@ +/** + * 出站上游域名的抗污染解析。 + * + * 问题背景:Windows 的 DNS Client 会在多个已配置的 DNS 服务器之间自行选择, + * 现场观测到它把 chatgpt.com 交给被投毒的路由器解析,拿到一个属于别人的 IP, + * TLS 校验随即以 ERR_TLS_CERT_ALTNAME_INVALID 失败(证书主体是 m.minghui.org), + * 代理只能向客户端返回 502。TLS 校验本身是对的,错的是"用了哪个解析器"。 + * + * 这里提供一条不走系统解析器缓存的解析路径:直接向受信 DNS 服务器(默认 + * 1.1.1.1 / 8.8.8.8 / 223.5.5.5)查询,按配置顺序取第一个可用结果,并丢弃 + * 回环、私网、链路本地等不可路由地址。全部受信服务器失败时回退到系统解析, + * 保证极端网络下不会比现状更差。 + * + * 已知边界:受信服务器自身若返回"公网但错误"的地址,这里无法识别;TLS 证书 + * 校验仍是最后一道防线(本次故障正是被它拦下的)。命中这类失败时调用方应调用 + * invalidate() 丢弃该域名的缓存条目,让重试拿到新答案。 + */ +'use strict'; + +const dns = require('node:dns'); +const net = require('node:net'); + +const DEFAULT_DNS_SERVERS = Object.freeze(['1.1.1.1', '8.8.8.8', '223.5.5.5']); +const MAX_DNS_SERVERS = 8; +const DEFAULT_QUERY_TIMEOUT_MS = 1500; +const MIN_QUERY_TIMEOUT_MS = 50; +const MAX_QUERY_TIMEOUT_MS = 30000; +const DEFAULT_CACHE_TTL_MS = 30000; +const MIN_CACHE_TTL_MS = 1000; +const MAX_CACHE_TTL_MS = 300000; +const DEFAULT_MAX_CACHE_ENTRIES = 512; +const MAX_CACHE_ENTRIES_LIMIT = 10000; +const MAX_ADDRESSES_PER_RESULT = 8; + +const PRIVATE_IPV4_RANGES = Object.freeze([ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +]); + +function clampInt(value, fallback, min, max) { + const parsed = + typeof value === 'number' ? value : Number.parseInt(String(value == null ? '' : value), 10); + const integer = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; + return Math.min(max, Math.max(min, integer)); +} + +function ipv4ToInt(address) { + const parts = String(address).split('.'); + if (parts.length !== 4) return null; + let value = 0; + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null; + const octet = Number(part); + if (octet > 255) return null; + value = value * 256 + octet; + } + return value; +} + +function isPublicIpv4(address) { + const value = ipv4ToInt(address); + if (value === null) return false; + for (const [base, bits] of PRIVATE_IPV4_RANGES) { + const baseValue = ipv4ToInt(base); + const mask = bits === 0 ? 0 : (0xffffffff << (32 - bits)) >>> 0; + if ((value & mask) >>> 0 === (baseValue & mask) >>> 0) return false; + } + return true; +} + +function isPublicIpv6(address) { + const value = String(address).toLowerCase().split('%')[0]; + if (!value) return false; + if (value === '::' || value === '::1') return false; + const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value); + if (mapped) return isPublicIpv4(mapped[1]); + if (value.startsWith('fc') || value.startsWith('fd')) return false; // 唯一本地地址 fc00::/7 + if (/^fe[89ab]/.test(value)) return false; // 链路本地 fe80::/10 + if (value.startsWith('ff')) return false; // 组播 ff00::/8 + if (value.startsWith('2001:db8')) return false; // 文档地址 + return true; +} + +/** 只接受可在公网路由的地址;污染与解析异常常表现为私网/回环答案。 */ +function isPublicAddress(address, family) { + const version = family || net.isIP(String(address)); + if (version === 4) return isPublicIpv4(address); + if (version === 6) return isPublicIpv6(address); + return false; +} + +/** + * 解析 DNS 服务器列表。接受数组或逗号分隔字符串,保留顺序去重, + * 返回可用列表与被拒绝的原始条目,便于配置校验给出具体报错。 + */ +function parseDnsServerList(value) { + const raw = Array.isArray(value) + ? value + : typeof value === 'string' && value.trim() + ? value.split(',') + : []; + const servers = []; + const invalid = []; + const seen = new Set(); + for (const entry of raw) { + const text = String(entry == null ? '' : entry).trim(); + if (!text) continue; + const family = net.isIP(text); + if (family !== 4 && family !== 6) { + invalid.push(text); + continue; + } + if (seen.has(text)) continue; + seen.add(text); + if (servers.length < MAX_DNS_SERVERS) servers.push(text); + } + return { servers, invalid }; +} + +function normalizeFamily(value) { + const parsed = Number(value); + if (parsed === 4 || parsed === 6) return parsed; + return 0; +} + +function normalizeHostnameKey(hostname) { + return String(hostname).trim().toLowerCase().replace(/\.$/, ''); +} + +/** + * 这些错误码说明"连到的那台机器不是我们要找的那台",最可能的原因是该域名的 + * DNS 答案不可信(污染、劫持、坏缓存)。命中时应丢弃本地缓存条目,让下一次 + * 重试重新查询,而不是继续复用同一个地址。 + * + * 刻意不包含 ECONNRESET/EPIPE 这类连接中途断开的错误:那类失败与地址是否正确 + * 无关,丢弃缓存只会增加无谓查询。 + */ +const ADDRESS_UNTRUSTWORTHY_ERROR_CODES = new Set([ + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'CERT_NOT_YET_VALID', + 'ERR_SSL_WRONG_VERSION_NUMBER', + 'ERR_SSL_PACKET_LENGTH_TOO_LONG', + 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', + 'ERR_SSL_TLSV1_ALERT_UNKNOWN_CA', + 'ECONNREFUSED', + 'EHOSTUNREACH', + 'ENETUNREACH', +]); + +function isUntrustworthyAddressError(errorCode) { + const code = String(errorCode == null ? '' : errorCode) + .trim() + .toUpperCase(); + return ADDRESS_UNTRUSTWORTHY_ERROR_CODES.has(code); +} + +/** + * 判断是否应该直接交给系统解析。 + * + * 单标签名(localhost、内网机器名)与 .local/.internal/.lan/… 由搜索域、NetBIOS 或 + * mDNS 提供,公网 DNS 一定查不到。若交给受信服务器,每次都要等满超时再回退, + * 会把正常的内网请求拖慢数秒,因此这类名字直接走系统解析。 + */ +function shouldUseSystemResolver(hostname) { + const host = normalizeHostnameKey(hostname); + if (!host) return true; + if (host === 'localhost') return true; + if (!host.includes('.')) return true; + return /\.(?:local|localhost|internal|intranet|lan|home|corp|localdomain)$/.test(host); +} + +function withTimeout(promise, timeoutMs, onTimeout) { + let timer = null; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => { + try { + if (typeof onTimeout === 'function') onTimeout(); + } catch (error) {} + const error = new Error(`DNS query timed out after ${timeoutMs}ms`); + error.code = 'ETIMEDOUT'; + reject(error); + }, timeoutMs); + }); + return Promise.race([promise, timeout]).finally(() => clearTimeout(timer)); +} + +/** + * 使用 promise 版 Resolver:回调版 resolve4 不接受 { ttl: true }, + * 而 TTL 是这里的缓存依据之一。 + */ +function defaultCreateResolver() { + return new dns.promises.Resolver({ timeout: DEFAULT_QUERY_TIMEOUT_MS, tries: 2 }); +} + +function collectAddresses(records, family) { + const items = Array.isArray(records) ? records : []; + const addresses = []; + const seen = new Set(); + let minTtlMs = null; + for (const item of items) { + const address = typeof item === 'string' ? item : item && item.address; + if (typeof address !== 'string' || !address) continue; + if (!isPublicAddress(address, family)) continue; + if (seen.has(address)) continue; + seen.add(address); + addresses.push({ address, family }); + const ttlSeconds = item && typeof item === 'object' ? Number(item.ttl) : NaN; + if (Number.isFinite(ttlSeconds) && ttlSeconds > 0) { + const ttlMs = ttlSeconds * 1000; + minTtlMs = minTtlMs === null ? ttlMs : Math.min(minTtlMs, ttlMs); + } + if (addresses.length >= MAX_ADDRESSES_PER_RESULT) break; + } + return { addresses, minTtlMs }; +} + +/** + * 创建把 IPv4 放在首位、只返回公网地址的上游解析器。 + * + * servers 为空表示纯系统解析(等同于禁用本模块),调用方据此支持用户关闭。 + */ +function createUpstreamResolver(options = {}) { + const { + servers = DEFAULT_DNS_SERVERS, + queryTimeoutMs = DEFAULT_QUERY_TIMEOUT_MS, + cacheTtlMs = DEFAULT_CACHE_TTL_MS, + maxCacheEntries = DEFAULT_MAX_CACHE_ENTRIES, + systemLookup = dns.lookup, + createResolver = defaultCreateResolver, + logger = null, + now = Date.now, + } = options; + if (typeof systemLookup !== 'function') { + throw new TypeError('createUpstreamResolver requires systemLookup to be a function'); + } + if (typeof createResolver !== 'function') { + throw new TypeError('createUpstreamResolver requires createResolver to be a function'); + } + + const trustedServers = parseDnsServerList(servers).servers; + const timeoutMs = clampInt( + queryTimeoutMs, + DEFAULT_QUERY_TIMEOUT_MS, + MIN_QUERY_TIMEOUT_MS, + MAX_QUERY_TIMEOUT_MS + ); + const ttlCapMs = clampInt(cacheTtlMs, DEFAULT_CACHE_TTL_MS, MIN_CACHE_TTL_MS, MAX_CACHE_TTL_MS); + const entryCap = clampInt(maxCacheEntries, DEFAULT_MAX_CACHE_ENTRIES, 1, MAX_CACHE_ENTRIES_LIMIT); + const cache = new Map(); + + function readCache(key) { + const entry = cache.get(key); + if (!entry) return null; + if (entry.expiresAt <= now()) { + cache.delete(key); + return null; + } + return entry.addresses; + } + + function writeCache(key, addresses, minTtlMs) { + const ttl = Math.min(ttlCapMs, Math.max(MIN_CACHE_TTL_MS, minTtlMs || ttlCapMs)); + if (cache.size >= entryCap) { + const oldest = cache.keys().next(); + if (!oldest.done) cache.delete(oldest.value); + } + cache.set(key, { addresses, expiresAt: now() + ttl }); + } + + function queryServer(server, hostname, family) { + let resolver; + try { + resolver = createResolver({ timeout: timeoutMs }); + resolver.setServers([server]); + } catch (error) { + return Promise.reject(error); + } + const queries = []; + if (family !== 6) { + queries.push( + resolver.resolve4(hostname, { ttl: true }).then((records) => collectAddresses(records, 4)) + ); + } + if (family !== 4) { + queries.push( + resolver.resolve6(hostname, { ttl: true }).then((records) => collectAddresses(records, 6)) + ); + } + if (queries.length === 0) return Promise.reject(new Error('no query family selected')); + + return withTimeout( + Promise.allSettled(queries).then((results) => { + const addresses = []; + const seen = new Set(); + let minTtlMs = null; + for (const result of results) { + if (result.status !== 'fulfilled') continue; + for (const item of result.value.addresses) { + if (seen.has(item.address)) continue; + seen.add(item.address); + addresses.push(item); + if (addresses.length >= MAX_ADDRESSES_PER_RESULT) break; + } + if (result.value.minTtlMs != null) { + minTtlMs = + minTtlMs === null ? result.value.minTtlMs : Math.min(minTtlMs, result.value.minTtlMs); + } + } + if (addresses.length === 0) { + const error = new Error(`No usable address from ${server}`); + error.code = 'ENODATA'; + throw error; + } + return { addresses, minTtlMs }; + }), + timeoutMs, + () => { + try { + resolver.cancel(); + } catch (error) {} + } + ); + } + + /** 按配置顺序取第一个返回可用地址的服务器;顺序即优先级。 */ + function resolveViaTrustedServers(hostname, family) { + const attempts = trustedServers.map((server) => + queryServer(server, hostname, family).then( + (result) => ({ server, result }), + (error) => ({ server, error }) + ) + ); + return Promise.all(attempts).then((results) => { + for (const item of results) { + if (item.result && item.result.addresses.length > 0) return item.result; + } + const error = new Error('All trusted DNS servers failed'); + error.code = 'EAI_AGAIN'; + throw error; + }); + } + + function reply(addresses, wantsAll, callback) { + if (wantsAll) { + callback( + null, + addresses.map((item) => ({ address: item.address, family: item.family })) + ); + return; + } + callback(null, addresses[0].address, addresses[0].family); + } + + /** + * 与 dns.lookup 同签名,可直接作为 http/https Agent 的 lookup 选项。 + */ + function lookup(hostname, options, callback) { + let normalizedOptions = options; + let done = callback; + if (typeof normalizedOptions === 'function') { + done = normalizedOptions; + normalizedOptions = {}; + } + if (typeof done !== 'function') { + throw new TypeError('lookup requires a callback'); + } + const opts = + typeof normalizedOptions === 'number' + ? { family: normalizedOptions } + : normalizedOptions || {}; + const family = normalizeFamily(opts.family); + const wantsAll = opts.all === true; + const host = String(hostname); + + const literalFamily = net.isIP(host); + if (literalFamily) { + queueMicrotask(() => reply([{ address: host, family: literalFamily }], wantsAll, done)); + return; + } + if (trustedServers.length === 0) { + systemLookup(host, normalizedOptions, done); + return; + } + if (shouldUseSystemResolver(host)) { + systemLookup(host, normalizedOptions, done); + return; + } + + const key = `${normalizeHostnameKey(host)}|${family}`; + const cached = readCache(key); + if (cached && cached.length > 0) { + queueMicrotask(() => reply(cached, wantsAll, done)); + return; + } + + resolveViaTrustedServers(host, family).then( + (result) => { + writeCache(key, result.addresses, result.minTtlMs); + reply(result.addresses, wantsAll, done); + }, + (error) => { + if (logger && typeof logger.debug === 'function') { + logger.debug( + { err: error, host }, + 'Trusted upstream DNS failed; falling back to system resolver' + ); + } + systemLookup(host, normalizedOptions, done); + } + ); + } + + function invalidate(hostname) { + const base = normalizeHostnameKey(hostname); + let removed = false; + for (const key of [...cache.keys()]) { + if (key === base || key.startsWith(`${base}|`)) { + cache.delete(key); + removed = true; + } + } + return removed; + } + + return { + lookup, + invalidate, + shouldUseSystemResolver, + servers: Object.freeze([...trustedServers]), + cacheSize: () => cache.size, + }; +} + +module.exports = { + DEFAULT_DNS_SERVERS, + MAX_DNS_SERVERS, + shouldUseSystemResolver, + ADDRESS_UNTRUSTWORTHY_ERROR_CODES, + isUntrustworthyAddressError, + DEFAULT_QUERY_TIMEOUT_MS, + DEFAULT_CACHE_TTL_MS, + MIN_CACHE_TTL_MS, + MAX_CACHE_TTL_MS, + DEFAULT_MAX_CACHE_ENTRIES, + isPublicAddress, + parseDnsServerList, + createUpstreamResolver, +}; diff --git a/lib/proxy/upstream-retry.js b/lib/proxy/upstream-retry.js index af6bf87e..6da6422f 100644 --- a/lib/proxy/upstream-retry.js +++ b/lib/proxy/upstream-retry.js @@ -2,6 +2,70 @@ const MAX_RETRY_DELAY_MS = 120000; const MAX_IDEMPOTENCY_KEY_CHARS = 256; const SAFE_REPLAY_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); +/** + * 在请求体发出之前就失败的 TLS 错误码。 + * + * 背景:DNS 被投毒时连到的服务器持有别人的证书,TLS 校验先于任何应用层字节失败, + * 于是请求从未到达上游。这类失败重放不会产生重复副作用,因此即便是不带 + * Idempotency-Key 的 POST 也应该允许重试——否则一次投毒会让整段对话直接 502。 + * 只收录握手阶段明确失败的错误码;连接中途断开的错误不在此列,仍走原有的 + * 可重放判定。 + */ +const PRE_TRANSMISSION_TLS_ERROR_CODES = new Set([ + 'ERR_TLS_CERT_ALTNAME_INVALID', + 'ERR_TLS_HANDSHAKE_TIMEOUT', + 'UNABLE_TO_VERIFY_LEAF_SIGNATURE', + 'UNABLE_TO_GET_ISSUER_CERT', + 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', + 'SELF_SIGNED_CERT_IN_CHAIN', + 'DEPTH_ZERO_SELF_SIGNED_CERT', + 'CERT_HAS_EXPIRED', + 'CERT_NOT_YET_VALID', + 'ERR_SSL_WRONG_VERSION_NUMBER', + 'ERR_SSL_PACKET_LENGTH_TOO_LONG', + 'ERR_SSL_SSLV3_ALERT_HANDSHAKE_FAILURE', + 'ERR_SSL_TLSV1_ALERT_UNKNOWN_CA', +]); + +function normalizeErrorCode(value) { + return String(value == null ? '' : value) + .trim() + .toUpperCase(); +} + +/** + * 判断失败是否发生在"请求体尚未发出"的阶段,因此重放没有上游副作用。 + * + * 两个来源: + * 1. 错误码本身就能证明握手失败(证书不匹配等),应用层字节从未写出; + * 2. 失败时 TLS 握手尚未完成。TLS 层在握手完成前只缓存应用层写入,握手没完成 + * 就没有任何 HTTP 字节到达网络——这是协议保证,与具体错误码无关,因此 + * "握手阶段被重置"也属于这一类,不必把 ECONNRESET 整体列为可重放。 + * + * 复用连接不享受第 2 条:握手早已完成,复用连接上的失败无法证明请求体未发出。 + */ +function isPreTransmissionFailure(errorCode, context) { + if (PRE_TRANSMISSION_TLS_ERROR_CODES.has(normalizeErrorCode(errorCode))) return true; + const safeContext = context && typeof context === 'object' ? context : {}; + if (safeContext.reusedSocket === true) return false; + return safeContext.tlsHandshakeIncomplete === true; +} + +/** + * 从出站请求对象读取判定所需的连接状态。 + * + * `secureConnecting` 在 TLS 握手期间为 true,握手完成后为 false;握手阶段被重置时 + * 它保持 true,因此错误回调里读取是可靠的。普通 TCP socket 上该属性不存在,按 + * "非 TLS"处理。 + */ +function readPreTransmissionContext(request) { + const socket = request && request.socket ? request.socket : null; + return { + tlsHandshakeIncomplete: !!(socket && socket.secureConnecting === true), + reusedSocket: !!(request && request.reusedSocket === true), + }; +} + function isRetryableStatusCode(statusCode) { return statusCode === 429 || statusCode >= 500; } @@ -138,7 +202,13 @@ function resolveUpstreamRecovery(options = {}) { if (backupProfile && shouldAllowFailover(options)) { return { type: 'failover', profile: backupProfile }; } - if (canReplayRequest(options) && shouldRetryAttempt(attempt, maxRetries)) { + // 握手前失败(典型是 DNS 投毒导致的证书不匹配)没有产生任何上游副作用, + // 允许在响应开始前重试,即使请求是不带幂等键的 POST。 + const replaySafe = + options.responseStarted !== true && + (canReplayRequest(options) || + isPreTransmissionFailure(options.errorCode, options.preTransmissionContext)); + if (replaySafe && shouldRetryAttempt(attempt, maxRetries)) { return { type: 'retry', delayMs: retryDelayMs(attempt, random) }; } } @@ -150,7 +220,10 @@ module.exports = { MAX_RETRY_DELAY_MS, MAX_IDEMPOTENCY_KEY_CHARS, SAFE_REPLAY_METHODS, + PRE_TRANSMISSION_TLS_ERROR_CODES, isRetryableStatusCode, + isPreTransmissionFailure, + readPreTransmissionContext, shouldRetryAttempt, parseRetryAfterMs, retryDelayMs, diff --git a/lib/proxy/validator.js b/lib/proxy/validator.js index 1e4f05b0..475a2c2c 100644 --- a/lib/proxy/validator.js +++ b/lib/proxy/validator.js @@ -2,6 +2,8 @@ const net = require('net'); const { MAX_ROUTING_PATTERN_CHARS, compileSafeRoutingPattern } = require('./safe-routing-pattern'); const { isPublicIpAddress } = require('./external-ingress-probe'); +const MAX_DNS_SERVER_ENTRIES = 8; + function validateConfig(config, options = {}) { const errors = []; const warnings = []; @@ -165,6 +167,39 @@ function validateOptionalBoolean(value, label, errors) { } } +/** + * 抗污染 DNS 服务器列表校验。 + * + * 允许数组或逗号分隔字符串(管理面输入常是字符串);未设置表示启用内置受信列表, + * 显式空值表示退回系统解析。每一项都必须是合法 IP,否则配置错误会静默退化成 + * 系统解析——正是本次 502 的成因,因此这里必须报错而不是忽略。 + */ +function validateOptionalDnsServerList(value, label, errors) { + if (value == null) return; + if (typeof value !== 'string' && !Array.isArray(value)) { + errors.push(`${label} must be an array of IP addresses or a comma-separated string`); + return; + } + const entries = Array.isArray(value) ? value : String(value).split(','); + if (entries.length > MAX_DNS_SERVER_ENTRIES) { + errors.push(`${label} must contain at most ${MAX_DNS_SERVER_ENTRIES} servers`); + return; + } + for (const entry of entries) { + const text = String(entry == null ? '' : entry).trim(); + if (!text) continue; + if (!isIpAddress(text)) { + errors.push(`${label} contains an invalid IP address: ${text}`); + } + } +} + +function isIpAddress(value) { + const text = String(value); + if (net.isIPv4(text)) return true; + return net.isIPv6(text); +} + function validateBoundedString(value, label, errors, maxLength, { required = false } = {}) { if (value == null || value === '') { if (required) errors.push(`${label} is required`); @@ -353,6 +388,11 @@ function validateAppSettings(appSettings, errors = [], label = 'appSettings') { errors, { min: 0, max: 5000 } ); + validateOptionalDnsServerList( + appSettings.upstreamDnsServers, + `${label}.upstreamDnsServers`, + errors + ); return { valid: errors.length === 0, errors }; } diff --git a/proxy.config.example.json b/proxy.config.example.json index 8b6a1b17..123fc774 100644 --- a/proxy.config.example.json +++ b/proxy.config.example.json @@ -18,6 +18,7 @@ "upstreamWarmupIntervalMs": 45000, "upstreamWarmupMaxTargets": 4, "upstreamWarmupStaggerMs": 200, + "upstreamDnsServers": ["1.1.1.1", "8.8.8.8", "223.5.5.5"], "streamDirectRequestBodies": false }, "entryAuth": { diff --git a/scripts/release-proxy-sources.json b/scripts/release-proxy-sources.json index ba7bd77f..1557eb22 100644 --- a/scripts/release-proxy-sources.json +++ b/scripts/release-proxy-sources.json @@ -153,6 +153,7 @@ "lib/proxy/trae-official.js", "lib/proxy/trae-status-handlers.js", "lib/proxy/upstream-cooldown-store.js", + "lib/proxy/upstream-dns.js", "lib/proxy/upstream-request.js", "lib/proxy/upstream-retry.js", "lib/proxy/upstream-warmup.js", diff --git a/test/runtime-http-agents.test.js b/test/runtime-http-agents.test.js index 285af01c..dac424d9 100644 --- a/test/runtime-http-agents.test.js +++ b/test/runtime-http-agents.test.js @@ -1,7 +1,12 @@ 'use strict'; const assert = require('node:assert/strict'); -const { AGENT_OPTIONS, createRuntimeHttpAgents } = require('../lib/proxy/runtime/http-agents'); +const { + AGENT_OPTIONS, + createRuntimeHttpAgents, + readUpstreamDnsConfig, +} = require('../lib/proxy/runtime/http-agents'); +const { DEFAULT_DNS_SERVERS } = require('../lib/proxy/upstream-dns'); let loadCalls = 0; const disabledVerification = createRuntimeHttpAgents({ @@ -36,4 +41,83 @@ assert.throws(() => createRuntimeHttpAgents(), /loadConfig/); assert.throws(() => createRuntimeHttpAgents({ loadConfig() {}, env: null }), /env/); assert.throws(() => createRuntimeHttpAgents({ loadConfig() {}, env: [] }), /env/); +// 抗污染解析必须真正挂到 https agent 上,否则上游仍走系统解析。 +const withDnsGuard = createRuntimeHttpAgents({ + env: {}, + loadConfig: () => ({ appSettings: { upstreamDnsServers: ['1.1.1.1'] } }), +}); +assert.equal(typeof withDnsGuard.createHttpsAgent().options.lookup, 'function'); +assert.deepStrictEqual(withDnsGuard.getUpstreamResolver().servers, ['1.1.1.1']); + +// 同一份服务器列表应复用解析器(保留缓存),列表变化时才重建。 +assert.equal( + withDnsGuard.getUpstreamResolver(), + (withDnsGuard.createHttpsAgent(), withDnsGuard.getUpstreamResolver()) +); + +// 显式空列表表示退回系统解析,agent 不应带 lookup 覆盖。 +const systemResolver = createRuntimeHttpAgents({ + env: {}, + loadConfig: () => ({ appSettings: { upstreamDnsServers: [] } }), +}); +assert.equal(systemResolver.createHttpsAgent().options.lookup, undefined); +assert.equal(systemResolver.getUpstreamResolver(), null); +assert.equal( + systemResolver.createHttpsAgent().options.rejectUnauthorized, + true, + 'DNS 关闭不应影响 TLS 校验' +); + +// 未配置时启用内置受信列表。 +const unsetDns = createRuntimeHttpAgents({ env: {}, loadConfig: () => ({ appSettings: {} }) }); +assert.deepStrictEqual(unsetDns.getUpstreamResolver().servers, [...DEFAULT_DNS_SERVERS]); + +// 失效钩子挂在 agent 上,请求路径才能丢弃被污染的缓存答案。 +const invalidatableAgent = withDnsGuard.createHttpsAgent(); +assert.equal(typeof invalidatableAgent.invalidateUpstreamAddress, 'function'); +assert.equal( + invalidatableAgent.invalidateUpstreamAddress('chatgpt.com', 'ERR_TLS_CERT_ALTNAME_INVALID'), + false, + '空缓存时返回 false,且不得抛错' +); +assert.equal( + invalidatableAgent.invalidateUpstreamAddress('chatgpt.com', 'ECONNRESET'), + false, + '普通连接中断不得清空缓存' +); +assert.equal(invalidatableAgent.invalidateUpstreamAddress('', 'CERT_HAS_EXPIRED'), false); +assert.equal(invalidatableAgent.invalidateUpstreamAddress(null, 'CERT_HAS_EXPIRED'), false); + +// 关闭 DNS 加固时没有缓存可清,钩子仍需安全返回 false 而不是抛错。 +assert.equal( + systemResolver.createHttpsAgent().invalidateUpstreamAddress('chatgpt.com', 'CERT_HAS_EXPIRED'), + false +); + +assert.deepStrictEqual( + readUpstreamDnsConfig(() => ({ appSettings: {} })), + { + enabled: true, + servers: undefined, + } +); +assert.deepStrictEqual( + readUpstreamDnsConfig(() => ({ appSettings: { upstreamDnsServers: '1.1.1.1,8.8.8.8' } })), + { enabled: true, servers: ['1.1.1.1', '8.8.8.8'] } +); +assert.deepStrictEqual( + readUpstreamDnsConfig(() => ({ appSettings: { upstreamDnsServers: '' } })), + { + enabled: false, + servers: [], + } +); +// 配置读取失败时保持默认开启:静默退回系统解析正是本次故障成因。 +assert.deepStrictEqual( + readUpstreamDnsConfig(() => { + throw new Error('broken config'); + }), + { enabled: true, servers: undefined } +); + console.log('[runtime-http-agents.test] ok'); diff --git a/test/upstream-dns.test.js b/test/upstream-dns.test.js new file mode 100644 index 00000000..abe6caa8 --- /dev/null +++ b/test/upstream-dns.test.js @@ -0,0 +1,468 @@ +'use strict'; + +const assert = require('node:assert/strict'); + +const { + DEFAULT_DNS_SERVERS, + MAX_DNS_SERVERS, + ADDRESS_UNTRUSTWORTHY_ERROR_CODES, + isUntrustworthyAddressError, + isPublicAddress, + shouldUseSystemResolver, + parseDnsServerList, + createUpstreamResolver, +} = require('../lib/proxy/upstream-dns'); + +function assertThrowsTypeError(fn, pattern) { + assert.throws(fn, (error) => error instanceof TypeError && pattern.test(error.message)); +} + +/** 造一个可编程的假 Resolver:按服务器与查询类型预置答案或错误。 */ +function createFakeResolverFactory(plans) { + const calls = []; + const createResolver = () => { + let servers = []; + return { + setServers(next) { + servers = [...next]; + }, + resolve4(hostname, options) { + calls.push({ type: 'A', hostname, servers: [...servers], options }); + const plan = plans[`${servers[0]}|A`]; + if (plan === undefined) + return Promise.reject(Object.assign(new Error('no plan'), { code: 'ENOTFOUND' })); + if (plan instanceof Error) return Promise.reject(plan); + return Promise.resolve(plan); + }, + resolve6(hostname, options) { + calls.push({ type: 'AAAA', hostname, servers: [...servers], options }); + const plan = plans[`${servers[0]}|AAAA`]; + if (plan === undefined) + return Promise.reject(Object.assign(new Error('no plan'), { code: 'ENOTFOUND' })); + if (plan instanceof Error) return Promise.reject(plan); + return Promise.resolve(plan); + }, + cancel() { + calls.push({ type: 'cancel', servers: [...servers] }); + }, + }; + }; + return { createResolver, calls }; +} + +function lookupPromise(resolver, hostname, options) { + return new Promise((resolve, reject) => { + resolver.lookup(hostname, options, (error, address, family) => { + if (error) reject(error); + else resolve({ address, family }); + }); + }); +} + +async function testDefaultServerList() { + assert.deepStrictEqual([...DEFAULT_DNS_SERVERS], ['1.1.1.1', '8.8.8.8', '223.5.5.5']); +} + +async function testParseServerListAcceptsArrayAndString() { + assert.deepStrictEqual(parseDnsServerList(['1.1.1.1', '8.8.8.8']), { + servers: ['1.1.1.1', '8.8.8.8'], + invalid: [], + }); + assert.deepStrictEqual(parseDnsServerList('1.1.1.1, 223.5.5.5'), { + servers: ['1.1.1.1', '223.5.5.5'], + invalid: [], + }); + assert.deepStrictEqual(parseDnsServerList(['2606:4700:4700::1111']), { + servers: ['2606:4700:4700::1111'], + invalid: [], + }); +} + +async function testParseServerListRejectsEmptyInvalidAndDuplicateEntries() { + assert.deepStrictEqual(parseDnsServerList(null), { servers: [], invalid: [] }); + assert.deepStrictEqual(parseDnsServerList(''), { servers: [], invalid: [] }); + assert.deepStrictEqual(parseDnsServerList([]), { servers: [], invalid: [] }); + assert.deepStrictEqual(parseDnsServerList(['1.1.1.1', '1.1.1.1']), { + servers: ['1.1.1.1'], + invalid: [], + }); + assert.deepStrictEqual(parseDnsServerList(['1.1.1.1', 'not-an-ip', '1.1.1.1.1', '8.8.8.8', '']), { + servers: ['1.1.1.1', '8.8.8.8'], + invalid: ['not-an-ip', '1.1.1.1.1'], + }); +} + +async function testParseServerListBoundsEntryCount() { + const many = Array.from({ length: 64 }, (_value, index) => `10.0.0.${index + 1}`); + const parsed = parseDnsServerList(many); + assert.strictEqual(parsed.servers.length, MAX_DNS_SERVERS); + assert.deepStrictEqual(parsed.invalid, []); +} + +async function testPublicAddressClassification() { + assert.strictEqual(isPublicAddress('104.18.32.47', 4), true); + assert.strictEqual(isPublicAddress('223.5.5.5'), true); + + for (const address of [ + '0.0.0.0', + '10.1.2.3', + '100.64.0.1', + '127.0.0.1', + '169.254.1.1', + '172.16.5.5', + '192.0.2.5', + '192.168.1.1', + '198.18.0.1', + '198.51.100.7', + '203.0.113.9', + '224.0.0.1', + '240.0.0.1', + '255.255.255.255', + ]) { + assert.strictEqual(isPublicAddress(address, 4), false, `${address} must be non-public`); + } + + assert.strictEqual(isPublicAddress('2606:4700:4700::1111', 6), true); + assert.strictEqual(isPublicAddress('::1', 6), false); + assert.strictEqual(isPublicAddress('::', 6), false); + assert.strictEqual(isPublicAddress('fd00::1', 6), false); + assert.strictEqual(isPublicAddress('fe80::1', 6), false); + assert.strictEqual(isPublicAddress('ff02::1', 6), false); + assert.strictEqual(isPublicAddress('2001:db8::1', 6), false); + assert.strictEqual(isPublicAddress('::ffff:10.0.0.1', 6), false); + assert.strictEqual(isPublicAddress('::ffff:104.18.32.47', 6), true); + + assert.strictEqual(isPublicAddress('not-an-address'), false); + assert.strictEqual(isPublicAddress('', 4), false); + assert.strictEqual(isPublicAddress(null), false); +} + +async function testLocalNamesUseSystemResolver() { + for (const host of ['localhost', 'fileserver', 'printer.local', 'db.internal', 'app.corp']) { + assert.strictEqual(shouldUseSystemResolver(host), true, `${host} must use system resolver`); + } + for (const host of ['chatgpt.com', 'api.openai.com', 'sub.domain.co.uk']) { + assert.strictEqual(shouldUseSystemResolver(host), false, `${host} must use trusted servers`); + } + assert.strictEqual(shouldUseSystemResolver(''), true); + assert.strictEqual(shouldUseSystemResolver(null), true); +} + +async function testUntrustworthyErrorClassification() { + assert.strictEqual(isUntrustworthyAddressError('ERR_TLS_CERT_ALTNAME_INVALID'), true); + assert.strictEqual(isUntrustworthyAddressError('err_tls_cert_altname_invalid'), true); + assert.strictEqual(isUntrustworthyAddressError(' CERT_HAS_EXPIRED '), true); + assert.strictEqual(isUntrustworthyAddressError('ECONNRESET'), false); + assert.strictEqual(isUntrustworthyAddressError('EPIPE'), false); + assert.strictEqual(isUntrustworthyAddressError('ETIMEDOUT'), false); + assert.strictEqual(isUntrustworthyAddressError(undefined), false); + assert.strictEqual(isUntrustworthyAddressError(null), false); + assert.strictEqual(ADDRESS_UNTRUSTWORTHY_ERROR_CODES.has('ECONNREFUSED'), true); +} + +async function testResolverFallsBackToSecondServer() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': Object.assign(new Error('timeout'), { code: 'ETIMEDOUT' }), + '8.8.8.8|A': [{ address: '104.18.32.47', ttl: 60 }], + }); + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1', '8.8.8.8'], + createResolver, + logger: null, + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.deepStrictEqual(result, { address: '104.18.32.47', family: 4 }); + assert.ok(calls.some((call) => call.servers[0] === '1.1.1.1')); + assert.ok(calls.some((call) => call.servers[0] === '8.8.8.8')); +} + +async function testResolverPrefersFirstServerOrder() { + const { createResolver } = createFakeResolverFactory({ + '223.5.5.5|A': [{ address: '203.0.113.10', ttl: 60 }], + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 60 }], + }); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1', '223.5.5.5'], createResolver }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(result.address, '104.18.32.47'); +} + +async function testResolverDropsNonPublicAnswers() { + const { createResolver } = createFakeResolverFactory({ + '1.1.1.1|A': [ + { address: '10.0.0.9', ttl: 60 }, + { address: '127.0.0.1', ttl: 60 }, + { address: '104.18.32.47', ttl: 60 }, + ], + }); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1'], createResolver }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(result.address, '104.18.32.47'); +} + +async function testResolverTreatsAllPrivateAnswersAsFailure() { + const { createResolver } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '192.168.1.1', ttl: 60 }], + '8.8.8.8|A': [{ address: '10.0.0.1', ttl: 60 }], + }); + const systemCalls = []; + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1', '8.8.8.8'], + createResolver, + systemLookup: (hostname, options, callback) => { + systemCalls.push({ hostname, options }); + callback(null, '104.18.32.47', 4); + }, + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.deepStrictEqual(result, { address: '104.18.32.47', family: 4 }); + assert.strictEqual(systemCalls.length, 1); +} + +async function testResolverFallsBackToSystemWhenAllServersFail() { + const { createResolver } = createFakeResolverFactory({ + '1.1.1.1|A': Object.assign(new Error('servfail'), { code: 'ESERVFAIL' }), + '8.8.8.8|A': Object.assign(new Error('refused'), { code: 'EREFUSED' }), + }); + const systemCalls = []; + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1', '8.8.8.8'], + createResolver, + systemLookup: (hostname, options, callback) => { + systemCalls.push(hostname); + callback(null, '172.64.155.209', 4); + }, + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(result.address, '172.64.155.209'); + assert.deepStrictEqual(systemCalls, ['chatgpt.com']); +} + +async function testResolverEmptyServerListUsesSystemResolution() { + let trustedQueries = 0; + const resolver = createUpstreamResolver({ + servers: [], + createResolver: () => { + trustedQueries += 1; + throw new Error('trusted resolver must not be created'); + }, + systemLookup: (hostname, options, callback) => callback(null, '104.18.32.47', 4), + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.deepStrictEqual(result, { address: '104.18.32.47', family: 4 }); + assert.strictEqual(trustedQueries, 0); +} + +async function testResolverLocalNamesUseSystemResolution() { + let trustedQueries = 0; + const systemHosts = []; + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver: () => { + trustedQueries += 1; + throw new Error('trusted resolver must not be created for local names'); + }, + systemLookup: (hostname, options, callback) => { + systemHosts.push(hostname); + callback(null, '127.0.0.1', 4); + }, + }); + await lookupPromise(resolver, 'fileserver', {}); + await lookupPromise(resolver, 'printer.local', {}); + assert.strictEqual(trustedQueries, 0); + assert.deepStrictEqual(systemHosts, ['fileserver', 'printer.local']); +} + +async function testResolverSkipsLookupForIpLiterals() { + let trustedQueries = 0; + let systemCalls = 0; + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver: () => { + trustedQueries += 1; + throw new Error('must not query for an IP literal'); + }, + systemLookup: () => { + systemCalls += 1; + }, + }); + const ipv4 = await lookupPromise(resolver, '104.18.32.47', {}); + const ipv6 = await lookupPromise(resolver, '::1', {}); + assert.deepStrictEqual(ipv4, { address: '104.18.32.47', family: 4 }); + assert.deepStrictEqual(ipv6, { address: '::1', family: 6 }); + assert.strictEqual(trustedQueries, 0); + assert.strictEqual(systemCalls, 0); +} + +async function testResolverCacheAvoidsRepeatQueries() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 120 }], + }); + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver, + cacheTtlMs: 60000, + }); + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + const afterFirst = calls.length; + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(calls.length, afterFirst); + assert.strictEqual(resolver.cacheSize(), 1); +} + +async function testResolverInvalidateForcesFreshQuery() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 120 }], + }); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1'], createResolver }); + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + const afterFirst = calls.length; + assert.strictEqual(resolver.invalidate('chatgpt.com'), true); + assert.strictEqual(resolver.cacheSize(), 0); + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.ok(calls.length > afterFirst); + assert.strictEqual(resolver.invalidate('unknown.example'), false); + assert.strictEqual(resolver.invalidate(''), false); +} + +async function testResolverCacheExpires() { + let clock = 0; + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 1 }], + }); + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver, + cacheTtlMs: 60000, + now: () => clock, + }); + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + const afterFirst = calls.length; + clock = 2000; + await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.ok(calls.length > afterFirst); +} + +async function testResolverCapsCacheEntries() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 120 }], + }); + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver, + maxCacheEntries: 1, + }); + await lookupPromise(resolver, 'a.example', { family: 4 }); + await lookupPromise(resolver, 'b.example', { family: 4 }); + assert.strictEqual(resolver.cacheSize(), 1); + const before = calls.length; + await lookupPromise(resolver, 'a.example', { family: 4 }); + assert.ok(calls.length > before); +} + +async function testResolverHonoursFamilyAndAllFlag() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 60 }], + '1.1.1.1|AAAA': [{ address: '2606:4700:4700::1111', ttl: 60 }], + }); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1'], createResolver }); + const all = await new Promise((resolve, reject) => { + resolver.lookup('chatgpt.com', { all: true }, (error, addresses) => + error ? reject(error) : resolve(addresses) + ); + }); + assert.ok(all.length >= 1); + for (const entry of all) { + assert.strictEqual(typeof entry.address, 'string'); + assert.ok(entry.family === 4 || entry.family === 6); + } + assert.ok(calls.some((call) => call.type === 'A')); +} + +async function testResolverFamilySixOnlyQueriesAaaa() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|AAAA': [{ address: '2606:4700:4700::1111', ttl: 60 }], + }); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1'], createResolver }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 6 }); + assert.deepStrictEqual(result, { address: '2606:4700:4700::1111', family: 6 }); + assert.deepStrictEqual( + calls.filter((call) => call.type === 'A'), + [] + ); +} + +async function testResolverTimesOutSlowServers() { + const cancelled = []; + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + queryTimeoutMs: 50, + createResolver: () => ({ + setServers() {}, + resolve4: () => new Promise(() => {}), + resolve6: () => new Promise(() => {}), + cancel() { + cancelled.push(true); + }, + }), + systemLookup: (hostname, options, callback) => callback(null, '104.18.32.47', 4), + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(result.address, '104.18.32.47'); + assert.ok(cancelled.length >= 1); +} + +async function testResolverBoundsTimeoutAndTtlOptions() { + const { createResolver, calls } = createFakeResolverFactory({ + '1.1.1.1|A': [{ address: '104.18.32.47', ttl: 999999 }], + }); + const resolver = createUpstreamResolver({ + servers: ['1.1.1.1'], + createResolver, + queryTimeoutMs: -5, + cacheTtlMs: 1, + maxCacheEntries: -3, + }); + const result = await lookupPromise(resolver, 'chatgpt.com', { family: 4 }); + assert.strictEqual(result.address, '104.18.32.47'); + assert.ok(calls.length >= 1); +} + +async function testResolverRejectsInvalidDependencies() { + assertThrowsTypeError(() => createUpstreamResolver({ systemLookup: null }), /systemLookup/); + assertThrowsTypeError(() => createUpstreamResolver({ createResolver: 'nope' }), /createResolver/); + const resolver = createUpstreamResolver({ servers: ['1.1.1.1'] }); + assert.throws(() => resolver.lookup('chatgpt.com', {}), /callback/); +} + +async function main() { + await testDefaultServerList(); + await testParseServerListAcceptsArrayAndString(); + await testParseServerListRejectsEmptyInvalidAndDuplicateEntries(); + await testParseServerListBoundsEntryCount(); + await testPublicAddressClassification(); + await testLocalNamesUseSystemResolver(); + await testUntrustworthyErrorClassification(); + await testResolverFallsBackToSecondServer(); + await testResolverPrefersFirstServerOrder(); + await testResolverDropsNonPublicAnswers(); + await testResolverTreatsAllPrivateAnswersAsFailure(); + await testResolverFallsBackToSystemWhenAllServersFail(); + await testResolverEmptyServerListUsesSystemResolution(); + await testResolverLocalNamesUseSystemResolution(); + await testResolverSkipsLookupForIpLiterals(); + await testResolverCacheAvoidsRepeatQueries(); + await testResolverInvalidateForcesFreshQuery(); + await testResolverCacheExpires(); + await testResolverCapsCacheEntries(); + await testResolverHonoursFamilyAndAllFlag(); + await testResolverFamilySixOnlyQueriesAaaa(); + await testResolverTimesOutSlowServers(); + await testResolverBoundsTimeoutAndTtlOptions(); + await testResolverRejectsInvalidDependencies(); + console.log('[upstream-dns.test] ok'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/upstream-retry-handshake.test.js b/test/upstream-retry-handshake.test.js new file mode 100644 index 00000000..b0c2eea5 --- /dev/null +++ b/test/upstream-retry-handshake.test.js @@ -0,0 +1,157 @@ +'use strict'; + +/** + * 回归:用真实 socket 验证"握手阶段被重置"这一判据。 + * + * 纯函数测试只能证明逻辑本身;这里证明 Node 在真实连接上产生的状态确实能被 + * readPreTransmissionContext 正确识别,否则线上那类 ECONNRESET 仍然会漏判。 + */ + +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const https = require('node:https'); +const net = require('node:net'); +const os = require('node:os'); +const path = require('node:path'); + +const { + readPreTransmissionContext, + isPreTransmissionFailure, +} = require('../lib/proxy/upstream-retry'); + +const repoRoot = path.resolve(__dirname, '..'); + +function listen(server) { + return new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }); +} + +function close(server) { + return new Promise((resolve) => server.close(() => resolve())); +} + +function send(port, agent, body) { + return new Promise((resolve) => { + const request = https.request( + { + host: '127.0.0.1', + port, + path: '/', + method: 'POST', + rejectUnauthorized: false, + servername: 'localhost', + agent, + headers: { 'content-type': 'text/plain', 'content-length': Buffer.byteLength(body) }, + }, + (response) => { + response.on('data', () => {}); + response.on('end', () => + resolve({ + phase: 'complete', + status: response.statusCode, + context: readPreTransmissionContext(request), + }) + ); + response.on('error', (error) => + resolve({ + phase: 'response-error', + code: error.code, + context: readPreTransmissionContext(request), + }) + ); + } + ); + request.on('error', (error) => + resolve({ + phase: 'request-error', + code: error.code, + context: readPreTransmissionContext(request), + }) + ); + request.end(body); + }); +} + +function buildServerCertificate() { + const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'hpm-retry-cert-')); + const previous = process.env.PROXY_RUNTIME_DIR; + process.env.PROXY_RUNTIME_DIR = runtimeDir; + try { + const cert = require(path.join(repoRoot, 'lib', 'proxy', 'cert')); + cert.ensureCert(); + return { + cert: fs.readFileSync(cert.CERT_FILE), + key: fs.readFileSync(cert.KEY_FILE), + cleanup: () => fs.rmSync(runtimeDir, { recursive: true, force: true }), + }; + } finally { + if (previous === undefined) delete process.env.PROXY_RUNTIME_DIR; + else process.env.PROXY_RUNTIME_DIR = previous; + } +} + +async function main() { + const credentials = buildServerCertificate(); + try { + // 1) 握手阶段被重置:真实 TCP 服务器在 TLS 完成前断开。 + const resetServer = net.createServer((socket) => setTimeout(() => socket.destroy(), 40)); + const resetPort = await listen(resetServer); + const resetResult = await send(resetPort, new https.Agent({ keepAlive: true }), 'x'.repeat(64)); + await close(resetServer); + + assert.strictEqual(resetResult.phase, 'request-error'); + assert.strictEqual( + resetResult.context.tlsHandshakeIncomplete, + true, + '握手未完成必须被识别出来,否则投毒重置仍会 502' + ); + assert.strictEqual(resetResult.context.reusedSocket, false); + assert.strictEqual(isPreTransmissionFailure(resetResult.code, resetResult.context), true); + + // 2) 握手完成后的正常响应:绝不能被误判成可重放。 + const okServer = https.createServer( + { cert: credentials.cert, key: credentials.key }, + (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('ok'); + } + ); + const okPort = await listen(okServer); + const okResult = await send(okPort, new https.Agent({ keepAlive: true }), 'x'.repeat(64)); + assert.strictEqual(okResult.phase, 'complete'); + assert.strictEqual(okResult.status, 200); + assert.strictEqual(okResult.context.tlsHandshakeIncomplete, false); + assert.strictEqual(isPreTransmissionFailure('ECONNRESET', okResult.context), false); + + // 3) 握手完成后连接中断:必须回到显式幂等契约,不能借握手豁免重放。 + const midServer = https.createServer( + { cert: credentials.cert, key: credentials.key }, + (req, res) => { + res.writeHead(200, { 'content-type': 'text/plain', 'content-length': 1000 }); + res.write('partial'); + setTimeout(() => res.socket && res.socket.destroy(), 40); + } + ); + const midPort = await listen(midServer); + const midResult = await send(midPort, new https.Agent({ keepAlive: false }), 'x'.repeat(64)); + await close(midServer); + await close(okServer); + + assert.strictEqual(midResult.context.tlsHandshakeIncomplete, false); + assert.strictEqual( + isPreTransmissionFailure(midResult.code || 'ECONNRESET', midResult.context), + false + ); + } finally { + credentials.cleanup(); + } + + console.log('[upstream-retry-handshake.test] ok'); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/test/upstream-retry.test.js b/test/upstream-retry.test.js index 7237f2e2..76f98826 100644 --- a/test/upstream-retry.test.js +++ b/test/upstream-retry.test.js @@ -11,6 +11,8 @@ const { isValidIdempotencyKey, canReplayRequest, resolveUpstreamRecovery, + isPreTransmissionFailure, + readPreTransmissionContext, } = require('../lib/proxy/upstream-retry'); function testRetryableStatusCodes() { @@ -209,6 +211,157 @@ function testUnsafeRequestsRequireExplicitIdempotencyContract() { ); } +/** + * 回归:DNS 被投毒时 chatgpt.com 会连到持有别人证书的服务器,TLS 校验以 + * ERR_TLS_CERT_ALTNAME_INVALID 失败。请求体从未发出,重放没有副作用,因此不 + * 带 Idempotency-Key 的 POST 也必须能重试,否则一次投毒就会让整段对话 502。 + */ +function testPreTransmissionTlsFailuresAreRetryableForUnsafeMethods() { + assert.strictEqual(isPreTransmissionFailure('ERR_TLS_CERT_ALTNAME_INVALID'), true); + assert.strictEqual(isPreTransmissionFailure('err_tls_cert_altname_invalid'), true); + assert.strictEqual(isPreTransmissionFailure(' UNABLE_TO_VERIFY_LEAF_SIGNATURE '), true); + assert.strictEqual(isPreTransmissionFailure('ERR_SSL_WRONG_VERSION_NUMBER'), true); + assert.strictEqual(isPreTransmissionFailure('ECONNRESET'), false); + assert.strictEqual(isPreTransmissionFailure('EPIPE'), false); + assert.strictEqual(isPreTransmissionFailure('ETIMEDOUT'), false); + assert.strictEqual(isPreTransmissionFailure(undefined), false); + assert.strictEqual(isPreTransmissionFailure(null), false); + + const poisoned = { + kind: 'error', + attempt: 1, + maxRetries: 2, + prepared: { profile: {} }, + profiles: [], + method: 'POST', + headers: {}, + errorCode: 'ERR_TLS_CERT_ALTNAME_INVALID', + random: () => 0, + }; + assert.deepStrictEqual(resolveUpstreamRecovery(poisoned), { type: 'retry', delayMs: 2000 }); + + // 重试次数用尽后必须停下,不能无限重放。 + assert.deepStrictEqual(resolveUpstreamRecovery({ ...poisoned, attempt: 3 }), { type: 'none' }); + + // 响应已经开始时不得重试,避免客户端收到重复内容。 + assert.deepStrictEqual(resolveUpstreamRecovery({ ...poisoned, responseStarted: true }), { + type: 'none', + }); + + // 普通连接中断仍需依赖显式幂等契约,不能借 TLS 豁免通道。 + assert.deepStrictEqual(resolveUpstreamRecovery({ ...poisoned, errorCode: 'ECONNRESET' }), { + type: 'none', + }); +} + +/** + * 回归:DNS 投毒除了让证书不匹配,还可能让握手阶段直接被重置,错误是 + * ECONNRESET + "disconnected before secure TLS connection was established"。 + * TLS 握手未完成时应用层字节从未送出,重放同样安全,因此不能因为错误码是 + * ECONNRESET 就整体放行——只认"握手未完成"这一结构性证据。 + */ +function testHandshakePhaseResetsAreReplayableWithoutWeakeningResetsInGeneral() { + // 握手未完成:可重放,即使是不带幂等键的 POST。 + assert.strictEqual( + isPreTransmissionFailure('ECONNRESET', { tlsHandshakeIncomplete: true, reusedSocket: false }), + true + ); + // 握手已完成(或复用连接):ECONNRESET 不享受豁免。 + assert.strictEqual( + isPreTransmissionFailure('ECONNRESET', { tlsHandshakeIncomplete: false, reusedSocket: false }), + false + ); + assert.strictEqual( + isPreTransmissionFailure('ECONNRESET', { tlsHandshakeIncomplete: true, reusedSocket: true }), + false + ); + // 没有上下文时保持原有保守行为。 + assert.strictEqual(isPreTransmissionFailure('ECONNRESET'), false); + assert.strictEqual(isPreTransmissionFailure('ECONNRESET', null), false); + assert.strictEqual(isPreTransmissionFailure('ECONNRESET', 'nope'), false); + assert.strictEqual(isPreTransmissionFailure('EPIPE', { tlsHandshakeIncomplete: false }), false); + // 错误码本身足够时,不需要任何上下文。 + assert.strictEqual( + isPreTransmissionFailure('ERR_TLS_CERT_ALTNAME_INVALID', { reusedSocket: true }), + true + ); + + const baseOptions = { + kind: 'error', + attempt: 1, + maxRetries: 2, + prepared: { profile: {} }, + profiles: [], + method: 'POST', + headers: {}, + errorCode: 'ECONNRESET', + random: () => 0, + }; + assert.deepStrictEqual( + resolveUpstreamRecovery({ + ...baseOptions, + preTransmissionContext: { tlsHandshakeIncomplete: true, reusedSocket: false }, + }), + { type: 'retry', delayMs: 2000 } + ); + assert.deepStrictEqual( + resolveUpstreamRecovery({ + ...baseOptions, + preTransmissionContext: { tlsHandshakeIncomplete: false, reusedSocket: false }, + }), + { type: 'none' } + ); + // 重试次数用尽后必须停下。 + assert.deepStrictEqual( + resolveUpstreamRecovery({ + ...baseOptions, + attempt: 3, + preTransmissionContext: { tlsHandshakeIncomplete: true, reusedSocket: false }, + }), + { type: 'none' } + ); + // 响应已经开始时不得重放。 + assert.deepStrictEqual( + resolveUpstreamRecovery({ + ...baseOptions, + responseStarted: true, + preTransmissionContext: { tlsHandshakeIncomplete: true, reusedSocket: false }, + }), + { type: 'none' } + ); +} + +/** + * 上下文读取必须只依赖连接的真实状态,不能把普通 socket 误判成握手中的 TLS。 + */ +function testPreTransmissionContextReadsSocketState() { + assert.deepStrictEqual(readPreTransmissionContext(null), { + tlsHandshakeIncomplete: false, + reusedSocket: false, + }); + assert.deepStrictEqual(readPreTransmissionContext(undefined), { + tlsHandshakeIncomplete: false, + reusedSocket: false, + }); + assert.deepStrictEqual(readPreTransmissionContext({}), { + tlsHandshakeIncomplete: false, + reusedSocket: false, + }); + // 普通 HTTP socket 没有 secureConnecting,必须判为握手中=false。 + assert.deepStrictEqual(readPreTransmissionContext({ socket: { encrypted: false } }), { + tlsHandshakeIncomplete: false, + reusedSocket: false, + }); + assert.deepStrictEqual( + readPreTransmissionContext({ socket: { secureConnecting: true }, reusedSocket: true }), + { tlsHandshakeIncomplete: true, reusedSocket: true } + ); + assert.deepStrictEqual( + readPreTransmissionContext({ socket: { secureConnecting: false }, reusedSocket: false }), + { tlsHandshakeIncomplete: false, reusedSocket: false } + ); +} + function testReplayValidationRejectsMalformedBoundaryValues() { assert.strictEqual(isValidIdempotencyKey(''), false); assert.strictEqual(isValidIdempotencyKey('x'.repeat(256)), true); @@ -242,6 +395,9 @@ testErrorFailoverOnlyFirstAttempt(); testRecoveryPrefersFailoverBeforeRetry(); testRecoveryRetriesWhenNoBackupExists(); testUnsafeRequestsRequireExplicitIdempotencyContract(); +testPreTransmissionTlsFailuresAreRetryableForUnsafeMethods(); +testHandshakePhaseResetsAreReplayableWithoutWeakeningResetsInGeneral(); +testPreTransmissionContextReadsSocketState(); testReplayValidationRejectsMalformedBoundaryValues(); console.log('[upstream-retry.test] ok'); diff --git a/test/validator.test.js b/test/validator.test.js index 71bab114..02528022 100644 --- a/test/validator.test.js +++ b/test/validator.test.js @@ -356,4 +356,70 @@ const { assert.strictEqual(validTimeout.valid, true); } +/** + * 回归:DNS 配置写错时必须报错。 + * + * 这些项如果被静默忽略,代理会退回系统解析,正是本次 chatgpt.com 被投毒导致 + * 502 的成因,所以非法 IP、错误类型、超长列表都要显式失败。 + */ +function testUpstreamDnsServersValidation() { + const baseConfig = (upstreamDnsServers) => ({ + activeProfile: 'main', + profiles: [ + { + id: 'main', + protocol: 'openai', + targetUrl: 'https://api.example.test/v1', + }, + ], + localServer: { host: 'localhost', listenPort: 3443, basePath: '/v1' }, + appSettings: { upstreamDnsServers }, + }); + + // 正常:数组、逗号分隔字符串、IPv6 都合法。 + assert.strictEqual(validateConfig(baseConfig(['1.1.1.1', '8.8.8.8', '223.5.5.5'])).valid, true); + assert.strictEqual(validateConfig(baseConfig('1.1.1.1,8.8.8.8')).valid, true); + assert.strictEqual(validateConfig(baseConfig(['2606:4700:4700::1111'])).valid, true); + // 空值表示退回系统解析,属于合法配置。 + assert.strictEqual(validateConfig(baseConfig([])).valid, true); + assert.strictEqual(validateConfig(baseConfig('')).valid, true); + assert.strictEqual(validateConfig(baseConfig(null)).valid, true); + assert.strictEqual(validateConfig(baseConfig(undefined)).valid, true); + + // 非法:错误类型、非法 IP、超长列表。 + const wrongType = validateConfig(baseConfig({ servers: ['1.1.1.1'] })); + assert.strictEqual(wrongType.valid, false); + assert.ok( + wrongType.errors.some((err) => + err.includes('appSettings.upstreamDnsServers must be an array of IP addresses') + ) + ); + + const invalidIp = validateConfig(baseConfig(['1.1.1.1', 'not-an-ip'])); + assert.strictEqual(invalidIp.valid, false); + assert.ok( + invalidIp.errors.some((err) => + err.includes('appSettings.upstreamDnsServers contains an invalid IP address: not-an-ip') + ) + ); + + const tooMany = validateConfig( + baseConfig(Array.from({ length: 9 }, (_value, index) => `10.0.0.${index + 1}`)) + ); + assert.strictEqual(tooMany.valid, false); + assert.ok( + tooMany.errors.some((err) => + err.includes('appSettings.upstreamDnsServers must contain at most 8 servers') + ) + ); + + // 超长列表即使内容合法也要拒绝,避免为一个错误配置反复失败。 + const boundary = validateConfig( + baseConfig(Array.from({ length: 8 }, (_value, index) => `10.0.0.${index + 1}`)) + ); + assert.strictEqual(boundary.valid, true); +} + +testUpstreamDnsServersValidation(); + console.log('[validator.test] ok');