Skip to main content

Router

Router is WaterWall's rule-based branch selector for connection-oriented traffic. It sits in the middle of a chain, evaluates ordered rules, and sends each connection to one selected branch. Metadata-only rules can select a branch during Init; rules that need sniffing select one from the first upstream payload.

Use it when one inbound path should split into several outbound paths by source IP, inbound port, destination IP, destination domain, network type, detected protocol, HTTP upgrade flag, or authenticated username/password.

Mental Model​

Think of Router as an early selector when it can be, and a deferred branch selector when it must inspect bytes:

  • If no rule or root setting needs payload inspection, it evaluates the line's source/destination metadata, network, and credential facts during Init and initializes the selected branch immediately.
  • A rule needing protocol detection, HTTP Host/:authority, TLS/QUIC SNI, or HTTP Upgrade waits for the first upstream payload and buffers those bytes while deciding.
  • After a deferred rule wins, it initializes the selected branch and replays the buffered bytes to that branch.
  • The decision is made once per connection. Later payloads go directly through the same selected branch.
  • Downstream traffic from the selected branch returns through Router to the previous node.

If no rule matches, the connection uses the node's top-level next. That next is the default route.

At Init, Router clears stale observed-protocol bits while preserving separate one-shot route-control metadata. This lets a protocol tunnel carry an already negotiated destination through Router to its designated outbound adapter without making that metadata available to protocol rules.

warning

Rules that need sniffing still need the client side to send first. For server-first or handshake clients that need downstream establishment before their first write, use only metadata-only rules (for example network, ports, addresses, or credentials) and leave root sniffing, protocol, and attributes out of that Router.

Typical Placement​

Route a normal TCP listener:

