VLESS over WebSocket 与 TLS
本配方搭建一对可用的 etemenanki-app 实例:服务端在 443 端口接受“TLS 里套 WebSocket、WebSocket 里套 VLESS”的连接;客户端运行在你自己的机器上,提供一个本地 SOCKS 端口,并把所有流量经由该服务端发出。本页还介绍一种常见变体:由 nginx 之类的反向代理占用 443 端口、终止 TLS,再把某个 WebSocket 路径转发到 etemenanki-app 的回环端口。
如果你希望代理流量看起来像是访问 Web 服务器的普通 HTTPS WebSocket 连接,或者希望与网站共用 443 端口,就可以采用本配方。这里用到的配置项在 VLESS 和传输层页面有完整说明;本页侧重于如何把它们组合起来,并逐层检查是否正常工作。
各部分如何衔接
Section titled “各部分如何衔接”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,见安装。
# 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"]# A local SOCKS proxy that sends everything to a VLESS server over WebSocket and TLS.# Replace the server address, the domain and the UUID with your server's values.
[log]level = "info"
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[[outbound]]tag = "proxy"protocol = "vless"server = "203.0.113.10" # the address to dialport = 443
[outbound.stream]network = "ws"security = "tls"
[outbound.stream.ws]# Must match the server's path. ?ed=2048 turns on early data; it is not sent as part of the path.path = "/vless?ed=2048"host = "proxy.example.com" # the Host header of the upgrade request
[outbound.stream.tls]server_name = "proxy.example.com" # SNI, and the name the certificate is checked against
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"
[route]default = "proxy"服务端监听所有网卡(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。
本配方用到的配置项
Section titled “本配方用到的配置项”| 配置项 | 所在端 | 此处的值 | 作用 |
|---|---|---|---|
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 | 登录所用的用户。 |
客户端如何确定名称
Section titled “客户端如何确定名称”客户端会用 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 检查
Section titled “服务端的 Host 检查”入站设置了 host 时,服务端会把它与请求的 Host 头比较,忽略大小写和 :port 后缀,因此 Host: Proxy.Example.com:443 可以通过。Host 不同或缺失的请求会收到 404 Not Found,与路径错误时的响应相同,且此时尚未读取任何 VLESS 字节。省略 host 则接受任意 Host。
这项检查能防止 WebSocket 端点响应通过其他名称(例如服务器的裸 IP 地址)到达的请求。它不是认证步骤,认证靠的是 UUID。
Early data
Section titled “Early data”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 足以覆盖所有请求,更大的值不会带来任何变化。
-
生成用户 id。 每个用户运行一次,把结果填入服务端的
users和对应客户端的id:终端窗口 cat /proc/sys/kernel/random/uuid也可以用
uuidgen。 -
安装证书。 把证书链和私钥复制到服务端配置中的路径,并确保运行 etemenanki-app 的用户可以读取。请使用完整证书链,叶子证书在前:服务端会把
cert_file中的每张证书都发给客户端,而缺少中间证书的叶子证书在很多客户端上会校验失败。etemenanki-app 只在构建配置时读取证书。只有配置文件内容变化时才会触发重载,因此单纯续期证书不会被加载。ACME 客户端续期之后,请重启服务端,或对配置文件做任意修改,让热重载读取新文件。无论哪种方式,已打开的连接都会被关闭。
-
检查服务端配置。
--test会构建启动时要构建的一切,读取证书和私钥,但不绑定任何端口:终端窗口 etemenanki-app --test -c /etc/etemenanki/server.tomlConfiguration OK. -
启动服务端。 443 端口需要 root 或
CAP_NET_BIND_SERVICEcapability;生产环境运行中提供了授予该权限的 systemd unit。日志会确认监听器已启动:INFO etemenanki_app::instance: inbound vless-ws-in listening on 0.0.0.0:443 -
从外部探测 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 Protocolsconnection: Upgradeupgrade: websocketsec-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 升级的请求都直接关闭,不做响应。 -
检查并启动客户端。 在你自己的机器上填入服务端地址、域名和你的 UUID,然后运行:
终端窗口 etemenanki-app --test -c client.tomletemenanki-app -c client.toml -
通过隧道发送请求。
终端窗口 curl --socks5-hostname 127.0.0.1:1080 https://example.comcurl会输出页面内容。请求的路径是curl→socks-in→proxy→ 经 TLS 和 WebSocket 到服务端 →direct→example.com。
如果第 7 步失败,请以 [log] level = "debug" 启动客户端和服务端,并参阅下文的故障排查部分:在默认的 info 级别下,失败的连接不会记录日志。
变体:由反向代理终止 TLS
Section titled “变体:由反向代理终止 TLS”如果 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["网站"]
[[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"server { listen 443 ssl; server_name proxy.example.com;
ssl_certificate /etc/ssl/proxy.example.com/fullchain.pem; ssl_certificate_key /etc/ssl/proxy.example.com/privkey.pem;
location = /vless { proxy_pass http://127.0.0.1:10000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_read_timeout 600s; proxy_send_timeout 600s; }
location / { root /var/www/html; }}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 迁移
Section titled “从 Xray 迁移”用 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 迁移页面。