嗅探
源码文件:51 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
Etemenanki/protocols/src/sniff/mod.rsEtemenanki/protocols/src/sniff/collector.rsEtemenanki/protocols/src/sniff/tls.rsEtemenanki/protocols/src/sniff/http.rsEtemenanki/concepts/src/sniff.rsEtemenanki/concepts/src/core.rsEtemenanki/concepts/src/runtime.rsEtemenanki/protocols/src/lib.rsEtemenanki/protocols/src/core/mod.rsEtemenanki/protocols/src/flow.rsEtemenanki/protocols/src/http/core.rsEtemenanki/protocols/src/socks/server.rsEtemenanki/protocols/src/hysteria/server/inbound.rsEtemenanki/protocols/src/trojan/core.rsEtemenanki/protocols/src/vless/core.rsEtemenanki/protocols/src/vmess/core.rsEtemenanki/protocols/src/ss_legacy/core.rsEtemenanki/protocols/src/ss_2022/core.rsEtemenanki/protocols/src/tun/inbound.rsEtemenanki/protocols/src/mux/demux.rsEtemenanki/environment/src/routing.rsEtemenanki/app/src/config.rsEtemenanki/app/src/inbound/mod.rsEtemenanki/app/src/inbound/tun.rsEtemenanki/app/src/serve.rsEtemenanki/app/src/router.rsEtemenanki/app/src/connector.rsEtemenanki/app/src/outbound/udp_fanout.rsEtemenanki/protocols/tests/unit/sniff/mod.rsEtemenanki/protocols/tests/unit/sniff/collector.rsEtemenanki/protocols/tests/unit/sniff/tls.rsEtemenanki/protocols/tests/unit/sniff/http.rsEtemenanki/protocols/tests/unit/core/mod.rsEtemenanki/protocols/tests/unit/http/core.rsEtemenanki/protocols/tests/unit/hysteria/server/inbound.rsEtemenanki/protocols/tests/unit/trojan/core.rsEtemenanki/protocols/tests/unit/vless/core.rsEtemenanki/protocols/tests/unit/vmess/core.rsEtemenanki/protocols/tests/unit/ss_legacy/core.rsEtemenanki/protocols/tests/unit/ss_2022/core.rsEtemenanki/protocols/tests/pipeline/socks.rsEtemenanki/protocols/tests/pipeline/tun.rsEtemenanki/concepts/tests/runtime.rsEtemenanki/environment/tests/unit/routing.rsEtemenanki/app/tests/integration/e2e_sniff.rskatana/src/config.rskatana/src/manager/node.rskatana/src/inbound.rskatana/src/serve.rskatana/src/router.rskatana/src/connector.rs
代理请求常常只给出一个 IP 地址。TUN 客户端总是如此,在连接前就已解析好域名的浏览器或代理客户端也是如此。这样的流无法匹配任何按域名编写的规则,geosite 列表也不例外。嗅探会读取客户端经隧道发出的最初几个字节,恢复出它真正要访问的名称(TLS ClientHello 中的 SNI,或 HTTP/1.x 请求中的 host),再把这个名称作为第二个可供匹配的域名交给路由器。
本页从两个解析器出发,自下而上讲解整个机制:字节预算和时间窗口、不依赖时钟的 Collector、sans-I/O 协议核心如何持有收集到的前缀并在连接建立后转发、必须先应答客户端才能嗅探的协议、mux 子流,以及读取嗅探结果的唯一位置。添加嗅探器、为新的协议核心加入嗅探,或修改路由器对 Flow::sniffed 的处理之前,请先阅读本页。
嗅探只为目标是裸 IP 的流填写 Flow::sniffed,此外不做任何事。按照设计,它不会:
- 让连接失败。 每个解析器都返回
Option。格式错误、被截断或无法识别的字节都得到None,此时流按其目标地址路由,就像嗅探关闭了一样。 - 改变目标地址。 出站拨号的仍是客户端请求的地址,只有路由器会读取嗅探到的名称。
- 检查已经给出域名的流。
worth_sniffing会在持有任何字节之前就拒绝这样的流,因此它们从不等待。 - 识别 TLS 和 HTTP/1.x 以外的协议。 没有 QUIC、DNS 或 BitTorrent 嗅探器,UDP 流也从不收集(见 嗅探在哪里运行)。
相关代码分布在四个 crate 和 katana 中:
| Crate | 组成部分 | 作用 |
|---|---|---|
etemenanki-concepts |
concepts/src/sniff.rs |
共享的词汇:Sniffer、SniffedBehavior、SniffedProtocol。不含解析逻辑。 |
etemenanki-protocols |
protocols/src/sniff/ |
两个解析器,以及 plausible_domain、worth_sniffing、Collector、SNIFF_LIMIT 和 SNIFF_TIMEOUT。 |
etemenanki-protocols |
protocols/src/core/mod.rs 和各个服务端协议核心 |
SniffPrefix 与 Phase::Sniff:持有字节、启动时间窗口、打开流。 |
etemenanki-environment |
environment/src/routing.rs |
RouteTarget::sniffed_domain,所有域名匹配条件都会参考它。 |
etemenanki-app、katana |
app/src/router.rs、katana src/router.rs |
把 Flow::sniffed 复制到 RouteTarget 中。 |
concepts/src/sniff.rs 定义了嗅探器的产出。它放在 concepts crate 中,这样 Flow(位于 protocols crate)和 katana 的路由器无需依赖解析器就能引用嗅探结果。
pub enum SniffedProtocol { Tls, Http,}
pub struct SniffedBehavior { pub protocol: SniffedProtocol, pub domain: CompactString,}
pub trait Sniffer { fn sniff(&self, data: &[u8]) -> Option<SniffedBehavior>;}Sniffer::sniff 是关于字节的纯函数:没有状态,不读时钟,除返回的名称外不做分配。每当有更多字节到达,collector 就在更长的缓冲区上再次调用它,遇到第一个 Some 即停止,因此实现对尚无法判定的前缀应返回 None。TLS 解析器严格遵守这一点。HTTP 解析器不会等待请求头结束,因此可能根据一条仍在到达中的 Host 行给出结果(见 HTTP 请求解析器)。
预算、时间窗口和入口函数
Section titled “预算、时间窗口和入口函数”protocols/src/sniff/mod.rs 包含两个限制值,以及所有调用方都会用到的三个自由函数:
pub const SNIFF_TIMEOUT: Duration = Duration::from_millis(300);pub const SNIFF_LIMIT: usize = 4 * 1024;const MAX_DOMAIN_LEN: usize = 253;
pub fn plausible_domain(host: &str) -> Option<CompactString>;pub fn sniff(data: &[u8]) -> Option<SniffedBehavior>;pub fn worth_sniffing(destination: &Destination) -> bool;| 常量 | 值 | 为什么必须存在 |
|---|---|---|
SNIFF_TIMEOUT |
300 ms | 在服务端先发言的协议(SMTP、SSH、MySQL)中,服务端开口之前客户端什么也不发。没有时间窗口,协议核心就会一直持有传输层等下去。 |
SNIFF_LIMIT |
4096 字节 | 没有它,对端可以在被路由之前发送任意多的数据。预算跨帧计算,因为扩展列表很长的 ClientHello 可能超过一次读取或一个 AEAD chunk。 |
MAX_DOMAIN_LEN |
253 | 合法 DNS 名称的最大长度。plausible_domain 会拒绝更长的名称。 |
sniff 按固定顺序运行已实现的嗅探器:
pub fn sniff(data: &[u8]) -> Option<SniffedBehavior> { TlsSniffer.sniff(data).or_else(|| HttpSniffer.sniff(data))}TLS 排在前面,是因为 record 头比请求行的区分度高得多。两者不可能同时匹配同一段字节:TLS 嗅探器要求第一个字节是 0x16,HTTP 嗅探器要求缓冲区以大写的方法名开头。
worth_sniffing 是每个调用方在进入嗅探阶段前都要检查的关口:
pub fn worth_sniffing(destination: &Destination) -> bool { matches!(destination.remote, Remote::IpAddr(_))}目标已经给出域名的流会按该域名路由,为它等待只会让每条这样的流多花最多 SNIFF_TIMEOUT,却毫无收获。这个检查只看 remote,不看 network:把 UDP 排除在外是调用方的职责,调用方对数据报请求从不进入该阶段。
Collector 与 Verdict
Section titled “Collector 与 Verdict”protocols/src/sniff/collector.rs 是嗅探中不依赖时钟的那一半:一个有上限的累加器,每次追加字节后都会对它持有的全部内容重新运行 sniff。
pub enum Verdict { Found, More, Exhausted,}
pub struct Collector { buf: Vec<u8>, found: Option<SniffedBehavior>,}
impl Collector { pub fn new() -> Self; pub fn remaining(&self) -> usize; pub fn push(&mut self, bytes: &[u8]) -> Verdict; pub fn held(&self) -> &[u8]; pub fn found(&self) -> Option<&SniffedBehavior>; pub fn take(self) -> (Vec<u8>, Option<SniffedBehavior>);}| 方法 | 行为 |
|---|---|
new |
用 Vec::with_capacity(SNIFF_LIMIT) 预先保留整个预算,因此收集过程中从不重新分配。 |
remaining |
SNIFF_LIMIT.saturating_sub(buf.len())。 |
push |
追加 bytes 的全部内容,若尚未存有结果则运行 sniff(&buf)。已有结果时返回 Found;否则 buf.len() >= SNIFF_LIMIT 时返回 Exhausted,其余情况返回 More。Found 优先:即使匹配出现在填满预算的那段字节中,结果仍是 Found。 |
held |
到目前为止收集的全部字节。它们是普通的 payload,调用方必须先把它们转发给出站,再转发流的其余部分。 |
found |
已存储的结果(引用)。 |
take |
消耗 collector,返回字节和结果。异步 SOCKS 服务端使用它。 |
push 不会截断输入。调用方提供的字节不得超过 remaining(),两个调用方都做到了:SniffPrefix::push 会切分输入,SOCKS 的 collect_prefix 每次最多读取 remaining() 字节。
Collector 没有截止时间。sans-I/O 协议核心自己通过 Timing 设置运行时唯一的定时器,异步 SOCKS 服务端则用 tokio::time::timeout_at 包裹读取。把时钟排除在 Collector 之外,两类调用方就能共用它,单元测试也无需运行时即可驱动它。
协议核心中的 SniffPrefix
Section titled “协议核心中的 SniffPrefix”每个支持嗅探的 sans-I/O 协议核心都组合了一个来自 protocols/src/core/mod.rs 的 SniffPrefix。它包装一个 Collector,并使其适配协议核心的约定:handle 返回它消耗了事件中的多少字节。
pub struct SniffPrefix { collector: Collector,}
impl SniffPrefix { pub fn new() -> Self; pub fn push(&mut self, plain: &[u8]) -> (usize, Verdict); pub fn held(&self) -> &[u8]; pub fn found(&self) -> Option<&SniffedBehavior>; pub fn result(&self) -> Option<SniffedBehavior>; pub fn clear(&mut self); pub fn is_empty(&self) -> bool;}push取min(plain.len(), collector.remaining())个字节,返回这个数量和判定结果。没有可取的字节时,预算已用完则返回Exhausted,否则(输入为空)返回More。明文协议核心把这个数量作为消耗长度返回,因此超出预算的字节留在运行时的读缓冲区中,在流打开后按正常方式中继。result克隆找到的值,字节保持原位,因为它们仍需转发。clear用Collector::new()替换 collector。流打开后,协议核心在每个字节事件开头都会调用它,第一次调用就会丢弃前缀(见 held 缓冲区规则)。
同一文件还定义了嗅探所依托的阶段和截止时间机制:
pub enum Phase { Handshake, Sniff, Relay, Closing,}
pub enum Expired { Handshake, Sniff, Idle,}
impl Timing { pub fn touch<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>); pub fn enter<C: ProxyCoreDecode>(&mut self, phase: Phase, fx: &mut Effects<'_, C>); pub fn expired<C: ProxyCoreDecode>(&mut self, fx: &mut Effects<'_, C>) -> Expired;}Timing::enter(Phase::Sniff, fx)推入Effect::SetDeadline(Some(SNIFF_TIMEOUT))。运行时只有一个定时器,所以它会取代握手截止时间。Timing::touch在Phase::Sniff中什么也不做。因此时间窗口是从协议核心开始嗅探起固定的 300 ms,而不是每来一个字节就延长的空闲定时器。- 在该阶段收到
Event::Deadline时,expired返回Expired::Sniff,不推入任何 effect。随后协议核心用已持有的内容打开流。
Flow::sniffed
Section titled “Flow::sniffed”结果随流本身传递,定义在 protocols/src/flow.rs 中:
pub struct Flow<T> { pub destination: Destination, pub user: NetworkUser<T>, pub sniffed: Option<SniffedBehavior>, pub source: Option<IpAddr>,}
impl<T> Flow<T> { pub fn new(destination: Destination, user: NetworkUser<T>, source: Option<IpAddr>) -> Self; pub fn toward(&self, destination: Destination) -> Self;}Flow::new 初始为 sniffed: None。协议核心在推入 Effect::Open 之前设置该字段,所以 connector 路由时能看到它。Flow::toward 为另一个目标构造一条共享用户和来源的流,并把 sniffed 重置为 None;手写的 Clone 则会保留它。见 Flow::toward 从干净状态开始。
TLS ClientHello 解析器
Section titled “TLS ClientHello 解析器”protocols/src/sniff/tls.rs → TlsSniffer 依次走过 record 头、握手头和扩展列表,直到 server_name 扩展(RFC 8446 第 4.1.2 节、RFC 6066 第 3 节)。它只读取所需的部分,并忽略所有版本字段。
const RECORD_HANDSHAKE: u8 = 0x16;const HANDSHAKE_CLIENT_HELLO: u8 = 0x01;const EXT_SERVER_NAME: u16 = 0x0000;const SNI_HOST_NAME: u8 = 0x00;const AFTER_RANDOM: usize = 4 + 2 + 32;
pub struct TlsSniffer;
fn u16_at(b: &[u8], at: usize) -> Option<u16>;fn client_hello_sni(data: &[u8]) -> Option<CompactString>;fn server_name(ext: &[u8]) -> Option<CompactString>;record 头从整个缓冲区中读取:
| 字段 | 大小 | 偏移 | 解析器的处理 |
|---|---|---|---|
| Content type | 1 | 0 | 必须为 0x16(RECORD_HANDSHAKE),否则返回 None。 |
| Legacy record version | 2 | 1 | 不读取。 |
| Record length | 2 | 3 | 仅作参考:body = data[5 .. min(5 + length, data.len())]。 |
后续步骤都在 body 上进行,因此下面的偏移都是相对 body 的:
| 字段 | 大小 | 在 body 中的偏移 |
解析器的处理 |
|---|---|---|---|
| Handshake type | 1 | 0 | 必须为 0x01(HANDSHAKE_CLIENT_HELLO),否则返回 None。 |
| Handshake length | 3 | 1 | 不读取。 |
legacy_version |
2 | 4 | 不读取。 |
random |
32 | 6 | 跳过。遍历从 AFTER_RANDOM = 38 开始。 |
legacy_session_id |
1 + n | 38 | 读取长度字节,然后跳过。 |
cipher_suites |
2 + n | 不定 | 读取大端长度,然后跳过。 |
legacy_compression_methods |
1 + n | 不定 | 读取长度字节,然后跳过。 |
| Extensions length | 2 | 不定 | 设置 ext_end = min(p + length, body.len())。 |
| Extension type | 2 | 每个扩展 | 遇到 0x0000(EXT_SERVER_NAME)时结束遍历。 |
| Extension length | 2 | 每个扩展 | 扩展数据必须位于 body 之内,否则返回 None。 |
server_name 扩展内部:
| 字段 | 大小 | 解析器的处理 |
|---|---|---|
server_name_list length |
2 | 列表必须位于扩展数据之内,否则返回 None。 |
name_type |
1 | 只有 0x00(SNI_HOST_NAME)携带域名,其他类型跳过。 |
| Name length | 2 | 名称必须位于列表之内,否则返回 None。 |
HostName |
n | 必须是 UTF-8,且必须通过 plausible_domain。 |
- 检查 content type,读取 record 长度并切出
body。record 长度是上限而非保证:字节仍在到达时,缓冲区只持有 record 的一个前缀,遍历会因字节不足返回None,下一次Collector::push再带着更多字节重试。 - 检查握手类型,令
p = AFTER_RANDOM。 - 跳过三个变长向量,每个都用
p = p.checked_add(width)?.checked_add(length)?。 - 读取扩展总长度,计算
ext_end并截断到body.len()。 - 当
p < ext_end时,读取类型和长度,取body.get(at..at + len)?,在第一个类型为0x0000的扩展处返回server_name(edata);否则跳过该扩展。 - 在
server_name中遍历各条目,返回第一个host_name条目经plausible_domain处理的结果。
解析器在第一个 server_name 扩展和第一个 host_name 条目处停止,即使该条目未通过 plausible_domain 也是如此。两份 RFC 都只允许各出现一个。
由于每个长度都会与实际存在的字节比对,被截断的 ClientHello 只可能得到 None 或真实的名称。截断点落在 SNI 内部时,server_name 扩展会超出 body,因此在读取名称之前,获取扩展数据的 get 就已失败。截断点恰好落在扩展边界时,循环在 ext_end 处结束(它已被截断到 body.len()),同样得到 None。
如何检查边界
Section titled “如何检查边界”无论对端发送什么,每一步都不会 panic,也不会越界读取:
- 每个偏移都用
checked_add计算,长度溢出时得到None。 - 每次读取都经过
slice::get或u16_at,越过末尾时返回None。 ?把None传出整个解析过程。
protocols/src/lib.rs 中的 crate 级 lint 集合保证了这一点:#![deny(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::arithmetic_side_effects)],仅在 cfg(test) 下解除。这些 lint 是 deny 级别,所以解析器中的索引表达式或未检查的 + 都是 cargo clippy 错误,与运行时使用的警告级别无关。
HTTP 请求解析器
Section titled “HTTP 请求解析器”protocols/src/sniff/http.rs → HttpSniffer 从 HTTP/1.x 请求中恢复 host。它的首要任务是拒绝那些只是碰巧包含 Host: 的字节。
const METHODS: &[&str] = &[ "GET", "POST", "HEAD", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH",];
pub struct HttpSniffer;
fn request_host(data: &[u8]) -> Option<CompactString>;fn request_target(line: &str) -> Option<&str>;fn authority_of(target: &str) -> Option<CompactString>;fn strip_port(host: &str) -> Option<&str>;- 切出请求头。
head_end是第一个\r\n\r\n的位置;如果空行尚未到达(收集期间的常见情况),则为缓冲区末尾。只解码data[..head_end],且它必须是合法的 UTF-8。请求头之后的 body 从不解码,所以二进制 body 不会破坏解析。 - 要求有请求行。 请求头按
\r\n切分。request_target按单个空格切分第一行,要求依次是METHODS中的一个方法(区分大小写)、一个请求目标,以及以HTTP/开头的第三个字段。否则返回None。正是这份固定的方法列表防止了二进制 payload 被当作 HTTP 读取,这也意味着 HTTP/2 连接前言(PRI * HTTP/2.0)不会被识别。 - 优先使用 absolute-form 的 authority。 如果请求目标以
http://或https://开头,authority_of取到第一个/为止的文本,只保留最后一个@之后的部分以去掉 userinfo,去掉端口,再交给plausible_domain。代理请求使用的就是这种形式。若它得到域名,就优先于任何Host请求头;若没有,解析器继续执行下一步。 - 否则读取
Host。 第一个名称(第一个:之前的文本,不做 trim)在忽略 ASCII 大小写后等于host的请求头行提供取值。之后的Host行不会再被查看,即使第一个被拒绝也一样。取值经过 trim,strip_port去掉末尾一个后面只跟数字的:,最后由plausible_domain判定。
对于以 [ 开头的取值,strip_port 不做处理,因此带方括号的 IPv6 字面量会完整地到达 plausible_domain,并因其中的字符在那里被拒绝。
plausible_domain
Section titled “plausible_domain”两个解析器最后都经过同一个过滤器,由它决定一个字符串能否用作路由域名:
pub fn plausible_domain(host: &str) -> Option<CompactString>;| 步骤 | 规则 | 拒绝的内容 |
|---|---|---|
| 1 | 去掉末尾的 . 字符。 |
不拒绝:example.com. 变为 example.com。 |
| 2 | 长度必须在 1 到 MAX_DOMAIN_LEN(253)之间。 |
""、"."、254 个字符的名称 |
| 3 | 每个字符都必须是 ASCII 字母数字、-、. 或 _。 |
空格、:、[、]、/、?、非 ASCII 字符,因而也包括所有 IPv6 字面量,例如 ::1 |
| 4 | 不能被解析为 IpAddr。 |
IPv4 字面量,例如 192.0.2.1 |
拒绝 IP 字面量是有意为之。它无法告诉路由器任何目标地址中没有的信息,而接受它会让客户端用一个并非其通信对象的地址来标记流。这里保留大小写;路由模型在匹配时会把名称转为小写(见 结果如何使用)。
嗅探在哪里运行
Section titled “嗅探在哪里运行”入站的 sniff 标志会传到每个协议核心的构造函数:
- etemenanki-app:每个入站的
sniffing键(app/src/config.rs→default_sniffing,默认true)。app/src/inbound/mod.rs把它复制到StreamInbound::sniff以及 SOCKS 和 Hysteria 的构造函数中,app/src/serve.rs再把它传给每个流式协议核心。对于tun,当它为false时,app/src/inbound/tun.rs调用TunInbound::without_sniffing。 - katana:取节点
[node.controller]下disable_sniffing键的反值(src/manager/node.rs),由NodeManager::bring_up在构建监听器时读取,由节点的代理管理器持有,并传给src/serve.rs构造的每个协议核心以及src/inbound.rs中的build_hysteria。改变disable_sniffing的热重载算作本地监听器修改:NodeManager::apply_static会立即强制换用新一代监听器,节点上已打开的连接随之断开。监听器仍在启动中的节点会在下一次尝试时按新值构建。见 节点如何应用静态更新。
pub fn new(config: Arc<HttpServerConfig<T>>, sniff: bool, source: Option<IpAddr>) -> Self;// protocols/src/socks/server.rspub fn new(config: SocksServerConfig<T>, sniff: bool) -> Self;// protocols/src/hysteria/server/inbound.rs(Hy2StreamCore;标志来自监听器配置)pub fn new(user: UserEntry<T>, sniff: bool, source: IpAddr) -> Self;// protocols/src/trojan/core.rs 和 protocols/src/vless/core.rspub fn new(validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>) -> Self;// protocols/src/vmess/core.rspub fn new( validator: Arc<AccountValidator<T>>, now: fn() -> i64, sniff: bool, source: Option<IpAddr>,) -> Self;// protocols/src/ss_legacy/core.rspub fn new(inner: Arc<Resolved<T>>, sniff: bool, source: Option<IpAddr>) -> Self;// protocols/src/ss_2022/core.rspub fn with_system_clock( config: Arc<Ss2022ServerConfig<T>>, validator: Option<Arc<Validator<T>>>, sniff: bool, source: Option<IpAddr>,) -> Self;// protocols/src/core/mod.rs(PassthroughCore,供 TUN 使用)pub fn sniffing(flow: Flow<T>) -> Self;// protocols/src/mux/demux.rspub fn new(flow: Flow<T>, sniff: bool) -> Self;只有在标志开启并且 worth_sniffing(&flow.destination) 为真时,调用方才会嗅探,而且只针对下表中的请求类型:
| 入站 | 位置 | 嗅探的请求 | 检查的字节 | 嗅探前是否先应答 |
|---|---|---|---|---|
| TUN | PassthroughCore::sniffing |
TCP 连接 | 传输层最初的字节 | 不存在应答 |
| HTTP | HttpCore |
仅 CONNECT |
隧道 payload | 是,200 Connection established |
| SOCKS 4、4a、5 | SocksInbound::connect |
仅 CONNECT |
隧道 payload | 是,授权应答 |
| Hysteria 2 | Hy2StreamCore |
TCP 流 | 流 payload | 是,一个 “Connected” TCPResponse |
| Trojan | TrojanCore |
CONNECT,发往 mux 地址的除外 |
请求头之后的 payload | 否 |
| VLESS | VlessCore |
Command::Tcp |
请求头之后的 payload | 否 |
| VMess | VMessCore |
Command::Tcp |
解密后的 body chunk | 否 |
| Shadowsocks | ShadowsocksCore |
每个请求(TCP) | 解密后的 chunk | 否 |
| Shadowsocks 2022 | Ss2022Core |
每个请求(TCP) | 解密后的 record | 否 |
| mux.cool | Demux |
每个发往 IP 的 New |
仅 New 帧的 payload |
否 |
普通(非 CONNECT)HTTP 代理请求从不嗅探:HttpCore 一解析完请求头就打开它。UDP 请求(SOCKS UDP ASSOCIATE,Trojan、VLESS 和 VMess 的 UDP,Hysteria 数据报协议核心,TUN UDP)同样不收集。
SOCKS UDP ASSOCIATE 由 SocksInbound::associate 处理,它从不读取 sniff 标志。它在转发第一个数据报时用 Flow::new(dest, user, source) 打开唯一的链路,因此 sniffed 保持为 None;并且它只转发来自其 ExpectedSender 所接纳客户端的数据报(经 TCP 时为控制连接的 IP,并固定到一个端口)。这项检查见关联接收谁的数据报。
嗅探协议核心的各个阶段
Section titled “嗅探协议核心的各个阶段”每个支持嗅探的单流协议核心都有相同的结构,只是各协议对状态的称呼不同:HTTP、Hysteria、Trojan 和 VLESS 协议核心中是 State::Sniff(Flow<T>);VMess 和两个 Shadowsocks 协议核心中是 State::Sniff,流另外存放;PassthroughCore 中则是 pending 加 Phase::Sniff。
stateDiagram-v2 [*] --> Handshake Handshake --> Relay: 解析完成,目标为域名或嗅探关闭 Handshake --> Sniff: 解析完成,目标为 IP 且嗅探开启 Sniff --> Sniff: push 返回 More Sniff --> Relay: Found 或 Exhausted Sniff --> Relay: Event Deadline,Expired Sniff Sniff --> Relay: 客户端 EOF,随后半关闭 Relay --> Closing Closing --> [*]
离开 Sniff 的每条路径都会经过协议核心的打开路径(open_sniffed,在 PassthroughCore 中是 open),它按顺序做三件事:
flow.sniffed = self.prefix.result(),只有在Found之后才是Some。- 推入带有该流的
Effect::Open;如果收集到了任何内容,再推入Effect::ForwardHeld { range: 0..held }。 timing.enter(Phase::Relay, fx),把唯一的截止时间重新设置为RELAY_IDLE_TIMEOUT空闲上限。
PassthroughCore 没有需要解析的请求头。它在第一个传输层事件时进入 Phase::Sniff,因此它的时间窗口从客户端的第一批字节开始计算。
在 sans-I/O 协议核心中收集
Section titled “在 sans-I/O 协议核心中收集”flowchart TB
ev["Event::Transport(data)"] --> push["SniffPrefix::push(plain)"]
push --> take["take = min(len, remaining)"]
take --> sniffers["sniff(held):先 TLS,后 HTTP"]
sniffers --> v{"Verdict"}
v -->|More| ret["return Ok(take),等待下一个事件"]
v -->|"Found 或 Exhausted"| open["open_sniffed"]
dl["Event::Deadline"] --> open
eof["Event::TransportEof"] --> open
open --> fx["Effect::Open,然后 Effect::ForwardHeld 0..held"]
fx --> relay["Phase::Relay"]
按 payload 到达的方式,协议核心分为两组:
- 明文 payload(HTTP、Hysteria、Trojan、VLESS、TUN)。协议核心直接推入事件切片,并返回
SniffPrefix::push取走的数量。HTTP、Hysteria、Trojan 和 VLESS 协议核心在处理完请求后就返回,因此同一次读取中紧随请求的 payload 会在运行时下一次调用时进入Sniff状态。只要协议核心有进展,运行时就会继续调用,因此超出预算的字节在流打开后按正常方式中继。 - 解密后的 payload(VMess、Shadowsocks、Shadowsocks 2022)。协议核心就地解开一个 chunk 或 record,推入其明文范围,并整体消耗该 chunk。如果判定结果在某个 chunk 中途结束了收集,协议核心会为已取字节之后的明文推入一个
Effect::Forward。VMess 会继续处理,按范围转发同一事件中后续的 chunk;两个 Shadowsocks 协议核心则在处理完该 chunk 后返回,在下一次调用时中继剩余部分。
Shadowsocks 协议核心还会在请求本身中收到 payload:legacy 协议核心的前几个 chunk 先携带地址,再携带 payload;Shadowsocks 2022 的变长头部也携带首段 payload。两者都会在做出决定前推入这段 payload,因此如果请求自带的 payload 已经得到 Found 或 Exhausted,就会立即打开流,而不进入 Phase::Sniff。legacy 协议核心还有一个额外情况:如果预算在这段 payload 中途耗尽,它会把前缀和 payload 的剩余部分一起复制到自己的 held 缓冲区,以便用一个 ForwardHeld 同时携带两者。
held 缓冲区规则
Section titled “held 缓冲区规则”支持嗅探的协议核心从线路上消耗收集到的字节,并把它们保存在 SniffPrefix 中。它通过 ProxyCoreDecode::held 暴露这些字节,并用 Effect::ForwardHeld 按范围转发;运行时在应用该 effect 时才根据 held() 解析范围。这发生在 connector 完成拨号之后,而不是推入 effect 的时候。
保证这一过程安全的规则写在 concepts/src/core.rs 中(模块文档中的 “The held buffer”):在所有排队的 held effect 都被应用之前,运行时不会向协议核心投递任何字节事件(Transport、TransportDatagram、Outbound、Datagram、OutboundEof、TransportEof)。这期间仍可能到达的事件(Connected、ConnectFailed、OutboundError、Deadline)只能向 held 缓冲区追加内容。因此协议核心可以在 Relay 中下一个字节事件的开头调用 SniffPrefix::clear,所有支持嗅探的协议核心都是这样做的。运行时一侧的内容见 服务端运行时。
先应答后嗅探
Section titled “先应答后嗅探”有三类客户端在发出请求后,会等服务端应答才继续发送数据。嗅探需要 payload,因此这些服务端先应答成功,再收集,最后连接:
| 协议 | 客户端等待的内容 | 提前应答的写入位置 |
|---|---|---|
HTTP CONNECT |
200 Connection established |
HttpCore::on_head 暂存 CONNECT_ESTABLISHED |
SOCKS 4、4a 和 5 CONNECT |
授权应答 | SocksInbound::connect 调用 write_granted |
| Hysteria 2 TCP 流 | TCPResponse |
Hy2StreamCore 暂存 response(true, "Connected") |
其他协议允许客户端紧跟在请求头后面发送 payload,因此它们先收集,连接之后再应答(如果该协议有应答的话)。
sequenceDiagram participant C as 客户端 participant S as 服务端协议核心 participant R as 运行时 participant N as Connector participant T as 目标 192.0.2.10 C->>S: CONNECT 192.0.2.10:443 S->>R: 暂存成功应答,SetDeadline(SNIFF_TIMEOUT) R->>C: 成功应答 C->>S: ClientHello S->>S: SniffPrefix::push 返回 Found S->>R: Open(sniffed = example.com),ForwardHeld 0..n R->>N: connect(flow) N->>N: 用嗅探到的域名构造 route_target N->>T: 拨号 192.0.2.10:443 R->>S: Event::Connected Note over S: reply 为 false,不再发送第二次应答 R->>T: 持有的 ClientHello
上图展示的是 sans-I/O 协议核心。HttpCore 和 Hy2StreamCore 以 reply: false 打开流,因此 Event::Connected 不会暂存任何内容。两者都声明了足以容纳提前应答的 STAGING_RESERVE:HTTP 为 256 字节,Hysteria 为 2048 字节,足够容纳带最长 padding 的 TCPResponse。如果暂存仍然失败,协议核心返回 staging_full()。
SOCKS 不是 sans-I/O 协议核心。protocols/src/socks/server.rs 中的 SocksInbound::connect 用异步 I/O 遵循同样的顺序:
async fn collect_prefix<S: AsyncRead + Unpin>( stream: &mut S,) -> (Vec<u8>, Option<etemenanki_concepts::sniff::SniffedBehavior>);write_granted发送成功应答。collect_prefix创建一个Collector,把唯一的截止时间固定在SNIFF_TIMEOUT之后,然后循环。每一轮在tokio::time::timeout_at(deadline, …)下最多读取remaining()字节。遇到More以外的判定结果、EOF、读取错误、截止时间到达或没有剩余空间时,循环停止,并返回Collector::take()。connector.connect(flow)在设置了flow.sniffed的情况下进行路由。- 前缀通过
write_all写入上游,随后relay_with_idle_guard双向中继。
连接之后,connect 根据 prefix.is_empty() 在嗅探路径和普通路径之间选择,而不是根据授权应答是否已发送。如果嗅探运行了但什么也没收集到(客户端在 SNIFF_TIMEOUT 内未发送任何数据、关闭了写端,或读取失败),connect 同样走普通路径:成功时会写入第二个授权应答,客户端会把它当作隧道中最先收到的字节;失败时则在授权应答之后再写入一个拒绝应答。因此,经由 SOCKS 入站、以 IP 为目标且开启嗅探时,服务端先发言的协议(SMTP、MySQL)会在服务端的第一批字节之前多看到一个 SOCKS 应答。在该入站上关闭 sniffing 即可避免。
Mux 子流
Section titled “Mux 子流”protocols/src/mux/demux.rs → Demux 为 Trojan、VLESS 和 VMess 内部的 mux.cool 提供服务。承载它的协议核心把自己的 sniff 标志传给 Demux::new(flow, sniff)。
子流在其 New 帧解析完成时就被打开,而承载连接不能为了替某个子流收集字节而停下,否则会阻塞其他所有子流。因此子流只嗅探一次,依据是其 New 帧携带的 payload,不使用 Collector、SNIFF_LIMIT 或截止时间:
let mut flow = self.flow.toward(target.clone());if self.sniff && crate::sniff::worth_sniffing(&target) { flow.sniffed = payload.and_then(crate::sniff::sniff);}不带数据的 New,或数据在 SNI 或 Host 行之前就结束的 New,不会给子流带来嗅探名称。承载连接本身从不嗅探:Trojan 在考虑嗅探之前先检查 is_mux_destination,VLESS 和 VMess 遇到 Command::Mux 时切换到 State::Mux。
这里的检查只有 worth_sniffing(&target),因此 UDP 子流的第一个包同样会交给嗅探器。但对 UDP 而言结果从不被读取,见下一节。
结果如何使用
Section titled “结果如何使用”每个产品中只有一个函数读取 Flow::sniffed,且只用于构造路由目标:
pub fn route_target<'a>(flow: &'a Flow, ctx: &'a FlowContext) -> routing::RouteTarget<'a>;pub fn route_target<'a>( dest: &'a Destination, sniffed: Option<&'a SniffedBehavior>, source: Option<IpAddr>,) -> routing::RouteTarget<'a>;app/src/connector.rs 中的 AppConnector::connect 调用前者;katana 的 src/connector.rs 用 flow.sniffed.as_ref() 调用后者。两者都会调用 RouteTarget::with_sniffed_domain(s.domain.as_str())。
在 environment/src/routing.rs 中,私有的 Domains 视图持有请求自身的域名(目标给出域名时)和嗅探到的域名,两者都经 to_ascii_lowercase 转为小写。每个域名匹配条件(DomainSuffix、DomainKeyword、DomainFull、DomainRegex、GeoSite)都通过 Domains::any 判断,任一名称满足即算匹配。Cidr、GeoIp、SourceCidr、PortRange、Network 和 InboundTag 忽略嗅探到的名称,也没有任何匹配条件读取 SniffedBehavior::protocol。路由表的 first-match 顺序保持不变。匹配条件的详细说明见 路由模型。
目标地址不会被改写
Section titled “目标地址不会被改写”路由完成后,AppConnector::connect 把未经修改的流交给 outbound.connect_stream(flow),出站拨号的是 flow.destination:仍然是客户端请求的 IP。katana 的 connector 同样使用 outbound.connect_stream(&flow.destination)。没有任何出站客户端读取 sniffed。嗅探到的名称只改变匹配哪条规则,从不改变字节发往何处。app 的端到端测试正依赖这一点:它们访问的 echo 服务器监听在 127.0.0.1 上,而嗅探名称 sniffed.example 无法解析。
Flow::toward 从干净状态开始
Section titled “Flow::toward 从干净状态开始”Flow::toward 把用户和来源复制到一个新目标,并设置 sniffed: None。凡是一条流派生出其他流的地方都会用到它:
| 调用方 | 新流 | 为什么 sniffed 应该为空 |
|---|---|---|
Demux,收到 New 时 |
一个 mux 子流 | 承载流的值与子流无关。子流自己的值紧接着根据其 New payload 设置。 |
TrojanCore,UDP |
关联的第一个包 | UDP 流不嗅探。 |
app/src/outbound/udp_fanout.rs → FanOutLink::poll_send_to |
每个数据报一条流,用于路由并打开其出站 | 每个包都有自己的目标,为某个目标恢复出的名称不得用来路由发往其他目标的包。 |
AppConnector::connect 把每条 UDP 流都变成一个 FanOutLink,后者用 route_target(&self.flow.toward(to.clone()), &self.ctx) 路由每个包。这就是 UDP mux 子流上的嗅探值从不被读取的原因,也是发往 IP 的 UDP 包只能匹配非域名规则的原因。katana 的 UDP FanOut 出于同样的理由,以 None 调用其 route_target。
| 不变量 | 保证机制 | 对应测试 |
|---|---|---|
| 嗅探从不让连接失败。 | 每个嗅探器都返回 Option,离开嗅探状态的每条路径都经过 open_sniffed。 |
non_tls_and_malformed_input_yield_nothing(protocols/tests/unit/sniff/tls.rs)、an_unsniffable_payload_falls_through_to_the_default(app/tests/integration/e2e_sniff.rs) |
| 解析器不会 panic,也不会越界读取。 | checked_add、slice::get 和 u16_at;crate 的 deny(clippy::indexing_slicing, clippy::arithmetic_side_effects, clippy::unwrap_used, clippy::expect_used)。 |
non_tls_and_malformed_input_yield_nothing、a_truncated_client_hello_yields_nothing_rather_than_garbage |
| 被截断的 ClientHello 只会得到真实的 SNI 或什么也得不到。 | 每个长度都与实际存在的字节比对;读取不足时以 None 结束。 |
a_truncated_client_hello_yields_nothing_rather_than_garbage |
跨多次读取的请求会被作为整体重新检查,且每个字节都被保留。(Host 取值在名称中间被截断是例外,见 HTTP 解析器一节。) |
Collector::push 对整个缓冲区重新运行 sniff。 |
a_host_split_across_pushes_is_found_once_complete、sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes(两者都在请求头名称内部切分) |
持有的字节不超过 SNIFF_LIMIT。 |
SniffPrefix::push 取 min(len, remaining());collect_prefix 最多读取 remaining()。 |
sniff_prefix_takes_no_more_than_its_budget(protocols/tests/unit/core/mod.rs)、the_budget_ends_the_search_without_a_match |
任何流等待名称的时间都不超过 SNIFF_TIMEOUT。 |
Timing::enter(Phase::Sniff) 设置截止时间,touch 从不延长它;SOCKS 中针对一个固定截止时间使用 timeout_at。 |
timing_reports_handshake_and_sniff_expiry_to_the_core、a_sniffing_passthrough_core_opens_on_the_sniff_deadline、sniff_deadline_opens_with_what_was_collected(protocols/tests/unit/trojan/core.rs) |
| 给出域名的流从不被延迟。 | 进入该阶段前检查 worth_sniffing。 |
a_domain_target_is_never_sniffed、a_sniffing_passthrough_core_with_a_domain_target_opens_at_once |
| 收集到的字节按顺序到达出站,并先于后续 payload。 | Effect::ForwardHeld 紧跟在 Effect::Open 之后推入;运行时的 held 缓冲区规则。 |
held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites(concepts/tests/runtime.rs)、new_server_vs_new_client_tcp(protocols/tests/pipeline/socks.rs) |
| IP 字面量永远不会成为嗅探名称。 | plausible_domain 拒绝任何能解析为 IpAddr 的内容。 |
domain_plausibility、an_ip_literal_sni_is_rejected、an_ip_host_is_rejected |
只是碰巧包含 Host: 的字节不算 HTTP。 |
request_target 要求列表中的方法和 HTTP/ 版本。 |
bytes_that_merely_contain_a_host_header_are_not_http |
| 提前应答的协议核心只应答一次。 | 提前应答之后调用 open(flow, false, fx),因此 Connected 不暂存任何内容。 |
connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix(protocols/tests/unit/http/core.rs)、sniffing_an_ip_target_answers_early_and_opens_with_the_host(protocols/tests/unit/hysteria/server/inbound.rs) |
sniffing = false 会让检查本身停止。 |
该标志控制是否进入嗅探阶段,因此不会持有字节,也不会启动时间窗口。 | turning_sniffing_off_stops_the_domain_rule_matching 检查的是结果(域名规则不再匹配);没有测试观察到等待确实消失了。 |
| 嗅探到的名称只是增加一个可供匹配的域名。 | 路由模型中的 Domains::any;出站拨号 flow.destination。 |
a_sniffed_domain_makes_an_ip_target_match_domain_rules、a_sniffed_domain_feeds_geosite_too(environment/tests/unit/routing.rs)、a_tcp_connection_becomes_a_stream(protocols/tests/pipeline/tun.rs) |
失败路径与取消
Section titled “失败路径与取消”- 无法识别或格式错误的字节:collector 一直返回
More,直到预算或时间窗口结束。随后流以sniffed: None打开,收集到的字节原样转发。 - 截止时间:
Phase::Sniff中的Event::Deadline映射为Expired::Sniff,协议核心调用open_sniffed。请求解析完成后,握手截止时间不再生效,因为enter已经取代了它。 - 嗅探期间客户端 EOF:sans-I/O 协议核心在嗅探状态下处理
Event::TransportEof(在 VMess 和 legacy Shadowsocks 中还包括 chunk 流的结束标记)时,先调用open_sniffed,再调用Passthrough::on_transport_eof。出站仍会被拨号,收到已收集的内容,然后看到半关闭。在 SOCKS 中,读到零字节或读取出错都会让collect_prefix提前结束,连接照常用已读到的内容继续。 - 提前应答后连接失败:见上面的警告。没有提前应答时,各协议按其常规方式报告连接失败。
- 暂存失败:
fx.stage(...).ok_or_else(staging_full)?返回io::Error::other("staging room below the core's declared reserve")。只要协议核心的STAGING_RESERVE能容纳提前应答,这种情况就不会发生。 - 取消:嗅探不拥有任何 task、channel 或锁。在 sans-I/O 协议核心中,收集到的字节存放在协议核心内,随其一同被丢弃。在 SOCKS 中,丢弃
servefuture 会同时丢弃正在进行的timeout_at读取和Collector。
| 名称 | 值 | 位置 | 含义 |
|---|---|---|---|
SNIFF_TIMEOUT |
300 ms | protocols/src/sniff/mod.rs |
从进入嗅探阶段起,等待可识别前缀的最长时间。 |
SNIFF_LIMIT |
4096 字节 | protocols/src/sniff/mod.rs |
每条流最多检查和持有的字节数,跨帧计算。 |
MAX_DOMAIN_LEN |
253 | protocols/src/sniff/mod.rs |
去掉末尾的点之后,可接受名称的最大长度。 |
AFTER_RANDOM |
38 | protocols/src/sniff/tls.rs |
legacy_session_id 在握手 body 中的偏移。 |
HANDSHAKE_TIMEOUT |
10 s | protocols/src/core/mod.rs |
在嗅探之前、解析请求期间生效。 |
RELAY_IDLE_TIMEOUT |
300 s | protocols/src/core/mod.rs |
流打开时设置。 |
修改这些值时需要了解的行为:
- 如果一条流的首条消息已经完整但不带名称(没有 SNI 的 ClientHello、没有
Host的 HTTP 请求),它会等满整个SNIFF_TIMEOUT,因为两个嗅探器都无法给出“肯定不是”的回答,collector 会一直要求更多数据。服务端先发言的协议,以及任何在等待应答前发送少于SNIFF_LIMIT字节的其他客户端先发言协议,也会遇到同样的等待。关闭sniffing,或让客户端发送域名,都能消除这段等待。 SniffPrefix::new会预留SNIFF_LIMIT字节,而每个支持嗅探的协议核心都会构造一个,因此每条这样的连接无论是否嗅探都带着这份预留。clear会构造一个新的Collector,再次预留同样大小,而协议核心在每个中继字节事件时都会调用它,所以这份预留会贯穿整个连接。
运行 cargo test -p etemenanki-protocols sniff 可以测试解析器和 collector,运行整个 crate 的测试可以覆盖各协议核心。app 的端到端测试会驱动真实的 Xray 客户端;找不到 Xray 可执行文件时,它们会提前返回并视为通过。
| 文件 | 测试 | 覆盖内容 |
|---|---|---|
protocols/tests/unit/sniff/mod.rs |
domain_plausibility |
末尾的点、空字符串、.、空格、254 个字符、IPv4 和 IPv6 字面量。 |
protocols/tests/unit/sniff/collector.rs |
a_host_split_across_pushes_is_found_once_complete、the_budget_ends_the_search_without_a_match |
跨多次 push 先得到 More 再得到 Found,并保留完整前缀;恰好达到 SNIFF_LIMIT 时得到 Exhausted。 |
protocols/tests/unit/sniff/tls.rs |
extracts_sni_from_a_real_client_hello、a_truncated_client_hello_yields_nothing_rather_than_garbage、an_ip_literal_sni_is_rejected、non_tls_and_malformed_input_yield_nothing、hand_built_and_real_hellos_agree |
用 OpenSSL 生成的 ClientHello 测试解析器,多个位置的截断,SNI 为 IP 的手工构造 hello,以及超出缓冲区的长度。 |
protocols/tests/unit/sniff/http.rs |
extracts_the_host_header、host_header_port_and_case_are_normalised_away、absolute_form_authority_wins_over_the_host_header、userinfo_is_stripped_from_an_absolute_target、bytes_that_merely_contain_a_host_header_are_not_http、an_ip_host_is_rejected、a_request_with_no_host_at_all_yields_nothing、a_binary_body_does_not_defeat_header_parsing |
HTTP 解析器的每一条规则。 |
protocols/tests/unit/core/mod.rs |
timing_reports_handshake_and_sniff_expiry_to_the_core、sniff_prefix_finds_a_host_across_pushes_and_keeps_the_bytes、sniff_prefix_takes_no_more_than_its_budget、a_sniffing_passthrough_core_holds_the_prefix_and_opens_with_the_host、a_sniffing_passthrough_core_opens_on_the_sniff_deadline、a_sniffing_passthrough_core_with_a_domain_target_opens_at_once |
Phase::Sniff 截止时间、SniffPrefix 的预算和 clear,以及准确的 effect 顺序 Open、ForwardHeld、SetDeadline(RELAY_IDLE_TIMEOUT)。 |
protocols/tests/unit/http/core.rs |
connect_to_an_ip_with_sniffing_replies_early_and_holds_the_prefix |
提前的 200、嗅探时间窗口,以及 Connected 时不发送第二个 200。 |
protocols/tests/unit/hysteria/server/inbound.rs |
sniffing_an_ip_target_answers_early_and_opens_with_the_host |
提前发送的 “Connected” TCPResponse,且没有第二个响应。 |
protocols/tests/unit/trojan/core.rs |
sniffing_an_ip_target_holds_the_prefix_until_a_host_is_found、sniff_deadline_opens_with_what_was_collected、a_domain_target_is_never_sniffed |
进入条件、截止时间和 effect 序列。 |
protocols/tests/unit/vless/core.rs、vmess/core.rs、ss_legacy/core.rs、ss_2022/core.rs |
sniffing_holds_the_prefix_then_opens_with_the_domain、sniffing_reads_chunks_until_a_host_appears、sniffing_an_ip_target_waits_for_a_recognisable_prefix、sniffing_reads_records_until_a_host_appears |
跨明文事件、加密 chunk 和 record 的收集。 |
protocols/tests/pipeline/socks.rs |
new_server_vs_new_client_tcp、new_server_refuses_an_unreachable_target_after_trying |
开启嗅探且目标为 IP 时,100 000 字节完整往返,说明前缀先于中继被转发;关闭嗅探时,拒绝应答能到达客户端。 |
protocols/tests/pipeline/tun.rs |
a_tcp_connection_becomes_a_stream |
发往 IP 的 TUN TCP 流根据其 Host 请求头带上 sniffed = example.com,请求字节原样到达出站。 |
concepts/tests/runtime.rs |
held_bytes_are_forwarded_after_the_dial_and_survive_later_rewrites、a_held_range_past_the_buffer_is_rejected |
held 缓冲区规则在运行时一侧的实现。 |
environment/tests/unit/routing.rs |
a_sniffed_domain_makes_an_ip_target_match_domain_rules、a_sniffed_domain_feeds_geosite_too |
域名和 geosite 匹配条件会参考嗅探到的名称。 |
app/tests/integration/e2e_sniff.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 |
经由 VLESS 入站的端到端测试:只有按域名编写的规则通向 freedom,所以有字节返回就证明是嗅探到的名称路由了这条流。 |
Demux 对 New payload 的嗅探,以及 Flow::toward 中的重置,都没有专门的测试。修改其中任何一处时,都应附带一个测试。