TcpListener -> Router
|-- target: tls_path
|-- target: ssh_path
`-- next: default_path

Route after a proxy server that already parsed destination metadata:

Socks5Server -> Router -> TcpConnector

Route connection lines created from packet traffic:

TunDevice -> PacketsToConnection -> Router -> TcpConnector

Router is a layer-4 tunnel. It routes WaterWall lines, not raw packets by itself. For raw packet chains, first use a packet/connection bridge such as PacketsToConnection when you need connection-style routing.

Complete Example​

This example uses one inbound listener and sends:

  • authenticated user alice to a premium path
  • TLS traffic for *.example.org to a domain-specific path
  • SSH traffic to an SSH path
  • everything else to the default direct path
{
"name": "inbound",
"type": "TcpListener",
"settings": {
"address": "0.0.0.0",
"port": 443,
"nodelay": true
},
"next": "router"
}
{
"name": "router",
"type": "Router",
"settings": {
"sniffing": [
"http1",
"tls"
],
"rules": [
{
"username": "alice",
"target": "premium_path"
},
{
"destination-domain": [
"*.example.org"
],
"protocol": "tls",
"target": "example_tls_path"
},
{
"destination-port": 22,
"target": "ssh_path"
}
]
},
"next": "direct_path"
}
{
"name": "direct_path",
"type": "TcpConnector",
"settings": {
"address": "203.0.113.10",
"port": 443
}
}

Rule targets such as premium_path, example_tls_path, and ssh_path must be real node names in the same config. Each target is a branch entry. It can point to a connector, a protocol tunnel, a Bridge, or another valid chain segment.

Required Fields​

Top-level fields:

FieldTypeDescription
namestringUser-chosen node name. Must be unique inside the config file.
typestringMust be exactly "Router".
nextstringRequired. This is the default route used when no rule matches.

settings may be omitted or empty. A Router with no rules sends every connection to next; if sniffing is enabled and domain sniffing is allowed for the line, it may still sniff the first payload to populate dest_ctx.domain before forwarding.

Settings​

SettingTypeDefaultDescription
rulesarrayunsetOrdered list of routing rules. Missing means all traffic uses next. An empty array is invalid.
sniffingarrayunsetDomain-sniffing modes. Supported values: "http1", "http2", "http", "tls", "quic", "http3".
sniff-even-if-domain-is-already-providedbooleanfalseWhen false, Router skips Host/:authority/SNI domain sniffing for destinations that already have dest_ctx.domain. If true, sniffing may replace an existing destination domain. Use carefully.
resolve-domainsbooleanfalseInserts an internal DomainResolver before Router so unresolved domain destinations are resolved before rule classification.
geoip-db-pathstringunsetRequired only if a rule uses geoip:<cc> in source-ips or destination-ip.
geosite-db-pathstringunsetRequired only if a rule uses geosite:<list> in destination-domain.

"http" is an alias for HTTP/1 Host plus cleartext HTTP/2 authority sniffing. It does not include HTTP/3; use "http3" as well when you want QUIC/HTTP/3 SNI.

Rule Basics​

A rule is a target plus at least one condition.

{
"destination-port": [
80,
443
],
"network": "tcp",
"target": "web_path"
}

Matching rules:

ScopeLogic
Values inside one conditionOR. destination-port: [80, 443] matches either port.
Conditions inside one ruleAND. Every configured condition must match.
Rules inside rulesFirst match wins, in JSON order. Later rules are skipped.
No matching ruleThe connection goes to top-level next.

Unknown fields inside a rule are ignored with a warning. A rule with only target and no conditions is invalid.

Condition Reference​

ConditionAcceptsMatches
source-ipsstring or arrayClient/source IP. Supports IP, CIDR, and geoip:<cc>.
source-portinteger or arrayInbound/local listener port stored in src_ctx.port.
source-port-rangetwo-integer arrayInclusive inbound/local listener port range.
destination-ipstring or arrayDestination IP. Supports IP, CIDR, and geoip:<cc>. Domain-only destinations do not match.
destination-portinteger or arrayDestination port stored in dest_ctx.port.
destination-port-rangetwo-integer arrayInclusive destination port range.
destination-domainstring or arrayDestination domain, including sniffed HTTP Host/:authority or TLS/QUIC SNI when root sniffing is enabled and domain sniffing is allowed for the line. Supports exact, wildcard, *, and geosite:<list>.
networkstring or arrayDestination transport flags: tcp, udp, icmp, packet. Comma-combined strings like "tcp,udp" are accepted.
protocolstring or arrayProtocol detected from first payload: http1, tls, bittorrent.
attributesnon-empty arraySniffed boolean attributes. Currently supports http_upgrade_present.
usernamestring or arrayExact case-sensitive match against authenticated credential markers on the line.
passwordstring or arrayExact case-sensitive match against authenticated credential markers on the line.

Port values must be integers in 0..65535. A port range must contain exactly two integers and the first value must be less than or equal to the second.

Source Conditions​

source-ips​

source-ips matches the line source address:

{
"source-ips": [
"10.0.0.0/8",
"192.0.2.10",
"geoip:ir"
],
"target": "local_or_iran_path"
}

Supported values:

  • IPv4 or IPv6 address
  • IPv4 or IPv6 CIDR
  • geoip:<ISO-3166-alpha-2>, such as geoip:ir or geoip:us

Bare IPs are treated as host routes: /32 for IPv4 and /128 for IPv6.

source-port and source-port-range​

In current listener behavior, source-port is the local/inbound port the peer connected to, not the peer's random ephemeral port.

This is useful with multiport listeners:

{
"source-port": [
80,
443
],
"target": "web_path"
}

Exact ports and a range can be combined in one rule. They are OR-combined:

{
"source-port": 443,
"source-port-range": [
10000,
10100
],
"target": "selected_inbound_ports"
}

Destination Conditions​

destination-ip​

destination-ip matches dest_ctx when the destination is already an IP:

{
"destination-ip": [
"198.51.100.0/24",
"geoip:us"
],
"target": "us_path"
}

If the destination is still domain-only, this condition does not match because there is no IP to compare. Use resolve-domains: true if you need domain destinations resolved before IP/GeoIP routing.

destination-port and destination-port-range​

destination-port matches the target service port:

{
"destination-port": [
80,
443
],
"target": "web_path"
}

Ranges are inclusive:

{
"destination-port-range": [
1000,
2000
],
"target": "range_path"
}

destination-domain​

destination-domain matches dest_ctx.domain.

{
"destination-domain": [
"example.com",
"*.example.org",
"geosite:cn"
],
"target": "domain_path"
}

Supported patterns:

PatternMeaning
example.comExact match, case-insensitive.
*.example.comMatches subdomains like www.example.com, but not example.com itself.
*Matches any non-empty domain.
geosite:cnMatches a named GeoSite list loaded from geosite-db-path.

For IP-only destinations, enable root sniffing so Router can read HTTP Host/:authority or TLS/QUIC SNI and store the observed value in dest_ctx.domain for destination-domain matching while preserving the IP endpoint.

Network And Protocol Conditions​

network​

network matches destination transport flags:

{
"network": "tcp,udp",
"target": "tcp_or_udp_path"
}

Supported values:

  • tcp
  • udp
  • icmp
  • packet

You can use one string, an array of strings, or comma-combined strings. Values inside the field are OR-combined.

protocol​

protocol matches application protocol detected from the first upstream payload:

{
"protocol": [
"tls",
"bittorrent"
],
"target": "special_protocol_path"
}

Supported values:

ValueDetection
http1HTTP/1 request method prefix such as GET , POST , or CONNECT .
tlsTLS ClientHello record.
bittorrentBitTorrent handshake prefix.

protocol does not require root sniffing. If any rule uses protocol, Router automatically runs the requested protocol detectors before classification.

Unknown values are fatal configuration errors. http, http2, quic, and http3 are root settings.sniffing modes/aliases only; they are not protocol values.

Attributes​

attributes matches boolean facts sniffed from the first payload.

Currently supported:

AttributeMeaning
http_upgrade_presentThe first HTTP/1 request contains an Upgrade: header.

Example:

{
"attributes": [
"http_upgrade_present"
],
"target": "websocket_path"
}

This attribute requires root sniffing to include "http1":

{
"sniffing": [
"http1"
],
"rules": [
{
"attributes": [
"http_upgrade_present"
],
"target": "websocket_path"
}
]
}

If http1 sniffing is not enabled, the rule will never match and Router logs a warning at startup. Unknown attribute names are fatal configuration errors.

Authenticated Identity​

username and password match credential markers already attached to the line by upstream authentication-capable tunnels.

{
"username": [
"alice",
"bob"
],
"target": "premium_path"
}
{
"password": "customer-secret",
"target": "customer_path"
}

Matching is exact and case-sensitive. If the line has no authenticated username or password, the corresponding condition does not match. When one rule contains both username and password, they must match the same credential marker; stacked authentication layers do not mix a username from one layer with a password from another.

Examples of tunnels that can populate authenticated credentials include Socks5Server, TrojanServer, and VlessServer. The exact meaning of username/password depends on the protocol that authenticated the line.

Domain Sniffing​

Root-level sniffing lets Router extract a domain from the first payload:

Some upstream tunnels may already provide dest_ctx.domain before Router sees the line, for example:

... -> Socks5Server -> Router -> ...
... -> TrojanServer / VlessServer -> Router -> ...

That depends on what the client requested. A SOCKS, Trojan, or VLESS client may send a domain name, but it may also send a literal IP address. The client might already know the IP, have resolved the name locally, use DoH/DoT or another DNS path before connecting, or be forwarding traffic that was IP-only from the start. In those cases Router may see no destination domain in dest_ctx.domain, and sniffing can be useful to determine the destination domain from Host/:authority/SNI.

{
"sniffing": [
"http",
"tls",
"http3"
]
}

Supported modes:

ValueReads
http1HTTP/1 Host header.
http2Cleartext HTTP/2 prior-knowledge :authority, falling back to host.
httpAlias for http1 plus http2.
tlsTLS ClientHello SNI.
quicQUIC Initial TLS ClientHello SNI.
http3Alias for quic.

Values are parsed case-insensitively. Missing or empty sniffing disables domain sniffing.

http intentionally does not include HTTP/3. Use both values when you want all HTTP versions:

{
"sniffing": [
"http",
"http3"
]
}

HTTP/2 sniffing is domain sniffing only: Router detects cleartext HTTP/2 prior-knowledge requests on TCP destination contexts and reads :authority from the first completed request HEADERS, falling back to that request's host. Later request streams cannot replace the first request's domain, including when several requests arrive in one payload. h2c upgrade requests are still handled through HTTP/1 Host sniffing.

TLS protocol detection and SNI sniffing accept a ClientHello split across TLS records, including a split inside the handshake header or hostname. Fragmented handshakes use at most 8192 bytes of reassembly storage; incomplete input still uses the sniff-window cutoff below. Complete single-record ClientHellos retain their existing direct parsing path.

HTTP/2 sniffing is available when WaterWall is built with router_enable_http2_sniffing=ON (default ON). QUIC/HTTP3 sniffing is available when built with router_enable_quic_sniffing=ON (default ON). Because http expands to http1 plus http2, it also requires the HTTP/2 build option. The http, http2, quic, and http3 names apply only to root settings.sniffing; they do not change rules[].protocol.

This is mainly for transparent or IP-backed flows where the original destination is an IP, but you still want domain routing:

{
"sniffing": [
"tls"
],
"rules": [
{
"destination-domain": "*.example.org",
"target": "example_path"
}
]
}

By default, Router does not even run Host/:authority/SNI domain sniffing for a line that already has dest_ctx.domain. That default protects destinations chosen by a previous tunnel or by the config from being replaced by client-supplied Host/:authority/SNI. Protocol detection and attribute sniffing are separate and may still run because they do not replace dest_ctx.domain.

Set sniff-even-if-domain-is-already-provided: true only when you deliberately trust the application-layer Host/:authority/SNI more than the existing destination domain.

When Host/:authority/SNI is found and Router is allowed to store it, Router writes it to dest_ctx.domain through the observed-domain path. The IP address, port, transport flags, optional protocol flags, and address type are preserved.

Destination shapeResult
Concrete IP with no existing domainDomain stored, domain_resolved = true; upstream connectors keep using the original IP and do not resolve the sniffed name.
Wildcard/any IP (0.0.0.0, ::) with no existing domainDomain stored, but not treated as resolved because there is no concrete endpoint IP.
Existing domain, default settingHost/:authority/SNI domain sniffing is skipped; dest_ctx.domain is not overwritten.
Domain-only, with sniff-even-if-domain-is-already-provided: trueSniffed domain replaces dest_ctx.domain, domain_resolved = false; an upstream connector will DNS-resolve and connect to the new name.

First-Payload Buffering​

Router buffers the first upstream bytes until classification is possible.

Detectors can ask for more bytes when the payload is a partial HTTP request, HTTP/2 preface/HEADERS sequence, TLS ClientHello, or BitTorrent handshake. The sniff window is bounded by the shared protocol-sniff limit:

8192 bytes

If the needed information is not found by then, the detector treats it as missing and Router continues classification with the facts it has.

Practical consequences:

  • Rules that use protocol, destination-domain with sniffing, or http_upgrade_present can delay branch selection until enough bytes arrive.
  • Non-matching protocols usually decide quickly because their first bytes do not look like the requested protocol.
  • After a route is selected, later payloads are not reclassified.

Branch initialization and initial replay preserve FIFO order: input received reentrantly during those callbacks waits behind the already accepted first bytes. For stream input, that additional input shares one ordinary buffer with a 2 MiB logical-byte limit; exceeding it closes the line. The limit does not reject a previously accepted complete first delivery merely because it exceeds the sniff window. UDP input retains each datagram separately, including empty datagrams, and uses a separate byte view for sniffing. Its pending FIFO is limited to 2 MiB of payload, 4 MiB of buffer capacity charge, and 1,024 datagrams. Exceeding any limit closes the line. Replay preserves every datagram's contents and boundaries, including input received during branch initialization or replay. Once startup replay finishes, later payloads pass through without a Router output queue.

GeoIP​

geoip:<cc> works in source-ips and destination-ip.

{
"settings": {
"geoip-db-path": "/var/lib/waterwall/GeoLite2-Country.mmdb",
"rules": [
{
"source-ips": "geoip:ir",
"target": "iran_path"
},
{
"destination-ip": [
"geoip:us",
"geoip:de"
],
"target": "western_path"
}
]
},
"next": "default_path"
}

Notes:

  • geoip-db-path is required only when at least one parsed rule uses geoip:<cc>.
  • The database must be a MaxMind country database.
  • Country codes are two-letter ISO-3166 codes and are parsed case-insensitively.
  • If an IP is not found in the database, the GeoIP condition does not match.
  • A domain-only destination must be resolved before destination-ip: geoip:... can match.

GeoSite​

geosite:<list> works in destination-domain.

{
"settings": {
"geosite-db-path": "/var/lib/waterwall/geosite_generated.json",
"sniffing": [
"http",
"tls",
"http3"
],
"rules": [
{
"destination-domain": [
"geosite:cn",
"geosite:category-ads-all"
],
"target": "special_domain_path"
}
]
},
"next": "default_path"
}

geosite-db-path is required only when a parsed rule uses a geosite: token. Router loads and validates the JSON database at startup.

Supported GeoSite entry types:

GeoSite typeMatch behavior
fullExact domain.
domain / root_domainRoot domain and subdomains.
plain / keywordCase-insensitive substring.
regex / regexpSTC cregex pattern, matched case-insensitively.

The generator for WaterWall-style GeoSite JSON lives in:

tunnels/Router/geosite_ww_style_generator/do_the_job.py

Router compiles only the lists referenced by your rules, then releases the raw JSON representation.

resolve-domains​

When resolve-domains is true, Router creates an internal DomainResolver before itself.

{
"settings": {
"resolve-domains": true,
"geoip-db-path": "/var/lib/waterwall/GeoLite2-Country.mmdb",
"rules": [
{
"destination-ip": "geoip:us",
"target": "us_path"
}
]
},
"next": "default_path"
}

Use this when an earlier node provides a domain destination and you want Router to make IP or GeoIP decisions after DNS resolution.

Important details:

  • The internal resolver runs before Router's own first-payload classification.
  • It only resolves destinations that are unresolved domain destinations.
  • It does not re-resolve Host/:authority/SNI domains sniffed later by Router.
  • IP destinations pass through the resolver unchanged.

Target Branches​

Each rule target is a node name in the same config.

At chain construction time, Router folds target nodes into its own chain so that:

  • target nodes get per-line state slots
  • downstream traffic from target branches returns through Router
  • lifecycle callbacks remain composable

Target rules:

  • A target must exist.
  • A target cannot be the Router itself.
  • A target branch must not already be bound to an incompatible previous node.
  • Sharing a chain does not make a node inside another configured branch a valid target. Router accepts its own default branch and explicitly bound targets, including their private inserted helpers.
  • Several rules may reference the same target.

Use a Bridge target when the branch you want to reach lives in a separate part of the config:

TcpListener -> Router
|-- target: bridge_to_special_path
`-- next: direct_path

