Split routing with geodata
This recipe builds a client-side etemenanki-app config that splits traffic four ways: ads and trackers are dropped, local and private destinations go direct, destinations inside one chosen country go direct, and everything else goes through a proxy server. The lists of ad domains, country domains and country address ranges come from the public v2fly geodata files, so you write six rules instead of thousands.
Use it when etemenanki-app runs on your own machine or router as a local SOCKS or HTTP proxy in front of a remote server. The page covers where to get the geodata, why the rules are in this order, what sniffing adds, and how to keep the files current.
What the config does
Section titled “What the config does”| Traffic | Outbound | Decided by |
|---|---|---|
| A name you exempt from the ad list | direct |
rule 1, domain_suffix |
| Ad and tracker domains | block |
rule 2, geosite = ["category-ads-all"] |
localhost, *.lan, private and loopback addresses |
direct |
rule 3, geosite and geoip private |
| A name you want proxied although it is on the country list | proxy |
rule 4, domain_suffix |
| A flow addressed by an IP outside the country | proxy |
rule 5, geoip = ["!cn"] |
| Domains and IPs in the country | direct |
rule 6, geosite and geoip cn |
| Everything else | proxy |
[route].default |
The example uses cn because it is the country both v2fly files cover best. Choosing another country explains what to change for a different one.
Get the geodata
Section titled “Get the geodata”etemenanki-app reads the v2ray/Xray .dat format: protobuf lists of domains (geosite.dat) and of CIDR ranges (geoip.dat). The v2fly projects publish both on GitHub, each with a SHA-256 checksum file:
| File | Download | Save as |
|---|---|---|
IP ranges per country, plus private |
geoip.dat from v2fly/geoip |
/etc/etemenanki/geoip.dat |
Domain lists such as cn, private, category-ads-all |
dlc.dat from v2fly/domain-list-community |
/etc/etemenanki/geosite.dat |
The domain list is published as dlc.dat. It has the same format as a geosite.dat; only the name differs.
-
Download both files and their checksums into an empty directory:
Terminal window cd "$(mktemp -d)"curl -fLO https://github.com/v2fly/geoip/releases/latest/download/geoip.datcurl -fLO https://github.com/v2fly/geoip/releases/latest/download/geoip.dat.sha256sumcurl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.datcurl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat.sha256sum -
Check them. Each line must end in
OK:Terminal window sha256sum -c geoip.dat.sha256sum dlc.dat.sha256sumgeoip.dat: OKdlc.dat: OK -
Install them where the config expects them:
Terminal window sudo install -D -m 0644 geoip.dat /etc/etemenanki/geoip.datsudo install -D -m 0644 dlc.dat /etc/etemenanki/geosite.dat
The config
Section titled “The config”# A local client that splits traffic with v2fly geodata: ads are dropped,# private and in-country (cn) destinations go direct, and everything else# goes through a Trojan proxy. Download geoip.dat and geosite.dat first.
[[inbound]]tag = "socks-in"protocol = "socks"listen = "127.0.0.1"port = 1080# The default. A flow addressed by IP is matched by its TLS SNI or HTTP Host# as well, so the domain and geosite rules below still reach it.sniffing = true
[[inbound]]tag = "http-in"protocol = "http"listen = "127.0.0.1"port = 8080
# The first outbound. [route].default names it explicitly anyway.[[outbound]]tag = "proxy"protocol = "trojan"server = "proxy.example.com"port = 443
[outbound.stream]network = "tls"
[outbound.settings]password = "replace-with-a-long-random-password"
[[outbound]]tag = "direct"protocol = "freedom"
[[outbound]]tag = "block"protocol = "blackhole"
[route]default = "proxy"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 matchers are alternatives: any one of them is enough.
# 1. Exception to rule 2: a name the ad list catches that you still need.[[route.rule]]outbound = "direct"domain_suffix = ["metrics.example.com"]
# 2. Ads and trackers are dropped.[[route.rule]]outbound = "block"geosite = ["category-ads-all"]
# 3. Local names and private addresses stay on the local network.[[route.rule]]outbound = "direct"geosite = ["private"]geoip = ["private"]
# 4. Exception to rule 6: names on the country list that you want proxied.[[route.rule]]outbound = "proxy"domain_suffix = ["example.com"]
# 5. A flow addressed by an IP outside the country goes through the proxy,# whatever name its payload claims. Rule 3 has already taken private IPs.[[route.rule]]outbound = "proxy"geoip = ["!cn"]
# 6. In-country names and addresses go direct. apple@cn and steam@cn add the# entries of those lists that carry the cn attribute, such as the# in-country download hosts, which the cn list does not contain.[[route.rule]]outbound = "direct"geosite = ["cn", "apple@cn", "steam@cn"]geoip = ["cn"]
# Everything else falls through to [route].default, the proxy.Replace proxy.example.com and the password with your Trojan server’s, or replace the proxy outbound with any other outbound that reaches your server; the rules do not depend on its protocol. One thing to keep in mind: UDP follows the same rules, and the http and shadowsocks outbounds (including the 2022- methods) carry no datagrams, so UDP routed to one of them is dropped. Replace metrics.example.com and example.com in rules 1 and 4 with the names you want to exempt, or delete those rules. Then check the file:
etemenanki-app --test -c /etc/etemenanki/config.tomlConfiguration OK.--test builds everything a real start builds, including the route table, so it opens both .dat files and looks up every code the rules name. A missing file or a misspelt code fails here, not when you start the proxy.
Point your applications at 127.0.0.1:1080 (SOCKS4, SOCKS4a or SOCKS5) or 127.0.0.1:8080 (HTTP proxy). Both inbounds share the same rules.
The route settings
Section titled “The route settings”The [route] table holds the default outbound and the two file paths:
| Key | Type | Required | Default | Description |
|---|---|---|---|---|
default | string | no | — | Tag of the outbound or balancer that takes every flow no rule matches. When absent, the first [[outbound]] in the file is the default; a balancer is never picked implicitly. A tag that names neither an outbound nor a balancer fails with route references unknown outbound tag: <tag>. |
geoip | path | depends | — | Path of a v2ray-format geoip.dat. Required when any rule uses geoip (a geoip matcher is used but no geoip file is configured), and not even opened otherwise. A relative path resolves against the working directory of the process, not the directory of the config file. The file is read at startup, by --test and on every reload; only the codes the rules name are kept. |
geosite | path | depends | — | Path of a v2ray-format geosite.dat. Required when any rule uses geosite (a geosite matcher is used but no geosite file is configured), and not even opened otherwise. Relative paths and reading work as for geoip. |
rule | array of tables | no | [] | The rules, written as [[route.rule]] blocks (singular: [[route.rules]] is an unknown field). Tried in file order; the first rule that matches picks the outbound. |
The rules use only three matcher keys: domain_suffix, geosite and geoip. The other matchers (domain_full, domain_keyword, domain_regex, cidr, source_cidr, port, network, inbound_tag) are described on Routing.
How the rules are evaluated
Section titled “How the rules are evaluated”Two principles decide everything:
- Across rules, the first match wins. etemenanki-app tries the
[[route.rule]]blocks from top to bottom and stops at the first one that matches. A flow that no rule matches goes to[route].default. - Inside one rule, any matcher is enough. Rule 3 matches a flow on the
privatedomain list or in aprivateaddress range. One rule cannot require two matchers together; Routing shows how rule order gets that effect.
This is the path a new flow takes through the example:
flowchart TB
F["New flow: a domain or an IP, maybe with a sniffed name"] --> R1{"1. under metrics.example.com?"}
R1 -- yes --> D["direct"]
R1 -- no --> R2{"2. on geosite category-ads-all?"}
R2 -- yes --> B["block"]
R2 -- no --> R3{"3. geosite private or geoip private?"}
R3 -- yes --> D
R3 -- no --> R4{"4. under example.com?"}
R4 -- yes --> P["proxy"]
R4 -- no --> R5{"5. an IP outside geoip cn?"}
R5 -- yes --> P
R5 -- no --> R6{"6. geosite cn, apple@cn, steam@cn or geoip cn?"}
R6 -- yes --> D
R6 -- "no: [route].default" --> P
Some flows, and where they end up:
| The client asks for | Sniffed name | First matching rule | Outbound |
|---|---|---|---|
doubleclick.net:443 |
not needed | 2 | block |
metrics.example.com:443, even if it is on the ad list |
not needed | 1 | direct |
192.168.1.1:80 |
none | 3, geoip private |
direct |
nas.lan:445 |
not needed | 3, geosite private |
direct |
adcdownload.apple.com:443 |
not needed | 6, apple@cn |
direct |
| An IP in the country, port 443 | none | 6, geoip cn |
direct |
| An IP abroad, port 443 | a name on the country list | 5 | proxy |
| An IP abroad, port 443 | doubleclick.net |
2 | block |
| A domain on no list | not needed | none | proxy (default) |
Why the rules are in this order
Section titled “Why the rules are in this order”Each position in the list is there for a reason. If you add rules, keep these relationships:
- An exception goes before the broad rule it carves out of. Rule 1 exempts one name from the ad list, and it works only because it comes before rule 2. Placed after rule 2, it would never be reached: every name it covers would already be blocked. Rule 4 does the same for the country list in rule 6.
- Blocking comes before any rule that forwards. Ads are dropped whether they are local, in-country or abroad.
privatecomes before!cn.!cnmatches every address that is not in thecnlist, and that includes192.168.0.0/16,10.0.0.0/8and loopback. Without rule 3 above it, rule 5 would send your LAN through the proxy.- For an IP-addressed flow, rule 5 lets the address win over the sniffed name. The address is where the bytes actually go; the sniffed name is only what the client wrote in its first packet. A flow to a foreign address whose TLS SNI is on the country list goes through the proxy. If you would rather trust the name, move rule 5 below rule 6.
A rule that can never be reached is not an error, and neither is a rule with no matcher keys at all. etemenanki-app accepts both, and they never match. When a rule seems to have no effect, check the rules above it first.
Geodata matchers
Section titled “Geodata matchers”geosite and geoip behave differently. The most important difference is what they are compared with.
geosite |
geoip |
|
|---|---|---|
| Loaded from | [route].geosite |
[route].geoip |
| Compared with | The destination domain, and the sniffed name | The destination IP only |
| A flow addressed by domain | Matched by its name | Never matches. etemenanki-app does not resolve names for routing |
| A flow addressed by IP | Matched only through a sniffed name | Matched by its address |
code |
Every entry in that list | Every range in that list |
code@attr |
Only the entries that carry the attribute | Not supported: geoip code not found: cn@ads |
!code |
Not supported: geosite code not found: !cn |
Every address outside the list |
| Case | Codes and attributes are case-insensitive | Codes are case-insensitive |
| Unknown code | geosite code not found: <code> |
geoip code not found: <code> |
Write codes without a prefix: geosite = ["cn"], not geosite = ["geosite:cn"], which fails with geosite code not found: geosite:cn.
code@attr
Section titled “code@attr”Many geosite lists tag some of their entries with an attribute. The v2fly file uses three: cn for names of an international service that are served in-country, !cn for the opposite, and ads for advertising names inside a company list. apple@cn keeps only the entries of the apple list that carry cn, such as adcdownload.apple.com, which the cn list itself does not contain. That is why rule 6 adds apple@cn and steam@cn next to cn.
Everything after the first @ is one attribute name, so google@!cn is valid too. An attribute that no entry carries is accepted and gives an empty list: apple@cm passes --test and never matches anything. Check the spelling of attributes yourself.
Some list names contain ! as part of the name, for example geolocation-!cn (sites generally outside the country). That is an ordinary code, not a negation: ! means “not” only at the start of a geoip entry.
geoip = ["!cn"] matches a destination IP that is in no range of the cn list. Two consequences:
- It matches private, loopback and link-local addresses too. Put a
privaterule above it, as rule 3 does. - Like every IP matcher, it never matches a flow addressed by domain.
!cndoes not mean “everything not in the country”; it means “every address not in the country”. Domains that match no rule go to[route].default.
What the files contain
Section titled “What the files contain”- The v2fly
geoip.dathas one entry per two-letter country code, plusprivateand atestentry. For IPv4,privatecovers0.0.0.0/8, the RFC 1918 ranges, loopback, link-local, CGNAT (100.64.0.0/10), the documentation and benchmarking ranges, and everything from224.0.0.0up. For IPv6 it covers::and::1, unique-localfc00::/7, link-localfe80::/10and multicastff00::/8. - The v2fly
geosite.dathas about 1,500 lists: per-company lists such asapple,googleandsteam, category lists such ascategory-ads-allandcategory-games, and country-oriented lists such ascn,geolocation-cnandgeolocation-!cn.privatecoverslocalhost,lan,local,internal, common router login names, reverse-lookup zones and any single-label name.cnincludes the whole.cntop-level domain.
etemenanki-app lowercases every entry as it loads a list. An entry it cannot use, such as a regular expression that the Rust regex engine rejects, is skipped with a warning (skipping invalid geosite regex …) and the rest of the list still loads.
The list names and their contents are maintained by v2fly and change between releases. Browse the data/ directory of the domain-list-community repository to see what a list contains before you rely on it.
Sniffing
Section titled “Sniffing”A rule written in domains, including every geosite rule, can only match a flow whose name etemenanki-app knows. Whether it knows the name depends on the client:
| Client setting | What etemenanki-app receives |
|---|---|
SOCKS5 with remote DNS (socks5h:// in curl, “Proxy DNS when using SOCKS v5” in Firefox), SOCKS4a |
The domain |
SOCKS5 with local DNS (socks5://), SOCKS4 |
An IP address the client resolved itself |
| HTTP proxy | Usually the domain: the CONNECT target, or the Host header of a plain request |
| A TUN inbound | Always an IP address |
Sniffing recovers the name in the second and fourth cases, and for an HTTP CONNECT to an IP address. A plain HTTP proxy request is routed on its Host header and is never sniffed. With sniffing = true on the inbound, which is the default, etemenanki-app reads the first bytes the client sends on an IP-addressed flow and takes the name from them:
- a TLS ClientHello gives its SNI, so HTTPS and most other TLS traffic is covered;
- a plain HTTP/1 request gives the host of an absolute request URL, or else its
Hostheader.
The recovered name is then tried by every domain matcher (domain_suffix, domain_full, domain_keyword, domain_regex and geosite) in addition to the destination itself. The IP matchers (cidr, geoip) ignore it.
The details matter when you predict where a flow goes:
- Only IP-addressed stream flows are inspected. A flow that already names a domain is routed on that name at once. UDP datagrams, including QUIC, are never sniffed; each packet is routed on its own address and port.
- The name is a routing hint, nothing more. The outbound still connects to the IP address the client asked for. An IP literal in the SNI or
Hostis ignored. - Sniffing waits at most 300 ms and reads at most 4 KiB. It stops early as soon as it recognises a ClientHello or a request. When the client sends nothing in that time, as in SMTP or FTP where the server speaks first, or sends bytes that are neither TLS nor HTTP, such as an SSH client’s version line, the flow waits out the 300 ms and is then routed on its IP alone.
- The inbound answers first. On a SOCKS, HTTP
CONNECTor Hysteria 2 inbound, the client sends nothing until the proxy replies. So for an IP-addressed request with sniffing on, etemenanki-app replies “connected” before it routes and dials. If the dial then fails, the client has already been told the connection succeeded, so it sees the connection close rather than an HTTP502or a SOCKS error reply it can act on.
Set sniffing = false on an inbound to skip all of this. IP-addressed flows on it are then matched by the IP rules only. The field is described with the other common inbound fields on Inbounds.
See it work
Section titled “See it work”With the example running, compare the two ways curl can use a SOCKS proxy for a name on the ad list:
curl -x socks5h://127.0.0.1:1080 https://doubleclick.netcurl sends the name, and rule 2 matches it directly. The result is the same with sniffing on or off.
curl -x socks5://127.0.0.1:1080 https://doubleclick.netcurl resolves the name itself and sends only an IP. With sniffing = true, the SNI in its ClientHello still matches rule 2. With sniffing = false, the flow is routed by its address, reaches rule 5 or 6, and the page loads.
A blocked request does not get an error page. blackhole accepts the flow, discards what the client sends and ends the stream at once, so an HTTPS client fails during the TLS handshake and a plain HTTP client gets no reply. With curl built on OpenSSL it looks like this:
curl: (35) OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to doubleclick.net:443curl: (52) Empty reply from serverChoosing another country
Section titled “Choosing another country”To split on a different country, change the places where the example names cn:
-
geoip = ["!cn"]in rule 5 andgeoip = ["cn"]in rule 6: use the country’s two-letter code, in lower or upper case. The v2flygeoip.dathas an entry for every country code. -
geosite = ["cn", "apple@cn", "steam@cn"]in rule 6: few countries have domain lists of their own in the v2fly file. A handful do, such ascategory-ruandtld-ru, orcategory-ir; look in the domain-list-communitydata/directory. Remove the@cnentries, which only make sense forcn. -
Where there is no list, match the country’s top-level domain yourself.
domain_suffix = ["de"]matches every name under.de:[[route.rule]]outbound = "direct"domain_suffix = ["de"]geoip = ["de"]
Keep rule 5 above the country rule, with the new code.
Keep the geodata up to date
Section titled “Keep the geodata up to date”etemenanki-app decodes the .dat files when it builds a configuration: at startup, on --test and on a reload. The running process keeps what it loaded in memory, so you can overwrite the files at any time without affecting it. The other side of that is that new files take effect only when a new configuration is built.
etemenanki-app watches the directory that holds the config file. On every change there, it re-reads the config file and compares its bytes with the last version it loaded, and it reloads only when they differ. Replacing geoip.dat or geosite.dat, even in the same directory, leaves the config bytes unchanged, so nothing is reloaded. You have two options:
- Change the config file. Any byte counts, including a comment. The reload rebuilds the whole configuration and reads the new
.datfiles. When only a comment changed, the log line readsconfig reload: no changes; the new geodata is in use regardless. - Restart etemenanki-app.
Both drop every open connection, because a reload replaces all listeners and outbounds. See Hot reload.
This script does the whole update. It keeps a marker comment such as # geodata: 2026-09-24T03:00:00Z in the config file, adding it on the first run and rewriting it on every later one, and that change is what triggers the reload:
#!/bin/sh# Update the v2fly geodata and make a running etemenanki-app load it.set -eudir=/etc/etemenankiconfig="$dir/config.toml"
tmp=$(mktemp -d)trap 'rm -rf "$tmp"' EXITcd "$tmp"
for url in \ https://github.com/v2fly/geoip/releases/latest/download/geoip.dat \ https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.datdo curl -fsSLO "$url" curl -fsSLO "$url.sha256sum"donesha256sum -c geoip.dat.sha256sum dlc.dat.sha256sum
# Keep the current files so a bad release can be rolled back.cp "$dir/geoip.dat" "$dir/geoip.dat.old"cp "$dir/geosite.dat" "$dir/geosite.dat.old"install -m 0644 geoip.dat "$dir/geoip.dat"install -m 0644 dlc.dat "$dir/geosite.dat"
# Every code the rules name must still exist in the new files.if ! etemenanki-app --test -c "$config"; then mv "$dir/geoip.dat.old" "$dir/geoip.dat" mv "$dir/geosite.dat.old" "$dir/geosite.dat" exit 1fi
# Change the config's bytes, which triggers the reload.stamp="# geodata: $(date -u +%Y-%m-%dT%H:%M:%SZ)"if grep -q '^# geodata: ' "$config"; then sed -i "s/^# geodata: .*/$stamp/" "$config"else printf '%s\n' "$stamp" >> "$config"fiRun it as root from a weekly cron job or systemd timer. The --test step is the important one: v2fly occasionally renames or removes a list, and a code your rules name that is missing from the new file fails with geosite code not found: <code>. Checking first leaves both the running process and your next restart on files that work.
Common errors
Section titled “Common errors”A configuration error stops --test, a start and a reload alike. The reason is the same in all three; only the prefix of the log line differs: configuration invalid: for --test, failed to start: for a start, and reload: build failed, keeping current config: for a reload.
| Reason | Cause | Fix |
|---|---|---|
a geosite matcher is used but no geosite file is configured |
A rule uses geosite and [route].geosite is not set |
Add the path |
a geoip matcher is used but no geoip file is configured |
A rule uses geoip and [route].geoip is not set |
Add the path |
No such file or directory (os error 2) |
A .dat path does not exist. The message does not name the file. A relative path resolves against the working directory of the process, not the config file’s directory |
Use an absolute path |
Permission denied (os error 13) |
The process cannot read a .dat file |
Make the file readable by the user etemenanki-app runs as |
geosite code not found: <code> |
A misspelt code, a geosite: prefix, a ! in front of a geosite code, a list the file no longer has, or an empty file |
Check the name in the domain-list-community data/ directory |
geoip code not found: <code> |
A misspelt code, an @attr on a geoip code, or a code missing from a reduced file such as geoip-only-cn-private.dat |
Use a two-letter country code or private, with at most a leading ! |
geosite decode: failed to decode Protobuf message: … |
The geosite path points at something other than a v2ray-format domain list: geoip.dat, an HTML page saved by a failed download, or a file in another tool’s format |
Download dlc.dat again and check its checksum |
geoip decode: failed to decode Protobuf message: … |
The same for the geoip path | Download geoip.dat again and check its checksum |
Some mistakes produce no error at all and only show up as a rule that never matches:
- an attribute no entry carries, such as
apple@cm; - a
domain_suffixwith a leading dot:.example.commatches nothing, writeexample.com; - a
geoiprule meant for domains: it never matches a flow addressed by name; - a domain or
geositerule for traffic that arrives as an IP, on an inbound withsniffing = falseor over UDP; - a rule below a broader rule that already takes all of its flows.