DocsByRcDocsByRc
Sing-box 节点搭建
VPS · sing-box 1.14+

Sing-box 节点搭建全流程

从一台空白的 Debian / Ubuntu 服务器开始,走完系统加固、防火墙、TLS 证书、 服务端配置、节点分发和排错的全过程。七种协议的服务端与客户端配置成对给出, 复制粘贴即可运行,只需要替换高亮标出的几处自己的信息。

适用Debian 11+ / Ubuntu 20.04+
难度中等
阅读—
更新2026-09-04

先把边界划清楚:这份教程做什么、需要你准备什么、以及哪些内容是必须自己动手改的。

你会得到什么

一台同时跑七种协议节点的服务器,一条命令生成全部分享链接与二维码, 以及一套按用户或按端口分流到不同住宅落地的链式中转。

需要多久

不含域名解析生效等待,全程约 30 分钟。只用 Reality 一种协议的话 10 分钟以内。

开工清单

七种协议怎么选

协议传输层要域名本教程端口适用场景
VLESS + RealityTCP / TLS不需要443无需证书,借用真实网站的 TLS 指纹,最难识别,新手首选
VLESS + WS + TLSWebSocket需要8443唯一能套 Cloudflare CDN 的方案,可隐藏 VPS 真实 IP
TrojanTCP / TLS需要9443伪装成普通 HTTPS,实现成熟,客户端支持面最广
Shadowsocks 2022TCP / UDP不需要80全加密无明文特征,协议开销最小,适合做备用线路
Hysteria2QUIC / UDP可选5678高丢包链路上速度优势明显,移动网络体验最好
TUIC v5QUIC / UDP可选7788基于 QUIC,握手快、延迟低,适合游戏和实时应用
AnyTLSTLS需要10443缓解 TLS-in-TLS 特征,需要 sing-box 1.12+ 双端
不必全开 七种协议可以只挑一两种。建议起步只开 Reality(无需域名、配置最少), 跑通之后再按需追加。第 8 节默认只开 443 上的 Reality,另外六个协议在第 10 节,一个协议一栏,要用再往骨架上挂。

关于域名与证书

需要证书的协议共用一张通配符证书,第 7 节用 Cloudflare DNS 验证一次签发, 因此域名必须托管在 Cloudflare(NS 指向 CF)。一张 *.yourdomain.com 覆盖下面全部子域名,后续加节点不用重新签发。

子域名Cloudflare 云朵解析到给谁用
node.yourdomain.com灰色(DNS only)VPS 真实 IPTrojan / Hysteria2 / TUIC / AnyTLS
cf.yourdomain.com橙色(Proxied)VPS 真实 IPVLESS + WS,走 CDN 隐藏真实 IP
两个域名不能混用 灰云域名走直连,橙云域名走 CDN。把 Trojan 的 server_name 写成橙云域名, 握手会被 Cloudflare 拦下;把 WS 节点写成灰云域名,则等于没套 CDN。全文已按上表区分, 照抄时留意别改错。

需要你替换的内容

全文只用一种标记:黄色高亮就是必须换成你自己信息的地方, 包括 YOUR_SERVER_IP、yourdomain.com、 各协议的 UUID 与密码。没有高亮的部分可以原样使用。

不用先粘到别处改:代码框可以直接编辑 每个代码框右上角有编辑 / 还原 / 复制三个按钮。点编辑, 框里就变成可以打字的状态,把高亮占位符换成你自己的 IP、域名、UUID,再点复制 拿到的就是改好的内容,直接粘进 VPS 就能跑。改完按 Esc (或 Ctrl/⌘ + Enter)退出编辑态。
改动只存在当前这个浏览器标签页里:点还原回到教程原文, 关掉页面再打开也一律是原始内容——不会有半份改过的教程留在那儿误导你。 所以真正要长期保存的配置,请存到 VPS 上或你自己的笔记里,别指望这个页面。 终端输出示例(浅色的那种框)不给编辑,那些是给你对照结果的,不是拿来执行的。

本教程约定:能一条命令做完的写成代码块,需要判断的写成提示框, 可能出错的写成第 13 节的折叠排错项。

先用 SSH 登进服务器,后面所有命令都在这个终端里执行。

打开本机终端并登录

把高亮部分换成你的公网 IP。首次连接会提示确认主机指纹,输入 yes 回车。

bash
# 默认 22 端口
ssh root@YOUR_SERVER_IP

# 服务商给了非标准端口时,用 -p 指定
ssh -p 22222 root@YOUR_SERVER_IP
确认已经进入服务器

提示符变成 root@主机名:~# 就说明登录成功。顺手确认一下系统版本和架构。

bash
cat /etc/os-release | head -n 2
uname -m
终端输出
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
x86_64
Windows 用户 Win10 1809 及以上自带 ssh 命令,直接在 PowerShell 里执行即可, 不需要额外装 PuTTY。若提示找不到命令,在「设置 → 应用 → 可选功能」里安装 OpenSSH 客户端。
到这里应该 终端提示符已变成服务器的主机名,uname -m 输出 x86_64 或 aarch64。

更新系统、装齐工具、校准时区、打开 BBR。这四件事做完,后面的步骤才不会莫名报错。

更新软件源并安装基础工具

后面十几节要用到的命令,绝大多数都不在系统的默认安装里—— ufw(第 3 节的防火墙)、dig(第 6 节验证解析)、 nc(第 13 节测端口)这些没装上,到那一步会直接报 command not found。这里一次装齐,后面就不用回头补。

bash
# 先更新索引,再升级已经装上的包
apt update && apt upgrade -y

# 一次装齐全教程要用到的工具(一行一组,按用途分组)
apt install -y \
  ca-certificates curl wget gnupg tar unzip nano \
  ufw \
  socat cron logrotate \
  jq qrencode openssl python3 python3-cryptography \
  iproute2 bind9-dnsutils netcat-openbsd iputils-ping lsof tcpdump htop

装完可以对着这张表核一遍,每个都能说清是哪一步在用:

包名提供的命令教程里哪一步用
ufwufw第 3 节配置防火墙、第 4 节改完 SSH 端口后放行新端口
bind9-dnsutilsdig、nslookup第 6 节确认域名解析到本机、第 8 节 Reality 借站自查
netcat-openbsdnc第 13 节从本地测 TCP 端口通不通
iproute2ss、ip第 12 节看监听端口、第 13 节查端口被谁占了
croncrontab第 7 节 acme.sh 装完会自己写一条续期定时任务,没有 cron 服务证书就不会自动续
socatsocat本教程用 DNS-01 签证书,不需要它;只有改用 acme.sh 的 --standalone 模式才要,装上算留个退路
jq、qrencode、python3、python3-cryptographyjq、qrencode、python3第 11 节的 vps-share 分享脚本:读配置、打二维码、从私钥反推 Reality 公钥
opensslopenssl第 5 节生成各协议的随机密码
curl、wget、ca-certificates、gnupg—第 5 节装 sing-box(含 APT 源的签名校验)、第 7 节装 acme.sh
nano、tar、unzip—第 8 节编辑 config.json;解压备份、下载的规则集
logrotatelogrotate第 13 节给 sing-box 日志做切割,不装的话那份 /etc/logrotate.d/sing-box 写了也没人读
iputils-pingping第 13 节判断是被墙还是服务没起来
lsof、tcpdump、htop同名不是必须,但排错到第 13 节后半段基本都会用上,先装免得临时找
这三个包名别写错 dnsutils 在 Debian 13 已经没有了,要写 bind9-dnsutils; netcat 从 Debian 12 起就不是有效包名,要写 netcat-openbsd; apt-transport-https 现在是个空的过渡包,不要装, APT 早就内置 https 支持了。
表里没写 iptables、procps,不是漏了 iptables 是 ufw 的依赖,装 ufw 时自动带上; sysctl 来自 procps,那是 Priority: required 的包, 任何一个能开机的系统上都有。 logrotate 和 iputils-ping 是 Priority: important, 正常的 Debian / Ubuntu 云镜像里本来就装好了, 上面仍然把它们写进去,是因为少数服务商提供的 minimal 镜像会把这一档裁掉—— 写上去顶多是「已是最新版」一行输出,不写就可能到第 13 节才发现少东西。
ufw 和 iptables-persistent 不能共存 在 Debian 12 / 13 与 Ubuntu 24.04 上,ufw 的包元数据里写着 Breaks: iptables-persistent, netfilter-persistent, 所以 apt 不会让两边同时装着——装 ufw 时如果机器上已经有 netfilter-persistent,apt 会提示把它移除, 请先看清 The following packages will be REMOVED: 那一行再回车。 这也意味着:本教程不需要、也不要手动去装 iptables-persistent。
Oracle Cloud 的 Ubuntu 实例请把 ufw 从上面那条命令里删掉 OCI 平台镜像靠一批预置 iptables 规则挂载自己的系统盘, Oracle 官方的 Known Issues 明确写了启用 ufw 可能清掉这些规则, 导致实例下次重启开不了机,并要求 Do not use UFW to edit firewall rules。 那台机器的防火墙走第 3 节末尾折叠里的 /etc/iptables/rules.v4 做法。
apt upgrade 过程中若弹出蓝底的「需要重启哪些服务」 或「保留还是替换配置文件」对话框,前者选默认(回车),后者选 keep the local version。升级内核后提示需要重启就执行 reboot,重新登录继续,不影响后面的步骤。
时区保持世界时间 UTC

Reality 与 TLS 握手对时间偏差敏感,服务器时间不准会直接导致连接失败。 时区本身统一用 UTC:日志时间戳、证书有效期、服务商后台的记录 都按 UTC 记,不换算就能对上;本教程后面所有示例输出里的时间也都是 UTC。

bash
# 设为世界时间 UTC(多数 VPS 镜像出厂就是,跑一遍图个确定)
timedatectl set-timezone UTC

# 打开网络时间同步
timedatectl set-ntp true

# 确认 System clock synchronized: yes
timedatectl status
终端输出
Local time:                Sat 2026-09-05 12:30:15 UTC
Universal time:            Sat 2026-09-05 12:30:15 UTC
RTC time:                  Sat 2026-09-05 12:30:15
Time zone:                 UTC (UTC, +0000)
System clock synchronized: yes
NTP service:               active
RTC in local TZ:           no
两种常见的不一样,都不是错 timedatectl 在真实终端里是把左边的标签右对齐成一列的, 上面为了和本文其它输出块统一,改成了标签左顶格、值对齐——只是排版差异,内容一致。 Time zone 显示 Etc/UTC 而不是 UTC—— 同一个时区的两个名字,偏移都是 +0000,不用管。 set-ntp true 报 NTP not supported—— 这台机器没装时间同步服务,执行 apt install -y systemd-timesyncd 后再跑一遍; 但如果它上面已经在跑 chrony 或 ntpsec, 就别装(会把原有的顶掉),改用 chronyc tracking 确认同步正常即可。
开启 BBR 拥塞控制

BBR 能显著改善跨境链路的吞吐。写成 /etc/sysctl.d/ 下的独立文件,重复执行不会产生重复配置。

bash /etc/sysctl.d/99-bbr.conf
cat > /etc/sysctl.d/99-bbr.conf <<'EOF'
net.core.default_qdisc = fq
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fastopen = 3
EOF

# 立即生效
sysctl --system >/dev/null

# 验证:两条都应输出 bbr / fq
sysctl net.ipv4.tcp_congestion_control
sysctl net.core.default_qdisc
终端输出
net.ipv4.tcp_congestion_control = bbr
net.core.default_qdisc = fq
为什么不写 /etc/sysctl.conf 用 cat >> 追加到主配置文件,每执行一次就多一份重复条目, 时间久了很难看出到底哪一行在生效。/etc/sysctl.d/ 下的独立文件可以直接覆盖重写, 也方便删掉回退。
到这里应该 tcp_congestion_control 输出 bbr, timedatectl 显示时间已同步。

先放行端口再启用防火墙,顺序反了会把自己锁在门外。

顺序不能反 必须先 ufw allow 放行 SSH 端口,再 ufw enable。 反过来执行会立刻断开当前 SSH 连接,且无法再登录,只能去服务商控制台用 VNC 救援。
用 Oracle Cloud 的先看本节末尾 OCI 的 Ubuntu 镜像上启用 ufw 是 Oracle 官方列出的已知问题, 会导致实例下次重启开不了机。那台机器请跳过本节的 ufw 步骤, 直接看最后那个折叠里的 /etc/iptables/rules.v4 做法。
放行端口并启用防火墙

下面只放行两个口:SSH 的 22 和第 8 节默认要用的 443。 后面每启用一个协议,再照着同样的格式补一条—— UDP 类协议(Hysteria2 / TUIC)别漏了 /udp,协议写错等于没放行。

bash
# SSH:最先放行,务必确认端口号正确
ufw allow 22/tcp

# TCP 口写法示例:443 是第 8 节 VLESS + Reality 的端口
ufw allow 443/tcp

# UDP 口写法示例:5678 是 Hysteria2 的端口,用到才加
ufw allow 5678/udp

# 启用防火墙。在 SSH 里执行会先问一句,输入 y 回车
#   Command may disrupt existing ssh connections. Proceed with operation (y|n)?
# 不想被问就写 ufw --force enable(--force 必须放在 enable 前面)
ufw enable

# 核对规则列表
ufw status verbose
终端输出
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
5678/udp                   ALLOW IN    Anywhere
22/tcp (v6)                ALLOW IN    Anywhere (v6)
443/tcp (v6)               ALLOW IN    Anywhere (v6)
5678/udp (v6)              ALLOW IN    Anywhere (v6)
每条规则出现两行不是重复 开了 IPv6 的机器上,一条 ufw allow 443/tcp 会同时生成 v4 和 v6 两条规则, 带 (v6) 后缀的那条就是 IPv6 版本, 命令本身也会回两行 Rules updated / Rules updated (v6)。 另外 Default: 里的 disabled (routed) 表示这台机器没开转发, 全新 VPS 的正常状态;New profiles: skip 是 ufw 的出厂默认值, 不用改。
开口越少越好,用到再加 没有程序监听的端口,外面探测的结果是「连接被拒绝」,不构成额外风险; 但一个 IP 上同时开一串奇怪的高位端口,这个端口组合本身就是指纹。 所以本节只放行 22 和 443, 第 10 节挑了别的协议再回来补对应的一条 ufw allow。 删一条规则用 ufw delete allow 5678/udp, 写法要和当初放行时一字不差才能删掉。
放行一段连续端口

需要一次开一整段时(典型场景是 Hysteria2 的端口跳跃, 客户端在一段 UDP 口里轮换,用来对抗按端口的限速), 用冒号连接起止端口。端口段必须带协议, 写成 ufw allow 40000:50000 会被 ufw 拒绝。

bash
# 放行 40000-50000 这一段 UDP
ufw allow 40000:50000/udp

# TCP 段同理
ufw allow 40000:50000/tcp

# 删掉时写法要和放行时完全一致
ufw delete allow 40000:50000/udp
端口段是安全边界上最贵的一笔 一条 40000:50000/udp 等于放行一万个口, 以后这台机器上任何监听在这个范围内的程序都会直接暴露到公网。 不做端口跳跃就不要开;真要开,把范围压到实际用得到的宽度, 并且记住第 8 节的配置里也要同步写上对应的跳跃范围,两边不一致就是不通。
检查端口有没有被别的程序占着

放行之后启动 sing-box 前先看一眼:有输出说明端口已被别的程序占用, 需要先停掉对应服务,或者把节点换到别的端口。 80 和 443 最容易冲突——很多 VPS 镜像预装了 nginx 或 apache。 把括号里的端口换成你实际要用的那几个:

bash
# TCP
ss -tlnp | grep -E ':(80|443)\b'

# UDP
ss -ulnp | grep -E ':(5678)\b'

还有一层:服务商安全组

两层防火墙都要放行 阿里云、腾讯云、AWS、Oracle Cloud、Google Cloud 等在系统之外还有一层安全组。 ufw 放行了但安全组没放行,端口依然不通,而且客户端只会显示超时, 很难看出原因。DigitalOcean、Vultr、Hetzner 默认不开安全组,可以跳过这一步。
服务商控制台位置注意
阿里云 / 腾讯云实例详情 → 安全组 → 配置规则 → 入方向TCP 与 UDP 要分别加规则
AWS EC2实例 → Security groups → Inbound rules默认只放行 22,其余全部要手动加
Oracle CloudVCN → 子网 → 安全列表 → 入站规则Ubuntu 镜像不能用 ufw,见本节末的折叠
Google CloudVPC 网络 → 防火墙 → 创建规则需要给实例打对应的网络标记
Oracle Cloud 的 Ubuntu 镜像:不要用 ufw,用 iptables 会开不了机
这一条是 Oracle 官方明文列出的已知问题 OCI 文档的 Known Issues 里有一条标题就叫 Ubuntu instance fails to reboot after enabling Uncomplicated Firewall (UFW): 平台镜像预置了一批 iptables 规则,用来让实例连上自己的 引导卷和块存储卷(iSCSI,走 169.254.0.2:3260 与 169.254.2.0/24:3260)。 ufw 可能把这些规则清掉,于是下次重启时实例挂不上系统盘,直接开不了机。 官方给的处置是一句话:Do not use UFW to edit firewall rules。

所以在 Oracle Cloud 的 Ubuntu 实例上,第 2 节不要装 ufw、第 3 节整节跳过, 改成直接编辑官方指定的规则文件 /etc/iptables/rules.v4。 先看一眼现状:

bash
iptables -L INPUT -n --line-numbers
nano /etc/iptables/rules.v4

出厂文件的 INPUT 段大致是下面这样。要放行的端口 必须插在最后那条 REJECT 之前—— 插在它后面等于没写,而且客户端看到的报错是 no route to host,很难联想到是顺序问题:

iptables-restore /etc/iptables/rules.v4
# 下面几行是镜像自带的,一行都不要删
-A INPUT -m state --state RELATED,ESTABLISHED -j ACCEPT
-A INPUT -p icmp -j ACCEPT
-A INPUT -i lo -j ACCEPT
-A INPUT -p udp --sport 123 -j ACCEPT
-A INPUT -p tcp -m state --state NEW -m tcp --dport 22 -j ACCEPT

# ↓↓↓ 新增的放行规则加在这里,也就是 REJECT 之前 ↓↓↓
-A INPUT -p tcp -m state --state NEW -m tcp --dport 443 -j ACCEPT
-A INPUT -p udp -m state --state NEW -m udp --dport 5678 -j ACCEPT

-A INPUT -j REJECT --reject-with icmp-host-prohibited

存盘后让它立即生效(官方给的就是这一条命令,不需要 netfilter-persistent save;文件本身就是开机加载的那份):

bash
iptables-restore < /etc/iptables/rules.v4
iptables -L INPUT -n --line-numbers
三件容易做错的事 ① 别执行 iptables -F INPUT——那会连 iSCSI 那几条一起冲掉, 后果和启用 ufw 一样。 ② IPv6 是另一份文件 /etc/iptables/rules.v6,要放行 IPv6 就同样改它。 ③ 系统防火墙放行完,VCN 安全列表的入站规则还要单独加一遍, 两层都通了端口才通。

