HAProxy + NGINX 基于SNI实现 Shadowsocks over tls 与 HTTPS 完全伪装与分流

基于 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)
客户端声明支持的应用层协议,如 h2http/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 的地理矛盾反而会引起怀疑。