从零开始配置 VPS

近日,笔者对高质量 LLM 的需求,带来了对高峰期仍能保持稳定的代理、纯净度较高的 IP 地址的需要。所以笔者决定不再用机场服务作主力,转向租用海外 VPS 做个人自用代理。

折腾的两个月以来,笔者遇到了 VPS 被恶意攻击、IP 地址被封禁等问题。与此同时,网络上,比如 YouTube 的视频教程大多都极简,只能保证用户成功搭建起入站、客户端,但是既缺少对 VPS 本身安全性的保护教程,也容易使用户“知其然而不知其所以然”。当然也不乏新协议、技术相关的内容,比如使用 XHTTP 协议使被封 IP 的 VPS 重焕生机、BBRv3 的介绍等……这些都是笔者所欠缺的知识。

综上,笔者将从自身的折腾经验出发,分享如何从零开始配置 VPS,主要内容如下:

  1. VPS 选购
  2. 安全性配置
  3. 3x-ui 安装
  4. 入站与客户端配置
  5. 订阅配置
  6. 安全发布订阅
  7. 公网 IP 被封后的 XHTTP 恢复方案
  8. ECH、DoH 与 H3 优化
  9. 性能测试、安全边界与故障排查
  10. 使用 Sub-Store 处理订阅

此外,笔者最初撰写本博客时,3x-ui 的版本是 v3.4.1。本文记录的是一个特定时间点的配置,3x-ui、Xray、Mihomo 和 XHTTP 本身变化都很快,尤其是客户端字段与协议支持,实际部署前仍应检查官方文档和发布记录。 补充XHTTP相关内容时,3x-ui 的版本来到 v3.4.2 。

参考网站:

VPS 选购

这篇博客并非广告,VPS 提供商的来源为 NodeSeek 以及 LLM 的输出(多为 ChatGPT)。笔者在选购的时候,主要是找带有三网优化(TRI)的 VPS 服务,比如 BandwagonHost、DMIT、GigsGigsCloud、Vmiss 和 Hostdare 等一系列 VPS 提供商,它们的价格、地区均有差异。对于本地用户来说,首先要关注的就是 VPS 的线路,大致有以下几种:

  • TRI 在 VPS 商家语境里通常是 Tri-network optimized 的缩写,即“三网优化”。它不是某个运营商的正式线路名称,而是套餐命名方式。一般表示电信、联通、移动三家分别尽量走较优路径,例如电信走 CN2 GIA,联通走 9929 或 4837 优化,移动走 CMI/CMIN2。
  • CN2 常见于电信方向,常见分类有 CN2 GT 和 CN2 GIA。GIA,即 Global Internet Access,通常比 GT 更高端,价格更贵,低峰和晚高峰的稳定性也通常更好。
  • 9929 通常指联通 AS9929,也常被称为联通精品网、CUII,是一类走联通优质国际链路的线路标识。相比联通普通骨干网 AS4837,9929 一般更适合对联通用户优化,常见于香港、日本、美国西海岸等面向本地三网优化的 VPS。
  • 4837 通常指联通普通骨干网 AS4837,也常被称为 169 骨干网。它覆盖面广、成本相对低,但国际方向在高峰期更容易受到拥塞影响。
  • CMI 通常指移动国际线路,常见 AS 号是 AS58453;CMIN2 则是近年 VPS 圈里常见的移动高端线路说法,常与 AS58807 相关。对于移动用户,CMI/CMIN2 的体验往往比绕路国际 BGP 更稳定。

不同 VPS 的服务详情,节点速率、延迟等数据,均可以通过搜索引擎查阅到,笔者不多赘述。 此外,两个月前笔者挑选 VPS 时,就有优质线路服务短缺的问题,比如 DMIT,笔者观察了许久,时至今日多数产品也都是 “Out of stock” 的状态,其他提供商也是同理(除了配置比较豪华的服务)。现在看来当时能搞到一台 VPS 用到现在,大概是运气好吧。

成功付款后,通常会被分配到一台 VPS 供我们折腾。操作系统的选择个人认为区别不大,管理界面大致如下:

通常这里会有最为重要的 VPS IP 信息,以及一些基本的管理功能。

安全性配置

开机后,第一步就是基本的安全性配置,避免被恶意攻击。 笔者在前几天遭遇了一次,原因是因为偷懒没有禁用密码登录,fail2ban 的规则也没设置好,于是在被攻击了长达 1 个小时后,IP 也被封了(可能是巧合)。这还是在 IP 被封以后,观察到 CPU 利用率一段时间内竟然持续 100%,才发现的。

首先,通过 SSH 和提供商给出的初始 root 账户连接到 VPS 上(使用提供商的 VNC console 也可以),执行下面的命令进行软件的更新,以及必要的 ufwfail2ban 的安装:

1
2
apt update && apt full-upgrade -y
apt install -y sudo curl wget vim git ufw fail2ban

SSH 登录

出于历史教训,应尽早关闭密码登录,仅保留 SSH 密钥登录方式。笔者使用了 VPS 官方控制台提供的 OpenSSH 配置功能,它会自动生成好密钥文件到 VPS 中,并关闭密码登录,我需要做的只是把密钥文件配置到客户端的机器上,以便于访问。

切换到客户端机器的用户目录下,找到(或者创建).ssh 目录,存放生成的密钥文件,文件名随意。 之后再创建(或编辑)一个名为 config 的文件,需要新增内容样式如下:

1
2
3
4
5
Host vps                            # 本地别名(以后 ssh vps)
    HostName 1.2.3.4                # VPS 的 IP 或域名
    User root                       # 登录用户名
    IdentityFile ~/.ssh/id_ed25519  # 私钥路径
    IdentitiesOnly yes              # 强制只用该密钥,避免 SSH 自动尝试其他 key

如果要自己生成密钥文件的话,那么流程略有不同,且 VPS 上也要进行对应操作。首先,在客户端机器上生成一个随机密钥:

1
ssh-keygen -t ed25519

把公钥复制到 VPS 上:

1
2
3
4
mkdir -p ~/.ssh
nano ~/.ssh/authorized_keys # 把生成好的密钥复制进去
chmod 600 ~/.ssh/authorized_keys
chmod 700 ~/.ssh

禁用密码登录:

1
nano /etc/ssh/sshd_config

找到以下字段并修改为:

1
2
3
4
PermitRootLogin prohibit-password
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes

最后重启一下服务:

1
systemctl reload ssh

改 SSH 默认端口

1
nano /etc/ssh/sshd_config

在其中找到 Port 字段,默认值通常是 22,我们把它改成非默认值。 改好后,在客户端机器的 ~/.ssh/ 下也进行修改(或添加)Port 字段为相应的值。

ufw 配置

一开始的时候,我们安装过了 ufw,所以先检查一下其状态:

1
ufw status

输出应该是 inactive,不过这不影响我们继续配置。首先,设置其默认策略:

1
2
ufw default deny incoming  # 拒绝所有入站连接(安全默认)
ufw default allow outgoing # 允许所有出站连接(系统正常联网)

接下来,要放行 SSH 连接的端口和 Web 入站的端口:

1
2
3
ufw allow 22/tcp # 默认值是 22,但应该填我们之前修改好的值
ufw allow 443/tcp
ufw allow 443/udp # 只在真正部署 Hysteria2 或其他公网 UDP 服务时开放

这里需要额外说明:后文的 XHTTP + Cloudflare Tunnel 不要求 VPS 公网开放 443/udp,也不要求 Xray 监听公网 UDP。它的 H3 终止在 Cloudflare 边缘,VPS 上的 cloudflared 主动建立出站 Tunnel,再通过本机 TCP 回源到 Xray。没有实际部署 Hysteria2 等公网 UDP 服务时,就不要为了“可能会用”而提前开放端口。

最后,开启 ufw,并检查记录是否正确:

1
2
ufw enable
ufw status verbose

fail2ban 配置

在完成 SSH 和防火墙基础加固之后,下一步是启用 fail2ban 来防止暴力破解 SSH 登录。

首先确认 fail2ban 是否已经安装并运行:

1
systemctl status fail2ban

检查完成后,启用并启动服务:

1
2
systemctl enable fail2ban
systemctl start fail2ban

接下来配置 SSH 防护规则。建议不要直接修改默认配置文件,而是在 jail.d 目录下新增配置:

1
nano /etc/fail2ban/jail.d/sshd.local

写入以下内容:

1
2
3
4
5
6
7
[sshd]
enabled = true
backend = systemd
port = ssh
maxretry = 3   # 最多尝试三次
findtime = 10m # 10 分钟之内
bantime = 12h  # 封禁时间

如果你修改过 SSH 端口,需要同步更新 port 字段,例如:port = 2222

配置完成后,重启 fail2ban 使其生效:

1
systemctl restart fail2ban

然后检查 SSH jail 是否正常运行:

1
2
fail2ban-client status
fail2ban-client status sshd

最后确认日志中已经开始记录失败登录尝试即可,说明防护已经生效。

自动安全更新

用于自动安装系统安全更新,减少 VPS 暴露时间窗口。

1
2
apt install -y unattended-upgrades apt-listchanges
dpkg-reconfigure --priority=low unattended-upgrades

检查是否启用:

1
cat /etc/apt/apt.conf.d/20auto-upgrades

测试运行:

1
unattended-upgrade --dry-run --debug

3x-ui 安装

使用官方脚本安装 3x-ui 面板:

1
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)

安装器会生成随机用户名、密码和 Web Base Path,端口可以手动指定,也可以随机生成。这些信息在第一次登录前一定要保留好,因为登录到面板上之后,这些都可以修改。格式大致如下:

1
2
3
4
5
Username:
Password:
Port:
WebBasePath:
Access URL:

之后,脚本会询问证书方式:

1
2
3
4
1. Let's Encrypt for Domain
2. Let's Encrypt for IP Address
3. Custom SSL Certificate
4. Skip SSL

多数教程在这里会让用户选择 1,去分配一个域名给 3x-ui 面板,便于在公网直接通过这个域名访问,笔者第一次安装时也是这样做的。

但是现在我认为没有必要,或者说暴露到公网上有一定风险,所以选择了 “4. Skip SSL”,后续通过 SSH Tunnel 来访问面板。

之后脚本会询问是否绑定到 127.0.0.1,输入:y (yes)

后续检查:

1
ss -lntp | grep x-ui

如果上一步选择了 “4. Skip SSL”,输出结果应该是:

1
127.0.0.1:面板端口

或者是:

1
0.0.0.0:面板端口

安装成功之后,检查运行状态:

1
2
systemctl status x-ui --no-pager
systemctl is-enabled x-ui

SSH Tunnel 访问面板

在客户端机器运行:

1
2
3
4
5
ssh -i ~/.ssh/你的私钥 \
  -p 你的 SSH 端口 \
  -N \
  -L 2222:127.0.0.1:面板端口 \
  root@你的 VPS 公网 IP

或者修改 ~/.ssh/config,实现 SSH 连接和隧道建立的一举两得:

1
2
3
4
5
6
Host vps
    HostName 1.2.3.4
    User root
    IdentityFile ~/.ssh/id_ed25519
    IdentitiesOnly yes
    LocalForward 面板端口号 127.0.0.1:面板端口号 # 新增字段用于建立隧道

入站与客户端配置

用户配置

登录后,我建议先在左侧栏的 “面板设置” -> “安全设定” -> “管理员凭据” 中,把用户名和密码进行更换。

入站配置

