11058 字
55 分钟
XHTTP 配置与玩法:过 CDN、上下行分离与 Encryption

前言#

本文来自对 XHTTP: Beyond REALITY 和社区讨论以及 Xray-core v26.6.1 源码的研读和实践。

配置基于 Xray v26.9.8 的字段名,示例统一使用以下占位:domain.com 为主域名,cf1.domain.com / cf2.domain.com / cf3.domain.com 为开启橙云的子域,/yourpath 为 XHTTP path,vpsadmin 账户运行 Xray,证书目录 ~/xray_cert。

优势#

  • XMUX:H2/H3 的 0-RTT 多路复用控制
  • 上下行分离:服务端仅按 path 中随机生成的 UUID 关联上下行,两个方向可以走完全不同的入口
  • 请求元数据混淆:会话 ID、seq、上行数据的载体(path / query / header / cookie)与形态都可配置、随机化,避免在 CDN 里留下固定模式
  • extra 分享机制:host、path、mode 以外的所有参数可整块塞进分享链接,由服务提供者下发
  • Browser Dialer:用真浏览器的网络栈和 TLS 指纹发请求
  • 无需 gRPC 库,性能更好,下行是独立 GET 不受 CDN 对 gRPC 的限速,相比 WS/HTTPUpgrade,没有 ALPN = http/1.1 的特征

模式#

模式上行下行HTTP 请求数说明
packet-up分包POST /path/UUID/seqGET 流式N 个兼容性最强
stream-up流式POST /path/UUIDGET 流式2 个上行不牺牲效率,上下行可分离
stream-one单个POST /path/,响应即下行同一请求1 个最接近普通请求,REALITY 默认

“mode” 四选一,客户端、服务端默认值都是 “auto”:

  • “auto” - 客户端:一律 packet-up,REALITY 时 stream-one(有 downloadSettings 时 stream-up)/ 服务端:同时接受三种模式
  • “packet-up” - 客户端:分包上行 + 流式下行(单独的子连接)/ 服务端:仅接受 packet-up
  • “stream-up” - 客户端:流式上行 + 流式下行(另一条子连接)/ 服务端:仅接受 stream-up 和 stream-one
  • “stream-one” - 客户端:流式上行 + 流式下行(同一条子连接),不能有 downloadSettings / 服务端:仅接受 stream-one

模式细节:

  • packet-up 的 seq 从 0 开始,必须发完上一个 POST 的 body 再发下一个;乱序到达由服务端按 seq 重组,默认最多缓存 30 个,超限断连。会话 ID 与 seq 默认拼在 path(/yourpath/UUID/seq),开启混淆后也可挪到 query、header 或 cookie
  • stream-up / stream-one 的上行默认带 Content-Type: application/grpc 伪装(配置 noGRPCHeader 可关闭),加上这个 header 后 H2 流式上行可穿透 CF,需面板开 gRPC 支持
  • stream-one 的 path 若末尾无 / 会自动补上
  • 下行响应头与 packet-up 一致;stream-one 会出现以 SSE 回应 gRPC 的情况,遇到问题可尝试配置 noSSEHeader

packet-up 专属参数:

  • scMaxEachPostBytes:每个 POST 最多携带的字节数,默认 1000000(1MB),应小于中间盒允许的最大值,服务端会拒绝超限 POST
  • scMinPostsIntervalMs:仅客户端,单个代理请求内 POST 的最小间隔,默认 30ms
  • scMaxBufferedPosts:仅服务端,最多缓存的 POST 数,默认 30

前两项建议填范围字符串(如 "500000-1000000")每次随机,减少指纹,三者均基于单个代理请求独立计数。

stream-up 专属参数:

  • scStreamUpServerSecs:仅服务端,默认 "20-80" 随机,每隔该时长向 stream-up 上行 POST 的响应方向写 xPaddingBytes 个字节保活(前提是请求携带 padding,默认总是携带),设 -1 停发保活数据

HTTP 版本(H1 / H2 / H3)#

三种 mode 决定工作模式,ALPN 决定底层 HTTP 版本,它们之间是解耦、可以任意组合的;ALPN 由客户端单方面决定:

客户端配置结果
只要有realitySettingsH2
无tlsSettings 也无 REALITYHTTP/1.1
alpn 为 ["http/1.1"]HTTP/1.1
alpn 为 ["h3"]H3
其余情况(不写alpn、写多项、写别的值)H2

注意:

  • H3 的条件是 alpn 仅有一项且值为 h3,"alpn": ["h3", "h2"] 得到的是 H2,客户端不会优先 H3、失败退回 H2
  • 服务端 alpn 为 ["h3"] 时才监听 UDP/QUIC,否则一律监听 TCP
  • 套 CF 时客户端 H3 会被降成 H1/H2 回源,服务端无需监听 UDP

XMUX#

XMUX 仅在客户端生效,H2/H3 均为 0-RTT 多路复用,XMUX 是控制它们的核心接口:

参数含义省略 xmux 时的取值
maxConcurrency每条连接最多同时承载的代理请求数,达到后建新连接0(不限)
maxConnections最多连接数,达到前每个新请求开新连接,之后开始复用3(固定)
cMaxReuseTimes一条连接最多被复用几次0(不限)
hMaxRequestTimes一条连接累计承载的 HTTP 请求上限(对付 Nginx 每连接 1000 请求上限)"600-900" 随机
hMaxReusableSecs一条连接的最长复用时长(对付 Nginx 一小时上限)"1800-3000" 随机
hKeepAlivePeriod空闲时 H2/H3 保活间隔(秒),0 为 Chrome H2 45s / quic-go 10s0

多线程测速前可设置 "maxConcurrency": 1,即连接数不限,每个线程各占一条连接。

一条底层连接复用到底可设置 "maxConnections": 1,但须同时显式设置 hMaxRequestTimes / hMaxReusableSecs,否则二者值为 0 即不限,连接会被无限复用直到 Nginx 达到 keepalive_requests / keepalive_time 主动断开。

日常可保持默认值。

注意:

  • maxConcurrency 与 maxConnections 冲突,只能二选一
  • hKeepAlivePeriod 是唯一不允许填范围的项(该值取随机本身才是特征),且允许负数(-1 关闭空闲保活)
  • 省略 xmux 或整组都为 0 时取默认值;参数只要有一项非 0,其余项就都没有默认值了,须全部显式填写
  • H3 下若 quicParams.keepAlivePeriod 非 0,则 hKeepAlivePeriod 被忽略
  • 使用 XHTTP 时不要启用 mux.cool,新版服务端已检查,只接受纯 XUDP

请求混淆#

padding 默认以 Referer: /yourpath?x_padding=... 的形式发出,这些在 CDN、反代的访问日志与 WAF 规则里都是显眼的模式,而混淆参数可以把元数据挪走并随机化:

