Skip to content

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.

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.

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.

  1. 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.dat
    curl -fLO https://github.com/v2fly/geoip/releases/latest/download/geoip.dat.sha256sum
    curl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat
    curl -fLO https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat.sha256sum
  2. Check them. Each line must end in OK:

    Terminal window
    sha256sum -c geoip.dat.sha256sum dlc.dat.sha256sum
    geoip.dat: OK
    dlc.dat: OK
  3. Install them where the config expects them:

    Terminal window
    sudo install -D -m 0644 geoip.dat /etc/etemenanki/geoip.dat
    sudo install -D -m 0644 dlc.dat /etc/etemenanki/geosite.dat
/etc/etemenanki/config.toml
# 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:

Terminal window
etemenanki-app --test -c /etc/etemenanki/config.toml
Configuration 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] table holds the default outbound and the two file paths:

KeyTypeRequiredDefaultDescription
defaultstringno—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>.
geoippathdepends—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.
geositepathdepends—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.
rulearray of tablesno[]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.

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 private domain list or in a private address 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)

Each position in the list is there for a reason. If you add rules, keep these relationships:

  1. 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.
  2. Blocking comes before any rule that forwards. Ads are dropped whether they are local, in-country or abroad.
  3. private comes before !cn. !cn matches every address that is not in the cn list, and that includes 192.168.0.0/16, 10.0.0.0/8 and loopback. Without rule 3 above it, rule 5 would send your LAN through the proxy.
  4. 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.

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.

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 private rule above it, as rule 3 does.
  • Like every IP matcher, it never matches a flow addressed by domain. !cn does not mean “everything not in the country”; it means “every address not in the country”. Domains that match no rule go to [route].default.
  • The v2fly geoip.dat has one entry per two-letter country code, plus private and a test entry. For IPv4, private covers 0.0.0.0/8, the RFC 1918 ranges, loopback, link-local, CGNAT (100.64.0.0/10), the documentation and benchmarking ranges, and everything from 224.0.0.0 up. For IPv6 it covers :: and ::1, unique-local fc00::/7, link-local fe80::/10 and multicast ff00::/8.
  • The v2fly geosite.dat has about 1,500 lists: per-company lists such as apple, google and steam, category lists such as category-ads-all and category-games, and country-oriented lists such as cn, geolocation-cn and geolocation-!cn. private covers localhost, lan, local, internal, common router login names, reverse-lookup zones and any single-label name. cn includes the whole .cn top-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.

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 Host header.

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 Host is 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 CONNECT or 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 HTTP 502 or 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.

With the example running, compare the two ways curl can use a SOCKS proxy for a name on the ad list:

Terminal window
curl -x socks5h://127.0.0.1:1080 https://doubleclick.net

curl sends the name, and rule 2 matches it directly. The result is the same with sniffing on or off.

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:443
curl: (52) Empty reply from server

To split on a different country, change the places where the example names cn:

  • geoip = ["!cn"] in rule 5 and geoip = ["cn"] in rule 6: use the country’s two-letter code, in lower or upper case. The v2fly geoip.dat has 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 as category-ru and tld-ru, or category-ir; look in the domain-list-community data/ directory. Remove the @cn entries, which only make sense for cn.

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

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 .dat files. When only a comment changed, the log line reads config 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:

update-geodata.sh
#!/bin/sh
# Update the v2fly geodata and make a running etemenanki-app load it.
set -eu
dir=/etc/etemenanki
config="$dir/config.toml"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cd "$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.dat
do
curl -fsSLO "$url"
curl -fsSLO "$url.sha256sum"
done
sha256sum -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 1
fi
# 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"
fi

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

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_suffix with a leading dot: .example.com matches nothing, write example.com;
  • a geoip rule meant for domains: it never matches a flow addressed by name;
  • a domain or geosite rule for traffic that arrives as an IP, on an inbound with sniffing = false or over UDP;
  • a rule below a broader rule that already takes all of its flows.