代理端口被封之后,把博客接进了 Cloudflare
前两天把博客搬到了美国的 VPS 上,同一台机器上还跑着 Xray,协议是 VLESS + REALITY,端口 37443。用了没两天,国内就连不上了。
这篇记一下后面两天做的事:先确认是不是真被封了,再弄明白该怎么改,最后把博客接进了 Cloudflare,代理换了个新域名,挪到 443 端口上。
文中服务器 IP、UUID、公钥一律省略。代理用的新域名也不写出来,原因后面会讲,文中用
<新域名>代替。
先确认是不是被封了
我人在东京,从这边连服务器不经过 GFW,测出来的结果说明不了国内的情况。所以测试都是 SSH 到一台国内的机器上做的。
先在 VPS 上看 Xray 还在不在:
ss -tlnp | grep 37443
在监听,进程也正常。再在 VPS 本机做一次 TLS 握手:
openssl s_client -connect 127.0.0.1:37443 -servername www.cloudflare.com </dev/null 2>/dev/null | head -5
拿到的是 www.cloudflare.com 的证书,由 Google Trust Services 签发。当时 REALITY 借的就是 Cloudflare 的站(更早用的是 www.microsoft.com,它的证书太大,超过了 REALITY 8192 字节的上限,握手失败,才换的),服务端本身没有问题。
最后是抓包。VPS 上开着 tcpdump,从国内那台机器去连 37443:
tcpdump -ni any tcp port 37443 -c 10
结果很清楚:国内发来的 SYN 每秒到一次,VPS 每次都回了 SYN-ACK,但对面一直在重发 SYN,说明回包一个都没收到。去程是通的,回程被丢了。只丢回程、不丢去程,是 GFW 封端口的典型表现。博客的 443 同时还能正常打开,所以封的是端口,IP 没事。
为什么会被封
在 VPS 本机做握手测试时其实已经能看出问题:REALITY 握手里写的 SNI 是 www.cloudflare.com,可这个域名解析出来是 Cloudflare 自己的 IP 段,不是我这台美国 VPS。端口也不是 443,而是 37443。
REALITY 防主动探测确实很有效:GFW 拿着 SNI 来连,看到的是 Cloudflare 的真证书。但它不一定要来探测,光看流量就能发现对不上:SNI 说要去 A 网站,目标 IP 却不是 A 网站的,端口也不对,流量还不小。这种连接一多就容易被盯上。
顺便了解了一下其他协议
既然要改,就先看了下除了 REALITY 还有什么选择:
- VLESS / Trojan + TLS + WebSocket / gRPC / XHTTP:需要自己的域名和证书,好处是能套 Cloudflare CDN,VPS 的 IP 被封了也能用。XHTTP 是 Xray 新出的传输方式,过 CDN 比 WebSocket 稳。
- Hysteria2、TUIC:基于 UDP/QUIC,线路差、丢包多的时候比 TCP 类协议快,问题是有些运营商会对 UDP 限速。
- ShadowTLS、AnyTLS:sing-box 那边的方案,思路和 REALITY 差不多。
- VMess、老版 Shadowsocks:已经过时了。
最后还是决定继续用 REALITY,只是换个用法:端口改成 443,SNI 换成自己的域名。
偷自己
这是社区里的叫法,意思是 REALITY 不借别人的网站,借自己 VPS 上的网站。
做法是在 VPS 上用 Nginx 跑一个正常的小网站,配好 Let's Encrypt 证书,然后把 REALITY 的伪装目标指向它:
客户端 ──TLS 握手(SNI = <新域名>)──▶ VPS:443
├─ 带了正确密钥(我)→ 代理
└─ 没带密钥(探测者)→ 本机的小网站,真证书 + 正常页面
这样 SNI 解析出来就是这台 VPS,证书是这个域名真实签发的,端口是 443,打开也是一个正常网站,前面那几处对不上的地方全都对上了。
端口刚被封的时候,我在论坛发帖问过,提到自己的域名已经拿来建站了。有位网友回复,大意是:自己的域名绑着自己主机的 IP,在国内访问时,DNS 查询和 443 上的 HTTPS 握手都会被看到,域名、IP 和人就关联起来了,所以网站和代理最好分开,或者网站套 CF;另外被封时多数只是封端口,换个端口,把原来的域名和 IP 解绑,临时换个子域名先用着。
他担心的方向和偷自己不太一样。偷自己是让流量更难被识别,他关心的是一旦被识别,网站会不会被一起牵连。两种考虑都对。最后的折中是:
- 博客接入 Cloudflare,开橙云,把源站 IP 藏起来;
- 另买一个和博客无关的域名,专门用来偷自己。
博客接入 Cloudflare
博客域名是在西部数码买的,解析也一直在西部数码。接入 CF 不用转移域名,只要把 NS 改成 CF 给的两个地址,以后解析记录就在 CF 上管,续费、实名都还在西部数码。
NS 是 Name Server,也就是负责回答"这个域名解析到哪个 IP"的服务器。
步骤不复杂,但有几个地方差点出问题:
- CF 添加站点后会自动扫描现有记录。扫出来只有
www、admin、img、api四条 A 记录,偏偏漏了根域名@。如果直接激活,切完 NS 后arcueidblog.top本身就打不开了。所以一定要拿西部数码后台的记录逐条对一遍。 - 那条
google-site-verification的 TXT 记录要留着,Search Console 会定期回来检查。 - SSL/TLS 模式选「完全(严格)」。VPS 上本来就有 Let's Encrypt 证书,选 Flexible 可能会出现重定向循环。
几条 A 记录都开了橙云(已代理)。api 子域名稍微纠结了一下:CF 免费版单次请求超过 100 秒会返回 524,模型长回复有可能被切断。最后先开着橙云,真遇到 524 再改成灰云。
CF 还提示这个域名没有 SPF 和 DMARC 记录,别人可以冒用它发邮件。博客的订阅邮件是用 Gmail 发的,这个域名本身不发信,所以加了两条"禁止冒充"的记录:
| 类型 | 名称 | 内容 |
|---|---|---|
| TXT | @ |
v=spf1 -all |
| TXT | _dmarc |
v=DMARC1; p=reject; |
我第一次把 DMARC 那条的名称也填成了 @,结果 _dmarc 下面是空的,查了一下才发现,改过来就好了。
查 NS 有没有生效:
dig +short NS arcueidblog.top @223.5.5.5
返回 *.ns.cloudflare.com 就是切好了。把最后的 DNS 换成 8.8.8.8、1.1.1.1 可以对比各家的缓存情况。我这次改完没多久,阿里、Google、Cloudflare 就都更新了。
开了橙云之后,访客连的是 CF 的节点,CF 再回源到美国的 VPS。回源这一段在海外,GFW 管不到,所以就算哪天 VPS 的 IP 被封,博客在国内也还能打开。
速度
我在东京这边感觉不到变化,因为东京有 CF 的节点。国内用 itdog 的网站测速跑了一次:
| 运营商 | 平均打开时间 |
|---|---|
| 移动 | 1.7 秒 |
| 电信 | 3.1 秒 |
| 联通 | 6.6 秒,部分节点 10~12 秒 |
316 个监测点全部打开成功,整体平均 3.2 秒。联通最慢,这和我原先以为的"电信最容易慢"不一样。免费版 CF 在国内没有节点,线路分配也经常变,这个结果只能作为一次参考。
缓存规则
测了一下当时的缓存情况:JS、CSS 已经被 CF 缓存,但默认只在边缘存 4 小时;首页和文章页的 HTML 每次都回源到美国,国内访客慢主要就慢在这里。
在「缓存 → Cache Rules」里加了四条规则。CF 的规则是后面的覆盖前面的,所以"绕过缓存"那条放在最后:
| 顺序 | 规则 | 边缘 TTL | 浏览器 TTL |
|---|---|---|---|
| 1 | 静态资源:/assets/、/vendor/、/models/ |
1 个月 | 7 天 |
| 2 | 图片站 img 子域名 |
1 个月 | 7 天 |
| 3 | 首页、/blog、/articles/* 的 HTML |
10 分钟 | 遵循源站 |
| 4 | admin、api 子域名和 /api/ 路径 |
绕过缓存 |
文章页敢缓存,是因为确认过这些页面不带 Cookie,登录状态都在前端,所有人拿到的 HTML 都一样。代价是改了文章标题或摘要后,最晚 10 分钟才会更新。发布新的前端版本后,要记得到 CF 后台点一下「清除所有内容」。
加完再测,文章页第二次访问就是 HIT 了。
拿回访客的真实 IP
接入 CF 后,Nginx 日志里的来源全变成了 172.71.x、162.158.x 这类 CF 节点的 IP。这不只是日志不好看:Nginx 对 api 和 Office 都有按 IP 限流,现在同一个 CF 节点后面的所有人共用一个额度,人一多就可能被误判成超限,返回 429。后端的访客统计和登录日志也都记成了 CF 的 IP。
修法是在 Nginx 里列出 CF 的 IP 段(官网上公布的,IPv4 15 段、IPv6 7 段),只有连接确实来自这些 IP 时,才采信 CF-Connecting-IP 头。直接连源站的人伪造这个头是没用的。后端优先读 Nginx 传过去的 X-Real-IP,不用改代码。
这里后来又改了一次,下面会说到。
买新域名
一开始想买 arcueidproxy.top,后来没买,原因有三个:
- 名字里带
proxy。偷自己时 SNI 是明文的,GFW 在握手里就能看到,这等于主动告诉它我是代理。 .top的注册局在国内。- 国内注册商要实名,域名直接和身份证绑在一起。
名字里的 arcueid 我还是想保留,好记,看起来也像个人网站。代价是有心人能猜到它和博客是同一个人。最后在 Cloudflare Registrar 买了一个 arcueid 开头的 .date 域名,不用实名,WHOIS 默认带隐私保护,一年几美元。
这篇文章不写出它的名字,就是为了不在博客上把两个域名直接连起来。
为什么新域名必须是灰云
橙云和灰云的区别:
| 橙云(已代理) | 灰云(仅 DNS) | |
|---|---|---|
| 别人查到的 IP | CF 的 IP | VPS 的真实 IP |
| 流量路径 | 访客 → CF → VPS | 访客 → VPS |
| TLS 由谁处理 | CF 用自己的证书解开再转发 | 服务器自己处理 |
| 支持的流量 | 只有 HTTP(S) | 任何协议、任何端口 |
博客适合橙云。新域名必须用灰云,有两个原因:一是 CF 会在中间用自己的证书把 TLS 解开,REALITY 依赖的是从客户端到服务器那一次完整的握手,被拆开就失效了;二是开了橙云,域名就解析到 CF 的 IP,又回到 SNI 和 IP 对不上的老问题。
灰云会公开 VPS 的 IP,这也是不能用博客子域名的原因:如果用 x.arcueidblog.top,谁都能查到这条记录拿到源站 IP,博客套 CF 就白做了。用独立域名的话,暴露的只是新域名指向这台机器,arcueidblog.top 下面仍然全是橙云。
REALITY 挪到 443
443 已经被博客的 Nginx 占着,所以在最前面加了一层 Nginx stream,只读取 TLS 握手里的 SNI,不解密,按域名分流:
外部 :443 → Nginx stream(ssl_preread 读 SNI)
├─ SNI = <新域名> → 宿主机上的 Xray :37443
│ ├─ 我的客户端 → 代理
│ └─ 探测者 → 诱饵站(Nginx :8444)
└─ 其他(博客、admin、img、api)→ Nginx :8443,照常处理
Nginx 的主要改动:
stream {
map $ssl_preread_server_name $sni_upstream {
<新域名> xray_reality;
default aurora_https;
}
upstream xray_reality { server host.docker.internal:37443; }
upstream aurora_https { server 127.0.0.1:8443; }
server {
listen 443;
ssl_preread on;
proxy_protocol on;
proxy_pass $sni_upstream;
}
}
原来所有的 listen 443 ssl 改成 listen 8443 ssl proxy_protocol。Nginx 跑在 Docker 里,Xray 跑在宿主机上,compose 里要加 extra_hosts: host.docker.internal:host-gateway,容器才能连到宿主机。
Xray 这边的改动不多:dest 改成 127.0.0.1:8444(诱饵站),serverNames 改成新域名,再在 sockopt 里打开 acceptProxyProtocol。UUID、密钥、shortId 都不变。新域名的证书用博客现有的 certbot 容器申请。
真实 IP 又丢了一次
加了 stream 这一层之后,http 那边看到的来源都变成了 127.0.0.1,前面配好的 CF IP 判断也就失效了。
解决办法是让 stream 用 PROXY protocol 把原始连接的 IP 传过去,然后分两步判断:
real_ip_header proxy_protocol:先从 PROXY protocol 里还原出真正连进来的 IP;- 用
geo判断这个 IP 是不是 CF 的节点,是的话取CF-Connecting-IP,不是的话就用连接本身的 IP。
set_real_ip_from 127.0.0.1;
real_ip_header proxy_protocol;
geo $remote_addr $from_cloudflare {
default 0;
# CF 的 22 个 IP 段,这里省略
}
map $from_cloudflare $client_ip {
1 $http_cf_connecting_ip;
default $remote_addr;
}
限流、日志和传给后端的 X-Real-IP 都改成用 $client_ip。日志格式里同时记下 $remote_addr,一眼能看出这个请求是哪个 CF 节点转过来的。
切换和验证
改了端口映射,Nginx 容器要重建。之前估计会断十几秒,实际只断了 1 秒左右。切完后验证了这些:
- 经 CF 访问博客、www、admin、img、api,都是 200;
- 日志里的
$client_ip是访客的真实 IP,伪造CF-Connecting-IP不会被采信; - 用新域名做 SNI、不带密钥去连,返回的是诱饵站,HTTP/2,证书校验通过;
- 在 VPS 上起一个临时客户端,经公网 443 连进来,访问 Google 的
generate_204成功,出口是 VPS。
37443 现在只接受带 PROXY protocol 头的连接,从外面直接连已经握手不了,安全组里那条放行规则也可以删了。
证书续期后 Nginx 不会 reload
改的时候发现了一个一直存在的问题:certbot 容器每 12 小时检查一次续期,但续完不会通知 Nginx,Nginx 会一直用内存里的旧证书。博客之前就是这样,只是还没到期没暴露出来。对诱饵站来说更要紧,证书一过期,探测者看到的就是一个证书过期的网站,很可疑。
在宿主机上加了一个 systemd 定时器,每天凌晨 5 点(东京时间)先 nginx -t 检查配置,通过了再 nginx -s reload。reload 是平滑的,不会断开现有连接。
诱饵站做成了个人主页
诱饵站一开始只放了几行占位文字,探测程序看不出问题,但人工看一眼就知道是摆设。后来干脆做成了一个真的个人主页:一段自我介绍,几个 GitHub 上的开源项目,几条夜里散步时记的笔记。纯静态 HTML,没有 JS,也不加载外部资源。
本来还想在上面链几篇博客文章,最后没放。新域名是灰云,谁都能查到它指向哪个 IP,主页上一放博客链接,等于直接告诉别人这两个网站在同一台机器上,博客套 CF 藏 IP 就打了折扣。反过来,哪天这个域名因为代理被盯上,顺着链接也能找到博客。
升级 Xray,不再兼容 Mihomo
之前服务端的 Xray 一直停在 26.7.28,原因是手机上用的是 Mihomo 内核的客户端。Xray 从 26.9.8 开始,REALITY 会拒绝不带 X25519MLKEM768 的 ClientHello,因为新版 Chrome 默认都带这个,不带的看起来像旧浏览器或者怪客户端。Mihomo 默认不带,要手动开 support-x25519mlkem768,上次迁移服务器时就在这上面卡了很久。
这次决定不再兼容 Mihomo,客户端改用 Xray 内核的(Windows 用 v2rayN,安卓用 v2rayNG),服务端升到了 v26.10.29。升级时下载官方 release,对照 .dgst 核对 SHA256,用新版本对现有配置跑一遍 -test,旧的二进制留着备份,然后替换、重启。配置一个字都没改,重启后端到端测试正常。
顺带问了下要不要装 x-ui 之类的面板。Xray 没有官方面板,第三方面板都会接管 Xray,把配置存进自己的数据库重新生成,手调的 acceptProxyProtocol 这些都得在面板里重新配一遍。面板本身还是一个 root 权限的 Web 服务,多了一个攻击面。我只有一个人用,就不装了。
我真正想要的只是方便拿到客户端配置,所以在 VPS 上写了个小脚本 xray-link:读取线上的 Xray 配置,用 xray x25519 -i 从私钥算出公钥,输出 vless:// 分享链接、各项参数和一个终端二维码。在 Mac 上一条命令就能把链接复制到剪贴板:
ssh vps-root xray-link --link | pbcopy
还没做完的
写这篇的时候,客户端还没换成新版,国内实测也还没做;安全组里 37443 的放行规则也还没删。