Routing
How the router picks an outbound for every connection using rules and matchers.
Every connection an inbound accepts has to end up at exactly one outbound. The router is the component that decides which one, and it does it once per connection — before the first byte is proxied — by walking a list of rules you wrote.
The first matching rule wins — order is the whole algorithm
Rules are evaluated top to bottom, and evaluation stops at the first match. PickRoute calls pickRouteInternal, which is just a for loop over r.rules returning the first rule whose Apply(ctx) is true. There is no specificity scoring and no “best match” — a broad rule placed
first shadows every rule below it. If nothing matches, the router returns common.ErrNoClue and
the dispatcher falls back to the first outbound in your config.
A rule is an AND of matchers, not a bag of options
Every field you set on a rule becomes one condition, and all of them must hold for the rule to
match. BuildCondition compiles the rule into a ConditionChan — a slice of Condition values whose Apply short-circuits to false on the first failing matcher. A rule with domain, port and network only fires when the connection satisfies all three:
{
"type": "field",
"domain": ["geosite:netflix"],
"port": "443",
"network": "tcp",
"outboundTag": "us-proxy"
} The matchers, and what they actually compare:
| Field | Matcher | Matches against |
|---|---|---|
domain | DomainMatcher (MPH group) | Target domain, lowercased; plain (substring), regexp, domain, full |
ip / source / localIP | IPMatcher over compiled CIDR sets | Target, source or local IPs — true if any IP matches (AnyMatch) |
port / sourcePort / localPort | PortMatcher | Target, source or local port lists and ranges |
network | NetworkMatcher | tcp / udp |
protocol | ProtocolMatcher | Sniffed protocol, by prefix ("http" matches "http1") |
user | UserMatcher | Inbound user email, exact or regexp: patterns |
inboundTag | InboundTagMatcher | Tag of the inbound that accepted the connection |
attrs | AttributeMatcher | Sniffed HTTP headers, one regex per lowercased key, all keys must match |
All domain entries of one rule compile into a single minimal-perfect-hash matcher group
(NewMphMatcherGroup), so a rule carrying an entire geosite list is still one lookup, not
thousands. GeoIP country lists are merged and compiled into shared, cached IP sets
(ipsetFactory.GetOrCreate), so ten rules referencing geoip:cn share one copy. And when no
prefix in a set is longer than /24 (IPv4) or /64 (IPv6), HeuristicGeoIPMatcher.AnyMatch probes a multi-answer DNS result once per /24 or /64 bucket instead of once per IP.
Sniffing is what makes domain and protocol rules work
The protocol and, often, the domain a rule sees come from sniffing the first bytes of the
connection, not from anything the client declared. The dispatcher peeks at the initial payload
before routing: on success it always stores content.Protocol = result.Protocol() — that string
("tls", "http1", "bittorrent", "quic") is exactly what ProtocolMatcher prefix-matches.
If destOverride allows it, the sniffed TLS SNI or HTTP Host also replaces the destination, so a
connection that arrived as a bare IP suddenly matches your domain rules.
Domains only become IPs when the strategy says so
A connection to example.com does not match geoip rules unless you opt into resolution —
by default (AsIs) the router only sees the domain. With IpIfNonMatch, the router first runs
the whole rule list as-is; only if nothing matched does it wrap the context in a DNS-resolving
one (ContextWithDNSClient) and run every rule a second time. With IpOnDemand, the resolving
context is installed up front, and the DNS lookup fires lazily the first time an IP condition
calls GetTargetIPs().
Balancers defer the choice from config time to match time
A rule that names a balancerTag instead of an outboundTag picks its outbound fresh on
every match. Rule.GetTag() calls Balancer.PickOutbound(), which selects candidate
outbounds by tag prefix (HandlerSelector.Select) and hands them to a strategy:
random(the default whenstrategyis empty) — uniform pick (dice.Roll). Only when afallbackTagis configured does it wire up the observatory and drop outbounds marked dead; candidates the observatory hasn’t probed count as alive.roundrobin— cycles through candidates with a shared index; same observatory rule asrandom: dead outbounds are skipped only when afallbackTagis set.leastping— always requires the observatory: the alive candidate with the lowest reported delay. Candidates with no observation are never picked.leastload— keeps only nodes that are alive, observed, and undermaxRTT; sorts them by RTT deviation weighted by per-node cost (deviation × √cost), appliesbaselines/expectedto keep the top group — then rolls a die among that group (selects[dice.Roll(count)]), so load spreads across all qualified nodes instead of hammering the single best one.
When a strategy comes up empty it returns an empty tag and Balancer.PickOutbound substitutes fallbackTag; the routing API can also pin a balancer to one outbound at runtime via SetOverrideTarget, which bypasses the strategy entirely.
Put catch-all rules first
A leading "network": "tcp,udp" rule matches everything; the geosite and geoip rules below it
are dead code — evaluation never reaches them.
Order rules narrow to broad
Block rules and specific domains first, country-level geoip next, the catch-all last — the first-match loop then behaves like fall-through you control.