跳转到内容

VLESS over WebSocket 与 TLS

本配方搭建一对可用的 etemenanki-app 实例:服务端在 443 端口接受“TLS 里套 WebSocket、WebSocket 里套 VLESS”的连接;客户端运行在你自己的机器上,提供一个本地 SOCKS 端口,并把所有流量经由该服务端发出。本页还介绍一种常见变体:由 nginx 之类的反向代理占用 443 端口、终止 TLS,再把某个 WebSocket 路径转发到 etemenanki-app 的回环端口。

如果你希望代理流量看起来像是访问 Web 服务器的普通 HTTPS WebSocket 连接,或者希望与网站共用 443 端口,就可以采用本配方。这里用到的配置项在 VLESS 和传输层页面有完整说明;本页侧重于如何把它们组合起来,并逐层检查是否正常工作。

flowchart LR
  A["curl 或浏览器"] -->|"SOCKS5"| C["客户端:socks-in,然后出站 proxy"]
  C -->|"TCP 443:TLS,WebSocket /vless,VLESS"| S["服务端:入站 vless-ws-in"]
  S --> D["出站 direct"]
  D --> T["目标网站"]

客户端到服务端的每条连接都叠加了三层,两端在每一层上都必须一致:

层 需要一致的内容 在哪里设置
TLS 客户端按某个名称校验服务端证书 服务端:[inbound.stream.tls] cert_file、key_file。客户端:[outbound.stream.tls] server_name
WebSocket 升级请求的路径,以及可选的 Host 头 [inbound.stream.ws] 和 [outbound.stream.ws] 的 path 与 host
VLESS 用户的 UUID 服务端:users[].id。客户端:id
  • 一台有公网地址的服务器,防火墙已放行 TCP 443 端口。
  • 一个 DNS 记录指向该服务器的域名。本页使用 proxy.example.com 和地址 203.0.113.10。
  • 由公共 CA 为该域名签发的证书,例如通过任意 ACME 客户端获取,保存为 PEM 证书链(fullchain.pem)和私钥(privkey.pem)。客户端用系统信任的根证书校验证书,因此自签名证书会校验失败,除非你给客户端配置 ca_file。
  • 两台机器上都已安装 etemenanki-app,见安装。
/etc/etemenanki/server.toml
# A VLESS server over WebSocket and TLS on port 443, with two users and a direct exit.
# Replace the certificate paths, the domain and the UUIDs before you use it.
[log]
level = "info"
[[inbound]]
tag = "vless-ws-in"
protocol = "vless"
listen = "0.0.0.0"
port = 443
# VLESS sends the user's UUID in the clear: keep TLS under the WebSocket.
[inbound.stream]
network = "ws"
security = "tls"
[inbound.stream.ws]
path = "/vless"
# Refuse upgrades whose Host header names anything else (404).
host = "proxy.example.com"
[inbound.stream.tls]
cert_file = "/etc/etemenanki/fullchain.pem"
key_file = "/etc/etemenanki/privkey.pem"
[inbound.settings]
users = [
{ id = "11111111-2222-3333-4444-555555555555", email = "alice@example.com" },
{ id = "11111111-2222-3333-4444-666666666666", email = "bob@example.com" },
]
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "direct"
# Keep clients away from the server's own private networks.
[[route.rule]]
outbound = "block"
cidr = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "127.0.0.0/8", "fc00::/7", "::1/128"]

服务端监听所有网卡(listen 默认是 127.0.0.1,远程客户端无法访问),用你的证书终止 TLS,在 /vless 上接受主机为 proxy.example.com 的 WebSocket 升级,并允许两个用户接入。一条规则把目标为私有地址段的连接发往 blackhole 出站,这样客户端就无法借助服务端访问其所在的本地网络。

客户端拨号 203.0.113.10:443,以 proxy.example.com 作为 TLS 服务器名称,在升级请求中发送 Host: proxy.example.com,请求路径 /vless,并开启 early data。