参数作用默认值
xPaddingBytespadding 长度范围(不可关闭,填 0 或负数会报错)100-1000 随机
xPaddingObfsMode总开关,开启后 padding 的位置与样式按下列参数走false(固定 Referer + x_padding)
xPaddingPlacementpadding 放哪:queryInHeader / cookie / header / queryqueryInHeader
xPaddingMethodpadding 内容:repeat-x(重复 X)/ tokenish(随机 Base62)repeat-x
xPaddingKeyquery / cookie 的键名x_padding
xPaddingHeader承载 query 的头名(例如Referer、Origin 等)X-Padding
sessionIDPlacement会话 ID 放哪:path / query / header / cookiepath
sessionIDKey非 path 放置时承载会话 ID 的键名(头名 / 参数名 / cookie 名)X-Session / x_session(由放置位置决定)
sessionIDTable会话 ID 的字符表(预置Base62、Alphabet、hex 等)空(用 UUID)
sessionIDLength会话 ID 的长度范围空(用 UUID)
seqPlacementseq 放哪:path / query / header / cookiepath
seqKey非 path 放置时承载 seq 的键名(头名 / 参数名 / cookie 名)X-Seq / x_seq(由放置位置决定)
uplinkDataPlacementpacket-up 上行数据放哪:body / header / cookie(后两者仅 packet-up)body
uplinkDataKey非 body 放置时承载上行数据的键名(头名 / cookie 名)X-Data / x_data(由放置位置决定)
uplinkChunkSize数据放 header / cookie 时每块编码后的大小header 3-4KB / cookie 2-3KB
uplinkHTTPMethod上行 HTTP 方法,GET 仅 packet-up 可用POST
serverMaxHeaderBytes服务端接受的最大请求头字节数8192
混淆参数随着封锁逐步加入

最先是 CDNVideo 只要请求包含 x_padding=XXXXX 参数,就抛出 403(issue #4346),于是有了换键名、换字符表(tokenish)和挪位置。

接着 Yandex Cloud、VK Cloud 等禁掉 POST(Yandex 文档),于是 uplinkHTTPMethod 允许改用 PUT、PATCH;再往后有 CDN 按 UUID 的 8-4-4-4-12 形状封 session ID(issue #6264),于是 sessionIDTable、sessionIDLength 能将其伪装成普通 token。

Xray 的思路是不应把手里的牌一下子打完(issue #19),所以混淆默认关闭且默认值保守,等某个特征真被针对了再使用对应参数,防止过度配置本身成了新特征,日常使用保持默认值即可。

注意:

  • 数据放 header 时要相应调大 serverMaxHeaderBytes(如 16384,过 CDN 还要留意中间盒的请求头上限)
  • tokenish 生成随机 Base62 串,按 HPACK huffman 编码后长度落在 xPaddingBytes 区间内,服务端校验同样按 huffman 长度算,比一串 X 更像真实数据
  • queryInHeader 仍是把 padding 塞进某个头(可自定义,默认 Referer)的 URL query 里,对 CF 兼容性最好;cookie / header / query 则完全离开 URL

客户端 extra#

extra 字段用于向客户端分享配置,服务端会校验 xhttpSettings 里的同名参数:

  • 两端须一致: padding 的 6 项以及 sessionIDPlacement、sessionIDKey、seqPlacement、seqKey、uplinkDataPlacement、uplinkDataKey。服务端会校验参数,两端不一致时请求会被 400 拒绝
  • 仅客户端: sessionIDTable、sessionIDLength、uplinkHTTPMethod、uplinkChunkSize、noGRPCHeader、scMinPostsIntervalMs
  • 仅服务端: serverMaxHeaderBytes、scStreamUpServerSecs、scMaxBufferedPosts、noSSEHeader

scMaxEachPostBytes 是单向约束,客户端按自己的值分包,服务端拿自己的 To 做上限,不小于客户端即可。

示例为 packet-up 模式,覆盖 extra 可用的全部字段:

客户端
"xhttpSettings": {
"path": "/yourpath",
"extra": {
// 追加的自定义请求头
"headers": { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" },
// 两端一致:padding 与 placement / key
"xPaddingBytes": "100-1000",
"xPaddingObfsMode": true,
"xPaddingPlacement": "queryInHeader",
"xPaddingMethod": "tokenish",
"xPaddingKey": "x",
"xPaddingHeader": "Origin",
"sessionIDPlacement": "query",
"sessionIDKey": "s",
"seqPlacement": "query",
"seqKey": "q",
"uplinkDataPlacement": "header",
"uplinkDataKey": "X-Data",
// 仅客户端:会话 ID 形态、上行形态与分包节奏
"sessionIDTable": "Base62",
"sessionIDLength": "12-20",
"uplinkChunkSize": "2000-3000",
"uplinkHTTPMethod": "GET",
"noGRPCHeader": false,
"scMaxEachPostBytes": "4000-8000",
"scMinPostsIntervalMs": "10-50",
// 仅客户端:XMUX,填了任意一项后须全部显式填写
"xmux": {
"maxConcurrency": 0, "maxConnections": 0, "cMaxReuseTimes": 0,
"hMaxRequestTimes": 0, "hMaxReusableSecs": 0, "hKeepAlivePeriod": 0
}
// "downloadSettings": { ... } // 上下行分离时的完整下行 streamSettings
}
}

注意:

  • downloadSettings.xhttpSettings 里也可写 extra,与上行的 extra 完全一致,XMUX 和请求混淆的字段与规则全部适用
  • 下行配置不继承上行的任何配置,XMUX 参数 roll 出的具体数也是各自独立随机的,随时间推移上下行复用完全不对称

选型#

RAW + REALITY + VisionXHTTP + REALITY
新连接延迟每条代理连接一次 TCP+TLS 握手XMUX 复用,新请求 0-RTT,延迟更低
多线程测速更强,每条连接独立拥塞窗口不如 Vision,除非maxConcurrency: 1
CPU / 吞吐Linux 下自动 Splice,内核直接转发无 Splice,H2 帧处理走用户态
上下行分离没有有(packet-up/stream-up)
中间盒/CDN不可能本身为此设计
抗单连接时序分析Vision 内层握手随机填充padding + XMUX 随机化 + 多流混合
组合适用场景
Vision + REALITY 直连单流拉满带宽、服务器 CPU 不富裕、Linux 环境
XHTTP + REALITY 直连网页浏览、有大量小连接、要上下行分离、要过 CDN 或前置反代
上行 XHTTP+TLS+CDN,下行 XHTTP+REALITY去程差、回程好
上行 XHTTP+REALITY,下行 XHTTP+TLS+CDN去程好、回程差
XHTTP+TLS+CDN去程回程都差或无法直连

玩法与示例配置#

以下示例共用基线结构:客户端 SOCKS 入站 + VLESS 出站,服务端 VLESS 入站 + freedom 出站。

基线:XHTTP + REALITY 直连#

服务端
{
"inbounds": [
{
"listen": "0.0.0.0",
"port": 443,
"protocol": "vless",
"settings": {
"users": [{ "id": "你的UUID" }],
"decryption": "none"
},
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" }, // mode 不填 = auto
"security": "reality",
"realitySettings": {
"target": "www.some-site.com:443",
"serverNames": ["www.some-site.com"],
"privateKey": "PrivateKey",
"shortIds": [""]
}
}
}
],
"outbounds": [{ "protocol": "freedom" }]
}
客户端
{
"inbounds": [
{
"listen": "127.0.0.1",
"port": 10808,
"protocol": "socks",
"settings": { "udp": true }
}
],
"outbounds": [
{
"protocol": "vless",
"settings": {
"address": "你的VPS_IP",
"port": 443,
"id": "你的UUID",
"encryption": "none"
},
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"security": "reality",
"realitySettings": {
"serverName": "www.some-site.com",
"password": "xray x25519 -i 私钥 得到的 Password",
"shortId": "",
"fingerprint": "chrome",
"spiderX": "/"
}
},
// XHTTP 不能开 mux.cool;concurrency 填负数关掉 TCP 复用,XUDP 走聚合隧道
"mux": { "enabled": true, "concurrency": -1, "xudpConcurrency": 16 }
}
]
}

过 CDN(TLS)#

cf1.domain.com 开启橙云、CF 面板 SSL 模式为 Full (strict),服务端持证书。

服务端
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"security": "tls",
"tlsSettings": {
"alpn": ["h2", "http/1.1"],
"certificates": [{
"certificateFile": "/home/vpsadmin/xray_cert/xray.crt",
"keyFile": "/home/vpsadmin/xray_cert/xray.key"
}]
},
// CF 回源必带 CF-Connecting-IP,用它当哨兵头决定是否信任 XFF
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
}

客户端 H2 版,address 填优选 IP,serverName 填域名:

客户端
{
"settings": {
"address": "优选 IP",
"port": 443,
"id": "你的UUID",
"encryption": "none"
},
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "fingerprint": "chrome" }
}
}

