Skip to content

Routing

Every TCP flow an inbound accepts, and every packet of a UDP association, goes to exactly one outbound or balancer. The router decides which, from the [route] table and its [[route.rule]] blocks. It looks at the flow’s destination and port, whether the flow is TCP or UDP, the inbound it arrived on, the client’s address, and a domain name that sniffing may have read from the first bytes.

This page covers every routing key, the order in which rules are tried, what each matcher can and cannot see, how sniffing and UDP fit in, and the errors a bad rule produces. Read it when you want some traffic to take a different path from the rest: blocking ads, keeping local traffic direct, or sending one service through a particular proxy.

A client that sends everything through a proxy, except example.net and its subdomains, which go out directly:

config.toml
[[outbound]]
tag = "proxy"
protocol = "socks"
server = "proxy.example.com"
port = 1080
[[outbound]]
tag = "direct"
protocol = "freedom"
[[route.rule]]
outbound = "direct"
domain_suffix = ["example.net"]

There is no [route].default, so flows that match no rule go to proxy, the first [[outbound]] in the file. Without any [route] section at all, every flow goes there.

flowchart TB
  F["New flow or UDP packet"] --> N{"Another rule left?"}
  N -- "yes" --> M{"Does any matcher of this rule match?"}
  M -- "yes" --> O["Use this rule's outbound"]
  M -- "no" --> N
  N -- "no" --> D{"route.default set?"}
  D -- "yes" --> DT["Use route.default"]
  D -- "no" --> DF["Use the first outbound"]
  • First match wins. Rules are tried from top to bottom in file order. The first rule that matches picks the outbound, and the rules below it are not consulted.
  • Inside one rule, any matcher is enough. A rule matches when at least one of its matchers matches, and a list matcher matches when any of its entries does. There is no way to require two matchers at once within a rule; see Combining conditions.
  • A rule without matchers never matches. A [[route.rule]] that has only outbound is accepted and has no effect.
  • The fallback is the default route. A flow that no rule matches goes to [route].default, or to the first [[outbound]] when default is not set.
  • TCP is routed once, UDP per packet. A TCP flow is routed when it opens and stays on that outbound. Each UDP packet is routed on its own destination; see UDP.
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.

default may name an outbound or a balancer. Leave it out and the first [[outbound]] in the file takes the unmatched flows. Balancers live in their own [[balancer]] list, so one becomes the default only when default names it.

Setting default explicitly costs one line and keeps the fallback from changing when someone reorders the outbounds.

geoip and geosite point at the .dat files that v2ray and Xray use: geoip.dat from the v2fly geoip project and geosite.dat (published as dlc.dat) from the v2fly domain-list-community project. etemenanki-app reads the protobuf format directly; no conversion is needed.

  • Loaded only when used. A file is opened only if at least one rule has a geoip or geosite matcher. Otherwise the path is not checked at all, not even for existence.
  • Relative paths follow the working directory. A relative path is resolved against the directory the process runs in, not the directory of the config file. Use absolute paths in service configs.
  • Read on every build. The files are read at startup, by --test, and on every reload of a changed config that parses. Only the codes that rules name are kept in memory.
  • Replacing a file does not reload. Hot reload is triggered by a change to the config file’s bytes, so after updating a .dat file, change the config file too (a comment is enough). See Hot reload.

Each rule has one required key, outbound, and any number of matchers. Unknown keys are refused, so a misspelt matcher fails loudly instead of turning into a rule that never matches:

unknown field `domain`, expected one of `outbound`, `domain_suffix`, `domain_keyword`, `domain_full`, `domain_regex`, `cidr`, `source_cidr`, `port`, `network`, `inbound_tag`, `geosite`, `geoip`
KeyTypeRequiredDefaultDescription
outboundstringyes—Tag of the outbound or balancer that takes the flows this rule matches. An unknown tag fails with route references unknown outbound tag: <tag>.
domain_suffixarray of stringsno[]Matches the domain itself and every subdomain, on label boundaries: example.com matches example.com and a.example.com, not notexample.com. Case-insensitive. Write no leading dot: .example.com matches nothing.
domain_keywordarray of stringsno[]Matches a domain that contains the string anywhere: ads matches ads.example.com and downloads.example.com. Case-insensitive.
domain_fullarray of stringsno[]Matches exactly this domain and no subdomain. Case-insensitive.
domain_regexarray of stringsno[]A regular expression in Rust regex syntax, tested against the lowercased domain. Unanchored: add ^ and $ to match the whole name. The pattern itself is not lowercased, so write it in lower case. An invalid pattern fails with invalid domain regex "<pattern>": ….
cidrarray of stringsno[]Matches a destination IP address inside the range, such as 10.0.0.0/8 or 2001:db8::/32. A bare address is a single host. Host bits must be zero (invalid cidr "10.0.0.1/8": host part of address was not zero). Never matches a destination given as a domain.
source_cidrarray of stringsno[]Matches when the client address is inside the range. Same syntax and errors as cidr. Flows from a Unix-socket inbound have no client address and never match.
portarray of stringsno[]Destination ports as strings: a single port "443" or an inclusive range "8000-9000". Spaces around the numbers are allowed. A bare integer is a type error (expected a string); a range whose lower bound is above its upper bound fails with invalid port spec: "9000-8000" has a lower bound above its upper bound.
networkstring (enum)no—The transport of the flow: tcp or udp. One string, not an array, lowercase only. Anything else fails with invalid rule network "<value>" (expected "tcp" or "udp").
inbound_tagarray of stringsno[]Matches flows that arrived on one of these inbounds. Compared exactly, case included. Not checked against the inbounds in the file, so a misspelt tag is accepted and never matches.
geositearray of stringsno[]A list from [route].geosite: code, or code@attr for only the entries that carry that attribute. Codes and attributes are case-insensitive. Write no geosite: prefix. An unknown code fails with geosite code not found: <code>; an unknown attribute is accepted and matches nothing.
geoiparray of stringsno[]A list from [route].geoip: code matches destination IPs in the list, !code matches destination IPs outside it. Case-insensitive. Like cidr, neither form ever matches a destination given as a domain. An unknown code fails with geoip code not found: <code>.

Four matchers test a domain name. geosite does too; its lists are built from the same four kinds of entry.

Matcher Rule value Matches Does not match
domain_suffix example.com example.com, www.example.com, a.b.example.com notexample.com, example.com.cdn.example.net
domain_keyword track track.example.com, backtrack.example.net example.com
domain_full www.example.com www.example.com example.com, a.www.example.com
domain_regex ^ad[0-9]+\. ad1.example.com, ad42.example.net bad1.example.com, ads.example.com

Case handling:

  • The name being tested is lowercased first, whether it came from the request or from sniffing.
  • domain_suffix, domain_keyword and domain_full values are lowercased when the config is loaded, so Example.COM in a rule is the same as example.com.
  • domain_regex patterns are not lowercased. They run against the lowercased name, so a pattern that needs an upper-case letter to match, such as ^WWW\., can never match. Write literal letters in lower case; escapes such as \d or \W are unaffected.

domain_regex uses the syntax of the Rust regex crate, which has no look-around and no backreferences. A pattern matches anywhere in the name unless you anchor it with ^ and $: example alone matches example.com and myexample.net. Write patterns as TOML literal strings in single quotes, so that a backslash reaches the regex unchanged:

domain_regex = ['^ad[0-9]+\.', '^cdn-[a-z]+\.example\.com$']

In a double-quoted TOML string, "\." is an invalid escape and the file does not parse.

cidr tests the destination address. source_cidr tests the client’s address. Both take CIDR notation, such as 10.0.0.0/8, 192.0.2.0/24 or 2001:db8::/32. A bare address such as 192.0.2.7 is a single host. The address must have its host bits set to zero: 10.0.0.1/8 is refused rather than silently widened.

The client address that source_cidr sees depends on the inbound:

Inbound Client address
TCP listener: socks, http, trojan, vless, vmess, shadowsocks The peer of the TCP connection
hysteria2 The QUIC client’s address, per connection
tun The source address of the local host’s packet
Any inbound on a Unix socket None: source_cidr never matches

etemenanki-app does not read X-Forwarded-For or the PROXY protocol. Behind a reverse proxy or CDN, the client address is the address of that proxy.

port tests the destination port. Each entry is a string, "443" or an inclusive range "8000-9000", and spaces around the numbers are ignored. "0-65535" matches every port. Integers are a type error, so write port = ["443"], not port = [443].

network is a single string, "tcp" or "udp", not a list. There is no value for both. Without network, the rule’s other matchers apply to TCP flows and UDP packets alike. A rule that should catch every flow can use port = ["0-65535"], which matches any port.

inbound_tag is a list of inbound tags, compared exactly. The tags are not checked against the [[inbound]] blocks, so a misspelt tag loads without error and never matches. Check the spelling when an inbound_tag rule seems to be ignored.

geosite entries name lists in the geosite file:

  • category-ads-all uses every entry of that list.
  • google@ads uses only the entries of google that carry the ads attribute. Here one entry requires two things at once: the list and the attribute.

geoip entries name lists in the geoip file:

  • private matches destination addresses in the list.
  • !cn matches destination addresses outside the list.

Codes and attributes are case-insensitive: CN, cn and Cn load the same list. An unknown code stops the config from loading, with geosite code not found: <code> or geoip code not found: <code>. An unknown attribute is not an error; google@nosuchattr loads and matches nothing.

In current v2fly releases, private covers the RFC 1918 ranges, loopback, link-local, shared address space (100.64.0.0/10), multicast and reserved space, IPv6 unique local addresses (fc00::/7), and also the documentation ranges 192.0.2.0/24, 198.51.100.0/24 and 203.0.113.0/24. Keep that in mind when you test rules with addresses from those ranges: a geoip = ["private"] rule catches them.

Like cidr, geoip tests only destinations given as an IP address, and that includes the negated form. !cn does not match a flow addressed to a domain name, whatever the name resolves to.

Inside a geosite list, entries of an unknown kind and regular expressions that do not compile are skipped with a skipping … warning in the log instead of failing the load.

A matcher only ever tests the field it is about. When the flow does not carry that field, the matcher does not match.

Matcher Tested against Never matches when
domain_suffix, domain_keyword, domain_full, domain_regex, geosite The destination name, and the sniffed name The destination is an IP address and nothing was sniffed
cidr, geoip The destination address The destination is a domain name
port The destination port Always tested
network tcp or udp Always tested
inbound_tag The tag of the inbound Always tested
source_cidr The client’s address The inbound is on a Unix socket

The router matches the destination as the client sent it. It never resolves a domain to find out which address rules apply. A client that sends names, such as a browser configured for remote DNS through SOCKS5 or curl --socks5-hostname, produces flows that cidr and geoip rules never match, even when the name resolves to a private address.

If a destination can arrive either way, cover both: a domain matcher for names and an address matcher for IP addresses, in the same rule. The complete example does this for local traffic, pairing domain_suffix = ["lan", "local"] with geoip = ["private"].

The opposite case, a flow addressed by IP that you want to match by name, is what sniffing is for. With sniffing = true on the inbound, which is the default, etemenanki-app reads the start of a TCP flow whose destination is an IP address and looks for a name:

  1. the SNI of a TLS ClientHello;
  2. failing that, the host of an HTTP/1 request: the authority of an absolute-form request line such as GET http://example.com/ HTTP/1.1, or else the Host header.

It waits at most 300 milliseconds and reads at most 4 KiB. A mux.cool sub-flow is the exception: only the data that arrived with its opening frame is inspected, without waiting. A flow that already names a domain is never sniffed. Nothing sniffing does can fail a connection: garbage, a truncated ClientHello, or a client that waits for the server to speak first leaves the flow routed on its address. An IP literal in the SNI or Host is ignored.

What the router does with the result:

  • Every domain matcher tries the sniffed name, geosite included, in addition to the destination name. That is how a domain rule catches a flow that arrived as an IP address.
  • Address matchers ignore it. cidr and geoip still test the destination address.
  • The destination is not rewritten. The outbound still connects to the IP address the client asked for. The sniffed name only steers the routing decision.
  • UDP is never sniffed. Packets are routed on their own destination only.

The sniffing flag is set per inbound. The Inbounds page explains when to turn it off and how it changes the reply a SOCKS or HTTP client sees.

A UDP association has no single destination: each packet carries its own. etemenanki-app therefore routes every packet separately, with the same rules and the same first-match order as TCP. The packet’s destination and port are matched, network is udp, and the association’s inbound tag and client address are carried along. Because UDP is never sniffed, a packet addressed to an IP address can match only address, port, network, inbound and source rules.

One association can therefore use several outbounds at once: DNS queries can go direct while other packets go through a proxy. A packet routed to blackhole is dropped, and the rest of the association carries on.

