feat: Node.js TLS 指纹代理 + 网络隔离防泄露
- 新增 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:
parent
0f03393010
commit
8cac4269aa
@ -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 直连配置
|
||||
|
||||
@ -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)
|
||||
|
||||
79
deploy/build-push-tls-proxy.sh
Executable file
79
deploy/build-push-tls-proxy.sh
Executable 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 "============================================="
|
||||
@ -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
|
||||
# 日志配置
|
||||
|
||||
@ -24,7 +24,7 @@ services:
|
||||
# Sub2API Application
|
||||
# ===========================================================================
|
||||
sub2api:
|
||||
image: weishaw/sub2api:latest
|
||||
image: zfc931912343/sub2api:latest
|
||||
container_name: sub2api
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
|
||||
@ -12,7 +12,7 @@
|
||||
|
||||
services:
|
||||
sub2api:
|
||||
image: weishaw/sub2api:latest
|
||||
image: zfc931912343/sub2api:latest
|
||||
container_name: sub2api
|
||||
restart: unless-stopped
|
||||
ulimits:
|
||||
|
||||
82
deploy/docker-compose.tls-proxy.yml
Normal file
82
deploy/docker-compose.tls-proxy.yml
Normal 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
|
||||
@ -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
104
tools/firewall/setup-firewall.sh
Executable 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
|
||||
24
tools/node-tls-proxy/Dockerfile
Normal file
24
tools/node-tls-proxy/Dockerfile
Normal 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"]
|
||||
14
tools/node-tls-proxy/package.json
Normal file
14
tools/node-tls-proxy/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
245
tools/node-tls-proxy/proxy.js
Normal file
245
tools/node-tls-proxy/proxy.js
Normal 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) });
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user