diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index e90e56af..c1fe02bc 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -456,6 +456,11 @@ type GatewayConfig struct { // TLSFingerprint: TLS指纹伪装配置 TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"` + // NodeTLSProxy: Node.js TLS 代理配置 + // 启用后,上游请求通过本地 Node.js 进程发起 TLS 连接, + // 实现天然 JA3/JA4 指纹匹配(无需 uTLS 模拟) + NodeTLSProxy NodeTLSProxyConfig `mapstructure:"node_tls_proxy"` + // UsageRecord: 使用量记录异步队列配置(有界队列 + 固定 worker) UsageRecord GatewayUsageRecordConfig `mapstructure:"usage_record"` @@ -669,6 +674,23 @@ type TLSProfileConfig struct { PointFormats []uint8 `mapstructure:"point_formats"` } +// NodeTLSProxyConfig Node.js TLS 代理配置 +// 启用后,上游 HTTPS 请求不再由 Go 直接发起,而是通过本地 Node.js 进程中转。 +// Node.js 使用原生 OpenSSL TLS 栈,其 JA3/JA4 指纹天然匹配 Claude CLI。 +type NodeTLSProxyConfig struct { + // Enabled: 是否启用 Node.js TLS 代理(默认关闭,保持向后兼容) + Enabled bool `mapstructure:"enabled"` + // ListenPort: Node.js 代理监听端口(默认 3456) + ListenPort int `mapstructure:"listen_port"` + // ListenHost: Node.js 代理监听地址(默认 127.0.0.1) + ListenHost string `mapstructure:"listen_host"` + // HealthPath: 健康检查路径(默认 /__health) + HealthPath string `mapstructure:"health_path"` + // UpstreamHost: 上游目标主机(默认 api.anthropic.com) + // 通常不需要修改,除非需要指向不同的 API 端点 + UpstreamHost string `mapstructure:"upstream_host"` +} + // GatewaySchedulingConfig accounts scheduling configuration. type GatewaySchedulingConfig struct { // 粘性会话排队配置 @@ -1447,6 +1469,13 @@ func setDefaults() { viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60) viper.SetDefault("gateway.tls_fingerprint.enabled", true) + + // Node.js TLS Proxy 默认值 + viper.SetDefault("gateway.node_tls_proxy.enabled", false) + viper.SetDefault("gateway.node_tls_proxy.listen_port", 3456) + viper.SetDefault("gateway.node_tls_proxy.listen_host", "127.0.0.1") + viper.SetDefault("gateway.node_tls_proxy.health_path", "/__health") + viper.SetDefault("gateway.node_tls_proxy.upstream_host", "api.anthropic.com") viper.SetDefault("concurrency.ping_interval", 10) // Sora 直连配置 diff --git a/backend/internal/repository/http_upstream.go b/backend/internal/repository/http_upstream.go index a4674c1a..b82ee65b 100644 --- a/backend/internal/repository/http_upstream.go +++ b/backend/internal/repository/http_upstream.go @@ -164,15 +164,22 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i // - enableTLSFingerprint: 是否启用 TLS 指纹伪装 // // TLS 指纹说明: -// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹 -// - 指纹模板根据 accountID % len(profiles) 自动选择 -// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景 +// - 优先使用 Node.js TLS 代理模式(gateway.node_tls_proxy.enabled): +// 将请求改为 HTTP 明文发送到本地 Node.js 代理,由 Node.js 原生 TLS 栈完成上游握手, +// JA3/JA4 指纹天然匹配 Claude CLI,无需 uTLS 模拟。 +// - 回退到 uTLS 模式(gateway.tls_fingerprint.enabled): +// 使用 utls 库模拟 Claude CLI 的 TLS ClientHello。 func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) { // 如果未启用 TLS 指纹,直接使用标准请求路径 if !enableTLSFingerprint { return s.Do(req, proxyURL, accountID, accountConcurrency) } + // 优先使用 Node.js TLS 代理模式 + if s.isNodeTLSProxyEnabled() { + return s.doViaNodeTLSProxy(req, accountID, accountConcurrency) + } + // TLS 指纹已启用,记录调试日志 targetHost := "" if req != nil && req.URL != nil { @@ -227,6 +234,45 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco return resp, nil } +// isNodeTLSProxyEnabled 检查 Node.js TLS 代理是否启用 +func (s *httpUpstreamService) isNodeTLSProxyEnabled() bool { + if s.cfg == nil { + return false + } + return s.cfg.Gateway.NodeTLSProxy.Enabled +} + +// doViaNodeTLSProxy 通过 Node.js TLS 代理发送请求 +// 将 HTTPS 请求改为 HTTP 明文发送到本地 Node.js 代理, +// 由 Node.js 进程使用原生 TLS 栈完成到上游的 HTTPS 连接。 +// 这样 JA3/JA4 指纹天然匹配 Node.js (Claude CLI)。 +func (s *httpUpstreamService) doViaNodeTLSProxy(req *http.Request, accountID int64, accountConcurrency int) (*http.Response, error) { + proxyCfg := s.cfg.Gateway.NodeTLSProxy + listenHost := proxyCfg.ListenHost + if listenHost == "" { + listenHost = "127.0.0.1" + } + listenPort := proxyCfg.ListenPort + if listenPort == 0 { + listenPort = 3456 + } + + // 重写请求 URL:https://api.anthropic.com/v1/... → http://127.0.0.1:3456/v1/... + originalURL := req.URL.String() + req.URL.Scheme = "http" + req.URL.Host = fmt.Sprintf("%s:%d", listenHost, listenPort) + + slog.Debug("node_tls_proxy_rewrite", + "account_id", accountID, + "original_url", originalURL, + "rewritten_to", req.URL.String(), + ) + + // 通过标准 HTTP 客户端发送(不需要 TLS,代理是本地 HTTP) + // proxyURL 为空(直连本地),使用标准 Do 路径 + return s.Do(req, "", accountID, accountConcurrency) +} + // acquireClientWithTLS 获取或创建带 TLS 指纹的客户端 func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) { return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, true, true) diff --git a/deploy/build-push-tls-proxy.sh b/deploy/build-push-tls-proxy.sh new file mode 100755 index 00000000..497946b0 --- /dev/null +++ b/deploy/build-push-tls-proxy.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# ============================================================================= +# Build and push node-tls-proxy multi-arch image +# ============================================================================= +# Usage: +# ./build-push.sh # build + push latest +# ./build-push.sh v1.0.0 # build + push with tag +# ./build-push.sh --local # build locally only (no push) +# +# Prerequisites: +# docker login # login to Docker Hub first +# docker buildx create --use # enable multi-arch builds (one-time) +# ============================================================================= + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CONTEXT_DIR="${SCRIPT_DIR}/../tools/node-tls-proxy" +IMAGE="zfc931912343/sub2api-tls-proxy" +PLATFORMS="linux/amd64,linux/arm64" + +TAG="${1:-latest}" +PUSH=true + +if [ "$TAG" = "--local" ]; then + TAG="latest" + PUSH=false +fi + +echo "=============================================" +echo " Node.js TLS Proxy Image Builder" +echo "=============================================" +echo " Image: ${IMAGE}:${TAG}" +echo " Platforms: ${PLATFORMS}" +echo " Push: ${PUSH}" +echo " Context: ${CONTEXT_DIR}" +echo "=============================================" + +# Verify context +if [ ! -f "${CONTEXT_DIR}/proxy.js" ]; then + echo "ERROR: proxy.js not found in ${CONTEXT_DIR}" + exit 1 +fi + +if [ "$PUSH" = true ]; then + echo "" + echo "[1/2] Building multi-arch image..." + docker buildx build \ + --platform "${PLATFORMS}" \ + --tag "${IMAGE}:${TAG}" \ + --tag "${IMAGE}:latest" \ + --push \ + --file "${CONTEXT_DIR}/Dockerfile" \ + "${CONTEXT_DIR}" + + echo "" + echo "[2/2] Verifying..." + docker manifest inspect "${IMAGE}:${TAG}" | head -20 +else + echo "" + echo "[1/1] Building local image (current arch only)..." + docker build \ + --tag "${IMAGE}:${TAG}" \ + --file "${CONTEXT_DIR}/Dockerfile" \ + "${CONTEXT_DIR}" +fi + +echo "" +echo "=============================================" +echo " Done!" +if [ "$PUSH" = true ]; then + echo " Pushed: ${IMAGE}:${TAG}" + echo " Pushed: ${IMAGE}:latest" + echo "" + echo " Cloud deploy:" + echo " cd deploy" + echo " docker compose -f docker-compose.yml -f docker-compose.tls-proxy.yml up -d" +fi +echo "=============================================" diff --git a/deploy/config.example.yaml b/deploy/config.example.yaml index 2058ced1..738b622f 100644 --- a/deploy/config.example.yaml +++ b/deploy/config.example.yaml @@ -379,6 +379,24 @@ gateway: # profile_2: # name: "Custom Profile 2" + # Node.js TLS Proxy / Node.js TLS 代理 + # Routes upstream requests through a local Node.js process for native TLS fingerprinting. + # The JA3/JA4 fingerprint naturally matches Claude CLI since Node.js uses the same OpenSSL stack. + # 通过本地 Node.js 进程转发上游请求,实现天然的 TLS 指纹匹配。 + # 启用后,上游 HTTPS 连接由 Node.js 原生 TLS 栈完成,JA3/JA4 天然匹配 Claude CLI。 + node_tls_proxy: + enabled: false + # Node.js proxy listen port / 代理监听端口 + listen_port: 3456 + # Node.js proxy listen host / 代理监听地址 + # Docker: use container name "node-tls-proxy" + # VPS: use "127.0.0.1" + listen_host: "127.0.0.1" + # Health check path / 健康检查路径 + health_path: "/__health" + # Upstream target host / 上游目标主机 + upstream_host: "api.anthropic.com" + # ============================================================================= # Logging Configuration # 日志配置 diff --git a/deploy/docker-compose.local.yml b/deploy/docker-compose.local.yml index 5aea78fb..f787fe05 100644 --- a/deploy/docker-compose.local.yml +++ b/deploy/docker-compose.local.yml @@ -24,7 +24,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: zfc931912343/sub2api:latest container_name: sub2api restart: unless-stopped ulimits: diff --git a/deploy/docker-compose.standalone.yml b/deploy/docker-compose.standalone.yml index df0ccfcc..ca2c6c36 100644 --- a/deploy/docker-compose.standalone.yml +++ b/deploy/docker-compose.standalone.yml @@ -12,7 +12,7 @@ services: sub2api: - image: weishaw/sub2api:latest + image: zfc931912343/sub2api:latest container_name: sub2api restart: unless-stopped ulimits: diff --git a/deploy/docker-compose.tls-proxy.yml b/deploy/docker-compose.tls-proxy.yml new file mode 100644 index 00000000..e48e4072 --- /dev/null +++ b/deploy/docker-compose.tls-proxy.yml @@ -0,0 +1,82 @@ +# ============================================================================= +# Node.js TLS Proxy Overlay +# ============================================================================= +# 在现有 docker-compose.yml 基础上增加 Node.js TLS 代理。 +# +# 用法: +# docker compose -f docker-compose.yml -f docker-compose.tls-proxy.yml up -d +# +# 架构: +# sub2api (Go) → HTTP 明文 → node-tls-proxy → HTTPS (原生 TLS) → api.anthropic.com +# +# 网络隔离: +# - sub2api 仅连接 internal + sub2api-network(访问 pg/redis,但无外网) +# - node-tls-proxy 双栈网络(internal + external),唯一的出站通道 +# - IPv6 内核级禁用 +# ============================================================================= + +services: + # =========================================================================== + # 覆盖 sub2api:加入 internal 网络 + 启用 Node.js TLS 代理 + # =========================================================================== + sub2api: + networks: + - sub2api-internal + - sub2api-network # 保留:访问 postgres/redis + environment: + # 启用 Node.js TLS 代理 + - GATEWAY_NODE_TLS_PROXY_ENABLED=true + - GATEWAY_NODE_TLS_PROXY_LISTEN_PORT=3456 + - GATEWAY_NODE_TLS_PROXY_LISTEN_HOST=node-tls-proxy + - GATEWAY_NODE_TLS_PROXY_UPSTREAM_HOST=api.anthropic.com + depends_on: + node-tls-proxy: + condition: service_healthy + + # =========================================================================== + # Node.js TLS Forward Proxy + # 直接拉取预构建镜像,支持 amd64/arm64 + # =========================================================================== + node-tls-proxy: + image: zfc931912343/sub2api-tls-proxy:latest + container_name: sub2api-node-tls-proxy + restart: unless-stopped + user: "1000:1000" + read_only: true + tmpfs: + - /tmp:size=10M + environment: + - PROXY_PORT=3456 + - PROXY_HOST=0.0.0.0 + - UPSTREAM_HOST=api.anthropic.com + # 可选:经过外部代理出站(HTTP CONNECT 隧道) + - UPSTREAM_PROXY=${TLS_PROXY_UPSTREAM_PROXY:-} + - TZ=${TZ:-Asia/Shanghai} + networks: + - sub2api-internal # sub2api 可以访问 + - sub2api-external # 可以访问外网 + sysctls: + # 内核级禁用 IPv6(防 IPv6 泄露) + - net.ipv6.conf.all.disable_ipv6=1 + - net.ipv6.conf.default.disable_ipv6=1 + healthcheck: + test: ["CMD", "node", "-e", "const h=require('http');h.get('http://127.0.0.1:3456/__health',r=>{process.exit(r.statusCode===200?0:1)}).on('error',()=>process.exit(1))"] + interval: 15s + timeout: 5s + retries: 3 + start_period: 5s + deploy: + resources: + limits: + memory: 256M + cpus: "1.0" + +# ============================================================================= +# Networks +# ============================================================================= +networks: + sub2api-internal: + internal: true # 关键:无外网访问 + driver: bridge + sub2api-external: + driver: bridge diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a0bc1a60..6f6b2a66 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -16,7 +16,7 @@ services: # Sub2API Application # =========================================================================== sub2api: - image: weishaw/sub2api:latest + image: zfc931912343/sub2api:latest container_name: sub2api restart: unless-stopped ulimits: @@ -146,7 +146,7 @@ services: networks: - sub2api-network healthcheck: - test: ["CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health"] + test: [ "CMD", "wget", "-q", "-T", "5", "-O", "/dev/null", "http://localhost:8080/health" ] interval: 30s timeout: 10s retries: 3 @@ -177,7 +177,7 @@ services: networks: - sub2api-network healthcheck: - test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}"] + test: [ "CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-sub2api} -d ${POSTGRES_DB:-sub2api}" ] interval: 10s timeout: 5s retries: 5 @@ -185,9 +185,9 @@ services: # 注意:不暴露端口到宿主机,应用通过内部网络连接 # 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"] - # =========================================================================== - # Redis Cache - # =========================================================================== + # =========================================================================== + # Redis Cache + # =========================================================================== redis: image: redis:8-alpine container_name: sub2api-redis @@ -199,12 +199,12 @@ services: volumes: - redis_data:/data command: > - sh -c ' - redis-server - --save 60 1 - --appendonly yes - --appendfsync everysec - ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' + sh -c ' + redis-server + --save 60 1 + --appendonly yes + --appendfsync everysec + ${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}' environment: - TZ=${TZ:-Asia/Shanghai} # REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag) @@ -212,7 +212,7 @@ services: networks: - sub2api-network healthcheck: - test: ["CMD", "redis-cli", "ping"] + test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 diff --git a/tools/firewall/setup-firewall.sh b/tools/firewall/setup-firewall.sh new file mode 100755 index 00000000..14041d88 --- /dev/null +++ b/tools/firewall/setup-firewall.sh @@ -0,0 +1,104 @@ +#!/bin/bash +# sub2api 指纹防泄露 iptables 规则 +# 确保只有 Node.js TLS Proxy 能直连上游 HTTPS, +# sub2api Go 进程即使有 bug 也无法绕过。 +# +# 用法: +# sudo bash setup-firewall.sh [apply|remove|status] +# +# 前置条件: +# - Node.js proxy 以专用用户 "nodeproxy" 运行 +# - 创建用户: sudo useradd -r -s /usr/sbin/nologin nodeproxy + +set -euo pipefail + +NODE_PROXY_USER="${MG_NODE_PROXY_USER:-nodeproxy}" +CHAIN_NAME="MG_FINGERPRINT" + +log() { echo "[$(date '+%H:%M:%S')] $*"; } + +apply_rules() { + log "Applying fingerprint firewall rules..." + + # 验证用户存在 + if ! id "$NODE_PROXY_USER" &>/dev/null; then + log "ERROR: User '$NODE_PROXY_USER' does not exist." + log "Create it: sudo useradd -r -s /usr/sbin/nologin $NODE_PROXY_USER" + exit 1 + fi + + # 创建自定义链(幂等) + iptables -N "$CHAIN_NAME" 2>/dev/null || iptables -F "$CHAIN_NAME" + + # === Rule 1: QUIC 阻断 — 丢弃所有出站 UDP 443/4433 === + iptables -A "$CHAIN_NAME" -p udp --dport 443 -j DROP \ + -m comment --comment "MG: block QUIC/HTTP3 UDP 443" + iptables -A "$CHAIN_NAME" -p udp --dport 4433 -j DROP \ + -m comment --comment "MG: block QUIC alt UDP 4433" + + # === Rule 2: 允许 Node.js proxy 出站 TCP 443 === + iptables -A "$CHAIN_NAME" -p tcp --dport 443 \ + -m owner --uid-owner "$NODE_PROXY_USER" -j ACCEPT \ + -m comment --comment "MG: allow nodeproxy TCP 443" + + # === Rule 3: 阻止其他进程直连 TCP 443 === + iptables -A "$CHAIN_NAME" -p tcp --dport 443 -j REJECT --reject-with tcp-reset \ + -m comment --comment "MG: block non-proxy TCP 443" + + # 将自定义链挂载到 OUTPUT(幂等) + if ! iptables -C OUTPUT -j "$CHAIN_NAME" 2>/dev/null; then + iptables -A OUTPUT -j "$CHAIN_NAME" + fi + + # === Rule 4: IPv6 全面阻断 === + ip6tables -N "${CHAIN_NAME}_V6" 2>/dev/null || ip6tables -F "${CHAIN_NAME}_V6" + # 允许回环 + ip6tables -A "${CHAIN_NAME}_V6" -o lo -j ACCEPT \ + -m comment --comment "MG: allow IPv6 loopback" + # 阻断其他 IPv6 出站 + ip6tables -A "${CHAIN_NAME}_V6" -j DROP \ + -m comment --comment "MG: block all IPv6 outbound" + + if ! ip6tables -C OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null; then + ip6tables -A OUTPUT -j "${CHAIN_NAME}_V6" + fi + + log "Firewall rules applied successfully." + log " - UDP 443/4433: BLOCKED (QUIC)" + log " - TCP 443: ONLY '$NODE_PROXY_USER' allowed" + log " - IPv6 outbound: BLOCKED" +} + +remove_rules() { + log "Removing fingerprint firewall rules..." + + # 从 OUTPUT 移除引用 + iptables -D OUTPUT -j "$CHAIN_NAME" 2>/dev/null || true + ip6tables -D OUTPUT -j "${CHAIN_NAME}_V6" 2>/dev/null || true + + # 清空并删除自定义链 + iptables -F "$CHAIN_NAME" 2>/dev/null || true + iptables -X "$CHAIN_NAME" 2>/dev/null || true + ip6tables -F "${CHAIN_NAME}_V6" 2>/dev/null || true + ip6tables -X "${CHAIN_NAME}_V6" 2>/dev/null || true + + log "Firewall rules removed." +} + +show_status() { + log "=== IPv4 MG_FINGERPRINT chain ===" + iptables -L "$CHAIN_NAME" -n -v 2>/dev/null || echo "(not found)" + echo + log "=== IPv6 MG_FINGERPRINT_V6 chain ===" + ip6tables -L "${CHAIN_NAME}_V6" -n -v 2>/dev/null || echo "(not found)" +} + +case "${1:-apply}" in + apply) apply_rules ;; + remove) remove_rules ;; + status) show_status ;; + *) + echo "Usage: $0 [apply|remove|status]" + exit 1 + ;; +esac diff --git a/tools/node-tls-proxy/Dockerfile b/tools/node-tls-proxy/Dockerfile new file mode 100644 index 00000000..cbcb4f93 --- /dev/null +++ b/tools/node-tls-proxy/Dockerfile @@ -0,0 +1,24 @@ +FROM node:24.13.0-slim + +LABEL maintainer="Wei-Shaw " +LABEL description="Node.js TLS Forward Proxy - native JA3/JA4 fingerprint matching" +LABEL org.opencontainers.image.source="https://github.com/Wei-Shaw/sub2api" + +WORKDIR /app + +COPY proxy.js package.json ./ + +# 零依赖,不需要 npm install + +ENV PROXY_PORT=3456 +ENV PROXY_HOST=0.0.0.0 +ENV UPSTREAM_HOST=api.anthropic.com + +EXPOSE 3456 + +# 健康检查:用 Node.js 内置 http 模块,不依赖 curl +HEALTHCHECK --interval=30s --timeout=5s --retries=3 --start-period=5s \ + CMD node -e "const http=require('http');const r=http.get('http://127.0.0.1:'+(process.env.PROXY_PORT||3456)+'/__health',s=>{process.exit(s.statusCode===200?0:1)});r.on('error',()=>process.exit(1));r.setTimeout(3000,()=>{r.destroy();process.exit(1)})" + +USER node +CMD ["node", "proxy.js"] diff --git a/tools/node-tls-proxy/package.json b/tools/node-tls-proxy/package.json new file mode 100644 index 00000000..a68007cb --- /dev/null +++ b/tools/node-tls-proxy/package.json @@ -0,0 +1,14 @@ +{ + "name": "node-tls-proxy", + "version": "1.0.0", + "private": true, + "description": "Node.js TLS forward proxy for native JA3/JA4 fingerprint matching", + "main": "proxy.js", + "scripts": { + "start": "node proxy.js", + "health": "curl -s http://127.0.0.1:${PROXY_PORT:-3456}/__health | jq ." + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/tools/node-tls-proxy/proxy.js b/tools/node-tls-proxy/proxy.js new file mode 100644 index 00000000..19d8befe --- /dev/null +++ b/tools/node-tls-proxy/proxy.js @@ -0,0 +1,245 @@ +'use strict'; + +const http = require('http'); +const https = require('https'); +const net = require('net'); + +// ─── 配置 ─────────────────────────────────────────────── +const UPSTREAM_HOST = process.env.UPSTREAM_HOST || 'api.anthropic.com'; +const LISTEN_PORT = parseInt(process.env.PROXY_PORT || '3456', 10); +const LISTEN_HOST = process.env.PROXY_HOST || '127.0.0.1'; +// 可选:上游 HTTPS 代理(仅支持 HTTP CONNECT 隧道) +const UPSTREAM_PROXY = process.env.UPSTREAM_PROXY || ''; +// 连接超时(ms) +const CONNECT_TIMEOUT = parseInt(process.env.CONNECT_TIMEOUT || '30000', 10); +// 空闲超时(ms)— 对 SSE 长连接要足够大 +const IDLE_TIMEOUT = parseInt(process.env.IDLE_TIMEOUT || '600000', 10); + +// ─── 日志 ─────────────────────────────────────────────── +const log = (level, msg, extra = {}) => { + const entry = { + time: new Date().toISOString(), + level, + msg, + ...extra, + }; + process.stderr.write(JSON.stringify(entry) + '\n'); +}; + +// ─── 健康检查路径 ───────────────────────────────────────── +const HEALTH_PATH = '/__health'; + +// ─── 通过 HTTP 代理建立 CONNECT 隧道 ────────────────────── +function connectViaProxy(proxyUrl, targetHost, targetPort) { + return new Promise((resolve, reject) => { + const proxy = new URL(proxyUrl); + const proxyPort = parseInt(proxy.port || '80', 10); + + const conn = net.connect(proxyPort, proxy.hostname, () => { + const connectReq = + `CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` + + `Host: ${targetHost}:${targetPort}\r\n`; + + // 代理认证 + const auth = proxy.username + ? `Proxy-Authorization: Basic ${Buffer.from( + `${decodeURIComponent(proxy.username)}:${decodeURIComponent(proxy.password || '')}` + ).toString('base64')}\r\n` + : ''; + + conn.write(connectReq + auth + '\r\n'); + }); + + conn.once('error', reject); + conn.setTimeout(CONNECT_TIMEOUT, () => { + conn.destroy(new Error('proxy CONNECT timeout')); + }); + + // 读取代理响应 + let buf = ''; + const onData = (chunk) => { + buf += chunk.toString(); + const idx = buf.indexOf('\r\n\r\n'); + if (idx === -1) return; // 头还没完整 + + conn.removeListener('data', onData); + const statusLine = buf.slice(0, buf.indexOf('\r\n')); + const statusCode = parseInt(statusLine.split(' ')[1], 10); + + if (statusCode === 200) { + conn.setTimeout(0); // 清除超时 + // 如果头之后有残余数据,先 unshift 回去 + const remainder = buf.slice(idx + 4); + if (remainder.length > 0) { + conn.unshift(Buffer.from(remainder)); + } + resolve(conn); + } else { + conn.destroy(); + reject(new Error(`proxy CONNECT failed: ${statusLine}`)); + } + }; + conn.on('data', onData); + }); +} + +// ─── 构建上游请求选项 ────────────────────────────────────── +function buildUpstreamOptions(req) { + // 复制头,重写 host + const headers = { ...req.headers }; + headers.host = UPSTREAM_HOST; + // 移除 hop-by-hop 头 + delete headers['connection']; + delete headers['keep-alive']; + delete headers['proxy-connection']; + delete headers['transfer-encoding']; + + return { + hostname: UPSTREAM_HOST, + port: 443, + path: req.url, + method: req.method, + headers, + // 关键:不设置任何自定义 TLS 选项 + // 让 Node.js 使用默认 TLS stack → JA3/JA4 天然匹配 + servername: UPSTREAM_HOST, // SNI + timeout: CONNECT_TIMEOUT, + }; +} + +// ─── 代理请求 ─────────────────────────────────────────── +async function proxyRequest(req, res) { + const opts = buildUpstreamOptions(req); + + let proxyReq; + + if (UPSTREAM_PROXY) { + // 通过代理建立隧道,然后 TLS 握手 + try { + const socket = await connectViaProxy(UPSTREAM_PROXY, UPSTREAM_HOST, 443); + opts.socket = socket; + opts.agent = false; // 使用自定义 socket,不走连接池 + proxyReq = https.request(opts); + } catch (err) { + log('error', 'proxy tunnel failed', { error: err.message }); + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'proxy_tunnel_error', message: err.message })); + } + return; + } + } else { + proxyReq = https.request(opts); + } + + // 上游响应 + proxyReq.on('response', (proxyRes) => { + // 过滤 hop-by-hop 头 + const responseHeaders = { ...proxyRes.headers }; + delete responseHeaders['connection']; + delete responseHeaders['keep-alive']; + delete responseHeaders['transfer-encoding']; // Node.js 会自动处理 + + res.writeHead(proxyRes.statusCode, responseHeaders); + + // SSE / 流式透传:逐块 pipe + proxyRes.pipe(res, { end: true }); + + proxyRes.on('error', (err) => { + log('error', 'upstream response error', { error: err.message }); + res.end(); + }); + }); + + // 上游连接错误 + proxyReq.on('error', (err) => { + log('error', 'upstream request error', { + error: err.message, + path: req.url, + method: req.method, + }); + if (!res.headersSent) { + res.writeHead(502, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'upstream_error', message: err.message })); + } + }); + + // 超时 + proxyReq.on('timeout', () => { + log('warn', 'upstream request timeout', { path: req.url }); + proxyReq.destroy(new Error('upstream timeout')); + }); + + // 客户端断开时中止上游请求 + req.on('close', () => { + if (!proxyReq.destroyed) { + proxyReq.destroy(); + } + }); + + // 将客户端请求体 pipe 到上游 + req.pipe(proxyReq, { end: true }); +} + +// ─── HTTP 服务器 ───────────────────────────────────────── +const server = http.createServer((req, res) => { + // 健康检查 + if (req.url === HEALTH_PATH) { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + upstream: UPSTREAM_HOST, + node: process.version, + openssl: process.versions.openssl, + uptime: process.uptime(), + })); + return; + } + + proxyRequest(req, res).catch((err) => { + log('error', 'unhandled proxy error', { error: err.message }); + if (!res.headersSent) { + res.writeHead(500, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'internal_error' })); + } + }); +}); + +// SSE 长连接:禁用 server 级超时(由上游控制) +server.timeout = 0; +server.keepAliveTimeout = IDLE_TIMEOUT; +server.headersTimeout = 60000; + +server.listen(LISTEN_PORT, LISTEN_HOST, () => { + log('info', 'node-tls-proxy started', { + listen: `${LISTEN_HOST}:${LISTEN_PORT}`, + upstream: `${UPSTREAM_HOST}:443`, + proxy: UPSTREAM_PROXY || '(direct)', + node: process.version, + openssl: process.versions.openssl, + }); +}); + +// ─── 优雅关闭 ───────────────────────────────────────────── +let shuttingDown = false; +function shutdown(signal) { + if (shuttingDown) return; + shuttingDown = true; + log('info', `received ${signal}, shutting down`); + server.close(() => { + log('info', 'server closed'); + process.exit(0); + }); + // 强制退出兜底 + setTimeout(() => process.exit(1), 5000); +} +process.on('SIGTERM', () => shutdown('SIGTERM')); +process.on('SIGINT', () => shutdown('SIGINT')); + +// 未捕获异常不要崩进程 +process.on('uncaughtException', (err) => { + log('error', 'uncaught exception', { error: err.message, stack: err.stack }); +}); +process.on('unhandledRejection', (reason) => { + log('error', 'unhandled rejection', { error: String(reason) }); +});