默认 22 端口每天会被扫上千次。改成高位端口并关闭密码连接、改用密钥登录,能挡掉绝大多数自动化爆破,这一步可以跳过[cite: 1]。

准备 SSH 密钥

如果你本机已经有 SSH 密钥(通常在本地电脑的 ~/.ssh/id_rsa.pub 或 ~/.ssh/id_ed25519.pub),可以直接复制其内容备用,跳过下面的生成步骤。如果没有密钥,请先在本机生成:

bash本机终端里执行
# 生成密钥(如提示路径和密码,一路回车默认即可)
ssh-keygen -t ed25519
配置密钥登录

将本机的公钥添加到服务器,有以下两种方式。完成之后请测试 ssh root@YOUR_SERVER_IP 是否能免密直接进入服务器。

如果你使用类似 FinalShell、Xshell 等带有可视化文件管理的 SSH 客户端,可以按以下步骤操作:

  1. 复制本机公钥:在本机终端执行 cat ~/.ssh/id_ed25519.pub,复制输出的完整公钥内容(形如 ssh-ed25519 AAAA...)。
  2. 创建认证文件夹:在服务器上进入 /root/ 目录。新建名为 .ssh 的文件夹(若已有则跳过),并将文件夹权限设置为 700。
  3. 写入公钥并设置权限:在 .ssh 文件夹下,新建或打开名为 authorized_keys 的文件。将刚才复制的公钥内容粘贴进去,保存并退出。最后将该文件的权限设置为 600。
bash本机终端里执行
# 将公钥传到服务器(期间需要输入当前服务器 root 密码)
ssh-copy-id root@YOUR_SERVER_IP
先放行新端口

顺序同样重要:先让新端口通,再改配置[cite: 1]。这样即使改错了,旧连接还在,可以退回去[cite: 1]。

bash
ufw allow YOUR_SSH_PORT/tcp
ufw status | grep YOUR_SSH_PORT
云厂商机器还要去控制台放行一次 阿里云 / 腾讯云 / AWS / OCI / GCP 的安全组是机器之外的一层, ufw 放行不等于它放行[cite: 1]。AWS 的默认安全组只开 22, 新端口不去入方向加规则,下一步验证时一定连不上, 而且现象跟「sshd 没改对」一模一样,很容易查错方向[cite: 1]。 先去控制台加 YOUR_SSH_PORT/tcp 入方向规则,再往下走[cite: 1]。
按第 3 节末尾走 iptables 路线的机器(如 Oracle Cloud) 这类机器上没有 ufw,上面那条会是 command not found[cite: 1]。 改成在 /etc/iptables/rules.v4 里、 紧挨着已有那条 22 端口 ACCEPT 的下面补一行 -A INPUT -p tcp --dport YOUR_SSH_PORT -j ACCEPT, 然后 iptables-restore < /etc/iptables/rules.v4[cite: 1]。 看到 command not found 千万别当成无关紧要跳过—— 新端口没放行就重启 sshd,等于把自己关在门外[cite: 1]。
修改 sshd 配置(更改端口与禁用密码)

此处需要完成两件独立的事:① 修改 SSH 端口;② 关闭密码连接及 root 密码连接。