配置项 所在端 此处的值 作用
stream.network 两端 "ws" 用 WebSocket 二进制消息承载 VLESS。
stream.security 两端 "tls" 在 WebSocket 之下加一层 TLS。只接受 "tls" 和 "none";省略该项即为 "none",也就是明文 WebSocket。
stream.ws.path 两端 "/vless",客户端为 "/vless?ed=2048" 升级路径。默认值为 "/",不以 / 开头的路径会自动补上。?ed= 在比较或发送前会被去掉。
stream.ws.host 服务端 "proxy.example.com" 可选。设置后,Host 头为其他名称的升级请求会收到 404 Not Found。
stream.ws.host 客户端 "proxy.example.com" 要发送的 Host 头。
stream.tls.cert_file、key_file 服务端 PEM 文件 证书链及其私钥。security = "tls" 时两者都必须设置。
stream.tls.server_name 客户端 "proxy.example.com" TLS 服务器名称(SNI),也是校验证书时使用的名称。
settings.users[].id 服务端 若干 UUID 允许接入的用户。
settings.id 客户端 一个 UUID 登录所用的用户。

客户端会用 server 补全你未填写的名称,所以这两个名称都是可选的:

客户端发送的内容 取值来源(按顺序取第一个已设置的)
TLS 服务器名称,以及证书必须匹配的名称 tls.server_name,然后是 server
WebSocket Host 头 ws.host,然后是 tls.server_name,然后是 server
升级路径 去掉 ?ed= 的 ws.path,或 /

如果 server 本身就是域名(server = "proxy.example.com"),可以同时省略 server_name 和 host。当 server 是 IP 地址,或者你拨号的机器并不是证书所指的那台(例如服务端前面有 CDN 或负载均衡)时,就要像示例那样显式设置。如果前端按一个名称路由、却出示另一个名称的证书,ws.host 和 server_name 可以不同。

入站设置了 host 时,服务端会把它与请求的 Host 头比较,忽略大小写和 :port 后缀,因此 Host: Proxy.Example.com:443 可以通过。Host 不同或缺失的请求会收到 404 Not Found,与路径错误时的响应相同,且此时尚未读取任何 VLESS 字节。省略 host 则接受任意 Host。

这项检查能防止 WebSocket 端点响应通过其他名称(例如服务器的裸 IP 地址)到达的请求。它不是认证步骤,认证靠的是 UUID。

WebSocket 连接通常要多花一个往返:客户端发送升级请求,等待 101 Switching Protocols,然后才发送 VLESS 请求。启用 early data 后,客户端会把第一次写入的内容,也就是 VLESS 请求头,直接放进升级请求中。

sequenceDiagram
    participant C as 客户端
    participant S as 服务端
    participant T as 目标
    Note over C,S: TCP 和 TLS 握手
    C->>S: GET /vless,Sec-WebSocket-Protocol 携带 VLESS 请求
    S-->>C: 101 Switching Protocols,回显该头
    S->>T: 在 101 回传途中发起连接
    C->>S: 负载数据
    S->>T: 负载数据
    T-->>S: 响应
    S-->>C: VLESS 响应和目标响应

客户端仍然要等到 101 才发送负载数据,所以负载数据到达服务端的时间并不会提前。变化在于:服务端随升级请求一起得知目标,并在 101 回传途中就去连接目标,而不是晚一个往返。第一个响应因此提前到达,提前的时间等于服务端连接目标所需的时间,最多为客户端与服务端之间的一个往返。这一点在这里很重要,因为 VLESS 出站不做多路复用:每个 TCP 流和每个 UDP 流都会各自建立一条 TCP、TLS 和 WebSocket 连接。

两端对 ?ed= 的处理:

  • 客户端。 出站 path 中的 ?ed=N 会开启 early data,上限为 N 字节。出站会推迟升级请求,直到第一次写入,把这次写入中最多 N 字节以 base64url 编码放进 Sec-WebSocket-Protocol 头发送,其余部分在升级完成后作为普通消息发送。N 的上限为 16384。ed=0 或非数字的值会关闭 early data。ed 参数永远不会出现在请求中:/vless?ed=2048 请求的是 /vless。
  • 服务端。 无需配置。入站接受任何客户端发来的 early data,并回显该头。入站 path 中的 ?ed= 会被忽略,所以可以把客户端的路径原样复制到服务端。超过 16 KiB 的 early data 会被 413 Payload Too Large 拒绝;不是 base64 的 Sec-WebSocket-Protocol 值会被当作普通请求头而忽略。