http and shadowsocks outbounds carry no UDP, and packets routed to them are dropped. Put a network = "udp" rule above the rule that points at them if UDP matters. The Outbounds page describes the sub-links behind per-packet routing and their limits.

A rule is an OR of all its matchers and all their entries. There is no AND. Some combinations can still be expressed, in three ways.

Use a matcher that already combines. geosite = ["google@ads"] requires both the list and the attribute. A domain_regex can require a prefix and a suffix at once, such as '^api\.[a-z]+\.example\.com$'. A port range such as "8000-9000" is one entry that bounds the port from both sides.

Carve out exceptions with order. “Everything under example.com goes through the proxy, except static.example.com” is two rules, the exception first:

[[route.rule]]
outbound = "direct"
domain_full = ["static.example.com"]
[[route.rule]]
outbound = "proxy"
domain_suffix = ["example.com"]

Claim the complement first. To act on flows that meet condition A and condition B, first send everything that is not A to the outbound it should get anyway. Only flows that are A reach the next rule, which then tests B. For example, to block QUIC (UDP to port 443) and let browsers fall back to TCP:

# Every TCP flow stops here, at the outbound it would get anyway.
[[route.rule]]
outbound = "proxy"
network = "tcp"
# Only UDP reaches this rule, so port 443 here means UDP to port 443.
[[route.rule]]
outbound = "block"
port = ["443"]

The price is that the first rule takes every TCP flow, so any rule meant for TCP must come above it. The same holds in general: every flow the complement rule catches skips all the rules below it.

This works only when one of the two conditions has a complement you can write as a matcher:

  • network has two values, so its complement is the other value.
  • A port’s complement is a list of ranges. “Clients in 192.0.2.0/24 connecting to port 25” is port = ["0-24", "26-65535"] sent to the usual outbound first, then source_cidr = ["192.0.2.0/24"] sent to its own outbound.
  • An address range’s complement is a list of CIDR blocks that covers the rest of the address space. It is long. For geoip, the complement of code is !code. For destination addresses, remember that neither a cidr list nor !code matches a destination given as a domain, so such flows also pass on to the next rule.
  • An inbound’s complement is the list of your other inbound tags.
  • Domain and geosite matchers have no complement: nothing matches “every name except these”. A combination made only of domain and geosite conditions, such as “under example.com and in category-ads-all”, cannot be expressed.

A local SOCKS5 client that blocks ads, keeps local traffic direct, sends speed tests direct, blocks QUIC, and sends the rest through a VLESS proxy over WebSocket and TLS:

routing.toml
# A local client that routes with geodata: ads are dropped, local names and
# private addresses go direct, and everything else goes through a VLESS proxy.
# QUIC (UDP to port 443) is blocked so that browsers fall back to TCP.
# Download geoip.dat and geosite.dat from the v2fly projects first.
[[inbound]]
tag = "socks-in"
protocol = "socks"
listen = "127.0.0.1"
port = 1080
# sniffing = true is the default: a flow addressed by IP is matched by its
# TLS SNI or HTTP Host as well, so domain and geosite rules still apply.
[inbound.settings]
udp = true # the default; shown because rule 5 below is about UDP
# The first outbound. [route].default names it explicitly anyway.
[[outbound]]
tag = "proxy"
protocol = "vless"
server = "proxy.example.com"
port = 443
[outbound.stream]
network = "ws"
security = "tls"
[outbound.stream.ws]
path = "/ws"
[outbound.settings]
id = "11111111-2222-3333-4444-555555555555"
[[outbound]]
tag = "direct"
protocol = "freedom"
[[outbound]]
tag = "block"
protocol = "blackhole"
[route]
default = "proxy"
# Read only because the rules below use geosite and geoip matchers.
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. Ads and trackers are dropped, whatever the protocol.
[[route.rule]]
outbound = "block"
geosite = ["category-ads-all"]
# 2. Local names and private addresses stay on the local network. The domain
# matchers catch names; geoip catches flows addressed by IP.
[[route.rule]]
outbound = "direct"
domain_full = ["localhost"]
domain_suffix = ["lan", "local"]
geoip = ["private"]
# 3. Any name that contains "speedtest" goes direct, so a speed test
# measures the local line rather than the proxy.
[[route.rule]]
outbound = "direct"
domain_keyword = ["speedtest"]
# 4 and 5 together block UDP to port 443. Rule 4 claims every remaining TCP
# flow, so only UDP ever reaches rule 5. Rule 4 names the default outbound,
# which leaves TCP routing unchanged.
[[route.rule]]
outbound = "proxy"
network = "tcp"
[[route.rule]]
outbound = "block"
port = ["443"]

