跳转到内容

传输层与 TLS

传输层负责在两台机器之间承载代理协议。在 VLESS、VMess、Trojan 或 HTTP 连接下面,以及 SOCKS 或 Shadowsocks 出站下面,etemenanki-app 可以使用明文 TCP、TLS、WebSocket,或者基于 HTTP/2 的 gRPC 隧道,还可以在 WebSocket 或 gRPC 下面再加一层 TLS。服务端用 [inbound.stream] 表选择传输层,客户端用 [outbound.stream] 表选择。一条连接的两端必须选择相同的传输层。

本页介绍 stream 表及其 tls、ws、grpc 子表的每个字段:哪些组合合法、哪些协议能使用传输层、证书如何加载和校验,以及各承载方式固定的超时和上限。当你要把代理放在 TLS、CDN 或反向代理后面,或者要连接这样部署的 Xray 服务器时,请阅读本页。katana 使用相同的传输层,但设置来自面板,详见 katana 自己的页面。

每种传输层都从一条 TCP 连接开始。network 选择其上的承载方式,security = "tls" 则在 TCP 与 WebSocket 或 gRPC 承载之间加入 TLS。代理协议运行在最内层。

flowchart LR
  tcp["TCP 连接"]
  tlsA["TLS,无 ALPN"]
  tlsB["TLS,ALPN 为 http/1.1 或 h2"]
  carrier["WebSocket,或基于 HTTP/2 的 gRPC"]
  proto["代理协议:VLESS、VMess、Trojan……"]
  tcp -->|"network = tcp"| proto
  tcp -->|"network = tls"| tlsA --> proto
  tcp -->|"ws 或 grpc,security = tls"| tlsB --> carrier
  tcp -->|"ws 或 grpc,无 security"| carrier
  carrier --> proto

WebSocket 每条 TCP 连接只承载一条代理连接。gRPC 入站在每条 TCP 连接上接受多条隧道,每个 HTTP/2 stream 一条;gRPC 出站则为它拨号的每个流新建一条 HTTP/2 连接。

一台在 443 端口上监听、使用 WebSocket 加 TLS 的 VLESS 服务端,以及连接它的客户端。服务端需要一张 proxy.example.com 的证书;客户端用系统根证书校验它。

server.toml
[[inbound]]
tag = "vless-in"
protocol = "vless"
listen = "0.0.0.0"
port = 443
[inbound.stream]
network = "ws"
security = "tls"
[inbound.stream.ws]
path = "/ray"
[inbound.stream.tls]
cert_file = "/etc/etemenanki/cert.pem"
key_file = "/etc/etemenanki/key.pem"
[inbound.settings]
users = [{ id = "11111111-2222-3333-4444-555555555555" }]
[[outbound]]
tag = "direct"
protocol = "freedom"

客户端既没有设置 tls.server_name,也没有设置 ws.host,所以两者都回退到 server:它发送 SNI proxy.example.com,按这个名称校验证书,并发送 Host: proxy.example.com。VLESS over WebSocket 与 TLS 演示了完整的部署过程,VMess over gRPC 与 TLS 则是 gRPC 版本。

每次修改后都运行 etemenanki-app --test -c server.toml。--test 会校验 stream 表,并读取证书、私钥和 CA 文件,所以本页中除了运行中连接才会出现的错误之外,其他错误都能在这一步发现。

[inbound.stream] 和 [outbound.stream] 使用同一套字段。省略这个表等同于 network = "tcp"。任何层级(包括子表)出现未知字段都会报错。