使用 H3 需显式指定 alpn:

客户端
"tlsSettings": { "serverName": "cf1.domain.com", "alpn": ["h3"], "fingerprint": "chrome" }

使用 H2 且要流式上行时显式指定 mode(需 CF 面板开 gRPC 支持):

客户端
"xhttpSettings": {
"path": "/yourpath",
"mode": "stream-up" // 或 stream-one
}

有的 CDN 会限速 stream-one 但不限 stream-up,遇到断流或降速尝试切换模式或调整混淆参数;若 CDN 对请求体大小敏感,可调分包节奏:

客户端
"xhttpSettings": {
"path": "/yourpath",
"extra": {
"scMaxEachPostBytes": "500000-1000000", // 需小于 CDN 允许的最大请求体
"scMinPostsIntervalMs": "10-50"
}
}

客户端连接 CF 边缘 IP,CF 用边缘证书握手,按 Host 回源到 VPS,验证 Origin 证书后转发给 Xray。

CF 会掐断下行 100 秒无实际数据的 HTTP,代理长连接需应用层保活,比如使用 sshd 的 ClientAliveInterval。

对于 Fastly、Gcore、CloudFront 这类非 CF 的 CDN 时,trustedXForwardedFor 需使用对应 CDN 每次回源都稳定携带的头(比如Fastly 的 Fastly-Client-IP,CloudFront 的 CloudFront-Viewer-Address),并确认 CDN 把真实客户端 IP 放进了 X-Forwarded-For。

常见问题:

  1. 服务端日志报 invalid x_padding length:0、请求被 400。优先考虑是 CDN 未将 URL 的 query string 透传回源,例如 CloudFront 的缓存策略默认不带 query string,需在 Cache Policy 里把 query string 设为转发全部。

  2. CDN 或 WAF 识别并拦截 ?x_padding= 导致 403,请求无法回源。考虑把 padding 挪出 URL,开启 xPaddingObfsMode 后把 xPaddingPlacement 设为 cookie 或 header,或尝试更改 xPaddingKey,做法见 把元数据搬出 URL。

Nginx 前置(TLS)#

Nginx 持真证书监听 443,XHTTP 入站监听本地明文:

server {
listen 443 ssl;
http2 on;
server_name cf1.domain.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
location /yourpath {
grpc_pass grpc://127.0.0.1:1234; # stream-up / stream-one 用这个
grpc_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
grpc_set_header X-From-Front 1; # 哨兵头,供 trustedXForwardedFor 匹配
grpc_read_timeout 1h;
grpc_send_timeout 1h;
}
location / { root /var/www/html; } # 其余路径是正常网站
}

Xray 入站:

服务端
{
"listen": "127.0.0.1",
"port": 1234,
"protocol": "vless",
"settings": { "users": [{ "id": "你的UUID" }], "decryption": "none" },
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"sockopt": { "trustedXForwardedFor": ["X-From-Front"] }
}
}

TLS 在 Nginx 终结,按 path 把 /yourpath 以 h2c 转发本地 1234,其余路径当普通网站服务。防御主动探测,TLS 指纹是 Nginx 的而非 Go 的。

TLS 版本由 Nginx 决定,特殊情况可回退 TLS 1.2,用于规避审查方对来自某些机房的 TLS 1.3 进行阻断的策略。

packet-up 模式下 grpc_pass 不适用,更换普通反代并关闭缓冲:

location /yourpath {
proxy_pass http://127.0.0.1:1234;
proxy_http_version 1.1;
proxy_buffering off; // 下行必须关,否则流式下行退化
proxy_request_buffering off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-From-Front 1;
proxy_read_timeout 1h;
}

客户端不出现新块,沿用 上文 的字段。

如需使用 H3,使用 Nginx ≥ 1.25 并监听 QUIC 即可,回源的 location 不变:

listen 443 quic reuseport; # H3,需 Nginx ≥ 1.25
listen 443 ssl;
http2 on;
http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400';

客户端 alpn 指定 ["h3"]。

Caddy 默认开启 H3,reverse_proxy 到同一个入站即可。

Cloudflare Worker / Snippet 反代前置#

不想让源站域名直接开橙云回源,可以用 Worker(或更轻量的 Snippet)把请求改写到后端域名再转发,客户端连接 Worker 路由绑定的域名:

Cloudflare Worker
export default {
async fetch(request) {
const url = new URL(request.url);
const backends = ["b1.origin.com", "b2.origin.com"]; // 后端域名,多填即随机挑
url.hostname = backends[Math.floor(Math.random() * backends.length)];
return fetch(new Request(url, request));
},
};

CF 面板里给 Worker 绑定 front.domain.com/yourpath* 路由,客户端 address/serverName/host 指向 front.domain.com,path、UUID、padding 原样透传。

前置域名与源站解耦,适用于随机挑后端、给已被阻断的源站套层 CF 或希望把前置逻辑与回源分开的情况,受 Worker 的 CPU 与子请求配额限制。

Cloudflare Argo 隧道(cloudflared 内网穿透)#

前面几种过 CDN 的方案都要求源站有公网 IP 且要监听端口。依赖 Argo 隧道允许源站无监听端口,公网 IP 和证书。

适合 NAT VPS、无 DDNS 且只有动态 IPv6 的机器、回源端口受限或源站 IP 无法直连的情况。

XHTTP 入站监听本地明文,由 cloudflared 的 ingress 按域名直接转发:

服务端
{
"listen": "127.0.0.1",
"port": 1234,
"protocol": "vless",
"settings": { "users": [{ "id": "你的UUID" }], "decryption": "none" },
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" }
}
}

cloudflared 固定隧道配置:

~/.cloudflared/config.yml
tunnel: 你的隧道ID
credentials-file: /home/vpsadmin/.cloudflared/你的隧道ID.json
protocol: auto # 隧道到 CF 边缘的传输,优先尝试 QUIC(UDP 7844),异常时自动回落 H2,如已确定 UDP 被封锁或环境对 QUIC 支持不佳,可直接指定为 http2
ingress:
- hostname: cf1.domain.com
service: http://127.0.0.1:1234
originRequest:
http2Origin: true # 回源本地入站用 H2,XHTTP 需要
- service: http_status:404

客户端沿用 过 CDN(TLS) 的字段,serverName 与 host 填隧道绑定的 cf1.domain.com。

注意:

  • 必须用固定(命名)隧道,trycloudflare 临时隧道不支持 XHTTP
  • 回源经隧道只有 H2,TLS 由 CF 边缘和隧道负责,Xray 入站明文 h2c
  • 一条隧道可按 hostname 分流给多个入站,也能和 Worker 前置配合使用

上下行分离#

downloadSettings 是一套完整的 streamSettings 外加 address/port,method 必须为 "xhttp"(不可省略),security 可为 "tls" 或 "reality"。

