Skip to content

Direct and blackhole

etemenanki-app has two outbounds that do not talk to another proxy server. freedom (alias direct) connects to the destination itself, from the machine the app runs on. blackhole (alias block) accepts traffic and throws it away. Almost every configuration uses at least one of them: freedom as the default route on a server or for local traffic on a client, and blackhole as the target of block rules.

This page covers what each one does with TCP and UDP, the few keys they read, how freedom resolves names and chooses addresses, and what a client sees when its traffic is blackholed. The fields shared by all outbounds are on Outbounds.

freedom blackhole
Aliases direct block
TCP Resolves the destination and connects to it Reports success, then ends the stream at once and discards what the client writes
UDP Sends each packet from a local socket to its own destination Accepts every packet and never answers
Keys it reads tag, protocol, address_family, stream (checked only) tag, protocol, stream (checked only)
server, port Not used for traffic Not used for traffic
[outbound.settings] Ignored, never parsed Ignored, never parsed
Resolver [dns] None
Balancer member Refused, unless a stray server and port are set Refused, unless a stray server and port are set

A client that sends everything straight out, except a block list. The first outbound in the file is the default route, so direct catches every flow that no rule matches:

config.toml
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[[route.rule]]
outbound = "block"
domain_suffix = ["ads.example.com", "tracker.example.net"]

A request for ads.example.com or any of its subdomains goes to block. Everything else is dialed directly. Check a file with etemenanki-app --test -c config.toml before you start it; it prints Configuration OK. or the first error.

Neither protocol has settings of its own. Of the common [[outbound]] keys, this is what each one does with them:

Key freedom blackhole
tag Required, unique among outbounds and balancers Required, unique among outbounds and balancers
protocol "freedom" or "direct" "blackhole" or "block"
server, port Accepted and not used for traffic: each flow goes to its own destination. port must still be a valid port number. See the balancer note below. Accepted and not used for traffic. Same balancer note.
stream Only plain TCP is allowed: network must be absent, "" or "tcp", and security absent, "" or "none", in lower case. The tls, ws and grpc sub-tables are not used, but unknown keys in them still fail the parse. Same rule as freedom
address_family Filters and orders the destination’s addresses. Default "auto". An unknown value fails the load. Accepted and ignored. The value is not even checked.
settings Never parsed. Any keys, and even a non-table value, are accepted and have no effect. Never parsed

Protocol names are case-sensitive: protocol = "Direct" fails with outbound direct: unknown protocol "Direct". The [[outbound]] table itself still rejects unknown keys, so a misspelt adress_family is a parse error with a line number even on these two protocols.

address_family takes auto (the default), ipv4_only, ipv6_only, prefer_ipv4 or prefer_ipv6, trimmed and case-insensitive, with - read as _ and a few short aliases such as ipv4 and v6. The full list is in Outbounds.

A balancer refuses a freedom or blackhole member because there is no upstream to probe. The check only looks for server and port, so an outbound of either protocol that carries both is accepted as a member and probed at that address, while its traffic still goes direct or is dropped. Leave server and port out; see Balancers.

freedom opens a new connection or socket from the host for every flow routed to it. The destination is the address the client asked for: a domain name, or an IP address used as it is.

When sniffing recovers a domain from a flow that was addressed by IP, the domain is used for routing only. freedom still connects to the IP address the client sent.

flowchart LR
  F["Flow to host:port"] --> Q{"Domain?"}
  Q -- "yes" --> R["Resolve with [dns]"]
  Q -- "no, an IP" --> A
  R --> A["Apply address_family: filter, then order"]
  A --> C["Connect to each address in turn, 10 s per attempt"]
  C -- "first success" --> OK["Relay"]
  C -- "all failed" --> E["Flow fails"]
  1. A domain is looked up through the resolver configured in [dns]. With no [dns] section, that is the operating system’s resolver. An IP address is not looked up.
  2. address_family removes the addresses it does not allow and, for prefer_ipv4 or prefer_ipv6, moves the preferred family to the front. The other family stays in the list as a fallback.
  3. freedom connects to the remaining addresses one after another and keeps the first connection that succeeds. Each attempt may take up to 10 seconds before the next address is tried.
  4. If every attempt fails, the flow fails and the error lists every address with its own reason.

The attempts run in sequence, not in parallel. freedom does not race IPv4 against IPv6 (Happy Eyeballs), so an address that silently drops packets costs the full 10 seconds before the next one is tried. On a host where one family is broken, set address_family to the working one rather than relying on the fallback:

[[outbound]]
tag = "direct"
protocol = "freedom"
address_family = "ipv4_only" # this host has no working IPv6 route

The operating system chooses the source address and interface of each connection from its routing table. etemenanki-app has no setting for a source address, an outgoing interface or a firewall mark on freedom sockets.