键类型必填默认值说明
networkstring (enum)否"tcp"协议下面的承载方式:tcp(明文 TCP)、tls(TLS over TCP)、ws(WebSocket)或 grpc(基于 HTTP/2 的 gRPC)。区分大小写,也不会去除首尾空格:"WS"、" ws" 和 "" 都会报 unknown stream network。没有传输层的协议在这里只接受 tcp(或空字符串)。
securitystring (enum)否"none"none(或空字符串)表示不再加一层,tls 表示在 ws 或 grpc 下面加一层 TLS。首尾空格会被忽略,但区分大小写。其他任何值都报 unknown stream security "..." (expected "tls" or "none")。network = "tcp" 时不接受 tls,应改写为 network = "tls"。network = "tls" 时,无论这里写什么,连接都是 TLS。
tlstable视情况—证书、私钥和证书校验设置,只有 stream 真正用到 TLS 时才会读取;例外是 ws 或 grpc 出站总会读取 server_name,作为 Host 或 :authority 的后备值。使用 TLS 的入站必须在这里写 cert_file 和 key_file。见 [stream.tls] 表。
wstable否—WebSocket 的路径和 Host,只有 network = "ws" 时才会读取。见 [stream.ws] 表。
grpctable视情况—gRPC 的服务名和 authority,只有 network = "grpc" 时才会读取;此时必须设置 grpc.service_name。见 [stream.grpc] 表。

ws 和 grpc 子表只在对应的 network 下才会读取,tls 中的证书和校验字段也只在 stream 使用 TLS 时才会读取。在 network = "grpc" 下写一个 [inbound.stream.ws] 表会被接受,但不起作用;在明文 ws network 下的 [outbound.stream.tls] 里写 ca_file 或 allow_insecure 也一样,即使文件不存在也不报错。例外是 tls.server_name:ws 或 grpc 出站无论是否使用 TLS,都会把它作为 ws.host 或 grpc.authority 的后备值。

network 指定承载方式,security 要求在其下面加一层 TLS。etemenanki-app 会校验这两个值的组合,而不是只把 security 与 "tls" 比较、其余情况一律走明文分支:如果配置要求 TLS 却悄悄得到明文,代理凭据会在任何错误出现之前就以明文发出。

network security 缺省、"" 或 "none" security = "tls" 其他 security 值
"tcp"(默认) 明文 TCP 拒绝:请使用 network = "tls" 拒绝
"tls" TLS over TCP TLS over TCP 拒绝
"ws" 基于明文 TCP 的 WebSocket 基于 TLS 的 WebSocket 拒绝
"grpc" 基于明文 HTTP/2(h2c)的 gRPC 基于 HTTP/2 over TLS 的 gRPC 拒绝
其他任何值 拒绝 拒绝 拒绝

network = "tls" 是本项目自己对 TLS over TCP 的写法。Xray 把同样的东西写成 "network": "tcp" 加 "security": "tls",而这恰好是 etemenanki-app 唯一拒绝的组合,错误信息会直接给出改法:

configuration invalid: inbound vless-in: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (security = "tls" layers TLS under network = "ws" or "grpc")

其他错误如下:

inbound vless-in: unknown stream network "WS"
inbound vless-in: unknown stream security "reality" (expected "tls" or "none")

自己掌管连接、或者从不拨号代理服务器的协议,无法承载传输层。etemenanki-app 不会悄悄丢弃这类协议的 stream 表,免得你以为端口已经伪装、实际却是裸露的,而是直接拒绝。

方向 接受 [stream] 拒绝传输层
入站 IP 监听器上的 http、trojan、vless、vmess socks、shadowsocks、hysteria2、tun,以及 Unix socket 上的所有协议
出站 socks、http、trojan、vless、vmess、shadowsocks freedom、blackhole、hysteria2、wireguard

对拒绝传输层的协议,network 只能缺省、为 "" 或 "tcp",security 只能缺省、为 "" 或 "none";这里会忽略首尾空格。其他值都会失败:

inbound socks-in: protocol socks does not support stream network "ws"
inbound ss: protocol shadowsocks does not support stream security "tls"
inbound local: protocol vless over a unix socket does not support stream network "ws"
outbound direct: protocol freedom does not support stream network "ws"

Hysteria 2 总是运行在自带 TLS 的 QUIC 之上。它的证书、私钥和校验字段位于 [inbound.settings] 和 [outbound.settings],见 Hysteria 2。

每个标签页给出服务端配置和对应的客户端配置,省略了协议本身的 [inbound.settings] 和 [outbound.settings]。

不需要 stream 表;下面两段与省略它效果相同。

