基于 TLS SNI 的单端口流量隐匿:HAProxy + NGINX 实现 Shadowsocks 与 HTTPS 完全伪装共存
背景
常见的代理部署方案存在一个明显缺陷:代理服务独占一个非标准端口(如 8388、1080),流量特征暴露明显。即便加上 TLS 加密,单独的监听端口本身就是识别特征。
本文实现以下目标:
- 服务端只对外暴露标准 443 端口
- 浏览器访问该端口得到正常的博客 HTTPS 响应
- 代理客户端访问同一端口,流量被路由到 Shadowsocks 服务
- 两类流量在 TLS 握手层面对外呈现完全一致的特征,无法通过被动流量分析区分
核心原理
TLS ClientHello 中的明文字段
TLS 握手的第一个包(ClientHello)在加密之前以明文传输,其中包含两个关键扩展字段:
SNI(Server Name Indication)
客户端声明要连接的目标域名,服务端据此选择对应的证书。
ALPN(Application-Layer Protocol Negotiation)
客户端声明支持的应用层协议,如 h2、http/1.1。
这两个字段对网络上的任何观察者(包括 GFW)均可见。
NGINX ssl_preread 的工作方式
NGINX stream 模块的 ssl_preread 指令在不终止 SSL 的前提下读取 ClientHello,提取 SNI 和 ALPN 字段,然后将原始 SSL 流量原封不动地透传到对应的后端。SSL 握手由后端完成,前端 443 端口本身不持有任何证书。
这是本方案的关键:流量路由发生在 TLS 握手之前,路由依据(SNI)是明文可读的,而路由结果(连接到哪个后端)对外不可见。
为何选择 SNI 而非 ALPN
ALPN 方案要求代理客户端发送自定义 ALPN 值(如 webrtc),该值在真实网络流量中极为罕见,是明显的流量特征。
SNI 方案让代理客户端连接时使用一个看起来完全合理的域名(cdnjs.cloudflare.com),ALPN 值使用标准的 h2,与正常浏览器流量完全一致。
TLS 1.3 的必要性
代理客户端连接时使用 SNI B(cdnjs.cloudflare.com),而服务端只持有 SNI A(blog.li-chunli.top)的证书,存在域名不匹配。
在 TLS 1.2 中,服务端证书在握手阶段明文传输,不匹配对外可见。
在 TLS 1.3 中,服务端证书在加密阶段传输,外部观察者无法看到证书内容。
因此两端必须强制使用 TLS 1.3,证书不匹配的问题才能对外完全隐藏。
架构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| ┌─────────────────────────────┐ │ 端口 443 │ │ ssl_preread on │ │ 读取 SNI,不终止 SSL │ └──────────────┬──────────────┘ │ ┌─────────────────────┴────────────────────┐ │ SNI=blog.li-chunli.top │ SNI=cdnjs.cloudflare.com ▼ ▼ ┌──────────────────┐ ┌──────────────────┐ │ 端口 4433 │ │ 端口 4444 │ │ NGINX HTTPS │ │ NGINX stream │ │ TLS 1.2/1.3 │ │ 仅 TLS 1.3 │ │ 终止 SSL │ │ 终止 SSL │ │ 返回博客内容 │ │ 转发明文 TCP │ └──────────────────┘ └────────┬─────────┘ │ ▼ 127.0.0.1:8300 (Shadowsocks)
|
端口说明:
443:唯一对外端口,仅读取 SNI,不做 SSL 终止
4433:NGINX HTTP server,处理博客流量,监听本地
4444:NGINX stream server,处理代理流量,监听本地,仅允许 TLS 1.3
8300:Shadowsocks 服务,仅接受本地连接
服务端部署(NGINX)
/etc/nginx/nginx.conf:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
| user www-data; worker_processes auto; pid /run/nginx.pid; error_log /var/log/nginx/error.log; include /etc/nginx/modules-enabled/*.conf;
events { worker_connections 768; }
http { sendfile on; tcp_nopush on; types_hash_max_size 2048; server_tokens off;
include /etc/nginx/mime.types; default_type application/octet-stream;
ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off;
access_log /var/log/nginx/access.log; gzip on;
include /etc/nginx/conf.d/*.conf; include /etc/nginx/sites-enabled/*;
server { listen 80; server_name blog.li-chunli.top; return 301 https://$host$request_uri; }
server { listen 4433 ssl; server_name blog.li-chunli.top; ssl_certificate /etc/nginx/blog.li-chunli.top_cert_chain.pem; ssl_certificate_key /etc/nginx/blog.li-chunli.top_key.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5;
location / { root /var/www/html/; index index.html index.htm; } } }
stream { map $ssl_preread_server_name $backend { blog.li-chunli.top 127.0.0.1:4433; cdnjs.cloudflare.com 127.0.0.1:4444; default 127.0.0.1:4433; }
server { listen 443; ssl_preread on; proxy_pass $backend; }
server { listen 4444 ssl; ssl_certificate /etc/nginx/blog.li-chunli.top_cert_chain.pem; ssl_certificate_key /etc/nginx/blog.li-chunli.top_key.key; ssl_protocols TLSv1.3; ssl_ciphers HIGH:!aNULL:!MD5; ssl_session_cache shared:TUNNEL:10m; ssl_session_timeout 10m; proxy_pass 127.0.0.1:8300; } }
|
部署:
1 2
| nginx -t systemctl reload nginx
|
客户端部署(HAProxy)
发送端机器 /etc/haproxy/haproxy.cfg:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| global log /dev/log local0 maxconn 4096 ssl-default-server-options ssl-min-ver TLSv1.3
defaults log global mode tcp timeout connect 5s timeout client 60s timeout server 60s
frontend ft_in bind *:55555 default_backend bk_server
backend bk_server server s1 blog.li-chunli.top:443 ssl alpn h2 verify none sni str(cdnjs.cloudflare.com) ssl-min-ver TLSv1.3
|
server 行各参数作用:
ssl — 对外连接启用 TLS,缺少此项则发出明文 TCP,连接必定失败
alpn h2 — ClientHello 中写入标准 ALPN 值,与浏览器一致
verify none — 跳过证书验证,因为服务端证书域名与 SNI 不匹配
sni str(cdnjs.cloudflare.com) — ClientHello 中写入 SNI,控制路由和伪装
ssl-min-ver TLSv1.3 — 强制 TLS 1.3,确保证书内容对外不可见
部署:
1 2
| haproxy -c -f /etc/haproxy/haproxy.cfg systemctl restart haproxy
|
完整链路
1 2 3 4 5 6 7 8 9 10
| Shadowsocks 客户端 → 发送端 55555(HAProxy) → TLS 1.3 握手 ClientHello: SNI=cdnjs.cloudflare.com, ALPN=h2 → 服务端 443(NGINX ssl_preread) 读取 SNI,识别为 cdnjs.cloudflare.com → 转发至 127.0.0.1:4444 → TLS 1.3 终止(证书为 blog.li-chunli.top,加密不可见) → 明文 TCP 转发至 127.0.0.1:8300 → Shadowsocks 服务处理
|
流量特征分析
GFW 在 ClientHello 阶段可以观察到的信息:
1 2 3 4 5
| 目标 IP: 美国 VPS 目标端口: 443(标准 HTTPS 端口) SNI: cdnjs.cloudflare.com(Cloudflare 公共 CDN,美国服务) ALPN: h2(标准值) TLS 版本: 1.3
|
这与"中国境内用户请求美国服务器上托管的 Cloudflare CDN 资源"的模式完全一致,无任何异常特征。
TLS 1.3 握手之后的内容(证书、应用数据)全部加密,对外不可见。
关键约束
ssl_preread 与 SSL 终止不能在同一 server 块中共存。端口 443 只负责读取 SNI 并透传,不参与 TLS 握手;握手由后端 4433 和 4444 各自完成。
SNI B(cdnjs.cloudflare.com)与服务端证书域名不匹配,客户端必须关闭证书验证(verify none)。此不匹配在 TLS 1.3 下对外不可见,在 TLS 1.2 下证书明文暴露,因此两端强制 TLS 1.3 是本方案成立的前提。
SNI 的选择应与服务端 IP 的地理位置保持逻辑一致。服务端位于美国,SNI 使用美国 CDN 域名(cdnjs.cloudflare.com)是合理的;若使用国内 CDN 域名(如 g.alicdn.com),IP 与 SNI 的地理矛盾反而会引起怀疑。