Skip to content

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.

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
  • 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.com and the address 203.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 a ca_file.
  • etemenanki-app installed on both machines, as described in Install.
/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" },
{ 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"]

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.

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.

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.

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.

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=N on the outbound’s path turns early data on with a limit of N bytes. The outbound holds the upgrade back until its first write, sends up to N bytes of that write base64url-encoded in the Sec-WebSocket-Protocol header, and sends any rest as ordinary messages after the upgrade. N is capped at 16384. ed=0, or a value that is not a number, turns early data off. The ed parameter never appears in the request: /vless?ed=2048 requests /vless.
  • Server. Nothing to configure. The inbound accepts early data from any client and echoes the header back. A ?ed= in the inbound’s path is ignored, so you can copy the client’s path to the server unchanged. Early data larger than 16 KiB is refused with 413 Payload Too Large, and a Sec-WebSocket-Protocol value 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.

  1. Generate the user ids. Run this once per user and put the results in the server’s users and in each client’s id:

    Terminal window
    cat /proc/sys/kernel/random/uuid

    uuidgen works too.

  2. 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_file to 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.

  3. Check the server config. --test builds everything a start would, reads the certificate and key, and binds nothing:

    Terminal window
    etemenanki-app --test -c /etc/etemenanki/server.toml
    Configuration OK.
  4. Start the server. Port 443 needs root or the CAP_NET_BIND_SERVICE capability; 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
  5. Probe the WebSocket endpoint from outside. Send a hand-made upgrade request with curl. This tests DNS, the firewall, the certificate, the path and the Host check 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/vless

    The expected answer is a 101, after which curl waits until --max-time and exits with (28) Operation timed out, because it never sends a VLESS request:

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

    HTTP/1.1 404 Not Found means the path or Host does not match. A TLS error from curl means the certificate does not cover the name. A plain curl https://proxy.example.com/vless without the upgrade headers gets (52) Empty reply from server: the server closes anything that is not a WebSocket upgrade without answering.

  6. 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.toml
    etemenanki-app -c client.toml
  7. Send a request through the tunnel.

    Terminal window
    curl --socks5-hostname 127.0.0.1:1080 https://example.com

    curl prints the page. The request went curl → 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"]
/etc/etemenanki/server.toml
[[inbound]]
tag = "vless-ws-in"
protocol = "vless"
listen = "127.0.0.1" # loopback only: nginx is the only client
port = 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"

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-For or the PROXY protocol, so every connection comes from 127.0.0.1: in the debug log, and for any source_cidr rule in [route].
  • The listener must be TCP. A VLESS inbound on a Unix socket (listen set to a path) carries no WebSocket, and a [inbound.stream] with network = "ws" fails with protocol 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).

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.

--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

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

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.