Skip to content

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.

The smallest useful file is a local SOCKS proxy that sends everything straight out:

config.toml
[[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:

Terminal window
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.

  1. Write or edit the file.

  2. Run etemenanki-app --test -c <file>.

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

  4. 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).

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

KeyTypeRequiredDefaultDescription
logtableno{}Logging, written as [log]. Holds only level. See the [log] section of the configuration file page.
dnstableno{}Name resolution for outbounds and balancer probes, written as [dns]. Without it, etemenanki-app uses the host resolver behind an always-on cache.
inboundarray of tablesno[]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.
outboundarray of tablesyes—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.
balancerarray of tablesno[]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.
routetableno{}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

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.

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 no default.
  • [[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.

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.

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.

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

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

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 u16

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

The build step constructs everything the process would run, without binding anything, in this order:

  1. a check that at least one [[outbound]] exists;
  2. [dns], including reading its ca_file;
  3. each [[outbound]], in file order, including its settings, its stream settings and any certificate or CA file;
  4. each [[balancer]], in file order;
  5. [route]: the rules in file order, then the default route, then the geosite and geoip files if a rule needs them;
  6. each [[inbound]], in file order, including its settings, 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`

Search the file for the tag in the message to find the table.

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

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] sets default, 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.

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 --test from 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 as Permission 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.

[log] has one key.

KeyTypeRequiredDefaultDescription
levelstringno"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:

  1. If the RUST_LOG environment variable is set and parses as a filter, it is used and [log] level is ignored. An empty RUST_LOG counts as a valid filter that enables nothing. A RUST_LOG that does not parse is ignored.
  2. Otherwise, if the file passes phase 1, its level is used, or info when the file has no level.
  3. Otherwise, info is 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.

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.

config.toml
# 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 name
url = "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 it
protocol = "socks"
listen = "127.0.0.1" # the default; write "0.0.0.0" to accept remote clients
port = 1080
[inbound.settings] # checked at build time, after the TOML parse
auth = "none"
udp = true
[[inbound]]
tag = "http-in"
protocol = "http"
listen = "127.0.0.1"
port = 8080
sniffing = 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 tag
outbounds = ["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"]
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.