Direction And Lifecycle Behavior​

Source-backed behavior:

CallbackBehavior
upstream InitInitializes Router line state and clears observed-protocol bits, preserving route-control metadata. Metadata-only rules select and initialize their branch here.
first upstream PayloadIf routing needs payload inspection, buffers and sniffs bytes, evaluates rules, initializes the selected branch and replays buffered bytes. Otherwise forwards to the branch selected during Init.
later upstream PayloadGoes directly to the selected target or default next.
upstream Pause / ResumeForwarded to the selected branch, including duplicates; ignored before selection. They do not gate payload replay.
upstream FinishDestroys Router line state and finishes the selected target/default branch if one exists.
downstream Payload / Pause / Resume / EstForwarded back to the previous node.
downstream FinishDestroys Router line state before forwarding finish to the previous node.

Router clears only observed-protocol bits in dest_ctx.optional_flags on upstream Init, so protocol observations do not leak into a later Router on the same line. Separate one-shot route-control metadata remains available to its intended adapter and does not count as a protocol match.

Pause/Resume are advisory in both directions. Router keeps no consumer-pause state and does not wait for Resume to finish branch startup or deliver ready input.

After branch Init returns and initial replay settles, Router destroys and zeros its line state and permanently bypasses itself when the selected branch entry is its actual next link. This covers the default route and a rule that selects the same entry. A different target keeps Router active to dispatch future upstream callbacks to that branch. Bypass does not change the selected destination or remove downstream nodes from the chain.

