跳转到内容

嗅探

源码文件:51 个 · 核对版本 Etemenanki 596916d · katana v3.0.1
  • Etemenanki/protocols/src/sniff/mod.rs
  • Etemenanki/protocols/src/sniff/collector.rs
  • Etemenanki/protocols/src/sniff/tls.rs
  • Etemenanki/protocols/src/sniff/http.rs
  • Etemenanki/concepts/src/sniff.rs
  • Etemenanki/concepts/src/core.rs
  • Etemenanki/concepts/src/runtime.rs
  • Etemenanki/protocols/src/lib.rs
  • Etemenanki/protocols/src/core/mod.rs
  • Etemenanki/protocols/src/flow.rs
  • Etemenanki/protocols/src/http/core.rs
  • Etemenanki/protocols/src/socks/server.rs
  • Etemenanki/protocols/src/hysteria/server/inbound.rs
  • Etemenanki/protocols/src/trojan/core.rs
  • Etemenanki/protocols/src/vless/core.rs
  • Etemenanki/protocols/src/vmess/core.rs
  • Etemenanki/protocols/src/ss_legacy/core.rs
  • Etemenanki/protocols/src/ss_2022/core.rs
  • Etemenanki/protocols/src/tun/inbound.rs
  • Etemenanki/protocols/src/mux/demux.rs
  • Etemenanki/environment/src/routing.rs
  • Etemenanki/app/src/config.rs
  • Etemenanki/app/src/inbound/mod.rs
  • Etemenanki/app/src/inbound/tun.rs
  • Etemenanki/app/src/serve.rs
  • Etemenanki/app/src/router.rs
  • Etemenanki/app/src/connector.rs
  • Etemenanki/app/src/outbound/udp_fanout.rs
  • Etemenanki/protocols/tests/unit/sniff/mod.rs
  • Etemenanki/protocols/tests/unit/sniff/collector.rs
  • Etemenanki/protocols/tests/unit/sniff/tls.rs
  • Etemenanki/protocols/tests/unit/sniff/http.rs
  • Etemenanki/protocols/tests/unit/core/mod.rs
  • Etemenanki/protocols/tests/unit/http/core.rs
  • Etemenanki/protocols/tests/unit/hysteria/server/inbound.rs
  • Etemenanki/protocols/tests/unit/trojan/core.rs
  • Etemenanki/protocols/tests/unit/vless/core.rs
  • Etemenanki/protocols/tests/unit/vmess/core.rs
  • Etemenanki/protocols/tests/unit/ss_legacy/core.rs
  • Etemenanki/protocols/tests/unit/ss_2022/core.rs
  • Etemenanki/protocols/tests/pipeline/socks.rs
  • Etemenanki/protocols/tests/pipeline/tun.rs
  • Etemenanki/concepts/tests/runtime.rs
  • Etemenanki/environment/tests/unit/routing.rs
  • Etemenanki/app/tests/integration/e2e_sniff.rs
  • katana/src/config.rs
  • katana/src/manager/node.rs
  • katana/src/inbound.rs
  • katana/src/serve.rs
  • katana/src/router.rs
  • katana/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 的路由器无需依赖解析器就能引用嗅探结果。

concepts/src/sniff.rs
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 请求解析器)。

protocols/src/sniff/mod.rs 包含两个限制值,以及所有调用方都会用到的三个自由函数:

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 按固定顺序运行已实现的嗅探器:

protocols/src/sniff/mod.rs
pub fn sniff(data: &[u8]) -> Option<SniffedBehavior> {
TlsSniffer.sniff(data).or_else(|| HttpSniffer.sniff(data))
}

TLS 排在前面,是因为 record 头比请求行的区分度高得多。两者不可能同时匹配同一段字节:TLS 嗅探器要求第一个字节是 0x16,HTTP 嗅探器要求缓冲区以大写的方法名开头。

worth_sniffing 是每个调用方在进入嗅探阶段前都要检查的关口:

protocols/src/sniff/mod.rs
pub fn worth_sniffing(destination: &Destination) -> bool {
matches!(destination.remote, Remote::IpAddr(_))
}

目标已经给出域名的流会按该域名路由,为它等待只会让每条这样的流多花最多 SNIFF_TIMEOUT,却毫无收获。这个检查只看 remote,不看 network:把 UDP 排除在外是调用方的职责,调用方对数据报请求从不进入该阶段。

