Skip to content

Gotchas

etemenanki-app is strict about its configuration file. An unknown key, a misspelt protocol or an unknown security value stops --test and a real start. That strictness covers what a key is called and what values it takes. It does not cover whether the object you put the key on ever reads it, and a few behaviours are deliberate but still catch people out.

This page collects those cases. Each entry says what happens, why, and what to do instead. Read it when a config passes --test but does not behave the way you expected, or before you carry a config over from Xray.

Gotcha What you see Fix
Keys that are accepted but ignored Configuration OK., then the key has no effect Remove the key, or move it to where it is read
TLS over TCP security = "tls" is not valid with network = "tcp" Write network = "tls"
ca_file adds trust A publicly trusted certificate still verifies Expect system roots plus your CA
UDP defaults Hysteria 2 clients get no UDP Set udp = true on the Hysteria 2 inbound
Relative paths No such file or directory (os error 2) without a file name Use absolute paths
Reloads drop connections Every client reconnects after an edit Plan edits; group changes into one save
[log].level on reload A new level has no effect Restart the process
Certificate rotation Old certificate still served Change the config file, or restart
The default route Unmatched traffic goes to an unexpected outbound Set [route].default
Rule matchers A rule catches far more than intended One condition per rule, most specific first
IPv6 syntax --test passes, every dial fails No brackets in server, endpoint and listen; brackets in [dns].server

The parser rejects keys it does not know. The keys below are all known, so they pass the parser, but the object they sit on never reads them. --test prints Configuration OK. and the value has no effect.

Key Ignored on Why
address_family every [[inbound]] Only outbounds choose destination addresses
stream.tls.server_name, allow_insecure, ca_file inbounds They configure a TLS client
stream.grpc.authority inbounds A server does not check the authority a client sends
stream.tls.cert_file, key_file outbounds There are no client certificates
[stream.ws], [stream.grpc], [stream.tls] any object whose network does not use them Only the chosen network’s table is read
Any [stream.*] sub-table protocols without a transport Only network and security are checked there
settings, server, port freedom, blackhole outbounds They dial the flow’s own destination, or nothing
address_family blackhole outbounds Not even validated
server, port wireguard outbounds The peer is settings.endpoint
[dns] keys backends that do not use them Each backend reads only its own keys
accounts SOCKS inbounds with auth = "none" Only auth = "password" reads them
password legacy Shadowsocks inbounds with users Each user’s own password is used
An inbound_tag that no inbound has [[route.rule]] Tags in rules are not checked

The subsections below explain the entries that need more than a line.

[[inbound]] has an address_family key, and the parser accepts any string in it, including values that are not valid policies. No inbound reads it. The key only does something on an [[outbound]], where it controls which address family the outbound dials. Put it there:

[[outbound]]
tag = "direct"
protocol = "freedom"
address_family = "prefer_ipv4"

See Outbounds for the accepted values.

An inbound is a TLS server. It reads cert_file and key_file from [inbound.stream.tls] and nothing else. server_name, allow_insecure and ca_file are client settings: on an inbound they are accepted and ignored, and a ca_file there is never even opened, so a path to a file that does not exist passes --test. The server does not verify client certificates.

[inbound.stream.grpc] authority is ignored for the same reason. The inbound’s [inbound.stream.ws] host is different: when it is set, the server compares it with the request’s Host header and answers 404 to a request for any other host.

An outbound is a TLS client and never presents a certificate. cert_file and key_file in [outbound.stream.tls] are accepted and ignored, and the files are not read. The outbound reads server_name, allow_insecure and ca_file (see Transports).

Sub-tables the chosen network does not use

Section titled “Sub-tables the chosen network does not use”

Each network reads only its own sub-table:

network Reads
tcp (default) nothing
tls [stream.tls]
ws [stream.ws], plus [stream.tls] when security = "tls"
grpc [stream.grpc], plus [stream.tls] when security = "tls"

A [stream.ws] table under network = "grpc" does nothing. On an inbound, a [stream.tls] table on a WebSocket or gRPC stream without security = "tls" does nothing either. An outbound is the one exception: without TLS it still reads tls.server_name, as the fallback for the WebSocket Host header when ws.host is not set, and for the gRPC authority when grpc.authority is not set. Its other TLS keys are not read.

Stream sub-tables on protocols without a transport

Section titled “Stream sub-tables on protocols without a transport”

Some protocols never build a transport:

  • the socks, shadowsocks, hysteria2 and tun inbounds;
  • the freedom, blackhole, hysteria2 and wireguard outbounds;
  • an http, trojan, vless or vmess inbound whose listen is a Unix socket path.

