Skip to content

Routing

Every [[node]] in a katana config has its own route table. For each flow a user opens, katana walks the table from top to bottom and sends the flow to the outbound named by the first rule that matches. If no rule matches, the flow takes the node’s default outbound, which is direct unless you change it.

Read this page when you want a node to block ads or private networks, send some sites through a WireGuard or proxy outbound, or understand why a rule does not match. The outbounds themselves are defined once for the whole process, as described in Outbounds.

The route table is local configuration, and no panel sends one. What a panel can send are audit rules: on Xboard and V2board, the panel’s routes with the block action become audit rules, which Audit covers.

This node blocks advertising domains and SMTP, and sends everything else directly:

/etc/katana/config.toml
[[node]]
panel_type = "NewV2board"
[node.api]
host = "https://panel.example.com"
node_id = 1
key = "replace-with-the-panel-key"
node_type = "V2ray"
[node.route]
default = "direct"
geosite = "/etc/katana/geosite.dat"
[[node.route.rule]]
outbound = "block"
geosite = ["category-ads-all"]
[[node.route.rule]]
outbound = "block"
port = ["25", "465", "587"]

Check a route before katana loads it:

Terminal window
katana --test -c /etc/katana/config.toml

--test builds every node’s router, so it reads the geodata files and resolves every outbound tag. It prints Configuration OK, or configuration error: followed by the first problem it finds.

Routing is one step in the path every flow takes once an inbound has decoded it. The same path serves every protocol katana speaks, including mux sub-flows and Hysteria 2 streams.

flowchart TB
  D["inbound decodes the request"] --> S["sniff first bytes: TCP to an IP only"]
  S --> A{"admit: user still registered?"}
  A -- no --> X["refused"]
  A -- yes --> N{"TCP or UDP?"}
  N -- TCP --> R["route: first matching rule, else default"]
  R --> B{"outbound is block?"}
  B -- yes --> X
  B -- no --> AU{"audit rule hit?"}
  AU -- yes --> X
  AU -- no --> DL["dial through the outbound"]
  DL --> M["meter: bill and pace the user"]
  N -- UDP --> P["per packet: route, audit, send, meter"]
  1. Sniff. For a TCP flow addressed by IP, the inbound first reads the client’s opening bytes for a domain name. See Sniffing.
  2. Admit. katana checks that the user is still in the panel’s user list. A user the last refresh removed is refused.
  3. Route. The router picks an outbound from the destination, the sniffed domain if there is one, and the port.
  4. Audit. katana matches the destination against the node’s audit rules. Audit runs only when the route did not already pick block, so a flow the route blocks is never recorded as an audit hit.
  5. Dial. The chosen outbound connects to the destination.
  6. Meter. Every byte that crosses the outbound is billed to the user and paced by the user’s speed limit. A refused flow never dials, so it costs the user nothing.
KeyTypeRequiredDefaultDescription
defaultstringno"direct"Tag of the outbound a flow takes when no rule matches. Any tag in the outbound pool is accepted: the built-in direct, block, freedom and blackhole, or the tag of a top-level [[outbound]]. Tags are case-sensitive. An unknown tag fails with route references unknown outbound tag: <tag>.
geoippathdepends—Path to a v2ray-format geoip.dat. Required as soon as any rule of this node uses geoip, otherwise the build fails with a geoip matcher is used but no geoip file is configured. katana reads the file only when a rule references it, so an unused path is never opened. Only the referenced codes are kept in memory.
geositepathdepends—Path to a v2ray-format geosite.dat (the v2fly dlc.dat works). Required as soon as any rule of this node uses geosite, otherwise the build fails with a geosite matcher is used but no geosite file is configured. Read only when referenced, like geoip.
rulearray of tablesno[]The rules, one [[node.route.rule]] block each, checked from top to bottom. The first rule that matches picks the outbound. Written in the singular: rules is an unknown field.

[node.route] is optional. Without it, the node has no rules and every flow goes direct.

The outbound pool is shared by all nodes. It always contains four built-in tags, and each top-level [[outbound]] adds its own:

Tag What it does
direct Connects to the destination from this host, using the process-wide [dns] resolver.
freedom Another name for direct, kept for Xray-style configs.
block Refuses a TCP flow and drops a UDP packet.
blackhole Another name for block.
any [[outbound]] tag Sends the flow through that upstream: SOCKS, HTTP, VMess, VLESS, Shadowsocks, WireGuard or a second direct.

