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 first example
Section titled “A first example”A client that sends everything through a proxy, except example.net and its subdomains, which go out directly:
[[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.
How a flow is routed
Section titled “How a flow is routed”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 onlyoutboundis 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]]whendefaultis 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.
[route]
Section titled “[route]”| 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 default route
Section titled “The default route”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.
Geodata files
Section titled “Geodata files”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
geoiporgeositematcher. 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
.datfile, change the config file too (a comment is enough). See Hot reload.
[[route.rule]]
Section titled “[[route.rule]]”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`| Key | Type | Required | Default | Description |
|---|---|---|---|---|
outbound | string | yes | — | 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_suffix | array of strings | no | [] | 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_keyword | array of strings | no | [] | Matches a domain that contains the string anywhere: ads matches ads.example.com and downloads.example.com. Case-insensitive. |
domain_full | array of strings | no | [] | Matches exactly this domain and no subdomain. Case-insensitive. |
domain_regex | array of strings | no | [] | 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>": …. |
cidr | array of strings | no | [] | 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_cidr | array of strings | no | [] | 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. |
port | array of strings | no | [] | 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. |
network | string (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_tag | array of strings | no | [] | 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. |
geosite | array of strings | no | [] | 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. |
geoip | array of strings | no | [] | 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>. |
Domain matchers
Section titled “Domain matchers”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_keywordanddomain_fullvalues are lowercased when the config is loaded, soExample.COMin a rule is the same asexample.com.domain_regexpatterns 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\dor\Ware 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.
Address and port matchers
Section titled “Address and port matchers”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 and inbound_tag
Section titled “network and inbound_tag”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 and geoip
Section titled “geosite and geoip”geosite entries name lists in the geosite file:
category-ads-alluses every entry of that list.google@adsuses only the entries ofgooglethat carry theadsattribute. Here one entry requires two things at once: the list and the attribute.
geoip entries name lists in the geoip file:
privatematches destination addresses in the list.!cnmatches 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.
What a matcher can see
Section titled “What a matcher can see”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 |
Domains are not resolved for routing
Section titled “Domains are not resolved for routing”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"].
Sniffing
Section titled “Sniffing”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:
- the SNI of a TLS ClientHello;
- 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 theHostheader.
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,
geositeincluded, 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.
cidrandgeoipstill 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.
Combining conditions
Section titled “Combining conditions”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:
networkhas two values, so its complement is the other value.- A port’s complement is a list of ranges. “Clients in
192.0.2.0/24connecting to port 25” isport = ["0-24", "26-65535"]sent to the usual outbound first, thensource_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 ofcodeis!code. For destination addresses, remember that neither acidrlist nor!codematches 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.comand incategory-ads-all”, cannot be expressed.
A complete example
Section titled “A complete example”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:
# 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.lanarrives as a name, whichgeoipnever tests;192.168.1.10arrives as an address, whichdomain_suffixnever tests. - The table says “a public IP address” on purpose. The documentation ranges used as placeholders elsewhere on this site are in the
privatelist, 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.
Common errors
Section titled “Common errors”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
cidrorgeoiprule for a flow that arrives as a name; - an earlier rule that matches first, often because of a
networkorportmatcher that matches more than intended; - a misspelt
inbound_tag, a leading dot indomain_suffix, or an upper-case letter in adomain_regex.