路由模型
源码文件:28 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/environment/src/lib.rsEtemenanki/environment/src/routing.rsEtemenanki/environment/tests/unit/routing.rsEtemenanki/app/src/router.rsEtemenanki/app/src/config.rsEtemenanki/app/src/instance.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/flow.rsEtemenanki/app/src/outbound/mod.rsEtemenanki/app/src/outbound/udp_fanout.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/sniff/mod.rsEtemenanki/concepts/src/sniff.rsEtemenanki/app/tests/unit/router.rsEtemenanki/app/tests/unit/config.rsEtemenanki/app/tests/integration/e2e_route_context.rsEtemenanki/app/tests/integration/e2e_sniff.rsEtemenanki/app/tests/integration/e2e_udp_route.rskatana/src/router.rskatana/src/config.rskatana/src/connector.rskatana/src/runtime.rskatana/src/manager/node.rskatana/tests/unit/connector.rskatana/tests/unit/e2e.rskatana/tests/integration/sniff.rs
路由模型决定一条流走哪个出站。它位于 etemenanki-environment 中,模块名为 etemenanki_environment::routing(environment/src/routing.rs),有两个消费方:独立运行的 app(app/src/router.rs)和 katana(src/router.rs)。两者各自把自己的规则表(app 中是 [[route.rule]],katana 中是 [[node.route.rule]])编译成同一种 RouteTable,并调用同一个 pick。
如果你要新增匹配条件、修改域名的比较方式、改动 geodata 加载器,或者修改消费方传给路由器的上下文,请先读本页。同一套规则在运维视角下的说明见 Etemenanki 路由和 katana 路由。
本模块负责四件事:
- 匹配。
RouteTable::pick按顺序遍历规则列表,返回第一条至少有一个匹配条件命中的规则的输出;都不命中时返回默认输出。 - 加载 geodata。
build_geo_data解码 v2ray/Xray 的geoip.dat和geosite.dat文件(protobuf 格式,通过prost),只保留规则表引用到的代码。 - 解析辅助函数。
parse_port_match和parse_domain_regex把配置字符串转换为经过校验的匹配条件,使消费方在构建规则表时就能拒绝错误输入。 - 组合。
Router把一张规则表和它的GeoData打包在一起。消费方每个 generation(一代实例)持有一个Router。
以下事情刻意留给调用方处理:
| 不在本模块 | 所在位置 |
|---|---|
配置格式。本模块从不接触 TOML;每个消费方把自己的规则结构体映射为 RouteMatch 值。 |
app 的 app/src/router.rs → build_router,katana 的 src/router.rs → build_router |
解析出站 tag。RouteTable 对 Out 泛型,存储的是 Arc<Out>。 |
各消费方的 build_router |
| DNS。域名目标只按名字匹配,从不解析,因此 CIDR 或 geoip 规则永远看不到某个域名实际要拨号的地址。 | 出站,见拨号器与 socket 策略 |
| 嗅探。本模块接收嗅探到的域名,但从不检查载荷。 | protocols crate,见嗅探 |
| UDP fan-out。本模块一次只路由一个目标;把一个关联按包拆分是消费方的工作。 | app 的 app/src/outbound/udp_fanout.rs → FanOutLink,katana 的 src/connector.rs → FanOut |
pick 是目标、规则表和 geodata 的纯函数。除了在 build_geo_data 中读取两个 .dat 文件之外,本模块不做任何 I/O,不 spawn 任务,不持有锁,也没有 async 代码。它继承 environment/src/lib.rs 中整个 crate 的 lint 门槛:#![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)],只在 cfg(test) 下放宽。
Remote 与 TargetNetwork
Section titled “Remote 与 TargetNetwork”pub enum Remote<'a> { IpAddr(IpAddr), Domain(&'a str),}
pub enum TargetNetwork { Tcp, Udp,}Remote 是目的地址的借用视图。它与 etemenanki_concepts::net::Remote 解耦,使路由模型不对消费方的网络类型做任何假设;每个消费方在一个 route_target 函数中转换自己的 Destination。Remote derive 了 Debug, Clone, Copy;TargetNetwork 另外还有 PartialEq, Eq。
RouteTarget
Section titled “RouteTarget”pub struct RouteTarget<'a> { pub remote: Remote<'a>, pub port: u16, pub network: Option<TargetNetwork>, pub source: Option<IpAddr>, pub sniffed_domain: Option<&'a str>, pub inbound_tag: Option<&'a str>,}
impl<'a> RouteTarget<'a> { pub fn new(remote: Remote<'a>, port: u16) -> Self; pub fn with_network(mut self, network: TargetNetwork) -> Self; pub fn with_source(mut self, source: IpAddr) -> Self; pub fn with_sniffed_domain(mut self, domain: &'a str) -> Self; pub fn with_inbound_tag(mut self, tag: &'a str) -> Self;}remote 和 port 是每个请求都有的;new 把四个 Option 字段设为 None,每个 with_* builder 设置其中一个。RouteTarget derive 了 Debug, Clone, Copy,因此在热路径上按值传递。这些字段是公开的,但消费方用 new 和 builder 构造目标,这样以后新增字段时不会破坏它们。
| 字段 | 读取方 | 为 None 时 |
|---|---|---|
remote |
所有域名匹配条件(当它是 Domain 时),Cidr 和 GeoIp(当它是 IpAddr 时) |
不会为空,总是设置 |
port |
PortRange |
不会为空,总是设置 |
network |
Network |
Network 匹配条件不匹配 |
source |
SourceCidr |
SourceCidr 匹配条件不匹配 |
sniffed_domain |
所有域名匹配条件,包括 GeoSite |
只尝试 remote |
inbound_tag |
InboundTag |
InboundTag 匹配条件不匹配 |
支撑整个模型的规则是:字段未提供时,读取它的匹配条件不匹配。 本模块从不猜测网络、来源或域名。代价是,一个未提供的字段会让规则悄无声息地永远不生效,因此 app 有端到端测试,证明其配置接受的每个上下文字段都确实传到了路由器(见测试)。
RouteMatch 与 DomainRegex
Section titled “RouteMatch 与 DomainRegex”pub enum RouteMatch { GeoSite(CompactString), DomainSuffix(CompactString), DomainKeyword(CompactString), DomainFull(CompactString), DomainRegex(DomainRegex), GeoIp(CompactString), Cidr(IpCidr), SourceCidr(IpCidr), PortRange(u16, u16), Network(TargetNetwork), InboundTag(CompactString),}RouteMatch derive 了 Debug, Clone, PartialEq, Eq。私有函数 matches(m, target, domains, geo) 负责求值一个变体。每个变体只读取目标中与自己相关的部分;域名匹配条件同时读取请求域名和嗅探到的域名:
| 变体 | 读取 | 匹配条件 |
|---|---|---|
DomainSuffix(parent) |
请求域名和嗅探域名 | 任一域名等于 parent 或是它在标签边界上的子域名(见后缀匹配) |
DomainKeyword(needle) |
请求域名和嗅探域名 | 任一域名包含子串 needle |
DomainFull(name) |
请求域名和嗅探域名 | 任一域名等于 name |
DomainRegex(re) |
请求域名和嗅探域名 | re 匹配任一域名(不锚定的 is_match) |
GeoSite(code) |
请求域名和嗅探域名 | 以 code 加载的 DomainSet 匹配任一域名 |
Cidr(c) |
remote,仅当它是 IP 时 |
c.contains(&ip) |
GeoIp(code) |
remote,仅当它是 IP 时 |
以 code 加载的 CidrSet 匹配该 IP |
SourceCidr(c) |
source |
source 已设置且 c.contains(&source) |
PortRange(lo, hi) |
port |
port >= lo && port <= hi,两端都包含 |
Network(want) |
network |
network == Some(want) |
InboundTag(tag) |
inbound_tag |
inbound_tag 已设置且等于 tag |
regex::Regex 既没有实现 PartialEq 也没有实现 Eq,所以正则变体对它做了一层包装:
#[derive(Debug, Clone)]pub struct DomainRegex(regex::Regex);
impl DomainRegex { pub fn new(pattern: &str) -> io::Result<Self>; pub fn as_str(&self) -> &str;}
impl PartialEq for DomainRegex { /* self.0.as_str() == other.0.as_str() */ }impl Eq for DomainRegex {}相等性按源 pattern 定义:两个正则匹配条件的写法相同,它们就相等。DomainRegex::new 编译 pattern,失败时返回 io::ErrorKind::InvalidInput,消息为 invalid domain regex {pattern:?}: {e}。内部的 Regex 是私有的,所以获得 DomainRegex 的唯一途径是 new,编译总是发生在构建规则表时,而不是匹配时。
RouteItem 与 RouteTable
Section titled “RouteItem 与 RouteTable”pub struct RouteItem<Out> { pub matchers: SmallVec<[RouteMatch; 3]>, pub output: Arc<Out>,}
pub struct RouteTable<Out> { pub routes: Vec<RouteItem<Out>>, pub default: Arc<Out>,}
impl<Out> RouteTable<Out> { pub fn pick(&self, target: RouteTarget<'_>, geo: &GeoData) -> Arc<Out>;}一个 RouteItem 就是一条规则:对其匹配条件取 OR。最多三个匹配条件内联存放在 SmallVec 中,更长的规则会溢出到堆上。输出是 Arc<Out>,所以 pick 返回的是引用计数克隆,同一个出站可以同时作为多条规则的输出和默认输出。这两个类型都没有 derive 任何 trait,Out 上也没有任何约束。
Router
Section titled “Router”pub struct Router<Outbound> { table: RouteTable<Outbound>, geo: GeoData,}
impl<Outbound> Router<Outbound> { pub fn new(table: RouteTable<Outbound>, geo: GeoData) -> Self; pub fn route(&self, target: RouteTarget<'_>) -> Arc<Outbound>; pub fn default_outbound(&self) -> Arc<Outbound>;}route 就是 self.table.pick(target, &self.geo)。default_outbound 返回规则表默认输出的一个克隆。它的文档注释仍然说它是数据报关联使用的路由,但在本页固定的版本中,两个消费方都没有调用它:二者都通过 fan-out 按包路由 UDP(见 UDP 逐包路由)。
每个消费方都为它定义了一个本地别名:
// etemenanki-app, app/src/router.rspub type Router = routing::Router<Outbound>;
// katana, src/router.rspub type Router<Outbound> = routing::Router<Outbound>;解析辅助函数
Section titled “解析辅助函数”pub fn parse_port_match(s: &str) -> io::Result<RouteMatch>;pub fn parse_domain_regex(pattern: &str) -> io::Result<RouteMatch>;parse_port_match 接受 "80" 或 "1000-2000":
- 它在第一个
-处拆分,并去掉两侧的空白,因此" 1000 - 2000 "也被接受。 - 每个边界都必须能解析为
u16。"70000"和"nope"会失败,报invalid port spec: "70000"(输入用{:?}加引号)。 - 下界大于上界时失败,报
invalid port spec: "2000-1000" has a lower bound above its upper bound。否则反向的范围会被编译成一条永远无法匹配的规则,与一条从未命中的规则无从区分。 - 单个端口
p变为PortRange(p, p),"80-80"也被接受,结果相同。
所有错误都是 io::ErrorKind::InvalidInput。parse_domain_regex 就是 DomainRegex::new(pattern).map(RouteMatch::DomainRegex)。
pick 如何决策
Section titled “pick 如何决策”flowchart TD
start(["pick(target, geo)"]) --> fold["Domains::of(target):把请求域名和嗅探域名各转小写一次"]
fold --> next{"routes 中还有规则吗?"}
next -- "没有" --> def(["return default.clone()"])
next -- "有,规则 i" --> any{"规则 i 有任一匹配条件命中?"}
any -- "是,在第一个处停止" --> out(["return routes[i].output.clone()"])
any -- "否" --> next
RouteTable::pick 中的循环直接带来三条性质:
- 第一条规则胜出。 循环在第一次命中时返回,所以规则顺序是唯一的优先级。放在前面的宽泛规则会遮蔽后面所有规则。
- 规则内任一匹配条件即可。
item.matchers.iter().any(...)让一条规则成为其匹配条件的 OR,模型中没有 AND。一条同时带domain_suffix和port的规则,会匹配满足其中任意一个的流,而不是要求两者都满足。 - 默认输出兜底。 没有规则匹配时,
pick返回self.default.clone()。它不会失败,也从不返回“无路由”。
matchers 为空的规则永远不匹配,因为对空集合做 any 结果是 false。两个消费方都接受这种规则(只有 outbound = "block" 的 [[route.rule]] 能通过 --test),它不起任何作用。兜底应写在 default 中。
pick 每次调用时通过一个私有辅助类型把目标的域名转为小写一次:
struct Domains { request: Option<String>, sniffed: Option<String>,}Domains::of 在 remote 为 Remote::Domain(d) 时把 request 设为 d.to_ascii_lowercase(),为 IP 时设为 None,并把 sniffed 设为小写后的 sniffed_domain。之后每个域名匹配条件都通过 Domains::any,先尝试 request,再尝试 sniffed。每次 pick 只转换一次,取代了此前逐个匹配条件转换的做法,后者在遍历经过的每条域名规则上都会分配一个新的 String。
这里只转换目标一侧。规则一侧必须已经是小写:
- app 在构建规则表时用
to_ascii_lowercase把domain_suffix、domain_keyword和domain_full转为小写;katana 把domain_suffix转为小写,这是它唯一的域名键。 build_domain_set在构建匹配器之前把每个 geosite 值转为小写。- 手工构造的、含大写字母的
RouteMatch::DomainSuffix永远不会匹配任何东西。
转换只针对 ASCII:to_ascii_lowercase 保留所有非 ASCII 字节,因此名字的其余部分严格按请求或载荷携带的形式比较。
嗅探到的域名
Section titled “嗅探到的域名”当请求本身只指定了 IP 时,sniffed_domain 携带从载荷中恢复的名字,即 TLS SNI 或 HTTP Host。所有域名匹配条件,包括 GeoSite,除请求域名外都会尝试它。这是以域名写成的规则匹配按 IP 寻址的流的唯一途径。Cidr 和 GeoIp 忽略嗅探到的域名,只读 remote,所以按 IP 寻址的流仍按其地址匹配。
protocols crate 只在入站开启嗅探且 worth_sniffing 成立,也就是目的地址是裸 IP 时,才填充 Flow::sniffed(一个 Option<SniffedBehavior>,其 domain 为 CompactString);已经指定域名的目的地址按该域名路由。两个消费方都把 s.domain.as_str() 传给 with_sniffed_domain。载荷如何检查见嗅探。
DomainSuffix 和 geosite 的 type 2(Domain)条目共用一个不分配内存的辅助函数:
fn is_subdomain_of(d: &str, parent: &str) -> bool { match d.len().checked_sub(parent.len()) { Some(0) => d == parent, Some(n) => d.ends_with(parent) && d.as_bytes().get(n.saturating_sub(1)) == Some(&b'.'), None => false, }}当 d 比 parent 长时,它必须以 parent 结尾,并且后缀前面的那个字节必须是 .:这是标签边界检查。它取代了 ends_with(&format!(".{parent}")),后者每次调用都要分配内存。checked_sub 和 get 让它符合 crate 的无 panic lint 门槛。
d |
parent |
结果 | 原因 |
|---|---|---|---|
example.com |
example.com |
匹配 | 长度相同且相等 |
mail.example.com |
example.com |
匹配 | 以它结尾,前面是 . |
notexample.com |
example.com |
不匹配 | 前面是 t,不在标签边界上 |
example.com.example.net |
example.com |
不匹配 | 不以它结尾 |
com |
example.com |
不匹配 | 比 parent 短 |
a.example.com |
.example.com |
不匹配 | .example.com 前面的字节是 a |
example.com. |
example.com |
不匹配 | 末尾的点属于 d |
最后两行对写配置的人很重要。带前导点的后缀,或者写成带末尾点的完全限定名的目标,都无法匹配常见的写法:本模块两侧都不去掉点。
IP、来源、端口、网络与入站标签
Section titled “IP、来源、端口、网络与入站标签”Cidr和GeoIp只在remote是 IP 时匹配。域名目标在查询任何集合之前就返回false,所以取反的GeoIp("!code")也不匹配域名目标。cidrcrate 的IpCidr::contains严格区分地址族:IPv4 范围永远不包含 IPv6 地址,IPv4 映射地址也不例外。SourceCidr读取source,从不读取remote。没有来源的目标会让规则失效,即使其目的地址落在范围内。PortRange两端都包含。Network与Option<TargetNetwork>比较,InboundTag与Option<&str>比较,所以在消费方提供该字段之前,二者都不起作用。两个消费方都映射了DialNetwork::Tcp和DialNetwork::Udp,对DialNetwork::Unknown和DialNetwork::Unix不提供网络。
GeoData 及其集合
Section titled “GeoData 及其集合”#[derive(Default)]pub struct GeoData { pub domain: HashMap<CompactString, DomainSet>, pub ip: HashMap<CompactString, CidrSet>,}
pub struct DomainSet { matchers: Vec<DomainMatcher>,}
pub struct CidrSet { cidrs: Vec<IpCidr>, reverse: bool,}两个 map 都以规则中写的原始匹配字符串为键,例如 "cn"、"google@ads" 或 "!cn"。RouteMatch::GeoSite(raw) 和 RouteMatch::GeoIp(raw) 用同一个字符串查找自己。DomainSet 和 CidrSet 的字段是私有的,所以在模块外部,build_geo_data 是填充它们的唯一途径;消费方仍然可以传入 GeoData::default()。被引用但不在 map 中的代码永远不匹配;只有当消费方构造 RouteMatch 值而没有经过 build_geo_data 时才会出现这种情况。
DomainSet::matches 是对其匹配器取 OR。CidrSet::matches 是 self.cidrs.iter().any(|c| c.contains(&ip)) ^ self.reverse,所以取反的集合匹配条目以外的所有地址;即使条目只列出 IPv4 范围,IPv6 地址也包括在内。
.dat 消息
Section titled “.dat 消息”protobuf 消息是 derive 了 prost::Message 的公开结构体,并刻意保持最小:prost 会跳过消息未声明的字段,所以新版 .dat 文件中多出的字段会被忽略。
| 消息 | 字段 | Tag | 类型 |
|---|---|---|---|
GeoIpList |
entry |
1 | repeated GeoIp |
GeoIp |
code |
1 | string |
cidr |
2 | repeated Cidr |
|
Cidr |
ip |
1 | bytes,4 或 16 字节 |
prefix |
2 | uint32 |
|
GeoSiteList |
entry |
1 | repeated GeoSite |
GeoSite |
code |
1 | string |
domain |
2 | repeated Domain |
|
Domain |
type(r#type) |
1 | int32 |
value |
2 | string |
|
attribute |
3 | repeated Attribute |
|
Attribute |
key |
1 | string;属性的值未声明 |
消息 GeoIp 和变体 RouteMatch::GeoIp 同名,但是互不相关的类型。
geosite 的 Domain.type 会变为一个私有的 DomainMatcher:
type |
DomainMatcher |
匹配条件 |
|---|---|---|
0 |
Substr |
域名包含该值 |
1 |
Regex |
编译后的值匹配该域名 |
2 |
Domain |
is_subdomain_of(domain, value) |
3 |
Full |
域名等于该值 |
| 其他值 | 无 | 跳过该条目并记录警告 |
build_geo_data
Section titled “build_geo_data”pub fn build_geo_data( geoip_path: Option<&Path>, geosite_path: Option<&Path>, geosite_refs: &[CompactString], geoip_refs: &[CompactString],) -> io::Result<GeoData>;消费方收集其规则用到的每个 geosite 和 geoip 字符串,作为 geosite_refs 和 geoip_refs 传入。先处理 geosite,再处理 geoip;每一类的步骤如下:
- 如果引用列表为空,即使配置了路径也不会打开文件。一个配置了不存在的
geoip文件、但没有使用任何geoip匹配条件的配置能通过--test。 - 否则必须配置路径,不然调用失败,报
a geosite matcher is used but no geosite file is configured(geoip 同理)。 - 用
std::fs::read读入整个文件,解码一次为GeoSiteList或GeoIpList。 - 逐个解析尚未在 map 中的原始引用;重复的原始字符串会被跳过。
- geosite:
code@attr在第一个@处拆分。用eq_ignore_ascii_case按code查找条目。带属性时,只保留带有key与之相等(忽略 ASCII 大小写)的Attribute的域名。前导!在这里没有含义:"!cn"会被按字面查找,并以未找到失败。 - geoip: 前导
!会设置reverse并被去掉。按剩余的代码查找条目,忽略 ASCII 大小写。
- geosite:
- 当该类的引用都解析完后,解码出的列表即被丢弃,所以只有被引用代码的集合留在内存中。
代码查找不区分大小写,但 map 的键是原始字符串。两条规则中的 "cn" 和 "CN" 会解析到同一个条目,但会以两个键构建两次。
两类数据对坏条目的处理不同:
- geosite 条目会被跳过并记录
tracing::warn!:无法编译的正则(skipping invalid geosite regex {value:?}: {e})和未知类型(skipping unknown geosite domain type {other})。社区列表中的一行错误不会让整个代码失效。 - geoip 条目出错是硬错误,因为
build_cidr_set会校验每个 CIDR:- IP 必须是 4 或 16 字节(
geoip cidr: ip must be 4 or 16 bytes, got {n}); - 前缀必须能放进
u8(geoip cidr: prefix too large); IpCidr::new必须接受这对值,它会拒绝比地址更长的前缀以及任何置位的主机位(geoip cidr: {e})。
- IP 必须是 4 或 16 字节(
各消费方如何编译配置
Section titled “各消费方如何编译配置”两个消费方都提供同一对函数:build_router 根据已构建的出站 map 编译路由配置,route_target 把一条流转换为 RouteTarget。
pub fn build_router( cfg: &crate::config::RouteConfig, outbounds: &HashMap<CompactString, Arc<Outbound>>, default_tag: &CompactString,) -> io::Result<Router>;
pub fn route_target<'a>(flow: &'a Flow, ctx: &'a FlowContext) -> routing::RouteTarget<'a>;app/src/instance.rs → build 在启动、重载和 --test 试运行时都以同样方式调用 build_router。default_tag 是第一个 [[outbound]] 的 tag(没有出站的配置会更早失败,报 config defines no outbounds);[route].default 会覆盖它。outbounds map 已经把每个 [[balancer]] 包含为一个 Outbound::Balanced,所以规则或默认输出都可以指向负载均衡器;负载均衡器在流连接时挑选健康成员,而不是在路由时。
route_target 提供全部上下文字段:
- 嗅探到的域名来自
flow.sniffed; - 入站 tag 来自
FlowContext::inbound_tag; - 来源为
flow.source.or(ctx.source),所以流自身的来源(QUIC 客户端、TUN 主机)优先于监听器在 accept 时记录的对端; - 网络来自
flow.destination.network。
FlowContext 在 app/src/serve.rs 中每个被接受的连接构建一次;Hysteria 2 和 TUN 入站则按对端以 source: Some(ip) 构建。
pub fn build_router<Outbound>( cfg: &crate::config::RouteConfig, outbounds: &HashMap<CompactString, Arc<Outbound>>, default_tag: &CompactString,) -> io::Result<Router<Outbound>>;
pub fn route_target<'a>( dest: &'a Destination, sniffed: Option<&'a SniffedBehavior>, source: Option<IpAddr>,) -> routing::RouteTarget<'a>;build_router 对出站类型泛型。katana 在四个地方以 default_tag 为 "direct" 调用它:
NodeManager::new,启动时为每个节点调用;重载时经由apply_reload预构建阶段中的build_node,为重载新增的每个节点调用;NodeManager::apply_static,用于运行中节点的[node.route]修改;NodeManager::rebuild_router,在[[outbound]]出站池变化之后调用;src/runtime.rs中的test_config,用于--test试运行。
build_outbounds 总是先在出站池中放入保留 tag direct 和 freedom(都是 FreedomConnector)以及 block 和 blackhole(都是 Outbound::Block),并拒绝复用这些 tag 的已配置出站。
route_target 提供嗅探到的域名、来源和网络。katana 从不提供入站 tag,其配置中也没有能产生 InboundTag 匹配条件的键。
每个规则字段按固定顺序变成匹配条件。由于规则是 OR,同一条规则内部的顺序不影响结果,只影响哪个匹配条件先短路。
| 配置键 | RouteMatch |
编译方式 | app | katana |
|---|---|---|---|---|
domain_suffix |
DomainSuffix |
to_ascii_lowercase |
是 | 是 |
domain_keyword |
DomainKeyword |
to_ascii_lowercase |
是 | 否 |
domain_full |
DomainFull |
to_ascii_lowercase |
是 | 否 |
domain_regex |
DomainRegex |
routing::parse_domain_regex |
是 | 否 |
cidr |
Cidr |
str::parse::<IpCidr> |
是 | 是 |
source_cidr |
SourceCidr |
str::parse::<IpCidr> |
是 | 否 |
inbound_tag |
InboundTag |
原样使用 | 是 | 否 |
network |
Network |
parse_network:只接受 "tcp" 或 "udp" |
是 | 否 |
port |
PortRange |
parse_port_match |
是 | 是 |
geosite |
GeoSite |
同时加入 geosite_refs |
是 | 是 |
geoip |
GeoIp |
同时加入 geoip_refs |
是 | 是 |
两个消费方的 RouteConfig 和 RuleConfig 都带有 #[serde(deny_unknown_fields)]。因此带 domain_keyword 的 katana 规则会解析失败(unknown field `domain_keyword`, expected one of `outbound`, `domain_suffix`, `cidr`, `port`, `geosite`, `geoip` ),而不是被忽略。
编译步骤有几个容易忽略的后果:
cidr和source_cidr的值经由cidrcrate 解析,它会拒绝置位的主机位:"192.0.2.1/24"失败,报invalid cidr "192.0.2.1/24": host part of address was not zero。不带前缀的值,例如"192.0.2.1",解析为单个主机(/32,IPv6 为/128)。network是一个字符串而不是列表,所以一条规则最多只能要求一种传输协议;"tcp,udp"失败,报invalid rule network "tcp,udp" (expected "tcp" or "udp")。inbound_tag的值不会与已配置的入站核对。指向不存在入站的 tag 会产生一个不起作用的匹配条件。- geodata 在所有规则编译完、所有出站 tag 解析完之后才加载,所以未知 tag 或错误端口会在读取
.dat文件之前报告。
路由器存放在哪里
Section titled “路由器存放在哪里”| etemenanki-app | katana | |
|---|---|---|
| 持有方式 | Built 中的 Arc<Router>,克隆到每个 AppConnector 和 FanOutLink 中 |
NodeManager 中的 parking_lot::Mutex<Arc<Router<Outbound>>>,以及每一代监听器 Dispatcher 中的 Arc<Router<Outbound>> |
| 何时重建 | 重载构建新的 generation 时 | 节点的 route 配置或全局出站池变化时 |
| 启动时构建失败 | 进程在 failed to start: {e} 之后退出 |
spawn_node 记录 node {id}: build router: {e} 并跳过该节点,其他节点照常启动 |
| 重建失败 | reload 记录 reload: build failed, keeping current config: {e},旧 generation 继续服务,见 Generation 与重载 |
取决于触发构建的原因,见下方列表 |
| geodata | 每个 generation 解码一次 | 每个节点各自解码,每次重建时再解码一次 |
katana 路由器在启动之后编译失败时,结果取决于触发构建的原因:
- 重载新增的节点。
apply_reload在触碰任何运行中的节点之前,先构建重载新增的每个节点(包括其路由器)。没有任何运行中节点具有其身份的节点都算新增:新的[[node]]条目、身份发生变化的条目,或启动时构建失败的条目。因此这样的条目在每次重载时都会被重新构建,在它构建成功或被移除之前,每次重载都会被拒绝。只要有一个失败,它就记录reload: node {display_id}: build router: {e}; keeping current config,新配置中的任何内容都不会生效:日志级别、出站池和所有节点都保持原样。{display_id}的格式是<panel_type>@<host>#<node_id>,其中面板类型为小写;在 newV2board 上后面还会跟/<type>,即向 UniProxy 请求时使用的节点类型(小写的node_type,或在 V2ray 系节点上设置了enable_vless时为vless),因为 newV2board 同时按类型和 id 查找节点。见进程运行时与重载。 [node.route]修改。apply_static在保存这次修改的任何部分之前,先用当前出站池编译新路由。如果编译失败,它记录node {id}: config edit refused, keeping the running one: {e},并拒绝该节点的整个修改,包括与路由一起修改的其他字段。运行中的路由器和传输层保持不变,因此不会断开任何连接。编译成功的修改会替换路由器;节点已启动时,还会强制进行一次面板轮询,从而重建该节点的传输层。仍在启动中的节点会在下一次尝试时用新路由器构建它的第一个监听器。[[outbound]]出站池变化。rebuild_router用新的出站池重新编译节点未变的路由。如果失败,它记录node {id}: route rebuild failed, keeping current: {e}并保留原路由器。之后节点的传输层仍会重建,所以即使路由未变,现有连接也会断开;见节点管理器。
fan-out 以出站的 Arc 指针作为其身份标识。这个指针是稳定的,因为路由器在整个生命周期内都持有它的出站。
UDP 逐包路由
Section titled “UDP 逐包路由”UDP 关联没有单一的目的地址:每个数据报都携带自己的目的地址。如果把关联作为一个整体路由,所有对端的流量都会发往同一个出站,UDP 也就不受路由规则约束。因此两个消费方都把 UDP 流转换为 fan-out link,而不是直接拨号:
- app:当
flow.destination.network == DialNetwork::Udp时,AppConnector::connect返回Outbound::Datagram(FanOutLink::new(router, ctx, flow)); - katana:在同样的判断下,
KatanaConnector::connect先对用户做准入,然后返回一个FanOut。
每次 poll_send_to 都按该包自身的目的地址路由:
- app 由
self.flow.toward(to.clone())构建目标。Flow::toward保留用户和来源,但把sniffed设为None;入站 tag 仍由FlowContext提供。 - katana 调用
route_target(to, None, self.source),来源在关联准入时就已固定。
逐包路由永远看不到嗅探到的域名:只有当 UDP 包本身按名字寻址时,域名规则才会匹配它。
sequenceDiagram
participant RT as 连接运行时
participant F as FanOutLink
participant R as Router
participant O as Outbound
participant S as 子 link
RT->>F: poll_send_to(buf, to)
F->>R: route(route_target(flow.toward(to), ctx))
R-->>F: 出站的 Arc,key = Arc::as_ptr
alt 已存在该 key 的子 link
F->>S: poll_send_to(buf, to)
Note over F: Ok 时把该子 link 移到末尾
else 当前没有正在打开的出站
F->>O: connect_datagram(flow.toward(to))
Note over F: opening = Some((key, future)),然后继续循环
else 正在打开的就是这个 key
F->>F: poll_opening,然后发送;打开失败则丢弃该包
else 正在打开另一个 key
F->>F: poll_opening,在其完成前返回 Pending
end
RT->>F: poll_recv_from(buf)
F->>F: poll_opening,推进进行中的拨号
F->>S: poll_recv_from,从 next 开始轮询
S-->>RT: 载荷和 from
fan-out 保存以下字段(katana 的 FanOut 另有 disp、gate、uid 和 source):
| 字段 | 类型 | 作用 |
|---|---|---|
subs |
Vec<Sub> |
每个出站一个子 link;最近发送过的位于末尾 |
next |
usize |
上次接收停下的位置,让每个子 link 都能轮到 |
opening |
Option<(usize, OpenFuture)> |
至多一个正在打开的出站及其 key(katana 中为 DatagramFuture) |
recv_waker |
Option<Waker> |
挂起的接收方,有新的子 link 加入时被唤醒 |
修改之前需要记住的行为:
- 子 link 按出站划分,而不是按对端。 key 是
Arc::as_ptr(outbound) as usize,所以路由到同一出站的所有对端共享一个子 link,负载均衡器算作一个出站。 MAX_SUBS = 64。 新的子 link 让表超过 64 个时,最久未发送的那个(subs[0])会被丢弃,next相应减一。- 一次只打开一个。 有打开操作进行中时,发往其他出站的包返回
Pending,由运行时保留在队列中。 - 打开失败会丢弃该包。
poll_send_to返回Ok(buf.len()),这样关联不会卡在一个无法到达的出站上,失败以debug级别记录为udp fan-out: opening an outbound failed: {e}。在 app 中,这也包括不承载数据报的出站:http、shadowsocks和shadowsocks-2022的connect_datagram会失败,报{what} carries no datagrams。 - 已打开子 link 的发送错误会原样返回;只有打开失败才会变成静默丢包。
- 接收出错的子 link 会被移除,以
debug级别记录为udp fan-out: a sub-link ended: {e},接收方会重新唤醒自己去轮询其余子 link。
在 app 中,blackhole 出站会得到一个真实的子 link(BlackholeLink),它吞掉所有包。katana 则直接短路:当路由选中 Outbound::Block,或审计规则禁止该目的地址时,poll_send_to 在轮询用户的 gate 之前就返回 Ok(buf.len()),因此该包被丢弃且不计费。katana 中 FanOut 的计费和审计部分见 katana 的 connector 与 UDP。
| 不变量 | 保证者 | 固定它的测试 |
|---|---|---|
| 第一条匹配的规则胜出;命中后不再查看后续规则。 | RouteTable::pick 中提前的 return |
first_matching_rule_wins |
| 规则中任一匹配条件命中,该规则即匹配。 | item.matchers.iter().any(...) |
any_matcher_within_a_rule_matches |
| 没有命中时返回默认输出。 | 最后的 self.default.clone() |
default_fallback_when_no_rule_matches |
| 字段未提供时,读取它的匹配条件不匹配。 | matches 中的 Option 比较和 is_some_and |
source_cidr_matches_the_client_address、network_matches_the_declared_transport、inbound_tag_matches_the_declared_inbound |
| 除非提供了嗅探到的域名,域名匹配条件永远不匹配 IP 目标。 | Domains::of 对 IP remote 返回 None |
domain_matchers_never_match_ip_dests、a_sniffed_domain_makes_an_ip_target_match_domain_rules |
| geosite 也会看到嗅探到的域名。 | GeoSite 通过 domains.any |
a_sniffed_domain_feeds_geosite_too |
Cidr 和 GeoIp 永远不匹配域名目标。 |
Remote::Domain(_) => false 分支 |
cidr_matches_the_destination_only(只覆盖 Cidr) |
| 对小写规则,域名比较忽略 ASCII 大小写。 | Domains::of 转换目标;消费方转换规则一侧 |
domain_matching_is_case_insensitive |
| 后缀只在标签边界上匹配。 | is_subdomain_of |
domain_suffix_matches_parents_not_lookalikes |
| 端口范围两端包含;反向、非数字或越界的写法会被拒绝。 | parse_port_match |
port_ranges_are_inclusive、port_parser_rejects_an_inverted_range |
| 无效正则会让规则表构建失败,而不是变成一条死规则。 | 唯一的构造函数 DomainRegex::new |
invalid_domain_regex_is_rejected、domain_regex_matches_its_pattern |
| 两个正则匹配条件的 pattern 相同即相等。 | impl PartialEq for DomainRegex |
domain_regex_equality_is_by_pattern |
| app 配置接受的每个上下文字段都会传到路由器。 | app 的 route_target |
app/tests/integration/e2e_route_context.rs 中的 inbound_tag_selects_the_route、source_cidr_matches_the_client_address、network_separates_tcp_from_udp |
| 流自身的来源优先于监听器的来源。 | flow.source.or(ctx.source) |
app/tests/unit/router.rs 中的 the_circuits_own_source_wins_over_the_listeners 及其三个同类测试 |
| 嗅探到的域名会端到端地改变路由。 | 两个 route_target 函数都传入 flow.sniffed |
app 中的 e2e_sniff.rs,katana 的 tests/integration/sniff.rs |
| UDP 按包路由,而不是按关联路由。 | FanOutLink、FanOut |
one_association_routes_each_peer_separately、replies_from_several_peers_merge_back_correctly,katana 的 udp_is_billed_after_routing_and_blocked_packets_are_free |
| 被引用的 geo 代码必须存在,被使用的 geo 类别必须有可读文件。 | build_geo_data 返回 NotFound、InvalidInput 或 std::fs::read 的错误 |
没有单元测试;由 --test 试运行检查 |
未注明文件的单元测试位于 environment/tests/unit/routing.rs。该文件通过 #[path = "../tests/unit/routing.rs"] 以 mod tests 的形式编译进 environment/src/routing.rs,因此可以直接构造 DomainSet 和 DomainMatcher 等私有类型。
pick 和 route 不会失败。本模块中的每个失败都发生在构建规则表时,并且每个失败都会让整个构建失败,所以错误规则会被拒绝,而不会产生一张带有静默死规则的表。拒绝的代价取决于消费方:见路由器存放在哪里。
| 情况 | io::ErrorKind |
消息 |
|---|---|---|
端口写法不是 u16 或其范围 |
InvalidInput |
invalid port spec: "70000" |
| 反向的端口范围 | InvalidInput |
invalid port spec: "2000-1000" has a lower bound above its upper bound |
| 正则无法编译 | InvalidInput |
invalid domain regex "(unclosed": regex parse error: …(多行) |
| 使用了 geosite 匹配条件但未配置 geosite 文件 | InvalidInput |
a geosite matcher is used but no geosite file is configured |
| 使用了 geoip 匹配条件但未配置 geoip 文件 | InvalidInput |
a geoip matcher is used but no geoip file is configured |
.dat 文件无法读取 |
来自 std::fs::read |
只有 OS 错误本身,例如 No such file or directory (os error 2);不包含路径 |
.dat 文件无法解码 |
InvalidData |
geosite decode: … 或 geoip decode: … |
| 文件中没有该代码 | NotFound |
geosite code not found: {code} 或 geoip code not found: {code};geosite 代码不带 @attr,geoip 代码不带 ! |
| geoip CIDR 格式错误 | InvalidData |
geoip cidr: … |
| 规则或默认输出中出现未知出站 tag(消费方) | InvalidInput |
route references unknown outbound tag: {tag} |
错误的 cidr 或 source_cidr(消费方) |
InvalidInput |
invalid cidr {s:?}: {e} |
错误的 network(app) |
InvalidInput |
invalid rule network {other:?} (expected "tcp" or "udp") |
在 --test 下,app 在 configuration invalid: 之后打印这些错误,katana 在 configuration error: 之后打印。运行时,它们出现在路由器存放在哪里列出的启动、重载和重建消息中。把 geoip.dat 配置成 geosite 文件会以 geosite decode 错误失败,而不会被当作错误的数据加载。
本模块自身没有需要取消的东西。connector 和 fan-out 持有创建它们时那个路由器的 Arc,两个消费方都不会在活动连接下替换路由器:app 的 reload 先构建新的 generation,再取消旧的,从而中止其进行中的任务;katana 在路由变化后重建节点的整个传输层,这会断开该节点上的所有连接(由 katana tests/unit/e2e.rs 中的 route_change_drops_connections 固定)。因此一条流在整个生命周期内都由同一个路由器路由。
| 项目 | 值 | 位置 |
|---|---|---|
| 每条规则内联存储的匹配条件数 | 3 | RouteItem::matchers,SmallVec<[RouteMatch; 3]> |
| 每个 UDP 关联的子 link 数 | 64 | app/src/outbound/udp_fanout.rs 和 katana src/connector.rs 中的 MAX_SUBS |
| 每个关联同时进行中的出站打开数 | 1 | FanOutLink::opening、FanOut::opening |
每次 pick 的内存分配 |
至多两个 String,即转为小写的请求域名和嗅探域名,即使规则表中没有域名规则也会分配 |
Domains::of |
| 规则查找 | 与规则数成线性关系,也与所经过的每个 geosite 或 geoip 集合的条目数成线性关系 | pick、DomainSet::matches、CidrSet::matches |
| geodata 文件 | 每次构建时整个读入内存并解码一次;只有被引用的集合会保留 | build_geo_data |
对 UDP 而言,pick 每个数据报运行一次,所以它的单次调用开销是按包支付的。
| 测试 | 文件 | 行为 |
|---|---|---|
any_matcher_within_a_rule_matches |
environment/tests/unit/routing.rs |
规则是其匹配条件的 OR |
first_matching_rule_wins |
同上 | 顺序即优先级 |
default_fallback_when_no_rule_matches |
同上 | 空规则表返回默认输出 |
domain_suffix_matches_parents_not_lookalikes |
同上 | 基于标签边界的后缀匹配 |
domain_keyword_matches_any_substring |
同上 | 子串匹配 |
domain_full_matches_only_the_exact_name |
同上 | 精确匹配 |
domain_regex_matches_its_pattern |
同上 | 通过 parse_domain_regex 的正则匹配 |
invalid_domain_regex_is_rejected |
同上 | 构建时的正则校验 |
domain_regex_equality_is_by_pattern |
同上 | DomainRegex 的相等性和 as_str |
domain_matching_is_case_insensitive |
同上 | 目标域名会被转为小写 |
domain_matchers_never_match_ip_dests |
同上 | IP 目标没有域名 |
a_sniffed_domain_makes_an_ip_target_match_domain_rules |
同上 | 嗅探到的域名会传到域名规则 |
a_sniffed_domain_feeds_geosite_too |
同上 | 嗅探到的域名会传到 geosite |
cidr_matches_the_destination_only |
同上 | Cidr 只读取 IP remote |
source_cidr_matches_the_client_address |
同上 | 来源与目的地址相互独立,缺失时不起作用 |
port_ranges_are_inclusive |
同上 | 边界包含在内 |
port_parser_rejects_an_inverted_range |
同上 | 拒绝反向、非数字和越界的写法 |
network_matches_the_declared_transport |
同上 | 网络匹配条件,没有上下文时不起作用 |
inbound_tag_matches_the_declared_inbound |
同上 | 入站 tag 匹配条件,没有上下文时不起作用 |
the_circuits_own_source_wins_over_the_listeners、without_one_the_listeners_source_is_used、with_neither_there_is_no_source_to_match_on、a_circuit_source_works_with_no_listener_source |
app/tests/unit/router.rs |
app 的 route_target 中来源的优先级 |
route_rules_accept_every_matcher |
app/tests/unit/config.rs |
每个匹配条件键都解析到各自的字段 |
inbound_tag_selects_the_route、source_cidr_matches_the_client_address、network_separates_tcp_from_udp |
app/tests/integration/e2e_route_context.rs |
上下文匹配条件端到端生效 |
an_http_host_routes_an_ip_addressed_flow、a_tls_sni_routes_an_ip_addressed_flow、a_flow_whose_sniffed_host_does_not_match_is_blocked、an_unsniffable_payload_falls_through_to_the_default、turning_sniffing_off_stops_the_domain_rule_matching |
app/tests/integration/e2e_sniff.rs |
嗅探到的域名端到端地改变路由 |
one_association_routes_each_peer_separately、replies_from_several_peers_merge_back_correctly |
app/tests/integration/e2e_udp_route.rs |
UDP 逐包路由与回包合并 |
a_blocked_destination_is_refused |
katana tests/unit/connector.rs |
路由到 block 的 TCP 流被拒绝 |
udp_is_billed_after_routing_and_blocked_packets_are_free |
katana tests/unit/connector.rs |
逐包路由;被拦截的包丢弃且不计费 |
a_sniffed_host_reaches_a_domain_rule、disable_sniffing_stops_the_domain_rule_matching |
katana tests/integration/sniff.rs |
katana 把嗅探到的域名传给路由器 |
route_change_drops_connections |
katana tests/unit/e2e.rs |
修改路由会重建节点并断开现有连接 |
在本页固定的版本中,build_geo_data、build_domain_set 和 build_cidr_set 没有单元测试。修改它们之后,最快的检查方式是用真实的 geoip.dat 和 geosite.dat 文件跑 app 的 --test 试运行,它会覆盖解码、code@attr、!code 以及缺少代码的错误。路由单元测试用 cargo test -p etemenanki-environment 运行;其余测试见测试。