这套架构我已经稳定跑了一段时间:只对外开一个 443 端口,由 Caddy 负责 TLS 和路径分流,Xray 全部监听 Unix domain socket(不暴露任何公网端口),同一个入口同时承载 REALITY、XHTTP、“特殊出口落地节点”、博客静态站和订阅服务。
客户端拿到一条订阅链接,就能自动看到 流量、到期时间、剩余额度,还能按需在普通节点和落地节点之间切换。
这篇文章把整套东西掰开揉碎讲一遍。所有 IP、域名、UUID、密钥、Token、账号密码我都换成了占位符,你照着替换成自己的即可。
一、先说架构:数据包到底怎么走
客户端 │ TLS :443(REALITY / XHTTP / 普通浏览器) │ ▼ Caddy layer4 tcp/:443 │ PROXY protocol v2(转发到 Unix socket) ▼ Xray REALITY 入站(@xray-reality.sock) ├── 是 REALITY 客户端 ──► 直接代理出站 └── 不是 ──► fallback(带 PROXY v2) │ ▼ Caddy HTTP 服务(@caddy-http.sock,在这里终止 TLS) ├── /xhttp/* ──► Xray XHTTP 入站(普通节点) ├── /xhttp-exit-a/* ──► Xray XHTTP 入站 ──► socks 出站 A(落地) ├── /xhttp-exit-b/* ──► Xray XHTTP 入站 ──► socks 出站 B(落地) ├── /sub/* ──► 订阅服务 127.0.0.1:8080 └── 其它 ──► 静态站点(博客)关键点一句话总结:
- Caddy 的
layer4模块接管 443 的裸 TCP,把它转交给 Xray 的 REALITY 入站; - REALITY 的 fallback 指向 Caddy 自己的 HTTP 服务(俗称”偷自己”),这样普通 TLS 访问者看到的就是一个真实网站;
- XHTTP 入站全部监听 Unix socket,由 Caddy 按 HTTP 路径反代进去;
- 落地节点不是新域名、新证书,只是同一个域名 + 不同 path,靠 Xray 的 routing 按入站 tag 分到不同的 socks 出站;
- 订阅服务用 SQLite 存用户,通过 Xray 的 gRPC API 动态增删用户、轮询流量,并在订阅响应头里回传用量。
二、为什么这么设计
1. 只开一个端口
只需要放行 443(和 SSH)。证书用 Cloudflare DNS-01 签发,连 80 端口都不用开,也不依赖 Let’s Encrypt 的 HTTP 挑战。
2. 为什么用 Caddy 的 layer4,而不是 Nginx
| 需求 | 传统做法 | 这套做法 |
|---|---|---|
| REALITY 需要处理裸 TCP | Xray 直接监听 443 | layer4 把裸 TCP 交给 Xray |
| XHTTP 需要按路径分流 | 另开端口 / 用 Nginx stream | Caddy HTTP 反代到 Unix socket |
| 证书自动续期 | certbot + 定时任务 | Caddy 内置 ACME + DNS-01 |
| 静态站 | 再起一个 web 服务 | Caddy file_server 顺手托管 |
Caddy 的 layer4 + HTTP 两种能力在一个进程里,省掉了一层。
3. 为什么 REALITY 的 fallback 要指向”自己”
REALITY 的伪装原理是:真客户端完成握手,假客户端被”转交”给一个真实的 TLS 站点。如果那个站点是别人的网站,你就在蹭别人的证书;而把它设成 Caddy 托管的自己的站点,就变成了真正的”偷自己”——证书、域名、内容全部自洽,抗主动探测能力最好。
4. 为什么落地节点复用同一个入口
自建落地(或买的 socks5 落地)的 IP 通常不想直接暴露给客户端。做法是:落地节点也当作一个 XHTTP 入站挂在入口上,客户端连的还是入口域名:443,只是 path 不同。入口上的 Xray 收到之后,按入站 tag 走 routing,转给对应的 socks 出站。
这样带来几个好处:
- 客户端只需要一个域名、一份证书;
- 落地 IP 完全不暴露;
- 出口切换 = 切换节点,本质上只是换 path;
- 可以做用户白名单:只有名单里的用户订阅里才会出现落地节点。
5. 订阅统一管理
订阅服务是唯一”知道全部用户”的地方。它通过 Xray 的 API 在运行时把用户加进各个入站,不用重启 Xray;同时按用户邮箱聚合流量。
三、前置准备
一台 VPS(下文称 入口机),以及:
- 一个域名,例如
node.example.com,DNS 托管在 Cloudflare; - Cloudflare API Token(需要
Zone:DNS:Edit权限); - 入口机的 443 端口可从公网访问;
- 一个 socks5 落地(下文用
198.51.100.20:1080举例,带用户名密码认证)。
需要安装的东西:
# 1) Xraybash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install
# 2) Go(编译订阅服务用,要求较新的版本)# 从 https://go.dev/dl/ 下载对应架构的包即可
# 3) xcaddy(用来编译带 layer4 + cloudflare DNS 模块的 Caddy)go install github.com/caddyserver/xcaddy/cmd/xcaddy@latest
# 编译 Caddy(关键:必须带 caddy-l4 和 caddy-dns/cloudflare 两个模块)~/go/bin/xcaddy build \ --with github.com/caddy-dns/cloudflare \ --with github.com/mholt/caddy-l4# 产物是当前目录的 ./caddy⚠️ 注意:官方预编译的 Caddy 不包含
layer4和cloudflareDNS 模块,必须自己用 xcaddy 编译。
四、目录结构
把所有东西放到一个目录里,方便打包、同步、备份:
/etc/goodnet/├── bin/│ ├── caddy # xcaddy 编译产物│ ├── xray # Xray 主程序│ ├── sub # 订阅服务(Go 编译产物)│ └── geoip.dat geoip.metadb geosite.dat # 分流数据├── Caddyfile # Caddy 配置├── xray.json # Xray 主配置├── xray_conf/ # Xray 入站配置(按文件拆分)│ ├── VLESS-REALITY-5443.json│ ├── VLESS-XHTTP-2023.json│ └── VLESS-XHTTP-LANDING.json├── sub/│ ├── *.go # 订阅服务源码│ ├── config.json # 订阅服务配置│ └── data/users.db # SQLite 用户库(运行数据,永远保留)├── caddy.service├── xray.service├── sub.service├── deploy.sh # 一键部署└── landing.sh # 落地节点管理(一条命令改 4 个文件)Xray 支持 -confdir 参数:主配置放在 xray.json,各个入站拆成 xray_conf/*.json,启动时自动合并。这样落地节点可以独立成文件,脚本改起来不会互相打架。
下面开始逐个文件讲。
五、Caddy:唯一对外的入口
1. Caddyfile
{ # 证书与 ACME 数据落盘,重启不重签 storage file_system /home/tls email admin@example.com
# DNS-01 挑战:证书签发只依赖 DNS,不占 80 端口 acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
log log0 { output file /var/log/caddy/access.log format json }
# 关键:这个 HTTP 服务监听的是 Unix socket,不监听公网端口 # listener_wrappers 先解开 PROXY protocol,再做 TLS 终止 servers unix/@caddy-http.sock { name http-entry listener_wrappers { proxy_protocol tls } trusted_proxies cloudflare { interval 12h timeout 15s } }
# layer4:接管 443 裸 TCP,转给 Xray REALITY,并附上 PROXY v2 layer4 { tcp/:443 { route { proxy { upstream unix/@xray-reality.sock proxy_protocol v2 } } } }}
node.example.com { log log0
# 站点绑定在 Unix socket 上(REALITY fallback 会连它) bind unix/@caddy-http.sock
# 订阅服务 handle /sub/* { reverse_proxy 127.0.0.1:8080 }
# 落地节点 A:注意放在通用 XHTTP 规则之前 handle /xhttp-exit-a/* { reverse_proxy unix/@xray-exit-a.sock { transport http { versions 2 } flush_interval -1 } }
# 落地节点 B handle /xhttp-exit-b/* { reverse_proxy unix/@xray-exit-b.sock { transport http { versions 2 } flush_interval -1 } }
# 普通 XHTTP 节点(通用路径,放最后) handle /xhttp/* { reverse_proxy unix/@xray-xhttp.sock { transport http { versions 2 } flush_interval -1 } }
# 兜底:静态站点 handle { encode zstd gzip
header { Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" X-Content-Type-Options "nosniff" Referrer-Policy "strict-origin-when-cross-origin" X-Frame-Options "SAMEORIGIN" }
@assets path /css/* /js/* @immutable path /uploads/* /images/* /fonts/* @documents not path /css/* /js/* /uploads/* /images/* /fonts/*
header @documents Cache-Control "public, max-age=0, must-revalidate" header @assets Cache-Control "public, max-age=3600" header @immutable Cache-Control "public, max-age=2592000"
root * /var/www/site file_server }
handle_errors { @notfound expression {http.error.status_code} == 404 rewrite @notfound /404.html root * /var/www/site file_server }}2. 几个必须理解的细节
servers unix/@caddy-http.sock:这是 Caddy 的全局 servers 选项,作用是把默认服务器改名并指定监听地址。这里让它监听一个抽象 Unix socket(Linux abstract namespace,@ 开头)。
listener_wrappers { proxy_protocol; tls }:顺序很重要——先 proxy_protocol 把 Xray fallback 发来的 PROXY v2 头剥掉,拿到真实客户端 IP,再做 tls 终止。如果顺序反了,TLS 会先看到 PROXY 头的二进制而握手失败。
layer4 块:这是 caddy-l4 模块提供的。它把 443 的原始 TCP 直接 proxy 到 @xray-reality.sock,并声明 proxy_protocol v2。也就是说,443 上的第一跳不是 HTTP,而是裸 TCP 转发。
handle 的顺序:Caddy 的 handle 是”第一个匹配就执行”。所以具体 path(/xhttp-exit-a/*)必须写在通用 path(/xhttp/*)前面。/xhttp/* 不会匹配 /xhttp-exit-a/...,因为 - 不是 /,但把具体规则放前面是更稳妥的写法。
flush_interval -1:XHTTP 是长连接 + 流式传输,必须禁用 Caddy 的响应缓冲,否则延迟爆炸、甚至卡死。
versions 2:XHTTP 走 HTTP/2 更能抗流量分析。
如果你发现客户端连不上,可以先确认 Caddy 里既有
/xhttp/*也有/xhttp(不带/*)的 handle——不同版本的 XHTTP 对 path 的拼接方式略有差异,两个都加上最保险。
3. Cloudflare Token 怎么给
caddy.service 里通过环境变量注入:
Environment=CLOUDFLARE_API_TOKEN=<CLOUDFLARE_API_TOKEN>更安全的做法是不要把 Token 写在 unit 文件里(unit 文件常被
systemctl cat看到),而是用EnvironmentFile=/etc/goodnet/caddy.env,并把该文件chmod 600。
六、Xray REALITY 入站
1. 先生成密钥
# 生成 X25519 密钥对xray x25519# Private key: <REALITY_PRIVATE_KEY># Public key: <REALITY_PUBLIC_KEY>
# 生成一个 shortId(16 位十六进制)openssl rand -hex 8# 0123456789abcdef把私钥填进服务端,公钥下发给客户端。
2. xray_conf/VLESS-REALITY-5443.json
{ "inbounds": [ { "tag": "reality-main", "listen": "@xray-reality.sock", "protocol": "vless", "settings": { "clients": [], "decryption": "none", "fallbacks": [ { "dest": "@caddy-http.sock" } ] }, "streamSettings": { "network": "raw", "security": "reality", "realitySettings": { "show": false, "target": "@caddy-http.sock", "xver": 2, "serverNames": [ "node.example.com" ], "publicKey": "<REALITY_PUBLIC_KEY>", "privateKey": "<REALITY_PRIVATE_KEY>", "shortIds": [ "0123456789abcdef" ] }, "sockopt": { "tcpFastOpen": true, "tcpcongestion": "bbr", "tcpMptcp": true, "tcpNoDelay": true }, "rawSettings": { "acceptProxyProtocol": true } }, "sniffing": { "enabled": true, "destOverride": [ "http", "tls" ], "routeOnly": true } } ]}关键字段解释:
| 字段 | 作用 |
|---|---|
listen: "@xray-reality.sock" | 监听抽象 Unix socket,公网不可直接访问 |
fallbacks.dest: "@caddy-http.sock" | 非 REALITY 客户端转交给 Caddy |
realitySettings.target / xver | fallback 目标与 PROXY 协议版本(必须与 Caddy 的 listener_wrappers 对上) |
serverNames | 允许的 SNI,必须包含真实域名 |
acceptProxyProtocol: true | 接受来自 Caddy layer4 的 PROXY v2 头 |
routeOnly: true | 嗅探结果只用于路由,不改写目标,避免影响 REALITY |
客户端的 flow(xtls-rprx-vision)不写在这里,因为用户是运行时由订阅服务通过 API 加进来的。这里 clients 留空(或只放一个调试用账号),完全交给订阅服务管理。
七、Xray XHTTP 入站
xray_conf/VLESS-XHTTP-2023.json
{ "inbounds": [ { "listen": "@xray-xhttp.sock", "protocol": "vless", "tag": "xhttp-main", "settings": { "clients": [], "decryption": "none" }, "streamSettings": { "network": "xhttp", "xhttpSettings": { "host": "", "path": "/xhttp", "mode": "auto", "extra": { "noSSEHeader": true, "scMaxEachPostBytes": 1000000, "xPaddingBytes": "100-1000" } } }, "sniffing": { "enabled": true, "destOverride": [ "http", "tls", "quic" ] } } ]}参数说明:
mode: auto:服务端自动适配,客户端可以用 stream-up / packet-up;noSSEHeader:去掉 SSE 响应头,更接近普通 HTTP;scMaxEachPostBytes:单个 POST 体上限;xPaddingBytes:给数据包加随机填充,弱化长度特征。
XHTTP 的入站也是 VLESS + decryption: none,用户同样由订阅服务运行时注入。
在较新的 Xray 上,
xray run -test会对”没有 flow 的 VLESS”给出 deprecation 警告(提示迁移到带 Flow/Seed 的新特性)。这只是警告,不影响运行;跟进新版时再评估即可。
八、Xray 主配置:API、统计与路由
xray.json 是整个系统的”大脑”,负责 API、流量统计和分流规则。
{ "log": { "access": "/var/log/xray/access.log", "error": "/var/log/xray/error.log", "loglevel": "warning" }, "dns": {}, "api": { "tag": "api", "services": [ "HandlerService", "LoggerService", "StatsService" ] }, "stats": {}, "policy": { "levels": { "0": { "handshake": 7, "connIdle": 676, "uplinkOnly": 7, "downlinkOnly": 8, "statsUserUplink": true, "statsUserDownlink": true } }, "system": { "statsInboundUplink": true, "statsInboundDownlink": true, "statsOutboundUplink": true, "statsOutboundDownlink": true } }, "routing": { "domainStrategy": "IPIfNonMatch", "rules": [ { "type": "field", "inboundTag": ["api"], "outboundTag": "api" }, { "type": "field", "protocol": ["bittorrent"], "marktag": "ban_bt", "outboundTag": "block" }, { "type": "field", "inboundTag": ["xhttp-exit-a"], "outboundTag": "landing-exit-a" }, { "type": "field", "inboundTag": ["xhttp-exit-b"], "outboundTag": "landing-exit-b" }, { "type": "field", "ip": ["geoip:cn"], "marktag": "ban_geoip_cn", "outboundTag": "block" }, { "type": "field", "domain": ["geosite:openai"], "marktag": "fix_openai", "outboundTag": "direct" }, { "type": "field", "ip": ["geoip:private"], "outboundTag": "block" } ] }, "inbounds": [ { "tag": "api", "port": 6765, "listen": "127.0.0.1", "protocol": "dokodemo-door", "settings": { "address": "127.0.0.1" } } ], "outbounds": [ { "tag": "direct", "protocol": "freedom" }, { "tag": "block", "protocol": "blackhole" }, { "tag": "landing-exit-a", "protocol": "socks", "settings": { "servers": [ { "address": "198.51.100.20", "port": 1080, "users": [ { "user": "<SOCKS_USER>", "pass": "<SOCKS_PASS>" } ] } ] } }, { "tag": "landing-exit-b", "protocol": "socks", "settings": { "servers": [ { "address": "198.51.100.21", "port": 1080, "users": [ { "user": "<SOCKS_USER>", "pass": "<SOCKS_PASS>" } ] } ] } } ]}1. API 与 api 出站
启用了 HandlerService 之后,Xray 会注册一个 tag 为 api 的内部出站。所以路由规则里写 "outboundTag": "api" 是合法的(不需要显式定义这个出站),dokodemo-door 入站 + 这条规则就是标准的本地管理接口接法。它只监听 127.0.0.1:6765,公网访问不到。
2. 统计与配额
"stats": {}:打开流量统计;policy.levels."0".statsUserUplink/Downlink:按用户分别累计上下行——这是订阅流量显示的基础;policy.system.statsInbound/Outbound...:按入站/出站累计。
3. 路由规则
protocol: bittorrent→block:封 BT;inboundTag: xhttp-exit-*→ 对应landing-*出站:落地节点的核心,按入站 tag 分流;geoip:cn→block(按需,也可以改direct);geosite:openai→direct:某些场景下 AI 域名走直连;geoip:private→block:防内网探测。
domainStrategy: IPIfNonMatch 表示”域名匹配不上时再解析成 IP 去匹配 IP 规则”。
九、特殊出口落地节点
1. 思路
每加一个落地,需要同时改 4 个地方,而且必须保持一致:
| 位置 | 内容 |
|---|---|
xray_conf/VLESS-XHTTP-LANDING.json | 新增一个 XHTTP 入站(tag = xhttp-exit-a,path = /xhttp-exit-a) |
xray.json outbounds | 新增 socks 出站(tag = landing-exit-a) |
xray.json routing | 新增 inboundTag → outboundTag 规则 |
Caddyfile | 新增 handle /xhttp-exit-a/* 反代到对应 Unix socket |
sub/config.json | 新增节点元数据(给订阅用) |
手改极易漏项。所以我写了个 landing.sh,一条命令改 5 个文件、自动跑 xray -test 校验,失败自动回滚。
2. xray_conf/VLESS-XHTTP-LANDING.json 示例
{ "inbounds": [ { "listen": "@xray-exit-a.sock", "protocol": "vless", "tag": "xhttp-exit-a", "settings": { "clients": [], "decryption": "none" }, "streamSettings": { "network": "xhttp", "xhttpSettings": { "host": "", "path": "/xhttp-exit-a", "mode": "auto", "extra": { "noSSEHeader": true, "scMaxEachPostBytes": 1000000, "xPaddingBytes": "100-1000" } } }, "sniffing": { "enabled": true, "destOverride": ["http", "tls", "quic"] } } ]}注意落地入站的 clients 永远是空的——它上面有哪些用户,完全由订阅服务的白名单 + Reconcile 决定。
3. landing.sh 用法
# 查看所有落地节点、path、出站、订阅显示名,以及白名单./landing.sh list
# 新增:slug 会派生出 tag/path/socket/outbound 四处命名./landing.sh add exit-a \ --server 198.51.100.20 --port 1080 \ --user <SOCKS_USER> --pass <SOCKS_PASS> \ --name 落地-A
# 删除./landing.sh remove exit-a
# 用户白名单(只有名单里的用户,订阅里才会出现落地节点)./landing.sh allow alice./landing.sh deny alice
# 改完统一生效./deploy.shslug 的派生规则(保证四处一致):
slug exit-ainbound tag xhttp-exit-apath /xhttp-exit-aunix socket @xray-exit-a.socksocks 出站 landing-exit-aadd / remove 内部会先快照 4 个文件,改完后调用 xray run -test;只要校验失败就整体回滚,绝不会把坏配置留在磁盘上。
十、订阅服务:用户、流量、白名单
订阅服务是一个纯 Go 的小程序(sub/),职责:
- 用 SQLite 存用户(UUID、订阅 token、到期时间、流量上限、已用流量);
- 通过
xray api adu在运行时把用户加进各个入站(不用重启 Xray); - 定期
statsquery拉取每个用户的流量并累加; - 对外提供订阅接口,返回 base64(通用)或 Clash YAML,并在响应头里回传流量信息。
1. sub/config.json
{ "listen": "127.0.0.1:8080", "xray_api": "127.0.0.1:6765", "xray_bin": "/etc/goodnet/bin/xray", "xray_conf_dir": "/etc/goodnet/xray_conf", "db_path": "data/users.db", "admin_token": "<ADMIN_TOKEN>", "public_host": "node.example.com", "public_port": 443, "base_url": "https://node.example.com/sub", "inbounds": { "reality": "reality-main", "xhttp": "xhttp-main" }, "reality": { "public_key": "<REALITY_PUBLIC_KEY>", "short_id": "0123456789abcdef", "fingerprint": "chrome", "server_names": ["node.example.com"] }, "xhttp": { "path": "/xhttp", "mode": "auto" }, "landing": { "users": ["alice"], "nodes": [ { "name": "落地-A", "tag": "xhttp-exit-a", "path": "/xhttp-exit-a", "host": "node.example.com", "mode": "auto" }, { "name": "落地-B", "tag": "xhttp-exit-b", "path": "/xhttp-exit-b", "host": "node.example.com", "mode": "auto" } ] }, "keep_emails": ["debug"], "traffic_poll_seconds": 60}要点:
inbounds.reality/inbounds.xhttp是逻辑名 → Xray 入站 tag 的映射;reality.public_key会由sub validate用xray x25519 -i <私钥>反推校验,防止公私钥不匹配这种低级错误;landing.users是落地白名单,空数组表示谁都不能用落地;keep_emails里的邮箱(比如调试账号debug)在 Reconcile 时不会被清理;traffic_poll_seconds是流量轮询间隔。
2. 用户管理命令
# 新增用户:30 天后到期、限 100 GiB./bin/sub user-add -config sub/config.json \ -name alice -days 30 -limit-gb 100# 输出会带 UUID 和订阅地址
# 指定到期日./bin/sub user-add -config sub/config.json -name bob -expire 2027-01-01
# 列出用户(含状态、已用流量、订阅 URL)./bin/sub user-list -config sub/config.json
# 查某个用户的订阅地址./bin/sub sub-url -config sub/config.json -name alice
# 删除用户./bin/sub user-del -config sub/config.json -name alice
# 校验 config.json 与 xray_conf 是否一致./bin/sub validate -config sub/config.json3. 流量是怎么统计和展示的
-
Xray 侧:
policy.levels."0".statsUserUplink/Downlink = true+"stats": {}; -
统计项名字形如
user>>>alice>>>traffic>>>uplink; -
订阅服务每隔
traffic_poll_seconds秒执行一次:Terminal window xray api statsquery --server=127.0.0.1:6765 -pattern "user>>>" -reset-reset表示取完清零,程序把增量累加进 SQLite(upload/download); -
订阅接口响应头:
Subscription-Userinfo: upload=1234567; download=7654321; total=107374182400; expire=1798761600绝大多数客户端(v2rayN、v2rayNG、Clash/Mihomo、Happ 等)就是靠这个头显示已用流量 / 总量 / 到期时间的。
4. 运行时增删用户(Reconcile)
- 新增用户时,程序调用 Xray 的 AlterInbound,把 client 加进
reality-main和xhttp-main;只有白名单用户才会被加进xhttp-exit-*; - 程序启动时会做一次全量 Reconcile:把数据库里”活跃用户”补齐到各入站,同时清理掉不再存在(且不在
keep_emails)的孤儿用户; - 因此不要手改
xray_conf里的clients,那只是初始状态。
对应到命令,就是 sub run 里调用的 xray api adu(AddUser)和 xray api rmu(RemoveUser)。adu 需要一份带 tag 的临时 inbound 描述,tag 是匹配依据,端口字段只是配置解析要求,会被忽略。
5. 订阅输出格式
GET /sub/<token>:
- 默认返回 base64 编码的通用订阅(v2rayN / v2rayNG / Shadowrocket 等);
- 带
?target=clash或 User-Agent 含clash/mihomo时,返回 Clash/Mihomo YAML,其中落地节点会被放进一个落地节点策略组,普通节点放另一个组; - 每个用户的节点是动态生成的:REALITY 节点、普通 XHTTP 节点,白名单用户额外加落地 XHTTP 节点。
6. 管理 API(需带 token)
TOKEN='<ADMIN_TOKEN>'BASE='http://127.0.0.1:8080'
curl -H "X-Admin-Token: $TOKEN" $BASE/admin/userscurl -H "X-Admin-Token: $TOKEN" -X POST $BASE/admin/users \ -d '{"name":"carol","days":30,"traffic_limit_gb":50}'curl -H "X-Admin-Token: $TOKEN" -X DELETE $BASE/admin/users/3curl -H "X-Admin-Token: $TOKEN" -X POST $BASE/admin/users/3/rotate-tokencurl -H "X-Admin-Token: $TOKEN" -X POST $BASE/admin/reconcile十一、systemd 与一键部署
1. Xray 单元
[Unit]Description=Xray ServiceAfter=network.target nss-lookup.target
[Service]User=rootExecStart=/etc/goodnet/bin/xray run -config /etc/goodnet/xray.json -confdir /etc/goodnet/xray_confRestart=on-failureRestartPreventExitStatus=23LimitNPROC=10000LimitNOFILE=1000000RuntimeDirectory=xrayRuntimeDirectoryMode=0755
[Install]WantedBy=multi-user.target-confdir 就是拆分配置的关键。
2. 订阅服务单元
[Unit]Description=Goodnet Subscription ServiceAfter=network.target xray.serviceWants=xray.service
[Service]User=rootExecStart=/etc/goodnet/bin/sub run -config /etc/goodnet/sub/config.jsonRestart=on-failureRestartSec=3LimitNOFILE=1000000
[Install]WantedBy=multi-user.target3. 部署脚本 deploy.sh
它做这几件事:
- 用
rsync把仓库同步到/etc/goodnet(排除sub/data/,用户库永不覆盖); - 安装三个 systemd 单元并
daemon-reload; - 校验:
xray run -test、caddy validate、sub validate,任一失败立即中止——绝不用坏配置重启; - 计算配置指纹(所有配置文件的 sha256),只有变化或服务未运行时才重启;
- 按
caddy → xray → sub顺序重启。
常用参数:
./deploy.sh # 同步 + 校验 + 按需重启./deploy.sh --force # 强制重启./deploy.sh --no-restart # 只同步校验./deploy.sh -n # dry-run./deploy.sh --blog # 顺带编译博客(pnpm build)典型工作流:
./landing.sh add exit-c --server 198.51.100.22 --port 1080 --user <U> --pass <P> --name 落地-C./deploy.sh十二、客户端
1. 通用订阅
把 sub-url 输出的链接导入客户端即可,客户端会自动解析出所有节点,并读取流量信息。
2. 手动配置时各个字段对应关系
REALITY 节点:
地址: node.example.com端口: 443UUID: <用户 UUID>流控: xtls-rprx-vision传输: tcp安全: realitySNI: node.example.comFingerprint: chromePublicKey: <REALITY_PUBLIC_KEY>ShortId: 0123456789abcdefXHTTP 节点:
地址: node.example.com端口: 443UUID: <用户 UUID>传输: xhttp安全: tlsSNI: node.example.comHost: node.example.comPath: /xhttpmode: auto落地节点:和 XHTTP 节点完全一样,只是 Path 换成 /xhttp-exit-a。
3. Clash / Mihomo
订阅 URL 加 ?target=clash,或让客户端 UA 包含 clash / mihomo,就会返回完整 YAML(含 规则、rule-providers、TUN、DNS 等)。落地节点会出现在 落地节点 策略组里。
十三、排错清单
| 现象 | 排查方向 |
|---|---|
| 443 完全不通 | ss -ltnp | grep 443 看的是 Caddy 还是别人;systemctl status caddy |
| 普通浏览器访问域名报证书错误 | REALITY fallback 的 target 是否等于 Caddy 的 HTTP socket;xver 是否为 2 |
| REALITY 能连、XHTTP 连不上 | Caddy 里 /xhttp 与 /xhttp/* 两个 handle 是否齐全;flush_interval 是否 -1;versions 2 是否加了 |
| 一改就全挂 | 别跳过 deploy.sh 的校验;手改配置后先 xray run -test |
| 订阅有节点但连不上 | 用 bin/sub user-list 看用户是否 active;到 Xray 上 xray api inbounduser --server=127.0.0.1:6765 -tag=reality-main 看用户是否真在里面 |
| 流量一直显示 0 | 检查 "stats": {}、policy.levels."0".statsUserUplink/Downlink、以及客户端 email 是否等于用户名 |
| 落地节点不出现 | 该用户是否在 landing.users 白名单;landing.nodes 是否配了 path |
| 删了用户还在 | Reconcile 会清理,但 keep_emails 里的不会被清;或手动 POST /admin/reconcile |
常用诊断命令:
systemctl status caddy xray subjournalctl -u xray -n 100 --no-pagertail -f /var/log/xray/error.logtail -f /var/log/caddy/access.log # JSON 格式,含真实客户端 IPss -lx | grep uds # 看 Unix socket 是否都在xray api statsquery --server=127.0.0.1:6765 -pattern "" -reset十四、安全清单(也是我脱敏的原因)
这篇教程里出现的所有内容都是占位符。真正落地时请注意:
- 不要把真实 IP、域名、UUID、REALITY 私钥、shortId、Cloudflare Token、订阅 token、落地 socks 密码贴到任何公开地方,包括博客、GitHub、群聊截图;
- REALITY 私钥只存在服务端配置里,下发给客户端的永远只有公钥;
sub/config.json(含admin_token)和caddy.service(含 CF Token)都要chmod 600;更推荐把 Token 放独立的EnvironmentFile;- 管理 API 只监听
127.0.0.1,不要暴露到公网; sub/data/users.db是唯一存量的用户数据,deploy.sh要排除它,备份时别漏;- 定期轮换:订阅 token(
rotate-token)、落地 socks 密码、Cloudflare Token; - 出口的
geoip:cn规则按自己的合规与使用场景决定,别盲目照抄。
小结
整套系统的分工非常清晰:
- Caddy:唯一入口,TLS + 路径分流 + 静态站;
- Xray:REALITY / XHTTP / 落地分流 + 流量统计;
- sub:用户、订阅、配额、流量显示;
- landing.sh / deploy.sh:把”配置一致性”和”部署安全”用脚本固化下来。
最值得借鉴的其实是最后一点:能用脚本保证一致性的地方,就不要靠人肉记忆。落地节点要在 5 个文件里保持命名一致,landing.sh 一条命令搞定还能自动校验回滚;配置上线前必须过 deploy.sh 的三重校验,永远不带着坏配置重启。
需要的人照着上面的顺序搭一遍即可。记住把所有占位符换成自己的,然后——别把真的贴出来。