之后,找到 “入站” -> “新建入站”,这是搭建代理服务的第一步:

  1. 我们先建立一个 VLESS + TCP + REALITY 的入站,“基础配置” 页中,“备注”随意(最好不留空),“协议”选择 “vless”。 如果之前安装面板时,给它分配了域名,那么“分享地址策略”不用更改;如果没有,那么“分享地址策略”要改为“自定义”,并在“自定义分享地址”中填写 VPS 的 IP。 “端口”改为 443。
  2. “传输” 页,“传输”项保持默认的 “RAW” 不变即可(即 TCP)。
  3. “安全” 页,“安全”项选择 “REALITY”。 “目标”栏,可以像多数教程那样,填一个大型网站的域名,比如 “www.microsoft.com:443”,同时“SNI”栏和“目标”保持一致,填 “www.microsoft.com”。 或者说去筛选一下域名,而不是随便填,原因如下:
    • REALITY 的目标域名不是随便拿来“装样子”的,它参与服务端对外握手特征的构造,也要求你的 VPS 到这个目标站能正常建立 TLS 1.3 / h2 连接。
    • 所以不能无脑填大网站:大网站往往有复杂 CDN、区域策略、TLS 策略差异。某个域名在你本地能访问,不代表从 VPS 所在机房访问也正常。
    • 更合理的做法是从 VPS 上实际测试候选目标,确认它长期可达、支持 TLS 1.3 和 h2、握手稳定,并且不会因为地区或 CDN 策略频繁变化。VPS 的物理位置可以作为延迟和稳定性的参考,但不能仅凭“日本 VPS 访问某个美国公司的域名”就断言它看起来不真实;这种判断缺少可靠依据。简单说,REALITY 目标域名要按“VPS 视角”实测,而不是按主观印象选择。
  4. 最后下滑,选择 “创建”,第一个入站就创建好了。

客户端配置

接着,我们还需要创建 “客户端”,虽然 VPS 的用途一般是个人自用,但是不同的设备也可以安排不同的客户端,便于管理。找到 “客户端” -> “添加客户端”:

  1. “基本” 页,只需在 “关联入站” 项,选择对应的“入站”即可,其他的配置项按需更改。
  2. “凭据” 页,只需在 “Flow” 项,选择 “xtls-rprx-vision” 即可,其他的配置项无需修改。
  3. 选择 “创建”,客户端就配置完毕了。

理论上来讲,如果安装面板时分配了域名,那么现在只要点击 “客户端信息”,复制订阅链接到客户端,就算成功了。 但是订阅设置还有几项有待调整。

订阅配置

路径配置

“面板设置” -> “订阅设置” -> “常规”,找到 “URL 路径” 项进行修改(这也是面板建议我们修改的,从而保证安全性),笔者是生成了一个 “CSPRNG 随机数”,改掉了原本的 “/sub/”,但具体改成什么都可以,只要能保证其安全。

Clash 配置

由于笔者用的客户端都是 Clash 系的,所以在此提及一下。

  1. 确认 “面板设置” -> “订阅设置” -> “常规” 中的 “Clash / Mihomo 订阅” 是开启状态。
  2. “面板设置” -> “Sub Formats” -> “常规”,找到 “Clash URL 路径” 进行修改,和上面路径配置同理。
  3. “面板设置” -> “订阅设置” -> “Clash / Mihomo”,开启 “启用路由” 项,然后填写下面的 “全局路由规则”。

路由规则的作用是分流:需要代理的服务走 VPS,例如 OpenAI、GitHub、Google;本来就能直连且更快的流量直接走本地网络。这样延迟更低、VPS 流量消耗更少,也更不容易触发网站风控。国内网站、本地局域网、银行/政务/支付、部分游戏和 CDN,如果都走 VPS,可能变慢、风控、定位异常,甚至访问失败。像 NAS、路由器、打印机这类内网地址更不应该走代理。

笔者最初使用的是直接写在配置里的 GEOSITE/GEOIP 规则。它能工作,但规则数据会跟着客户端内核或本地 geodata 版本走,不利于单独更新。后来改成了 Mihomo 的 Rules Provider:规则本身使用 MetaCubeX 维护的 .mrs 文件,客户端每天检查一次更新,而配置只负责定义优先级。

下面是笔者目前实际使用的模板,可以直接填进 3x-ui 的“全局路由规则”:

 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
rule-providers:
  private-domain:
    type: http
    behavior: domain
    format: mrs
    interval: 86400
    proxy: PROXY
    path: ./ruleset/private-domain.mrs
    url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geosite/private.mrs"

  # 中间的 Provider 按同样格式定义,此处省略:
  # private-ip、ads、apple-cn、microsoft-cn、steam-cn、games-cn、
  # cn-domain、openai、anthropic、github-copilot、ai-non-cn、
  # google、github、microsoft-global、youtube、telegram-domain、
  # telegram-ip、twitter、gfw、geolocation-non-cn

  cn-ip:
    type: http
    behavior: ipcidr
    format: mrs
    interval: 86400
    proxy: PROXY
    path: ./ruleset/cn-ip.mrs
    url: "https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/meta/geo/geoip/cn.mrs"

rules:
  - RULE-SET,private-domain,DIRECT
  - RULE-SET,private-ip,DIRECT,no-resolve
  - RULE-SET,ads,REJECT

  - RULE-SET,apple-cn,DIRECT
  - RULE-SET,microsoft-cn,DIRECT
  - RULE-SET,steam-cn,DIRECT
  - RULE-SET,games-cn,DIRECT
  - RULE-SET,cn-domain,DIRECT

  - RULE-SET,openai,PROXY
  - RULE-SET,anthropic,PROXY
  - RULE-SET,github-copilot,PROXY
  - RULE-SET,ai-non-cn,PROXY
  - RULE-SET,google,PROXY
  - RULE-SET,github,PROXY
  - RULE-SET,microsoft-global,PROXY
  - RULE-SET,youtube,PROXY
  - RULE-SET,telegram-domain,PROXY
  - RULE-SET,telegram-ip,PROXY,no-resolve
  - RULE-SET,twitter,PROXY
  - RULE-SET,gfw,PROXY
  - RULE-SET,geolocation-non-cn,PROXY

  - RULE-SET,cn-ip,DIRECT
  - MATCH,PROXY