2048 是 Xray 客户端的常用值。etemenanki-app 客户端在升级请求中只会放入 VLESS 请求头,最多几百字节(最长的部分是最多 255 字节的域名),所以 2048 足以覆盖所有请求,更大的值不会带来任何变化。

  1. 生成用户 id。 每个用户运行一次,把结果填入服务端的 users 和对应客户端的 id:

    终端窗口
    cat /proc/sys/kernel/random/uuid

    也可以用 uuidgen。

  2. 安装证书。 把证书链和私钥复制到服务端配置中的路径,并确保运行 etemenanki-app 的用户可以读取。请使用完整证书链,叶子证书在前:服务端会把 cert_file 中的每张证书都发给客户端,而缺少中间证书的叶子证书在很多客户端上会校验失败。

    etemenanki-app 只在构建配置时读取证书。只有配置文件内容变化时才会触发重载,因此单纯续期证书不会被加载。ACME 客户端续期之后,请重启服务端,或对配置文件做任意修改,让热重载读取新文件。无论哪种方式,已打开的连接都会被关闭。

  3. 检查服务端配置。 --test 会构建启动时要构建的一切,读取证书和私钥,但不绑定任何端口:

    终端窗口
    etemenanki-app --test -c /etc/etemenanki/server.toml
    Configuration OK.
  4. 启动服务端。 443 端口需要 root 或 CAP_NET_BIND_SERVICE capability;生产环境运行中提供了授予该权限的 systemd unit。日志会确认监听器已启动:

    INFO etemenanki_app::instance: inbound vless-ws-in listening on 0.0.0.0:443
  5. 从外部探测 WebSocket 端点。 用 curl 手工发送一个升级请求。这一步可以一次性检查 DNS、防火墙、证书、路径和 Host 检查:

    终端窗口
    curl -i --http1.1 --max-time 3 \
    -H 'Connection: Upgrade' -H 'Upgrade: websocket' \
    -H 'Sec-WebSocket-Version: 13' -H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
    https://proxy.example.com/vless

    预期结果是 101,之后 curl 会一直等到 --max-time,然后以 (28) Operation timed out 退出,因为它从不发送 VLESS 请求:

    HTTP/1.1 101 Switching Protocols
    connection: Upgrade
    upgrade: websocket
    sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

    HTTP/1.1 404 Not Found 表示路径或 Host 不匹配。curl 报 TLS 错误表示证书不包含该名称。不带升级头的普通 curl https://proxy.example.com/vless 会得到 (52) Empty reply from server:服务端对任何非 WebSocket 升级的请求都直接关闭,不做响应。

  6. 检查并启动客户端。 在你自己的机器上填入服务端地址、域名和你的 UUID,然后运行:

    终端窗口
    etemenanki-app --test -c client.toml
    etemenanki-app -c client.toml
  7. 通过隧道发送请求。

    终端窗口
    curl --socks5-hostname 127.0.0.1:1080 https://example.com

    curl 会输出页面内容。请求的路径是 curl → socks-in → proxy → 经 TLS 和 WebSocket 到服务端 → direct → example.com。

如果第 7 步失败,请以 [log] level = "debug" 启动客户端和服务端,并参阅下文的故障排查部分:在默认的 info 级别下,失败的连接不会记录日志。

如果 443 端口已被 Web 服务器占用,可以让它终止 TLS,并把某个路径转发给 etemenanki-app。客户端完全不用改动:它仍然通过 TLS 和 WebSocket 连接 proxy.example.com:443,并校验反向代理的证书。

flowchart LR
  C["客户端"] -->|"443 上的 TLS"| N["nginx,location /vless"]
  N -->|"明文 WebSocket 到 127.0.0.1:10000"| S["入站 vless-ws-in,无 TLS"]
  N -->|"其他所有路径"| W["网站"]
