本文介绍如何用 Caddy 在服务器公网 IP:443 上终结 TLS,并把 WebSocket 流量 reverse proxy 到本机 Xray,同时满足:

  • 使用 self-signed certificate(不依赖域名、不走公网 ACME)
  • 客户端 SNI 可任意填写(便于应对部分 SNI blocking)
  • 客户端 WebSocket Host 可任意填写(不必等于 IP 或 SNI)
  • 本机其它真实域名站点可与 Xray 按 SNI 分流共存

文中 IP、UUID、域名均为 placeholder,发表与部署时请替换为你自己的值。


1. 架构说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Client
| TCP -> YOUR_SERVER_IP:443
| TLS ClientHello.SNI = any value (e.g. a popular CDN hostname)
| HTTP / WS Host header = any value
| path = /rayws (fixed in this guide)
v
+--------------------------------------+
| Caddy :443 |
| - named host -> real ACME site |
| - any other SNI -> internal CA cert |
| - path /rayws* -> 127.0.0.1:10000 |
+------------------+-------------------+
v
Xray VLESS + WS
listen 127.0.0.1:10000
path only; no Host check

各层职责如下:

Layer 由谁处理 作用
TCP destination 客户端 address 填公网 IP 流量真正打到你的机器
TLS SNI Caddy 选证书、完成 handshake
HTTP Host / WS Host 原样透传 Xray 强制匹配 Host
path Caddy + Xray 固定入口路径,例如 /rayws

地址填 IP、SNI 填其它 hostname 是合法用法:SNI 只是 TLS ClientHello 里的字段,并不要求该名字 DNS resolve 到你的 IP。部分网络只 string-match 特定 SNI,换成常见 hostname 有时可以绕过。


2. 前置条件

  • Linux server,已开放 443(80 可选)
  • 已安装 CaddyXray
  • 建议 Xray 只 listen 127.0.0.1,不要直接暴露到公网
  • 客户端需支持 allowInsecure / skip certificate verification(self-signed 场景必须)

下文使用的 placeholder:

1
2
3
4
5
# Replace with your own values
SERVER_IP=203.0.113.10
XRAY_PORT=10000
XRAY_PATH=/rayws
XRAY_UUID=00000000-0000-0000-0000-000000000000

203.0.113.0/24 是文档用 reserved address(RFC 5737),请换成你的 public IP。


3. 安装组件(简述)

3.1 Caddy(Debian / Ubuntu 示例)

1
2
3
4
5
6
7
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl gnupg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
| tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install -y caddy
caddy version

3.2 Xray

1
bash -c "$(curl -L https://github.com/XTLS/Xray-install/raw/main/install-release.sh)" @ install

生成 UUID:

1
2
cat /proc/sys/kernel/random/uuid
# or: xray uuid

4. Xray 配置(关键:不校验 Host)

写入 /usr/local/etc/xray/config.json

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
{
"log": {
"loglevel": "warning",
"access": "/var/log/xray/access.log",
"error": "/var/log/xray/error.log"
},
"inbounds": [
{
"listen": "127.0.0.1",
"port": 10000,
"protocol": "vless",
"settings": {
"clients": [
{
"id": "00000000-0000-0000-0000-000000000000",
"level": 0
}
],
"decryption": "none"
},
"streamSettings": {
"network": "ws",
"security": "none",
"wsSettings": {
"path": "/rayws"
}
},
"sniffing": {
"enabled": true,
"destOverride": ["http", "tls", "quic"]
}
}
],
"outbounds": [
{ "protocol": "freedom", "tag": "direct" },
{ "protocol": "blackhole", "tag": "block" }
]
}

不要wsSettings.headers 里写死 Host
一旦配置了类似 "Host": "example.com",server 往往会要求 client Host 必须匹配,就无法做到 “arbitrary Host”。

TLS 由 Caddy 终结;Xray inbound 保持 "security": "none" 即可(本机 plain WebSocket,外网对 Caddy 仍是 HTTPS)。

权限与 service(若以 nobody 运行):

1
2
3
4
5
6
7
mkdir -p /var/log/xray
chown -R nobody:nogroup /usr/local/etc/xray /var/log/xray
chmod 644 /usr/local/etc/xray/config.json
systemctl enable --now xray
systemctl restart xray
ss -tlnp | grep 10000
# Expected: 127.0.0.1:10000

5. Caddy 配置(核心)

编辑 /etc/caddy/Caddyfile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
# Fallback when the client sends no SNI
default_sni 203.0.113.10
}

# Optional: real hostname with public ACME certificate
# More specific host blocks take precedence over the catch-all below.
# Remove this entire block if you do not need it.
monitor.example.com {
encode gzip zstd
reverse_proxy 127.0.0.1:8090
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options nosniff
X-Frame-Options SAMEORIGIN
-Server
}
}

# Catch-all HTTPS for any SNI
:443 {
tls internal {
on_demand
}

@xray path /rayws*
handle @xray {
reverse_proxy 127.0.0.1:10000
}

handle {
respond "OK" 200
}
}

:80 {
handle {
respond "OK" 200
}
}

5.1 指令说明

