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.
At a glance
Section titled “At a glance”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 minimal example
Section titled “A minimal example”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:
[[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
Section titled “freedom”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"]
- 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. address_familyremoves the addresses it does not allow and, forprefer_ipv4orprefer_ipv6, moves the preferred family to the front. The other family stays in the list as a fallback.freedomconnects 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.- If every attempt fails, the flow fails and the error lists every address with its own reason.
With address_family = "auto", the addresses are tried in the order the resolver returned them:
[dns].backend |
Order |
|---|---|
system (default) |
As the operating system returns them, which follows its address-selection rules |
udp, tls, https |
All A records first, then all AAAA records |
Choose prefer_ipv6 or prefer_ipv4 when you want a fixed preference whatever the backend.
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 routeThe 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.
freedombinds an unconnected IPv4 socket and an IPv6-only socket, each on an ephemeral port.ipv4_onlyopens only the IPv4 socket andipv6_onlyonly 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,freedomcarries on with the other. If neither can be bound, the packet is dropped and the association logsudp fan-out: opening an outbound failed: udp: no usable local socket in any requested familyatdebuglevel. - 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]withaddress_familyapplied, andfreedomkeeps 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_familyremoves, has its packets dropped for the rest of the association. Each drop is logged atdebugasfreedom: 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
Section titled “blackhole”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.
No response option
Section titled “No response option”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.
Typical uses
Section titled “Typical uses”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"]On a proxy server, freedom is usually the default route: whatever the clients ask for is dialed from the server. Put it first, or name it in [route].default:
[[outbound]]tag = "direct"protocol = "freedom"address_family = "prefer_ipv4"
[[outbound]]tag = "block"protocol = "blackhole"
[route]default = "direct"geoip = "/etc/etemenanki/geoip.dat"
# Keep clients away from the server's own private networks.[[route.rule]]outbound = "block"geoip = ["private"]Dropping UDP to port 443 makes browsers fall back from HTTP/3 to TCP, where sniffing and domain rules work:
[[route.rule]]outbound = "block"network = "udp"port = ["443"]Place this rule before any rule that would otherwise send that UDP elsewhere. Rules are first-match.
Common errors
Section titled “Common errors”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 |