protocols/src/sniff/collector.rs 是嗅探中不依赖时钟的那一半:一个有上限的累加器,每次追加字节后都会对它持有的全部内容重新运行 sniff。

protocols/src/sniff/collector.rs
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 之外,两类调用方就能共用它,单元测试也无需运行时即可驱动它。

每个支持嗅探的 sans-I/O 协议核心都组合了一个来自 protocols/src/core/mod.rs 的 SniffPrefix。它包装一个 Collector,并使其适配协议核心的约定:handle 返回它消耗了事件中的多少字节。

protocols/src/core/mod.rs
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 缓冲区规则)。

同一文件还定义了嗅探所依托的阶段和截止时间机制:

protocols/src/core/mod.rs
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。随后协议核心用已持有的内容打开流。

结果随流本身传递,定义在 protocols/src/flow.rs 中:

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 从干净状态开始。

protocols/src/sniff/tls.rs → TlsSniffer 依次走过 record 头、握手头和扩展列表,直到 server_name 扩展(RFC 8446 第 4.1.2 节、RFC 6066 第 3 节)。它只读取所需的部分,并忽略所有版本字段。

protocols/src/sniff/tls.rs
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。
  1. 检查 content type,读取 record 长度并切出 body。record 长度是上限而非保证:字节仍在到达时,缓冲区只持有 record 的一个前缀,遍历会因字节不足返回 None,下一次 Collector::push 再带着更多字节重试。
  2. 检查握手类型,令 p = AFTER_RANDOM。
  3. 跳过三个变长向量,每个都用 p = p.checked_add(width)?.checked_add(length)?。
  4. 读取扩展总长度,计算 ext_end 并截断到 body.len()。
  5. 当 p < ext_end 时,读取类型和长度,取 body.get(at..at + len)?,在第一个类型为 0x0000 的扩展处返回 server_name(edata);否则跳过该扩展。
  6. 在 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。

无论对端发送什么,每一步都不会 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 错误,与运行时使用的警告级别无关。

protocols/src/sniff/http.rs → HttpSniffer 从 HTTP/1.x 请求中恢复 host。它的首要任务是拒绝那些只是碰巧包含 Host: 的字节。

protocols/src/sniff/http.rs
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>;
  1. 切出请求头。 head_end 是第一个 \r\n\r\n 的位置;如果空行尚未到达(收集期间的常见情况),则为缓冲区末尾。只解码 data[..head_end],且它必须是合法的 UTF-8。请求头之后的 body 从不解码,所以二进制 body 不会破坏解析。
  2. 要求有请求行。 请求头按 \r\n 切分。request_target 按单个空格切分第一行,要求依次是 METHODS 中的一个方法(区分大小写)、一个请求目标,以及以 HTTP/ 开头的第三个字段。否则返回 None。正是这份固定的方法列表防止了二进制 payload 被当作 HTTP 读取,这也意味着 HTTP/2 连接前言(PRI * HTTP/2.0)不会被识别。
  3. 优先使用 absolute-form 的 authority。 如果请求目标以 http:// 或 https:// 开头,authority_of 取到第一个 / 为止的文本,只保留最后一个 @ 之后的部分以去掉 userinfo,去掉端口,再交给 plausible_domain。代理请求使用的就是这种形式。若它得到域名,就优先于任何 Host 请求头;若没有,解析器继续执行下一步。
  4. 否则读取 Host。 第一个名称(第一个 : 之前的文本,不做 trim)在忽略 ASCII 大小写后等于 host 的请求头行提供取值。之后的 Host 行不会再被查看,即使第一个被拒绝也一样。取值经过 trim,strip_port 去掉末尾一个后面只跟数字的 :,最后由 plausible_domain 判定。

