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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/proxy-runtime.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
3 changes: 3 additions & 0 deletions lib/proxy/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
14 changes: 14 additions & 0 deletions lib/proxy/core-request-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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,
});
Expand Down
105 changes: 95 additions & 10 deletions lib/proxy/runtime/http-agents.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -10,39 +15,119 @@ 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');
}
if (!env || typeof env !== 'object' || Array.isArray(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),
};
Comment on lines +71 to +75

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor DNS settings under the TLS environment override

When PROXY_ALLOW_UNTRUSTED_UPSTREAM=1, this branch never calls loadConfig, so lastKnownConfig remains null and the resolver always uses the built-in DNS servers. Consequently, an explicit upstreamDnsServers: [] cannot disable the feature and a custom resolver list is also ignored whenever the TLS environment override is enabled, which can break development or private-upstream setups that require system DNS. The environment override should affect certificate verification without bypassing the independent DNS configuration.

Useful? React with 👍 / 👎.

}
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,
};
}

module.exports = {
AGENT_OPTIONS,
createRuntimeHttpAgents,
readUpstreamDnsConfig,
upstreamDnsConfigFromConfig,
};
Loading
Loading