[inbound.stream]
network = "tcp"
[outbound.stream]
network = "tcp"

[inbound.stream.tls] 和 [outbound.stream.tls] 存放所有 TLS 形态的证书设置:network = "tls",以及带 security = "tls" 的 ws 或 grpc。这个表只有一套字段,但入站只读取 cert_file 和 key_file,出站只读取另外三个。

键类型必填默认值说明
server_namestring否—仅用于出站。作为 SNI 发送、并用来校验服务器证书的名称。未设置时使用出站的 server。如果这个名称是 IP 地址,则不发送 SNI,证书里必须包含该 IP。ws 或 grpc 出站(无论是否带 TLS)也会拿它作为 ws.host 或 grpc.authority 的后备值。入站忽略此项。
allow_insecurebool否false仅用于出站。完全跳过证书链和主机名校验。不能与 ca_file 同时设置(报 tls.allow_insecure and tls.ca_file cannot both be set)。入站忽略此项。
ca_filepath否—仅用于出站。PEM 格式的 CA 证书文件,在系统根证书之外额外信任这些 CA;主机名仍然会校验。文件里没有证书时报 no certificate in CA PEM bundle。不能与 allow_insecure 同时设置。入站忽略此项。
cert_filepath视情况—仅用于入站;入站使用 TLS 时必填(否则报 tls stream needs tls.cert_file)。PEM 格式的证书链:叶子证书在前,中间证书在后。出站忽略此项,不支持客户端证书。
key_filepath视情况—仅用于入站;入站使用 TLS 时必填(否则报 tls stream needs tls.key_file)。与叶子证书对应的 PEM 私钥;私钥与证书不匹配时报 no private key assigned。出站忽略此项。

使用 TLS 的入站需要这两个文件。证书文件是 PEM 格式的证书链,叶子证书在前,后面跟着所有中间证书,也就是大多数 ACME 客户端签发的 fullchain.pem。私钥必须与叶子证书匹配。

[inbound.stream.tls]
cert_file = "/etc/etemenanki/fullchain.pem"
key_file = "/etc/etemenanki/privkey.pem"

无论客户端发送什么 SNI,入站都向所有客户端提供这同一张证书;它不按名称选择证书,也不拒绝未知的名称。它从不要求客户端出示证书。

问题 错误
缺少 cert_file inbound vless-in: tls stream needs tls.cert_file
缺少 key_file inbound vless-in: tls stream needs tls.key_file
文件不存在或无法读取 No such file or directory (os error 2),或其他操作系统错误
cert_file 中没有 PEM 证书(例如私钥和证书的路径写反了) no certificate in PEM bundle
key_file 中没有 PEM 私钥 一条以 No supported data to decode. Input type: PEM 结尾的 OpenSSL 解码错误
私钥与证书不匹配 OpenSSL 错误 SSL_CTX_check_private_key:no private key assigned

etemenanki-app 在构建配置时读取这些文件,即启动时和每次热重载时。只有配置文件内容发生变化才会触发重载,所以磁盘上续期后的证书不会被自动加载。证书续期后,请重启进程或修改配置文件。

出站要决定三件事:作为 SNI 发送的名称、期望在证书中看到的名称(与前者相同),以及信任哪些证书颁发机构。

这个名称在设置了 tls.server_name 时取其值,否则取 server。由于每个能使用传输层的出站都要求设置 server,名称总是存在的。当你用 IP 地址拨号、而证书签发给某个域名时,或者服务器位于按 SNI 路由的前端之后时,请设置 server_name:

[[outbound]]
tag = "proxy"
protocol = "trojan"
server = "203.0.113.10"
port = 443
[outbound.stream]
network = "tls"
[outbound.stream.tls]
server_name = "proxy.example.com"
[outbound.settings]
password = "replace-with-a-long-random-password"

如果这个名称是 IP 地址,则不发送 SNI,且证书中必须包含该 IP 地址。

证书校验有三种模式:

设置 信任的证书 是否校验主机名
既不设 ca_file 也不设 allow_insecure(默认) 系统根证书 是
ca_file = "/etc/etemenanki/ca.pem" 系统根证书,加上文件中的每一张证书 是
allow_insecure = true 任何证书 否

系统根证书指 OpenSSL 的默认信任库;环境变量 SSL_CERT_FILE 和 SSL_CERT_DIR 可以改变 OpenSSL 查找它的位置。ca_file 是在这个信任库之上追加,而不是替换它,所以即使设置了 ca_file,使用公共受信证书的服务器仍然能通过校验。对证书由你自己的 CA 签发的服务器,请使用 ca_file。自签名的服务器证书同样可以:把证书本身放进 ca_file,并确保它覆盖客户端要校验的名称。

ca_file 与 allow_insecure 不能同时使用:

outbound proxy: tls.allow_insecure and tls.ca_file cannot both be set

TLS 由 OpenSSL 提供。两端都接受 TLS 1.2 和 TLS 1.3,拒绝更旧的版本;对端支持时使用 TLS 1.3。入站使用 Mozilla 的 “intermediate” 加密套件配置。

ALPN 由承载方式固定,无法设置:

承载方式 出站提供的 ALPN 入站选择的 ALPN
network = "tls" 无 无
带 security = "tls" 的 ws http/1.1 http/1.1
带 security = "tls" 的 grpc h2 h2

如果客户端提供的协议中没有入站认识的,入站不会拒绝,而是在不协商 ALPN 的情况下完成握手。

TLS 表只有上面这五个字段。以下内容都没有对应字段:

  • ALPN、加密套件、TLS 版本或椭圆曲线;
  • TLS 指纹(uTLS)或 REALITY;
  • 证书固定(pinning);
  • 客户端证书(双向 TLS),两端都不支持;
  • 每个入站使用多张证书,或按 SNI 选择证书。

Xray tlsSettings 中的字段,例如 alpn 或 fingerprint,会作为未知字段被拒绝:

unknown field `alpn`, expected one of `server_name`, `allow_insecure`, `ca_file`, `cert_file`, `key_file`

network = "ws" 让协议运行在一个 WebSocket 会话中:每次写入都成为一条二进制消息。放在 CDN 或会转发 WebSocket 升级请求的 HTTP 反向代理后面时,应使用这种传输层。

键类型必填默认值说明
pathstring否"/"升级请求的 HTTP 路径。空路径视为 /,缺少开头的 / 会自动补上,所以 ray 等同于 /ray。?ed=N 查询参数不算路径的一部分:在出站上它开启最多 N 字节(上限 16384)的 early data;在入站上它会被去掉并忽略。入站对请求路径做精确比较,其他路径一律返回 404。
hoststring否—入站:设置后,请求的 Host 头必须与之相同(不区分大小写,忽略 :port),否则升级请求返回 404;不设置则接受任何 Host。出站:要发送的 Host 头,未设置时依次使用 tls.server_name 和 server;它不影响 TLS 的 SNI。server 是 IPv6 地址时应设置此项,因为裸 IPv6 地址不能直接作为 URI 主机。

两端以相同方式规范化路径,所以下面每一对写法是等价的:

写法 实际使用
缺省或 "" /
ray /ray
/ray?ed=2048 /ray,在出站上开启 early data
/ray?foo=1&ed=2048 /ray?foo=1,在出站上开启 early data

入站将升级请求的路径与自己的路径做区分大小写的精确比较,其他路径的请求一律返回 404 Not Found。它只比较路径,从不比较查询字符串,因此如果入站的 path 保留了 ed 以外的查询参数,就永远匹配不到任何请求。

在入站上,host 是一项可选检查。设置后,升级请求必须带有同名的 Host 头,比较时不区分大小写,并忽略 :port 后缀;Host 不同或缺失的请求会收到 404 Not Found,与路径错误时的响应相同。不设置 host 时,接受任何 Host。

在出站上,host 是要发送的 Host 头。未设置时,出站按以下顺序取第一个已设置的值:

  1. ws.host
  2. tls.server_name,即使 security 不是 "tls"
  3. server