For these, etemenanki-app checks stream.network and stream.security only. A network other than tcp or a security other than none is refused:

inbound socks-in: protocol socks does not support stream network "ws"
outbound direct: protocol freedom does not support stream security "tls"
inbound local-in: protocol vless over a unix socket does not support stream network "ws"

The sub-tables are not checked. [stream.tls], [stream.ws] and [stream.grpc] on these protocols are accepted and ignored. The case that costs people time is Hysteria 2, whose TLS settings live in [outbound.settings]:

[[outbound]]
tag = "hy2"
protocol = "hysteria2"
server = "proxy.example.com"
port = 443
# Accepted, never read: the certificate is still checked
# against the system roots and the name proxy.example.com.
[outbound.stream.tls]
server_name = "cdn.example.com"
ca_file = "/etc/etemenanki/ca.pem"
[outbound.settings]
password = "replace-with-a-long-random-password"

Leave the [stream] block out entirely on these protocols.

server, port and settings on freedom, blackhole and WireGuard

Section titled “server, port and settings on freedom, blackhole and WireGuard”

freedom dials each flow’s own destination, and blackhole dials nothing. Neither reads server, port or [outbound.settings]. Nothing in their settings table is checked, so Xray keys such as domainStrategy or redirect pass --test and do nothing. blackhole also does not read address_family, so an invalid value there is not reported. freedom does read and validate it.

A wireguard outbound takes its peer from settings.endpoint and ignores server and port.

There is one place where server and port on these three protocols are read: a balancer. A balancer member needs a server and port to probe with a TCP connect, and it looks only for the keys, not at the protocol (Hysteria 2 is the one exception it refuses). A freedom, blackhole or wireguard outbound with a stray server and port is therefore accepted as a member and probed at that address, while its traffic still goes direct, nowhere, or through the tunnel. The probe then says nothing about the outbound itself.

Each [dns] backend reads only its own keys and ignores the rest:

Key system udp tls https
server ignored required required required
server_name ignored ignored required ignored
url ignored ignored ignored required
ca_file read, not used read, not used used used

ca_file is a special case: whenever it is set, etemenanki-app reads the file while it builds the config, whatever the backend. A path that does not exist fails the build even under system, where the file would never be used. With https, the TLS name comes from the host in url, so server_name has no effect. A port written in url is ignored as well: the resolver is always dialed at server. See DNS.

A SOCKS inbound defaults to auth = "none". Adding accounts does not change that: the accounts are accepted and ignored, and the proxy stays open to anyone who can reach it. Set both:

[inbound.settings]
auth = "password"
accounts = [{ user = "alice", pass = "replace-with-a-long-random-password" }]

The opposite mistake is also accepted: auth = "password" with no accounts builds a proxy that nobody can log in to.

The HTTP inbound has no auth key. It requires credentials exactly when accounts is not empty.

The legacy Shadowsocks password with users

Section titled “The legacy Shadowsocks password with users”

A legacy (AEAD) Shadowsocks inbound always requires password: leaving it out fails with inbound ss-in: invalid settings: missing field `password` . As soon as users is not empty, though, only the users’ passwords are accepted, and the top-level password is never used. Clients configured with it fail to connect. Give each client one of the users passwords.

A Shadowsocks 2022 inbound is different: with users, the top-level password is the server’s identity key and is required by the protocol. See Shadowsocks.

Every outbound a rule names must exist, or the build fails with route references unknown outbound tag. The inbound_tag values in a rule are not checked. A misspelt tag makes that matcher match nothing, and no error or warning points at it:

[[inbound]]
tag = "socks-in"
# …
[[route.rule]]
inbound_tag = ["sock-in"] # no such inbound; this rule never matches on it
outbound = "block"

Compare the tags in your rules with the tag of each [[inbound]] whenever a rule seems to have no effect.

Xray spells TLS over plain TCP as network = "tcp" plus security = "tls". etemenanki-app refuses that pair, including a security = "tls" written without any network, since network defaults to tcp:

inbound trojan-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 refusal is deliberate. Silently building a plaintext listener or dialer here would send a Trojan password hash or a VLESS UUID in clear text before the mistake became visible.

[inbound.stream]
network = "tcp"
security = "tls"

How network and security combine:

network security absent or "none" security = "tls"
tcp plain TCP refused
tls TLS over TCP TLS over TCP
ws plaintext WebSocket WebSocket over TLS
grpc plaintext gRPC gRPC over TLS