通过 SSH 工具的可视化文件管理功能,进入 /etc/ssh/ 文件夹,打开 sshd_config 文件进行编辑:

  1. 修改端口:在文件中找到带有 Port 22 的行(前面可能被 # 号注释掉了),将其改为 Port YOUR_SSH_PORT,并确保删除了开头的 # 号使其生效。
  2. 关闭密码连接:找到 PasswordAuthentication yes 的行,将其改为 PasswordAuthentication no,并去掉开头的 # 号。
  3. 关闭 root 密码连接:找到 PermitRootLogin yes 的行,将其改为 PermitRootLogin prohibit-password,并去掉开头的 # 号。

确认无误后,保存文件并退出。

bash /etc/ssh/sshd_config
# 1. 备份原文件
cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak

# 2. 修改端口(-E 用扩展正则,^#? 兼容注释与非注释)
sed -i -E 's/^#?Port +22$/Port YOUR_SSH_PORT/' /etc/ssh/sshd_config

# 3. 关闭密码连接与 root 密码连接
sed -i -E 's/^#?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sed -i -E 's/^#?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config

# 4. 确认改成功了,应输出新端口与关闭密码的配置
grep -E '^(Port|PasswordAuthentication|PermitRootLogin)' /etc/ssh/sshd_config

配置修改完成后,进行语法校验并重启服务:

bash
# 1. 语法校验,无输出表示通过(这一步不能跳)[cite: 1]
sshd -t

# 2. 校验通过后再重启[cite: 1]
systemctl restart ssh || systemctl restart sshd

# 3. 必须确认新端口真的监听上了(这一步比第 4 步更重要)[cite: 1]
ss -ltnp | grep -E ':(22|YOUR_SSH_PORT)\b'
第 3 步只看到 :22、没有新端口 → 是 socket 激活在管端口 Debian 13 与 Ubuntu 24.04 的 openssh 默认由 ssh.socket 决定监听端口, 端口写在它的 ListenStream= 里, 此时 sshd_config 的 Port 被完全忽略[cite: 1]。 最坑的是前面每一步都「成功」:grep ^Port 有输出、 sshd -t 无报错、restart 也不报错,只有第 3 步能看出来[cite: 1]。 先确认是不是这种情况:systemctl is-enabled ssh.socket 返回 enabled 就是[cite: 1]。
bash socket 激活的机器才需要执行
# 用 drop-in 改端口。第一行空的 ListenStream= 是清空官方单元里的 22,
# 不写这一行就变成 22 和 YOUR_SSH_PORT 同时监听[cite: 1]
mkdir -p /etc/systemd/system/ssh.socket.d
cat > /etc/systemd/system/ssh.socket.d/port.conf <<'EOF'
[Socket]
ListenStream=
ListenStream=YOUR_SSH_PORT
EOF

systemctl daemon-reload
systemctl restart ssh.socket

# 再查一遍,这次应该只剩新端口[cite: 1]
ss -ltnp | grep -E ':(22|YOUR_SSH_PORT)\b'
不想用 socket 激活也可以整个关掉 systemctl disable --now ssh.socket && systemctl enable --now ssh.service, 之后端口就回到 sshd_config 说话[cite: 1]。 两条路选一条即可,官方单元里 ssh.socket 和 ssh.service 是 Conflicts= 关系, 不会同时生效[cite: 1]。
用新端口及密钥开一个新窗口验证

不要关掉当前窗口[cite: 1]。另开一个终端用新端口和密钥登录, 成功之后再回来关旧窗口,并撤掉 22 端口[cite: 1]。

bash
# 本机新终端里执行[cite: 1]
ssh -p YOUR_SSH_PORT root@YOUR_SERVER_IP

# 新端口能免密登录后,在服务器上撤掉 22[cite: 1]
ufw delete allow 22/tcp
唯一容易把自己锁死的一步 新端口和密钥没验证成功就关掉旧窗口,是这份教程里最常见的翻车方式[cite: 1]。 真的锁死了,只能去服务商控制台用 VNC / Serial Console 恢复 /etc/ssh/sshd_config.bak[cite: 1]。

加官方 APT 源装稳定版,顺手生成后面配置里要用的 UUID、Reality 密钥和密码。

添加官方 APT 源

官方给了两种装法,这里用软件源那种:装完之后 apt 就能管升级,第 12 节的日常升级命令才成立。 三条命令分别是放签名密钥、写源文件、刷新索引。

bash
# 1. 放官方签名公钥(deb822 的 Signed-By 直接吃 ASCII armored 的 .asc)
mkdir -p /etc/apt/keyrings
curl -fsSL https://sing-box.app/gpg.key -o /etc/apt/keyrings/sagernet.asc
chmod a+r /etc/apt/keyrings/sagernet.asc

# 2. 写源文件(deb822 格式,注意扩展名是 .sources 而不是 .list)
cat > /etc/apt/sources.list.d/sagernet.sources <<'EOF'
Types: deb
URIs: https://deb.sagernet.org/
Suites: *
Components: *
Enabled: yes
Signed-By: /etc/apt/keyrings/sagernet.asc
EOF

# 3. 刷新索引并安装稳定版
apt update
apt install -y sing-box
装完之后它既没启动、也没设开机自启,这是对的 官方 deb 的安装脚本只做一件事——用 systemd-sysusers 建出 sing-box 这个系统用户,不会 enable、 也不会 start 服务。 所以现在执行 systemctl status sing-box 应该看到 inactive (dead),这是正常的: 第 8 节写配置,第 12 节才正式 enable --now 启动。
但 deb 自带了一份「能跑」的默认配置,现在千万别手动启动它 包里附带的 /etc/sing-box/config.json 是官方的演示配置: 一个监听 :: 的 shadowsocks 入站,端口 8080, 密码是写死在官方公开仓库里的固定值。 这时候要是自己 systemctl start sing-box, 等于对外开了一个「密码全世界都知道」的代理。
两道保险:一是第 3 节的防火墙先做完了,8080 并不在放行名单里,外面连不进来; 二是第 8 节会把这个文件整份覆盖掉。在第 8 节写完自己的配置之前,不要启动服务。 这也正是本教程把防火墙放在装 sing-box 之前的原因。
/etc/sing-box/config.json 是 dpkg 的 conffile 官方打包时把它登记成了「配置文件」,dpkg 因此会记住它的校验和。 以后第 12 节升级 sing-box 时,只要官方那份默认配置在两个版本之间变过, 而你又改过本地文件,dpkg 就会停下来问你留哪一份(没变过就不会问,直接保留你的)。 真被问到时长这样,直接回车即可——默认就是保留你自己的配置:
dpkg 提示
Configuration file '/etc/sing-box/config.json'
 ==> Modified (by you or by a script) since installation.
 ==> Package distributor has shipped an updated version.
   What would you like to do about it ?  Your options are:
    Y or I  : install the package maintainer's version
    N or O  : keep your currently-installed version
      D     : show the differences between the versions
      Z     : start a shell to examine the situation
 The default action is to keep your current version.
*** config.json (Y/I/N/O/D/Z) [default=N] ?
这里千万不要选 Y Y / I 是「用打包者的版本」, 一按下去你整套服务端配置就被那份 8080 演示配置替换掉,节点当场全部失效。 要留自己的就选 N(或 O,或者直接回车用默认值)。 想先看看官方改了什么,可以先按 D 看 diff,看完再选 N。
想装预览版 / 不想加源 同一个源里 sing-box-beta 是预览版包,和 sing-box 互斥,把第 3 步的包名换掉即可。
官方的另一种装法是一次性脚本,它直接抓 GitHub Release 的 deb/rpm 装上—— 好处是不加源,代价是以后升级得再跑一遍脚本, 第 12 节的 apt 升级对它无效: curl -fsSL https://sing-box.app/install.sh | sh, 预览版加 sh -s -- --beta, 指定版本加 sh -s -- --version 1.14.0。
确认版本号

本教程全部在 1.14.0 上实测。 dns.servers 的 type/server 写法与 default_domain_resolver 需要 1.12 以上, 第 14 节客户端配置里的 http_clients 与第 8 节提到的 cache_file.store_dns 则是 1.14.0 才有,版本低于 1.14 那两处要跳过。

bash
sing-box version
终端输出
sing-box version 1.14.0

Environment: go1.26.7 linux/amd64
Tags: with_gvisor,with_quic,with_dhcp,with_wireguard,with_utls,with_acme,with_clash_api,…
Revision: 0b8995879f29a9b98ee027bc17b75e101445b238
CGO: disabled
Tags: 真实输出是一长串(上面省略了后半段), 只要里面有 with_quic(Hysteria2 / TUIC 要用)和 with_utls(Reality 要用)就够了,官方发布的包都带。 CGO: disabled 也是官方发布包的正常特征。
按需生成密钥与密码

每条命令只生成一样东西,需要哪个跑哪一条——只想要一个 UUID,就只跑第一条。 每个代码框右上角的文件名标注了它对应第 8、9 节里哪个黄色占位符,填配置时对着抄不会串。 生成出来的值立刻复制进本地文本文件保存。

bashUUID → YOUR_UUID_1 / _2 / _3
# 一个。VLESS 每个用户一个,TUIC 也要一个
sing-box generate uuid

# 一次要三个就用这行
for i in 1 2 3; do sing-box generate uuid; done
bashReality 密钥对 → YOUR_REALITY_PRIVATE_KEY / _PUBLIC_KEY
sing-box generate reality-keypair
终端输出
PrivateKey: YOUR_REALITY_PRIVATE_KEY
PublicKey: YOUR_REALITY_PUBLIC_KEY
两把钥匙不要装反 PrivateKey 填服务端的 reality.private_key, PublicKey 填客户端的 reality.public_key, 而且必须是同一次命令输出的这一对。 装反或配错的症状是客户端一直连不上,服务端日志里一行 REALITY: processed invalid connection。 私钥只在这里出现一次,终端刷屏后无法找回。
bashshort_id → YOUR_SHORT_ID
# 4 字节 = 8 位十六进制。允许 0-8 字节,服务端和客户端必须写同一个值
sing-box generate rand 4 --hex
bashWS 路径 → YOUR_WS_PATH
# 输出形如 /1a2b3c4d5e6f7a8b。带上开头的斜杠,两端一模一样
echo "/$(openssl rand -hex 8)"
bashShadowsocks 2022 密钥 → YOUR_SS_PSK_BASE64
# 2022-blake3-aes-256-gcm 要求正好 32 字节的 base64,长度不对启动就会失败
sing-box generate rand 32 --base64
bash各协议密码 → YOUR_TROJAN_PASSWORD 等
# Trojan / Hysteria2 / TUIC / AnyTLS 的密码,以及 Hysteria2 的 obfs 密码,
# 都是任意字符串,这一条命令通用。用到几个就跑几遍,别几个协议共用同一个
openssl rand -base64 18
密码里会出现 + / = 这是 base64 的正常字符,填进 JSON 配置没问题。 但拼进分享链接时必须做 URL 编码——第 11 节的 vps-share 已经替你处理了,只有手写链接才需要留意。 嫌麻烦就换成 openssl rand -hex 24,输出只有十六进制字符。
一次全要:整段生成 + 对齐打印 可选

七个协议全都要开、想一次把所有值凑齐时用这一段:先把每个随机值存进变量, 最后统一对齐打印。把输出完整复制到本地文本文件保存, 后面第 8 节和客户端配置都要用。

bash
# ── 1) 生成 ──────────────────────────────────
# 三个 UUID:第 8 节的 VLESS 一个用户/一个端口占一个,TUIC 复用 UUID_2
UUID_1=$(sing-box generate uuid)
UUID_2=$(sing-box generate uuid)
UUID_3=$(sing-box generate uuid)
RKP=$(sing-box generate reality-keypair)
RK_PRIV=$(echo "$RKP" | awk '/PrivateKey/{print $2}')
RK_PUB=$(echo "$RKP" | awk '/PublicKey/{print $2}')
SHORT_ID=$(sing-box generate rand 4 --hex)         # 0-8 字节的十六进制
WS_PATH="/$(openssl rand -hex 8)"                   # WS 路径,两端必须一模一样
SS_PSK=$(sing-box generate rand 32 --base64)      # 2022-blake3-aes-256-gcm 要 32 字节
TROJAN_PW=$(openssl rand -base64 18)
HY2_PW=$(openssl rand -base64 18)
HY2_OBFS=$(openssl rand -base64 18)             # 可选,默认不用;要用见第 10 节 Hysteria2 的服务端栏
TUIC_PW=$(openssl rand -base64 18)
ANYTLS_PW=$(openssl rand -base64 18)

# ── 2) 对齐打印 ──────────────────────────────
printf '%-22s %s\n' \
  "UUID_1"                  "$UUID_1" \
  "UUID_2"                  "$UUID_2" \
  "UUID_3"                  "$UUID_3" \
  "REALITY_PRIVATE_KEY"     "$RK_PRIV" \
  "REALITY_PUBLIC_KEY"      "$RK_PUB" \
  "SHORT_ID"                "$SHORT_ID" \
  "WS_PATH"                 "$WS_PATH" \
  "SS_PSK_BASE64"           "$SS_PSK" \
  "TROJAN_PASSWORD"         "$TROJAN_PW" \
  "HY2_PASSWORD"            "$HY2_PW" \
  "HY2_OBFS_PASSWORD"       "$HY2_OBFS" \
  "TUIC_PASSWORD"           "$TUIC_PW" \
  "ANYTLS_PASSWORD"         "$ANYTLS_PW"
终端输出(示例,你的每一行都会不同)
UUID_1                 YOUR_UUID_1
UUID_2                 YOUR_UUID_2
UUID_3                 YOUR_UUID_3
REALITY_PRIVATE_KEY    YOUR_REALITY_PRIVATE_KEY
REALITY_PUBLIC_KEY     YOUR_REALITY_PUBLIC_KEY
SHORT_ID               YOUR_SHORT_ID
WS_PATH                /YOUR_WS_PATH
SS_PSK_BASE64          YOUR_SS_PSK_BASE64
TROJAN_PASSWORD        YOUR_TROJAN_PASSWORD
HY2_PASSWORD           YOUR_HY2_PASSWORD
HY2_OBFS_PASSWORD      YOUR_HY2_OBFS_PASSWORD
TUIC_PASSWORD          YOUR_TUIC_PASSWORD
ANYTLS_PASSWORD        YOUR_ANYTLS_PASSWORD
这里的名字就是后面配置里的高亮名 上面左列的名字和第 8 节、第 10 节配置里那些黄色高亮占位符一一对应—— 填配置时对着这份清单抄,不会串。 真实输出的右列是随机串(UUID 是 3f7d2b18-… 这种形状, Reality 公私钥是 43 个字符的 base64url), 这里用占位符表示,是为了防止有人照抄示例值。
现在就保存 Reality 的私钥只在这里出现一次,终端刷屏后无法找回。 私钥填服务端配置,公钥填客户端配置,两者必须成对,不能混用别人的。 这些变量只存在于当前 SSH 会话,断开就没了。
到这里应该 sing-box version 输出 1.14.0(或更高), 并且本地已保存好一份含 UUID、Reality 公私钥、short_id 和各协议密码的清单。

只用 Reality 或 Shadowsocks 的话,前面三步可以跳过。 但本节末尾「IP 被封之后」那一段值得先看一眼—— 那是唯一在 IP 被封之后还能救回来的方案,而它要求你提前把域名和证书准备好。

把域名 NS 指向 Cloudflare

在 Cloudflare 添加站点后,它会给出两个 NS 地址,去域名注册商那里替换原有 NS。 生效时间通常几分钟到几小时。

第 7 节用 Cloudflare 的 DNS API 做证书验证, 所以域名必须托管在 Cloudflare。用别家 DNS 的话得改用其他验证方式, 本教程不涉及。
添加两条 A 记录

在 Cloudflare 的 DNS → 记录里添加,两条都指向同一个 VPS IP, 区别只在代理状态(云朵颜色)。

类型名称内容代理状态TTL
AYOUR_DNS_NODEYOUR_SERVER_IP仅 DNS(灰云)自动
AYOUR_DNS_CFYOUR_SERVER_IP已代理(橙云)自动
验证解析已生效

灰云域名应解析到你的真实 IP;橙云域名会解析到 Cloudflare 的 IP,这是正常的。

bash
# 灰云:应输出你的 VPS 真实 IP
dig +short node.yourdomain.com

# 橙云:应输出 104.x / 172.6x 等 Cloudflare 段的 IP
dig +short cf.yourdomain.com

橙云和灰云的区别

对比项灰云(仅 DNS)橙云(已代理)
真实 IP公开可见被 Cloudflare 隐藏
连接路径客户端直连 VPS客户端 → CF 边缘 → VPS
可用端口任意仅 CF 支持的 HTTPS 端口
支持的协议全部只有 WebSocket / gRPC 等 HTTP 系传输
延迟取决于直连质量可用「优选 IP」改善,但多一跳
橙云的端口限制 Cloudflare 代理只转发固定端口的 HTTPS 流量: 443、2053、2083、2087、 2096、8443。本教程 WS 节点用 8443 正好在列表内。 改成别的端口(比如 9443)套 CDN 会直接不通。
Reality 不要套 CDN Reality 借用的是真实网站的 TLS 握手特征,中间多一层 CDN 会破坏这个特征。 Reality、Trojan、Hysteria2、TUIC、AnyTLS 全部走灰云直连。
套了 CDN 的节点速度不理想 优选 IP

Cloudflare 分配给你的边缘节点不一定是本地最优。做法是把客户端配置里的 server 从域名换成一个延迟更低的 Cloudflare IP, 而 Host 头与 server_name 仍然填真实域名不变 —— 服务端完全不用改。

Host 头得显式写出来 sing-box 的 Host 是从 server 字段来的, 不会自动跟着 tls.server_name 走——这一点抓包实测过。 所以 server 一改成 IP 或别的域名,就必须同时在 transport.headers 里补上 Host, 否则 CF 找不到回源站点。完整写法见第 10 节 ② 的客户端栏。
只有橙云 CDN 节点能这么做。灰云直连节点把 server 改成别的 IP 会直接连不上。

优选 IP 可以用各类第三方测速站获取,也可以在本机用 ping / httping 自己测几段 Cloudflare IP 挑最快的。 下面「IP 被封之后」那一段里给的 saas.sin.fan 也是同一类手段, 区别只是它把「挑 IP」这件事做成了一个域名。

IP 被封之后:切 Cloudflare 反代

这是给「用了一段时间突然全断」准备的退路。先确认真的是 IP 被封, 再动配置——第 12.4 节有一张 ping / TCP 443 / SSH 端口的三列对照表, 只有落在「ping 通、443 不通、SSH 端口也不通」那一行才算。 如果只是某一个端口不通,换端口就行,用不着走这一节。

思路很简单:被封的是「你家 → 你的 VPS 这个 IP」这条路。 客户端改成去连 Cloudflare 的边缘节点,由 CF 回源到你的 VPS, 那条被封的路就不再出现在链路里。 回源走的是境外线路,不受影响;服务端一个字都不用改—— 前提是第 6 节的橙云记录和第 10 节的 WS 入站你本来就配好了。

要动的地方改成什么不改会怎样
Cloudflare DNScf 那条 A 记录确认是橙云,内容仍然填被封的那个真实 IP灰云状态下客户端还是直连你的 IP,等于没切
Cloudflare SSL/TLS加密模式选 Full (strict)选 Flexible 时 CF 用明文回源,8443 上的 TLS 入站握不上手,客户端表现是 525/连不上
服务端什么都不用改。只是此刻只有 WS 那个入站还能用—
防火墙ufw allow 8443/tcp(第 3 节只开了 22 和 443)CF 回源被挡在门外,网页侧会看到 521
客户端 server填 saas.sin.fan填被封的 IP 或灰云域名 = 还是走那条断掉的路
客户端 Host 头
和 tls.server_name
保持你自己的橙云域名不变CF 靠这个名字才知道该回源到哪台机器,改错直接 404/526
saas.sin.fan 是干什么的 它是一个把「优选 Cloudflare IP」做成域名的服务:客户端的 server 填上它,解析出来的就是一个挑过的 CF 边缘 IP, 不用自己一段段测。本教程没有实测过这个域名 (它自己带 Cloudflare 人机验证,抓不到页面), 所以把它当成一个可替换的来源看:不通就换成自己测出来的 CF IP, 或者干脆退回填你的橙云域名——那样也能用,只是边缘节点由 CF 就近分配、不一定最快。 客户端配置见第 10 节 ② VLESS + WS 的「sing-box 客户端」栏。
这一招救不了两种情况 一是连 CF 都回不了源:机房整段 IP 在境外侧也不可达、或者机器本身挂了, 表现是网页侧 521/522,这时只能换 IP。 二是你只有 Reality 一个入站:Reality、Trojan、Hysteria2、TUIC、AnyTLS 都不能走橙云,切过来之后一个都不通。 所以真正的准备动作是提前把 WS 入站配好、证书签好, 平时不用,出事那天只改客户端一行。
套 CDN 之后,CF 看得见你在访问哪些站 橙云意味着客户端那层 TLS 在 Cloudflare 边缘就被解开, 再重新加密回源。VLESS 自身不做加密,所以中间这一段 「谁、要连哪个目标地址」对 CF 是可见的——目标站自己的 HTTPS 内容它读不到, 但访问关系它有。这是套 CDN 的固有代价,不是配置写错。 在意这一点就别把它当常态方案,只在被封时临时用。

用 acme.sh 走 Cloudflare DNS 验证签发一张通配符证书,一次搞定所有子域名, 而且不需要占用 80 端口。

安装 acme.sh

把邮箱换成你自己的,用于接收证书到期提醒。

bash
curl https://get.acme.sh | sh -s email=YOUR_EMAIL

# 让 acme.sh 命令在当前会话立即可用
source ~/.bashrc

# 设为默认使用 Let's Encrypt(默认 CA 可能是 ZeroSSL)
~/.acme.sh/acme.sh --set-default-ca --server letsencrypt
创建 Cloudflare API Token

在 Cloudflare 后台 My Profile → API Tokens → Create Token, 选 Edit zone DNS 模板。

配置项要选的值
PermissionsZone · DNS · Edit
Zone ResourcesInclude · Specific zone · 你的域名
TTL留空(不过期)或设一个足够长的时间
不要用 Global API Key Global Key 拥有账号全部权限,泄露风险高。Scoped Token 只能改指定域名的 DNS 记录, 权限刚好够用。Token 只在创建时显示一次,当场复制保存。
申请通配符证书

-d 两个参数分别覆盖根域名和所有一级子域名。 用 ECC 证书(--keylength ec-256)握手更快、体积更小。

bash
# 1. 导出 Token(当前会话有效,acme.sh 会自动记住供续期使用)
export CF_Token="YOUR_CF_TOKEN"

# 2. 申请证书(DNS 记录传播需要等,命令会自己等待,别中断)
~/.acme.sh/acme.sh --issue --dns dns_cf \
  -d yourdomain.com \
  -d "*.yourdomain.com" \
  --keylength ec-256
终端输出(成功时)
[Sat Sep  5 12:40:11 UTC 2026] Cert success.
-----BEGIN CERTIFICATE-----
...
[Sat Sep  5 12:40:11 UTC 2026] Your cert is in: /root/.acme.sh/yourdomain.com_ecc/yourdomain.com.cer
卡在 Verifying 属正常,DNS 记录传播通常 30 秒到 2 分钟。 超过 5 分钟仍失败,多半是 Token 权限不对或域名 NS 还没切到 Cloudflare。
安装证书到固定目录

不要直接把配置指向 ~/.acme.sh/ 里的文件—— 续期时那些文件名会变。用 --install-cert 复制到固定路径, acme.sh 会在每次续期后自动更新这两个文件并重启 sing-box。

bash /etc/sing-box/certs/
mkdir -p /etc/sing-box/certs

~/.acme.sh/acme.sh --install-cert -d yourdomain.com --ecc \
  --fullchain-file /etc/sing-box/certs/fullchain.pem \
  --key-file       /etc/sing-box/certs/key.pem \
  --reloadcmd      "systemctl restart sing-box"

# 收紧权限:私钥只有 root 可读
chmod 600 /etc/sing-box/certs/key.pem
chmod 644 /etc/sing-box/certs/fullchain.pem

# 确认两个文件都在
ls -l /etc/sing-box/certs/
这条命令会立刻执行一次 --reloadcmd,服务会在这里被启动 --install-cert 不只是登记续期后要做什么——它复制完证书就会把 --reloadcmd 跑一遍。 也就是说 systemctl restart sing-box 现在就执行了,而 restart 对一个没在跑的服务等于 start。 所以这一步之后 systemctl status sing-box 会变成 active (running), 和第 5 节说的 inactive (dead) 不一样,这是预期的,不是哪里做错了。
此时用的还是 deb 自带的默认配置(shadowsocks,端口 8080)。 第 5 节红框里的两道保险仍然成立:8080 不在第 3 节的放行名单里,第 8 节会把配置整份覆盖并重启。 想让状态跟第 5 节完全对得上,可以顺手 systemctl stop sing-box,不停也不影响后面。
关于权限:600 的私钥确实能读到,但原因不是「以 root 运行」 官方 deb 的单元里写的是 User=sing-box—— sing-box 不是以 root 跑的。 它能读 600 的私钥,是因为同一份单元还给了 AmbientCapabilities=… CAP_DAC_READ_SEARCH, 这个能力按内核定义会「绕过所有针对文件与目录的读取、搜索权限检查」, 属主是 root 也照样读得到。
所以这里不需要 chown。 但反过来要注意:如果你在自己的 drop-in 里重写了 AmbientCapabilities= 而漏掉 CAP_DAC_READ_SEARCH, 证书就会读不出来,日志里报 permission denied。 那种情况下要么把这个能力补回去,要么改属主: chown -R sing-box:sing-box /etc/sing-box/certs。
确认自动续期已就位

acme.sh 安装时会写入一条 cron 任务,证书到期前 30 天自动续期并执行上面的 --reloadcmd。

bash
# 应看到一条 acme.sh --cron 的定时任务
crontab -l | grep acme

# cron 服务必须是 running,否则续期不会执行
systemctl is-active cron

# 查看证书有效期
openssl x509 -in /etc/sing-box/certs/fullchain.pem -noout -dates
到这里应该 /etc/sing-box/certs/ 下有 fullchain.pem 与 key.pem 两个文件,openssl 能读出 90 天左右的有效期, crontab -l 里有 acme.sh 的续期任务。
证书申请失败的几种原因 高频
报错关键字原因处理
invalid domain / NXDOMAIN域名 NS 还没切到 Cloudflaredig NS yourdomain.com 确认返回的是 CF 的 NS
Invalid request headersToken 权限不足或复制时带了空格重新创建 Token,确认是 Zone-DNS-Edit,重新 export
CF_Token is not set换了终端窗口,export 的变量丢了重新执行 export CF_Token="..." 再申请
rateLimited同一域名短时间反复申请,触发 Let's Encrypt 限流等一小时;调试期间加 --staging 用测试环境

整份教程的核心。本节给两份完整配置,都是链式形态: 客户端连中转机,中转机把流量交给你买的住宅 SOCKS5 落地,再由落地访问目标站。 两份的差别只有一处——怎么决定谁走哪台落地: 方案 A 按用户名分,方案 B 按端口分。 入站都只用 VLESS + Reality,DNS、路由、出站、缓存这些槽位按最终形态铺好了, 以后要加协议、加规则、加出口,往骨架上挂即可,不用重写。

链路长什么样

拓扑
  客户端(你的手机 / 电脑)
    │
    │  VLESS + Reality:线路优化,延迟低
    ▼
  中转机(香港 / 日本)—— 本教程配置的就是这一台
    │
    │  SOCKS5 出站:公网一跳,明文,务必锁定来源 IP
    ▼
  住宅 SOCKS5 落地(从服务商买的,你不用配它)
    │
    │  直连
    ▼
  目标站点 —— 它看到的是住宅 IP

为什么要两跳:直连远端延迟高、丢包多;而机房 IP 大多被流媒体和 AI 服务 标记为「代理」。中转机负责线路质量,落地负责 IP 干净。

落地那一侧你什么都不用配 本节的两份配置全部写在中转机上——也就是你按第 1 到第 7 节装好的那台 VPS。 落地是从服务商买的住宅 SOCKS5,对方给你「地址 + 端口 + 账号 + 密码」四样东西, 你只是当客户端去连它。要问供应商拿哪些信息,见本节后面「落地那边要拿到什么」。

两份配置怎么选

方案 A · 按用户分流方案 B · 按端口分流
对外开的端口443 一个443 / 8443 / 2053 三个
靠什么区分出口同一个端口,靠 UUID 对应的用户名靠端口,每个端口一个独立 UUID
识别面最小,一个 IP 上只有 443 在做 TLS,和普通网站一样较大,三个端口同时做 TLS,正常网站不长这样
想换出口时客户端换一条节点(换 UUID)客户端换一条节点(端口和 UUID 要同时换)
再加一台落地只改中转机配置,客户端不动防火墙不动要开新端口、改 ufw、加新节点
适合自己 + 家人 + 客人共用一台中转,各人固定走各自的出口一个人想手动在几条出口之间来回切
两份都是完整配置,不要混着抄 下面两栏各自是一份可以直接用的 /etc/sing-box/config.json, 不是「基础版 + 补丁」的关系。挑一份整份写进去。 看不出该选哪个就用方案 A——它对外只暴露一个端口, 而你的首要目标是不被识别。

配置文件的结构

/etc/sing-box/ ├── config.json ← 本节要写的文件 ├── certs/ │ ├── fullchain.pem ← 第 7 节生成的证书链,Reality 用不到,加别的协议才要 │ └── key.pem ← 第 7 节生成的私钥 ├── rules/ ← 默认不用;改用本地规则集时才需要 └── config.json.bak ← 下面第一步的备份 /var/lib/sing-box/ └── cache.db ← 运行时缓存,自动生成
顶层字段作用本节两份配置怎么用
log日志级别与格式warn 级别,正常访问不落盘(见本节后面「日志级别到底记了什么」)
dns域名解析策略用户流量走一台加密 DoH(1.1.1.1);配置里的域名走系统解析。不做任何 reject
inbounds节点入口,每个协议 / 每个端口一个对象方案 A 一个 Reality 三个用户;方案 B 三个 Reality 各一个用户。另外六个协议见第 10 节
outbounds流量出口direct + 两台住宅 SOCKS5 落地。落地换成自己的 sing-box 就把 socks 改成 vless/trojan 等对应类型,其余不动
endpoints顶层,WireGuard 从 1.13 起放这里本教程不用。要挂 WARP 才需要,且必须写在顶层、不能写进 outbounds
route流量走哪个出口嗅探 → DNS 劫持 → 分流 → 解析,未命中的直连
experimental缓存等附加能力只是结构占位——按本节配置它其实什么都不缓存,原因见本节后面的 cache_file 折叠
备份默认配置,建好缓存目录

装包时带的那份 config.json 先留一份, 改坏了能对照。缓存目录必须提前建好—— 配置里写了 cache_file 而目录不存在, sing-box check 照样返回 0,启动才 FATAL, 而且 sing-box 不会自己建这个目录。

bash
# 有就备份,没有就跳过,不会报错
[ -f /etc/sing-box/config.json ] && cp /etc/sing-box/config.json /etc/sing-box/config.json.bak

# 配置目录 + 缓存目录
#   缓存目录必须归 sing-box 用户,否则服务以 User=sing-box 跑起来后写不进 cache.db
mkdir -p /etc/sing-box /var/lib/sing-box
chown sing-box:sing-box /var/lib/sing-box
chmod 700 /var/lib/sing-box
写入配置文件

下面两栏挑一栏,那就是 /etc/sing-box/config.json 的完整内容——整份复制进去,不用再拼接别的片段。 写完记得按下面那条命令收一下权限。

注释可以留在文件里 sing-box 解析配置时接受 JSONC:// 行注释、 /* */ 块注释、末尾多余的逗号都能过 sing-box check, 不需要粘贴前手工删掉。 更省事的是 sing-box format -w 会保留注释, 格式化之后注释还在,只是块注释会被挪到上一行末尾——所以统一用 // 最省心。 这两点都是在 1.14.0 上实测的。

一个端口、三个用户:me 走落地 A,family 走落地 B, guest 一条规则都不命中,从中转机本地直接出网。 加人就在上面的参数面板点「添加用户」,users 和 route.rules 会自动生成, 端口和防火墙都不用动。

jsonc /etc/sing-box/config.json
// 配置由上方参数面板实时生成,填完直接点「复制」
分流字段是 auth_user,不是 user user / user_id 匹配的是服务器上的 Linux 进程属主, 和 process_name 是同一类进程字段,跟节点用户毫无关系。 写成 user 的后果是静默失效:check 通过、启动零告警,客户端也照样能上网, 只是三个人全落到了 final。 本配置在路由末尾加了兜底 reject,这类笔误会变成「连不上」而不是「悄悄走错出口」。 auth_user 对应入站 users[].name。
name 不能省,UUID 也别复用 没有 name 的用户在路由里没法被 auth_user 匹配到。 UUID 用 sing-box generate uuid 一人生成一个, 共用一个就没法区分身份,也没法单独停掉某个人。 name 改名时,dns.rules 和 route.rules 里的引用要同步改。

三个端口各是一个独立入站:443 走落地 A,8443 走落地 B, 2053 从中转机本地直接出网。 三个入站的 UUID、private_key、short_id 全部各自独立, 泄露任何一个都不影响另外两个。

jsonc /etc/sing-box/config.json
// 配置由上方参数面板实时生成,填完直接点「复制」
换端口必须同时换 UUID 三个端口的 UUID 不一样,所以客户端上「把端口从 443 改成 8443、UUID 不动」 连不上。实测服务端会记一行 ERROR inbound/vless[in-8443]: process connection from …: unknown UUID: …, 客户端那边只表现为超时。要切出口就整条节点一起换。
8443 和 2053 要自己补防火墙 第 3 节只放行了 22 和 443, 选方案 B 就要再补两条: ufw allow 8443/tcp 和 ufw allow 2053/tcp。 而且云厂商控制台的安全组是另一层, 阿里云 / 腾讯云 / AWS / OCI / GCP 还要在入方向单独加这两个端口, 否则端口在机器上监听着、外面照样进不来。 自查一条:ss -ltnp | grep -E ':(443|8443|2053)\b' 有三行, 就说明服务端这侧没问题,剩下的只能是防火墙或安全组。
多开两个端口是有代价的 一个 IP 上只有 443 在做 TLS,是最接近普通网站的形态。 多开 8443 / 2053 之后,这个端口组合本身就成了一条特征—— 正常的网站服务器不会长这样。方案 B 换来的是操作上的方便, 付出的是识别面。真在意这一点就用方案 A。

放上去的办法两种,选一种即可。写完一定要收权限: 这份文件里有 Reality 私钥和所有人的 UUID, 默认的 644 意味着这台机器上任何一个账号都能读到它。

bash 中转机
# 方式一:在服务器上直接编辑,粘贴后 Ctrl+O 回车保存、Ctrl+X 退出
nano /etc/sing-box/config.json

# 方式二:本地存成 config.json,再传上去
#   做过第 4 节改端口的用 -P 22222;没做过第 4 节就把 -P 22222 去掉
# scp -P 22222 config.json root@YOUR_SERVER_IP:/etc/sing-box/config.json

# 两种方式都要跑这一条
chmod 600 /etc/sing-box/config.json
粘贴前先确认编辑器没有自动折行 nano 默认不硬折行,粘进去是什么样就是什么样。 但如果你是从网页里复制到某些带「自动换行」的编辑器再传上去, 长行被真的插进换行符会让 JSON 变成非法—— sing-box check 会报 invalid character。 下一步的 check 就是用来拦这种问题的。
面板里还有空项,配置就还不能用 上面参数面板里标黄的占位符都要填上:用户 UUID 点 🎲 直接生成, 落地节点的 IP/用户名/密码填住宅 SOCKS5 供应商给你的信息; 落地端口如果不是 1080 就改成实际的——"server_port" 这个位置必须是数字,填错 check 只会报 invalid character,不会告诉你是哪一行。
只有一台落地、或者暂时不想用链式,在面板里把多余的落地节点删掉就行, 引用它的 DNS 和路由规则会跟着一起消失,其余不用改。
手上根本没有落地节点:面板里删光就行,不用换配置 住宅 SOCKS5 是可选的加分项,没有它这份配置照样能跑,只是出口 IP 就是 VPS 自己的。 改法是在上面的参数面板里把落地节点删光,出站、DNS 和 route.rules 里引用它们的部分会自动消失,其余一个字不动——剩下的流量会落到 "final": "direct" 上,从 VPS 直接出去。 不要图省事把占位符原样留着: LANDING_A_IP 这种中文/英文占位符是合法的 JSON 字符串, sing-box check 会通过、服务也起得来, 但走到那条规则的流量全部连不出去,表现是「节点连上了、网页转圈」, 排查起来比直接报错麻烦得多。
校验语法并格式化
bash
# ① 语法 + 字段合法性检查。这一步过不了就不要启动服务
sing-box check -c /etc/sing-box/config.json && echo "配置 OK"

# ② 顺便格式化(缩进对齐、字段排序),注释会被保留
sing-box format -c /etc/sing-box/config.json -w

# ③ 起服务,然后确认端口真的监听上了
systemctl restart sing-box
systemctl --no-pager status sing-box
ss -tlnp | grep sing-box
输出
配置 OK
check 过了不等于起得来 有两种错 check 抓不到,只在启动时炸:
① cache_file 的父目录不存在 → initialize cache-file: open /var/lib/sing-box/cache.db: no such file or directory。 上面第一步的 mkdir -p 就是为了这个。
② route.rules 里引用了没在 route.rule_set 声明过的 tag → 端口会先正常监听上,然后进程立刻退出,日志只有一行 initialize rule[2]: rule-set not found: …。
所以每次改完配置都要 systemctl status 加 ss -tlnp 各看一眼, 光看 check 的返回值不够。
常见报错对照 decode config ... invalid character '}' → 多了或少了逗号; missing tls certificate → 证书路径不对,回第 7 节确认; parse private key → Reality 私钥粘漏了字符; missing domain resolver for domain server address → dns.servers[].server 写成了域名,那里必须写 IP; detour to an empty direct outbound makes no sense → 某处写了 "detour": "direct",把那一整行删掉。 注意这条既可能是启动时的 FATAL(写在 DNS 或 http_clients 上), 也可能只是每条连接一行 ERROR、服务照样显示在跑(写在出站上)。

落地那边要拿到什么

住宅 SOCKS5 是买来的服务,落地机上的东西不由你配。 你只需要从供应商那里拿到下面这几项,填进 outbounds 对应的位置:

要拿到的信息填进配置哪一项注意
SOCKS5 入口地址serverIP 或域名都行。写域名时靠 default_domain_resolver 解析,本节配置已经设成系统解析
端口server_port必须是数字,不是 1080 就照实改
用户名 / 密码username / password供应商一般都会给。确实不需要认证就把这两行删掉
支不支持 UDP ASSOCIATE—不支持的话见下面 UDP 那一条
出口 IP 是固定还是轮换—轮换套餐会让流媒体反复要求重新登录,选节点时先问清楚
并发连接数上限—多人共用一台落地时会先撞到这个上限,表现是「有时候打不开」
username / password 什么时候能省 这两个字段本身是可选的,socks 出站只有 server 和 server_port 必填。但省不省要看落地: 落地要求认证而出站没写,连接直接失败, 日志里是 socks5: unsupported auth method: 255; 反过来落地不要求认证、出站却写了凭据,凭据被忽略,照样通。 所以模板里每台都写上——多写无害,漏写会断,而且这个错是响亮的, 不会静默。
这一跳是明文,凭据也是明文 SOCKS5 没有加密,RFC 1929 的用户名密码是明文过公网的。 这一跳不穿墙,所以它不是「被 GFW 识别」的风险; 它的代价是另外两条:链路上的人能看到你的落地凭据, 以及住宅供应商能看到你访问的每一个域名—— 后者是刻意的,本节配置故意把原始域名交给落地(atyp=3), 否则解锁能力就作废了。选供应商时把这一点算进去。 真要给这一跳加密,只能换成落地那边支持的加密协议(比如对方也给 VLESS / Trojan 节点): 把 landing-a 那段出站的 "type": "socks" 换成 "vless"/"trojan",字段按第 10 节对应协议的客户端栏填, route.rules 里引用它的 tag 不用动。
UDP 通不通取决于落地 socks 出站默认 TCP / UDP 都启用,但 UDP 需要落地支持 UDP ASSOCIATE,不少住宅代理只做了 TCP。 症状是普通网站正常、走 QUIC / HTTP3 的站点很慢或打不开。 两种处理:在落地出站里加 "network": "tcp" 把 UDP 关掉, 或者在分流规则里加 "network": ["tcp"] 限定条件, 让 UDP 落回 direct。 不要用 reject——直接拒绝 QUIC 会让客户端一直重试, 反而比让它慢慢超时更显眼,而本教程服务端一条 reject 都不留。 最省事的其实是客户端关掉 HTTP/3,浏览器自己就会退回 TCP。

确认分流真的生效

配置能起来只说明语法对了,不说明流量走对了。三个身份分别连上,各查一次出口 IP:

bash在客户端上,每个身份各跑一次
curl -s https://api.ip.sb/geoip
身份方案 A 用哪个方案 B 用哪个应该看到谁的 IP
1用户 me端口 443落地 A
2用户 family端口 8443落地 B
3用户 guest端口 2053中转机自己
三个全都显示中转机 IP,说明规则一条都没命中 方案 A 先查是不是把 auth_user 写成了 user, 再查 users[].name 有没有漏填、和规则里的值是否逐字一致。 方案 B 查 route.rules 里的 inbound 和入站的 tag 对不对得上。 中转机上临时把 log.level 改成 info 再 journalctl -u sing-box -f,能直接看到每条连接选了哪个出站 tag, 查完记得改回 warn。
链路排查:节点连上了但访问不了目标站点

按这个顺序查,基本一次定位。前两步在中转机上执行:

bash在中转机上执行
# ① 中转机能不能连到落地的端口
nc -vz LANDING_A_IP 1080

# ② 直接拿落地当代理试一次,绕开 sing-box 单独验证落地本身
curl -sS -o /dev/null -w '%{http_code}\n' --max-time 15 \
  -x socks5h://LANDING_A_USER:LANDING_A_PASS@LANDING_A_IP:1080 \
  https://www.example.com/

# ③ 看每条连接选中了哪个出站 tag(临时开 info)
journalctl -u sing-box -f | grep -E 'outbound|landing'
现象原因处理
nc 超时落地限制了来源 IP,或者你的套餐要先在面板里绑定 IP把中转机 IP 加进供应商的白名单
步骤 ② 就失败问题在落地或凭据,和 sing-box 无关核对地址 / 端口 / 账号密码,或找供应商
日志 unsupported auth method: 255落地要求认证,出站没写凭据补 username / password
日志一直是 direct分流规则没命中核对 auth_user / inbound 与入站里的 name / tag 是否逐字一致
普通站点正常、QUIC 站点打不开落地不支持 UDP ASSOCIATE见上面 UDP 那个提示框
落地收到的是 IP 不是域名resolve 排在分流规则前面了把分流规则移到 resolve 之前
流媒体 / AI 站点仍提示不可用落地 IP 本身被标记,或套餐在轮换 IP换落地,与配置无关
客户端直接超时、服务端日志有 unknown UUID方案 B 里端口和 UUID 没配套改整条节点一起换

配置之外还剩三处

配置文件管得住经过节点的流量,管不住服务器自己。下面三件事顺手做掉, 长期挂着才不会在别的地方漏出来。

bash
# ① 配置和证书里全是密钥,别留全局可读
chmod 600 /etc/sing-box/config.json
chmod 600 /etc/sing-box/certs/key.pem
chmod 700 /etc/sing-box/certs
chown -R root:root /etc/sing-box

# ② 缓存目录。配置里写了 cache_file 就必须有这个目录,否则启动失败
#   属主必须是 sing-box:服务不是以 root 跑的,root 的目录它写不进去
mkdir -p /var/lib/sing-box
chown sing-box:sing-box /var/lib/sing-box
chmod 700 /var/lib/sing-box
③ 服务器自己的 DNS 仍然是明文 配置里那台 DoH 只覆盖经过节点的查询。 系统自身的 apt update、证书续期、时间同步走的是 /etc/resolv.conf 里的明文 DNS,机房照样能看到。 这些暴露的是服务器行为、不是用户访问记录,威胁小得多,一般不必处理; 真要处理就在本机跑一个 DoH 代理再把 resolv.conf 指向它,属于另一个话题。
日志级别到底记了什么 实测

用同一份配置访问两个站点,只改 log.level:

实测结果日志行数 / 出现访问域名的次数
level = info     11 行日志,其中 5 行出现访问域名
level = warn      1 行日志,其中 1 行出现访问域名(仅解析失败那条)
level = error     1 行日志,其中 1 行出现访问域名

info 级别的典型两行:
  INFO inbound/vless[reality-in]: inbound connection to www.wikipedia.org:443
  INFO outbound/direct[direct]: outbound connection to www.wikipedia.org:443

info 会给每条连接写两行带完整域名的记录,全部进 journald。 一台跑几个月的节点,等于在服务器上留了一份完整浏览历史—— 服务商快照、机器被入侵、甚至只是别人拿到 root,这份记录都在。

warn 是推荐值,也是上面那份配置用的值:正常访问不记,出错才记。 但它不是完全静默的——有人拿探测工具扫你的 443, 日志里会出现 ERROR ... REALITY: processed invalid connection。 这条其实有用:它说明有人在主动试你这个端口。

要更彻底可以用 "level": "fatal" 或 "disabled": true, 代价是出问题时几乎没有线索可查,也看不到有没有人在探测。 配合 journald 限容一起用:

bash
# 限制 journald 总容量并缩短保留期,避免日志无限增长
mkdir -p /etc/systemd/journald.conf.d
cat > /etc/systemd/journald.conf.d/99-limit.conf <<'EOF'
[Journal]
SystemMaxUse=200M
MaxRetentionSec=7day
EOF
systemctl restart systemd-journald
关于 cache_file:现在留着它是为了以后 实测

experimental.cache_file 默认只持久化三类东西:远程 rule_set 的内容、FakeIP 映射表、Clash API 里的选择状态。 按本节的配置(enabled 之外什么都不加), DNS 解析结果和访问过的域名都不写盘。

但 store_dns 这个字段是存在的,别手滑打开 store_dns 是 1.14.0 新增字段,默认 false。 写成 "store_dns": true 之后实测:跑两次解析, cache.db 从 32768 涨到 65536 字节, 用 strings 就能在里面搜到访问过的域名 (测了两个站,一个出现 12 处、一个 10 处)。 也就是说这个开关一开,缓存文件里就会有一份域名清单。 默认不开的话实测一个域名都不落盘。 顺带一句:拼错成 store_dns_cache 会直接 unknown field 退出——所以「这个字段不存在」的说法是错的,只是名字不同。

关键的一点:本地 rule_set 也不进缓存。 用 type: local(把 .srs 放在 /etc/sing-box/rules/ 里)配三个规则集, 跑起来 cache.db 还是空的。三种情况各跑一次实测:

实测结果cache.db 文件大小
 32768 B   不配 rule_set —— bolt 空库,从未写入
 32768 B   3 个 rule_set,type = local —— 一样是空的,本地文件直接读盘
262144 B   3 个 rule_set,type = remote —— 规则集内容已落盘

三种情况下,缓存文件里出现过的访问域名:均为 0 处

所以按本文的推荐配置,cache_file 是一个结构占位, 不是在干活。留着的理由只有一个:以后你要改用远程规则集、 或者在服务端开 FakeIP / clash_api,不用再回来动结构。 它也不构成隐私隐患,因为它不记录任何访问过的域名。 想删掉整个 experimental 块也完全没问题, 那就把 mkdir -p /var/lib/sing-box 一起省了。

顺带三个 1.14 的字段变化,抄旧教程的话留意: store_rdrc 已废弃(1.16 移除); 远程规则集的 download_detour 同样废弃; 客户端侧的 independent_cache 也是废弃字段。 这三个还留在配置里只会多一行 WARN,但迟早会变成硬错误。

systemd 单元加固(可选) 未在本文实测

下面这些选项限制 sing-box 进程能碰到的系统资源,属于纵深防御—— 即使 sing-box 本身出现漏洞,能造成的破坏也有限。 这段与前面的配置不同,没有在本文的测试环境里跑过(沙箱里没有 systemd), 按下面的顺序自己验证一遍再长期用。

bash/etc/systemd/system/sing-box.service.d/99-hardening.conf
mkdir -p /etc/systemd/system/sing-box.service.d
cat > /etc/systemd/system/sing-box.service.d/99-hardening.conf <<'EOF'
[Service]
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK AF_UNIX
# ProtectSystem=strict 会把整个文件系统变成只读,这个目录必须放行
#   以后若按第 13 节把日志写进文件,要把日志目录一起加上,写成:
#   ReadWritePaths=/var/lib/sing-box /var/log/sing-box
ReadWritePaths=/var/lib/sing-box
# 能力清单照抄官方单元,一个都别少:
#   CAP_NET_BIND_SERVICE 绑 443/80 这类特权端口
#   CAP_DAC_READ_SEARCH  以 sing-box 用户读 root 的 600 证书私钥
#   CAP_NET_ADMIN/RAW    tun、嗅探等功能要用
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH
EOF

systemctl daemon-reload
systemctl restart sing-box

# 验证三步,缺一不可
systemctl status sing-box --no-pager              # 1. 起来了没
ss -tlnp | grep sing-box                     # 2. 端口绑上了没
systemd-analyze security sing-box.service     # 3. 加固评分(越低越好)
起不来先删这两行 ProtectSystem=strict 和 MemoryDenyWriteExecute=true 是最容易出问题的两项。日志里出现 Read-only file system 就是前者, 补 ReadWritePaths=;出现莫名崩溃就删后者。 每加一项重启验证一次,别一次全上。
AmbientCapabilities= 那行不要自己删项 官方单元给的是 CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SYS_PTRACE CAP_DAC_READ_SEARCH 五项,上面照抄了。少写 CAP_NET_BIND_SERVICE 会绑不上 443; 少写 CAP_DAC_READ_SEARCH 会读不到第 6 节那份 chmod 600 的证书私钥(服务以 User=sing-box 运行,不是 root), 日志里表现为 permission denied——而且这两种错都是「重启后才发现」, 所以改完必须走下面的三步验证。
这两份配置验证到什么程度 全部在 sing-box 1.14.0 真二进制上跑过,不是照文档推的。 两份都 check 零告警、能启动、端口正常监听,运行期间 WARN 与 ERROR 都是 0 行,注释原样留在文件里不影响解析。 分流是端到端验的:在落地那一侧架了一个会打日志的 SOCKS5 桩, 方案 A 的三个用户分别落到落地 A / 落地 B / 本机直出, 方案 B 的三个端口同样,桩上能看到正确的账号密码, 并且收到的是原始域名(atyp=3)而不是已经解析好的 IP—— 这正是链式中转要的效果。方案 B 还验了「端口换了 UUID 没换」会被拒。 DoH 被打掉时的表现也验了:把 DoH 指向一个黑洞地址, Reality 握手和入站照常工作,用户查域名则明确报 dns: lookup failed——说明 default_domain_resolver 确实接住了配置里的域名。 探测抗性:openssl 直连 Reality 端口拿回的是 Apple 的真实 EV 证书、校验通过, curl 拿到 200 和真实首页;换一个不存在的 SNI 去连,收到标准 handshake failure。 没验的部分:上面那段 systemd 加固(沙箱里没有 systemd), 以及真实住宅 SOCKS5 供应商的行为差异。这两处按说明自己复核。
关于「绝对不被封」这件事,说句实话 配置能管的是三件事:主动探测时的表现、TLS 指纹、以及 DNS 会不会泄漏。 这三样上面都做到了当前能做到的程度。 但配置管不了另外几件事——机房整段 IP 被打包封禁、 长期大流量本身构成的行为特征、借用的目标站哪天不再适合借、 以及节点分享出去之后的人为泄漏。 真正影响「能用多久」的往往是后面这几条,而它们都不在配置文件里。 所以正确的预期是:把可控的部分做到最好,同时准备好换 IP 的手段, 而不是指望一份配置一劳永逸。

端口与协议对照(开了多协议才用得上)

上面两份配置只用 Reality:方案 A 一个 443,方案 B 是 443 / 8443 / 2053 三个同协议端口。 只有当你从第 10 节挑了别的协议加进 inbounds,下面这张表才有意义—— 用来核对端口、传输层、域名、证书四项有没有配错, 以及第 3 节的 ufw 有没有把对应的口放行。

协议端口传输层域名要证书
VLESS + Reality443TCP—(借用他人域名)否
VLESS + WS + TLS8443TCPcf. 橙云是
Trojan9443TCPnode. 灰云是
Shadowsocks 202280TCP + UDP—(用 IP)否
Hysteria25678UDPnode. 灰云是
TUIC v57788UDPnode. 灰云是
AnyTLS10443TCPnode. 灰云是
为什么 SS2022 用 80 端口 80 在几乎所有网络环境里都不会被阻断,适合做保底节点。 但很多镜像默认装了 nginx / apache 并占用 80, 启动前用 ss -tlnp | grep ':80\b' 确认一下; 被占用就 systemctl disable --now nginx,或把 SS 换到别的端口。

9落地机配置Landing Server

落地机是你买的第二台 VPS,专门负责把流量送出去。第 8 节的 landing-a 出站就是连到它。这一节在落地机上装 sing-box,配好入站和 SSRF 防护。

9.1 落地机是干什么的

回顾第 8 节的链路:

text
你的设备 → 入口机(VLESS Reality 443)→ 落地机(VLESS/SS)→ 目标网站

落地机只做两件事:收(从入口机收流量)、送(送到公网)。它不需要管用户、不需要分流、不需要伪装——越简单越好。

如果你现在用的是购买的 SOCKS5 落地,这一节可以跳过,等以后换成自己的 VPS 再回来看。入口机配置不用改,只要把第 8 节落地列表里的协议从 SOCKS5 换成 VLESS/SS,填上这台机器的参数就行。

9.2 基础环境

跟入口机一样,走一遍第 1–5 节:SSH 登录、更新系统、防火墙、SSH 安全、装 sing-box。防火墙只放行给入口机用的端口(比如 8388),其他全关。

bash
# 只放行落地端口(示例 8388),替换成你实际用的
ufw allow 8388/tcp
ufw allow 8388/udp

9.3 sing-box 配置

落地机的配置比入口机简单得多:一个入站(给入口机连)、一个直连出站、三条路由规则(SSRF 防护)。

⚙ 落地机参数

填好后下方配置自动替换。跟第 8 节落地列表里的参数保持一致。

jsonc/etc/sing-box/config.json
// 落地机配置由上方参数面板生成

9.4 跟入口机对接

两边参数必须一致,否则连不上:

参数落地机(本节)入口机(第 8 节落地列表)
协议VLESS / SS下拉选一样的
端口监听端口落地端口填一样的
UUID/密码入站凭证落地密码填一样的
服务器—填落地机 IP

对接完,在入口机跑 sing-box check -c /etc/sing-box/config.json,再在落地机跑一遍,两边都过才算完。

七个协议,每个给三栏:服务端入站块、sing-box 客户端出站、mihomo(Clash.Meta)YAML 加分享链接。第 8 节的配置默认只开 Reality 一个入站, 要加别的协议就把对应的「sing-box 服务端」那一栏整段并进 inbounds 数组, 放在 reality-in 后面用逗号隔开,路由和出站一个字都不用改。

先看这里,能省一半时间 下面所有 高亮值 服务端与客户端必须逐字一致。 90% 的「连不上」都是这三处对不上:UUID / 密码写错、端口写错、 server_name 与服务端证书域名不一致。 另外第 11 节有个脚本能从服务端配置自动生成客户端链接, 手填容易错的话直接用脚本。
加协议之前先想清楚代价 一个 IP 上同时开 443 / 8443 / 9443 / 80 / 5678 / 7788 / 10443, 这个端口组合本身就是一个很强的指纹:正常的网站服务器不会长这样。 你的目标是「不被识别」,那么能不加就不加。 真需要多协议,优先考虑分到不同机器上,而不是堆在一台。 七段全加上配置能过 check、七个口都能监听——这一点是实测过的, 但「能跑」和「该开」是两件事。
协议端口被识别的代价
VLESS + Reality443/tcp最低,对外就是一台普通 HTTPS 服务器
VLESS + WS + TLS8443/tcp低,套 CDN 后源 IP 不暴露;但 8443 这个端口本身偏门
Trojan9443/tcp中,证书是你自己的域名,主动探测能看出这个 IP 只服务一个冷门域名
AnyTLS10443/tcp中,同上,且协议较新、实现分布窄
Hysteria25678/udp中偏高,长期大流量 UDP 到单一境外 IP,行为特征明显
TUIC v57788/udp中偏高,同上
Shadowsocks 202280/tcp最高,80 口不做 HTTP 却跑高熵流量,是最容易被打标的形态之一
加了端口记得开防火墙 第 3 节只放行了 22 和 443。 每加一个协议就补一条:TCP 口写 ufw allow 9443/tcp, UDP 口写 ufw allow 5678/udp——协议写错等于没放行。 云厂商控制台的安全组是另一层,也要同步加。 需要证书的几个协议用的是第 7 节签下来的路径,先把第 7 节做完。

VLESS + Reality · TCP 443

客户端要额外填 public_key(第 5 节 reality-keypair 输出的 PublicKey,不是服务端用的 PrivateKey)和 short_id。

jsoncinbounds[] · 第 8 节骨架里已有这一段
{
  "tag": "reality-in",
  "type": "vless",
  "listen": "::",             // 同时收 IPv4 和 IPv6
  "listen_port": 443,
  // 多个人用就往数组里加,name 是分流用的标识
  "users": [
    { "name": "me", "uuid": "YOUR_UUID_1", "flow": "xtls-rprx-vision" }
  ],
  "tls": {
    "enabled": true,
    "server_name": "updates.cdn-apple.com",   // 必须与 handshake.server 一致
    "reality": {
      "enabled": true,
      // 借站目标:客户端握手时真的会连到这里
      "handshake": { "server": "updates.cdn-apple.com", "server_port": 443 },
      "private_key": "YOUR_REALITY_PRIVATE_KEY",
      "short_id": ["YOUR_SHORT_ID"],
      // 客户端时间偏差超过 1 分钟直接拒,挡掉一部分重放式探测
      "max_time_difference": "1m"
    }
  }
}
只有这个协议不需要证书和域名 Reality 用的是借站目标的真实证书,所以第 6、7 节都可以跳过, 客户端直接填服务器 IP。这也是它最难被识别的原因: 外面探测这个端口,拿到的是 updates.cdn-apple.com 的真证书。 借站目标怎么挑,见下面的折叠。
Reality 借哪个站:四条硬指标和一条自查命令

借站目标(handshake.server)必须同时满足四条, 差一条都会让节点变得容易被识别,甚至直接连不上:

要求为什么
证书 CN/SAN 含该域名本身探测方拿到的证书要和它请求的 SNI 对得上,否则一眼假
HTTP 首行不是 3xx会跳转的站说明这不是内容服务器,行为对不上
支持 TLS 1.3Reality 只在 1.3 上工作
支持 X25519 密钥交换同上,握手要用
bash
D=updates.cdn-apple.com

# 1) 证书主题里必须出现 $D 本身
echo | openssl s_client -connect $D:443 -servername $D -tls1_3 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName

# 2) 首行不能是 301/302/307
curl -sI https://$D | head -1

实测可用:www.apple.com(200)、 www.cloudflare.com(200)、updates.cdn-apple.com(403 也可以, 403 说明服务器在正常应答,只是拒绝了这个路径)。

实测不可用:www.bing.com(证书 CN 是 r.bing.com,还 302 跳 cn.bing.com)、 swdist.apple.com(CN 是 swcdn.apple.com)、 www.nvidia.com(CN 是 it.nvidia.com,307)。 addons.mozilla.org 勉强可用(301)。

别借国内站,也别借冷门小站 借站目标应该是你所在地区访问量大、且在境外的站点。 借一个没人访问的域名,等于给这个 IP 挂了一块「只有我在连」的牌子。 也不要借你自己的域名。
jsoncoutbounds[]
{
  "tag": "Reality-443",
  "type": "vless",
  "server": "YOUR_SERVER_IP",
  "server_port": 443,
  "uuid": "YOUR_UUID_1",
  "flow": "xtls-rprx-vision",
  "packet_encoding": "xudp",
  "tls": {
    "enabled": true,
    "server_name": "updates.cdn-apple.com",
    "utls": { "enabled": true, "fingerprint": "chrome" },
    "reality": {
      "enabled": true,
      "public_key": "YOUR_REALITY_PUBLIC_KEY",
      "short_id": "YOUR_SHORT_ID"
    }
  }
}
short_id 在服务端是数组、在客户端是单个字符串, 写法不一样但值必须相同。utls.fingerprint 建议保持 chrome。
yamlproxies:
- name: "Reality-443"
  type: vless
  server: YOUR_SERVER_IP
  port: 443
  uuid: YOUR_UUID_1
  network: tcp
  udp: true
  tls: true
  flow: xtls-rprx-vision
  servername: updates.cdn-apple.com
  client-fingerprint: chrome
  reality-opts:
    public-key: YOUR_REALITY_PUBLIC_KEY
    short-id: "YOUR_SHORT_ID"
分享链接
vless://YOUR_UUID_1@YOUR_SERVER_IP:443?encryption=none&security=reality
&sni=updates.cdn-apple.com&fp=chrome&pbk=YOUR_REALITY_PUBLIC_KEY&sid=YOUR_SHORT_ID
&type=tcp&flow=xtls-rprx-vision#Reality-443
上面为了排版换了行,实际使用必须是一整行、不能有换行和空格。 手拼容易出错,建议用第 11 节的脚本生成。

VLESS + WS + TLS · TCP 8443 · 唯一可套 CDN

七个节点里只有这一个能过 Cloudflare,也是IP 被封之后唯一还能救回来的那一个。 客户端栏直接给的就是第 6 节「IP 被封之后」的反代写法: 连 CF 边缘、回源到你的机器,服务端一个字不用改。

jsonc追加进 inbounds 数组
{
  "tag": "ws-in",
  "type": "vless",
  "listen": "::",
  "listen_port": 8443,
  "users": [ { "name": "YOUR_USERNAME", "uuid": "YOUR_UUID_1" } ],
  // 走 WS 的不能填 flow,vision 和 WS 不兼容
  "transport": {
    "type": "ws",
    "path": "/YOUR_WS_PATH",
    // 0-RTT 早期数据,少一轮往返
    "max_early_data": 2048,
    "early_data_header_name": "Sec-WebSocket-Protocol"
  },
  "tls": {
    "enabled": true,
    // 这里填第 6 节的橙云域名
    "server_name": "cf.yourdomain.com",
    "min_version": "1.3",
    "certificate_path": "/etc/sing-box/certs/fullchain.pem",
    "key_path": "/etc/sing-box/certs/key.pem"
  }
}
8443 是 Cloudflare 支持的端口之一 橙云代理只转发指定端口,8443 在列表内, 改成 9443 之类的会直接不通——完整列表见第 6 节。 另外 path 两端必须一模一样, 大小写和开头的斜杠都算。
jsoncoutbounds[] · CF 反代写法
{
  "tag": "WS-CDN-8443",
  "type": "vless",
  // 这里填的是 CF 边缘的入口,不是你的服务器。
  // saas.sin.fan 会自动解析出一个优选过的 Cloudflare IP
  "server": "saas.sin.fan",
  "server_port": 8443,
  "uuid": "YOUR_UUID_1",
  "transport": {
    "type": "ws",
    "path": "/YOUR_WS_PATH",
    // 必写!CF 靠这个名字才知道回源到哪台机器
    "headers": { "Host": "cf.yourdomain.com" },
    "max_early_data": 2048,
    "early_data_header_name": "Sec-WebSocket-Protocol"
  },
  "tls": {
    "enabled": true,
    // 和上面的 Host 一致:你自己的橙云域名,证书也是签给它的
    "server_name": "cf.yourdomain.com",
    "utls": { "enabled": true, "fingerprint": "chrome" }
  }
}
实测:server 一旦不是你的域名,headers.Host 就是必填的 sing-box 发出的 Host 头取的是 server 这个字段本身, 不是 tls.server_name。用 nc 和一个本地 TLS 服务端各抓了一次原始请求: 只写 server_name 而不写 headers.Host 时, 发出去的是 Host: 127.0.0.1:18082——服务器地址原样带端口。 换成 saas.sin.fan 就会变成 Host: saas.sin.fan:8443, CF 那边匹配不到你的站点,网页侧直接 404。 所以优选 IP/优选域名和 headers.Host 必须成对出现。
不用优选、直接填自己的橙云域名也行 把 server 换成 cf.yourdomain.com, 这时 headers 那两行可以整段删掉—— Host 取 server,正好就是对的。边缘节点交给 CF 就近分配, 省事但不一定最快。IP 没被封的时候更该用的是灰云直连的 Reality(第 10 节 ①),CDN 这条路只是退路。
三个字段必须两边一致 path、max_early_data、early_data_header_name。 只要有一个不同,握手能成功但数据传不动,表现为「连上了但打不开网页」。 headers.Host 不在这个名单里——它只给 CF 看,服务端不校验。
yamlproxies:
- name: "WS-CDN-8443"
  type: vless
  # CF 边缘入口;不用优选就换成 cf.yourdomain.com
  server: saas.sin.fan
  port: 8443
  uuid: YOUR_UUID_1
  network: ws
  udp: true
  tls: true
  servername: cf.yourdomain.com
  client-fingerprint: chrome
  ws-opts:
    path: "/YOUR_WS_PATH"
    headers:
      Host: cf.yourdomain.com
    max-early-data: 2048
    early-data-header-name: Sec-WebSocket-Protocol
分享链接
vless://YOUR_UUID_1@saas.sin.fan:8443?encryption=none
&security=tls&sni=cf.yourdomain.com&fp=chrome&type=ws
&host=cf.yourdomain.com&path=%2FYOUR_WS_PATH#WS-CDN-8443
链接里 @ 后面那一段是连哪里, host= 是 Host 头,sni= 是 TLS 名字——后两个都填你自己的橙云域名,只有第一个换成优选地址。 path 要 URL 编码:开头的 / 写成 %2F。
第 11 节的 vps-share 是从服务端配置里读信息的,它不知道你在客户端用了优选, 所以它生成的这条链接里 @ 后面是你的域名——想用优选,把那一段手工改掉即可, 其余参数都不用动。

Trojan · TCP 9443 · 兼容性最好

字段最少、几乎所有客户端都支持,适合给不会折腾的人用。

jsonc追加进 inbounds 数组
{
  "tag": "trojan-in",
  "type": "trojan",
  "listen": "::",
  "listen_port": 9443,
  "users": [ { "name": "YOUR_USERNAME", "password": "YOUR_TROJAN_PASSWORD" } ],
  "tls": {
    "enabled": true,
    // 灰云域名,Trojan 不能走 CDN
    "server_name": "node.yourdomain.com",
    "min_version": "1.3",
    "certificate_path": "/etc/sing-box/certs/fullchain.pem",
    "key_path": "/etc/sing-box/certs/key.pem"
  }
}
证书是你自己域名的,这一点会暴露 主动探测这个端口能看出它只服务一个访问量极低的域名。 Trojan 换来的是客户端支持面最广,代价就在这里。 min_version: "1.3" 不要降,1.2 的握手特征更容易被区分。
jsoncoutbounds[]
{
  "tag": "Trojan-9443",
  "type": "trojan",
  "server": "node.yourdomain.com",
  "server_port": 9443,
  "password": "YOUR_TROJAN_PASSWORD",
  "tls": {
    "enabled": true,
    "server_name": "node.yourdomain.com",
    "utls": { "enabled": true, "fingerprint": "chrome" }
  }
}
yamlproxies:
- name: "Trojan-9443"
  type: trojan
  server: node.yourdomain.com
  port: 9443
  password: "YOUR_TROJAN_PASSWORD"
  udp: true
  sni: node.yourdomain.com
  client-fingerprint: chrome
分享链接
trojan://YOUR_TROJAN_PASSWORD@node.yourdomain.com:9443
?security=tls&sni=node.yourdomain.com&fp=chrome&type=tcp#Trojan-9443
密码里如果有 @ # ? & / 这些字符,放进链接前必须百分号编码,否则会被解析成分隔符。 第 5 节用 openssl rand -hex 生成密码就是为了避开这个问题。

Shadowsocks 2022 · TCP + UDP 80 · 延迟最低

没有 TLS 握手,建连最快、CPU 开销最小。缺点是流量特征相对明显, 适合当保底/低延迟节点,不适合长期大流量。

jsonc追加进 inbounds 数组
{
  "tag": "ss-in",
  "type": "shadowsocks",
  "listen": "::",
  "listen_port": 80,
  "method": "2022-blake3-aes-256-gcm",
  // 2022 系列的 password 必须是 32 字节 base64:sing-box generate rand 32 --base64
  "password": "YOUR_SS_PSK_BASE64"
}
这是七个协议里识别风险最高的一个 没有 TLS 外壳,80 端口不做 HTTP 却跑高熵流量—— 这是最容易被打标的形态之一。 它的优点只有一个:没有 TLS 握手,延迟最低。 网络环境敏感的地区不建议长期用。
多个人用同一个 SS 入站:加 users 数组

SS2022 支持多用户:入站的 password 是服务端 PSK, 再给每个人一个自己的 PSK。

jsoncss-in · 多用户形态
{
  "tag": "ss-in",
  // …前面的字段不变…
  "password": "YOUR_SS_PSK_BASE64",   // 服务端 PSK
  "users": [
    { "name": "me",     "password": "YOUR_SS_USER_PSK_1" },
    { "name": "family", "password": "YOUR_SS_USER_PSK_2" }
  ]
}
客户端密码是两段拼起来的 多用户模式下客户端填的不是单个 PSK,而是 服务端PSK:用户PSK——中间一个冒号,两段都是 32 字节 base64。 只填一段的症状是握手直接失败。 第 11 节的 vps-share 会自动拼好,不用手算。
jsoncoutbounds[]
{
  "tag": "SS2022-80",
  "type": "shadowsocks",
  "server": "YOUR_SERVER_IP",
  "server_port": 80,
  "method": "2022-blake3-aes-256-gcm",
  "password": "YOUR_SS_PSK_BASE64",
  "udp_over_tcp": false
}
method 必须与服务端完全一致, 2022-blake3-aes-256-gcm 要求密码是 32 字节 base64; 若用 2022-blake3-aes-128-gcm 则是 16 字节,长度不对会直接拒绝启动。
yamlproxies:
- name: "SS2022-80"
  type: ss
  server: YOUR_SERVER_IP
  port: 80
  cipher: 2022-blake3-aes-256-gcm
  password: "YOUR_SS_PSK_BASE64"
  udp: true

SS 的分享链接格式(SIP002)是 ss:// + userinfo 的 base64, userinfo 为 方法:密码:

bash
# SIP002 要求 URL-safe base64:+ → - ,/ → _ ,去掉末尾 =
# 用标准 base64 会让部分客户端解析失败,这是最容易踩的坑
USERINFO=$(printf '%s' "2022-blake3-aes-256-gcm:YOUR_SS_PSK_BASE64" \
  | base64 -w0 | tr '+/' '-_' | tr -d '=')

echo "ss://${USERINFO}@YOUR_SERVER_IP:80#SS2022-80"
不要用普通 base64 直接输出 SIP002 规定 userinfo 用的是 URL-safe 且不带填充的 base64。 标准 base64 的输出末尾会带 =, 部分客户端把它当成参数分隔符或直接拒收,症状是「密码明明对却握手失败」。 上面的 tr 两步转换必不可少——tr '+/' '-_' 换字母表, tr -d '=' 去填充。
顺带纠正一个流传很广的说法 常见说法是「SS2022 的密码本身是 base64,含 + 或 /, 套一层 base64 后这两个字符出现在 URL 里就会解析失败」。 前半句对,后半句不成立:外层 base64 只在原文出现 > ? ~ 这类字节时才会产出第 62/63 号字符 (+ / /),而 PSK 的字符全在 base64 字母表内, 所以外层结果只会是 A–Z a–z 0–9。 真正需要处理的只有末尾那个 =。 tr '+/' '-_' 仍然照写——它对别的密码形式有用,而且不会有副作用。

Hysteria2 · UDP 5678 · 弱网首选

基于 QUIC,丢包率高的线路(晚高峰、移动网络)优势明显。 前提是运营商没有对 UDP 限速——部分地区 QoS 会砍 UDP,那就退回 TCP 协议。

jsonc追加进 inbounds 数组
{
  "tag": "hy2-in",
  "type": "hysteria2",
  "listen": "::",
  "listen_port": 5678,
  // 服务端不写 up_mbps/down_mbps:写了会启用 Brutal 把所有客户端限死
  "ignore_client_bandwidth": false,
  "users": [ { "name": "YOUR_USERNAME", "password": "YOUR_HY2_PASSWORD" } ],
  "tls": {
    "enabled": true,
    "server_name": "node.yourdomain.com",
    "alpn": ["h3"],
    "certificate_path": "/etc/sing-box/certs/fullchain.pem",
    "key_path": "/etc/sing-box/certs/key.pem"
  }
}
这是 UDP,防火墙别写成 tcp ufw allow 5678/udp。写成 /tcp 等于没放行, 而症状是客户端一直转圈、服务端日志一行不多——很难猜。 云厂商安全组同样要放 UDP。
再加一层 obfs(salamander):把 QUIC 特征也盖住 可选

第 5 节生成的那个 HY2_OBFS_PASSWORD 就是留给它的。 开了之后 QUIC 握手包会被一层对称加密盖住, 探测方连「这是 QUIC」都看不出来;代价是每个包多一次加解密, CPU 很弱的小鸡上会掉一点速度。 不加也完全能用,上面那份配置默认没加。 要加就必须两端一起加。

jsonc服务端 · hy2-in
{
  "tag": "hy2-in",
  // …前面的字段都不变…
  "users": [ { "name": "YOUR_USERNAME", "password": "YOUR_HY2_PASSWORD" } ],
  // 加在 users 和 tls 之间。type 只有 salamander 一个取值
  "obfs": {
    "type": "salamander",
    "password": "YOUR_HY2_OBFS_PASSWORD"
  },
  "tls": { /* …不变… */ }
}
jsonc客户端 · 同一节的「sing-box 客户端」那一栏
{
  "tag": "Hysteria2-5678",
  // …前面的字段都不变…
  "password": "YOUR_HY2_PASSWORD",
  // 和服务端一字不差,写错就是连不上
  "obfs": {
    "type": "salamander",
    "password": "YOUR_HY2_OBFS_PASSWORD"
  },
  "tls": { /* …不变… */ }
}
实测:obfs 密码不一致时,服务端日志一行都不多 拿两端一致的配置测,连接正常;只把客户端的 obfs 密码改错一个字, 客户端会干等到超时(十几秒后报 connection: … context canceled), 而服务端日志除了启动那行 udp server started at 之外 什么都不会多出来——连 inbound connection from 都没有, 更不会有「密码错误」之类的提示。 因为 obfs 那层在 QUIC 之下,对不上的包直接被当垃圾丢掉,根本进不到认证环节。
所以别拿「服务端日志没反应」当成服务端出问题的证据, 这个症状和「防火墙没放行 UDP 口」几乎一模一样。 加了 obfs 之后排查先核对两边的这一个字符串。
分享工具不用改 第 11 节的 vps-share 会自己从配置里读 obfs.type 和 obfs.password,在生成的链接里补上 &obfs=salamander&obfs-password=…(密码会做 URL 编码)。 mihomo 那边对应的两个字段是 obfs: salamander 和 obfs-password:。 端口没变,防火墙不用动。
jsoncoutbounds[]
{
  "tag": "Hysteria2-5678",
  "type": "hysteria2",
  "server": "node.yourdomain.com",
  "server_port": 5678,
  "password": "YOUR_HY2_PASSWORD",
  // up_mbps / down_mbps 同样建议不写:两边都不写才会用 BBR 自适应,
  // 写了就切 Brutal 并硬限速。只在明确知道自己带宽上限时才填。
  "tls": {
    "enabled": true,
    "alpn": ["h3"],
    "server_name": "node.yourdomain.com"
  }
}
不要开 insecure 本教程第 7 节签的是真实证书,客户端开 "insecure": true 会让中间人替换证书也能连上,等于白签。 只有用自签证书时才需要它——那种情况下也应该改用 Reality。
yamlproxies:
- name: "Hysteria2-5678"
  type: hysteria2
  server: node.yourdomain.com
  port: 5678
  password: "YOUR_HY2_PASSWORD"
  sni: node.yourdomain.com
  alpn:
    - h3
  skip-cert-verify: false
分享链接
hysteria2://YOUR_HY2_PASSWORD@node.yourdomain.com:5678
?sni=node.yourdomain.com&alpn=h3&insecure=0#Hysteria2-5678

TUIC v5 · UDP 7788 · UDP 转发能力强

同样基于 QUIC。相比 Hysteria2,TUIC 的原生 UDP 中继更完整, 打游戏、语音通话、BT 这类场景更合适。

jsonc追加进 inbounds 数组
{
  "tag": "tuic-in",
  "type": "tuic",
  "listen": "::",
  "listen_port": 7788,
  "users": [
    {
      "name": "YOUR_USERNAME",
      "uuid": "YOUR_UUID_2",
      "password": "YOUR_TUIC_PASSWORD"
    }
  ],
  "congestion_control": "bbr",
  // 0-RTT 省一轮握手,但会带来可重放风险,默认关
  "zero_rtt_handshake": false,
  "tls": {
    "enabled": true,
    "server_name": "node.yourdomain.com",
    "alpn": ["h3"],
    "certificate_path": "/etc/sing-box/certs/fullchain.pem",
    "key_path": "/etc/sing-box/certs/key.pem"
  }
}
同样是 UDP ufw allow 7788/udp。 uuid 和 password 两个都必须和客户端对上, congestion_control 也要两端一致。
jsoncoutbounds[]
{
  "tag": "TUIC-7788",
  "type": "tuic",
  "server": "node.yourdomain.com",
  "server_port": 7788,
  "uuid": "YOUR_UUID_2",
  "password": "YOUR_TUIC_PASSWORD",
  "congestion_control": "bbr",
  // native = 原生 UDP 中继;quic = 封进 QUIC 流(NAT 严格时改这个)
  "udp_relay_mode": "native",
  "zero_rtt_handshake": false,
  "tls": {
    "enabled": true,
    "alpn": ["h3"],
    "server_name": "node.yourdomain.com"
  }
}
uuid 与 password 两个都要, 这是 TUIC 与 VLESS 最大的区别。congestion_control 两端要一致。
yamlproxies:
- name: "TUIC-7788"
  type: tuic
  server: node.yourdomain.com
  port: 7788
  uuid: YOUR_UUID_2
  password: "YOUR_TUIC_PASSWORD"
  sni: node.yourdomain.com
  alpn:
    - h3
  congestion-controller: bbr
  udp-relay-mode: native
  reduce-rtt: false
  skip-cert-verify: false
分享链接
tuic://YOUR_UUID_2:YOUR_TUIC_PASSWORD@node.yourdomain.com:7788
?sni=node.yourdomain.com&alpn=h3&congestion_control=bbr
&udp_relay_mode=native&allow_insecure=0#TUIC-7788

AnyTLS · TCP 10443 · 1.12 新增

专门针对「TLS in TLS」流量指纹做了填充与分帧处理, 在深度包检测较严的网络下比裸 Trojan 更耐用。需要 1.12+ 的服务端和客户端。

jsonc追加进 inbounds 数组
{
  "tag": "anytls-in",
  "type": "anytls",
  "listen": "::",
  "listen_port": 10443,
  "users": [ { "name": "YOUR_USERNAME", "password": "YOUR_ANYTLS_PASSWORD" } ],
  // 空数组 = 用内置默认填充。写成 "" 是类型错误,check 会直接失败
  "padding_scheme": [],
  "tls": {
    "enabled": true,
    "server_name": "node.yourdomain.com",
    "min_version": "1.3",
    "certificate_path": "/etc/sing-box/certs/fullchain.pem",
    "key_path": "/etc/sing-box/certs/key.pem"
  }
}
协议新、实现少,本身就是特征 AnyTLS 的抗指纹是针对「TLS 里面又套一层 TLS」这个形态做的, 对「这个 IP 只服务一个冷门域名」这类主动探测没有帮助。 ufw allow 10443/tcp 别忘了开。
jsoncoutbounds[]
{
  "tag": "AnyTLS-10443",
  "type": "anytls",
  "server": "node.yourdomain.com",
  "server_port": 10443,
  "password": "YOUR_ANYTLS_PASSWORD",
  // 连接池,减少频繁握手;不确定就用这两个值
  "idle_session_check_interval": "30s",
  "idle_session_timeout": "30s",
  "min_idle_session": 5,
  // 客户端这边同样不要写 padding_scheme,让它用内置默认方案
  "tls": {
    "enabled": true,
    "server_name": "node.yourdomain.com",
    "utls": { "enabled": true, "fingerprint": "chrome" }
  }
}
padding_scheme 不是字符串 它是字符串数组,元素形如 "stop=8"、"0=30-30"、 "1=100-400"。写成 "padding_scheme": "" 会让 sing-box 启动失败并报类型错误。除非你清楚自己在调什么, 直接省略这个字段。
yamlproxies:
- name: "AnyTLS-10443"
  type: anytls
  server: node.yourdomain.com
  port: 10443
  password: "YOUR_ANYTLS_PASSWORD"
  sni: node.yourdomain.com
  client-fingerprint: chrome
  udp: true
  skip-cert-verify: false
AnyTLS 目前没有被广泛采纳的分享链接标准, 各客户端实现不一致。第 11 节的脚本会按 anytls:// 的常见形式拼一条出来, 认这个格式的客户端可以直接导入;不认的就用上面的 YAML / JSON 手动添加。

选哪个节点

日常上网

优先 Reality。不需要域名和证书,被封的概率最低, xtls-rprx-vision 的性能也最好。这是唯一一个「不小心暴露 IP 也很难被确认」的方案。

IP 被墙之后

切 VLESS+WS 走 Cloudflare。客户端连的是 CF 的 IP, 你的服务器 IP 不再直接暴露,被墙也照样能用。代价是延迟增加、速度受 CF 免费线路限制。

晚高峰卡顿

试 Hysteria2 或 TUIC。QUIC 对丢包的容忍度远高于 TCP。 如果反而更慢,说明本地运营商在对 UDP 做 QoS,退回 TCP 协议。

给别人用

Trojan 或 SS2022。字段少、客户端支持广, 发个链接对方扫码就能用,不用解释什么是 short_id。

手拼分享链接容易出错,尤其是 SS2022 的 base64 编码和含特殊字符的密码。 这一节装一个脚本,直接从服务端配置读出所有节点,一次生成全部链接和终端二维码。

它替你做三件事 ① 遍历配置里的全部入站,覆盖本教程的七种协议,加一个节点就自动多一条链接; ② 从 private_key 现场推导 Reality 公钥,不需要你另外保存; ③ 密码含 @ # ? & / 时自动做百分号编码, 并在终端直接打出二维码——手机对着屏幕扫一下就导入了,不用把链接复制来复制去。

11.1 安装脚本

依赖第 2 节已经装齐了 脚本用到 jq、qrencode、openssl、curl、 python3-cryptography,第 2 节那条 apt install 里都有。 真的缺了脚本启动时会自己 apt-get install 补上,不会中途报错退出。
bash /usr/local/bin/vps-share
cat > /usr/local/bin/vps-share << 'SCRIPTEOF'
#!/bin/bash
CONFIG="/etc/sing-box/config.json"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m'

# 1. 自动检查并安装依赖
MISSING_PKGS=""
for pkg in qrencode jq openssl curl python3 python3-cryptography base64; do
  command -v $pkg &>/dev/null || dpkg -s $pkg &>/dev/null || MISSING_PKGS="$MISSING_PKGS $pkg"
done
if [ -n "$MISSING_PKGS" ]; then
  echo -e "${YELLOW}正在安装缺失的依赖: $MISSING_PKGS${NC}"
  apt-get update -qq && apt-get install -y $MISSING_PKGS coreutils &>/dev/null
fi

[ -f "$CONFIG" ] || { echo -e "${RED}错误:找不到配置文件 $CONFIG${NC}"; exit 1; }

# 2. 清理 JSON 单行注释
CLEAN_JSON=$(sed -E -e 's@(^|[[:space:]])//.*$@@' "$CONFIG")

# 3. 获取服务器 IP
SERVER_IP=$(curl -s4 --max-time 5 ifconfig.me || curl -s4 --max-time 5 ip.sb)

echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN}     Sing-box 终极节点自动导出工具      ${NC}"
echo -e "${CYAN} (已修复高强度密码特殊字符的 URL 编码)  ${NC}"
echo -e "${CYAN}========================================${NC}"
echo ""