这里的顺序不能随意调整。私网、广告和中国大陆域名必须先处理,之后才是需要代理的服务;cn-ip 放在靠后位置,作为域名规则没有命中时的 IP 兜底,最后才交给 MATCH,PROXY。所有 Provider 都通过 PROXY 下载,主要是为了保证 GitHub Raw 可达;如果代理暂时失效,已经下载到本地的 .mrs 缓存仍可继续使用,但这期间无法取得新的规则版本。

安全发布订阅

如果和笔者一样,没有给面板配置域名的话,经过了上述一系列操作,订阅现在仍然无法从公网获取。接下来,我们为订阅服务创建 Cloudflare Tunnel,保证订阅正常的获取和更新。当然,面板还是不会暴露给公网的,代理入站还是走 VPS IP 443 端口。

首先,更改 “面板设置” -> “订阅设置” -> “常规” 下的 “监听端口”,默认是 2096。

安装 Cloudflare 服务:

1
2
apt install -y cloudflared  # 安装 cloudflared
cloudflared tunnel login    # 登录 Cloudflare

这里会输出一个链接,我们复制到浏览器,完成 Cloudflare 的登录。

接着,创建隧道,这里仍然需要用到域名:

1
2
cloudflared tunnel create xui-sub                       # xui-sub 是示例隧道名
cloudflared tunnel route dns xui-sub <SUB_HOST>         # 给订阅域名创建 DNS 路由

然后写 /etc/cloudflared/config.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
tunnel: 你的-tunnel-id
credentials-file: /etc/cloudflared/你的-tunnel-id.json

ingress:
  - hostname: <SUB_HOST>
    path: /clash/.*
    service: http://127.0.0.1:2096

  - hostname: <SUB_HOST>
    service: http_status:404

  - service: http_status:404

这里最关键的是:第一条只匹配 /clash/.*,其他路径全部 404。Cloudflare Tunnel 的 ingress 规则按顺序匹配; 没有 pathhostname 规则会匹配该域名下所有路径,所以 404 兜底必须放在订阅规则后面。 Cloudflare 官方文档也说明 ingress rules 会按顺序匹配,并要求最后配置兜底规则。

验证配置:

1
2
3
cloudflared --config /etc/cloudflared/config.yml tunnel ingress validate
cloudflared --config /etc/cloudflared/config.yml tunnel ingress rule https://<SUB_HOST>/clash/<CLASH_BASEURL>
cloudflared --config /etc/cloudflared/config.yml tunnel ingress rule https://<SUB_HOST>/anything

期望输出:

1
2
/clash/test  → http://127.0.0.1:2096    # 默认端口是 2096
/anything    → http_status:404

最后安装为服务并启动:

1
2
3
cloudflared --config /etc/cloudflared/config.yml service install
systemctl enable --now cloudflared
systemctl status cloudflared --no-pager

如果还想更加安全,限制对订阅前缀的高频扫描,那么需要在 Cloudflare 的 “Security” -> “Security Rules” -> “Rate limiting rules” 中填写相应的规则,比如:

1
(http.host eq "sub.example.com" and starts_with(http.request.uri.path, "/clash/"))

这一条 Tunnel 只负责发布订阅,不能和后面的代理 Tunnel 混为一谈。它把极少数订阅路径转发到 3x-ui 的订阅服务,面板管理端口依然只通过 SSH Tunnel 访问;真正承载 VLESS + XHTTP 流量的是另一个独立 hostname 和本机端口。

VLESS + XHTTP

前面配置的 RAW + REALITY,需要客户端直接连接 VPS 的公网 IP。正常使用时,这种方式很简单;但是一旦 IP 被封,VPS 上的 SSH、Xray 和其他服务可能都还好好的,客户端却已经连不上了。

笔者后来使用的 XHTTP + Cloudflare Tunnel,就是为这台 VPS 换了一个入口:客户端不再直连 VPS,而是先连接 Cloudflare,再由 Cloudflare 把流量送回去。它不会让被封的 IP 恢复正常,只是绕开了原来的入口。

为什么原 IP 被封后仍然可以使用

整个连接过程大致如下:

1
2
3
4
5
6
Mihomo
  → Cloudflare(H3 + ECH)
  → Cloudflare Tunnel
  → VPS 上的 cloudflared
  → 127.0.0.1:44433
  → Xray

这里需要注意,H3 只用在 Mihomo 到 Cloudflare 的这一段。Cloudflare Tunnel 是另外一条连接;流量到达 VPS 后,cloudflared 再通过普通的 HTTP/TCP,把它交给本机的 Xray。

所以,Xray 只监听 127.0.0.1:44433/TCP 就可以了,x-ui 中的 QUIC Params 也不用打开。Cloudflare 目前不支持通过 HTTP/3 连接源站,如果把 Tunnel 后面的服务错写成 HTTPS、UDP 或其他端口,通常只会得到 502 错误,并不会让速度更快。

建立 XHTTP 入站

笔者没有删除原来的 REALITY 入站,而是另外创建了一条 XHTTP。这样旧线路仍然可以作为备用,出现问题时,也方便判断是公网 IP、Cloudflare 还是 XHTTP 本身出了故障。

登录面板后,找到 “入站” -> “新建入站”。真正需要关注的设置并不多:

字段配置
协议VLESS
Listen127.0.0.1
本地端口44433(示例,可自定义)
TransportXHTTP
Host<XHTTP_HOST>
Path一条足够长的随机路径
Modepacket-up
Padding100-1000
Uplink HTTP methodPOST
服务端 XMUX、QUIC Params关闭
Flow启用 VLESS Encryption 时可使用 xtls-rprx-vision;否则留空
VLESS Encryption由面板成对生成服务端与客户端配置