ws.host 只设置这个请求头。SNI 和证书中校验的名称仍然来自 tls.server_name 或 server,所以在 CDN 后面通常要两者都设置,如下例所示。

当 server 是一个与 CDN 或反向代理用于路由的站点名称不同的地址时,请设置 host:

[[outbound]]
tag = "via-cdn"
protocol = "vmess"
server = "198.51.100.20" # CDN 边缘节点
port = 443
[outbound.stream]
network = "ws"
security = "tls"
[outbound.stream.ws]
path = "/ray"
host = "proxy.example.com"
[outbound.stream.tls]
server_name = "proxy.example.com"
[outbound.settings]
id = "11111111-2222-3333-4444-555555555555"

WebSocket early data 可以省去一次往返:客户端不必等升级完成,而是把代理连接的前几个字节放在升级请求里一起发出。etemenanki-app 使用与 Xray 相同的方案,因此无论哪一端是 Xray 都能互通。

  • 出站。 在 path 后面加上 ?ed=N。出站在拨号时就建立 TCP(和 TLS)连接,但会暂缓发送升级请求,直到协议写出第一批字节。其中最多 N 个字节经 base64url 编码后放在 Sec-WebSocket-Protocol 头里发送,其余字节随后作为普通消息发送。N 的上限是 16384。ed=0 或非数字的值会关闭 early data;无论哪种情况,这个参数都会从路径中移除。
  • 入站。 无需配置。无论自己的 path 是否带 ?ed=,入站都接受任何客户端放在 Sec-WebSocket-Protocol 头中的 early data,并在该头携带了 early data 时把它原样回显。无法按 base64 解码的头会被忽略。解码后超过 16 KiB 的 early data 会被拒绝,返回 413 Payload Too Large。

Xray 客户端通常写 path = "/ray?ed=2048"。在 etemenanki-app 出站上使用同样的路径,在入站上使用 /ray 或 /ray?ed=2048 均可。

network = "grpc" 把协议承载在 HTTP/2 上的 gRPC stream 中,与 Xray 的 gRPC 传输兼容。它适合 HTTP/2 前端以及能代理 gRPC 的 CDN。

键类型必填默认值说明
service_namestring视情况—network = "grpc" 时必填(否则报 grpc stream needs grpc.service_name)。隧道路径为 /SERVICE/Tun 和 /SERVICE/TunMulti。名称原样拼进路径,所以不要带斜杠。两端必须使用相同的名称。
authoritystring否—仅用于出站。每个请求的 HTTP/2 :authority,未设置时依次使用 tls.server_name 和 server;它不影响 TLS 的 SNI。server 是 IPv6 地址时应设置此项,因为裸 IPv6 地址不能直接作为 URI authority。入站不检查 authority,也忽略此项。

network = "grpc" 时,两端都必须设置 service_name:

inbound vmess-in: grpc stream needs grpc.service_name

它会生成两个请求路径。以 service_name = "tunnel" 为例:

路径 Xray 中的名称 入站 出站
/tunnel/Tun gun 模式,每条 gRPC 消息一个 Hunk 接受 始终使用
/tunnel/TunMulti multi 模式(multiMode: true),每条消息一批 MultiHunk 接受 从不使用

入站同时服务这两个路径,所以 Xray 客户端无论是否开启 multiMode 都能连接。对其他路径的请求会以 REFUSED_STREAM 重置。入站不检查 :authority。

出站把每条隧道作为一个发往 /SERVICE/Tun 的 POST 请求发送,并带有:

  • :authority,取 grpc.authority、tls.server_name(即使不使用 TLS)和 server 中第一个已设置的值;
  • content-type: application/grpc 和 te: trailers;
  • 一个固定的桌面版 Chrome user-agent,无法修改。

出站拨号的每个流都会建立自己的 TCP 连接和 HTTP/2 连接;流不会在共享连接上多路复用。

以下数值都不可配置。