An [[outbound]] cannot reuse a built-in tag; katana rejects it with duplicate/reserved outbound tag <tag>.

KeyTypeRequiredDefaultDescription
outboundstringyes—Tag of the outbound a matching flow takes. It must exist in the outbound pool (direct, block, freedom, blackhole or a top-level [[outbound]] tag), otherwise the build fails with route references unknown outbound tag: <tag>. Leaving it out fails the parse with a missing field error.
domain_suffixarray of stringsno[]Domains that match themselves and every subdomain: example.com matches example.com and www.example.com, not notexample.com. katana lowercases each entry. Matched against the domain the client asked for and against the sniffed TLS SNI or HTTP Host. Write entries without a leading dot: .example.com matches nothing.
cidrarray of stringsno[]Destination IP ranges, IPv4 or IPv6, such as 192.0.2.0/24 or 2001:db8::/32. A bare address is a single-host range. Parsing is strict: set host bits (192.0.2.1/24) or an oversized prefix fail with invalid cidr "<value>": <reason>. Matches only destinations the client addressed by IP; katana does not resolve domains before routing.
portarray of stringsno[]Destination ports as strings: a single port "443" or an inclusive range "6881-6889". Spaces around the numbers are ignored. A TOML integer such as 80 fails the parse with invalid type: integer, followed by expected a string. A value that is not a port fails with invalid port spec: "<value>", and a range whose lower bound is above its upper bound fails with invalid port spec: "<value>" has a lower bound above its upper bound.
geositearray of stringsno[]Entries of the [node.route].geosite file: code, or code@attr to keep only the domains tagged with that attribute, such as category-ads-all or google@ads. Codes and attributes are case-insensitive. Matched against the requested domain and the sniffed domain, like domain_suffix. A code missing from the file fails with geosite code not found: <code>; an attribute that tags no domain is accepted and matches nothing.
geoiparray of stringsno[]Entries of the [node.route].geoip file: code, such as private or cn, or !code to match every IP outside that list. Codes are case-insensitive. Like cidr, it matches only destinations addressed by IP, and !code does not match a domain destination either. A code missing from the file fails with geoip code not found: <code>.
  • First match wins. katana checks the rules in the order they appear in the file and stops at the first one that matches. Put narrow rules above broad ones.
  • Matchers inside one rule are OR-ed. A rule matches when any single entry of any of its keys matches. domain_suffix = ["example.com"] together with port = ["443"] matches every flow to example.com on any port, and every flow to port 443 anywhere.
  • There is no AND. A rule cannot say “this domain, and only on this port”.
  • A rule with no matchers never matches. A rule that sets only outbound passes --test and has no effect. Use [node.route].default for a catch-all.

A destination is either a domain or an IP address, depending on how the client addressed it. katana does not resolve domains before routing; the chosen outbound resolves the name later, if at all. So each matcher sees only one kind of destination:

Matcher Matches against Case Sees a sniffed domain
domain_suffix the requested domain lowercased on both sides yes
geosite the requested domain code and attribute case-insensitive yes
cidr the destination IP not applicable no
geoip the destination IP code case-insensitive no
port the destination port not applicable not applicable

An entry matches the domain itself and every subdomain, on a label boundary: example.com matches example.com and cdn.example.com, but not badexample.com. Write the bare domain. A leading dot, as in .example.com, matches nothing.

Entries are IPv4 or IPv6 networks. A bare address such as 192.0.2.10 is a single host. katana rejects a network whose host bits are set, so a typo does not quietly become a different range:

configuration error: invalid cidr "192.0.2.1/24": host part of address was not zero

Ports are strings, because a single key holds both single ports and ranges: "443" or "6881-6889", both inclusive. An integer in the array fails the TOML parse, and an inverted range fails the build:

configuration error: invalid port spec: "90-80" has a lower bound above its upper bound

geosite entries name lists in the [node.route].geosite file. The public v2fly domain list builds a compatible file (dlc.dat).

  • code matches every domain in that list, for example category-ads-all.
  • code@attr keeps only the domains tagged with that attribute, for example google@ads. One attribute per entry.
  • A list can contain suffix, full, keyword and regex entries; katana honours all four. A regex entry in the file that does not compile is skipped with a warning in the log.

An unknown code fails the build with geosite code not found: <code>. An attribute that no domain in the list carries is not an error: the entry is accepted and matches nothing. Check the attribute name in the list’s source when such a rule never fires.

