Contents

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.

app/router/router.go
One pass over the rule list; the first rule whose conditions all hold decides the outbound

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:

app/router/config.go app/router/condition.go
{
	"type": "field",
	"domain": ["geosite:netflix"],
	"port": "443",
	"network": "tcp",
	"outboundTag": "us-proxy"
}

The matchers, and what they actually compare:

FieldMatcherMatches against
domainDomainMatcher (MPH group)Target domain, lowercased; plain (substring), regexp, domain, full
ip / source / localIPIPMatcher over compiled CIDR setsTarget, source or local IPs — true if any IP matches (AnyMatch)
port / sourcePort / localPortPortMatcherTarget, source or local port lists and ranges
networkNetworkMatchertcp / udp
protocolProtocolMatcherSniffed protocol, by prefix ("http" matches "http1")
userUserMatcherInbound user email, exact or regexp: patterns
inboundTagInboundTagMatcherTag of the inbound that accepted the connection
attrsAttributeMatcherSniffed 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.

app/router/condition_geoip.go

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.

app/dispatcher/default.go

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().

features/routing/dns/context.go

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:

app/router/balancing.go app/router/strategy_leastload.go
  • random (the default when strategy is empty) — uniform pick (dice.Roll). Only when a fallbackTag is 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 as random: dead outbounds are skipped only when a fallbackTag is 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 under maxRTT; sorts them by RTT deviation weighted by per-node cost (deviation × √cost), applies baselines/expected to 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.

Don't

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.

Do

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.