VLESS over WebSocket and TLS
This recipe builds a working pair of etemenanki-app instances: a server that accepts VLESS inside a WebSocket inside TLS on port 443, and a client on your own machine that offers a local SOCKS port and sends everything through that server. It also shows a common variant, where a reverse proxy such as nginx owns port 443, terminates TLS, and forwards one WebSocket path to etemenanki-app on a loopback port.
Use it when you want a proxy whose traffic looks like an ordinary HTTPS WebSocket connection to a web server, and when you want to share port 443 with a website. The keys used here are explained in full on the VLESS and transports pages; this page concentrates on putting them together and checking that each layer works.
How the pieces fit
Section titled “How the pieces fit”flowchart LR A["curl or browser"] -->|"SOCKS5"| C["client: socks-in, then outbound proxy"] C -->|"TCP 443: TLS, WebSocket /vless, VLESS"| S["server: inbound vless-ws-in"] S --> D["outbound direct"] D --> T["Target site"]
Every connection from the client to the server stacks three layers, and both sides must agree on each one:
| Layer | What has to match | Where it is set |
|---|---|---|
| TLS | The client checks the server’s certificate against a name | Server: [inbound.stream.tls] cert_file, key_file. Client: [outbound.stream.tls] server_name |
| WebSocket | The upgrade request’s path, and optionally its Host header |
[inbound.stream.ws] and [outbound.stream.ws] path and host |
| VLESS | The user’s UUID | Server: users[].id. Client: id |
Before you start
Section titled “Before you start”- A server with a public address, and TCP port 443 open in its firewall.
- A domain name whose DNS record points at the server. This page uses
proxy.example.comand the address203.0.113.10. - A certificate for that name from a public CA, for example from any ACME client, saved as a PEM chain (
fullchain.pem) and a private key (privkey.pem). The client verifies the certificate against the system’s trusted roots, so a self-signed certificate fails unless you give the client aca_file. - etemenanki-app installed on both machines, as described in Install.
The configuration pair
Section titled “The configuration pair”# 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" }, { id = "11111111-2222-3333-4444-666666666666" },]
[[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"The server listens on every interface (listen defaults to 127.0.0.1, which a remote client cannot reach), terminates TLS with your certificate, accepts WebSocket upgrades on /vless for the host proxy.example.com, and admits two users. A rule sends connections to private address ranges to a blackhole outbound, so clients cannot use the server to reach its own local network.
The client dials 203.0.113.10:443, presents proxy.example.com as the TLS server name, sends Host: proxy.example.com in the upgrade request, and requests the path /vless with early data turned on.
The keys this recipe uses
Section titled “The keys this recipe uses”| Key | Side | Value here | What it does |
|---|---|---|---|
stream.network |
Both | "ws" |
Carries VLESS in WebSocket binary messages. |
stream.security |
Both | "tls" |
Puts TLS under the WebSocket. Only "tls" and "none" are accepted; leaving the key out means "none", plain WebSocket. |
stream.ws.path |
Both | "/vless", client "/vless?ed=2048" |
The upgrade path. The default is "/", and a path without a leading / gets one. ?ed= is removed before comparing or sending. |
stream.ws.host |
Server | "proxy.example.com" |
Optional. When set, an upgrade whose Host header names anything else gets 404 Not Found. |
stream.ws.host |
Client | "proxy.example.com" |
The Host header to send. |
stream.tls.cert_file, key_file |
Server | PEM files | The certificate chain and its private key. Both are required when security = "tls". |
stream.tls.server_name |
Client | "proxy.example.com" |
The TLS server name (SNI) and the name the certificate is checked against. |
settings.users[].id |
Server | UUIDs | The users allowed in. |
settings.id |
Client | One UUID | The user to log in as. |
How the client picks its names
Section titled “How the client picks its names”The client fills in whatever you leave out from server, so the two names are optional:
| What the client sends | Taken from, first one set |
|---|---|
| TLS server name, and the name the certificate must match | tls.server_name, then server |
WebSocket Host header |
ws.host, then tls.server_name, then server |
| Upgrade path | ws.path without ?ed=, or / |
When server is already the domain (server = "proxy.example.com"), you can leave out both server_name and host. Set them, as the example does, when server is an IP address, or when the machine you dial is not the one the certificate names, for example a CDN or load balancer in front of the server. ws.host and server_name can differ if your front end routes on one name and presents a certificate for another.
The server’s Host check
Section titled “The server’s Host check”With host set on the inbound, the server compares it with the request’s Host header, ignoring case and any :port suffix, so Host: Proxy.Example.com:443 passes. A request with a different or missing Host gets 404 Not Found, the same answer as a wrong path, before any VLESS byte is read. Leave host out to accept any Host.
The check keeps the WebSocket endpoint from answering requests that reach the server under another name, such as its bare IP address. It is not an authentication step: the UUID is.
Early data
Section titled “Early data”A WebSocket connection normally costs one extra round trip: the client sends the upgrade request, waits for 101 Switching Protocols, and only then sends the VLESS request. With early data the client puts its first write, the VLESS request header, inside the upgrade request itself.
sequenceDiagram
participant C as Client
participant S as Server
participant T as Target
Note over C,S: TCP and TLS handshakes
C->>S: GET /vless, Sec-WebSocket-Protocol carries the VLESS request
S-->>C: 101 Switching Protocols, header echoed
S->>T: Connect, while the 101 is still on its way
C->>S: Payload
S->>T: Payload
T-->>S: Reply
S-->>C: VLESS response and reply
The client still waits for the 101 before it sends any payload, so the payload reaches the server no sooner. What changes is that the server learns the target together with the upgrade and connects to it while the 101 travels back, instead of one round trip later. The first reply arrives earlier by the time the server needs to reach the target, at most one round trip between client and server. This matters here because the VLESS outbound does not multiplex: every TCP flow and every UDP flow opens its own TCP, TLS and WebSocket connection.
How the two sides treat ?ed=:
- Client.
?ed=Non the outbound’spathturns early data on with a limit ofNbytes. The outbound holds the upgrade back until its first write, sends up toNbytes of that write base64url-encoded in theSec-WebSocket-Protocolheader, and sends any rest as ordinary messages after the upgrade.Nis capped at 16384.ed=0, or a value that is not a number, turns early data off. Theedparameter never appears in the request:/vless?ed=2048requests/vless. - Server. Nothing to configure. The inbound accepts early data from any client and echoes the header back. A
?ed=in the inbound’spathis ignored, so you can copy the client’s path to the server unchanged. Early data larger than 16 KiB is refused with413 Payload Too Large, and aSec-WebSocket-Protocolvalue that is not base64 is treated as an ordinary header and ignored.
2048 is the value Xray clients commonly use. An etemenanki-app client never puts more than the VLESS request header in the upgrade, which is at most a few hundred bytes (the longest part is a domain name of up to 255 bytes), so 2048 covers every request and a larger value changes nothing.
Deploy and test
Section titled “Deploy and test”-
Generate the user ids. Run this once per user and put the results in the server’s
usersand in each client’sid:Terminal window cat /proc/sys/kernel/random/uuiduuidgenworks too. -
Install the certificate. Copy the certificate chain and key to the paths in the server config and make them readable by the user that runs etemenanki-app. Use the full chain, leaf first: the server sends every certificate in
cert_fileto the client, and a leaf without its intermediates fails verification on many clients.etemenanki-app reads the certificate only when it builds a configuration. A reload starts only when the config file’s content changes, so a renewed certificate alone is not picked up. After your ACME client renews it, restart the server or make any change to the config file so that hot reload reads the new files. Either way, open connections are closed.
-
Check the server config.
--testbuilds everything a start would, reads the certificate and key, and binds nothing:Terminal window etemenanki-app --test -c /etc/etemenanki/server.tomlConfiguration OK. -
Start the server. Port 443 needs root or the
CAP_NET_BIND_SERVICEcapability; Running in production has a systemd unit that grants it. The log confirms the listener:INFO etemenanki_app::instance: inbound vless-ws-in listening on 0.0.0.0:443 -
Probe the WebSocket endpoint from outside. Send a hand-made upgrade request with
curl. This tests DNS, the firewall, the certificate, the path and theHostcheck in one go:Terminal window 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/vlessThe expected answer is a
101, after whichcurlwaits until--max-timeand exits with(28) Operation timed out, because it never sends a VLESS request:HTTP/1.1 101 Switching Protocolsconnection: Upgradeupgrade: websocketsec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=HTTP/1.1 404 Not Foundmeans the path orHostdoes not match. A TLS error fromcurlmeans the certificate does not cover the name. A plaincurl https://proxy.example.com/vlesswithout the upgrade headers gets(52) Empty reply from server: the server closes anything that is not a WebSocket upgrade without answering. -
Check and start the client. On your own machine, fill in the server address, the domain and your UUID, then:
Terminal window etemenanki-app --test -c client.tomletemenanki-app -c client.toml -
Send a request through the tunnel.
Terminal window curl --socks5-hostname 127.0.0.1:1080 https://example.comcurlprints the page. The request wentcurl→socks-in→proxy→ TLS and WebSocket to the server →direct→example.com.
If step 7 fails, start the client and the server with [log] level = "debug" and read the failure section below: at the default info level, a failed connection is not logged.
Variant: TLS terminated by a reverse proxy
Section titled “Variant: TLS terminated by a reverse proxy”When a web server already owns port 443, let it terminate TLS and forward one path to etemenanki-app. The client does not change at all: it still connects to proxy.example.com:443 with TLS and WebSocket, and verifies the reverse proxy’s certificate.
flowchart LR C["client"] -->|"TLS on 443"| N["nginx, location /vless"] N -->|"plain WebSocket to 127.0.0.1:10000"| S["inbound vless-ws-in, no TLS"] N -->|"every other path"| W["website"]
[[inbound]]tag = "vless-ws-in"protocol = "vless"listen = "127.0.0.1" # loopback only: nginx is the only clientport = 10000
[inbound.stream]network = "ws"security = "none" # nginx has already removed TLS
[inbound.stream.ws]path = "/vless"host = "proxy.example.com" # nginx must pass the client's Host through
[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; }}What each part of the nginx block is for:
| Directive | Why it is needed |
|---|---|
location = /vless |
Forwards exactly the WebSocket path. The query string, including ?ed=2048, is not part of the match. |
proxy_http_version 1.1, Upgrade, Connection "upgrade" |
nginx talks HTTP/1.0 to the upstream and does not forward the upgrade headers on its own. Without proxy_http_version 1.1, etemenanki-app logs WebSocket protocol error: HTTP version must be 1.1 or higher at debug level; without the two headers, WebSocket protocol error: No "Connection: upgrade" header. Either way it closes the connection without an answer, and nginx gives the client 502 Bad Gateway. |
proxy_set_header Host $host |
By default nginx sends Host: 127.0.0.1:10000, which fails the inbound’s host check with 404. Either pass the client’s Host through or remove host from the inbound. |
proxy_read_timeout, proxy_send_timeout |
nginx closes a proxied connection after 60 seconds without traffic by default. etemenanki-app only probes a quiet WebSocket with a ping after 60 seconds, so an idle tunnel would race that limit. Raise both above etemenanki-app’s own 300-second idle limit. |
nginx passes the Sec-WebSocket-Protocol header through unchanged, so early data works across it.
Things that change when a reverse proxy sits in front:
- The server sees nginx as the client. etemenanki-app does not read
X-Forwarded-Foror the PROXY protocol, so every connection comes from127.0.0.1: in the debug log, and for anysource_cidrrule in[route]. - The listener must be TCP. A VLESS inbound on a Unix socket (
listenset to a path) carries no WebSocket, and a[inbound.stream]withnetwork = "ws"fails withprotocol vless over a unix socket does not support stream network "ws". Use a loopback port. - Keep the port on loopback.
listen = "127.0.0.1", the default, keeps the plaintext listener away from the network. Do not open port 10000 in the firewall.
Test the variant with the same steps as above. Step 5 now tests nginx and etemenanki-app together. A 502 Bad Gateway from nginx means that etemenanki-app is not listening on the loopback port that proxy_pass names, or that nginx does not forward the upgrade (see the table above).
Limits that apply
Section titled “Limits that apply”| Limit | Value | Effect |
|---|---|---|
| TLS handshake and WebSocket upgrade, on the server | 10 seconds together | A client that is slower is disconnected. |
| VLESS request after the upgrade | 10 seconds | A connection that upgrades but sends no VLESS request, like the curl probe in step 5, is closed after this time. |
| Quiet WebSocket | Ping after 60 seconds, closed after 300 seconds | Each side sends a ping after 60 seconds without traffic, and ends the session when nothing has arrived and nothing has been written for 300 seconds. A peer that answers pings keeps an idle tunnel open. |
| WebSocket message or frame | 1 MiB | A peer that sends larger messages is disconnected. |
| Early data | 16 KiB | Larger early data is refused with 413. |
| TLS versions | 1.2 and 1.3 | The server advertises the ALPN protocol http/1.1 for WebSocket. |
The limits shared by all inbounds, such as connection caps, are on the limits page.
When it does not work
Section titled “When it does not work”Configuration errors
Section titled “Configuration errors”--test reports these before anything starts. A hot reload that hits one logs it and keeps the running configuration:
| Error | Cause | Fix |
|---|---|---|
unknown field `paht`, expected `path` or `host` |
A typo in the ws table, or an Xray key such as headers |
Use path and host only; host replaces Xray’s headers.Host |
unknown stream security "TLS" (expected "tls" or "none") |
Any security other than those two, including a different case |
Write security = "tls" |
unknown stream network "websocket" |
Xray-style or misspelled network | Write network = "ws" |
inbound vless-ws-in: tls stream needs tls.cert_file (or tls.key_file) |
security = "tls" on the inbound without a certificate or key |
Add both under [inbound.stream.tls] |
No such file or directory (os error 2) |
cert_file or key_file names a missing file. The message does not say which |
Check both paths, and that the user running etemenanki-app can read them |
An OpenSSL error naming SSL_CTX_check_private_key, for example error:0A0000BE:SSL routines:SSL_CTX_check_private_key:no private key assigned:… (the wording depends on the OpenSSL build) |
The key does not belong to the certificate | Use the key issued with that certificate |
protocol vless over a unix socket does not support stream network "ws" |
WebSocket on a Unix socket listen |
Listen on a loopback port |
Connection failures
Section titled “Connection failures”When the client cannot open the tunnel, what curl reports depends on how it names the target. With --socks5-hostname, as in step 7, the SOCKS inbound answers with a failure reply and curl prints (97) Can't complete SOCKS5 connection to example.com. (4) ((5) when the server refused the TCP connection). With --socks5, curl sends an IP address; the inbound then grants the request first, to read the opening bytes for sniffing, and closes the connection when the tunnel fails, so curl reports (52) Empty reply from server for an http:// URL or a TLS error for an https:// one. The reason is in the debug log of each side. On the client, lines start with socks connection from … ended:; on the server, with inbound transport failed: for the TLS and WebSocket layers and vless connection from … ended: for VLESS.
| Client log | Server log | Cause | Fix |
|---|---|---|---|
HTTP error: 404 Not Found |
HTTP error: 404 Not Found |
The path differs, or the inbound has host and the client sends another Host |
Compare ws.path on both sides, and the client’s ws.host (or server_name, or server) with the inbound’s host |
certificate verify failed |
sslv3 alert bad certificate |
The certificate does not cover the client’s TLS server name | Set tls.server_name to a name on the certificate |
certificate verify failed |
tlsv1 alert unknown ca |
The client does not trust the issuer: a self-signed certificate or a missing intermediate | Use the full chain in cert_file, or give the client tls.ca_file for a private CA |
failed to connect to any address (…: Connection refused (os error 111)), or a timeout |
Nothing | Firewall, DNS, or the server is not listening | Repeat step 5 of the deployment |
| Upgrade succeeds, then the connection closes | invalid vless request user id |
The client’s id is not in users |
Copy the UUID again |
HTTP error: 502 Bad Gateway |
WebSocket protocol error: No "Connection: upgrade" header, or HTTP version must be 1.1 or higher |
A reverse proxy does not forward the upgrade | Add proxy_http_version 1.1 and the Upgrade and Connection headers, as in the nginx block |
Coming from Xray
Section titled “Coming from Xray”The same pair written for Xray maps onto these keys. An Xray client works against the etemenanki-app server, and the other way round; the integration tests run both directions against xray-core over WebSocket with and without TLS, Early data against Xray is tested in both directions with VMess over plain WebSocket; with VLESS it uses the same WebSocket code.
| 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", same meaning |
wsSettings.host, or wsSettings.headers.Host |
[.stream.ws] host |
tlsSettings.serverName |
[.stream.tls] server_name |
tlsSettings.certificates[0].certificateFile and .keyFile |
[inbound.stream.tls] cert_file and key_file |
vnext[0].address, .port |
server and port on the [[outbound]] |
vnext[0].users[0].id |
[outbound.settings] id |
The Xray migration page covers the rest of the configuration.