Directive 作用
default_sni client 未带 SNI 时的 fallback hostname
:443 监听所有 hostname(catch-all),不绑死单一 domain
tls internal 使用 Caddy local CA,不走 public ACME
on_demand 按 client 当前 SNI 即时签发 certificate
@xray path /rayws* 仅该 path 转发到 Xray
reverse_proxy 127.0.0.1:10000 反代到本机 Xray;默认不改写 Host
Named site block 可选真实站点;按 SNI 优先于 catch-all

5.2 校验并 reload

1
2
caddy validate --config /etc/caddy/Caddyfile --adapter caddyfile
systemctl reload caddy

5.3 可选:导出 local root CA

1
ls /var/lib/caddy/.local/share/caddy/pki/authorities/local/

6. Firewall

1
2
3
4
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw enable

7. 服务端自测

7.1 默认访问

1
2
curl -sk "https://203.0.113.10/"
# Expected body: OK

7.2 任意 SNI

1
2
3
4
5
curl -sk --connect-to "www.example-cdn.com:443:203.0.113.10:443" \
"https://www.example-cdn.com/"

curl -sk --connect-to "cdn.example.net:443:203.0.113.10:443" \
"https://cdn.example.net/"

返回 OK 说明 Caddy 已接受该 SNI 并完成自签名握手。完整 VLESS 连通还需要用 client 测 /rayws

7.3 path 是否打到 Xray

1
2
3
4
5
curl -sk -o /dev/null -w "%{http_code}\n" \
-H "Connection: Upgrade" \
-H "Upgrade: websocket" \
--connect-to "www.example-cdn.com:443:203.0.113.10:443" \
"https://www.example-cdn.com/rayws"

非 404(例如 400)说明请求已进入 Xray。


8. 客户端配置

8.1 固定项与可变项

Field 是否固定 取值说明
Address 固定 服务器 public IP
Port 固定 443
UUID 固定 与 server 一致
Protocol 固定 VLESS
Transport 固定 WebSocket
path 固定 /rayws
TLS 固定 开启
allowInsecure / skip verify 固定 必须开启(self-signed)
SNI 可变 任意 hostname,可随时改
WS Host 可变 任意值,可与 SNI 不同
1
2
3
4
5
# SNI and Host use the same popular hostname
vless://UUID@203.0.113.10:443?encryption=none&security=tls&sni=www.example-cdn.com&fp=chrome&type=ws&host=www.example-cdn.com&path=%2Frayws&allowInsecure=1#xray-any-sni

# SNI and Host may differ
vless://UUID@203.0.113.10:443?encryption=none&security=tls&sni=update.example-cdn.com&fp=chrome&type=ws&host=static.example.net&path=%2Frayws&allowInsecure=1#xray-mixed

8.3 图形客户端勾选清单

  1. Address = 服务器 public IP
  2. Port = 443
  3. TLS = on
  4. Allow insecure / skip certificate verification = on
  5. SNI = 你想伪装的 hostname(被拦时可改)
  6. Transport = ws,path = /rayws
  7. Host(若有单独一栏)= 任意(可随时改)

链路被过滤时,优先 只改 SNI;仍不通再改 Host;不要轻易改 path / UUID / port。


9. 同机有真实域名时如何共存

Caddy 对 更具体的 host matcher 优先:

1
2
3
4
5
6
7
8
9
app.example.com {
reverse_proxy 127.0.0.1:8080
}

:443 {
tls internal {
on_demand
}
}
  • Client SNI = app.example.com → 真实站点 + public certificate
  • Client SNI = 其它任意值 → internal CA + Xray catch-all

因此不要把本机 production hostname 当作 Xray 的伪装 SNI,否则流量会进 named site 而非 catch-all。


10. 常见问题

Q1:必须开启 allowInsecure 吗?

是。 tls internal 签发的证书不被系统/浏览器默认信任。要么开启 allowInsecure,要么在支持自定义 CA 的 client 中导入 Caddy local root。

Q2:”任意 SNI” 在网络路径上一定有效吗?

Server 侧:可以。 Network 侧:不保证。 仅做 SNI string-match 的中间设备,换 SNI 可能绕过;若还校验 IP 归属,仍可能被拦。

Q3:为什么 path 要固定?

避免整站 reverse proxy 过于开放。path 过宽更容易被探测。

Q4:在 Xray 里写了 Host 后,client 必须一致吗?

会。 若要 arbitrary Host,删除 wsSettings.headers.Host,也不要在 Caddy 里用 header_up Host 写死常量。

Q5:on_demand 安全吗?

本文仅使用 tls internal(local CA),不消耗 public ACME。建议 Xray 仅 listen localhost、firewall 只放行 22/80/443、UUID 足够随机。

Q6:与域名证书、REALITY 等方案如何对比?

Approach 优点 缺点
IP + self-signed + flexible SNI(本文) 不绑 domain;SNI / Host 灵活 必须 skip cert verify
Domain + public cert + fixed SNI 证书可信 SNI 被拦不好换
REALITY 伪装更强 配置复杂

11. 最小可运行清单

Xray: 127.0.0.1:10000,VLESS + WS,path = /rayws不校验 Host,inbound 无 TLS。

Caddy:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
default_sni 203.0.113.10
}

:443 {
tls internal {
on_demand
}

@xray path /rayws*
handle @xray {
reverse_proxy 127.0.0.1:10000
}

handle {
respond "OK" 200
}
}

Client: address = IP,port = 443,path = /rayws,TLS + allowInsecure;SNI 与 WS Host 可随时修改


12. 参考资料