sockopt 项也可被分享,上行 sockopt 设 "penetrate": true 可覆盖下行,适合使用 mark 的情况。

同一 CDN 上行 IPv4 H2,下行 IPv6 H3#

只改客户端:

客户端
"streamSettings": {
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "fingerprint": "chrome" },
"xhttpSettings": {
"path": "/yourpath",
"mode": "stream-up", // 不能使用 stream-one
"extra": {
"downloadSettings": {
"address": "优选 IPv6",
"port": 443,
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "alpn": ["h3"], "fingerprint": "chrome" },
"xhttpSettings": { "path": "/yourpath" } // path 必须一致
}
}
}
}

客户端随机生成 UUID,上行 POST /yourpath/UUID 走 IPv4 的 H2 到边缘 IP-A,下行 GET /yourpath/UUID 走 IPv6 的 QUIC H3 到边缘 IP-B。两个方向的源 IP、目标 IP、四层协议、HTTP 版本均不同。服务端按 UUID 把两半缝合,30 秒内没缝上就终止会话,基于单条连接的检测只能看到半条流。

上行下行不同 CDN#

上行和下行使用两家不同的 CDN。例如上行套 CF,下行套另一家 CDN:

客户端
"streamSettings": {
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "fingerprint": "chrome" },
"xhttpSettings": {
"path": "/yourpath",
"mode": "stream-up",
"extra": {
"downloadSettings": {
"address": "另一家 CDN 的 IP",
"port": 443,
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "b.other-cdn.com", "fingerprint": "chrome" },
"xhttpSettings": { "path": "/yourpath" }
}
}
}
}

两家 CDN 按 SNI 回源到同一台 VPS 的同一个 XHTTP 入站、path 一致,按 UUID 缝合。两个方向所属的 CDN 基础设施不同,任何一方手里都只有半条流。

同域域前置#

三个地址字段在一次 XHTTP over CDN 的请求里互相独立:

字段是什么谁能看见决定什么
address实际拨号目标链路上所有人包发到哪个 IP
tlsSettings.serverNameTLS ClientHello 的 SNI链路上所有人,可用 ECH 加密CDN 用哪张证书握手
xhttpSettings.hostHTTP Host 头(H2/H3 为:authority)TLS 加密,只有 CDN 能看见CDN 回源到哪台机器

SNI 与 Host 不一致即域前置,两者是同一 zone 内不同子域即同域域前置。主流商业级 CDN 均已全面禁止未经授权的跨 zone 域前置,所以采用同域域前置。

cf1 上行、cf2 下行,cf3 为共同 Host,均开启橙云并指向同一 VPS:

客户端
"streamSettings": {
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "fingerprint": "chrome" },
"xhttpSettings": {
"host": "cf3.domain.com", // 客户端发送优先级 host > serverName > address
"path": "/yourpath",
"mode": "stream-up",
"extra": {
"downloadSettings": {
"address": "优选 IP",
"port": 443,
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf2.domain.com", "alpn": ["h3"], "fingerprint": "chrome" },
"xhttpSettings": { "host": "cf3.domain.com", "path": "/yourpath" }
}
}
}
}

两条 TLS 握手 SNI 分别是 cf1 和 cf2,两个方向的 Host 都是 cf3,CF 按 cf3 回源。

当 cf1 和 cf2 橙云均指向同一台 VPS 时,不填 host 也可以,各方向按 SNI 回源至同一台机器同一 path,按 UUID 缝合。需要 host 的场景:源站前有 Nginx 按 server_name 分流、只想为一个域名配回源规则、或想让两个方向走完全一样的回源逻辑。

上下行非对称 XMUX#

客户端
"xhttpSettings": {
"path": "/yourpath",
"mode": "stream-up",
"extra": {
// 上行数据少,全挤在一条底层连接上;过 Nginx 时须限制 hMaxRequestTimes 和 hMaxReusableSecs
"xmux": {
"maxConcurrency": 0, "maxConnections": 1, "cMaxReuseTimes": 0,
"hMaxRequestTimes": 0, "hMaxReusableSecs": 0, "hKeepAlivePeriod": 0
},
"downloadSettings": {
"address": "回程优的 IP",
"port": 443,
"method": "xhttp",
"security": "tls",
"tlsSettings": { "serverName": "cf1.domain.com", "fingerprint": "chrome" },
"xhttpSettings": {
"path": "/yourpath",
"extra": {
// 下行数据多,每条底层连接只跑一个请求,摊到多条链路
"xmux": {
"maxConcurrency": 1, "maxConnections": 0, "cMaxReuseTimes": 0,
"hMaxRequestTimes": 0, "hMaxReusableSecs": 0, "hKeepAlivePeriod": 0
}
}
}
}
}
}

上行所有 POST 复用同一条 TCP,省握手;下行每个 GET 各开一条 TCP,避开单连接拥塞窗口和队头阻塞;两个方向各走最优路径。

上/下行 REALITY 直连 + 下/上行过 CDN#

服务端
{
"inbounds": [
// XHTTP 明文入站,监听本地
{
"listen": "127.0.0.1",
"port": 1234,
"protocol": "vless",
"settings": { "users": [{ "id": "你的UUID" }], "decryption": "none" },
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"sockopt": { "trustedXForwardedFor": ["CF-Connecting-IP"] }
}
},
// 入口 A:REALITY 监听 443,非法 VLESS 首包回落到 1234
{
"listen": "0.0.0.0",
"port": 443,
"protocol": "vless",
"settings": {
"users": [{ "id": "一个用不到的UUID" }],
"decryption": "none",
"fallbacks": [{ "dest": 1234, "xver": 0 }]
},
"streamSettings": {
"method": "raw",
"security": "reality",
"realitySettings": {
"target": "www.some-site.com:443",
"serverNames": ["www.some-site.com"],
"privateKey": "PrivateKey",
"shortIds": [""]
}
}
}
],
"outbounds": [{ "protocol": "freedom" }]
}

入口 B 是 Nginx,监听回源端口 8443,持真证书:

server {
listen 8443 ssl;
http2 on;
server_name cf1.domain.com;
ssl_certificate /etc/ssl/fullchain.pem;
ssl_certificate_key /etc/ssl/privkey.pem;
location /yourpath {
proxy_pass http://127.0.0.1:1234;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}
}
客户端
{
"settings": {
"address": "你的VPS_IP",
"port": 443,
"id": "你的UUID",
"encryption": "none"
},
"streamSettings": {
"method": "xhttp",
"security": "reality",
"realitySettings": {
"serverName": "www.some-site.com",
"password": "Password",
"shortId": "",
"fingerprint": "chrome"
},
"xhttpSettings": {
"path": "/yourpath",
"mode": "stream-up",
"extra": {
"downloadSettings": {
"address": "优选 IP",
"port": 443,
"method": "xhttp",
"security": "tls",
"tlsSettings": {
"serverName": "cf1.domain.com",
"alpn": ["h3"],
"fingerprint": "chrome"
},
"xhttpSettings": { "path": "/yourpath" }
}
}
}
}
}

上行直连 443,REALITY 鉴权通过后,首包非法流量回落至 127.0.0.1:1234。下行走 CF 的 QUIC H3,CF 回源到 8443 的 Nginx 反代给同一个 127.0.0.1:1234。

把客户端的上行、下行对调,就是上行过 CDN、下行 REALITY 直连,服务端配置不用改。

443 端口复用#

443 监听 raw + REALITY 入站,REALITY 鉴权失败的流量回落本机 Nginx,由 Nginx 提供伪装站并将 /yourpath 反代到内部 XHTTP 入站;REALITY 鉴权通过但首包非法的流量回落到同一个内部 XHTTP 入站:

服务端
{
"inbounds": [
// 内部 XHTTP 入站,监听本地
{
"listen": "127.0.0.1", // 也可使用 Unix socket
"port": 1234,
"protocol": "vless",
"settings": {
"users": [{ "id": "XHTTP 的 UUID" }],
"decryption": "none"
},
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
// CF 回源带 CF-Connecting-IP,Nginx 前置注入 X-From-Front
"sockopt": {
"trustedXForwardedFor": ["CF-Connecting-IP", "X-From-Front"]
}
}
},
// raw + REALITY入站,承载 Vision 直连,其余回落
{
"listen": "0.0.0.0",
"port": 443,
"protocol": "vless",
"settings": {
"users": [{ "id": "Vision 的 UUID", "flow": "xtls-rprx-vision" }],
"decryption": "none",
"fallbacks": [{ "dest": 1234, "xver": 0 }] // XHTTP 的 H2 preface 非法,回落到 1234
},
"streamSettings": {
"method": "raw",
"security": "reality",
"realitySettings": {
"target": "127.0.0.1:8443",
"serverNames": ["your.domain.com"],
"privateKey": "PrivateKey",
"shortIds": [""]
}
}
}
],
"outbounds": [{ "protocol": "freedom" }]
}