其中,Path 可以理解为这条入站的隐藏入口,不要使用日期、用户名或常见单词。除了使用面板自带的随机生成功能,也可以在本地运行:

1
python3 -c 'import secrets; print("/" + secrets.token_hex(16))'

命令会生成一条随机路径,生成一次就够了。Path、UUID 和完整的 VLESS Encryption 字符串都需要妥善保存,不要放进公开截图或日志中。

VLESS Encryption 在服务端显示为 decryption,在客户端显示为 encryption,两者需要由面板成对生成,不能只复制其中一项。这里也顺便说明一下 Flow:如果服务端仍然是 decryption: none,Flow 就应该留空;如果已经正确启用了 VLESS Encryption,则可以同时使用 xtls-rprx-vision。所以,XHTTP 并不是一律不能使用 Vision。

此外,面板中 XHTTP 入站的“安全”仍然填写 none,而客户端节点却需要开启 TLS。两处看上去矛盾,其实是因为 TLS 在 Cloudflare 结束;Cloudflare 再通过加密 Tunnel 回到 VPS,最后一小段只在本机的 127.0.0.1 上传输。

复用现有 Tunnel,并使用独立域名

前面发布订阅时已经安装过 cloudflared,所以这里不需要再安装一套,也不需要新建第二条 Tunnel。笔者直接复用了原来的 Tunnel,只为 XHTTP 准备一个新的域名。下面把订阅域名写作 <SUB_HOST>,XHTTP 域名写作 <XHTTP_HOST>,两者不要混用。

编辑 /etc/cloudflared/config.yml,内容大致如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
tunnel: <TUNNEL_ID>
credentials-file: /etc/cloudflared/<TUNNEL_ID>.json

ingress:
  - hostname: <XHTTP_HOST>
    service: http://127.0.0.1:44433

  - hostname: <SUB_HOST>
    path: /clash/.*
    service: http://127.0.0.1:2096

  - hostname: <SUB_HOST>
    service: http_status:404

  - service: http_status:404

接着,为 XHTTP 域名创建 DNS 路由,并检查规则是否正确:

1
2
3
4
cloudflared tunnel route dns <TUNNEL_ID> <XHTTP_HOST>
cloudflared --config /etc/cloudflared/config.yml tunnel ingress validate
cloudflared --config /etc/cloudflared/config.yml tunnel ingress rule https://<XHTTP_HOST>/
systemctl restart cloudflared

这些规则会从上往下匹配,所以最后的 404 必须保留,用来拒绝没有写进配置的域名和路径。3x-ui 的管理端口也不要放进 Tunnel,继续使用前文的 SSH Tunnel 访问即可。

配置完成后,如果直接用浏览器打开 XHTTP 域名,看到 404 也不用着急。普通网页请求没有正确的 Path、UUID 和 XHTTP 请求格式,本来就不应该被当作有效连接。

Cloudflare 侧的设置

接下来进入 Cloudflare,完成下面三项设置:

  1. 找到 “Speed” -> “Settings” -> “Protocol Optimization”,开启 “HTTP/3”。
  2. 找到 “Rules” -> “Overview” -> “Create rule” -> “Configuration Rule”,新建一条只匹配 XHTTP 域名的规则,把 “Request Body Buffering” 和 “Response Body Buffering” 都设为 None
  3. 不要给这个域名添加 Cloudflare Access、网页挑战或过于严格的 Bot/WAF 规则。

Buffering 可以理解成“先把数据攒起来,再继续转发”。普通网页这样做问题不大,但是 XHTTP 需要持续传输,等待缓冲反而容易变慢。这里一定要让规则只匹配 XHTTP 域名,不要直接应用到整个站点(Zone)。

关联现有客户端

XHTTP 入站创建完成后,不需要重新创建一批客户端。找到之前创建的客户端,把新的 XHTTP 入站加入“关联入站”即可。这样 UUID 和订阅链接都不用更换,客户端刷新原来的订阅后,就能同时看到 REALITY 和 XHTTP 节点。

这里还有一个容易忽略的问题:客户端使用的 Mihomo 内核不能太旧。笔者曾经在一台设备上连接成功,换到其他设备却始终失败,最后发现并不是 VPS 配错了,而是那些设备的内核还不支持 XHTTP/H3、ECH 或 reuse-settings。版本更新得很快,所以这里不必死记某个版本号,直接使用近期的稳定版更省事。

Clash Verge Global Extend Script

在使用文末的 Sub-Store 之前,3x-ui 生成的订阅里没有笔者需要的 H3、ECH 和连接复用设置。因此,笔者最初在 Clash Verge Rev 中使用 Global Extend Script,在每次加载订阅时自动补上这些内容。如果已经部署了后文的 Sub-Store,这一节可以跳过,不要让两套脚本重复修改同一个节点。

这个脚本不会生成 UUID、Path 或 VLESS Encryption,也不会代替 3x-ui 管理客户端。它只负责四件事:把目标 XHTTP 节点改为 H3、开启 ECH、加入连接复用设置,并为目标域名准备加密 DNS。节点本身的凭据,仍然来自每个客户端自己的订阅。

下面保留一份可以直接使用的精简版:

 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
const TARGET_HOST = "xh.example.com";
const DOH_SERVER = "https://dns.alidns.com/dns-query";
const DOH_BOOTSTRAP = "223.5.5.5";