How some flows are routed:

Flow Rule Outbound
TCP to a name in category-ads-all 1 block
TCP to a public IP address on port 443, whose TLS SNI is in category-ads-all 1, through sniffing block
TCP to nas.lan:445 2, domain_suffix direct
TCP to 192.168.1.10:80 2, geoip direct
TCP to www.speedtest.example.com:443 3 direct
TCP to www.example.com:443 4 proxy
UDP to a public IP address on port 443 5 block
UDP to a public IP address on port 53 none proxy, the default
UDP to 192.168.1.1:53 2, geoip direct

Three things to notice:

  • Rule 2 needs both kinds of matcher. nas.lan arrives as a name, which geoip never tests; 192.168.1.10 arrives as an address, which domain_suffix never tests.
  • The table says “a public IP address” on purpose. The documentation ranges used as placeholders elsewhere on this site are in the private list, so rule 2 would send them direct.
  • Rules 1 to 3 apply to UDP as well, because they come before the TCP-only rule 4. A UDP packet to a private address reaches rule 2 and goes direct.

Check the file with etemenanki-app --test -c routing.toml. --test builds the whole router, including reading the geodata files and looking up every code, without binding any listener.

Every error below stops --test and startup. In the log, the message follows configuration invalid: for --test and failed to start: at startup. On a hot reload, the running configuration stays in place and the new one is refused with reload: parse failed, keeping current config: … for the errors the TOML parser reports (unknown field, missing field, wrong type) and reload: build failed, keeping current config: … for the rest.

A refused reload is not retried on its own. The instance remembers the refused file contents, so after putting a missing or broken geodata file right, change the config file again (a comment is enough) to trigger another reload.

Message Cause Fix
route references unknown outbound tag: <tag> A rule’s outbound or [route].default names no outbound or balancer Fix the tag, or define the outbound
config defines no outbounds There is no [[outbound]], so there is nothing to route to, not even a default Define at least one outbound
unknown field `<key>`, expected one of `outbound`, … A misspelt matcher, or an Xray key such as domain or ip Use one of the keys in the rule table
missing field `outbound` A rule without outbound Add it
invalid type: integer `443`, expected a string port = [443] Quote the ports: port = ["443"]
invalid port spec: "<value>" A port that is not a number from 0 to 65535, or a malformed range Write "443" or "8000-9000"
invalid port spec: "9000-8000" has a lower bound above its upper bound An inverted range Swap the bounds
invalid rule network "TCP" (expected "tcp" or "udp") An unknown or capitalised value, or "tcp,udp" Use tcp or udp, or leave network out
invalid type: sequence, expected a string network = ["tcp", "udp"] network takes one string
invalid cidr "10.0.0.1/8": host part of address was not zero Host bits set in a cidr or source_cidr entry Use the network address, 10.0.0.0/8
invalid cidr "<value>": couldn't parse address in network: invalid IP address syntax Not an address at all, such as a host name Rules match addresses in CIDR form only
invalid domain regex "<pattern>": regex parse error: … A domain_regex that does not compile Fix the pattern; the message shows where it fails
a geosite matcher is used but no geosite file is configured A geosite rule without [route].geosite Set the path
a geoip matcher is used but no geoip file is configured A geoip rule without [route].geoip Set the path
No such file or directory (os error 2), or another OS error such as Is a directory (os error 21) The geodata path is wrong or unreadable. The message does not name the file Check [route].geoip and [route].geosite, and remember that relative paths follow the working directory
geosite code not found: <code> / geoip code not found: <code> The file has no list with that name, or the entry has an Xray prefix such as geosite: Check the name against the file’s lists
geosite decode: failed to decode Protobuf message: … / geoip decode: … The file is not in v2ray format, or the two paths are swapped Point each key at the right file

A rule that loads but never seems to match is almost always one of these:

  • a domain rule for a flow that arrives as an IP address, with sniffing off or nothing to sniff;
  • a cidr or geoip rule for a flow that arrives as a name;
  • an earlier rule that matches first, often because of a network or port matcher that matches more than intended;
  • a misspelt inbound_tag, a leading dot in domain_suffix, or an upper-case letter in a domain_regex.