这套架构我已经稳定跑了一段时间:只对外开一个 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 需要处理裸 TCPXray 直接监听 443layer4 把裸 TCP 交给 Xray
XHTTP 需要按路径分流另开端口 / 用 Nginx streamCaddy 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 举例,带用户名密码认证)。

需要安装的东西:

Terminal window
# 1) Xray
bash -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 和 cloudflare DNS 模块,必须自己用 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. 先生成密钥#

Terminal window
# 生成 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 / xverfallback 目标与 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 用法#

Terminal window
# 查看所有落地节点、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.sh

slug 的派生规则(保证四处一致):

slug exit-a
inbound tag xhttp-exit-a
path /xhttp-exit-a
unix socket @xray-exit-a.sock
socks 出站 landing-exit-a

add / remove 内部会先快照 4 个文件,改完后调用 xray run -test;只要校验失败就整体回滚,绝不会把坏配置留在磁盘上。

十、订阅服务:用户、流量、白名单#

订阅服务是一个纯 Go 的小程序(sub/),职责:

  1. 用 SQLite 存用户(UUID、订阅 token、到期时间、流量上限、已用流量);
  2. 通过 xray api adu 在运行时把用户加进各个入站(不用重启 Xray);
  3. 定期 statsquery 拉取每个用户的流量并累加;
  4. 对外提供订阅接口,返回 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. 用户管理命令#

Terminal window
# 新增用户: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.json

3. 流量是怎么统计和展示的#

  • 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)#

Terminal window
TOKEN='<ADMIN_TOKEN>'
BASE='http://127.0.0.1:8080'
curl -H "X-Admin-Token: $TOKEN" $BASE/admin/users
curl -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/3
curl -H "X-Admin-Token: $TOKEN" -X POST $BASE/admin/users/3/rotate-token
curl -H "X-Admin-Token: $TOKEN" -X POST $BASE/admin/reconcile

十一、systemd 与一键部署#

1. Xray 单元#

[Unit]
Description=Xray Service
After=network.target nss-lookup.target
[Service]
User=root
ExecStart=/etc/goodnet/bin/xray run -config /etc/goodnet/xray.json -confdir /etc/goodnet/xray_conf
Restart=on-failure
RestartPreventExitStatus=23
LimitNPROC=10000
LimitNOFILE=1000000
RuntimeDirectory=xray
RuntimeDirectoryMode=0755
[Install]
WantedBy=multi-user.target

-confdir 就是拆分配置的关键。

2. 订阅服务单元#

[Unit]
Description=Goodnet Subscription Service
After=network.target xray.service
Wants=xray.service
[Service]
User=root
ExecStart=/etc/goodnet/bin/sub run -config /etc/goodnet/sub/config.json
Restart=on-failure
RestartSec=3
LimitNOFILE=1000000
[Install]
WantedBy=multi-user.target

3. 部署脚本 deploy.sh#

它做这几件事:

  1. 用 rsync 把仓库同步到 /etc/goodnet(排除 sub/data/,用户库永不覆盖);
  2. 安装三个 systemd 单元并 daemon-reload;
  3. 校验:xray run -test、caddy validate、sub validate,任一失败立即中止——绝不用坏配置重启;
  4. 计算配置指纹(所有配置文件的 sha256),只有变化或服务未运行时才重启;
  5. 按 caddy → xray → sub 顺序重启。

常用参数:

Terminal window
./deploy.sh # 同步 + 校验 + 按需重启
./deploy.sh --force # 强制重启
./deploy.sh --no-restart # 只同步校验
./deploy.sh -n # dry-run
./deploy.sh --blog # 顺带编译博客(pnpm build)

典型工作流:

Terminal window
./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
端口: 443
UUID: <用户 UUID>
流控: xtls-rprx-vision
传输: tcp
安全: reality
SNI: node.example.com
Fingerprint: chrome
PublicKey: <REALITY_PUBLIC_KEY>
ShortId: 0123456789abcdef

XHTTP 节点:

地址: node.example.com
端口: 443
UUID: <用户 UUID>
传输: xhttp
安全: tls
SNI: node.example.com
Host: node.example.com
Path: /xhttp
mode: 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

常用诊断命令:

Terminal window
systemctl status caddy xray sub
journalctl -u xray -n 100 --no-pager
tail -f /var/log/xray/error.log
tail -f /var/log/caddy/access.log # JSON 格式,含真实客户端 IP
ss -lx | grep uds # 看 Unix socket 是否都在
xray api statsquery --server=127.0.0.1:6765 -pattern "" -reset

十四、安全清单(也是我脱敏的原因)#

这篇教程里出现的所有内容都是占位符。真正落地时请注意:

  1. 不要把真实 IP、域名、UUID、REALITY 私钥、shortId、Cloudflare Token、订阅 token、落地 socks 密码贴到任何公开地方,包括博客、GitHub、群聊截图;
  2. REALITY 私钥只存在服务端配置里,下发给客户端的永远只有公钥;
  3. sub/config.json(含 admin_token)和 caddy.service(含 CF Token)都要 chmod 600;更推荐把 Token 放独立的 EnvironmentFile;
  4. 管理 API 只监听 127.0.0.1,不要暴露到公网;
  5. sub/data/users.db 是唯一存量的用户数据,deploy.sh 要排除它,备份时别漏;
  6. 定期轮换:订阅 token(rotate-token)、落地 socks 密码、Cloudflare Token;
  7. 出口的 geoip:cn 规则按自己的合规与使用场景决定,别盲目照抄。

小结#

整套系统的分工非常清晰:

  • Caddy:唯一入口,TLS + 路径分流 + 静态站;
  • Xray:REALITY / XHTTP / 落地分流 + 流量统计;
  • sub:用户、订阅、配额、流量显示;
  • landing.sh / deploy.sh:把”配置一致性”和”部署安全”用脚本固化下来。

最值得借鉴的其实是最后一点:能用脚本保证一致性的地方,就不要靠人肉记忆。落地节点要在 5 个文件里保持命名一致,landing.sh 一条命令搞定还能自动校验回滚;配置上线前必须过 deploy.sh 的三重校验,永远不带着坏配置重启。

需要的人照着上面的顺序搭一遍即可。记住把所有占位符换成自己的,然后——别把真的贴出来。