Skip to content

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.

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.
  • 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_SERVICE capability; 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).
  • curl on the client machine, for the final test.

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.

/etc/etemenanki/config.toml (server)
# 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", email = "alice@example.com" },
]
[[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",
]
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.

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

  2. 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/certs
    sudo install -m 0644 fullchain.pem /etc/etemenanki/certs/fullchain.pem
    sudo install -m 0600 privkey.pem /etc/etemenanki/certs/privkey.pem

    These commands suit a server that runs as root. The systemd unit in Running etemenanki-app runs as the user etemenanki instead; for it, install the key with -o root -g etemenanki -m 0640 so that the service can read it. You can also point cert_file and key_file straight at the ACME client’s own output files instead of copying them; write absolute paths either way.

  3. Choose the shared values. Generate a UUID for the user:

    Terminal window
    uuidgen

    Put it in users on the server and in id on the client, replacing 11111111-2222-3333-4444-555555555555. Pick a service name, write it into service_name on both sides, and replace proxy.example.com in the client with your domain. Save the server file as /etc/etemenanki/config.toml on the server and the client file as /etc/etemenanki/config.toml on your machine.

  4. Check both files. On each machine:

    Terminal window
    etemenanki-app --test -c /etc/etemenanki/config.toml

    Each prints Configuration OK. and exits with status 0. On the server, --test also 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.

  5. Start the server. Run it in the foreground first, so you see its log:

    Terminal window
    sudo etemenanki-app -c /etc/etemenanki/config.toml
    INFO etemenanki_app::instance: inbound vmess-in listening on 0.0.0.0:443

    Once the test passes, run it as a service instead; see Running etemenanki-app.

  6. Start the client. On your machine:

    Terminal window
    etemenanki-app -c /etc/etemenanki/config.toml
    INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080

    The client opens nothing toward the server yet. It connects when the first application uses the SOCKS port.

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

    curl prints 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 server for a plain http:// URL or a TLS error such as SSL_ERROR_SYSCALL for https://. 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.

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

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.

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.

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.

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:

  • server is an IP address. Set server_name to 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.
  • server points at a CDN or another front. Set server_name and, if the front routes on it, authority to 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 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 logic

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

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 id

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

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.

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

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

  3. Point the client at the front. Set server to the front’s hostname or address and keep server_name set 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. Set authority separately 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.

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:

Xray client outbound
{
"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.

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

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). Run etemenanki-app --test -c /etc/etemenanki/config.toml first: 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.

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.