The configuration file
etemenanki-app reads one TOML file. It describes where clients connect (inbounds), where their traffic leaves (outbounds), how each flow picks an outbound (routing), and a few process-wide settings such as logging and DNS. This page covers the file as a whole: its layout, how strictly it is checked, the errors you will see, and the [log] table. Each section of the file has its own page, linked at the end.
Read this page before you write your first file by hand, or when --test rejects a file and the error message is not enough.
At a glance
Section titled “At a glance”The smallest useful file is a local SOCKS proxy that sends everything straight out:
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080
[[outbound]]tag = "direct"protocol = "freedom"Pass the file with -c (long form --config). Without it, etemenanki-app reads config.toml in the current working directory. Check a file without starting anything with --test:
etemenanki-app --test -c /etc/etemenanki/config.toml--test runs every check a real start runs, and reads every file the configuration needs, but binds no listener and opens no TUN device. On success it prints Configuration OK. and exits with status 0. On failure it logs configuration invalid: … and exits with status 1.
-
Write or edit the file.
-
Run
etemenanki-app --test -c <file>. -
If it fails, fix the error it names and run it again. Only the first error is reported, so a file with three mistakes takes three rounds.
-
Start the proxy with
etemenanki-app -c <file>, or save the file in place if the proxy is already running: it reloads on its own (see Hot reload).
Top-level structure
Section titled “Top-level structure”A file is made of six top-level entries. All of them are optional to the TOML parser, but the build needs at least one [[outbound]].
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
log | table | no | {} | Logging, written as [log]. Holds only level. See the [log] section of the configuration file page. |
dns | table | no | {} | Name resolution for outbounds and balancer probes, written as [dns]. Without it, etemenanki-app uses the host resolver behind an always-on cache. |
inbound | array of tables | no | [] | The listeners, one [[inbound]] block each. Written in the singular: [[inbounds]] is an unknown field. Zero inbounds is valid, although such a process accepts nothing. Tags must be unique among inbounds. |
outbound | array of tables | yes | — | Where flows leave, one [[outbound]] block each. Written in the singular. At least one is required, otherwise the build fails with config defines no outbounds. The first [[outbound]] in the file is the default route unless [route] sets default. Tags must be unique among outbounds. |
balancer | array of tables | no | [] | Groups of interchangeable outbounds, one [[balancer]] block each. Written in the singular. A balancer tag can be used wherever an outbound tag is accepted, so it must not repeat an outbound tag or another balancer tag. |
route | table | no | {} | Routing, written as [route], with its rules as [[route.rule]] blocks (singular; [[route.rules]] is an unknown field). The first matching rule wins. Without [route], every flow goes to the first [[outbound]]. |
This is how the sections relate to each other. Dashed lines are lookups rather than traffic.
flowchart LR IN["[[inbound]]"] --> R["[route] and [[route.rule]]"] R --> OUT["[[outbound]]"] R --> BAL["[[balancer]]"] BAL --> OUT DNS["[dns]"] -.-> OUT DNS -.-> BAL
Array tables and sub-tables
Section titled “Array tables and sub-tables”inbound, outbound, balancer and route.rule are arrays of tables: you write [[inbound]] with double brackets once per entry. The names are singular, even though Xray’s JSON spells the same lists inbounds and outbounds.
| You write | Result |
|---|---|
[[inbound]] |
One more inbound. |
[[inbounds]] |
Parse error: unknown field `inbounds`, expected one of `log`, `dns`, `inbound`, `outbound`, `balancer`, `route` . |
[inbound] (single brackets) |
Parse error: invalid type: map, expected a sequence. The same happens for [outbound], [balancer] and [route.rule]. |
[[route.rule]] |
One more routing rule. |
[[route.rules]] |
Parse error: unknown field `rules`, expected one of `default`, `geoip`, `geosite`, `rule` . |
Nested tables such as [inbound.settings], [inbound.stream] or [outbound.stream.tls] attach to the closest [[inbound]] or [[outbound]] above them. That is plain TOML, and it is the easiest mistake to make when you move blocks around.
When order matters
Section titled “When order matters”The position of a section in the file does not matter, with two exceptions:
[[outbound]]order. The first[[outbound]]is the default route when[route]has nodefault.[[route.rule]]order. Rules are tried from top to bottom and the first match wins. See Routing.
Order also decides which error you see first when a file has several; see Phase 2: building.
Strict by design
Section titled “Strict by design”etemenanki-app fails closed: a mistake stops the proxy with an error instead of quietly changing what it does. A misspelled listen that fell back to a default could put a proxy meant for localhost on the network; a misspelled security that fell back to plaintext would send credentials in the clear. Both are refused.
Unknown keys are errors
Section titled “Unknown keys are errors”Every table in the file rejects keys it does not know. That includes the nested stream, tls, ws and grpc tables and, for every protocol that has settings, its settings table. Keys are case-sensitive, so Tag is as unknown as lisen.
TOML parse error at line 8, column 1 |8 | lisen = "127.0.0.1" | ^^^^^unknown field `lisen`, expected one of `tag`, `protocol`, `listen`, `port`, `stream`, `address_family`, `sniffing`, `settings`The only alias in the whole schema is clients for users in a Shadowsocks inbound’s settings, accepted for Xray compatibility.
Unknown values are errors
Section titled “Unknown values are errors”Values such as protocol, network and strategy are plain strings to the TOML parser. The build step matches each one against the values it supports, and anything else is an error. None of them falls back to a default.
Most of these comparisons are exact and case-sensitive. A few are forgiving:
| Value | Accepted spelling | Example of a rejected value |
|---|---|---|
protocol (inbound and outbound) |
Exact, lower case. Aliases: hysteria and hy2 for hysteria2; on an outbound also direct for freedom and block for blackhole. |
"Freedom" → outbound direct: unknown protocol "Freedom" |
[.stream] network |
Exact: tcp, tls, ws, grpc. |
"WS" → inbound a: unknown stream network "WS" |
[.stream] security |
Exact after trimming spaces: tls, none or empty. tls is refused with network = "tcp"; TLS over TCP is network = "tls". |
"TLS" → unknown stream security "TLS" (expected "tls" or "none") |
[[balancer]] strategy |
Exact: failover, round_robin. |
"Failover" → unknown balancer strategy "Failover" (expected "failover" or "round_robin") |
[dns] backend |
Exact: system, udp, tls, https. |
"UDP" → dns: unknown backend "UDP" (expected "system", "udp", "tls" or "https") |
[[route.rule]] network |
Exact: tcp, udp. |
"TCP" → invalid rule network "TCP" (expected "tcp" or "udp") |
SOCKS inbound auth, Hysteria 2 obfs |
Exact. | "Password" → unknown socks auth "Password" |
Outbound address_family |
Case-insensitive, spaces trimmed, - read as _. |
"ipv5" → invalid address_family "ipv5" |
VMess outbound security |
Case-insensitive. | "none" → unknown vmess security "none" |
Shadowsocks method |
Case-insensitive for the AEAD methods; exact for the 2022- methods. |
"2022-BLAKE3-AES-128-GCM" → inbound a: unknown shadowsocks-2022 method "2022-BLAKE3-AES-128-GCM" |
[log] level |
Case-insensitive, but never rejected. See Logging. | "warning" is accepted and silences logging. |
Tags are compared exactly too: default = "direct" does not find an outbound tagged "Direct".
How a file is checked
Section titled “How a file is checked”Every time etemenanki-app reads the file, for --test, at startup and on each reload, it runs the same two phases. Only a real start goes on to bind listeners.
flowchart LR A["read the file"] --> B["phase 1: TOML parse"] B -->|"error with line and column"| X["refused"] B --> C["phase 2: build"] C -->|"error without line numbers"| X C --> T["--test: Configuration OK."] C --> S["start or reload: bind listeners"]
| Phase 1: parsing | Phase 2: building | |
|---|---|---|
| Checks | TOML syntax, UTF-8, unknown keys, value types (a string where a number belongs, a port above 65535), required keys such as tag and protocol |
every settings table, every enumerated value, tags and references, the files the config names, protocol-specific rules |
| Error location | line and column, with the offending line quoted | no line number; most errors name the object, for example inbound socks-in: or dns:, but some name nothing, such as file errors and invalid rule network "TCP" (expected "tcp" or "udp") |
| Stops at | the first error | the first error |
Phase 1: parsing
Section titled “Phase 1: parsing”The parser turns the file into typed tables. Its errors quote the line:
TOML parse error at line 7, column 8 |7 | port = "1080" | ^^^^^^invalid type: string "1080", expected u16Each protocol’s [inbound.settings] or [outbound.settings] table is not checked here. Its shape depends on protocol, so the parser keeps it as an untyped table and hands it to the build step.
Phase 2: building
Section titled “Phase 2: building”The build step constructs everything the process would run, without binding anything, in this order:
- a check that at least one
[[outbound]]exists; [dns], including reading itsca_file;- each
[[outbound]], in file order, including itssettings, its stream settings and any certificate or CA file; - each
[[balancer]], in file order; [route]: the rules in file order, then the default route, then the geosite and geoip files if a rule needs them;- each
[[inbound]], in file order, including itssettings, its stream settings and its certificate and key.
It stops at the first error, so a broken rule is reported before a broken inbound even if the inbound comes first in the file.
A settings table is checked in this phase, so its errors carry no line number. They name the object instead, in the form inbound <tag>: invalid settings: … or outbound <tag>: invalid settings: …:
[[inbound]]tag = "socks-in"protocol = "socks"port = 1080
[inbound.settings]auth = "password"acounts = [{ user = "alice", pass = "replace-with-a-long-random-password" }]inbound socks-in: invalid settings: unknown field `acounts`, expected one of `auth`, `accounts`, `udp`, `udp_bind`[[outbound]]tag = "trojan-out"protocol = "trojan"server = "proxy.example.com"port = 443outbound trojan-out: invalid settings: missing field `password`[[inbound]]tag = "socks-in"protocol = "socks"port = 1080
[inbound.settings]udp = "yes"inbound socks-in: invalid settings: invalid type: string "yes", expected a booleanin `udp`Search the file for the tag in the message to find the table.
Where the errors appear
Section titled “Where the errors appear”The error text is the same in every situation; only the prefix differs:
| Situation | Log line | What happens |
|---|---|---|
etemenanki-app --test |
configuration invalid: <error> |
Exits with status 1. |
| Startup | failed to start: <error> |
Exits with status 1. |
| Reload, phase 1 | reload: parse failed, keeping current config: <error> |
The running configuration stays in place. |
| Reload, phase 2 | reload: build failed, keeping current config: <error> |
The running configuration stays in place. |
| Reload, file unreadable | reload: cannot read <path>: <error> |
The running configuration stays in place. |
--test cannot catch problems that only exist when a socket is bound, such as a port already in use or a privileged port without the right capability. At startup such a problem stops the process, for example failed to start: inbound socks-in bind 127.0.0.1:1080 failed: Address already in use (os error 98). On a reload the old listeners are already closed by then, so the error is logged as inbound <tag> bind <address> failed: <error>, that inbound stays down, and the other inbounds start. See Hot reload.
Log lines, including these errors, go to standard output, as does Configuration OK..
Tags and the default route
Section titled “Tags and the default route”Every inbound, outbound and balancer has a tag. Rules, balancers and [route] default refer to outbounds by tag, and rules can match on the inbound tag.
| Rule | Error when broken |
|---|---|
| Inbound tags are unique among inbounds. | duplicate inbound tag: <tag> |
| Outbound tags are unique among outbounds. | duplicate outbound tag: <tag> |
| A balancer tag differs from every outbound tag and every other balancer tag, because a balancer can be used wherever an outbound can. | balancer tag <tag> collides with an outbound tag |
At least one [[outbound]] exists. |
config defines no outbounds |
Every outbound tag named in [route] default or a rule’s outbound exists, as an outbound or a balancer. |
route references unknown outbound tag: <tag> |
Every tag in a balancer’s outbounds names an [[outbound]]. Another balancer is not accepted there. |
balancer <tag> references unknown outbound tag: <member> |
Inbound and outbound tags live in separate namespaces, so an inbound and an outbound may share a tag. A rule’s inbound_tag list is not checked against the inbounds: a tag that matches no inbound makes that condition never match.
The default route is where a flow goes when no rule matches:
- if
[route]setsdefault, that outbound or balancer; - otherwise, the first
[[outbound]]in the file.
A file without [route] therefore sends everything to its first outbound. When you add a blackhole or freedom outbound above your proxy outbound, set default explicitly, or all traffic will follow the new first entry.
Files the configuration reads
Section titled “Files the configuration reads”Some keys name files. etemenanki-app reads them during the build phase, so --test fails if one is missing or unreadable.
| Key | Read when |
|---|---|
[inbound.stream.tls] cert_file, key_file |
The inbound’s stream uses TLS (network = "tls", or security = "tls" under ws or grpc). |
[outbound.stream.tls] ca_file |
The outbound’s stream uses TLS. |
[dns] ca_file |
Always, when the key is set, whatever the backend. |
[route] geosite |
Only when at least one rule has a geosite condition. |
[route] geoip |
Only when at least one rule has a geoip condition. |
Hysteria 2 inbound settings.cert_file, settings.key_file |
Always. |
Hysteria 2 outbound settings.ca_file |
When the key is set. |
A rule that uses geosite or geoip while the matching [route] key is missing fails with a geosite matcher is used but no geosite file is configured (or the geoip equivalent). A file that is present but lacks a code a rule names fails with geosite code not found: <code> or geoip code not found: <code>.
Two properties catch people out:
- Relative paths resolve against the process’s working directory, not against the config file’s directory.
cert_file = "certs/server.pem"works when you run--testfrom the config directory and fails when a service manager starts the process from/. Use absolute paths. - File errors do not name the file. A missing file is reported as
No such file or directory (os error 2), and a file without permission asPermission denied (os error 13), with no path and no tag. Check each path the file names, starting with the ones in the build order above.
configuration invalid: No such file or directory (os error 2)The same message appears when the configuration file itself, the one -c names, does not exist.
These files are read again on every reload, but a change to one of them does not trigger a reload by itself. See Hot reload.
Logging: [log]
Section titled “Logging: [log]”[log] has one key.
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
level | string | no | "info" | A tracing EnvFilter directive, the same syntax as RUST_LOG: a level (off, error, warn, info, debug, trace, or a number from 0 to 5, case-insensitive), optionally followed by per-target overrides such as "warn,etemenanki_protocols=debug". A RUST_LOG variable that parses as a filter replaces this value, even an empty one, which enables nothing. The value is not validated: a word that is not a level, such as "warning", is read as a target name and silences every log line, errors included. An empty value, or one in which no directive parses, logs errors only. Read once at startup and not applied on reload. |
level is a filter directive in the syntax of the tracing crate’s EnvFilter, the same one RUST_LOG uses. A bare level applies to everything; target=level pairs, separated by commas, override it for one crate or module.
level |
Effect |
|---|---|
"info" (default) |
Startup, listeners, reloads, warnings and errors. |
"debug" |
Adds per-connection events, such as failed handshakes and refused connections. Verbose on a busy server. |
"warn,etemenanki_protocols=debug" |
Warnings and errors everywhere, debug output from the protocol implementations. |
"off" |
Nothing, errors included. |
The level is resolved once, at startup:
- If the
RUST_LOGenvironment variable is set and parses as a filter, it is used and[log] levelis ignored. An emptyRUST_LOGcounts as a valid filter that enables nothing. ARUST_LOGthat does not parse is ignored. - Otherwise, if the file passes phase 1, its
levelis used, orinfowhen the file has nolevel. - Otherwise,
infois used, so a TOML parse error is always printed.
A change to [log] is noticed on reload, and the reload line reports log changed, but the new level is not applied. Restart the process to change it.
A complete annotated example
Section titled “A complete annotated example”This file exercises most sections: logging, DNS over HTTPS, two local inbounds, a VLESS and a Trojan outbound behind a failover balancer, direct and blackhole outbounds, and a few routing rules. It passes --test once geodata files that contain the category-ads-all and private codes exist at the two paths it names; without them the build stops with No such file or directory (os error 2). Replace the placeholder server names, UUID and password with your own.
# A complete, annotated etemenanki-app configuration.## It runs a local gateway: SOCKS5 and HTTP proxies on the loopback interface,# traffic carried to a VLESS server over WebSocket + TLS, a Trojan server that# takes over when the VLESS server stops answering, and rules that block ads,# keep private addresses direct and refuse outgoing mail.## Check it before you use it:# etemenanki-app --test -c /etc/etemenanki/config.toml## Every key is spelled exactly as etemenanki-app expects it. An unknown key is# an error, not a silent no-op. Relative paths resolve against the working# directory of the process, not against this file, so paths here are absolute.
# ---------------------------------------------------------------------------# Logging# ---------------------------------------------------------------------------[log]# An EnvFilter directive: a level, optionally with per-target overrides such# as "info,etemenanki_protocols=debug". A RUST_LOG variable that parses as a# filter replaces it. Read at startup only: a reload does not change it.level = "info"
# ---------------------------------------------------------------------------# Name resolution# ---------------------------------------------------------------------------# Used for the outbound servers below, for "direct" destinations and for# balancer probes. Leave the whole table out to use the host resolver.[dns]backend = "https" # "system" | "udp" | "tls" | "https"server = "1.1.1.1:443" # an IP literal with a port, never a nameurl = "https://cloudflare-dns.com/dns-query"
# ---------------------------------------------------------------------------# Inbounds: where clients connect# ---------------------------------------------------------------------------# A table such as [inbound.settings] belongs to the closest [[inbound]] above# it, so keep each inbound's sub-tables directly under it.
[[inbound]]tag = "socks-in" # unique among inbounds; rules can match on itprotocol = "socks"listen = "127.0.0.1" # the default; write "0.0.0.0" to accept remote clientsport = 1080
[inbound.settings] # checked at build time, after the TOML parseauth = "none"udp = true
[[inbound]]tag = "http-in"protocol = "http"listen = "127.0.0.1"port = 8080sniffing = true # the default: for an IP destination, read the name from TLS SNI or HTTP Host
[inbound.settings]accounts = [{ user = "alice", pass = "replace-with-a-long-random-password" }]
# ---------------------------------------------------------------------------# Outbounds: where flows leave# ---------------------------------------------------------------------------# Without [route].default, the first [[outbound]] is the default route. This# file sets default = "auto" below, so the order here only affects the order# in which build errors are reported.
[[outbound]]tag = "vless-ws"protocol = "vless"server = "proxy.example.com"port = 443
[outbound.stream]network = "ws"security = "tls" # "tls" or "none"; any other value is an error
[outbound.stream.ws]path = "/ws"
[outbound.stream.tls]server_name = "proxy.example.com"
[outbound.settings]id = "11111111-2222-3333-4444-555555555555"
[[outbound]]tag = "trojan-tls"protocol = "trojan"server = "203.0.113.10"port = 443
[outbound.stream]network = "tls" # TLS over plain TCP; no security key needed
[outbound.stream.tls]server_name = "example.com" # the name the server certificate is checked against
[outbound.settings]password = "replace-with-a-long-random-password"
[[outbound]]tag = "direct"protocol = "freedom" # connect to the destination directly
[[outbound]]tag = "block"protocol = "blackhole" # accept the flow and drop everything
# ---------------------------------------------------------------------------# Balancers: several outbounds behind one tag# ---------------------------------------------------------------------------[[balancer]]tag = "auto" # must not repeat an outbound tagoutbounds = ["vless-ws", "trojan-tls"]strategy = "failover" # list order is priority; or "round_robin"probe_interval = 30 # seconds between TCP health probes (default 30)probe_timeout = 5 # seconds before a probe counts as failed (default 5)
# ---------------------------------------------------------------------------# Routing# ---------------------------------------------------------------------------[route]default = "auto" # an outbound or balancer tag# Each file is read only when a rule below uses a geoip or geosite condition.geoip = "/etc/etemenanki/geoip.dat"geosite = "/etc/etemenanki/geosite.dat"
# Rules are tried from top to bottom and the first match wins. Inside one# rule, the flow matches if ANY of the listed conditions matches: the second# rule below sends a flow direct when its address is private OR its name ends# in .lan. Conditions are never combined with AND.
[[route.rule]]outbound = "block"geosite = ["category-ads-all"]
[[route.rule]]outbound = "direct"geoip = ["private"]domain_suffix = ["lan"]
# Never relay outgoing mail. Ports are strings: "25", or a range "8000-9000".[[route.rule]]outbound = "block"port = ["25", "465", "587"]Common errors
Section titled “Common errors”| Error | Cause | Fix |
|---|---|---|
unknown field `X`, expected one of … (with a line number) |
A misspelled key, a key in the wrong table, or a plural array name. | Use one of the listed names. Check that the key sits under the right [...] header. |
invalid type: map, expected a sequence |
[inbound] or [outbound] with single brackets. |
Write [[inbound]]. |
inbound <tag>: invalid settings: … |
A problem inside that inbound’s [inbound.settings]. |
Read the rest of the message; it has the serde error for the settings table. |
config defines no outbounds |
The file has no [[outbound]]. |
Add at least one outbound, even if it is only freedom. |
route references unknown outbound tag: <tag> |
[route] default or a rule’s outbound names a tag that is not defined, possibly in a different case. |
Define the outbound or balancer, or fix the spelling. |
outbound <tag>: unknown protocol "<value>" |
An unsupported or capitalised protocol name. | Use the lower-case name from Outbounds or Inbounds. |
No such file or directory (os error 2) |
The file -c names, or a path in it, does not exist, often a relative path resolved against the wrong directory. |
Use absolute paths and check each one. |
--test prints nothing and exits with status 1 |
[log] level is not a valid level or is off, or RUST_LOG is empty or off, so the error is filtered out. |
Run RUST_LOG=info etemenanki-app --test -c <file> to see the error, then fix level. |