XHTTP 的 UUID 只在内部入站校验,几种客户端出站方案共用同一个 path 和 UUID。Nginx 配置参考 Nginx 前置,将监听端口改为8443,并给 your.domain.com 配置 server_name 用于主动探测回落即可。

不能使用 path 进行回落,REALITY/TLS 下为 H2,path 提取只解析 H1 的明文请求行,且 XHTTP 会在 path 后追加 UUID 和 seq,H1 下同样无法匹配。

元数据搬出 URL#

默认情况下会话 ID、seq 拼在 path,padding 置于 Referer,位置固定;请求混淆 的参数能把 packet-up 伪装成普通带 cookie 的 GET,请求变成一串没有 body 的 GET:

客户端/服务端
"xhttpSettings": {
"path": "/yourpath",
"mode": "packet-up",
"extra": {
"xPaddingObfsMode": true,
"xPaddingPlacement": "cookie",
"xPaddingKey": "_ga",
"xPaddingMethod": "tokenish",
"sessionIDPlacement": "cookie",
"sessionIDKey": "sid",
"sessionIDTable": "Base62",
"sessionIDLength": "16-24",
"seqPlacement": "cookie",
"seqKey": "n",
"uplinkDataPlacement": "cookie",
"uplinkDataKey": "d",
"uplinkHTTPMethod": "GET"
}
}

uplinkDataPlacement 和 uplinkHTTPMethod 的 GET 仅支持 packet-up 模式,会话 ID 从 UUID 换成 16-24 位的 Base62 串,更像普通 session token。

cookie 每块只装 2 到 3 KB(uplinkChunkSize 可调),上行一大就是一长串 cookie,所以只适合上行小或应对 body 检查的场景。

下行 GET 的 Content-Type: text/event-stream 可由服务端配置 noSSEHeader 去掉;使用 stream-up/one 时,上行的 application/grpc 伪装可由客户端配置 noGRPCHeader 去掉。

Browser Dialer#

使用真实浏览器的网络栈和 TLS 指纹发起连接,数据经本地 WebSocket 回到 Xray,有一定的性能损耗:

客户端
XRAY_BROWSER_DIALER=127.0.0.1:8080 ./xray -c config.json

浏览器打开 localhost:8080 并保持。注意:

  • address 必须是域名,要指定 IP 就改系统 hosts 或内置 DNS
  • tlsSettings 将失效,HTTP 版本由浏览器决定,SNI == host == address
  • 浏览器到服务端必须直连

Firefox 93+ 在严格追踪保护 / 隐私窗口下会无视 unsafe-url 等宽松 referrer 策略并裁掉跨站请求的 Referer(Mozilla 安全博客),padding 默认置于 Referer 会连不上,把 padding 挪到 header 即可以解决。

四层调优(BBR / TFO / MPTCP)#

XHTTP 走 H1/H2 时底层是一条 TCP,可在 sockopt 里对它做拥塞控制、握手和多路径调优:

客户端
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"sockopt": {
"tcpFastOpen": true, // TFO,省一次握手往返
"tcpCongestion": "bbr", // 这条连接单独用 BBR
"tcpMptcp": true // MPTCP 多路径
}
}

注意:

  • tcpCongestion 使用 bbr 前先确认内核支持,sockopt 只改这条连接,全局启用需要 sysctl 设置 net.core.default_qdisc=fq 和 net.ipv4.tcp_congestion_control=bbr
  • tcpMptcp 需要 Linux 5.6+ 且 net.mptcp.enabled=1,客户端所在系统也要支持,只开一端等于没开
  • tcpFastOpen 也可以填队列长度(整数),需客户端、服务端、中间盒都放行,任一环节失败回退普通握手
  • 过 CDN 时客户端到 CF 边缘的握手由 CF 决定,sockopt 只对 CF 回源段、直连与自建反代链路有意义
  • 上下行分离时上行、下行各有各的 sockopt;上行设 "penetrate": true 可覆盖下行

FinalMask 给 H3 调拥塞控制#

客户端
"streamSettings": {
"method": "xhttp",
"xhttpSettings": { "path": "/yourpath" },
"security": "tls",
// 直连,serverName 用解析到 VPS 的域名(灰云或主域)
"tlsSettings": { "serverName": "domain.com", "alpn": ["h3"], "fingerprint": "chrome" },
"finalmask": {
"quicParams": { "congestion": "force-brutal", "brutalUp": "30 mbps" },
"udp": [
// v26.9.9 起端口跳跃是独立的 UDP mask,须在最外层,仅客户端
{ "type": "udphop", "settings": { "remotePorts": "20000-50000", "interval": "5-10", "mode": "intervalRemote" } }
]
}
}

XHTTP H3 无协商机制,不支持 brutal,只能用免协商的 force-brutal,它强制上行按 brutalUp 定速发包。两者都只对 H3 直连有意义。

不适用套 CDN 的方案。套 CF 时 force-brutal 定速的是客户端到对端的 QUIC,到 CF 边缘终止并以 H2/H1 回源;端口跳跃改的是客户端 QUIC 的目标端口,而 CF 边缘只在标准端口接收 QUIC。

端口跳跃在 v26.9.9 从 quicParams.udpHop 挪到了 finalmask.udp 下,字段 ports 改为 remotePorts 且必须显式指定 mode:

  • “intervalRemote”:周期换远端端口,底层 socket 和本地源端口不动,最经典的端口跳跃
  • “intervalLocal”:周期换本地源端口,跳跃点新开本地 UDP socket
  • “perConnRemote”:每条连接定一次,之后整条连接固定不变

老写法在新版会被静默丢弃;服务端要让被跳到的整段端口都能到达 QUIC 监听端口,一般在 nftables/iptables 对端口段进行重定向。

H3 直连默认带着 quic-go 的 Chrome QUIC 指纹(零长 Connection ID),要关掉可在 quicParams 里设 disableChromeParrot。官方文档不建议服务端裸跑 quic-go H3,更推荐藏在真 Nginx/Caddy 后面。

CDN 相关#

Cloudflare#

橙云子域:CF 代理流量,可做 CDN 优选、域前置、回源目标。

灰云子域:CF 只做 DNS 解析、不代理,只有灰云才能解析出真实 IP,适合给直连节点当 address。

