代理端口被封之后,把博客接进了 Cloudflare

· 网安 · #折腾, #Xray, #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 还有什么选择:

最后还是决定继续用 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

博客域名是在西部数码买的,解析也一直在西部数码。接入 CF 不用转移域名,只要把 NS 改成 CF 给的两个地址,以后解析记录就在 CF 上管,续费、实名都还在西部数码。

NS 是 Name Server,也就是负责回答"这个域名解析到哪个 IP"的服务器。

步骤不复杂,但有几个地方差点出问题:

  1. CF 添加站点后会自动扫描现有记录。扫出来只有 www、admin、img、api 四条 A 记录,偏偏漏了根域名 @。如果直接激活,切完 NS 后 arcueidblog.top 本身就打不开了。所以一定要拿西部数码后台的记录逐条对一遍。
  2. 那条 google-site-verification 的 TXT 记录要留着,Search Console 会定期回来检查。
  3. 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,后来没买,原因有三个:

名字里的 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 传过去,然后分两步判断:

  1. real_ip_header proxy_protocol:先从 PROXY protocol 里还原出真正连进来的 IP;
  2. 用 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 秒左右。切完后验证了这些:

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 的放行规则也还没删。

← 全部文章