network = "tls" is always TLS, even with security = "none" beside it. Any security value other than tls, none or an empty string is refused, including a different case: security = "TLS" fails with unknown stream security "TLS" (expected "tls" or "none").

Every ca_file that verifies a server adds its certificates to the system’s default trust store. It does not replace the store. This holds for all three places the key appears:

  • [outbound.stream.tls] ca_file;
  • ca_file in a Hysteria 2 outbound’s [outbound.settings];
  • [dns] ca_file.

A server with a publicly trusted certificate for the right name still verifies when ca_file is set, and there is no key that restricts trust to the file alone. Use ca_file to make a certificate from your own CA verify, not to pin one.

ca_file and allow_insecure = true cannot be combined. The build fails with outbound <tag>: tls.allow_insecure and tls.ca_file cannot both be set, or allow_insecure and ca_file cannot both be set for Hysteria 2.

Three inbounds have a switch for relaying UDP, and they do not agree on its default. The trojan, vless and vmess inbounds carry UDP without a switch.

Inbound Key Default
socks settings.udp true
tun settings.udp true
hysteria2 settings.udp false

A Hysteria 2 server without udp = true accepts TCP streams only, so UDP from its clients (DNS, QUIC, games, voice calls) does not reach any outbound. Turn it on explicitly:

[inbound.settings]
cert_file = "/etc/etemenanki/cert.pem"
key_file = "/etc/etemenanki/key.pem"
password = "replace-with-a-long-random-password"
udp = true

udp_idle_timeout on a Hysteria 2 inbound needs udp = true. Without it, the build fails with udp_idle_timeout is set but udp is not enabled rather than ignoring the timeout.

The http and shadowsocks inbounds carry no UDP at all. On the outbound side, http and shadowsocks cannot carry UDP either: a UDP flow routed to them fails with http carries no datagrams (or shadowsocks, shadowsocks-2022). See Hysteria 2 and Outbounds.

Relative paths follow the working directory

Section titled “Relative paths follow the working directory”

etemenanki-app opens every file named in the config as written. A relative path is resolved against the process’s current working directory, not against the directory that holds the config file. That applies to:

  • cert_file, key_file and ca_file in [stream.tls];
  • cert_file, key_file and ca_file in Hysteria 2 settings;
  • [dns] ca_file;
  • [route] geoip and geosite;
  • the -c path itself, which defaults to config.toml in the working directory.

A config that passes --test in /etc/etemenanki can fail when a service manager starts it from /. The error does not name the file. A start logs the first line below, and --test the second:

ERROR etemenanki_app: failed to start: No such file or directory (os error 2)
ERROR etemenanki_app: configuration invalid: No such file or directory (os error 2)

A Unix socket listen is the one path that cannot be relative. etemenanki-app treats a listen value as a socket path only when it starts with /. A relative path such as run/proxy.sock is taken as a host to bind with a TCP port, so --test fails with inbound <tag>: port is required. Write the full path, for example listen = "/run/etemenanki/proxy.sock".

Use absolute paths throughout. If you must use relative ones, set the service’s working directory to match (WorkingDirectory= in a systemd unit), and run --test from that same directory.

etemenanki-app watches the directory that holds its config file and reloads when the file’s contents change. The flow below shows where the surprises are. Hot reload describes it in full.

flowchart TB
  A["change in the config directory"] --> B["read the config file"]
  B --> C{"same bytes as the last attempt?"}
  C -->|yes| D["nothing happens"]
  C -->|no| E["parse and build, reading every referenced file"]
  E -->|fails| F["log the error, keep the old generation"]
  E -->|succeeds| G["cancel the old generation: every connection drops"]
  G --> H["bind the new listeners"]

A reload does not patch the running proxy. It builds a complete new generation, cancels the old one, and starts the new one. Cancelling the old generation ends every connection it carries, including connections on inbounds whose settings did not change. A comment-only edit is enough to trigger this, because the check is on the file’s bytes.

Between the two generations the listeners are closed briefly. If a listener of the new generation cannot bind, for example because another process took the port in that gap, the error is logged and that inbound stays down until the next successful reload. The other inbounds start normally.

Save the file once, with all your changes, at a time when dropped connections matter least. Editors that write a file in several steps are handled: events are collected for 200 ms before the reload runs.

The log level is set when the process starts and is not changed by a reload. The reload line may say log changed, but the running filter stays the same. Restart the process to apply a new level.

Two related details:

  • The RUST_LOG environment variable, when it is set to a filter that parses, overrides [log].level entirely.
  • The level is not validated. A value that is not a level, such as level = "verbose", is taken as a filter for a module of that name and silences every log line, errors included. A config with a mistake then makes --test exit with status 1 without printing why. Use error, warn, info, debug or trace.