function main(config) {
  const proxies = Array.isArray(config.proxies) ? config.proxies : [];
  const proxy = proxies.find((item) =>
    item &&
    item.type === "vless" &&
    item.network === "xhttp" &&
    item.server === TARGET_HOST &&
    item.tls === true
  );

  if (!proxy) {
    console.log("[XHTTP-H3-ECH] skipped: target proxy not found");
    return config;
  }

  proxy.alpn = ["h3"];
  proxy["ech-opts"] = Object.assign({}, proxy["ech-opts"] || {}, {
    enable: true,
    "query-server-name": TARGET_HOST
  });

  const xhttp = Object.assign({}, proxy["xhttp-opts"] || {});
  xhttp.mode = "packet-up";
  xhttp["reuse-settings"] = {
    "max-concurrency": "16-32",
    "h-max-reusable-secs": "1800-3000",
    "h-keep-alive-period": 0
  };
  proxy["xhttp-opts"] = xhttp;

  const dns = config.dns && typeof config.dns === "object"
    ? config.dns
    : {};
  config.dns = dns;
  dns.enable = true;

  if (!Array.isArray(dns["default-nameserver"]) ||
      dns["default-nameserver"].length === 0) {
    dns["default-nameserver"] = [DOH_BOOTSTRAP];
  }
  if (!Array.isArray(dns.nameserver) || dns.nameserver.length === 0) {
    dns.nameserver = [DOH_SERVER];
  }

  const proxyDns = Array.isArray(dns["proxy-server-nameserver"])
    ? dns["proxy-server-nameserver"]
    : [];
  if (!proxyDns.includes(DOH_SERVER)) proxyDns.push(DOH_SERVER);
  dns["proxy-server-nameserver"] = proxyDns;

  const policy =
    dns["proxy-server-nameserver-policy"] &&
    typeof dns["proxy-server-nameserver-policy"] === "object"
      ? dns["proxy-server-nameserver-policy"]
      : {};
  policy[TARGET_HOST] = [DOH_SERVER];
  dns["proxy-server-nameserver-policy"] = policy;

  console.log("[XHTTP-H3-ECH] updated 1 proxy(s)");
  return config;
}

脚本保存并运行后,日志中应该出现:

1
[XHTTP-H3-ECH] updated 1 proxy(s)

如果显示 skipped,说明脚本没有找到目标节点。这时先检查最终配置中的 typenetworkservertls 是否与脚本一致,没有必要一开始就重装 VPS。

ECH 与 DoH

客户端连接网站时,需要先告诉服务器自己要访问哪个域名,这个信息就是常说的 SNI。没有 ECH 时,SNI 会出现在 TLS 连接开始阶段,沿途网络也就能看到 XHTTP 域名。开启 ECH 后,这部分信息会被加密,外面只留下 Cloudflare 共用的名称 cloudflare-ech.com

不过,ECH 并不等于完全隐身。外部仍然能看到 Cloudflare IP、UDP/443、连接时间和流量大小;Cloudflare 为了把请求送到正确的位置,也仍然知道真实域名、Path 和请求形式。

DNS 也需要单独处理。如果客户端先通过普通 DNS 查询 XHTTP 域名,那么域名可能在建立 TLS 连接之前就已经暴露。因此,Mihomo 还要使用 DoH 或 DoT 查询 HTTPS/TYPE65 记录,从中取得 ECH 所需的配置。

这份 ECH 配置会由 Cloudflare 更新,所以不要把某一次查询得到的结果长期写死。按照 Cloudflare 当前的说明,免费套餐中的域名默认启用 ECH;其他套餐可以在 “SSL/TLS” -> “Edge Certificates” 中找到 “Encrypted ClientHello (ECH)” 开关。设置完成后,还要检查 XHTTP 域名的 HTTPS/TYPE65 记录,确认其中确实带有 ECH 配置。

性能测试、安全边界与故障排查

连接恢复以后,笔者做的第一件事就是测速。毕竟“能够连接”和“能够正常使用”还是两回事。下面的数据只来自笔者当时使用的网络和 VPS,用来记录排查过程,不能直接套用到其他线路上。

从 H2 到 H3 的实测历程

最开始使用 H2 时,网页和小请求勉强正常,但是下载速度只有零点几 MB/s。与此同时,VPS 直连测速接近 92 Mbps,CPU 和内存也很空闲,所以问题应该不在 VPS 的性能上。

为了确认问题是否来自 H2,笔者做了一轮简单的对比:UUID、Path、ECH、Encryption、XMUX、当前网络和下载文件都不变,只把 ALPN 从 H2 改成 H3。ALPN 是 TLS 连接中用来选择后续协议的设置,在这次测试里,可以简单理解成只切换 H2 和 H3。结果如下:

传输20 MB 下载结果观察
H2 + packet-up约 0.525–0.674 MB/s延迟和丢包较高时,TCP 容易出现排队等待
H3 + packet-up约 5.07–5.79 MB/s三轮 20 MB 均成功,速度约提升 10 倍

这个差距比笔者预想得还大。H3 的三轮 20 MB 测试全部成功,速度大约提高了 10 倍。H3 并不能降低实际距离带来的延迟,但是它使用 QUIC,遇到丢包时,不会像同一条 TCP 连接那样让后面的数据全部排队等待。

笔者又继续测试了几次:单连接下载 20 MB 文件时,速度大约为 4.79–6.60 MB/s;同时开启四路下载时,合计大约为 9.5 MB/s,也就是约 76 Mbps。这个结果已经比较接近 VPS 直连的 92 Mbps,说明 XHTTP 本身没有卡住整条线路。剩下的差距,更可能来自较长的延迟、丢包、单连接限制,以及当时分配到的 Cloudflare 路径。如果 VPS 套餐本来就是 100 Mbps 端口,这个结果也已经离上限不远了。

当然,这只是笔者这条线路在当时的结果,不能保证换一个网络也能提高 10 倍。另外,当前脚本只允许使用 H3;如果某个网络不允许 UDP/443,它不会自动退回 H2/TCP。这是速度提高以后,需要同时接受的限制。

后续性能优化的边界

测速不理想时,很容易继续搜索“优化参数”,然后把能找到的开关全部打开。笔者也考虑过 BBRv3 和 x-ui 中的 QUIC Params,但是检查以后没有这么做。