当面板 SSL 模式为 Flexible 时,CF 回源走明文 HTTP,建议使用 Full (strict) 并配证书。

回源端口需要是 Cloudflare 支持的端口,非标端口(比如 10086)需使用 Origin Rule 重写回源端口。

CF 面板可选设置 Cache Rules,按 CDN 主机名或 XHTTP path 匹配、缓存资格设为绕过(Bypass),虽然 XHTTP 下行本来就不进缓存,但可以预防 CF 版本行为变化。

VLESS Encryption#

过 CDN 时外层 TLS 终结在 CF 边缘,CF 解密后能看到内层 VLESS 明文。纯 VLESS 自身不加密,能读到明文的包括 CF 本身,以及回源段若非 Full (strict) 时 CF 与 VPS 之间的中间人。

VLESS Encryption 在 VLESS 内层端到端认证加密,独立于外层 TLS 与公共 CA 体系,客户端预置服务端静态公钥(X25519 或 ML-KEM-768),每条连接做临时密钥交换,兼具前向安全与后量子安全;载荷走 AES-256-GCM / ChaCha20-Poly1305,即使 CF 或链路上任何人拿着有效证书 MITM 掉外层 TLS,没有服务端静态私钥也无法伪造内层握手和读取内容。

执行 xray vlessenc 生成配对的 decryption/encryption,输出含 X25519 与 ML-KEM-768 两版,二选一不要混用(握手本身两者都后量子安全,ML-KEM-768 版额外能防客户端参数泄露后被未来量子计算机破解出私钥冒充服务端)

配置串以 . 分块:

"mlkem768x25519plus.<mode>.<rtt>.<padding...>.<key>[.<key>...]"

mlkem768x25519plus 为握手方式,<mode> 为流量外观:

  • “native”:头部有公钥特征,流量为 TLSv1.3 的 23 3 3 l>>8 l AEAD 头特征
  • “xorpub”:头部无公钥特征,流量同上
  • “random”:全随机数加密

<rtt> 服务端为 0-RTT 有效期如 600s(可写范围 60-600s,0 则关闭 0-RTT),客户端为 0rtt/1rtt。

<padding> 是可选参数,仅作用于 1-RTT 以消除握手的长度特征,每个字段为 probability-from-to 三段式:

<probability>-<from>-<to>
  • <probability>:0–100 的百分比(实际命中率为 (probability+1)%)
  • <from> / <to>:左闭右开区间。len 类为字节数,gap 类为毫秒数

字段按 len、gap 交替串联,以 strings.Split(padding, ".") 结果数组的下标判断,偶数(i%2 == 0)为 len,奇数(i%2 == 1)为 gap。

双端默认值均为 “100-111-1111.75-0-111.50-0-3333”:

  1. “100-111-1111”: 在 1-RTT client/server hello 后以 100% 的概率粘上随机 111 到 1111 字节的 padding
  2. “75-0-111”: 以 75% 的概率等待随机 0 到 111 ms
  3. “50-0-3333”: 再次以 50% 的概率发送随机 0 到 3333 字节的 padding(若为 0 则不 Write())

服务端、客户端可以设置不同的 padding 参数,按 len、gap 的顺序无限串联;padding 参数必须整体位于 key 之前。

第一个 padding 概率必须为 100% 且 <from>-<to> 都必须 ≥ 18+17 = 35,所有 len 段最大值之和必须 ≤ 18+65535 = 65553。

<key> 为身份认证密钥,至少选择 1 个:

  • decryption: X25519 私钥(32B) | ML-KEM-768 种子(64B)
  • encryption: X25519 公钥(32B) | ML-KEM-768 封装密钥(1184B)

配置串在格式上支持多级 relay 密钥链,认证密钥块可重复。客户端按中继顺序依次列出链路上每一级的公钥或封装密钥,核心按这个顺序逐级完成身份认证,每一级都基于公私钥机制,且下一级被上一级加密绑定。

客户端(出站 encryption)在 <key> 按中继顺序配置链路上每一级的密钥(即列出 N 个密钥,第 j 个是第 j 级节点的公钥或封装密钥),每一级独立完成一次身份认证。

服务端(入站 decryption,即终止节点),只配置自己终止的级,通常就是一个密钥。中间节点转发时只剥离自己那一级,把剩余部分连同 iv 继续往下传,全程不解密流量。

中间节点只配置自己的私钥以及下一级公钥的 hash32,并不解密流量。相邻两级通过一个 32 字节的 blake3 hash 绑定,发送方向第 j 级写入的是第 j+1 级公钥的 hash32,并用第 j 级协商出的密钥加密;第 j 级节点解出后与本地记录比对,不符合直接断连。

例如对于一个三级密钥链 C → A → B → S:

节点需要配置的密钥
C(客户端)3 个公钥,按 A、B、S 的顺序
A(中转)自己的私钥 + 下一级信息
B(中转)自己的私钥 + 下一级信息
S(服务端)1 个私钥

级数直接决定握手长度:

Lrelays=∑i=1nℓi+32(n−1)L_{\text{relays}}=\sum_{i=1}^{n}\ell_i+32(n-1)

其中:

  • LrL_r: relays 长度
  • ℓi\ell_i: 第 ii 级密钥材料长度
  • nn: 级数

Ph=ℓiv+Lr,ℓiv=16P_h = \ell_{\mathrm{iv}} + L_r,\quad \ell_{\mathrm{iv}} = 16

其中:

  • PhP_h: 握手前缀长度
  • ℓiv\ell_{\mathrm{iv}}: iv 的长度,固定为 1616 字节
IMPORTANT

Xray-core 尚未实现中转转发。开发者表示:入站即出站,这个需要写代码来中转,Xray-core 暂无此功能,计划在 vless-relay 项目中实现(参考)。即关于多级密钥链,目前仅在格式上有除 hash32 字段以外的支持,转发还尚未实现

最短可用形式:

服务端
"decryption": "mlkem768x25519plus.native.600s.<X25519 Privatekey | ML-KEM-768 Seed>"
客户端
"encryption": "mlkem768x25519plus.native.0rtt.<X25519 PublicKey | ML-KEM-768 EncapsulationKey>"

注意:

  • REALITY 本身已在同一条直连链路上做了服务端认证与端到端加密,无需另外设置
  • 裸跑(即 security: "none" 且无可靠 finalmask 时) 抗不住熵检测与主动探测,不要这样做
  • 只要 TLS 终结在你不完全信任的中间盒,VLESS Encryption 就有意义

XTLS Vision 穿透 Encryption#

仅设置 VLESS Encryption 时,握手后所有数据经 AEAD 加密封装成伪 TLS 1.3 应用数据记录,核心将数据按 8192 字节分段,每段附一个 5 字节头(即 TLSv1.3 应用数据记录头)与 16 字节 AEAD tag,两个方向各一套 AEAD。

全程只能以读入、解封、复制、转交的方式在用户态完成,没有 ReadV 或 splice 可用。这部分开销叠在外层 TLS 或 XHTTP 的帧处理之上,吞吐越高越明显。

Vision 是 VLESS 的 flow: xtls-rprx-vision 所激活的一段流内协议,先分块填充外层去除 TLS-in-TLS 的长度特征,再在确认内层是 TLS 1.3(且套件非 TLS_AES_128_CCM_8_SHA256)后于第一条完整的应用数据记录处剥去外层加密层(无 Encryption 时即外层 TLS),穿透到裸 socket 做 splice(目前仅下行方向,上行 splice 尚未启用),从此该方向不再经过承载层的编解码,省掉双重加密的开销。

