Skip to content

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.

  • etemenanki-app is installed and on your PATH. Run etemenanki-app --version to check; Install covers getting the binary.
  • curl is 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.

  1. 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: freedom connects straight to whatever destination the client asked for. With no [route] section, every connection goes to the first outbound.

  2. Check it. --test parses 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.toml

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

  3. Run it. Start the proxy in the foreground:

    Terminal window
    etemenanki-app -c config.toml

    When 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:1080

    The process now waits for connections. Leave it running and open a second terminal.

  4. Send a request through it. In the second terminal:

    Terminal window
    curl --socks5-hostname 127.0.0.1:1080 https://example.com

    curl prints the HTML of the Example Domain page. The request went curl → socks-in → direct → example.com.

    --socks5-hostname makes curl pass the name example.com to the proxy, so the proxy resolves it. With plain --socks5, curl resolves 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 HTTP Host header). This is called sniffing, and it is on by default.

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.

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.

  1. Edit config.toml so 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 block outbound uses protocol = "blackhole". The [route] table names direct as the default, which is what the first config did implicitly. And one [[route.rule]] sends example.net, plus every name under it, to block.

  2. 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 changed
    2026-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.

  3. Test both sides of the rule. A name under example.net is now dropped:

    Terminal window
    curl --socks5-hostname 127.0.0.1:1080 https://www.example.net
    curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to www.example.net:443

    The exact message depends on your curl build. The SOCKS handshake itself succeeds; the blackhole outbound then closes the connection without sending a byte, so curl sees the TLS handshake cut off. Over plain HTTP the same rule shows up as curl: (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"]
  • 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]] when default is not set.
  • domain_suffix respects label boundaries. "example.net" matches example.net and www.example.net, but not notexample.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 outbound names 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.

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:

Terminal window
etemenanki-app --test -c config.toml
2026-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)

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 down

then closes its listeners and exits with status 0. It does not wait for open connections to finish; they are closed at once.

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 logs config reload: no changes and still restarts everything. If one of the new listeners cannot bind, for example because its port is taken, etemenanki-app logs inbound <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 1

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

You now have a working proxy and a feel for how the config is checked. From here: