跳转到内容

路由模型

源码文件:28 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/environment/src/lib.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/app/src/router.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/instance.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/flow.rs
  • Etemenanki/app/src/outbound/mod.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/concepts/src/sniff.rs
  • Etemenanki/app/tests/unit/router.rs
  • Etemenanki/app/tests/unit/config.rs
  • Etemenanki/app/tests/integration/e2e_route_context.rs
  • Etemenanki/app/tests/integration/e2e_sniff.rs
  • Etemenanki/app/tests/integration/e2e_udp_route.rs
  • katana/src/router.rs
  • katana/src/config.rs
  • katana/src/connector.rs
  • katana/src/runtime.rs
  • katana/src/manager/node.rs
  • katana/tests/unit/connector.rs
  • katana/tests/unit/e2e.rs
  • katana/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) 下放宽。

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。

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 有端到端测试,证明其配置接受的每个上下文字段都确实传到了路由器(见测试)。

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,编译总是发生在构建规则表时,而不是匹配时。

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 上也没有任何约束。

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.rs
pub type Router = routing::Router<Outbound>;
// katana, src/router.rs
pub type Router<Outbound> = routing::Router<Outbound>;
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)。

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 中的循环直接带来三条性质:

  1. 第一条规则胜出。 循环在第一次命中时返回,所以规则顺序是唯一的优先级。放在前面的宽泛规则会遮蔽后面所有规则。
  2. 规则内任一匹配条件即可。 item.matchers.iter().any(...) 让一条规则成为其匹配条件的 OR,模型中没有 AND。一条同时带 domain_suffix 和 port 的规则,会匹配满足其中任意一个的流,而不是要求两者都满足。
  3. 默认输出兜底。 没有规则匹配时,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 字节,因此名字的其余部分严格按请求或载荷携带的形式比较。

当请求本身只指定了 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") 也不匹配域名目标。cidr crate 的 IpCidr::contains 严格区分地址族:IPv4 范围永远不包含 IPv6 地址,IPv4 映射地址也不例外。
  • SourceCidr 读取 source,从不读取 remote。没有来源的目标会让规则失效,即使其目的地址落在范围内。
  • PortRange 两端都包含。
  • Network 与 Option<TargetNetwork> 比较,InboundTag 与 Option<&str> 比较,所以在消费方提供该字段之前,二者都不起作用。两个消费方都映射了 DialNetwork::Tcp 和 DialNetwork::Udp,对 DialNetwork::Unknown 和 DialNetwork::Unix 不提供网络。
#[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 地址也包括在内。

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 域名等于该值
其他值 无 跳过该条目并记录警告
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;每一类的步骤如下:

  1. 如果引用列表为空,即使配置了路径也不会打开文件。一个配置了不存在的 geoip 文件、但没有使用任何 geoip 匹配条件的配置能通过 --test。
  2. 否则必须配置路径,不然调用失败,报 a geosite matcher is used but no geosite file is configured(geoip 同理)。
  3. 用 std::fs::read 读入整个文件,解码一次为 GeoSiteList 或 GeoIpList。
  4. 逐个解析尚未在 map 中的原始引用;重复的原始字符串会被跳过。
    • geosite: code@attr 在第一个 @ 处拆分。用 eq_ignore_ascii_case 按 code 查找条目。带属性时,只保留带有 key 与之相等(忽略 ASCII 大小写)的 Attribute 的域名。前导 ! 在这里没有含义:"!cn" 会被按字面查找,并以未找到失败。
    • geoip: 前导 ! 会设置 reverse 并被去掉。按剩余的代码查找条目,忽略 ASCII 大小写。
  5. 当该类的引用都解析完后,解码出的列表即被丢弃,所以只有被引用代码的集合留在内存中。

代码查找不区分大小写,但 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})。

两个消费方都提供同一对函数: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) 构建。

每个规则字段按固定顺序变成匹配条件。由于规则是 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 的值经由 cidr crate 解析,它会拒绝置位的主机位:"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 文件之前报告。
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 关联没有单一的目的地址:每个数据报都携带自己的目的地址。如果把关联作为一个整体路由,所有对端的流量都会发往同一个出站,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 运行;其余测试见测试。