Rotating a certificate needs a config change

Section titled “Rotating a certificate needs a config change”

Certificates, CA files and geodata files are read while a config is built. Replacing one of them on disk changes nothing in the running proxy: the reload that the directory change triggers finds the config file’s bytes unchanged and stops there.

After you renew a certificate, do one of these:

  • change the config file’s bytes, for example a comment line such as # reload-stamp: 2026-09-24, which rebuilds the generation and reads the new files;
  • restart the process.

Both drop open connections. A certificate renewal hook can do either.

The same rule catches a failed reload. etemenanki-app remembers the bytes of the file it last tried, even when that attempt failed. If a reload failed because a referenced file was missing, creating the file does not retry; change the config file again after the file is in place.

Traffic that no rule matches goes to [route].default. When default is not set, it goes to the first [[outbound]] in the file. Reordering the outbounds, or adding a new one at the top, changes where unmatched traffic goes. A blackhole outbound written first drops everything that no rule sends elsewhere. A config with no [[outbound]] at all fails with config defines no outbounds.

Name the default explicitly so the order of the file does not matter:

[route]
default = "direct"

default may name an outbound or a balancer. A tag that does not exist fails with route references unknown outbound tag: <tag>.

A rule matches when any of its matchers matches

Section titled “A rule matches when any of its matchers matches”

A [[route.rule]] can list several matchers, but they are combined with OR, not AND: the rule applies as soon as one of them matches. This is the opposite of Xray, where the conditions of one rule must all hold.

# Matches every flow to example.com, and also every flow to
# port 443 anywhere, not only example.com on port 443.
[[route.rule]]
domain_suffix = ["example.com"]
port = ["443"]
outbound = "proxy"

Rules are tried in file order and the first match wins, so there is no way to require two conditions in one rule. Write one kind of condition per rule and put the most specific rules first.

Two more details of matching:

  • A rule with no matchers at all never matches.
  • cidr and geoip match only flows whose destination is an IP address. A flow addressed by domain name is not resolved for routing, so an IP rule never matches it. Domain rules, in turn, see the requested name and a name found by sniffing.

See Routing for every matcher.

IPv6 addresses in server, endpoint and listen

Section titled “IPv6 addresses in server, endpoint and listen”

Write IPv6 addresses without brackets in:

  • an outbound’s server;
  • a WireGuard outbound’s settings.endpoint;
  • an inbound’s listen.

A bracketed address is not an IP address to etemenanki-app, so it is taken as a host name, and no resolver can answer for a name like [2001:db8::1]. --test does not resolve names or bind ports, so it accepts the value. The failure comes later: every dial through that outbound fails, the WireGuard tunnel never comes up, or the start fails with:

ERROR etemenanki_app: failed to start: inbound socks-in bind [::]:1080 failed: failed to lookup address information: Name or service not known

[dns] server is the exception. It is parsed as a socket address, so an IPv6 address there needs brackets, and anything else is refused by --test:

Key Correct Wrong
server "2001:db8::10" "[2001:db8::10]"
settings.endpoint (WireGuard) "2001:db8::10:51820" "[2001:db8::10]:51820"
listen "::" "[::]"
[dns] server "[2001:db8::53]:53" "2001:db8::53:53" (fails with dns: invalid server address: invalid socket address syntax)

The WireGuard endpoint is split at its last colon, so the unbracketed form is not ambiguous: everything before the last : is the address and everything after it is the port.

The errors on this page that --test does report:

Message Cause Fix
security = "tls" is not valid with network = "tcp"; … Xray-style TLS over TCP, or security = "tls" without network network = "tls"
unknown stream security "…" (expected "tls" or "none") A typo or a different case security = "tls"
protocol <protocol> does not support stream network "…" / stream security "…" A [stream] block on a protocol without a transport Remove the [stream] block
No such file or directory (os error 2) A file in the config is missing, often a relative path Use absolute paths
tls.allow_insecure and tls.ca_file cannot both be set Both verification options on one outbound Keep one
udp_idle_timeout is set but udp is not enabled A Hysteria 2 timeout without udp = true Add udp = true, or remove the timeout
invalid settings: missing field `password` A legacy Shadowsocks inbound with users but no password Add a password; it is not used for users
dns: invalid server address: invalid socket address syntax A host name, or an IPv6 address without brackets, in [dns] server Write an IP socket address, IPv6 in brackets
route references unknown outbound tag: <tag> [route].default or a rule names a missing outbound Fix the tag