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
bda7c39e55
commit
a72ba424cc
@ -456,6 +456,11 @@ type GatewayConfig struct {
|
|||||||
// TLSFingerprint: TLS指纹伪装配置
|
// TLSFingerprint: TLS指纹伪装配置
|
||||||
TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"`
|
TLSFingerprint TLSFingerprintConfig `mapstructure:"tls_fingerprint"`
|
||||||
|
|
||||||
|
// NodeTLSProxy: Node.js TLS 代理配置
|
||||||
|
// 启用后,上游请求通过本地 Node.js 进程发起 TLS 连接,
|
||||||
|
// 实现天然 JA3/JA4 指纹匹配(无需 uTLS 模拟)
|
||||||
|
NodeTLSProxy NodeTLSProxyConfig `mapstructure:"node_tls_proxy"`
|
||||||
|
|
||||||
// UsageRecord: 使用量记录异步队列配置(有界队列 + 固定 worker)
|
// UsageRecord: 使用量记录异步队列配置(有界队列 + 固定 worker)
|
||||||
UsageRecord GatewayUsageRecordConfig `mapstructure:"usage_record"`
|
UsageRecord GatewayUsageRecordConfig `mapstructure:"usage_record"`
|
||||||
|
|
||||||
@ -669,6 +674,23 @@ type TLSProfileConfig struct {
|
|||||||
PointFormats []uint8 `mapstructure:"point_formats"`
|
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.
|
// GatewaySchedulingConfig accounts scheduling configuration.
|
||||||
type GatewaySchedulingConfig struct {
|
type GatewaySchedulingConfig struct {
|
||||||
// 粘性会话排队配置
|
// 粘性会话排队配置
|
||||||
@ -1447,6 +1469,13 @@ func setDefaults() {
|
|||||||
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
|
viper.SetDefault("gateway.user_message_queue.cleanup_interval_seconds", 60)
|
||||||
|
|
||||||
viper.SetDefault("gateway.tls_fingerprint.enabled", true)
|
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)
|
viper.SetDefault("concurrency.ping_interval", 10)
|
||||||
|
|
||||||
// Sora 直连配置
|
// Sora 直连配置
|
||||||
|
|||||||
@ -164,15 +164,22 @@ func (s *httpUpstreamService) Do(req *http.Request, proxyURL string, accountID i
|
|||||||
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
// - enableTLSFingerprint: 是否启用 TLS 指纹伪装
|
||||||
//
|
//
|
||||||
// TLS 指纹说明:
|
// TLS 指纹说明:
|
||||||
// - 当 enableTLSFingerprint=true 时,使用 utls 库模拟 Claude CLI 的 TLS 指纹
|
// - 优先使用 Node.js TLS 代理模式(gateway.node_tls_proxy.enabled):
|
||||||
// - 指纹模板根据 accountID % len(profiles) 自动选择
|
// 将请求改为 HTTP 明文发送到本地 Node.js 代理,由 Node.js 原生 TLS 栈完成上游握手,
|
||||||
// - 支持直连、HTTP/HTTPS 代理、SOCKS5 代理三种场景
|
// 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) {
|
func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, enableTLSFingerprint bool) (*http.Response, error) {
|
||||||
// 如果未启用 TLS 指纹,直接使用标准请求路径
|
// 如果未启用 TLS 指纹,直接使用标准请求路径
|
||||||
if !enableTLSFingerprint {
|
if !enableTLSFingerprint {
|
||||||
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
return s.Do(req, proxyURL, accountID, accountConcurrency)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 优先使用 Node.js TLS 代理模式
|
||||||
|
if s.isNodeTLSProxyEnabled() {
|
||||||
|
return s.doViaNodeTLSProxy(req, accountID, accountConcurrency)
|
||||||
|
}
|
||||||
|
|
||||||
// TLS 指纹已启用,记录调试日志
|
// TLS 指纹已启用,记录调试日志
|
||||||
targetHost := ""
|
targetHost := ""
|
||||||
if req != nil && req.URL != nil {
|
if req != nil && req.URL != nil {
|
||||||
@ -227,6 +234,45 @@ func (s *httpUpstreamService) DoWithTLS(req *http.Request, proxyURL string, acco
|
|||||||
return resp, nil
|
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 指纹的客户端
|
// acquireClientWithTLS 获取或创建带 TLS 指纹的客户端
|
||||||
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) {
|
func (s *httpUpstreamService) acquireClientWithTLS(proxyURL string, accountID int64, accountConcurrency int, profile *tlsfingerprint.Profile) (*upstreamClientEntry, error) {
|
||||||
return s.getClientEntryWithTLS(proxyURL, accountID, accountConcurrency, profile, true, true)
|
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:
|
# profile_2:
|
||||||
# name: "Custom 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
|
# Logging Configuration
|
||||||
# 日志配置
|
# 日志配置
|
||||||
|
|||||||
@ -24,7 +24,7 @@ services:
|
|||||||
# Sub2API Application
|
# Sub2API Application
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: zfc931912343/sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ulimits:
|
ulimits:
|
||||||
|
|||||||
@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: zfc931912343/sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ulimits:
|
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 Application
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
sub2api:
|
sub2api:
|
||||||
image: weishaw/sub2api:latest
|
image: zfc931912343/sub2api:latest
|
||||||
container_name: sub2api
|
container_name: sub2api
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ulimits:
|
ulimits:
|
||||||
@ -146,7 +146,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- sub2api-network
|
- sub2api-network
|
||||||
healthcheck:
|
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
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@ -177,7 +177,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- sub2api-network
|
- sub2api-network
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
@ -185,9 +185,9 @@ services:
|
|||||||
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
# 注意:不暴露端口到宿主机,应用通过内部网络连接
|
||||||
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
# 如需调试,可临时添加:ports: ["127.0.0.1:5433:5432"]
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Redis Cache
|
# Redis Cache
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
redis:
|
redis:
|
||||||
image: redis:8-alpine
|
image: redis:8-alpine
|
||||||
container_name: sub2api-redis
|
container_name: sub2api-redis
|
||||||
@ -199,12 +199,12 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redis_data:/data
|
- redis_data:/data
|
||||||
command: >
|
command: >
|
||||||
sh -c '
|
sh -c '
|
||||||
redis-server
|
redis-server
|
||||||
--save 60 1
|
--save 60 1
|
||||||
--appendonly yes
|
--appendonly yes
|
||||||
--appendfsync everysec
|
--appendfsync everysec
|
||||||
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
|
||||||
environment:
|
environment:
|
||||||
- TZ=${TZ:-Asia/Shanghai}
|
- TZ=${TZ:-Asia/Shanghai}
|
||||||
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
|
||||||
@ -212,7 +212,7 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- sub2api-network
|
- sub2api-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "redis-cli", "ping"]
|
test: [ "CMD", "redis-cli", "ping" ]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
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