Recipes​

Route By Domain With A Default Fallback​

{
"name": "router",
"type": "Router",
"settings": {
"sniffing": [
"http",
"tls",
"http3"
],
"rules": [
{
"destination-domain": [
"*.example.com",
"api.vendor.test"
],
"target": "domain_path"
}
]
},
"next": "default_path"
}

Route GeoIP Clients​

{
"name": "router",
"type": "Router",
"settings": {
"geoip-db-path": "/var/lib/waterwall/GeoLite2-Country.mmdb",
"rules": [
{
"source-ips": [
"geoip:ir",
"10.0.0.0/8"
],
"target": "regional_path"
}
]
},
"next": "default_path"
}

Split WebSocket Upgrades​

{
"name": "router",
"type": "Router",
"settings": {
"sniffing": [
"http1"
],
"rules": [
{
"attributes": [
"http_upgrade_present"
],
"target": "websocket_path"
}
]
},
"next": "plain_http_path"
}

Match Several Conditions Together​

{
"name": "router",
"type": "Router",
"settings": {
"rules": [
{
"network": "tcp",
"destination-port": 443,
"protocol": "tls",
"target": "tls_443_path"
}
]
},
"next": "default_path"
}

This rule matches only when the line is TCP, the destination port is 443, and the first payload is detected as TLS. If any condition fails, Router tries the next rule.