# X25519 公钥推导函数 (REALITY 必备)
derive_pubkey() {
  python3 - "$1" << 'PYEOF'
import sys, base64
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
try:
    priv_b64 = sys.argv[1].replace(\'-\', \'+\').replace(\'_\', \'/\')
    priv_b64 += \'=\' * (-len(priv_b64) % 4)
    priv_key = X25519PrivateKey.from_private_bytes(base64.b64decode(priv_b64))
    pub_bytes = priv_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
    print(base64.urlsafe_b64encode(pub_bytes).rstrip(b\'=\').decode())
except Exception:
    pass
PYEOF
}

# 核心安全组件:urlencode 函数(修复密码含 @ # ? 等符号破坏 URI 的问题)
urlencode() {
  local LC_ALL=C
  for (( i=0; i<${#1}; i++ )); do
    local c="${1:i:1}"
    case "$c" in
      [a-zA-Z0-9.~_-]) printf "$c" ;;
      *) printf '%%%02X' "'$c" ;;
    esac
  done
}

# 遍历所有入站节点。users 里有多个人的入站会被拆成「一人一条」:
#   2 个用户 → 输出 2 条记录,tag 后面各自补上 name,users 只留他自己那一个
#   只有 1 个用户的入站原样通过,tag 不变(和以前的行为完全一致)
echo "$CLEAN_JSON" | jq -c '.inbounds[]?
  | if (.users? | type) == "array" and (.users | length) > 1
    then (.users | to_entries[]) as $e
         | (.users = [$e.value]
            | .tag = "\(.tag)-\($e.value.name // "user\($e.key + 1)")")
    else .
    end' | while read -r node; do
  TYPE=$(echo "$node" | jq -r '.type // empty')
  TAG=$(echo "$node" | jq -r '.tag // "Unknown-Node"')
  PORT=$(echo "$node" | jq -r '.listen_port // empty')
  
  [ -z "$TYPE" ] && continue

  # ================= 1. VLESS =================
  if [ "$TYPE" == "vless" ]; then
    UUID=$(echo "$node" | jq -r '.users[0].uuid // empty')
    ENC_UUID=$(urlencode "$UUID")
    FLOW=$(echo "$node" | jq -r '.users[0].flow // empty')
    TRANSPORT=$(echo "$node" | jq -r '.transport.type // "tcp"')
    HAS_TLS=$(echo "$node" | jq -r '.tls.enabled // "false"')
    HAS_REALITY=$(echo "$node" | jq -r '.tls.reality.enabled // "false"')
    SERVER_NAME=$(echo "$node" | jq -r '.tls.server_name // empty')
    FP=$(echo "$node" | jq -r '.tls.utls.fingerprint // "chrome"')

    QUERY="type=${TRANSPORT}&encryption=none"

    if [ "$HAS_REALITY" == "true" ]; then
      PRIVKEY=$(echo "$node" | jq -r '.tls.reality.private_key // empty')
      PBK=$(derive_pubkey "$PRIVKEY")
      SNI=$(echo "$node" | jq -r '.tls.reality.handshake.server // empty')
      SID=$(echo "$node" | jq -r '.tls.reality.short_id[0] // empty')
      
      [ -z "$PBK" ] && { echo -e "${RED}[跳过] $TAG (VLESS):public key 推导失败${NC}"; continue; }
      QUERY="${QUERY}&security=reality&pbk=${PBK}&sni=${SNI}&sid=${SID}&fp=${FP}"
      [ "$FLOW" != "null" ] && [ -n "$FLOW" ] && QUERY="${QUERY}&flow=${FLOW}"
      HOST_ADDR=$SERVER_IP

    elif [ "$HAS_TLS" == "true" ]; then
      QUERY="${QUERY}&security=tls&sni=${SERVER_NAME}&fp=${FP}"
      [ "$FLOW" != "null" ] && [ -n "$FLOW" ] && QUERY="${QUERY}&flow=${FLOW}"
      HOST_ADDR=${SERVER_NAME:-$SERVER_IP}
    else
      QUERY="${QUERY}&security=none"
      HOST_ADDR=$SERVER_IP
    fi

    if [ "$TRANSPORT" == "ws" ]; then
      WS_PATH=$(echo "$node" | jq -r '.transport.path // "/"')
      WS_HOST=$(echo "$node" | jq -r '.transport.headers.Host // empty')
      QUERY="${QUERY}&path=$(urlencode "$WS_PATH")"
      [ "$WS_HOST" != "null" ] && [ -n "$WS_HOST" ] && QUERY="${QUERY}&host=${WS_HOST}"
    elif [ "$TRANSPORT" == "grpc" ]; then
      SVC_NAME=$(echo "$node" | jq -r '.transport.service_name // empty')
      QUERY="${QUERY}&serviceName=$(urlencode "$SVC_NAME")"
    fi

    LINK="vless://${ENC_UUID}@${HOST_ADDR}:${PORT}?${QUERY}#$(urlencode "$TAG")"
    echo -e "${GREEN}[VLESS] $TAG${NC} (传输: $TRANSPORT)"

  # ================= 2. Hysteria2 =================
  elif [ "$TYPE" == "hysteria2" ]; then
    PASS=$(echo "$node" | jq -r '.users[0].password // empty')
    ENC_PASS=$(urlencode "$PASS")
    UP=$(echo "$node" | jq -r '.up_mbps // empty')
    DOWN=$(echo "$node" | jq -r '.down_mbps // empty')
    
    OBFS_TYPE=$(echo "$node" | jq -r '.obfs?.type // empty')
    OBFS_PASS=$(echo "$node" | jq -r '.obfs?.password // empty')
    ENC_OBFS_PASS=$(urlencode "$OBFS_PASS")
    
    NODE_SNI=$(echo "$node" | jq -r '.tls.server_name // empty')
    if [ -z "$NODE_SNI" ] || [ "$NODE_SNI" == "null" ]; then
      CERT=$(echo "$node" | jq -r '.tls.certificate_path // empty')
      if [ -n "$CERT" ] && [ "$CERT" != "null" ] && [ -f "$CERT" ]; then
        NODE_SNI=$(openssl x509 -in "$CERT" -noout -subject 2>/dev/null | sed 's/.*CN\s*=\s*//' | tr -d ' ')
      fi
    fi

    QUERY=""
    [ -n "$NODE_SNI" ] && [ "$NODE_SNI" != "null" ] && QUERY="sni=${NODE_SNI}&insecure=0" || QUERY="insecure=1"
    [ -n "$UP" ] && [ "$UP" != "null" ] && [ "$UP" != "0" ] && QUERY="${QUERY}&up=${UP}"
    [ -n "$DOWN" ] && [ "$DOWN" != "null" ] && [ "$DOWN" != "0" ] && QUERY="${QUERY}&down=${DOWN}"
    
    if [ -n "$OBFS_TYPE" ] && [ "$OBFS_TYPE" != "null" ]; then
      QUERY="${QUERY}&obfs=${OBFS_TYPE}&obfs-password=${ENC_OBFS_PASS}"
    fi

    HOST_ADDR=${NODE_SNI:-$SERVER_IP}
    [ -z "$NODE_SNI" ] || [ "$NODE_SNI" == "null" ] && HOST_ADDR=$SERVER_IP

    LINK="hy2://${ENC_PASS}@${HOST_ADDR}:${PORT}?${QUERY}#$(urlencode "$TAG")"
    echo -e "${GREEN}[Hysteria2] $TAG${NC}"
    [ -n "$OBFS_TYPE" ] && [ "$OBFS_TYPE" != "null" ] && echo -e "  包含混淆: $OBFS_TYPE"

  # ================= 3. Trojan =================
  elif [ "$TYPE" == "trojan" ]; then
    PASS=$(echo "$node" | jq -r '.users[0].password // empty')
    ENC_PASS=$(urlencode "$PASS")
    SNI=$(echo "$node" | jq -r '.tls.server_name // empty')
    TRANSPORT=$(echo "$node" | jq -r '.transport.type // "tcp"')
    
    QUERY="sni=${SNI:-$SERVER_IP}"
    
    if [ "$TRANSPORT" == "ws" ]; then
       WS_PATH=$(echo "$node" | jq -r '.transport.path // "/"')
       WS_HOST=$(echo "$node" | jq -r '.transport.headers.Host // empty')
       QUERY="${QUERY}&type=ws&path=$(urlencode "$WS_PATH")"
       [ "$WS_HOST" != "null" ] && [ -n "$WS_HOST" ] && QUERY="${QUERY}&host=${WS_HOST}"
    elif [ "$TRANSPORT" == "grpc" ]; then
       SVC_NAME=$(echo "$node" | jq -r '.transport.service_name // empty')
       QUERY="${QUERY}&type=grpc&serviceName=$(urlencode "$SVC_NAME")"
    fi

    LINK="trojan://${ENC_PASS}@${SNI:-$SERVER_IP}:${PORT}?${QUERY}#$(urlencode "$TAG")"
    echo -e "${GREEN}[Trojan] $TAG${NC} (传输: $TRANSPORT)"

  # ================= 4. Shadowsocks =================
  elif [ "$TYPE" == "shadowsocks" ]; then
    METHOD=$(echo "$node" | jq -r '.method // empty')
    # 2022 系列多用户时,客户端密码是「服务端 PSK:用户 PSK」两段拼起来
    PASS=$(echo "$node" | jq -r 'if (.users? | type) == "array" and (.users | length) > 0
      then "\(.password):\(.users[0].password)" else .password end // empty')
    
    B64_CRED=$(echo -n "${METHOD}:${PASS}" | base64 | tr -d '\n\r' | tr '+/' '-_' | tr -d '=')
    LINK="ss://${B64_CRED}@${SERVER_IP}:${PORT}#$(urlencode "$TAG")"
    echo -e "${GREEN}[Shadowsocks] $TAG${NC}"

  # ================= 5. TUIC v5 =================
  elif [ "$TYPE" == "tuic" ]; then
    UUID=$(echo "$node" | jq -r '.users[0].uuid // empty')
    PASS=$(echo "$node" | jq -r '.users[0].password // empty')
    ENC_UUID=$(urlencode "$UUID")
    ENC_PASS=$(urlencode "$PASS")
    CC=$(echo "$node" | jq -r '.congestion_control // "bbr"')
    ALPN=$(echo "$node" | jq -r 'if (.tls.alpn|type)=="array" then (.tls.alpn|join(",")) else (.tls.alpn // "h3") end')
    NODE_SNI=$(echo "$node" | jq -r '.tls.server_name // empty')
    if [ -z "$NODE_SNI" ] || [ "$NODE_SNI" == "null" ]; then
      CERT=$(echo "$node" | jq -r '.tls.certificate_path // empty')
      if [ -n "$CERT" ] && [ "$CERT" != "null" ] && [ -f "$CERT" ]; then
        NODE_SNI=$(openssl x509 -in "$CERT" -noout -subject 2>/dev/null | sed 's/.*CN\s*=\s*//' | tr -d ' ')
      fi
    fi
    if [ -n "$NODE_SNI" ] && [ "$NODE_SNI" != "null" ]; then
      QUERY="congestion_control=${CC}&alpn=$(urlencode "$ALPN")&sni=${NODE_SNI}&udp_relay_mode=native"
      HOST_ADDR=$NODE_SNI
    else
      QUERY="congestion_control=${CC}&alpn=$(urlencode "$ALPN")&allow_insecure=1&udp_relay_mode=native"
      HOST_ADDR=$SERVER_IP
    fi
    LINK="tuic://${ENC_UUID}:${ENC_PASS}@${HOST_ADDR}:${PORT}?${QUERY}#$(urlencode "$TAG")"
    echo -e "${GREEN}[TUIC v5] $TAG${NC}"

  # ================= 6. AnyTLS =================
  elif [ "$TYPE" == "anytls" ]; then
    PASS=$(echo "$node" | jq -r '.users[0].password // empty')
    ENC_PASS=$(urlencode "$PASS")
    SNI=$(echo "$node" | jq -r '.tls.server_name // empty')
    FP=$(echo "$node" | jq -r '.tls.utls.fingerprint // "chrome"')
    HOST_ADDR=${SNI:-$SERVER_IP}
    if [ -n "$SNI" ] && [ "$SNI" != "null" ]; then
      QUERY="sni=${SNI}&insecure=0&fp=${FP}"
    else
      QUERY="insecure=1&fp=${FP}"
    fi
    LINK="anytls://${ENC_PASS}@${HOST_ADDR}:${PORT}?${QUERY}#$(urlencode "$TAG")"
    echo -e "${GREEN}[AnyTLS] $TAG${NC}"

  else
    echo -e "${YELLOW}[忽略] $TAG:暂不支持 '$TYPE' 协议生成分享链接。${NC}"
    echo ""
    continue
  fi

  # 输出链接与二维码
  echo "$LINK"
  echo -e "${CYAN}扫码:${NC}"
  qrencode -t ansiutf8 "$LINK"
  echo ""

done

echo -e "${CYAN}========================================${NC}"
echo -e "${CYAN}            节点导出完毕                ${NC}"
echo -e "${CYAN}========================================${NC}"
SCRIPTEOF

chmod +x /usr/local/bin/vps-share
公钥是算出来的,不用另存 Reality 用 X25519:公钥 = 私钥标量 × 基点,由私钥唯一决定。 所以脚本读到 private_key 就能算出客户端要填的 public_key, 结果与第 5 节 sing-box generate reality-keypair 给你的那一行逐字一致。 反过来说,私钥泄露就等于公钥泄露——公钥本来就是公开的, 真正要守住的只有私钥,按第 14 节的方式离线保管。

11.2 使用

装完就在 PATH 里了,在哪个目录下都能直接敲 vps-share 回车。 它读 /etc/sing-box/config.json(含 // 注释也能读), 公网 IP 用 ifconfig.me / ip.sb 自动探测。

bash
# 打印全部节点的链接 + 终端二维码
vps-share

# 只要纯链接:去掉颜色码,挑出链接行,导出成文件
vps-share | sed 's/\x1b\[[0-9;]*m//g' | \
  grep -E '^(vless|trojan|ss|hy2|tuic|anytls)://' > /root/nodes.txt

# 改完配置重启后再跑一次,链接会跟着变
systemctl restart sing-box && vps-share
输出(节选)
========================================
     Sing-box 终极节点自动导出工具
 (已修复高强度密码特殊字符的 URL 编码)
========================================

[VLESS] reality-in-me (传输: tcp)
vless://11111111-2222-3333-4444-555555555555@203.0.113.10:443?type=tcp
&encryption=none&security=reality&pbk=kQx7NfP0…9dZsA1
                                      ↑ 脚本用 private_key 现场算出来的,不用你自己填
&sni=updates.cdn-apple.com&sid=a1b2c3d4&fp=chrome&flow=xtls-rprx-vision#reality-in-me
扫码:
  █▀▀▀▀▀█ ▀▄█ ▄▀▄ █▀▀▀▀▀█
  █ ███ █ █▀▄▀▄▀█ █ ███ █    ← 手机对着屏幕扫这个就导入了
  █▀▀▀▀▀█ ▀ ▀▄▀ ▄ █▀▀▀▀▀█

[VLESS] reality-in-family (传输: tcp)
        ↑ 同一个 443 入站里的第二个用户,自动单独出一条
vless://66666666-7777-8888-9999-000000000000@203.0.113.10:443?type=tcp
&encryption=none&security=reality&pbk=kQx7NfP0…9dZsA1
&sni=updates.cdn-apple.com&sid=a1b2c3d4&fp=chrome&flow=xtls-rprx-vision#reality-in-family
扫码:
  (略)

[Trojan] trojan-in (传输: tcp)
trojan://p%40ss%3Fw0rd%231@node.yourdomain.com:9443?sni=node.yourdomain.com#trojan-in
         ↑ 配置里写的是 p@ss?w0rd#1,@ ? # 已自动做百分号编码

[Shadowsocks] ss-in
ss://MjAyMi1ibGFrZTMtYWVzLTI1Ni1nY206WU9VUl9TU19QU0tfQkFTRTY0@203.0.113.10:80#ss-in
     ↑ SIP002 要求的 URL-safe base64,末尾不带 =

…(其余四个协议同理)

========================================
            节点导出完毕
========================================
一个入站挂多个用户时,会一人一条全部导出 脚本先把 users 里的人拆开,每个用户单独出一条链接和二维码, 节点名是「入站 tag + 用户 name」,例如 reality-in-me、 reality-in-family、reality-in-guest, 直接把对应那条发给对应的人即可,不用手改 UUID。 users 里只有一个人的入站原样导出,tag 不变。 第 8 节方案 B 是三个独立入站,各自导出一条,行为和以前一样。
Shadowsocks 2022 多用户的密码是两段 SS2022 的服务端有一个全局 PSK(入站的 password), 每个用户还有自己的 PSK(users[].password)。 客户端要填的是两段用冒号拼起来的 服务端PSK:用户PSK,只填一段一定连不上。 脚本已经按这个规则拼好了,单用户(只有全局 password、没有 users)的写法不受影响。
这份输出等于把服务器交出去 每条链接里都含完整凭据(UUID / 密码 / PSK),拿到就能直接连。 不要贴进群、不要传网盘、不要截图发朋友圈; 导出的 /root/nodes.txt 发完就 rm 掉。 二维码同理——它就是链接本身,别对着屏幕录屏或直播。
脚本里三处和老版本不一样的地方已修正

这个脚本沿用得比较久,本次只动了三处,其余逐字未改:

位置老写法的问题现在的写法
剥离配置注释
CLEAN_JSON=
老写法把整行里第一个 // 之后全部删掉,只对 http:// 做了例外。 于是密码里只要出现 //,那一行就被截断, jq 直接报 parse error,整个工具一个节点都出不来。 只删「行首或空格后」的 //。 注释照样剥干净,密码中间的 // 不受影响, https:// 也不用再开例外。
SS 链接的 userinfo
B64_CRED=
用的是标准 base64,末尾带 = 填充。 SIP002 要求的是 URL-safe 且不带填充的 base64, 部分客户端会把 = 当成参数分隔符或直接拒收。 加一步 tr '+/' '-_' | tr -d '=', 转成 SIP002 要求的形式,解码回去与配置里的 method:password 逐字一致。
遍历入站
jq -c '.inbounds[]?'
每个入站只取 users[0], 所以一个入站挂三个人时只导出第一个人, 另外两个人得手动改 UUID。 在 jq 里先把多用户入站拆成一人一条, 再走原来的逻辑。users 始终只有一个元素, 所以下面所有 users[0] 的读法一行都不用改。
顺手澄清一个常见说法 经常看到「SS 链接里的 + 和 / 会让客户端解析失败」。 对 SS2022 来说这两个字符根本不会出现: PSK 本身就在 base64 字母表内,再套一层 base64 后 只可能产出 A–Z a–z 0–9,第 62/63 号字符 (+ / /)需要原文里有 > ? ~ 才会出现。 实际有偏差的只有末尾那个 =。上面那行改动修的就是它。

配置校验通过之后启动服务,并确认配置里写了的端口都真的在监听。 这一节也是日后维护时最常回来看的一节。

启动并设为开机自启
bash
# 校验放在启动前,配置有错就不要动服务
sing-box check -c /etc/sing-box/config.json || { echo "配置有错,先修配置"; exit 1; }

systemctl enable --now sing-box
systemctl --no-pager status sing-box
输出(节选)
● sing-box.service - sing-box service
     Loaded: loaded (/usr/lib/systemd/system/sing-box.service; enabled)
     Active: active (running) since Fri 2026-09-04 10:12:33 UTC; 3s ago
       Docs: https://sing-box.sagernet.org
   Main PID: 8421 (sing-box)
      Tasks: 9 (limit: 1131)
     Memory: 18.4M
        CPU: 121ms
     CGroup: /system.slice/sing-box.service
             └─8421 /usr/bin/sing-box -D /var/lib/sing-box -C /etc/sing-box run
路径显示成 /lib/systemd/system/ 也是对的 deb 包把单元文件装在 /usr/lib/systemd/system/sing-box.service, 而现在的 Debian/Ubuntu 上 /lib 就是指向 /usr/lib 的符号链接, 旧版 systemd 会按 /lib/... 的形式打印同一个文件。 enabled 后面各版本还会再跟一段 vendor preset: enabled 或 preset: enabled(措辞随 systemd 版本变), 那只是发行版的默认策略,跟当前是否开机自启无关 —— 真正管用的是前面那个 enabled。 再往后 Tasks / Memory / CPU 的数字每台机器都不同, 平时只需盯 Active: 那一行。
不是 active (running) 就先看日志 activating (auto-restart) 表示启动后立刻崩了、systemd 在反复重试; failed 表示彻底起不来。两种情况都执行 journalctl -u sing-box -n 50 --no-pager 看真实原因, 不要反复 restart。
确认端口在监听
bash
# TCP:443 / 8443 / 9443 / 80 / 10443
ss -tlnp | grep sing-box

# UDP:5678(Hysteria2)/ 7788(TUIC)
ss -ulnp | grep sing-box
输出
LISTEN 0  4096  *:443    *:*  users:(("sing-box",pid=8421,fd=9))
LISTEN 0  4096  *:8443   *:*  users:(("sing-box",pid=8421,fd=10))
LISTEN 0  4096  *:9443   *:*  users:(("sing-box",pid=8421,fd=11))
LISTEN 0  4096  *:80     *:*  users:(("sing-box",pid=8421,fd=12))
LISTEN 0  4096  *:10443  *:*  users:(("sing-box",pid=8421,fd=13))
UNCONN 0  0     *:5678   *:*  users:(("sing-box",pid=8421,fd=14))
UNCONN 0  0     *:7788   *:*  users:(("sing-box",pid=8421,fd=15))
你的机器上多半只有 *:443 一行,这不是出错 上面这份输出是「第 10 节把 7 个协议全开了」的样子。 按第 8 节那份配置直接部署的话,服务端只有一个 443 入站, 所以 ss -tlnp 只会看到 *:443,ss -ulnp 一行都没有—— 这时候就是对的。以你 config.json 里实际写了几个 inbounds 为准,每开一个协议,这里就多一行。
配置里写了、这里却没出现 服务是 running 但某个已经写进配置的端口不在列表里, 几乎都是端口被别的进程占了。 用 ss -tlnp | grep ':80\b' 看是谁占的(常见是 nginx / apache / caddy), systemctl disable --now nginx 让位,或把该协议换个端口。
从本机自测一次

先排除服务端问题,再去查客户端和防火墙:

bash
# TLS 端口能否正常握手(应看到证书信息)
echo | openssl s_client -connect 127.0.0.1:9443 \
  -servername node.yourdomain.com 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# Reality 端口:应当返回被借用网站的证书,看起来跟真访问那个站一样
echo | openssl s_client -connect 127.0.0.1:443 \
  -servername updates.cdn-apple.com 2>/dev/null \
  | openssl x509 -noout -subject
Reality 端口返回别人的证书是正常的 这正是 Reality 的原理:不带正确 short_id 的握手会被透明转发 给真实的 updates.cdn-apple.com,所以主动探测者看到的与真访问那个站完全一致。 这也是它难以被识别的原因。

日常命令

操作命令说明
启动 / 停止
systemctl start|stop sing-box
—
重启
systemctl restart sing-box
改完配置用这个,现有连接会断开
重载
systemctl reload sing-box
发 SIGHUP 让 sing-box 重读配置。它会重建全部入站,现有连接同样会断——不是「平滑重载」,只是省了一次进程重启
状态
systemctl --no-pager status sing-box
看是否 running、内存占用、最近几行日志
开机自启
systemctl enable|disable sing-box
官方 deb 装完默认没有 enable,要手动开
实时日志
journalctl -u sing-box -f
Ctrl+C 退出
校验配置
sing-box check -C /etc/sing-box
每次改配置后、重启前都跑一次。用 -C 目录跟 systemd 实际加载的一致
格式化配置
sing-box format -c /etc/sing-box/config.json -w
缩进对齐、去掉空字段;注释会保留
看版本
sing-box version
确认 ≥ 1.14(本教程的配置用到 1.14 才有的字段)
关于 reload 的取舍 既然两者都会断连接,改配置时直接用 restart 更直观, 而且能顺带确认「重启后还能正常起来」——避免下次机器重启时才发现配置有问题。 reload 的唯一优势是不重置 Restart= 的失败计数。

升级 sing-box

bash
# ① 备份配置(升级偶尔会带来字段变更)
#    备份名千万别以 .json 结尾,原因见下面那个红框
cp /etc/sing-box/config.json /etc/sing-box/config.json.$(date +%F)

# ② 只升级 sing-box 这一个包
#    注意不要写 apt upgrade -y sing-box —— upgrade 子命令不接受包名参数,
#    实际效果是升级【全部】软件包,可能连内核一起换掉。
apt-get update
apt-get install --only-upgrade -y sing-box

# ③ 新版本校验配置。有字段废弃时这里会直接报出来
#    用 -C 目录(而不是 -c 单文件),跟 systemd 实际加载的完全一致
sing-box version
sing-box check -C /etc/sing-box

# ④ 没问题再重启
systemctl restart sing-box && systemctl --no-pager status sing-box
/etc/sing-box/ 里所有 .json 文件都会被合并成一份配置 官方 systemd 单元里是 sing-box -D /var/lib/sing-box -C /etc/sing-box run—— -C 是「目录」而不是单文件,sing-box 会把该目录下 每一个 .json 都读进来合并。 实测:目录里放两个各含一个入站的 .json,启动后两个端口同时在监听。
所以备份、试验性副本一律不要用 .json 结尾。 config.json.2026-09-05、config.json.bak 都是安全的(实测不会被加载), config-old.json、backup.json 会被悄悄合进去。 更稳妥的做法是把备份放到 /etc/sing-box/ 之外,比如 /root/sb-backup/。
第 ② 步可能会问你留哪份配置文件 /etc/sing-box/config.json 被官方登记成了 dpkg conffile(第 5 节讲过), 官方那份默认配置一旦在版本之间变过,dpkg 就会停下来问 *** config.json (Y/I/N/O/D/Z) [default=N] ?。 直接回车(默认 N = 保留你自己的)。 选 Y 会把你的配置换成官方那份 8080 演示配置,节点当场全废。
大版本升级先看变更说明 1.11 → 1.12 就废弃过一批 DNS 与路由字段的旧写法。 跨大版本升级前扫一眼 github.com/SagerNet/sing-box/releases, 并且保留上一步的配置备份——真出问题时 apt-get install -y sing-box=旧版本号 可以退回去。

连不上的时候,按「服务端 → 防火墙 → 客户端」的顺序查, 一层一层排除,不要跳着猜。绝大多数问题在前两层就能定位。

13.1 日志在哪

默认没有 /var/log/sing-box.log 这个文件 官方 deb 包的 systemd 单元把 sing-box 的输出交给 journald, 配置里的 log 段如果没写 output, 就不会产生任何日志文件。 tail -f /var/log/sing-box.log 只会得到「No such file or directory」。
bash
# 实时跟踪(最常用)
journalctl -u sing-box -f

# 最近 100 行
journalctl -u sing-box -n 100 --no-pager

# 只看本次启动之后 / 只看错误级别
journalctl -u sing-box -b --no-pager
journalctl -u sing-box -p err --no-pager

# 按时间段捞
journalctl -u sing-box --since "1 hour ago" --no-pager
journalctl -u sing-box --since "2026-09-04 10:00" --until "2026-09-04 11:00"
确实想要日志文件(配置 + 轮转)

在配置的 log 段加 output,sing-box 才会写文件:

jsoncconfig.json → log
"log": {
  "disabled": false,
  "level": "info",
  "timestamp": true,
  // 写了 output 之后日志【只】进文件,journald 里就看不到了
  "output": "/var/log/sing-box/sing-box.log"
}
bash
# 目录要先建好,否则启动失败
# 属主必须给 sing-box:服务以 User=sing-box 运行,root 的目录它建不了日志文件
mkdir -p /var/log/sing-box
chown sing-box:sing-box /var/log/sing-box

# 加轮转,否则 debug 级别下几天就能吃掉几个 G
cat > /etc/logrotate.d/sing-box <<'LREOF'
/var/log/sing-box/*.log {
    daily
    rotate 7
    compress
    missingok
    notifempty
    copytruncate
}
LREOF

systemctl restart sing-box
tail -f /var/log/sing-box/sing-box.log
这里用 copytruncate 而不是默认的 create, 因为 sing-box 不会在轮转后重开文件句柄——用 create 会导致轮转后日志写进已删除的 inode, 表现为「日志文件永远是空的」。
做过第 8 节「systemd 加固」的机器,还要改一行 那份 drop-in 里有 ProtectSystem=strict,它把整个文件系统变成只读,只放行 ReadWritePaths= 里列出的目录。 上面刚建好的 /var/log/sing-box 不在里面,直接 restart 会起不来。 把 /etc/systemd/system/sing-box.service.d/99-hardening.conf 里那一行改成 ReadWritePaths=/var/lib/sing-box /var/log/sing-box(两个路径用空格隔开), 再 systemctl daemon-reload && systemctl restart sing-box。 没做过第 8 节加固的机器不用管这条。

13.2 排查顺序

决策树
  客户端连不上
      │
      ├─ ① 服务端起来了吗?
      │     systemctl status sing-box
      │     └─ 不是 running,就看 journalctl -u sing-box -n 50,对照 13.3 的表
      │
      ├─ ② 端口在监听吗?
      │     ss -tlnp | grep sing-box
      │     └─ 少了端口,多半是端口被占用,换端口或停掉占用进程
      │
      ├─ ③ 外网能到端口吗?
      │     nc -vz 服务器IP 端口
      │     └─ 超时,就是防火墙或安全组没放行,回第 3 节
      │
      ├─ ④ TLS 握手成功吗?
      │     openssl s_client -connect IP:端口 -servername 域名
      │     └─ 证书报错,是域名或证书路径不对,回第 7 节
      │
      └─ ⑤ 前面都正常但还是用不了
            客户端参数对不上,回第 10 节逐字核对:
            UUID / 密码 / 端口 / server_name / short_id / Reality 公钥
第 ③ 步有两个前提 这条 nc 在你自己的电脑上执行,不是在服务器上—— 在服务器上连自己的端口永远是通的,测不出防火墙问题。 另外 nc -vz 只能测 TCP: Hysteria2、TUIC 走 UDP,nc -vzu 的结果不可信(UDP 无连接, 不回包也可能显示成功)。这两个协议直接跳到第 ⑤ 步,用客户端实际连一次判断。

13.3 常见报错对照

日志 / 现象原因处理
decode config: invalid character JSON 语法错:多/少逗号,或占位符没换(尤其数字位置) sing-box check 会给出行号;检查数组末尾多余的逗号
parse padding scheme / 类型错误 AnyTLS 写了 "padding_scheme": "" 该字段是字符串数组,直接删掉整行
open certificate: no such file 证书路径不对,或 acme.sh 还没 --install-cert ls -l /etc/sing-box/certs/ 确认;回第 7 节
bad password: not base64 SS2022 密码不是 32 字节 base64 openssl rand -base64 32 重新生成,两端同步改
address already in use 端口被别的进程占了 ss -tlnp | grep ':端口\b' 找到占用者
parse private key Reality 私钥粘漏或粘错(把 PublicKey 填成了 private_key) 重新 sing-box generate reality-keypair,公私钥别混
能连上,网页打不开 WS 的 path 或 early-data 参数两端不一致 回第 10 节核对三个 ws 字段
能上网,但 DNS 解析很慢 DNS 走了被污染或超时的服务器 确认 strategy 与 default_domain_resolver 已配置
部分网站走了直连 客户端拿到了 HTTPS/SVCB 记录里的 ECH / Alt-Svc 在客户端的 dns.rules 里加一条 { "query_type": "HTTPS", "action": "reject" }。 服务端不做这件事——服务端一条 reject 都不留,理由见第 8 节
UDP 协议连不上,TCP 正常 防火墙只放了 TCP,或运营商 QoS 掉 UDP ufw allow 5678/udp;仍不行就换 TCP 协议
Hysteria2 速度被限死 服务端或客户端写了 up_mbps/down_mbps,切到了 Brutal 两端都删掉这两个字段,退回 BBR
规则集下载失败反复重试 访问 GitHub 受限 改用 local 类型 rule_set,或手动下载 .srs
用几天后突然全断 IP 被墙 ping 通但 TCP 不通即为封 IP;换 IP 或切 CDN 节点(第 6 节)

13.4 判断是不是 IP 被墙

bash在本地电脑执行,不是在服务器上
# ① ICMP 通不通(有些机房本身就禁 ping,通不过不能直接判定被墙)
ping -c 4 YOUR_SERVER_IP

# ② TCP 端口通不通 —— 这个才是关键
nc -vz YOUR_SERVER_IP 443

# ③ 换个端口试。只有个别端口不通 = 端口被阻断,不是 IP 被墙
nc -vz YOUR_SERVER_IP 22222
pingTCP 443SSH 端口结论
通通通网络正常,问题在配置或客户端
通不通通443 端口被针对性阻断 → 换端口
通不通不通IP 被墙 → 换 IP,或切 CDN 节点
不通通通机房禁 ICMP,正常
不通不通不通先去服务商后台看机器是否还在运行
被墙之后 两条路:换 IP,或者切 Cloudflare 反代—— 后者不用碰服务端,只改客户端一行,具体做法在第 6 节末尾「IP 被封之后」。 大多数服务商也支持付费换 IP(Vultr 可以先 snapshot 再重建)。 换 IP 后记得同步:DNS 的 A 记录、客户端里填 IP 的节点(Reality、SS2022)、 以及防火墙里按 IP 放行的规则,还有住宅落地供应商那边绑定的来源 IP(见第 8 节)。

服务端搭完只是一半。这一节给各平台客户端的选择、 一份可直接用的完整客户端配置,以及一张日常维护清单。

14.1 客户端选择

平台推荐支持全部七协议备注
Windowssing-box 官方 CLI + GUI 前端 / mihomo 系客户端是要 AnyTLS 就必须用 1.12+ 的内核
macOSsing-box(App Store)是官方出品,内核版本跟得最紧
iOS / iPadOSsing-box是需要非中国区 Apple ID
Androidsing-box for Android是GitHub Releases 直接下 APK
Linux 桌面sing-box CLI(tun 模式)是与服务端同一个二进制
路由器取决于固件,多为 mihomo 内核部分AnyTLS / TUIC 支持看固件版本
内核版本决定能用哪些协议 AnyTLS 需要 sing-box 1.12+。用旧内核的客户端导入 AnyTLS 节点会直接报 unknown outbound type。TUIC v5 与 Hysteria2 的要求相对宽松, 但 1.8 以下的客户端建议直接升级,配置字段差异太多。

14.2 完整客户端配置

把第 10 节各协议的出站片段填进 outbounds, 就得到一份带 tun 全局代理和国内外分流的可用配置。

展开完整客户端 config.json(tun + 分流 + 节点选择)
jsonc 客户端 · config.json
{
  "log": { "level": "warn", "timestamp": true },

  // ── DNS:国内域名用国内 DNS 直连解析,其余走代理解析防污染 ──
  "dns": {
    "servers": [
      { "tag": "proxy-dns",  "type": "tls", "server": "1.1.1.1", "detour": "节点选择" },
      // local-dns 这行千万别加 "detour": "direct",会启动失败,原因见下面红框
      { "tag": "local-dns",  "type": "udp", "server": "223.5.5.5" }
    ],
    "rules": [
      { "rule_set": ["geosite-cn"], "action": "route", "server": "local-dns" }
    ],
    "final": "proxy-dns",
    "strategy": "prefer_ipv4"
  },

  // ── 入站:tun 全局接管 ──
  "inbounds": [
    {
      "type": "tun",
      "tag": "tun-in",
      "address": ["172.19.0.1/30", "fdfe:dcba:9876::1/126"],
      "auto_route": true,
      "strict_route": true,
      "stack": "mixed"
    },
    {
      // 给不走 tun 的程序留一个手动代理口
      "type": "mixed",
      "tag": "mixed-in",
      "listen": "127.0.0.1",
      "listen_port": 7890
    }
  ],

  // ── 规则集下载用的 HTTP 客户端(1.14.0 起必须显式声明,见下方说明)──
  "http_clients": [
    { "tag": "rs-client", "detour": "节点选择" }
  ],

  // ── 出站 ──
  "outbounds": [
    {
      "type": "selector",
      "tag": "节点选择",
      // 这里列出下面实际存在的节点 tag
      "outbounds": ["自动选择", "Reality-443", "Trojan-9443", "Hysteria2-5678"],
      "default": "自动选择"
    },
    {
      "type": "urltest",
      "tag": "自动选择",
      "outbounds": ["Reality-443", "Trojan-9443", "Hysteria2-5678"],
      "url": "https://www.gstatic.com/generate_204",
      "interval": "3m",
      "tolerance": 50
    },

    // ↓↓↓ 把第 10 节的出站片段粘到这里 ↓↓↓
    // { "tag": "Reality-443",    "type": "vless",     ... },
    // { "tag": "Trojan-9443",    "type": "trojan",    ... },
    // { "tag": "Hysteria2-5678", "type": "hysteria2", ... },

    { "tag": "direct", "type": "direct" }
  ],

  // ── 路由分流 ──
  "route": {
    "rules": [
      { "action": "sniff" },
      { "protocol": "dns", "action": "hijack-dns" },
      // 局域网与本机地址直连
      { "ip_is_private": true, "action": "route", "outbound": "direct" },
      // 国内 DNS 服务器本身直连(放在规则集前面,第一次开、规则集还没下好时也生效)
      { "ip_cidr": ["223.5.5.5/32"], "action": "route", "outbound": "direct" },
      // 国内域名与国内 IP 直连
      { "rule_set": ["geosite-cn", "geoip-cn"], "action": "route", "outbound": "direct" },
      // 广告拦截(可选,不需要就删掉这条和对应 rule_set)
      { "rule_set": ["geosite-category-ads-all"], "action": "reject" }
    ],
    "rule_set": [
      {
        "tag": "geosite-cn", "type": "remote", "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-geolocation-cn.srs",
        "update_interval": "7d"
      },
      {
        "tag": "geoip-cn", "type": "remote", "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs",
        "update_interval": "7d"
      },
      {
        "tag": "geosite-category-ads-all", "type": "remote", "format": "binary",
        "url": "https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-category-ads-all.srs",
        "update_interval": "7d"
      }
    ],
    "final": "节点选择",
    "auto_detect_interface": true,
    // 上面那个 http_clients 的 tag:规则集就从这里下载
    "default_http_client": "rs-client",
    "default_domain_resolver": { "server": "local-dns" }
  },

  "experimental": {
    "cache_file": { "enabled": true },
    // 面板:可以在浏览器里切节点、看连接和实时速率
    "clash_api": {
      "external_controller": "127.0.0.1:9090",
      "default_mode": "rule"
    }
  }
}
国内 DNS 那行不要加 "detour": "direct" 很多老教程会给国内 DNS 服务器写 "detour": "direct",想让它「绕开路由直连」。 在 1.14 上这会直接启动失败: start dns/udp[local-dns]: detour to an empty direct outbound makes no sense ——指向一个空的 direct 出站本来就等于什么都没做,所以新版本干脆拒绝启动。 正确做法就是上面这样:不写 detour, 让这条查询走正常路由,再用 route.rules 里那条 "ip_cidr": ["223.5.5.5/32"] 把它钉在直连上。 换成别的国内 DNS(如 119.29.29.29)时,记得把这条 ip_cidr 一起改。
为什么要单独写一条 ip_cidr,geoip-cn 不是已经覆盖了吗 覆盖了,但只在规则集下载完之后。第一次开客户端时本地还没有规则集缓存, geoip-cn 是空的,国内 DNS 的查询会落到 route.final 上、绕一趟代理出去。 单独那条 ip_cidr 不依赖任何下载,第一次开就生效。 相反,proxy-dns 保留 detour: "节点选择" 是对的: 它指向的是真实节点,不是空的 direct。
external_controller 必须留在 127.0.0.1 这是 Clash 面板的控制接口,能切节点、看全部连接。 官方文档对它的原话是:如果监听在 0.0.0.0 上, 务必设置 secret。 这里写 127.0.0.1:9090 就是只允许本机访问,所以不用 secret; 一旦你改成 0.0.0.0:9090 或 :9090, 等于把「随意改你代理配置」的权限开给整个网络,那时 secret 不是可选项。
组里列的 tag 必须和粘进去的节点逐字一致 selector.outbounds、urltest.outbounds 里的名字, 必须与你实际粘进去的节点 tag 一个字都不差。 少列一个:不报错,只是那个节点在面板里选不到。 多列一个(比如照抄了上面的骨架却没粘节点进去): sing-box check 照样通过, 启动时才 FATAL,而且报错指向的是组、不是缺的那个节点——实测原文:
start service: dependency[Reality-443] not found for outbound[自动选择]
所以粘完节点后,把这两处成员表和下面的节点 tag 对着数一遍, 这一步不能靠 check 帮你兜。
规则集为什么不再写 download_detour 官方 1.14.0 的废弃清单里写明:download_detour 已废弃, 1.16.0 移除,改用 http_client;同时 「不声明任何 HTTP 客户端、隐式走默认出站」这个老行为也一并废弃了。 所以上面用的是顶层 http_clients + route.default_http_client 这一对。实测这两种老写法启动时各会打一行 WARN: legacy `download_detour` … is deprecated in sing-box 1.14.0 与 implicit default HTTP client using default outbound for remote rule-sets is deprecated …; 按上面写则一行警告都没有。
节点还没通的时候,客户端会直接起不来(不是「起来了但没规则」) rs-client 的 detour 指向 "节点选择", 意思是规则集通过代理下载。所以第一次启动、或者节点参数填错时, 下载失败会让 sing-box 整个拒绝启动。实测报错长这样(三个规则集各一段):
FATAL start service: (initialize rule-set[0]: initial rule-set: geosite-cn: Get "https://raw.githubusercontent.com/…": reality verification failed | …)
注意这行字面上在说「规则集下载不下来」,真正的原因是节点连不上—— 很容易让人跑去改规则集的 URL。check 这一步是通过的,看不出问题。
两个办法:把 rs-client 的 detour 那一行整行删掉 (不写就是走正常路由直连下载,实测同一份配置立刻 0 FATAL 起来), 等 cache.db 里存下规则集之后再加回去; 或者给每个 rule_set 补一行 initial_path (1.14.0 新增,指向一份本地 .srs)—— 实测补上之后,即使节点完全不通也照样 0 FATAL / 0 ERROR 启动, 这是长期最省事的写法。
别把它改成 "detour": "direct" 这是上一条里最容易踩的坑:http_clients 的 detour 和 DNS 的一样受那条限制。实测把它改成 "direct" 后 check 仍然通过,但启动时每个规则集都会报 initial rule-set: … detour to an empty direct outbound makes no sense 并直接 FATAL。要直连就是删掉整行,不是填 "direct"。
验证标准 客户端面板显示已连接、延迟数值正常, 浏览器访问 ip.sb 显示服务器所在地区的 IP。 延迟正常但 IP 仍是本地的,说明分流规则把目标站点判成了国内。

14.3 日常维护清单

每月

每季度

安全底线

出问题前先备份

bash一条命令打包关键文件
tar -czf /root/sing-box-backup-$(date +%F).tar.gz \
  /etc/sing-box/config.json \
  /etc/sing-box/certs \
  /root/.acme.sh/account.conf 2>/dev/null

echo "备份完成,记得下载到本地:"
ls -lh /root/sing-box-backup-*.tar.gz
备份文件含私钥,不要留在服务器上 下载到本地后 rm 掉服务器上的副本。 用 scp -P 22222 root@服务器IP:/root/sing-box-backup-*.tar.gz ./ 拉到本地。

14.4 参考

官方文档 sing-box.sagernet.org 是唯一权威来源。 配置字段在版本间会变,遇到本教程与官方文档不一致时以官方文档为准, 并注意查看文档右上角的版本切换——不同版本的字段说明差异很大。
资源位置用途
配置字段手册sing-box.sagernet.org/configuration/查字段类型与默认值
版本变更github.com/SagerNet/sing-box/releases升级前看废弃字段
规则集github.com/SagerNet/sing-geosite / sing-geoip可用的 .srs 列表
迁移指南sing-box.sagernet.org/migration/跨大版本升级时的字段对照

到这里,七个协议的节点、证书自动续期、分享链接生成、 链式中转和排查手段都已就位。日后只需要按 14.3 的清单定期维护。

>
v20261016-v104