geoip entries name lists in the [node.route].geoip file, such as the public v2fly geoip release.

  • code matches every IP in that list, for example private or cn.
  • !code matches every IP that is not in that list. It still matches only IP destinations.

An unknown code fails the build with geoip code not found: <code>, with the ! removed.

Matchers the kernel has but katana does not expose

Section titled “Matchers the kernel has but katana does not expose”

The routing engine katana uses also supports domain_keyword, domain_full, domain_regex, source_cidr, network and inbound_tag. katana’s config does not accept these keys; writing one fails the parse with unknown field. etemenanki-app exposes all of them: see Routing in the etemenanki-app guide. Inside katana, a geosite list is the only way to use keyword, full or regex domain matching.

A client often connects to an IP address even when it means a domain: the domain was resolved on the client side, or the application dials IPs directly. Such a flow can never match a domain rule on its destination alone. Sniffing recovers the name from the first bytes the client sends, so domain_suffix and geosite rules can still match it.

Property Value
Default on; turn it off with disable_sniffing = true in [node.controller]
Flows sniffed TCP flows whose destination is an IP address; a flow that already names a domain is routed by that name without waiting
What is read the SNI of a TLS ClientHello, then the host of an HTTP request (an absolute request URI, else the Host header)
Budget the first 4 KiB, collected for at most 300 ms; the flow opens as soon as a name is found, the 4 KiB are in, or the 300 ms have passed. A mux sub-flow is sniffed only from the bytes in the frame that opens it, without waiting
Used by domain_suffix and geosite only, in addition to the requested destination

Sniffing is a routing hint and nothing else:

  • katana still dials the IP address the client asked for. The sniffed name never replaces the destination.
  • Audit rules match the destination as the client addressed it, not the sniffed name.
  • An IP literal in the SNI or Host is ignored, as are names with characters a DNS name cannot contain.
  • A payload that is neither TLS nor HTTP, or arrives too late, leaves the flow unsniffed; it is routed on its destination as if sniffing were off. Sniffing never fails a flow.

Changing disable_sniffing on a running node rebuilds that node’s listener at once, which drops every connection on the node. Every flow the new listener accepts follows the new value.

TCP and UDP reach the router in different shapes, so a block behaves differently:

TCP flow UDP association
Routed once, when the flow opens per packet, on each packet’s own destination
Sniffed yes, when addressed by IP never
Blocked by the route or an audit rule refused before anything is dialed that packet is dropped; the association stays open
Billing when blocked nothing the dropped packet is not billed
Outbound cannot carry UDP not applicable the packet is dropped

A UDP association has no single destination, because every datagram carries its own. katana therefore routes, audits and bills each packet on its way out, and one association can talk to several outbounds at once. It opens one link per outbound an association uses, keeps at most 64 of them open, and closes the least recently used one when it needs another. A packet whose outbound link fails to open is dropped, so one unreachable upstream does not stall the association.

The http and Shadowsocks outbounds carry no datagrams. UDP packets routed to them are dropped, so route UDP traffic you care about to direct, SOCKS, VMess, VLESS or WireGuard.

The snippets below show only the tables that matter for each case, not a complete file. Paths, addresses, keys and tags are placeholders.

[node.route]
geosite = "/etc/katana/geosite.dat"
[[node.route.rule]]
outbound = "block"
geosite = ["category-ads-all"]

Sniffing lets this rule also catch a client that dials an ad server by IP, as long as the TLS SNI or HTTP Host it sends names a listed domain.

Define the WireGuard outbound once at the top level of the file, then route to its tag from any node:

[[outbound]]
tag = "wg"
protocol = "wireguard"
server = "203.0.113.10"
port = 51820
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # generate with: wg genkey
public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" # the peer's public key
local_address = ["10.8.0.2/32"]
# ... [[node]], [node.api] ...
[node.route]
geosite = "/etc/katana/geosite.dat"
[[node.route.rule]]
outbound = "wg"
geosite = ["netflix"]
domain_suffix = ["video.example.com"]

Everything else keeps going direct. Outbounds documents every WireGuard key.

Keep users from reaching this host’s own network, loopback and link-local addresses when they address them by IP:

[[node.route.rule]]
outbound = "block"
cidr = [
"127.0.0.0/8", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16",
"169.254.0.0/16", "100.64.0.0/10",
"::1/128", "fc00::/7", "fe80::/10",
]