UDP through freedom is routed packet by packet, like all UDP in etemenanki-app (see How UDP reaches an outbound). The first packet of an association that is routed to a freedom outbound opens the local sockets that all its later packets to that outbound share.

  • One socket per family. freedom binds an unconnected IPv4 socket and an IPv6-only socket, each on an ephemeral port. ipv4_only opens only the IPv4 socket and ipv6_only only the IPv6 one, so a packet addressed to an IP of the other family is dropped. If one family cannot be bound, for example because the host has IPv6 disabled, freedom carries on with the other. If neither can be bound, the packet is dropped and the association logs udp fan-out: opening an outbound failed: udp: no usable local socket in any requested family at debug level.
  • Each packet goes to its own destination, out of the socket of the matching family. A packet to an IPv6 address when no IPv6 socket is open is dropped; the association carries on.
  • Domains are resolved once per association. The first packet to a name triggers a lookup through [dns] with address_family applied, and freedom keeps the first address that is left. Every later packet to that name, on that association, goes to that one address. There is no fallback to a second address for UDP.
  • A name that does not resolve, or resolves only to addresses address_family removes, has its packets dropped for the rest of the association. Each drop is logged at debug as freedom: dropping a datagram to an unresolvable ….
  • Lookups happen one at a time. While a name is being looked up, outgoing packets on the association wait for it.
  • Replies are returned to the client with the IP address and port they came from, not the name the client sent to.

blackhole never opens a connection. It answers at once, so the inbound treats the flow as established, and then discards it.

The outbound side of the flow reports end of stream immediately, and every byte the client writes is accepted and dropped. The inbound therefore finishes its handshake with a success reply and then closes its sending side toward the client:

Inbound What the client sees
SOCKS5 CONNECT Reply 0x00 (succeeded), then end of stream. curl reports Empty reply from server.
HTTP CONNECT HTTP/1.1 200 Connection established, then end of stream. A TLS client fails its handshake.
HTTP, plain request The connection closes without a response. curl reports Empty reply from server.
Trojan, VLESS, VMess, Shadowsocks, Hysteria 2 The tunnel opens, and its stream ends without data.

The connection is fully closed once the client closes its own side, or when the relay idle timeout (300 seconds with no data in either direction) ends it; see Limits. A client that keeps writing keeps the connection open, and everything it writes is discarded.

Every datagram routed to blackhole is accepted and discarded. Nothing is ever sent back, and no error reaches the client, so a blocked DNS query or QUIC handshake simply goes unanswered until the client gives up. Other packets on the same association that are routed elsewhere keep working.

Xray’s blackhole can answer an HTTP request with a canned 403 response. etemenanki-app’s cannot: there is no response setting, and a [outbound.settings.response] table is ignored. A blocked plain HTTP request never gets a response; the connection just closes.

Send ad and tracker domains to blackhole and everything else direct. The geosite list comes from a geosite.dat file:

[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "direct"
geosite = "/etc/etemenanki/geosite.dat"
[[route.rule]]
outbound = "block"
geosite = ["category-ads-all"]

These stop etemenanki-app --test -c config.toml and a normal start. The log line begins with configuration invalid:.

Message Cause Fix
outbound <tag>: unknown protocol "Direct" Capitalised or misspelt protocol name Write freedom, direct, blackhole or block in lower case
outbound <tag>: invalid address_family "either" An address_family value outside the accepted list, on freedom Use auto, ipv4_only, ipv6_only, prefer_ipv4 or prefer_ipv6
outbound <tag>: protocol freedom does not support stream network "ws" A network other than tcp in [outbound.stream] Remove the [outbound.stream] block
outbound <tag>: protocol blackhole does not support stream security "tls" A security other than none in [outbound.stream] Remove the [outbound.stream] block
balancer <tag>: outbound <tag> has no upstream a TCP health probe can reach, so it cannot be balanced freedom or blackhole, without server and port, listed in [[balancer]].outbounds Route to them with a rule instead of a balancer; see Balancers

The error names the canonical protocol, so an outbound written as protocol = "block" is reported as blackhole.

These appear only while traffic flows, in the debug log ([log].level = "debug"). The line that carries the reason depends on the inbound: the SOCKS inbound logs socks connection from Some(<ip>) ended: <reason>, the HTTP inbound http: connect failed: <reason>, and the Trojan, VLESS, VMess and Shadowsocks inbounds <protocol>: outbound gone: <reason>. Reasons include:

Reason Cause
failed to connect to any address (192.0.2.10:443: Connection refused (os error 111)) Every resolved address refused or timed out. Each one is listed with its own error.
connect to 192.0.2.10:443 timed out Appears inside the list above when an attempt reached the 10-second limit
dial: no usable ipv6_only destination address for example.com:443 address_family removed every address of the destination
failed to lookup address information: Name or service not known The system resolver could not resolve the name. The text comes from the operating system and varies by platform.
dns: server returned rcode 3, dns: query timed out, dns: example.com did not resolve A udp, tls or https resolver could not resolve the name: it does not exist, no answer arrived within the query timeout, or it has no A or AAAA record
freedom: dropping a datagram to an unresolvable … A UDP packet to a name that did not resolve on this association

What the client sees when a freedom dial fails depends on the inbound:

Inbound What the client sees
SOCKS5 CONNECT Reply 0x05 (connection refused) when every connect attempt failed, including by timeout, and 0x04 (host unreachable) for other failures such as a name that does not resolve
SOCKS5 or HTTP CONNECT to an IP address with sniffing on The success reply was already sent before the dial, so the client only sees the connection close
HTTP CONNECT, otherwise HTTP/1.1 502 Bad Gateway
HTTP, plain request The connection closes without a response

See SOCKS and HTTP.