VPS 本来已经使用 bbr + fq,CPU 和内存也很空闲。检查 cloudflared 日志时,还能看到东京 VPS 发出的 4 条 Tunnel 连接都落在 NRT,也就是 Cloudflare 的东京节点。不过,这里很容易产生一个误会:它只能说明 VPS 这一侧的 cloudflared 连接到了东京,不能证明客户端也从东京进入 Cloudflare。

POP 可以简单理解为 Cloudflare 分布在各地的边缘机房。把路径拆开以后会更容易理解:

1
2
3
4
5
客户端
  → 客户端所在网络选择的 Cloudflare 入口 POP
  → Cloudflare 内部网络
  → cloudflared 连接的 Tunnel POP
  → VPS

Cloudflare 在很多地区使用同一组 IP,再根据网络情况把连接送到不同的机房,这就是 Anycast。客户端会进入哪个机房,取决于所在地区、运营商、IPv4/IPv6 和当时的网络路由;VPS 连接哪个机房,则由 VPS 到 Cloudflare 的网络决定。两者可能相同,也可能完全不同。

例如,东京 VPS 的 cloudflared 当前连接到了 NRT,而另一台洛杉矶 VPS 的 Tunnel 连接实测会分布在 LAX 和 PHX。普通用户没有一个开关,可以让所有客户端都固定进入 NRT。中国不同运营商,甚至同一网络在不同时段,也可能进入 HKG、LAX、SJC 等其他机房。

如果要确认客户端进入了哪里,需要在对应客户端的网络下查看 CF-Ray 后缀,或者访问 /cdn-cgi/trace;如果要确认 VPS 一侧连接了哪里,则需要查看 cloudflared 日志。单独使用 ping、traceroute 或 IP 地址归属库,都不能证明实际连接的是哪个 Cloudflare 机房。

回到速度问题上,较慢的是客户端到 Cloudflare 的 QUIC 连接,继续调整 VPS 的 TCP 拥塞控制并不能直接解决它。Xray 入站的 QUIC 也不在当前连接路径中,所以仍然保持关闭。

后面如果继续优化,笔者认为下面这些更值得逐项测试:

  • 使用 Mihomo 的近期稳定版,并在升级后重新检查脚本日志和最终配置。
  • 对比测试 XMUX 的 max-concurrency。可以先从 16-32 降到 4-8,因为并发量并不是越高越快。
  • 只有上传速度较慢时,才测试 sc-min-posts-interval-ms。它控制 packet-up 的上传节奏,对下载速度没有直接帮助。
  • 分别测试 IPv4 和 IPv6,记录实际连接的 Cloudflare 机房、延迟和丢包。
  • 换一个网络或运营商进行测试,有时比继续调整 VPS 内核更直接。
  • 下载大文件时使用多个连接,可以更接近线路的总上限。
  • 如果套餐只有 100 Mbps 端口,想要继续提高上限,只能升级套餐或更换 VPS。

这里最重要的是一次只修改一项。QUIC Params、BBRv3 和一些激进的发包参数,看上去都像是“性能开关”,但是如果同时修改,即使速度偶然提高,也无法确认是哪一项起了作用,更不知道是否会带来新的不稳定。

安全边界与可能的 GFW 故障点

这套方案解决了笔者最直接的问题:客户端不再连接 VPS 的公网 IP,真实 SNI 也不会直接出现在 TLS 连接的开头。但是,它并不是“开启以后就不会再出问题”。

Cloudflare 仍然能看到 XHTTP 外层的域名、Path 和 HTTP 请求形式,只是里面的 VLESS 数据又受到 VLESS Encryption 保护。换句话说,它减少了暴露给沿途网络的信息,却不能让 Cloudflare 完全看不到请求。

为了不把不同问题混在一起,笔者把可能遇到的情况整理成了下面这张表。有些问题可以通过修改自己的配置解决,有些则发生在客户端到 Cloudflare 的网络上,继续修改 VPS 并没有用。

风险谁能处理实际应对
订阅、Path、UUID 泄露使用者可控制独立凭据、可信渠道、泄露后轮换,不公开截图和日志
UDP/443/QUIC 干扰运营商或网络策略主导换网络或改用其他传输方式;本地调整 Xray 参数无法解决系统性阻断
DoH/TYPE65 干扰解析器与网络共同决定换可达解析器或网络;系统性封锁不能只靠 VPS 解决
Cloudflare 分配的线路较差Cloudflare/运营商主导比较 IPv4/IPv6、不同网络和实际连接的机房,保留实测记录
ECH 或公共名称 cloudflare-ech.com 受到干扰Cloudflare、客户端实现和网络策略共同决定等待实现或公共名称变化,或改用其他传输方式;调整 Path、BBR 无效
VPS/Xray/cloudflared 故障使用者可控制监控服务、备份配置、检查监听与日志

这里需要区分 UDP/443、ECH 和 DoH。没有 ECH 时,QUIC 连接开头的 SNI 仍然可以被读取,公开研究也已经观察到 GFW 会利用这项信息。ECH 可以把 SNI 加密,但是它又需要通过加密 DNS 取得配置;如果 DoH 或 TYPE65 查询本身受到干扰,ECH 也可能无法正常工作。

所以,笔者更愿意把这套方案理解为:它提高了针对单一 VPS IP 和真实 SNI 进行处理的难度,但是外部仍然能看到 Cloudflare IP、ECH 的公共名称、QUIC 连接和流量大小。它比原来的单 IP 直连多了几层保护,却不代表无法识别,更不代表以后永远不会失效。

故障排查

这套连接经过的环节比较多,出错时很容易在 VPS 和客户端之间来回修改。笔者现在会从客户端开始,按照“客户端配置 -> DNS/ECH -> Cloudflare -> Tunnel -> 本机端口 -> Xray”的顺序检查,并且每次只修改一个地方。