Route Authenticated Users​

{
"name": "router",
"type": "Router",
"settings": {
"rules": [
{
"username": [
"alice",
"bob"
],
"target": "premium_path"
},
{
"username": "guest",
"target": "limited_path"
}
]
},
"next": "anonymous_path"
}

Place Router after the tunnel that authenticates and stores credentials on the line.

Common Mistakes​

Expecting rule order to be priority-free​

Rules are not all evaluated and scored. The first fully matching rule wins. Put the most specific rules first and broad catch-all rules later.

Using a match-all rule​

A rule with only target is invalid. Use top-level next as the default route. If you want an explicit broad rule before the default, use a real condition such as "destination-domain": "*" for domain-only routing.

Expecting destination-domain to work on IP-only traffic without sniffing​

For IP-only flows, dest_ctx.domain is empty unless a previous node provided it or Router sniffed it from HTTP Host/:authority or TLS/QUIC SNI. Enable root sniffing for this case.

Expecting protocol to need root sniffing​

protocol detection is separate from Host/:authority/SNI domain sniffing. If a rule uses protocol, Router runs the needed protocol detectors automatically.

Confusing source-port with the client's ephemeral port​

For common listeners, source-port is the local/inbound port the peer connected to. Use it for multiport listener routing.

Routing server-first protocols​

Router initializes a branch during Init when its configured rules use only metadata already on the line. If any configured rule or root setting needs payload sniffing, Router instead waits for the first upstream payload; such a configuration cannot initialize a server-first path until client data arrives.

Node Metadata​

Router does not prepend protocol headers and does not need left padding.

Source-backed metadata:

PropertyValue
node flagskNodeFlagSupportsSplice
can_have_prevtrue
can_have_nexttrue
layer_groupkNodeLayer4
layer_group_prev_nodekNodeLayer4
layer_group_next_nodekNodeLayer4
required_padding_left0 bytes