fix: Node.js TLS 代理动态识别上游主机
Some checks failed
CI / test (push) Has been cancelled
CI / golangci-lint (push) Has been cancelled
Security Scan / backend-security (push) Has been cancelled
Security Scan / frontend-security (push) Has been cancelled

- Go: 通过 X-Forwarded-Host 传递原始目标主机给 Node.js 代理
- Node.js: 读取 X-Forwarded-Host 动态连接到正确的上游主机
- 所有 HTTPS 上游请求统一走代理,不再固定绑定 api.anthropic.com
- Gemini/Sora 等不同上游自动识别,无需手动配置
This commit is contained in:
win 2026-03-22 01:09:39 +08:00
parent 2fff535bcd
commit 5c587c1095
2 changed files with 37 additions and 10 deletions

View File

@ -124,7 +124,7 @@ func NewHTTPUpstream(cfg *config.Config) service.HTTPUpstream {
// - 调用方必须关闭 resp.Body否则会导致 inFlight 计数泄漏
// - inFlight > 0 的客户端不会被淘汰,确保活跃请求不被中断
func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
// 优先使用 Node.js TLS 代理模式(全局生效,不依赖账号级 TLS 指纹开关)
// 优先使用 Node.js TLS 代理模式:拦截所有 HTTPS 上游请求
if s.isNodeTLSProxyEnabled() && req != nil && req.URL != nil && req.URL.Scheme == "https" {
return s.doViaNodeTLSProxy(req, accountID, accountConcurrency)
}
@ -181,8 +181,7 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
}
// 优先使用 Node.js TLS 代理模式
if s.isNodeTLSProxyEnabled() {
return s.doViaNodeTLSProxy(req, accountID, accountConcurrency)
if s.isNodeTLSProxyEnabled() && s.shouldRouteViaNodeProxy(req) { return s.doViaNodeTLSProxy(req, accountID, accountConcurrency)
}
// TLS 指纹已启用,记录调试日志
@ -247,10 +246,27 @@ func (s *httpUpstreamService) isNodeTLSProxyEnabled() bool {
return s.cfg.Gateway.NodeTLSProxy.Enabled
}
// shouldRouteViaNodeProxy 判断请求是否应该走 Node.js TLS 代理
// 仅拦截发往配置的上游主机(默认 api.anthropic.com的 HTTPS 请求,
// 其他请求(如 Gemini、Sora走原有路径。
func (s *httpUpstreamService) shouldRouteViaNodeProxy(req *http.Request) bool {
if req == nil || req.URL == nil || req.URL.Scheme != "https" {
return false
}
upstreamHost := s.cfg.Gateway.NodeTLSProxy.UpstreamHost
if upstreamHost == "" {
upstreamHost = "api.anthropic.com"
}
// 比较请求的目标主机(去掉端口)
reqHost := req.URL.Hostname()
return reqHost == upstreamHost
}
// doViaNodeTLSProxy 通过 Node.js TLS 代理发送请求
// 将 HTTPS 请求改为 HTTP 明文发送到本地 Node.js 代理,
// 由 Node.js 进程使用原生 TLS 栈完成到上游的 HTTPS 连接。
// 这样 JA3/JA4 指纹天然匹配 Node.js (Claude CLI)。
// 原始目标主机通过 X-Forwarded-Host 传递给 Node.js 代理,
// 代理据此动态连接到正确的上游主机。
func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, accountID int64, accountConcurrency int) (*http.Response, error) {
proxyCfg := s.cfg.Gateway.NodeTLSProxy
listenHost := proxyCfg.ListenHost
@ -262,6 +278,10 @@ func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, accountID int
listenPort = 3456
}
// 保存原始目标主机,通过自定义头传给 Node.js 代理
originalHost := req.URL.Host
req.Header.Set("X-Forwarded-Host", originalHost)
// 重写请求 URLhttps://api.anthropic.com/v1/... → http://127.0.0.1:3456/v1/...
originalURL := req.URL.String()
req.URL.Scheme = "http"
@ -270,11 +290,14 @@ func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, accountID int
slog.Debug("node_tls_proxy_rewrite",
"account_id", accountID,
"original_url", originalURL,
"original_host", originalHost,
"rewritten_to", req.URL.String(),
)
// 递归保护:标记已经过代理重写,避免 Do() 再次进入本方法
req.URL.Scheme = "http" // Do() 只拦截 scheme=="https"http 会走正常路径
// 通过标准 HTTP 客户端发送(不需要 TLS代理是本地 HTTP
// proxyURL 为空(直连本地),使用标准 Do 路径
return s.Do(req, "", accountID, accountConcurrency)
}

View File

@ -85,24 +85,28 @@ function connectViaProxy(proxyUrl, targetHost, targetPort) {
// ─── 构建上游请求选项 ──────────────────────────────────────
function buildUpstreamOptions(req) {
// 复制头,重写 host
// 动态确定上游主机:优先使用 X-Forwarded-Host回退到 UPSTREAM_HOST 配置
const targetHost = req.headers['x-forwarded-host'] || UPSTREAM_HOST;
// 复制头,重写 host 为实际目标
const headers = { ...req.headers };
headers.host = UPSTREAM_HOST;
// 移除 hop-by-hop 头
headers.host = targetHost;
// 移除内部头和 hop-by-hop 头
delete headers['x-forwarded-host'];
delete headers['connection'];
delete headers['keep-alive'];
delete headers['proxy-connection'];
delete headers['transfer-encoding'];
return {
hostname: UPSTREAM_HOST,
hostname: targetHost,
port: 443,
path: req.url,
method: req.method,
headers,
// 关键:不设置任何自定义 TLS 选项
// 让 Node.js 使用默认 TLS stack → JA3/JA4 天然匹配
servername: UPSTREAM_HOST, // SNI
servername: targetHost, // SNI
timeout: CONNECT_TIMEOUT,
};
}