对于以 [ 开头的取值,strip_port 不做处理,因此带方括号的 IPv6 字面量会完整地到达 plausible_domain,并因其中的字符在那里被拒绝。

两个解析器最后都经过同一个过滤器,由它决定一个字符串能否用作路由域名:

protocols/src/sniff/mod.rs
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 字面量是有意为之。它无法告诉路由器任何目标地址中没有的信息,而接受它会让客户端用一个并非其通信对象的地址来标记流。这里保留大小写;路由模型在匹配时会把名称转为小写(见 结果如何使用)。

入站的 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 会立即强制换用新一代监听器,节点上已打开的连接随之断开。监听器仍在启动中的节点会在下一次尝试时按新值构建。见 节点如何应用静态更新。
protocols/src/http/core.rs
pub fn new(config: Arc<HttpServerConfig<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/socks/server.rs
pub 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.rs
pub fn new(validator: Arc<Validator<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/vmess/core.rs
pub fn new(
validator: Arc<AccountValidator<T>>,
now: fn() -> i64,
sniff: bool,
source: Option<IpAddr>,
) -> Self;
// protocols/src/ss_legacy/core.rs
pub fn new(inner: Arc<Resolved<T>>, sniff: bool, source: Option<IpAddr>) -> Self;
// protocols/src/ss_2022/core.rs
pub 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.rs
pub 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,并固定到一个端口)。这项检查见关联接收谁的数据报。

每个支持嗅探的单流协议核心都有相同的结构,只是各协议对状态的称呼不同: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),它按顺序做三件事:

  1. flow.sniffed = self.prefix.result(),只有在 Found 之后才是 Some。
  2. 推入带有该流的 Effect::Open;如果收集到了任何内容,再推入 Effect::ForwardHeld { range: 0..held }。
  3. timing.enter(Phase::Relay, fx),把唯一的截止时间重新设置为 RELAY_IDLE_TIMEOUT 空闲上限。

PassthroughCore 没有需要解析的请求头。它在第一个传输层事件时进入 Phase::Sniff,因此它的时间窗口从客户端的第一批字节开始计算。

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 同时携带两者。

支持嗅探的协议核心从线路上消耗收集到的字节,并把它们保存在 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,所有支持嗅探的协议核心都是这样做的。运行时一侧的内容见 服务端运行时。

有三类客户端在发出请求后,会等服务端应答才继续发送数据。嗅探需要 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 遵循同样的顺序:

protocols/src/socks/server.rs
async fn collect_prefix<S: AsyncRead + Unpin>(
stream: &mut S,
) -> (Vec<u8>, Option<etemenanki_concepts::sniff::SniffedBehavior>);
  1. write_granted 发送成功应答。
  2. collect_prefix 创建一个 Collector,把唯一的截止时间固定在 SNIFF_TIMEOUT 之后,然后循环。每一轮在 tokio::time::timeout_at(deadline, …) 下最多读取 remaining() 字节。遇到 More 以外的判定结果、EOF、读取错误、截止时间到达或没有剩余空间时,循环停止,并返回 Collector::take()。
  3. connector.connect(flow) 在设置了 flow.sniffed 的情况下进行路由。
  4. 前缀通过 write_all 写入上游,随后 relay_with_idle_guard 双向中继。

连接之后,connect 根据 prefix.is_empty() 在嗅探路径和普通路径之间选择,而不是根据授权应答是否已发送。如果嗅探运行了但什么也没收集到(客户端在 SNIFF_TIMEOUT 内未发送任何数据、关闭了写端,或读取失败),connect 同样走普通路径:成功时会写入第二个授权应答,客户端会把它当作隧道中最先收到的字节;失败时则在授权应答之后再写入一个拒绝应答。因此,经由 SOCKS 入站、以 IP 为目标且开启嗅探时,服务端先发言的协议(SMTP、MySQL)会在服务端的第一批字节之前多看到一个 SOCKS 应答。在该入站上关闭 sniffing 即可避免。

protocols/src/mux/demux.rs → Demux 为 Trojan、VLESS 和 VMess 内部的 mux.cool 提供服务。承载它的协议核心把自己的 sniff 标志传给 Demux::new(flow, sniff)。

子流在其 New 帧解析完成时就被打开,而承载连接不能为了替某个子流收集字节而停下,否则会阻塞其他所有子流。因此子流只嗅探一次,依据是其 New 帧携带的 payload,不使用 Collector、SNIFF_LIMIT 或截止时间:

protocols/src/mux/demux.rs
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 而言结果从不被读取,见下一节。

每个产品中只有一个函数读取 Flow::sniffed,且只用于构造路由目标:

app/src/router.rs
pub fn route_target<'a>(flow: &'a Flow, ctx: &'a FlowContext) -> routing::RouteTarget<'a>;
katana src/router.rs
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 顺序保持不变。匹配条件的详细说明见 路由模型。

路由完成后,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 把用户和来源复制到一个新目标,并设置 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)
  • 无法识别或格式错误的字节: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 中,丢弃 serve future 会同时丢弃正在进行的 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 中的重置,都没有专门的测试。修改其中任何一处时,都应附带一个测试。