NOTE

Encryption 也有 padding,但其与 vision 中的分块填充分工不同且时段不重叠:

Encryption 的 <padding> 用于自身 1-RTT 握手,对端一次性读走丢弃。

Vision 的分块填充只覆盖内层 TLS 握手期,从 VLESS 体的第一帧起,首帧加 21 字节(16 字节 UUID + 5 字节帧头),其后每帧只加 5 字节帧头,再按内容长短补随机填充;到内层出现第一条完整的应用数据记录即停止(内层是 TLS 1.2 或根本不是 TLS 时,另有更早的结束条件)。

层次上 Vision 的填充在 AEAD 之内,AEAD 记录长度随填充变化;两者掩盖的特征也不同,前者处理伪 TLS 握手的定长块,后者处理 TLS-in-TLS 的长度特征。

顺带一提,Vision 的默认填充参数是在 ClientHello 只有数百字节的年代定的,这套区间可能已经失效,维护者已在 #5391 提出要调整默认参数。

在 VLESS Encryption 下,穿透与 splice 独立判定:

Encryption 之下穿透(旁路 Encryption 的 AEAD)内核 splice(仅下行)
裸 TCP(security: "none")是是,Linux/Android 上零拷贝
TLS / REALITY是否,剥到 TLS 层为止,退回用户态复制
XHTTP / CDN 等带帧层的传输是否

确认内层为 TLS 1.3(套件非 TLS_AES_128_CCM_8_SHA256)后,该方向在第一条完整的应用数据记录处切到 direct copy(接收方向以对端帧头里的 direct 指令为准),读写落在 Encryption 下层,AEAD 不再参与,省一层加解密;<mode> 为 random 时 XOR 层保留,AEAD 同样被旁路。

能否进一步做零拷贝 splice,取决于剥去 Encryption 后是否为裸 socket(核心不再往下剥 TLS/REALITY(避免二次穿透),也不剥 random 的 XOR 层)。

所以仅有 Encryption 下为裸 TCP 时才支持 splice,且要求出站侧同样可拼接(如 freedom)、TCP 层 finalmask 可旁路(sudoku、xmc、服务端 fragment 不支持)。不满足时数据同样会绕过 AEAD,但多几次用户态内存拷贝。

两端的 flow 必须一致,服务端漏写会直接拒绝该用户:

服务端
"users": [{ "id": "你的UUID", "flow": "xtls-rprx-vision" }],
"decryption": "mlkem768x25519plus.native.600s.<X25519 Privatekey | ML-KEM-768 Seed>"
客户端
"users": [{ "id": "你的UUID", "flow": "xtls-rprx-vision", "encryption": "mlkem768x25519plus.native.0rtt.<X25519 PublicKey | ML-KEM-768 EncapsulationKey>" }]

xtls-rprx-vision 会拦掉目标是 443 的 UDP(QUIC),需要 QUIC 时改用 xtls-rprx-vision-udp443。

TLS-in-TLS 长度特征#

内层 TLS 记录被外层原样封装,只要外层不把内层记录合并或拆分,密文流上的包长就与内层分别对应。审查方仅通过包长即可识别。

内层 TLS 握手最初几个包的长度很有规律,CCS + Finished 约 80 字节,首个应用数据记录(通常是 h2 SETTINGS)约 60 字节,这两个短包近乎定值(参考);

ClientHello 在启用 X25519MLKEM768 后从数百字节涨到 1.5 KB 以上,ServerHello 因 key_share 回显也增大约 1.1 KB,证书 flight 则随站点证书链变化,且是数千字节的突发而非单条记录(RFC 8446 §4 允许握手消息跨记录与多条消息合进一条记录)。

这样一串握手形状的包长出现在一条已建立好的加密连接里,几乎只可能来自嵌套的第二层 TLS。

该特征是元数据问题,只能靠填充或重新分帧消除。同时填充只能把包撑大、无法缩小,多路复用只能增加往返、无法减少,两者都有原理性上限(Xue et al., USENIX Security 2024(该论文的测量部分另有争议,见下))。

是否需要 flow 的填充,取决于该特征对谁可见(下表均为关闭 flow 的状态):

场景(无 flow)包长是否与内层对应谁看得见
TLS / REALITY 直连是,真 TLS 记录on-path 上任何人
Encryption native/xorpub,裸 TCP是,伪 TLS 头明文on-path 上任何人
Encryption random,裸 TCP否,仅帧头被 XOR 打乱无人
XHTTP(H2/H3)且非 Encryption random否,外层 HTTP 分帧不对应on-path 不可见;外层 TLS 终结端可见
KCP 传输、xdns、sudoku 等否,段化/封装/整条流变换无人
TIP

为什么 Encryption native/xorpub 也有该特征?

Encryption 与真 TLS 记录同构,如果将 Encryption 层看作长度透明(这里和后面的透明指公开、可查、可验证)的封装层(即 1:1 分帧 + 明文长度字段),与真 TLS 是一致的(记录头明文,外长 = 内长 + tag)。

而在 random 下,帧头会被 XOR 运算打乱,无该特征。

即当外层同时满足“内容不可读 + 帧头可读 + 分帧 1:1 + 长度可由内层计算”时,内层的分帧与长度可被原样透出。

Vision 通过 direct copy 动作实现穿透,穿透后该方向将由内层 TLS 而非 Encryption 的 AEAD 封装。此时对外层 TLS 终结端来说,载荷从不可解析的 AEAD 密文变成一串包长可见、内容不可读的嵌套 TLS 记录;但对于 on-path 仍不可见。

另外,填充抹掉的只是长度维度,方向与往返节奏仍然存在。Xue 等人在 ISP 上实测 1.1 亿条流,XTLS Vision 的长度特征被填平后,只凭方向序列仍能识别出 51.281% 的 XTLS-vision(论文近似建模,非当前的原生完整实现)流量,误报率 0.1989%;未填充的 vless over tls 对照组检测率为 74.830%(非同一检测模型)(USENIX Security ‘24 - Fingerprinting Obfuscated Proxy Traffic with Encapsulated TLS Handshakes)。

同时,开发者对该论文的测量部分提出过两点质疑:

  • 一是被测的被代理流量全是 TLS 1.2(现实世界以 TLS 1.3 为主,少一次握手、特征更少),而 TLS 1.2 不会触发 Vision 的穿透,握手之后仍带着 TLS-in-TLS 特征,测的并不是实际部署下的形态
  • 二是 Table 3 里 Vision 用的是专门逆向该协议定制的 c2 模型,而不是通用的 c1 模型,与其他协议的数字不具直接可比性

据开发者转述,第一作者的解释是内层 TLS 1.3 与 c1 模型对 Vision 的检测效果都不佳,于是选了 TLS 1.2 并未放出 c1 的数据(评论一、评论二)。

