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.
At a glance
Section titled “At a glance”| 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 |
Accepted but ignored
Section titled “Accepted but ignored”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.
address_family on an inbound
Section titled “address_family on an inbound”[[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.
Client-side TLS keys on an inbound
Section titled “Client-side TLS keys on an inbound”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.
Server-side TLS keys on an outbound
Section titled “Server-side TLS keys on an outbound”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,hysteria2andtuninbounds; - the
freedom,blackhole,hysteria2andwireguardoutbounds; - an
http,trojan,vlessorvmessinbound whoselistenis 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"[[outbound]]tag = "hy2"protocol = "hysteria2"server = "proxy.example.com"port = 443
[outbound.settings]password = "replace-with-a-long-random-password"server_name = "cdn.example.com"ca_file = "/etc/etemenanki/ca.pem"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.
DNS keys the backend does not use
Section titled “DNS keys the backend does not use”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.
SOCKS accounts with auth = "none"
Section titled “SOCKS accounts with auth = "none"”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.
An unknown inbound_tag in a rule
Section titled “An unknown inbound_tag in a rule”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 itoutbound = "block"Compare the tags in your rules with the tag of each [[inbound]] whenever a rule seems to have no effect.
TLS over TCP is network = "tls"
Section titled “TLS over TCP is network = "tls"”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"[inbound.stream]network = "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").
ca_file adds to the system roots
Section titled “ca_file adds to the system roots”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_filein 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.
UDP defaults differ between inbounds
Section titled “UDP defaults differ between inbounds”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 = trueudp_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_fileandca_filein[stream.tls];cert_file,key_fileandca_filein Hysteria 2 settings;[dns] ca_file;[route] geoipandgeosite;- the
-cpath itself, which defaults toconfig.tomlin 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.
Reloads
Section titled “Reloads”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 drops every connection
Section titled “A reload drops every connection”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.
[log].level is read once
Section titled “[log].level is read once”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_LOGenvironment variable, when it is set to a filter that parses, overrides[log].levelentirely. - 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--testexit with status 1 without printing why. Useerror,warn,info,debugortrace.
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.
Routing
Section titled “Routing”The first outbound is the default route
Section titled “The first outbound is the default route”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.
cidrandgeoipmatch 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.
Common errors
Section titled “Common errors”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 |