Both variants match only destinations the client addressed by IP, as What each matcher looks at explains.

Order matters: the block rules come first, so a private address is refused even if a later rule would have matched it.

/etc/katana/config.toml
[[outbound]]
tag = "wg"
protocol = "wireguard"
server = "203.0.113.10"
port = 51820
private_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
local_address = ["10.8.0.2/32"]
[[node]]
panel_type = "NewV2board"
[node.api]
host = "https://panel.example.com"
node_id = 1
key = "replace-with-the-panel-key"
node_type = "V2ray"
[node.route]
default = "direct"
geoip = "/etc/katana/geoip.dat"
geosite = "/etc/katana/geosite.dat"
[[node.route.rule]]
outbound = "block"
geosite = ["category-ads-all"]
[[node.route.rule]]
outbound = "block"
geoip = ["private"]
[[node.route.rule]]
outbound = "wg"
geosite = ["netflix"]
[[node.route.rule]]
outbound = "block"
port = ["25", "465", "587"]

katana watches its config file. When you save a change to a node’s [node.route], that node compiles the new rules before it changes anything. If they compile, it switches to the new router, runs one panel poll at once and rebuilds its listener, which drops every connection on that node. A node that is still coming up checks the new rules the same way, stores them and retries its start at once. A change to any [[outbound]] recompiles every node’s router and drops connections on every node. Hot reload lists what each kind of edit does.

katana compares the configuration, not the geodata files. Replacing geosite.dat or geoip.dat on disk under the same path does not recompile any router. A node reads the new file the next time its router is compiled: on a [node.route] edit, on an [[outbound]] edit, or at restart.

If the edited route of a running node does not compile, the node refuses the edit and logs node <id>: config edit refused, keeping the running one: <error>. It keeps its previous rules and its listener, so no connection drops. It also applies nothing else from that node’s edit: fix the route and save again to apply the rest. The other nodes in the file still take their own edits.

route rebuild failed, keeping current appears only when an [[outbound]] edit breaks rules you did not change, for example when you remove an outbound that a rule still names. The node then keeps routing with its previous rules and the previous outbound, but it still rebuilds its listener, so its connections drop. If the new [[outbound]] entries do not build, katana logs reload: bad outbounds, keeping current config and applies nothing from that edit.

A node that a reload adds, or that katana starts afresh because its panel identity changed, must build before katana applies anything. If its route does not compile, katana logs reload: node <node>: build router: <error>; keeping current config, where <node> names the node by panel type, panel host, node ID and, on NewV2board and V2board, the node type katana asks for. Nothing from that reload is applied, and every node keeps running as it was.

At startup, a route that does not compile keeps only that node from starting: katana logs node <id>: build router: with the reason and runs the other nodes. If no node can start, katana exits with no nodes could be started. Run katana --test before you save.

Message Cause Fix
route references unknown outbound tag: wg A rule’s outbound or the route default names a tag that is not in the pool. Define [[outbound]] with that tag, or fix the spelling. Tags are case-sensitive.
a geosite matcher is used but no geosite file is configured A rule uses geosite but [node.route] has no geosite path. Add geosite = "/etc/katana/geosite.dat" to this node’s [node.route]. Each node needs its own path.
a geoip matcher is used but no geoip file is configured The same for geoip. Add a geoip path.
geosite code not found: <code> The file has no list with that name. A ! prefix is not supported for geosite. Check the code against the file’s source list.
geoip code not found: <code> The file has no list with that name. Check the code; the message shows it without !.
No such file or directory (os error 2) A referenced geodata path does not exist. The message does not name the file. Check the geoip and geosite paths of the node you changed.
geosite decode: failed to decode Protobuf message: … The file is not a geosite list, for example a geoip.dat in the geosite key. Swap the paths or download the right file.
invalid cidr "192.0.2.1/24": host part of address was not zero Host bits are set in a cidr entry. Write the network address, 192.0.2.0/24, or a single host, 192.0.2.1.
invalid port spec: "http" A port entry is not a number or range. Use numbers such as "80" or "8000-8100".
invalid type: integer `80`, expected a string A port entry is a TOML integer. Quote it: port = ["80"].
unknown field, expected one of … A key katana does not accept, such as domain or domain_keyword, or rules for rule. Use one of the keys listed on this page.

A rule that never fires usually has one of these causes: an earlier rule matches first, the rule is an IP rule and the client addresses a domain, the geosite attribute tags nothing, or the domain entry starts with a dot.