项目 值 适用范围
TCP keepalive 静默 120 秒后发送第一个探测包,之后每 30 秒一次;连续 3 个探测无响应则断开连接 入站接受的每条 TCP 连接,以及出站拨往服务器的每条连接
传输层握手 TLS 握手、WebSocket 升级或 HTTP/2 preface 共 10 秒,包含 TLS 入站
协议握手 在传输层握手之后开始计时的另一个 10 秒限制;见限制 入站
WebSocket ping 60 秒内没有 WebSocket 流量时发送一个 Ping 两端
WebSocket 空闲 300 秒内既没有收到任何帧、也没有发送任何数据时,会话结束 两端
WebSocket 消息大小 每条消息和每个帧最大 1 MiB 两端
WebSocket early data 16 KiB 两端
gRPC 连接空闲 没有打开的 stream 的 HTTP/2 连接在 300 秒后关闭 入站
gRPC ping 每 60 秒发送一个 PING;如果 PONG 超过 20 秒未返回,则关闭连接 入站
gRPC 消息大小 每条消息最大 1 MiB 两端
HTTP/2 stream 每条连接最多 256 个并发 stream 入站
HTTP/2 流量控制 每个 stream 窗口 4 MiB,每条连接 16 MiB,最大帧 256 KiB 两端

keepalive 和 ping 机制用于回收对端已经消失却没有关闭的连接,例如切换了网络的手机。它们不会关闭只是空闲、但仍在响应的连接。

错误 原因 解决方法
security = "tls" is not valid with network = "tcp" 使用了 Xray 对 TLS over TCP 的写法 改写为 network = "tls",并去掉 security
unknown stream network "WS" 大小写错误、带空格,或承载方式不受支持 严格使用 tcp、tls、ws 或 grpc
unknown stream security "reality" (expected "tls" or "none") 使用了 REALITY、XTLS,或拼写错误 使用 tls 或 none
protocol socks does not support stream network "ws" 在无法承载传输层的协议上设置了传输层 删除 stream 表,或把传输层移到能使用它的协议上
protocol vless over a unix socket does not support stream network "ws" 在 Unix socket 监听器上设置了传输层 改为监听 IP 地址,或去掉传输层
tls stream needs tls.cert_file 入站使用 TLS 但没有证书 在 [inbound.stream.tls] 中添加 cert_file 和 key_file
no certificate in PEM bundle cert_file 中没有 PEM 证书 检查路径,并确认 cert_file 和 key_file 没有写反
no private key assigned key_file 与 cert_file 中的第一张证书不对应 使用与证书一同签发的私钥,并把叶子证书放在 cert_file 的最前面
No such file or directory (os error 2) 证书、私钥或 CA 路径错误 检查 stream 表中的每个文件路径
grpc stream needs grpc.service_name network = "grpc" 但没有服务名 在两端把 service_name 设为相同的值
tls stream needs tls.server_name or server、ws stream needs ws.host or server 出站没有设置 server;stream 的检查先于 server 本身的检查 设置 server 和 port
tls.allow_insecure and tls.ca_file cannot both be set 同时设置了两种校验覆盖方式 保留 ca_file,删除 allow_insecure
no certificate in CA PEM bundle ca_file 中没有 PEM 证书 让 ca_file 指向包含 CA 证书的 PEM 文件
unknown field `headers`, expected `path` or `host` 在 ws 表中使用了 Xray 的字段 用 host 代替 headers.Host

以下故障能通过 --test,只会在运行中的连接上出现:

  • 两端的路径、Host 或服务名不一致。 WebSocket 入站返回 404,gRPC 入站重置 stream,客户端记录一次拨号失败。请逐字符比较 path、host 和 service_name。
  • 客户端不接受的证书。 客户端记录一条 TLS 校验错误。请确认证书覆盖了客户端校验的名称(server_name 或 server),并且 cert_file 中的证书链包含了中间证书。
  • ws 或 grpc 出站使用 IPv6 server,却没有设置 ws.host 或 grpc.authority。 每次拨号都会失败;见 gRPC 一节末尾的警告,并显式设置 host。

更多跨主题的易错点汇总在常见陷阱中,Xray streamSettings 的逐字段对照见从 Xray 迁移。