方向与往返节奏仍然可辨的结论仍然成立,开发者自己也把“一短去一长回再开始传输数据”列为当前最大的问题,并表示“应该顺便把这个流量特征给消除掉”(#5391)。

ECH 加密#

ECH(Encrypted Client Hello)将 ClientHello 拆成内外两层,内层为真实域名,用 ECHConfig 内的 HPKE 公钥加密后置于 encrypted_client_hello 扩展;外层是 Decoy SNI。中间人因此无法得知真实的 SNI。

客户端启用 ECH 需设置 tlsSettings.echConfigList,有两种形式:

形式写法特点
固定 ECHConfigbase64 的 ECHConfigList无额外查询,服务端轮换密钥后需手动同步客户端
从 DNS 服务器查询"cf1.domain.com+https://1.1.1.1/dns-query"自动跟随轮换,按记录 TTL 缓存

固定 ECHConfig#

不依赖 DNS 服务器,执行 xray tls ech 生成:

Terminal window
xray tls ech --serverName domain.com # 外层 SNI,输出 ECH config list 与 ECH server keys
xray tls ech --pem # 输出 PEM
xray tls ech -i "<ECH server keys>" # 丢失 config list 可由 server keys 反推
服务端
"tlsSettings": {
"serverName": "domain.com",
"certificates": [
{ "certificateFile": "/home/vpsadmin/xray_cert/xray.crt", "keyFile": "/home/vpsadmin/xray_cert/xray.key" }
],
"echServerKeys": "<ECH server keys>"
}
客户端
"tlsSettings": {
"serverName": "domain.com",
"echConfigList": "<ECH config list>"
}

ECH 命中时客户端用内层 ClientHello 的域名(即 serverName)验证服务端证书,所以服务端证书必须包含内层域名;服务端配置 ECH 后仍然接受非 ECH 连接。

NOTE

根据 RFC 9849 6.1.7 的要求,服务端拒绝 ECH(如密钥轮换,客户端配置过期)时,握手改用明文的 Decoy SNI 继续,此时需要使用 Decoy SNI 的证书。

从规范层面来讲,若服务端未持有外层 SNI 的证书,降级路径必然失败。但实际上,uTLS/Go 在 ECH 被拒时会直接报 tls: server rejected ECH 终止连接,不进行 retry_configs 重试。所以对于 Xray,外层 SNI 可以任意选择合理的域名。

从 DNS 服务器查询#

CF 的 Decoy SNI 固定是 cloudflare-ech.com,所有启用 ECH 的站点共用。开启 ECH 后域名的 HTTPS(TYPE65) 记录里会多出 ech= 字段:

Terminal window
dig +short HTTPS cf1.domain.com
# 1 . alpn="h2,h3" ipv4hint=... ech="AEX+DQBB..."

Xray 会通过 HTTPS 记录动态获取其 ECHConfig,DNS服务器支持 https://(DoH)、h2c://、udp:// 三种:

客户端
"tlsSettings": {
"serverName": "cf1.domain.com",
"alpn": ["h2"],
"echConfigList": "cf1.domain.com+udp://1.1.1.1:53"
}

域名+DNS服务器 其中前缀是”指定该域名用于查询 ECHConfig”,不写前缀时按 serverName 或 address(如果 SNI 为空且目标为一个域名)查询;通常用于将查询域名与目标域名分开(比如你想从 DNS 获取 ECHConfig 但又不想暴露自己在查询这个域名的 HTTPS 记录或者在这个域名下发布 HTTPS 记录时)。

Xray 遵守 DNS 下发的 TTL;首次同步阻塞查询,过期不足 4 小时使用旧值连接、后台 goroutine 异步刷新,过期超过 4 小时同步阻塞刷新。

CF 轮换 ECH 密钥很快(记录 TTL 约 5 小时),一旦指定的解析器记录滞后,就会拿过期的 ECHConfig 去握手,导致 tls: server rejected ECH,需留意。

当查询失败或 base64 解不开时,Xray 会使用非法 ECHConfig 让握手必然失败,不会造成 SNI 泄露。

ECH 查询的连接使用 internet.DialSystem,不经过路由规则和出站,可用 echSockopt 的 dialerProxy 指定 tag 出站:

客户端
"tlsSettings": {
"serverName": "cf1.domain.com",
"echConfigList": "https://1.1.1.1/dns-query",
"echSockopt": { "dialerProxy": "proxy" }
}

注意:

  • ECH 仅抵御基于 SNI 的识别与封锁,不防 MITM
  • ECH 只对 security: "tls" 有意义
  • 如果配置了 minVersion,其值必须为 1.3
  • 不要再为 ECH 配置 fingerprint: "unsafe" 了,早期 utls 的 BUG 已经被修复了

证书:ACME DNS-01 与 CF Origin CA#

cf1/cf2 同属一个 zone,签一张 *.domain.com(可加主域)的通配符即可:

ACME + DNS-01Origin CA
签发需 API token面板点几下,复制粘贴
有效期90 天,cron 自动续15 年,无续期
谁信任所有浏览器/系统只有 CF
额外依赖acme.sh + token 存 VPS无

ACME DNS-01 证书#

登录 Cloudflare 获取 Cloudflare API Token

验证 token 是否存活:

Terminal window
curl -s -H "Authorization: Bearer 你的token" https://api.cloudflare.com/client/v4/user/tokens/verify

签发证书:

Terminal window
export CF_Token="你的token"
export CF_Account_ID="你的AccountID"
acme.sh --set-default-ca --server letsencrypt
acme.sh --issue --dns dns_cf -d "domain.com" -d "*.domain.com" --keylength ec-256

token 会被明文存进 ~/.acme.sh/account.conf 供续期自动复用,机器需保证安全,如发生泄露需要在面板 Revoke。

安装给 Xray:

Terminal window
mkdir ~/xray_cert
acme.sh --install-cert -d "domain.com" --ecc \
--fullchain-file ~/xray_cert/xray.crt \
--key-file ~/xray_cert/xray.key
chmod +r ~/xray_cert/xray.key # Xray 非 root 运行时

acme.sh 自带每日 cron,自动续期并重新执行 install-cert,Xray 默认热重载证书。

CF Origin CA 证书#

只有 CF 信任,15 年免续:

  1. 面板 -> SSL/TLS -> Origin Server -> Create Certificate
  2. 保持默认:ECC 私钥,Hostnames 填 domain.com 和 *.domain.com,15 年有效期 -> Create
  3. 页面给出 Origin Certificate 和 Private Key 两段 PEM,存成两个文件:
Terminal window
mkdir ~/xray_cert
vim ~/xray_cert/xray.crt # 粘贴 Origin Certificate 整段,含 BEGIN/END 行
vim ~/xray_cert/xray.key # 粘贴 Private Key 整段
chmod 600 ~/xray_cert/xray.key
chmod +r ~/xray_cert/xray.crt
  1. Xray/Nginx 的引用方式与 ACME 完全一致,泄露或换机器时在 Origin Server 页面 Revoke。

注意事项#

  • packet-up 和 Referer 长 padding 会刷出大量长日志,建议在反代软件里指定不记录;使用请求混淆也可让日志形态不再扎眼
  • address 填优选 IP 时 serverName 必填,且 IP 不能当 SNI
  • v26.7.11 起 REALITY 服务端在 minClientVer 留空时默认为 26.3.27,其他内核和旧客户端会被静默拒连,不建议随意降低 minClientVer 放行,也不要让客户端发出非正常的 ClientHello
  • v26.9.8 起 REALITY 服务端移除 minClientVer 限制并强制要求 ClientHello 携带 X25519MLKEM768,奇怪和过时的指纹会直接被当回落流量处理
  • XHTTP 目前只有 Xray 原生支持;sing-box 主线尚未内置,有社区 fork 支持;Mihomo 自 2026 年 3 月(约 v1.19.22)起支持 xhttp-opts。一些订阅转换工具会把 XHTTP 的某些字段静默丢弃,实践中需留意
XHTTP 配置与玩法:过 CDN、上下行分离与 Encryption
https://blog.cobweb11.top/posts/xhttp/
作者
Mochi
发布于
2026-08-13
许可协议
CC0 1.0 Universal