症状优先检查
脚本显示 skipped节点的 typenetworkservertls 没有同时匹配
配置解析报错Mihomo 版本过旧,或当前内核不支持相应字段
ECH/TYPE65 错误DoH 是否可用、HTTPS 记录中是否带有 ECH 配置
H3 握手超时但 Tunnel 正常客户端到 Cloudflare 的 UDP/443 和运营商路径
Cloudflare 404域名、Path、规则顺序及 Host/SNI 是否一致;直接用浏览器访问时,404 也可能是正常的
Cloudflare 502127.0.0.1:44433 是否监听,service 的 HTTP/HTTPS 与端口是否写对
单连接慢、同时下载快延迟、丢包、Cloudflare 路径和 VPS 套餐上限
出现 stream canceled by remote with error code 0,但请求成功可能只是 packet-up 请求正常结束时留下的日志,需要结合实际失败率、客户端错误和 Tunnel 是否重连判断

其中,404 和 502 最容易混淆。404 通常表示请求已经到达 Tunnel,只是域名、Path 或请求形式没有匹配;502 则表示 Cloudflare 已经找到了 Tunnel,却无法连接后面的服务。例如,Xray 没有监听 127.0.0.1:44433,或者本来应该使用 HTTP,却误写成了 HTTPS。遇到 502 时,先检查本机端口和 Tunnel 后面的协议,比反复更换 UUID 更有效。

使用 Sub-Store 处理订阅

前面的 Global Extend Script 虽然解决了 H3 和 ECH 的问题,但是它只存在于 Clash Verge Rev 中。换到手机上的 Clash Meta 后,3x-ui 原始订阅仍然只有面板生成的配置,脚本补上的 H3、ECH 和连接复用设置也就消失了。

当然,也可以把处理好的 YAML 文件直接导入手机,但是这样又失去了订阅的意义:3x-ui 中新增的节点、流量、到期时间和规则无法自动更新。笔者最后加入了 Sub-Store,把原来运行在客户端中的脚本搬到了服务器端。

订阅如何更新

Sub-Store 并没有取代 3x-ui。3x-ui 仍然负责客户端、入站、流量、到期时间和原始订阅;Sub-Store 只是在原始订阅生成以后,再补上面板无法输出的字段。

笔者把这套更新过程拆成了两部分:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
3x-ui 原始订阅(仅监听 127.0.0.1)
  → 每 60 秒同步一次
  → Sub-Store 读取并处理
  → 为每个有效 subId 保存一份最终配置

客户端
  → 原来的订阅域名、路径和 subId
  → Cloudflare Tunnel
  → 只读订阅网关
  → Sub-Store 中已经处理好的配置

对客户端来说,订阅链接没有变化,也不需要重新导入。改变的只是 Cloudflare Tunnel 后面的目标:以前直接连接 3x-ui 的原始订阅端口,现在先经过只读网关,再从 Sub-Store 取得最终配置。

这里的“每 60 秒同步”也不是说配置会主动推送到客户端。它只表示 3x-ui 的改动通常等待约一分钟就会进入 Sub-Store;客户端仍然要按照自己的更新间隔,或者手动刷新订阅,才能取得新配置。

前文写在 Clash Verge Global Extend Script 中的主要内容,现在由 Sub-Store 的 Script Operator 完成。以 XHTTP 节点为例,最终配置会直接包含:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
alpn:
  - h3
ech-opts:
  enable: true
  query-server-name: <XHTTP_HOST>
xhttp-opts:
  mode: packet-up
  reuse-settings:
    max-concurrency: "16-32"
    h-max-reusable-secs: "1800-3000"
    h-keep-alive-period: 0

DoH 和目标域名的 DNS 规则也在同一次转换中加入。这样,无论使用桌面端还是手机端,只要客户端采用较新的 Mihomo 内核,从订阅中拿到的就是处理完成的配置,不再依赖 Clash Verge 的全局脚本。

转换脚本一定要准确匹配节点的协议、域名和 TLS 设置,不能给所有节点统一添加 H3。例如,标准 WebSocket 使用 HTTP/1.1,如果把它也强制改成 H3,节点反而会无法连接。

Sub-Store 的管理接口不应该直接暴露到公网。笔者实际使用的 3x-ui 原始订阅、Sub-Store 后端和只读网关都只监听 127.0.0.1;Cloudflare Tunnel 只能到达只读网关,不能访问 Sub-Store 的管理 API。

只读网关还会检查 subId 是否属于当前启用的客户端,拒绝其他路径和请求方法,不缓存订阅,也不会把完整订阅路径写进日志。这样即使增加了 Sub-Store,也不会顺手把新的管理入口暴露出去。

这套方案的代价是服务器上多了几个需要维护的服务。如果 Sub-Store 暂时停止,客户端刷新订阅时会收到 502;不过,已经加载到客户端中的旧配置仍然可以继续使用。排查时可以按照“3x-ui 原始订阅 -> 同步任务 -> Sub-Store -> 只读网关 -> Cloudflare Tunnel”的顺序逐项检查。

各部分分别负责什么

部分负责的内容
3x-ui客户端、UUID、subId、入站、流量、总额、到期时间、原始订阅和 Rules Provider
Sub-Store读取原始订阅、运行转换脚本、保存并输出最终的 Mihomo 配置
只读订阅网关检查路径和 subId,只允许仍然启用的客户端读取自己的配置
Cloudflare Tunnel继续使用原来的域名和路径,把公开订阅请求送到只读网关

这样一来,笔者仍然可以在 3x-ui 中管理客户端和查看用量;Sub-Store 输出时,也会保留订阅标题、更新间隔和流量信息。它只是多了一道“加工”步骤,并没有变成新的用户管理面板。

Licensed under CC BY-NC-SA 4.0
Wednesday, July 15, 2026
comments powered by Disqus