feat: Node.js TLS 指纹代理 + 网络隔离防泄露
Some checks failed
CI / test (push) Failing after 1m32s
CI / golangci-lint (push) Failing after 33s
Security Scan / backend-security (push) Failing after 32s
Security Scan / frontend-security (push) Failing after 32s

- 新增 Node.js TLS Forward Proxy (tools/node-tls-proxy/)
  原生 Node.js TLS 栈发起上游 HTTPS,JA3/JA4 天然匹配 Claude CLI
  SSE 流式透传,支持上游 HTTP CONNECT 代理
  零依赖,Node.js 24.13.0 锁定版本

- Go 集成 (config.go + http_upstream.go)
  新增 NodeTLSProxyConfig 配置
  DoWithTLS 优先走 Node.js 代理模式,URL 重写 https→http://localhost:3456

- Docker 网络隔离 (docker-compose.tls-proxy.yml)
  sub2api 容器仅 internal 网络,物理隔离外网
  node-tls-proxy 唯一出站通道,IPv6 内核级禁用

- iptables 防泄露脚本 (tools/firewall/)
  QUIC/UDP 443 全局 DROP,仅 nodeproxy 用户可出站 TCP 443

- 镜像切换为 zfc931912343/ 仓库
This commit is contained in:
win 2026-03-22 00:18:43 +08:00
parent bda7c39e55
commit a72ba424cc
12 changed files with 659 additions and 18 deletions

View File

@ -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 直连配置

View File

@ -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
}
// 重写请求 URLhttps://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)

79
deploy/build-push-tls-proxy.sh Executable file
View File

@ -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 "============================================="

View File

@ -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
# 日志配置

View File

@ -24,7 +24,7 @@ services:
# Sub2API Application
# ===========================================================================
sub2api:
image: weishaw/sub2api:latest
image: zfc931912343/sub2api:latest
container_name: sub2api
restart: unless-stopped
ulimits:

View File

@ -12,7 +12,7 @@
services:
sub2api:
image: weishaw/sub2api:latest
image: zfc931912343/sub2api:latest
container_name: sub2api
restart: unless-stopped
ulimits:

View File

@ -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

View File

@ -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

104
tools/firewall/setup-firewall.sh Executable file
View File

@ -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

View File

@ -0,0 +1,24 @@
FROM node:24.13.0-slim
LABEL maintainer="Wei-Shaw <github.com/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"]

View File

@ -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"
}
}

View File

@ -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) });
});