/etc/etemenanki/server.toml
[[inbound]]
tag = "vless-ws-in"
protocol = "vless"
listen = "127.0.0.1" # 仅回环地址:nginx 是唯一的客户端
port = 10000
[inbound.stream]
network = "ws"
security = "none" # nginx 已经去掉了 TLS
[inbound.stream.ws]
path = "/vless"
host = "proxy.example.com" # nginx 必须透传客户端的 Host
[inbound.settings]
users = [
{ id = "11111111-2222-3333-4444-555555555555" },
]
[[outbound]]
tag = "direct"
protocol = "freedom"
[route]
default = "direct"

nginx 配置块中各部分的用途:

指令 为什么需要
location = /vless 只精确转发这个 WebSocket 路径。查询字符串(包括 ?ed=2048)不参与匹配。
proxy_http_version 1.1、Upgrade、Connection "upgrade" nginx 与上游之间默认使用 HTTP/1.0,也不会自动转发升级相关的请求头。缺少 proxy_http_version 1.1 时,etemenanki-app 会在 debug 级别记录 WebSocket protocol error: HTTP version must be 1.1 or higher;缺少这两个请求头时,记录 WebSocket protocol error: No "Connection: upgrade" header。两种情况下它都会不做响应直接关闭连接,nginx 则向客户端返回 502 Bad Gateway。
proxy_set_header Host $host nginx 默认发送 Host: 127.0.0.1:10000,无法通过入站的 host 检查,会得到 404。要么透传客户端的 Host,要么从入站中去掉 host。
proxy_read_timeout、proxy_send_timeout nginx 默认在代理连接 60 秒无流量后将其关闭。etemenanki-app 要在 WebSocket 静默 60 秒后才发送 ping 探测,因此空闲隧道会与这个时限相互竞争。请把两者都调到高于 etemenanki-app 自身 300 秒的空闲上限。

nginx 会原样透传 Sec-WebSocket-Protocol 头,因此经过 nginx 时 early data 依然有效。

前面加上反向代理之后,有这些变化:

  • 服务端把 nginx 视为客户端。 etemenanki-app 不读取 X-Forwarded-For,也不支持 PROXY protocol,所以每条连接都来自 127.0.0.1:debug 日志中如此,[route] 中的 source_cidr 规则看到的也是如此。
  • 监听器必须是 TCP。 Unix socket 上的 VLESS 入站(listen 设为路径)无法承载 WebSocket,network = "ws" 的 [inbound.stream] 会报错 protocol vless over a unix socket does not support stream network "ws"。请使用回环端口。
  • 端口只绑定回环地址。 listen = "127.0.0.1"(即默认值)让明文监听器不暴露到网络上。不要在防火墙中放行 10000 端口。

测试该变体的步骤与上文相同。第 5 步此时会同时检查 nginx 和 etemenanki-app。nginx 返回 502 Bad Gateway 表示 etemenanki-app 没有在 proxy_pass 指定的回环端口上监听,或者 nginx 没有转发升级请求(见上表)。

上限 值 效果
服务端的 TLS 握手和 WebSocket 升级 两者合计 10 秒 超时的客户端会被断开。
升级之后的 VLESS 请求 10 秒 完成升级却不发送 VLESS 请求的连接(例如第 5 步中的 curl 探测)会在此时间后被关闭。
静默的 WebSocket 60 秒后发送 ping,300 秒后关闭 每一端在 60 秒无流量后发送 ping;如果 300 秒内既没有收到数据也没有写出数据,就结束会话。会响应 ping 的对端可以让空闲隧道保持打开。
WebSocket 消息或帧 1 MiB 发送更大消息的对端会被断开。
Early data 16 KiB 更大的 early data 会被 413 拒绝。
TLS 版本 1.2 和 1.3 服务端为 WebSocket 通告 ALPN 协议 http/1.1。

所有入站共用的上限(例如连接数上限)见上限页面。

--test 会在启动前报告这些错误。热重载遇到这类错误时会记录日志,并保留正在运行的配置:

错误 原因 解决方法
unknown field `paht`, expected `path` or `host` ws 表中有拼写错误,或使用了 headers 之类的 Xray 配置项 只使用 path 和 host;host 替代 Xray 的 headers.Host
unknown stream security "TLS" (expected "tls" or "none") security 不是这两个值之一,包括大小写不同 写成 security = "tls"
unknown stream network "websocket" Xray 风格或拼写错误的 network 写成 network = "ws"
inbound vless-ws-in: tls stream needs tls.cert_file(或 tls.key_file) 入站设置了 security = "tls",但缺少证书或私钥 在 [inbound.stream.tls] 下同时添加两者
No such file or directory (os error 2) cert_file 或 key_file 指向的文件不存在。错误信息不会指明是哪一个 检查两个路径,并确认运行 etemenanki-app 的用户有读取权限
提到 SSL_CTX_check_private_key 的 OpenSSL 错误,例如 error:0A0000BE:SSL routines:SSL_CTX_check_private_key:no private key assigned:…(具体措辞取决于 OpenSSL 构建) 私钥与证书不匹配 使用与该证书一同签发的私钥
protocol vless over a unix socket does not support stream network "ws" 在 Unix socket listen 上使用 WebSocket 改为监听回环端口

客户端无法建立隧道时,curl 报告的内容取决于它如何指定目标。使用 --socks5-hostname(如第 7 步)时,SOCKS 入站会返回失败应答,curl 输出 (97) Can't complete SOCKS5 connection to example.com. (4)(服务端拒绝 TCP 连接时为 (5))。使用 --socks5 时,curl 发送的是 IP 地址;此时入站会先批准请求,以便读取开头的字节用于嗅探,隧道失败后再关闭连接,因此对于 http:// URL,curl 报告 (52) Empty reply from server,对于 https:// URL 则报告 TLS 错误。具体原因在两端各自的 debug 日志中。客户端的日志行以 socks connection from … ended: 开头;服务端 TLS 和 WebSocket 层的日志行以 inbound transport failed: 开头,VLESS 层的以 vless connection from … ended: 开头。

客户端日志 服务端日志 原因 解决方法
HTTP error: 404 Not Found HTTP error: 404 Not Found 路径不同,或者入站设置了 host 而客户端发送了其他 Host 对比两端的 ws.path,并将客户端的 ws.host(或 server_name,或 server)与入站的 host 对比
certificate verify failed sslv3 alert bad certificate 证书不包含客户端使用的 TLS 服务器名称 把 tls.server_name 设为证书上的名称
certificate verify failed tlsv1 alert unknown ca 客户端不信任签发者:自签名证书或缺少中间证书 在 cert_file 中使用完整证书链,或为私有 CA 给客户端配置 tls.ca_file
failed to connect to any address (…: Connection refused (os error 111)),或超时 无 防火墙、DNS 问题,或服务端未在监听 重做部署中的第 5 步
升级成功,随后连接被关闭 invalid vless request user id 客户端的 id 不在 users 中 重新复制 UUID
HTTP error: 502 Bad Gateway WebSocket protocol error: No "Connection: upgrade" header,或 HTTP version must be 1.1 or higher 反向代理没有转发升级请求 按 nginx 配置块添加 proxy_http_version 1.1 以及 Upgrade 和 Connection 请求头

用 Xray 写的同一对配置可以对应到下面这些配置项。Xray 客户端可以连接 etemenanki-app 服务端,反之亦然;集成测试会在有 TLS 和无 TLS 的 WebSocket 上,针对 xray-core 双向运行。与 Xray 之间的 early data 在明文 WebSocket 上用 VMess 做了双向测试;VLESS 使用的是同一套 WebSocket 代码。

Xray JSON etemenanki-app TOML
streamSettings.network = "ws" [.stream] network = "ws"
streamSettings.security = "tls" [.stream] security = "tls"
wsSettings.path = "/vless?ed=2048" [.stream.ws] path = "/vless?ed=2048",含义相同
wsSettings.host 或 wsSettings.headers.Host [.stream.ws] host
tlsSettings.serverName [.stream.tls] server_name
tlsSettings.certificates[0].certificateFile 和 .keyFile [inbound.stream.tls] cert_file 和 key_file
vnext[0].address、.port [[outbound]] 上的 server 和 port
vnext[0].users[0].id [outbound.settings] id

其余配置见 Xray 迁移页面。