Transports and TLS
A transport is what carries a proxy protocol between two machines. Under a VLESS, VMess, Trojan or HTTP connection, and under a SOCKS or Shadowsocks outbound, etemenanki-app can put plain TCP, TLS, a WebSocket or a gRPC tunnel over HTTP/2, and it can add TLS under the WebSocket or gRPC layer. You choose it with the [inbound.stream] table on a server and the [outbound.stream] table on a client. Both sides of a connection must choose the same transport.
This page covers every key of the stream table and its tls, ws and grpc sub-tables: which combinations are valid, which protocols accept a transport, how certificates are loaded and verified, and the fixed timeouts and limits of each carrier. Read it when you put a proxy behind TLS, a CDN or a reverse proxy, or when you connect to an Xray server that does. katana uses the same transports but takes their settings from the panel; its own pages cover that.
How the layers stack
Section titled “How the layers stack”Every transport starts with one TCP connection. network picks the carrier on top of it, and security = "tls" adds TLS between TCP and a WebSocket or gRPC carrier. The proxy protocol runs on the innermost layer.
flowchart LR tcp["TCP connection"] tlsA["TLS, no ALPN"] tlsB["TLS, ALPN http/1.1 or h2"] carrier["WebSocket or gRPC over HTTP/2"] proto["Proxy protocol: VLESS, VMess, Trojan, ..."] tcp -->|"network = tcp"| proto tcp -->|"network = tls"| tlsA --> proto tcp -->|"ws or grpc, security = tls"| tlsB --> carrier tcp -->|"ws or grpc, no security"| carrier carrier --> proto
A WebSocket carries one proxy connection per TCP connection. A gRPC inbound accepts many tunnels, one per HTTP/2 stream, on each TCP connection; a gRPC outbound opens a new HTTP/2 connection for each flow it dials.
A minimal example
Section titled “A minimal example”A VLESS server that listens behind WebSocket and TLS on port 443, and the client that reaches it. The server needs a certificate for proxy.example.com; the client checks it against the system roots.
[[inbound]]tag = "vless-in"protocol = "vless"listen = "0.0.0.0"port = 443
[inbound.stream]network = "ws"security = "tls"
[inbound.stream.ws]path = "/ray"
[inbound.stream.tls]cert_file = "/etc/etemenanki/cert.pem"key_file = "/etc/etemenanki/key.pem"
[inbound.settings]users = [{ id = "11111111-2222-3333-4444-555555555555" }]
[[outbound]]tag = "direct"protocol = "freedom"[[inbound]]tag = "socks-in"protocol = "socks"port = 1080
[[outbound]]tag = "proxy"protocol = "vless"server = "proxy.example.com"port = 443
[outbound.stream]network = "ws"security = "tls"
[outbound.stream.ws]path = "/ray?ed=2048" # the server still matches /ray; ?ed= turns on early data
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"The client sets neither tls.server_name nor ws.host, so both fall back to server: it sends the SNI proxy.example.com, verifies the certificate for that name, and sends Host: proxy.example.com. VLESS over WebSocket and TLS walks through a complete deployment, and VMess over gRPC and TLS does the same for gRPC.
Run etemenanki-app --test -c server.toml after every change. --test validates the stream table and reads the certificate, key and CA files, so every error on this page except those in the running connection shows up there.
The stream table
Section titled “The stream table”[inbound.stream] and [outbound.stream] share the same keys. Leaving the table out is the same as network = "tcp". Unknown keys are errors at every level, including the sub-tables.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
network | string (enum) | no | "tcp" | The carrier under the protocol: tcp (plain TCP), tls (TLS over TCP), ws (WebSocket) or grpc (gRPC over HTTP/2). Case-sensitive and not trimmed: "WS", " ws" and "" all fail with unknown stream network. Protocols without a transport accept only tcp (or empty) here. |
security | string (enum) | no | "none" | none (or empty) for no extra layer, tls to put TLS under a ws or grpc network. Surrounding spaces are ignored; the match is case-sensitive. Any other value fails with unknown stream security "..." (expected "tls" or "none"). With network = "tcp", tls is refused; write network = "tls" instead. With network = "tls", the connection is TLS whatever this says. |
tls | table | depends | — | Certificate, key and verification settings, read only when the stream actually uses TLS, except that a ws or grpc outbound always reads server_name as its Host or :authority fallback. An inbound using TLS needs cert_file and key_file here. See the [stream.tls] table. |
ws | table | no | — | WebSocket path and Host, read only when network = "ws". See the [stream.ws] table. |
grpc | table | depends | — | gRPC service name and authority, read only when network = "grpc"; that network requires grpc.service_name. See the [stream.grpc] table. |
The ws and grpc sub-tables are read only under their own network, and the certificate and verification keys of tls only when the stream uses TLS. A [inbound.stream.ws] table under network = "grpc" is accepted and has no effect, and so is a ca_file or allow_insecure in [outbound.stream.tls] under a plain ws network, even if the file does not exist. The exception is tls.server_name: a ws or grpc outbound uses it as the fallback for ws.host or grpc.authority with or without TLS.
Combining network and security
Section titled “Combining network and security”network names the carrier and security asks for TLS underneath it. etemenanki-app validates the pair instead of comparing security to "tls" and taking the plaintext branch for anything else: a config that asks for TLS and silently gets plaintext would send the proxy credential in the clear before any error appeared.
network |
security absent, "" or "none" |
security = "tls" |
Any other security |
|---|---|---|---|
"tcp" (default) |
Plain TCP | Refused: use network = "tls" |
Refused |
"tls" |
TLS over TCP | TLS over TCP | Refused |
"ws" |
WebSocket over plain TCP | WebSocket over TLS | Refused |
"grpc" |
gRPC over plain HTTP/2 (h2c) | gRPC over HTTP/2 over TLS | Refused |
| anything else | Refused | Refused | Refused |
network = "tls" is this project’s own name for TLS over TCP. Xray spells the same thing "network": "tcp" with "security": "tls", which is the one pair etemenanki-app refuses, with a message that names the fix:
configuration invalid: inbound vless-in: security = "tls" is not valid with network = "tcp"; use network = "tls" for TLS over plain TCP (security = "tls" layers TLS under network = "ws" or "grpc")The other errors look like this:
inbound vless-in: unknown stream network "WS"inbound vless-in: unknown stream security "reality" (expected "tls" or "none")Which protocols take a transport
Section titled “Which protocols take a transport”A protocol that owns its connection or never dials a proxy server cannot carry a transport. Rather than drop the stream table silently, and leave you with a bare port you believe is disguised, etemenanki-app refuses it.
| Side | Takes [stream] |
Refuses a transport |
|---|---|---|
| Inbound | http, trojan, vless, vmess, on an IP listener |
socks, shadowsocks, hysteria2, tun, and every protocol on a Unix socket |
| Outbound | socks, http, trojan, vless, vmess, shadowsocks |
freedom, blackhole, hysteria2, wireguard |
On the refusing side, network may only be absent, "" or "tcp", and security only absent, "" or "none"; surrounding spaces are ignored here. Anything else fails:
inbound socks-in: protocol socks does not support stream network "ws"inbound ss: protocol shadowsocks does not support stream security "tls"inbound local: protocol vless over a unix socket does not support stream network "ws"outbound direct: protocol freedom does not support stream network "ws"Hysteria 2 always runs over QUIC with its own TLS. Its certificate, key and verification keys live in [inbound.settings] and [outbound.settings]; see Hysteria 2.
Every combination
Section titled “Every combination”Each tab shows the server side and the matching client side. The [inbound.settings] and [outbound.settings] of the protocol are left out.
No stream table is needed; these two are the same as leaving it out.
[inbound.stream]network = "tcp"[outbound.stream]network = "tcp"[inbound.stream]network = "tls"
[inbound.stream.tls]cert_file = "/etc/etemenanki/cert.pem"key_file = "/etc/etemenanki/key.pem"[outbound.stream]network = "tls"# SNI and verification use `server` unless tls.server_name is set[inbound.stream]network = "ws"
[inbound.stream.ws]path = "/ray"[outbound.stream]network = "ws"
[outbound.stream.ws]path = "/ray"[inbound.stream]network = "ws"security = "tls"
[inbound.stream.ws]path = "/ray"host = "proxy.example.com" # optional: refuse other Host headers
[inbound.stream.tls]cert_file = "/etc/etemenanki/cert.pem"key_file = "/etc/etemenanki/key.pem"[outbound.stream]network = "ws"security = "tls"
[outbound.stream.ws]path = "/ray?ed=2048"[inbound.stream]network = "grpc"
[inbound.stream.grpc]service_name = "tunnel"[outbound.stream]network = "grpc"
[outbound.stream.grpc]service_name = "tunnel"[inbound.stream]network = "grpc"security = "tls"
[inbound.stream.grpc]service_name = "tunnel"
[inbound.stream.tls]cert_file = "/etc/etemenanki/cert.pem"key_file = "/etc/etemenanki/key.pem"[outbound.stream]network = "grpc"security = "tls"
[outbound.stream.grpc]service_name = "tunnel"[inbound.stream.tls] and [outbound.stream.tls] hold the certificate settings for every TLS shape: network = "tls", and ws or grpc with security = "tls". The table has one set of keys, but an inbound reads only cert_file and key_file, and an outbound reads only the other three.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
server_name | string | no | — | Outbound only. The name sent as SNI and checked against the server certificate. Falls back to the outbound server. When the name is an IP address, no SNI is sent and the certificate must list that IP. A ws or grpc outbound also uses it as the fallback for ws.host or grpc.authority, with or without TLS. Ignored on an inbound. |
allow_insecure | bool | no | false | Outbound only. Skip certificate chain and host name verification entirely. Cannot be combined with ca_file (tls.allow_insecure and tls.ca_file cannot both be set). Ignored on an inbound. |
ca_file | path | no | — | Outbound only. A PEM file of CA certificates to trust in addition to the system roots; the host name is still verified. A file with no certificate fails with no certificate in CA PEM bundle. Cannot be combined with allow_insecure. Ignored on an inbound. |
cert_file | path | depends | — | Inbound only, required whenever the inbound uses TLS (tls stream needs tls.cert_file). A PEM certificate chain: the leaf certificate first, then any intermediates. Ignored on an outbound; client certificates are not supported. |
key_file | path | depends | — | Inbound only, required whenever the inbound uses TLS (tls stream needs tls.key_file). The PEM private key that matches the leaf certificate; a key that does not match fails with no private key assigned. Ignored on an outbound. |
Inbound: certificate and key
Section titled “Inbound: certificate and key”An inbound that uses TLS needs both files. The certificate file is a PEM chain with the leaf certificate first, followed by any intermediates, as issued by most ACME clients as fullchain.pem. The key must match the leaf.
[inbound.stream.tls]cert_file = "/etc/etemenanki/fullchain.pem"key_file = "/etc/etemenanki/privkey.pem"The inbound serves this one certificate to every client, whatever SNI the client sends; it does not select certificates by name and does not reject unknown names. It never asks clients for a certificate.
| Problem | Error |
|---|---|
cert_file missing |
inbound vless-in: tls stream needs tls.cert_file |
key_file missing |
inbound vless-in: tls stream needs tls.key_file |
| A file does not exist or cannot be read | No such file or directory (os error 2), or another OS error |
No PEM certificate in cert_file (for example, the key and certificate paths are swapped) |
no certificate in PEM bundle |
No PEM private key in key_file |
An OpenSSL decoder error ending in No supported data to decode. Input type: PEM |
| The key does not match the certificate | An OpenSSL error SSL_CTX_check_private_key:no private key assigned |
etemenanki-app reads the files while it builds the config, at startup and on each hot reload. A reload happens only when the content of the config file changes, so a renewed certificate on disk is not picked up by itself. After a renewal, restart the process or change the config file.
Outbound: server name and verification
Section titled “Outbound: server name and verification”An outbound decides three things: the name it sends as SNI, the name it expects in the certificate (the same one), and which certificate authorities it trusts.
The name is tls.server_name when set, otherwise server. Because every outbound that takes a transport also requires server, there is always a name. Set server_name when you dial an IP address but the certificate is issued for a domain, or when the server sits behind a front that routes by SNI:
[[outbound]]tag = "proxy"protocol = "trojan"server = "203.0.113.10"port = 443
[outbound.stream]network = "tls"
[outbound.stream.tls]server_name = "proxy.example.com"
[outbound.settings]password = "replace-with-a-long-random-password"If the name is an IP address, no SNI is sent, and the certificate must list that IP address.
Verification has three modes:
| Setting | Trusted | Host name checked |
|---|---|---|
Neither ca_file nor allow_insecure (default) |
The system roots | Yes |
ca_file = "/etc/etemenanki/ca.pem" |
The system roots plus every certificate in the file | Yes |
allow_insecure = true |
Any certificate | No |
The system roots are OpenSSL’s default trust store; the SSL_CERT_FILE and SSL_CERT_DIR environment variables change where OpenSSL looks for it. ca_file adds to that store, it does not replace it, so a server with a publicly trusted certificate still verifies when ca_file is set. Use it for a server whose certificate comes from your own CA. A self-signed server certificate works too: put the certificate itself in ca_file, and make sure it covers the name the client checks.
ca_file and allow_insecure cannot be combined:
outbound proxy: tls.allow_insecure and tls.ca_file cannot both be setProtocol versions and ALPN
Section titled “Protocol versions and ALPN”TLS is provided by OpenSSL. Both sides accept TLS 1.2 and TLS 1.3 and refuse anything older; TLS 1.3 is used when the peer supports it. The inbound uses the Mozilla “intermediate” cipher configuration.
ALPN is fixed by the carrier and cannot be set:
| Carrier | ALPN offered by the outbound | ALPN selected by the inbound |
|---|---|---|
network = "tls" |
None | None |
ws with security = "tls" |
http/1.1 |
http/1.1 |
grpc with security = "tls" |
h2 |
h2 |
An inbound whose client offers no protocol it knows completes the handshake without ALPN instead of refusing it.
What is not configurable
Section titled “What is not configurable”The TLS table has exactly the five keys above. There is no key for:
- ALPN, cipher suites, TLS versions or curves;
- a TLS fingerprint (uTLS) or REALITY;
- certificate pinning;
- client certificates (mutual TLS), on either side;
- more than one certificate per inbound, or selecting a certificate by SNI.
A key from Xray’s tlsSettings, such as alpn or fingerprint, is refused as an unknown field:
unknown field `alpn`, expected one of `server_name`, `allow_insecure`, `ca_file`, `cert_file`, `key_file`WebSocket
Section titled “WebSocket”network = "ws" runs the protocol inside a WebSocket session: each write becomes one binary message. This is the transport to use behind a CDN or an HTTP reverse proxy that forwards WebSocket upgrades.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
path | string | no | "/" | The HTTP path of the upgrade request. An empty path becomes / and a missing leading / is added, so ray means /ray. A ?ed=N query parameter is not part of the path: on an outbound it turns on early data of up to N bytes (at most 16384); on an inbound it is removed and ignored. The inbound compares the request path exactly and answers anything else with 404. |
host | string | no | — | On an inbound: when set, the request Host header must match it, ignoring case and any :port, or the upgrade gets 404; when absent, any Host is accepted. On an outbound: the Host header to send, falling back to tls.server_name and then to server; it does not change the TLS SNI. Set it when server is an IPv6 address, which is not a valid URI host on its own. |
Both sides normalise the path the same way, so these pairs are equivalent:
| Written | Used |
|---|---|
absent or "" |
/ |
ray |
/ray |
/ray?ed=2048 |
/ray, with early data on an outbound |
/ray?foo=1&ed=2048 |
/ray?foo=1, with early data on an outbound |
The inbound compares the path of the upgrade request with its own, case-sensitively and exactly, and answers a request for any other path with 404 Not Found. It compares the path only, never the query string, so an inbound path that keeps a query parameter other than ed never matches any request.
On an inbound, host is an optional check. When it is set, the upgrade request must carry a Host header with the same name, compared without case and without any :port suffix; a request with a different or missing Host gets 404 Not Found, the same answer as a wrong path. When host is absent, any Host is accepted.
On an outbound, host is the Host header to send. When it is absent, the outbound uses the first of these that is set:
ws.hosttls.server_name, even whensecurityis not"tls"server
ws.host only sets the header. The SNI and the name checked in the certificate still come from tls.server_name or server, so behind a CDN you usually set both, as below.
Set host when server is an address that differs from the site name a CDN or reverse proxy routes on:
[[outbound]]tag = "via-cdn"protocol = "vmess"server = "198.51.100.20" # the CDN edgeport = 443
[outbound.stream]network = "ws"security = "tls"
[outbound.stream.ws]path = "/ray"host = "proxy.example.com"
[outbound.stream.tls]server_name = "proxy.example.com"
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"Early data
Section titled “Early data”WebSocket early data saves one round trip: the client sends the first bytes of the proxy connection inside the upgrade request instead of waiting for the upgrade to finish. etemenanki-app uses the same scheme as Xray, so it works with Xray clients and servers in both directions.
- Outbound. Add
?ed=Ntopath. The outbound opens the TCP (and TLS) connection when it dials, but holds the upgrade request back until the protocol writes its first bytes. Up toNof those bytes travel base64url-encoded in theSec-WebSocket-Protocolheader; the rest follow as ordinary messages.Nis capped at 16384.ed=0or a value that is not a number turns early data off, and the parameter is removed from the path either way. - Inbound. Nothing to configure. The inbound accepts early data from any client in the
Sec-WebSocket-Protocolheader, whether or not its ownpathhas?ed=, and echoes the header back when it carried early data. A header that does not decode as base64 is ignored. Early data larger than 16 KiB after decoding is refused with413 Payload Too Large.
Xray clients commonly write path = "/ray?ed=2048". Use the same path on the etemenanki-app outbound, and either /ray or /ray?ed=2048 on the inbound.
network = "grpc" carries the protocol in a gRPC stream over HTTP/2, compatible with Xray’s gRPC transport. It suits HTTP/2 front ends and CDNs that proxy gRPC.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
service_name | string | depends | — | Required when network = "grpc" (grpc stream needs grpc.service_name). The tunnel paths are /SERVICE/Tun and /SERVICE/TunMulti. The name is inserted verbatim, so write it without slashes. Both sides must use the same name. |
authority | string | no | — | Outbound only. The HTTP/2 :authority of each request, falling back to tls.server_name and then to server; it does not change the TLS SNI. Set it when server is an IPv6 address, which is not a valid URI authority on its own. An inbound does not check the authority and ignores this key. |
Service name and paths
Section titled “Service name and paths”service_name is required with network = "grpc", on both sides:
inbound vmess-in: grpc stream needs grpc.service_nameIt becomes two request paths. For service_name = "tunnel":
| Path | Xray name | Inbound | Outbound |
|---|---|---|---|
/tunnel/Tun |
gun mode, one Hunk per gRPC message |
Accepted | Always used |
/tunnel/TunMulti |
multi mode (multiMode: true), a MultiHunk batch per message |
Accepted | Never used |
The inbound serves both paths, so Xray clients work with or without multiMode. Requests for any other path are reset with REFUSED_STREAM. The inbound does not check :authority.
Outbound requests
Section titled “Outbound requests”The outbound sends each tunnel as a POST to /SERVICE/Tun with:
:authorityset to the first ofgrpc.authority,tls.server_name(even without TLS) andserver;content-type: application/grpcandte: trailers;- a fixed desktop Chrome
user-agent, which cannot be changed.
Every flow the outbound dials opens its own TCP connection and HTTP/2 connection; flows are not multiplexed on a shared connection.
Timeouts, keepalives and limits
Section titled “Timeouts, keepalives and limits”None of these values can be configured.
| What | Value | Where |
|---|---|---|
| TCP keepalive | First probe after 120 s of silence, then every 30 s; the connection is dropped after 3 unanswered probes | Every TCP connection an inbound accepts and every connection an outbound dials to its server |
| Transport handshake | 10 s for the TLS handshake, WebSocket upgrade or HTTP/2 preface, TLS included | Inbound |
| Protocol handshake | A separate 10 s limit that starts after the transport handshake; see limits | Inbound |
| WebSocket ping | A Ping after 60 s without WebSocket traffic |
Both sides |
| WebSocket idle | The session ends after 300 s with no frame received and nothing sent | Both sides |
| WebSocket message size | 1 MiB per message and per frame | Both sides |
| WebSocket early data | 16 KiB | Both sides |
| gRPC connection idle | An HTTP/2 connection with no open stream is closed after 300 s | Inbound |
| gRPC ping | A PING every 60 s; the connection is closed if the PONG takes more than 20 s |
Inbound |
| gRPC message size | 1 MiB per message | Both sides |
| HTTP/2 streams | 256 concurrent streams per connection | Inbound |
| HTTP/2 flow control | 4 MiB window per stream, 16 MiB per connection, 256 KiB maximum frame | Both sides |
The keepalive and ping schedules exist to reclaim connections whose peer vanished without closing them, such as a phone that changed networks. They do not close a connection that is merely idle but still answering.
Common errors
Section titled “Common errors”| Error | Cause | Fix |
|---|---|---|
security = "tls" is not valid with network = "tcp" |
Xray’s spelling of TLS over TCP | Write network = "tls" and drop security |
unknown stream network "WS" |
Wrong case, spaces or an unsupported carrier | Use exactly tcp, tls, ws or grpc |
unknown stream security "reality" (expected "tls" or "none") |
REALITY, XTLS or a typo | Use tls or none |
protocol socks does not support stream network "ws" |
A transport on a protocol that cannot carry one | Remove the stream table, or move the transport to a protocol that takes one |
protocol vless over a unix socket does not support stream network "ws" |
A transport on a Unix-socket listener | Listen on an IP address, or drop the transport |
tls stream needs tls.cert_file |
Inbound TLS without a certificate | Add cert_file and key_file to [inbound.stream.tls] |
no certificate in PEM bundle |
cert_file holds no PEM certificate |
Check the path, and that cert_file and key_file are not swapped |
no private key assigned |
key_file does not belong to the first certificate in cert_file |
Use the key issued with the certificate, and put the leaf first in cert_file |
No such file or directory (os error 2) |
A certificate, key or CA path is wrong | Check every file path in the stream tables |
grpc stream needs grpc.service_name |
network = "grpc" without a service name |
Set service_name to the same value on both sides |
tls stream needs tls.server_name or server, ws stream needs ws.host or server |
An outbound without server; the stream is checked before server itself |
Set server and port |
tls.allow_insecure and tls.ca_file cannot both be set |
Both verification overrides at once | Keep ca_file and remove allow_insecure |
no certificate in CA PEM bundle |
ca_file holds no PEM certificate |
Point ca_file at a PEM file of CA certificates |
unknown field `headers`, expected `path` or `host` |
An Xray key in the ws table |
Use host instead of headers.Host |
These failures pass --test and appear only in the running connection:
- A path,
Hostor service name that differs between the two sides. The WebSocket inbound answers404, the gRPC inbound resets the stream, and the client logs a failed dial. Comparepath,hostandservice_namecharacter for character. - A certificate the client does not accept. The client logs a TLS verification error. Check that the certificate covers the name the client checks (
server_nameorserver), and that the chain incert_fileincludes the intermediates. - An IPv6
serveron awsorgrpcoutbound withoutws.hostorgrpc.authority. Every dial fails; see the caution at the end of the gRPC section and set the host explicitly.
More cross-cutting surprises are collected in gotchas, and a key-by-key mapping from Xray’s streamSettings is in coming from Xray.