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');