VMess over gRPC and TLS
This recipe builds a complete VMess deployment with two copies of etemenanki-app. The server listens on TCP port 443 and accepts VMess carried inside gRPC, inside TLS, with a certificate from a public certificate authority. The client runs on your own machine, offers a SOCKS proxy on 127.0.0.1:1080, and sends every connection to the server through that same gRPC-over-TLS tunnel.
On the wire the connection looks like an ordinary HTTP/2 gRPC call to your domain on port 443, which is why this layout is a common choice when the path between the two machines is filtered or when you want to put a CDN in front of the server. Follow the steps in order; each one ends with a check.
What you build
Section titled “What you build”flowchart LR A["browser or curl"] -->|"SOCKS5"| S["client inbound socks-in 127.0.0.1:1080"] S --> O["client outbound proxy (vmess)"] O -->|"TLS, HTTP/2, gRPC to proxy.example.com:443"| I["server inbound vmess-in"] I --> D["server outbound direct (freedom)"] D --> T["destination"]
Each connection passes through four layers between the two machines. From the outside in:
| Layer | Set by | What it does here |
|---|---|---|
| TCP | server, port on the client; listen, port on the server |
Carries everything on port 443. |
| TLS | [stream].security = "tls" and [stream.tls] |
Encrypts the connection and proves the server’s identity with its certificate. ALPN is h2. |
| HTTP/2 and gRPC | [stream].network = "grpc" and [stream.grpc] |
Frames the tunnel as a gRPC call to /<service_name>/Tun. |
| VMess | protocol = "vmess" and [inbound.settings] or [outbound.settings] |
Authenticates the user by UUID and timestamp, names the destination, and encrypts the payload once more. |
Before you start
Section titled “Before you start”- A domain name whose A (and, if you use IPv6, AAAA) record points at the server. This page uses
proxy.example.com; replace it everywhere with your own name. - TCP port 443 open on the server, and free: no web server may already listen on it. Binding a port below 1024 needs root or the
CAP_NET_BIND_SERVICEcapability; Running etemenanki-app covers service setup. - etemenanki-app installed on both machines. Install covers the binary and the suggested
/etc/etemenanki/layout. - An ACME client on the server, such as certbot, acme.sh or lego, to obtain a free certificate.
- Synchronized clocks on both machines. VMess refuses a client whose clock is more than 120 seconds away from the server’s, so turn on NTP now (see Keep the clocks in sync).
curlon the client machine, for the final test.
The two configs
Section titled “The two configs”Both files are complete and pass --test as shown, once the certificate files exist. The values in the table after the tabs must agree between the two files; the UUID, the service name and the domain name are the ones you replace with your own.
# The server side of the "VMess over gRPC and TLS" recipe: a VMess inbound# served as gRPC over TLS on port 443, with a direct exit.# Replace the certificate paths, the service name and the UUID before you use it.
[log]level = "info"
[[inbound]]tag = "vmess-in"protocol = "vmess"listen = "0.0.0.0"port = 443
[inbound.stream]network = "grpc"security = "tls"
# The client must use exactly the same service name.[inbound.stream.grpc]service_name = "tunnel"
# The certificate for proxy.example.com, as your ACME client writes it.[inbound.stream.tls]cert_file = "/etc/etemenanki/certs/fullchain.pem"key_file = "/etc/etemenanki/certs/privkey.pem"
[inbound.settings]users = [ { id = "11111111-2222-3333-4444-555555555555" },]
[[outbound]]tag = "direct"protocol = "freedom"
[[outbound]]tag = "block"protocol = "blackhole"
[route]default = "direct"
# Refuse requests for private, loopback and link-local addresses. This rule# only sees requests that name an IP: a domain is not resolved before routing.[[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", "169.254.0.0/16", "fc00::/7", "fe80::/10", "::1/128",]# The client side of the "VMess over gRPC and TLS" recipe: a local SOCKS# proxy on 127.0.0.1:1080 that sends every connection to the VMess server.# Replace the server name, the service name and the UUID with the server's.
[log]level = "info"
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[[outbound]]tag = "proxy"protocol = "vmess"server = "proxy.example.com"port = 443
[outbound.stream]network = "grpc"security = "tls"
# Must equal the server's service_name.[outbound.stream.grpc]service_name = "tunnel"
# The name the server's certificate is issued for.[outbound.stream.tls]server_name = "proxy.example.com"
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"security = "aes-128-gcm"
[route]default = "proxy"| Value | Server | Client | Rule |
|---|---|---|---|
| User UUID | [inbound.settings].users[].id |
[outbound.settings].id |
The client’s id must be one of the server’s users. |
| Service name | [inbound.stream.grpc].service_name |
[outbound.stream.grpc].service_name |
Must be byte-for-byte identical, including case. |
| Domain name | the name in the certificate behind cert_file |
[outbound.stream.tls].server_name (or server) |
The certificate must be valid for the name the client verifies. |
| Port | port = 443 |
port = 443 |
The client dials the port the server listens on. |
| Transport | network = "grpc", security = "tls" |
network = "grpc", security = "tls" |
Both sides must speak gRPC over TLS. |
The server also carries a block outbound and one rule that sends private, loopback and link-local addresses to it. Everything else goes out through direct. The rule matches only requests that name an IP address: the router does not resolve a domain before it matches, so a client that asks for a name resolving to a private address is not caught. curl --socks5-hostname and browsers that resolve through the proxy always send names. To keep clients off internal hosts, also block the internal domain names or restrict the server with a host firewall; Routing explains the matching.
Deploy it
Section titled “Deploy it”-
Obtain a certificate for your domain. Use your ACME client to get a certificate for
proxy.example.com. Any challenge type that does not need port 443 works: HTTP-01 answers on port 80, and DNS-01 needs no port at all. TLS-ALPN-01 needs port 443, which etemenanki-app will hold, so it fails once the server is running.You need two PEM files from the ACME client:
- the full chain: your certificate followed by the intermediate certificates (certbot calls it
fullchain.pem); - the private key, unencrypted (certbot calls it
privkey.pem).
Use the full chain, not the bare certificate. etemenanki-app sends the first certificate in the file as the leaf and every following one as an intermediate; without the intermediates, clients that verify against the system trust store fail with
certificate verify failed. - the full chain: your certificate followed by the intermediate certificates (certbot calls it
-
Place the files on the server. Copy them to the paths the server config names and keep the key readable only by the service:
Terminal window sudo install -d -m 0755 /etc/etemenanki/certssudo install -m 0644 fullchain.pem /etc/etemenanki/certs/fullchain.pemsudo install -m 0600 privkey.pem /etc/etemenanki/certs/privkey.pemThese commands suit a server that runs as root. The systemd unit in Running etemenanki-app runs as the user
etemenankiinstead; for it, install the key with-o root -g etemenanki -m 0640so that the service can read it. You can also pointcert_fileandkey_filestraight at the ACME client’s own output files instead of copying them; write absolute paths either way. -
Choose the shared values. Generate a UUID for the user:
Terminal window uuidgenPut it in
userson the server and inidon the client, replacing11111111-2222-3333-4444-555555555555. Pick a service name, write it intoservice_nameon both sides, and replaceproxy.example.comin the client with your domain. Save the server file as/etc/etemenanki/config.tomlon the server and the client file as/etc/etemenanki/config.tomlon your machine. -
Check both files. On each machine:
Terminal window etemenanki-app --test -c /etc/etemenanki/config.tomlEach prints
Configuration OK.and exits with status0. On the server,--testalso reads and parses the certificate and key, so a wrong path or a key that does not belong to the certificate shows up here rather than at the first connection. Common errors lists what the failures look like. -
Start the server. Run it in the foreground first, so you see its log:
Terminal window sudo etemenanki-app -c /etc/etemenanki/config.tomlINFO etemenanki_app::instance: inbound vmess-in listening on 0.0.0.0:443Once the test passes, run it as a service instead; see Running etemenanki-app.
-
Start the client. On your machine:
Terminal window etemenanki-app -c /etc/etemenanki/config.tomlINFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080The client opens nothing toward the server yet. It connects when the first application uses the SOCKS port.
-
Send a request through the tunnel. In a second terminal on the client machine:
Terminal window curl --socks5-hostname 127.0.0.1:1080 https://example.comcurlprints the HTML of the Example Domain page. To confirm that traffic really leaves from the server, request any service that echoes your public IP address the same way: it prints the server’s address, not yours.If it fails, the kind of failure tells you how far the connection got:
- curl cannot complete the SOCKS5 connection. The client could not reach the server: the TCP connection or the TLS handshake failed, and the client refused the SOCKS request.
- curl gets a connection that closes without data, such as
curl: (52) Empty reply from serverfor a plainhttp://URL or a TLS error such asSSL_ERROR_SYSCALLforhttps://. TCP, TLS and HTTP/2 worked, but the server refused the gRPC stream or the VMess request: check the service name, the UUID and the clocks.
For the details, set
level = "debug"under[log]on both machines, restart both, and read Common errors.
Point your browser or other applications at SOCKS5 proxy 127.0.0.1:1080. The SOCKS inbound also accepts UDP (its udp setting defaults to true), and the VMess outbound carries each UDP flow inside its own tunnel connection, so DNS and other UDP traffic from SOCKS5 clients work too. SOCKS describes the inbound’s settings.
Settings used by this recipe
Section titled “Settings used by this recipe”Every key below is checked: a misspelled key, including Xray’s camel-case names such as serviceName, fails with unknown field `serviceName`, expected `service_name` or `authority` . Transports covers the stream settings for every network, and VMess covers [inbound.settings] and [outbound.settings].
[stream]
Section titled “[stream]”| Key | Type | Default | Accepted values | Notes |
|---|---|---|---|---|
network |
string (enum) | "tcp" |
tcp, tls, ws, grpc |
Case-sensitive. grpc requires [stream.grpc].service_name. Anything else fails with unknown stream network. |
security |
string (enum) | none | tls, none, or empty |
With network = "grpc", tls puts TLS under HTTP/2; leaving it out runs gRPC over plain TCP. Any other value, including TLS, reality and xtls, fails with unknown stream security "…" (expected "tls" or "none"). |
gRPC without security = "tls" passes --test on both sides, so a client that leaves it out builds a plaintext dialer. Against this server it fails at connect time, and the server logs wrong version number. Always set security = "tls" on both sides for this recipe.
[stream.grpc]
Section titled “[stream.grpc]”| Key | Type | Default | Server | Client |
|---|---|---|---|---|
service_name |
string | none; required with network = "grpc" |
The server answers requests for /<service_name>/Tun and /<service_name>/TunMulti and refuses every other path. |
The client always requests /<service_name>/Tun. |
authority |
string | tls.server_name, then server |
Ignored: the server does not check the authority a client sends. | The HTTP/2 :authority of the request, the equivalent of the HTTP Host header. |
A missing service_name fails with inbound vmess-in: grpc stream needs grpc.service_name (or outbound proxy: … on the client). An empty string passes the check but builds the path //Tun; use a real name. etemenanki-app inserts the name into the path verbatim, so write a plain name such as tunnel, without slashes.
[stream.tls]
Section titled “[stream.tls]”| Key | Type | Default | Server | Client |
|---|---|---|---|---|
cert_file |
path | none | Required: PEM certificate chain, leaf first. Leaving it out fails with tls stream needs tls.cert_file. |
Ignored. |
key_file |
path | none | Required: PEM private key, unencrypted, matching the leaf certificate. Leaving it out fails with tls stream needs tls.key_file. |
Ignored. |
server_name |
string | server |
Ignored. | The name sent as SNI and checked against the certificate. |
allow_insecure |
bool | false |
Ignored. | true turns off certificate chain and name verification. Leave it off for a real certificate. |
ca_file |
path | none | Ignored. | Extra PEM CA certificates, added to the system trust store. Cannot be combined with allow_insecure. |
Both sides use OpenSSL with TLS 1.2 as the minimum, so TLS 1.3 is negotiated when both ends support it. For gRPC, both sides offer and select the ALPN protocol h2; there is no key for ALPN, cipher suites or a client fingerprint.
How the client picks its names
Section titled “How the client picks its names”The client uses three different names, and each falls back to the next when you leave it out:
| Purpose | First choice | Fallback | Last fallback |
|---|---|---|---|
| Address the TCP connection goes to | server |
none | none |
| TLS server name (SNI) and certificate check | [stream.tls].server_name |
server |
none |
HTTP/2 :authority |
[stream.grpc].authority |
[stream.tls].server_name |
server |
When server is your domain name, as in the example, server_name and authority can both be left out: they default to the same name. Set them explicitly when the three differ:
serveris an IP address. Setserver_nameto the domain in the certificate. Without it the client checks the certificate against the IP address, which a certificate issued for a name does not cover.serverpoints at a CDN or another front. Setserver_nameand, if the front routes on it,authorityto your own domain. See Behind a CDN.
The client resolves server with etemenanki-app’s own resolver, and address_family on the outbound decides which IP family it uses; see Outbounds and DNS.
The service name must match
Section titled “The service name must match”The service name is the only part of the request path that you choose, so it is how the server tells tunnel requests from anything else that reaches port 443. When the client asks for a different name, the TLS and HTTP/2 handshakes still succeed; the server then refuses that one stream with the HTTP/2 error REFUSED_STREAM and logs no error for it, not even at debug level.
The client logs the refusal at debug level:
DEBUG etemenanki_app::serve: socks connection from Some(127.0.0.1) ended: stream error received: refused stream before processing any application logicand the application behind it sees a connection that closes without data. If the server’s log stays silent while clients fail, compare the two service_name values first.
Keep the clocks in sync
Section titled “Keep the clocks in sync”Every VMess connection carries a timestamp, and the server accepts it only when it is within 120 seconds of the server clock, in either direction. A client or server whose clock drifts further fails with exactly the same error as a wrong UUID, logged by the server at debug level:
DEBUG etemenanki_app::serve: vmess connection from Some(203.0.113.7) ended: proxy core: vmess: unknown user or invalid auth idRun NTP on both machines (timedatectl status should show System clock synchronized: yes). Time zones do not matter, only the absolute clock. The clock window on the VMess page explains the check and gives a step-by-step fix.
Behind a CDN
Section titled “Behind a CDN”Because the tunnel is a standard gRPC call over HTTP/2, you can place a CDN or reverse proxy that supports gRPC between the client and the server. The front terminates the client’s TLS connection, then opens its own HTTP/2 connection to your server and forwards the gRPC stream.
-
Enable gRPC on the front. Many CDNs forward gRPC only when you turn it on for the domain, and some accept it only on certain ports; check your provider’s documentation. The front must speak HTTP/2 over TLS to your server; a front that downgrades to HTTP/1.1 cannot carry gRPC.
-
Keep a certificate the front accepts on the server. The front connects to your server over TLS and checks its certificate according to its own settings. A public certificate for your domain works with a strict setting; some CDNs also issue origin certificates for this link.
-
Point the client at the front. Set
serverto the front’s hostname or address and keepserver_nameset to your own domain. The client then sends your domain as SNI and as the:authority, which is what the front uses to find your site. Setauthorityseparately only when the front expects a different host name than the TLS name.
The server never checks the :authority it receives, so whatever host name the front forwards is accepted.
Interoperating with Xray
Section titled “Interoperating with Xray”The server accepts Xray clients over gRPC and TLS, and the client can connect to an Xray VMess server with the same transport. The Etemenanki test suite runs this server layout against a real Xray client, and the gRPC-over-TLS client transport against a real Xray server. When you move a configuration between the two, map the keys like this:
| Xray JSON | etemenanki-app TOML | Notes |
|---|---|---|
inbound settings.clients[].id |
[inbound.settings].users[].id |
Each user takes only id. alterId, email and level are refused. |
outbound settings.vnext[].address, port |
server, port |
|
outbound vnext[].users[].id |
[outbound.settings].id |
|
outbound vnext[].users[].security |
[outbound.settings].security |
aes-128-gcm, chacha20-poly1305 or auto, in any case. Here auto always means AES-128-GCM. none and zero are refused, and the server also refuses Xray clients that use them. |
vnext[].users[].alterId |
none | Must be 0 on the Xray side; etemenanki-app speaks AEAD VMess only. |
streamSettings.network: "grpc" |
[stream].network = "grpc" |
|
streamSettings.security: "tls" |
[stream].security = "tls" |
reality is not supported. |
grpcSettings.serviceName |
[stream.grpc].service_name |
Plain names only. Xray’s custom-path form, a serviceName that starts with /, has no equivalent. |
grpcSettings.authority |
[stream.grpc].authority |
Client side only. |
grpcSettings.multiMode |
none | The server accepts both modes (Tun and TunMulti). The client always uses Tun, which every Xray server accepts. |
grpcSettings.user_agent, idle_timeout, health_check_timeout, permit_without_stream, initial_windows_size |
none | Fixed inside etemenanki-app. |
tlsSettings.serverName |
[stream.tls].server_name |
|
tlsSettings.allowInsecure |
[stream.tls].allow_insecure |
|
tlsSettings.certificates[].certificateFile, keyFile |
[stream.tls].cert_file, key_file |
Files only; inline certificates are not supported. |
tlsSettings.alpn, fingerprint, pinnedPeerCertSha256 |
none | ALPN is always h2 for gRPC over TLS. |
An Xray client that connects to the server in this recipe needs an outbound like this one:
{ "protocol": "vmess", "settings": { "vnext": [ { "address": "proxy.example.com", "port": 443, "users": [ { "id": "11111111-2222-3333-4444-555555555555", "alterId": 0, "security": "aes-128-gcm" } ] } ] }, "streamSettings": { "network": "grpc", "security": "tls", "tlsSettings": { "serverName": "proxy.example.com" }, "grpcSettings": { "serviceName": "tunnel" } }}Xray clients may turn on mux ("mux": { "enabled": true }) against this server: the VMess inbound serves mux.cool and XUDP automatically. Migrating from Xray lists the differences for every other part of a configuration.
Connections and limits
Section titled “Connections and limits”- One tunnel connection per flow on the client. The etemenanki-app client opens a new TCP connection, TLS session and HTTP/2 connection for every proxied TCP connection and every UDP flow, and closes it when the flow ends. It does not keep a shared HTTP/2 connection open between flows, so each new flow costs one TCP and one TLS handshake to the server.
- Many streams per connection on the server. The server accepts up to 256 concurrent gRPC streams on one HTTP/2 connection, which is what Xray clients use when they share a connection between flows. A single gRPC message may be at most 1 MiB.
- Handshake time. A client must finish the TLS and HTTP/2 handshakes within 10 seconds of connecting (otherwise the server logs
grpc handshake timed out). On each gRPC stream, the server closes a stream that sends nothing for 10 seconds (inbound handshake timed out after 10s), and once the first bytes arrive the VMess request must be complete within 10 seconds. - Idle connections. The server drops an HTTP/2 connection that has carried no stream for 300 seconds. It also sends an HTTP/2 PING every 60 seconds and drops the connection if the answer takes longer than 20 seconds, which catches clients that vanished without closing while their streams were still open. A proxied connection itself closes after 300 seconds without traffic in either direction.
Limits lists these together with the per-inbound connection caps.
Renewing the certificate
Section titled “Renewing the certificate”etemenanki-app reads the certificate and key when it builds the configuration: at startup and on a reload. It does not watch the certificate files, and a reload happens only when the bytes of the configuration file change, so replacing the certificate alone has no effect on the running server.
After each renewal, have your ACME client’s deploy or reload hook do one of these:
- Restart the server. With the unit from Running etemenanki-app, that is
systemctl restart etemenanki(use your unit’s name). Runetemenanki-app --test -c /etc/etemenanki/config.tomlfirst: it reads the new certificate and key exactly as a start does, so a broken file shows up while the old process still serves. - Change the configuration file. Any change to its bytes, such as a rewritten comment line, starts a reload, and the reload reads the certificate and key again. If the new files are broken, the reload fails with
reload: build failed, keeping current config: …and the old certificate stays in use. Changing a certificate or geodata file shows a one-line hook for this.
Both drop the connections that are open at that moment; clients reconnect on their next flow.
Common errors
Section titled “Common errors”Configuration errors are prefixed with configuration invalid: under --test, with failed to start: at startup, and with reload: build failed, keeping current config: (or reload: parse failed, … for a TOML error) on a reload. Connection errors appear only at debug level: set level = "debug" under [log] and restart, because a reload does not change the level. On the client they read socks connection from … ended: …; on the server, inbound transport failed: … for TLS and HTTP/2 failures and vmess connection from … ended: … for VMess failures.
| Message | Where | Cause | Fix |
|---|---|---|---|
grpc stream needs grpc.service_name |
--test, either side |
network = "grpc" without [stream.grpc].service_name |
Add the service name. |
unknown field `serviceName`, expected `service_name` or `authority` |
--test, either side |
Xray’s key name | Write service_name. |
tls stream needs tls.cert_file (or tls.key_file) |
--test, server |
security = "tls" without the certificate or key path |
Add both paths under [inbound.stream.tls]. |
No such file or directory (os error 2) |
--test, server |
A certificate or key path does not exist; the message does not name the file | Check both paths, and write them as absolute paths. |
no certificate in PEM bundle |
--test, server |
cert_file points at a file without a certificate, such as the key |
Point cert_file at the full-chain PEM file. |
SSL_CTX_check_private_key:no private key assigned (inside a longer OpenSSL error) |
--test, server |
key_file does not belong to the certificate |
Use the key issued together with the certificate. |
unknown stream security "TLS" (expected "tls" or "none") |
--test, either side |
Upper case, or an Xray-only value such as reality |
Write security = "tls". |
tls.allow_insecure and tls.ca_file cannot both be set |
--test, client |
Both verification overrides at once | Keep ca_file for a private CA, or neither for a public certificate. |
stream error received: refused stream before processing any application logic |
client, debug |
The client’s service_name differs from the server’s |
Make the two identical. The server logs nothing for this case. |
certificate verify failed |
client, debug |
The certificate does not cover server_name, has expired, or lacks its intermediates |
Check server_name, renew, or use the full chain on the server. |
sslv3 alert bad certificate |
server, debug |
The client refused the server’s certificate | See certificate verify failed above. |
wrong version number |
server, debug |
The client connects without TLS: security = "tls" is missing on the client |
Add security = "tls" to the client’s [outbound.stream]. |
vmess: unknown user or invalid auth id |
server, debug |
Wrong UUID, or clocks more than 120 seconds apart | Compare the UUIDs, then the clocks. |
failed to connect to any address (…: Connection refused (os error 111)) |
client, debug |
Nothing listens on the server’s port, or a firewall rejects it | Check that the server runs and that port 443 is open. |
unknown vmess security "none" |
--test, client |
The VMess body cipher is none or zero |
Use aes-128-gcm, chacha20-poly1305 or auto. |