Your first proxy
This page walks you through a complete first run of etemenanki-app. You write a short config for a SOCKS proxy on your own machine, check it without starting anything, send a request through it with curl, and then add a routing rule that blocks a domain while the proxy keeps running. Along the way you see the exact lines the program prints, both when things work and when you make a typo.
Nothing here needs a server or root. Everything listens on 127.0.0.1, so the proxy is reachable only from the machine it runs on.
Before you start
Section titled “Before you start”etemenanki-appis installed and on yourPATH. Runetemenanki-app --versionto check; Install covers getting the binary.curlis installed, and the machine can reach the internet directly.- Nothing else is listening on TCP port
1080. If something is, pick another port and use it everywhere below.
Work in an empty directory. etemenanki-app reads config.toml from the current directory when you leave out -c, but every command on this page passes -c config.toml explicitly.
Run a local SOCKS proxy
Section titled “Run a local SOCKS proxy”-
Write the config. Save this as
config.toml:config.toml # A first etemenanki-app config: a local SOCKS proxy on 127.0.0.1:1080 that# sends every connection straight out to the internet.# Accept SOCKS 4/4a/5 clients on the loopback interface only.[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080# Connect to the requested destination directly.[[outbound]]tag = "direct"protocol = "freedom"The file declares two things. An inbound is where connections come in: here, a SOCKS server bound to the loopback address on port
1080. An outbound is where connections go:freedomconnects straight to whatever destination the client asked for. With no[route]section, every connection goes to the first outbound. -
Check it.
--testparses the file and builds every inbound, outbound and routing rule, exactly as a start would, but binds no port and opens no connection:Terminal window etemenanki-app --test -c config.tomlA valid file prints one line and exits with status
0:Configuration OK.An invalid file prints the reason and exits with status
1. When you make a typo below shows what that looks like. -
Run it. Start the proxy in the foreground:
Terminal window etemenanki-app -c config.tomlWhen the listener is up, it logs:
2026-09-24T20:21:58.014596Z INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080The process now waits for connections. Leave it running and open a second terminal.
-
Send a request through it. In the second terminal:
Terminal window curl --socks5-hostname 127.0.0.1:1080 https://example.comcurlprints the HTML of the Example Domain page. The request wentcurl→socks-in→direct→example.com.--socks5-hostnamemakescurlpass the nameexample.comto the proxy, so the proxy resolves it. With plain--socks5,curlresolves the name itself and sends only the IP address. Both work, and domain rules still apply to the second form. When a client asks for an IP address, the SOCKS inbound waits up to 300 ms for the first bytes of the connection and reads the name from them (the TLS server name or the HTTPHostheader). This is called sniffing, and it is on by default.
What each key does
Section titled “What each key does”The config uses only a handful of keys. etemenanki-app checks every key in these tables: a key it does not know is an error, not something it skips.
| Key | Type | Default | Meaning |
|---|---|---|---|
[[inbound]] |
array of tables | none | One table per listener. A config may have none, in which case the process runs but accepts nothing. |
tag |
string | required | The name used in logs and in routing rules. Tags must be unique among inbounds, and separately among outbounds. |
protocol |
string (enum) | required | What the inbound speaks. "socks" accepts SOCKS 4, 4a and 5 on the same port. |
listen |
string | "127.0.0.1" |
The address to bind. When you leave it out, the inbound binds loopback, never every interface. A value starting with / is a Unix socket path. |
port |
u16 | required | The TCP port. Required unless listen is a Unix socket path, where it is refused. |
[[outbound]] |
array of tables | none | One table per destination. At least one is required. |
tag |
string | required | The name routing rules and [route].default refer to. |
protocol |
string (enum) | required | "freedom" (alias "direct") connects to the requested destination. "blackhole" (alias "block") drops the connection. |
The SOCKS inbound’s own options sit in [inbound.settings], and this config uses their defaults: no authentication (auth = "none"), UDP ASSOCIATE enabled (udp = true), and sniffing on (sniffing = true, a key of the inbound itself). SOCKS lists them all.
Block a domain
Section titled “Block a domain”Now add a second outbound that drops connections, and a rule that sends one domain to it. You can do this while the proxy from the previous section is still running: etemenanki-app watches its config file and reloads it when it changes.
-
Edit
config.tomlso it reads:config.toml # The first-proxy config extended with routing: connections to example.net# and its subdomains are dropped, everything else goes out directly.[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080[[outbound]]tag = "direct"protocol = "freedom"# Accepts a connection and closes it without sending anything.[[outbound]]tag = "block"protocol = "blackhole"[route]# Used when no rule matches. Without this line the first outbound is used.default = "direct"# Rules are tried in order; the first one that matches picks the outbound.[[route.rule]]domain_suffix = ["example.net"]outbound = "block"Three things are new. The
blockoutbound usesprotocol = "blackhole". The[route]table namesdirectas the default, which is what the first config did implicitly. And one[[route.rule]]sendsexample.net, plus every name under it, toblock. -
Save the file and watch the first terminal. Within a moment the running proxy logs the change and restarts its listener:
2026-09-24T20:21:59.323617Z INFO etemenanki_app::instance: config reload: outbounds +[block]; route changed2026-09-24T20:21:59.323905Z INFO etemenanki_app::instance: inbound socks-in listening on 127.0.0.1:1080+[block]means an outbound with that tag was added. The same line uses-[…]for removed tags and~[…]for changed ones. -
Test both sides of the rule. A name under
example.netis now dropped:Terminal window curl --socks5-hostname 127.0.0.1:1080 https://www.example.netcurl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.example.net:443The exact message depends on your
curlbuild. The SOCKS handshake itself succeeds; the blackhole outbound then closes the connection without sending a byte, socurlsees the TLS handshake cut off. Over plain HTTP the same rule shows up ascurl: (52) Empty reply from server.Everything else still goes out directly:
Terminal window curl --socks5-hostname 127.0.0.1:1080 https://example.com
Each connection is routed like this:
flowchart LR
C["curl"] --> I["inbound socks-in"]
I --> R{"rule 1: domain_suffix example.net?"}
R -- "match" --> B["outbound block"]
R -- "no match" --> D["route.default: direct"]
D --> N["destination"]
How the rule matches
Section titled “How the rule matches”- First match wins. Rules are tried in the order they appear in the file. The first rule that matches picks the outbound, and later rules are not consulted. A connection that no rule matches goes to
[route].default, or to the first[[outbound]]whendefaultis not set. domain_suffixrespects label boundaries."example.net"matchesexample.netandwww.example.net, but notnotexample.net. Matching ignores case.- The sniffed name counts. A domain rule is checked against the name the client asked for. When the client sent only an IP address, the rule is checked against the name sniffing read from the connection instead. That is why
curl --socks5, which sends only an IP address, is still blocked. A connection that carries no name at all, such as a raw TCP connection to an IP address, never matches a domain rule. - Unknown tags are errors. A rule whose
outboundnames a tag that does not exist fails the whole config.
| Key | Type | Default | Meaning |
|---|---|---|---|
[route].default |
string | tag of the first [[outbound]] |
Outbound for connections that no rule matches. |
[[route.rule]] |
array of tables | none | Routing rules, tried in file order. |
outbound |
string | required | Tag of the outbound (or balancer) that matching connections use. |
domain_suffix |
array of strings | [] |
Domains to match, each including all of its subdomains. |
When you make a typo
Section titled “When you make a typo”etemenanki-app fails closed: a key it does not recognise stops the program instead of being ignored. The reason is safety. If a misspelt listen were skipped, the inbound would quietly bind a different address than the one you wrote.
Rename listen to lisen in the first config and check it again:
etemenanki-app --test -c config.toml2026-09-24T20:23:41.522763Z ERROR etemenanki_app: configuration invalid: 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 message gives the line and column, points at the offending key, and lists every key that is valid at that position. The exit status is 1.
Other mistakes are reported the same way. Errors in the outer structure of the file, such as unknown keys, wrong types and missing required keys, carry a line number. Errors found later, while etemenanki-app builds the inbounds, outbounds and routes, have no line number; most of them name the inbound or outbound instead. [inbound.settings] and [outbound.settings] are checked at that later stage, so a typo there is reported without a line number:
| Mistake | What etemenanki-app prints after configuration invalid: |
|---|---|
| Misspelt key | TOML parse error at line 8, column 1 … unknown field `lisen`, expected one of … |
Misspelt table, such as [[route.rules]] |
unknown field `rules`, expected one of `default`, `geoip`, `geosite`, `rule` |
| Port written as a string | invalid type: string "1080", expected u16 |
| Port above 65535 | invalid value: integer `70000`, expected u16 |
Missing tag |
missing field `tag` |
| Misspelt protocol | outbound block: unknown protocol "blackhol" |
Misspelt key in [inbound.settings] |
inbound socks-in: invalid settings: unknown field `udp_bnd`, expected one of `auth`, `accounts`, `udp`, `udp_bind` |
| Rule points at a missing outbound | route references unknown outbound tag: blocked |
IP listen without port |
inbound socks-in: port is required |
No [[outbound]] at all |
config defines no outbounds |
| Two outbounds with the same tag | duplicate outbound tag: direct |
Wrong -c path |
No such file or directory (os error 2) |
Stopping and reloading
Section titled “Stopping and reloading”Ctrl-C
Section titled “Ctrl-C”Press Ctrl-C in the terminal that runs the proxy, or send it SIGTERM. Both do the same thing: etemenanki-app logs
2026-09-24T20:22:00.756740Z INFO etemenanki_app: shutting downthen closes its listeners and exits with status 0. It does not wait for open connections to finish; they are closed at once.
Editing the file
Section titled “Editing the file”While it runs, etemenanki-app watches the directory that holds its config file, so a save is picked up even from editors that write a new file and rename it into place. After a change it waits about 200 ms for the save to settle, then reads the file again. A reload that finds the same bytes as last time does nothing. If the directory cannot be watched, etemenanki-app logs config hot-reload disabled: with the reason and keeps running without reloads.
What happens next depends on the new file:
-
The new file is valid. etemenanki-app logs
config reload:followed by what changed, stops every listener and closes every open connection, then starts the new listeners. Clients have to reconnect. This happens for any change to the file’s bytes, even a comment: such an edit logsconfig reload: no changesand still restarts everything. If one of the new listeners cannot bind, for example because its port is taken, etemenanki-app logsinbound <tag> bind <address> failed:with the reason, starts the other inbounds, and runs without that one until the file changes again. -
The new file is invalid. etemenanki-app logs the error and keeps the old configuration running. Nothing is interrupted:
2026-09-24T20:22:11.344292Z ERROR etemenanki_app::instance: reload: parse failed, keeping current config: TOML parse error at line 5, column 1Errors found after parsing, such as an unknown outbound tag, are logged as
reload: build failed, keeping current config:. Fix the file and save it again.
Because a reload closes every connection, run etemenanki-app --test -c config.toml before you save changes to a proxy that other people use. Hot reload covers the details, including what is not reloaded: the log level is read once at startup, from the RUST_LOG environment variable if set, otherwise from [log].level (default info).
Next steps
Section titled “Next steps”You now have a working proxy and a feel for how the config is checked. From here:
- Configuration file describes the file’s overall layout and every top-level table.
- Inbounds and Outbounds list the common keys and every protocol.
- Routing covers the other rule conditions: IP ranges, ports, networks, geodata and more.
- Running etemenanki-app shows how to run it as a service.