流量探测(Sniffing / Sniffer)
本页把「嗅探(sniffer / sniffing)」从「路由」章节中独立出来,集中说明:
- 嗅探在核心中的执行位置与开关逻辑(代码路径)
- 嗅探结果如何影响路由与目标地址(
destOverride/routeOnly/attrs) - 每个协议的嗅探过程与实现细节(逐协议,按代码解释)
配置入口仍然在入站
sniffing字段,见 入站代理。
执行位置(代码路径)
嗅探发生在路由之前,由 Dispatcher 读取连接的前置数据进行识别:
- 触发入口:
app/dispatcher/default_dispatch.go(*DefaultDispatcher).DispatchLink(...)(*DefaultDispatcher).Dispatch(...)
- 读取与缓存(不消费流量):
cachedReader(app/dispatcher/default_cached_reader.go) - 组织嗅探器列表:
NewSniffer(ctx)(app/dispatcher/sniffer.go) - 实际嗅探循环与超时:
sniffer(...)(app/dispatcher/default_sniffing.go)
核心会把嗅探到的结果写入 session.Content,供路由规则读取(routing.rules.protocol / routing.rules.attrs 等)。
什么时候会嗅探(触发条件)
嗅探并不是“永远发生”,它由两类条件触发:
入站启用 sniffing
- 当入站配置
sniffing.enabled=true时,会对该连接执行嗅探。 - 只有在这种情况下,才允许执行「重置目标地址」(
destOverride/routeOnly)。
- 当入站配置
路由驱动嗅探(protocol-only)
- 即使
sniffing.enabled=false,当路由规则需要某些协议识别时,核心也可能为了匹配routing.rules.protocol而主动嗅探。 - 当前支持路由驱动嗅探的协议:
wireguard/mtproto/zerotier/ech/rdp/mqtt/ntp/postgres/ikev2/dtls/trojan - 代码:
DefaultDispatcher.shouldEnableProtocolSniffing→Router.ShouldSniffProtocolForContext(app/dispatcher/default_sniffing.go、app/router/router.go)
- 即使
DNS / ECH 的特殊开关
dns:只有同时满足以下条件才会加入 DNS sniffer(app/dispatcher/sniffer.go)- 入站
sniffing.enabled=true - 路由规则中存在且需要
protocol: "dns"(按当前路由上下文判断)
- 入站
ech:只有当路由规则需要protocol: "ech"时才会加入 ECH sniffer;它也属于路由驱动嗅探的一部分。
Trojan 的特殊开关
trojan 嗅探属于路由驱动嗅探:只有当路由规则中存在且需要 protocol: "trojan"(按当前路由上下文判断)时才会启用。
Trojan 嗅探是基于 TLS ClientHello 的保守指纹识别,不提取域名;核心会把它与 TLS 的域名识别结果组合(CompositeResult),以避免影响域名路由/目标重置。
metadataOnly
当入站配置 sniffing.metadataOnly=true 时,核心只运行元数据嗅探器(metadata sniffer),不会读取 payload 内容进行协议识别。
目前主要用于 FakeDNS 反查(见下文「FakeDNS」)。
超时与重试(嗅探为何会“失败”)
嗅探只读取连接建立后的少量前置数据,不会无限等待:
- 初始缓存窗口:200ms(
sniffer()中的cacheDeadline) - 对于返回
common.ErrNoClue的情况,最多尝试 2 次 - 对于返回
protocol.ErrProtoNeedMoreData的情况,会继续读取更多数据(不消耗尝试次数),直到超时
这意味着:
- 某些协议如果首包不足,可能需要多次读取才能识别(例如 TLS 分片 ClientHello、QUIC 多包 CRYPTO 数据、DNS over TCP 的长度前缀等)
- 如果客户端在 200ms 内没有发送足够数据,可能触发
timeout on sniffing
嗅探结果如何影响路由与目标地址
SniffResult:输出字段
嗅探器返回 SniffResult(app/dispatcher/sniffer.go):
Protocol() string:当前连接的“协议类型”,用于routing.rules.protocolDomain() string:提取到的域名(可能为空);用于destOverride/routeOnly
部分嗅探结果还实现可选接口:
ALPN() string:用于写入attrs["alpn"],并支持路由protocol中的alpn:*语法(见 路由)
attrs:路由可用的属性
嗅探过程中核心会写入一些属性到 session.Content.Attributes(common/session/session.go、app/dispatcher/default_sniffing.go):
alpn:来自http/tls/quic/ech(若能提取)tls_sni:仅当 TLS 嗅探成功后写入,1表示存在 SNI,0表示无 SNItls_version:TLS 版本(优先取 ClientHello 的supported_versions,否则回退到ClientHellolegacy 版本)- HTTP/1.x headers:HTTP 嗅探会把请求头写入 attrs(键名统一转小写),并额外写入
:method、:path
routing.rules.attrs 匹配示例:
- 检测 HTTP GET:
{":method": "GET"} - 检测 HTTP Path:
{":path": "/test"} - 检测 Content Type:
{"accept": "text/html"} - 检测 TLS 无 SNI:
{"tls_sni": "^0$"} - 检测 TLS 有 SNI:
{"tls_sni": "^1$"} - 检测 TLS 1.3:
{"tls_version": "^1\\.3$"}
ALPN 路由示例(对应 routing.rules.protocol):
{
"routing": {
"rules": [
{
"type": "field",
"protocol": ["alpn:h2"],
"destination": "h2-upstream"
},
{
"type": "field",
"protocol": ["alpn:apns-security-v3"],
"destination": "apns-upstream"
},
{
"type": "field",
"protocol": ["alpn"],
"destination": "any-alpn"
}
]
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
alpn:h2与alpn=H2等价(前缀匹配,不区分大小写);protocol支持!反匹配(如!alpn:h2):- 同时含正向与反向项时:至少命中一个正向项,且不能命中任一反向项;
- 仅含反向项时:只要不命中反向项即命中。
destOverride / routeOnly:目标地址重置与“仅用于路由”
目标地址的处理逻辑在 app/dispatcher/default_dispatch.go / app/dispatcher/default_sniffing.go:
- 只有当入站
sniffing.enabled=true时,才会根据destOverride进行目标地址重置(DefaultDispatcher.shouldOverride) - 若
routeOnly=true,则嗅探到的域名只会写入RouteTarget供路由使用,实际出站仍连接原始 IP(Target不变) - DNS 嗅探为特殊情况:即使命中,也只会写入
RouteTarget,避免把“DNS 查询域名”当成真实 dial 目标
支持的嗅探协议
按“是否可提取域名”分为两类:
- 可提取域名:
http(http1)、tls、quic、dns、fakedns - 仅用于协议识别(用于
routing.rules.protocol):http2、ech、wireguard、mtproto、zerotier、rdp、mqtt、ntp、postgres(postgres+ssl/postgres+gssenc/postgres+cancel)、ikev2、dtls、bittorrent(含 uTP)、stun/turn、socks(socks4/socks5)、ssh、trojan
其中
dns/ech/ 路由驱动嗅探协议存在额外启用条件,见上文「什么时候会嗅探」。
各协议嗅探过程与代码逻辑
下面按协议列出其嗅探入口、触发条件与识别细节。路径均以 Xray-core 源码为准。
HTTP(http1 / http2)
- 入口:
common/protocol/http/sniff.go→SniffHTTP - 网络:TCP
- 识别逻辑:
- HTTP/2:匹配连接前言
PRI * HTTP/2.0\\r\\n\\r\\nSM\\r\\n\\r\\n,命中则返回Protocol=http2- 仅识别协议与 ALPN(
h2),不解析域名
- 仅识别协议与 ALPN(
- HTTP/1.x:
- 先判断是否以常见方法名开头(GET/POST/HEAD/PUT/DELETE/OPTIONS/CONNECT)
- 按行拆分 header,读取
Host:并用ParseHost解析,得到域名 - 命中则返回
Protocol=http1、Domain=<host>
- HTTP/2:匹配连接前言
- attrs 写入:
- 若当前连接
Content.Attributes为空,会把所有 header 写入 attrs(键小写) - 并额外写入
:method、:path(来自请求行)
- 若当前连接
TLS
- 入口:
common/protocol/tls/sniff.go→SniffTLS/ReadClientHello - 网络:TCP
- 识别逻辑:
- 校验 TLS record(ContentType=Handshake 0x16,版本 major=3)
- 提取 ClientHello 扩展:
- SNI(扩展 0x00)→
Domain - ALPN(扩展 0x10)→
ALPN
- SNI(扩展 0x00)→
- 支持 ClientHello 跨多个 TLS record 分片:最多拼接 8 个 record
protocol.ErrProtoNeedMoreData:- 当已确认是 ClientHello,但当前缓存不足以拼出完整握手数据时会返回,让 Dispatcher 继续读数据而不消耗尝试次数
- 输出:
Protocol=tlsDomain可能为空(无 SNI)ALPN取列表中的第一个
- attrs:
- Dispatcher 会写入
attrs["alpn"](若非空) - Dispatcher 会写入
attrs["tls_sni"](1/0)
- Dispatcher 会写入
QUIC
- 入口:
common/protocol/quic/sniff.go→SniffQUIC - 网络:UDP
- 识别逻辑(高层流程):
- 解析 QUIC Long Header Initial(支持 draft-29 与 v1)
- 基于 RFC9001 的初始密钥派生,解除 Header Protection 并解密 Initial payload
- 解析帧,仅收集 CRYPTO frame 的数据(按 offset 拼接)
- 从 CRYPTO 数据中解析 TLS ClientHello(复用
common/protocol/tls.ReadClientHello),提取 SNI/ALPN
- 多包情况:
- 如果当前包的 CRYPTO 数据不足以解析 ClientHello,会继续解析后续 UDP payload
- 若全部 payload 都是合法 QUIC 包但仍缺数据,返回
protocol.ErrProtoNeedMoreData
- 输出:
Protocol=quic、Domain=<sni>、ALPN=<alpn>
DNS
- 入口:
common/protocol/dns/sniff.go→SniffDNS - 网络:TCP / UDP
- 启用条件(重要):
- 只有当路由规则需要
protocol:"dns"且入站sniffing.enabled=true时才会启用(由 Dispatcher/Router 共同判断)
- 只有当路由规则需要
- 识别逻辑:
- UDP:直接解析 DNS 报文第一个 Question 的域名(小写,去掉尾随
.) - TCP:先读取 2 字节长度前缀,再按长度取出 DNS payload 解析
- UDP:直接解析 DNS 报文第一个 Question 的域名(小写,去掉尾随
protocol.ErrProtoNeedMoreData:- DNS over TCP 长度前缀/报文不完整时返回
- 输出:
Protocol=dns、Domain=<qname> - 目标处理:
- 命中后只会影响
RouteTarget(用于路由),不会把查询域名当作真实 dial 目标
- 命中后只会影响
FakeDNS(metadata sniffer)
- 入口:
app/dispatcher/fakednssniffer.gonewFakeDNSSniffer(元数据嗅探器)newFakeDNSThenOthers(FakeDNS miss 时的“尽力恢复”)
- 类型:metadata sniffer(不读 payload)
- 启用条件:
- 核心启用 FakeDNS,并能在 ctx 中取得
dns.FakeDNSEngine
- 核心启用 FakeDNS,并能在 ctx 中取得
- 识别逻辑:
- 对当前连接的
Outbound.Target.Address执行FakeDNSEngine.GetDomainFromFakeDNS(ip)反查 - 命中则返回
Protocol=fakedns、Domain=<domain>
- 对当前连接的
fakedns+others:- 当目标 IP 位于 FakeDNS IP 池,但反查 miss 时,会尝试运行其它嗅探器提取域名
- 返回
Protocol=fakedns+others,并保留“原始嗅探协议”信息用于目标重置匹配
ECH
- 入口:
common/protocol/ech/sniff.go→SniffECH - 网络:TCP
- 启用条件:
- 只有当路由规则需要
protocol:"ech"时才会启用(用于路由识别)
- 只有当路由规则需要
- 识别逻辑:
- 解析 TLS ClientHello(首个 record),检查是否包含 ECH 扩展
0xfe0d - 同时尽力提取 ALPN(用于
alpn:*路由)
- 解析 TLS ClientHello(首个 record),检查是否包含 ECH 扩展
- 输出:
Protocol=echDomain永远为空(避免基于 Outer SNI 做目标地址重置)
WireGuard
- 入口:
common/protocol/wireguard/sniff.go→SniffWireGuard - 网络:UDP
- 路由驱动:支持
- 识别逻辑:
- WireGuard 报文类型为 little-endian uint32,且高 3 字节为 0
- 根据报文类型(握手/数据)检查最小/固定长度
- 输出:
Protocol=wireguard
MTProto
- 入口:
app/dispatcher/mtprotosniffer.go→sniffMTProto - 网络:TCP
- 路由驱动:支持
- 识别逻辑(仅明文 transport):
- Abridged:首字节
0xef,后续按长度字段(含0x7f + 3 字节扩展长度)校验 - Intermediate:前 4 字节
0xeeeeeeee,后接 little-endian 长度 - Padded Intermediate:前 4 字节
0xdddddddd,后接 little-endian 长度 - 长度按 MTProto 4 字节对齐规则校验
- Abridged:首字节
- 输出:
Protocol=mtproto - 说明:不尝试识别 MTProxy 的 obfuscated/fake-tls 形态
ZeroTier
- 入口:
app/dispatcher/zerotiersniffer.go→sniffZeroTier - 网络:UDP
- 路由驱动:支持
- 识别逻辑(尽力识别,不绑定端口):
- 分片头特征:检查 fragment indicator、fragNo/fragTotal、hops 等范围
- 明文 HELLO:检查 flags/cipher/verb/proto version,以及时间戳(ms since epoch)范围
- 输出:
Protocol=zerotier
RDP
- 入口:
common/protocol/rdp/sniff.go→SniffRDP - 网络:TCP
- 路由驱动:支持
- 识别逻辑:
- 匹配 TPKT(version=3)+ X.224 Connection Request(TPDU type=0xe0)
- 输出:
Protocol=rdp
MQTT
- 入口:
common/protocol/mqtt/sniff.go→SniffMQTT - 网络:TCP
- 路由驱动:支持
- 识别逻辑:
- 匹配 CONNECT(0x10)
- 解析 Remaining Length(变长整数)
- 校验协议名(
MQTT/MQIsdp)、协议版本、flags 保留位等
protocol.ErrProtoNeedMoreData:- 报文看起来像 CONNECT 但长度不足以验证时返回
- 输出:
Protocol=mqtt
NTP
- 入口:
common/protocol/ntp/sniff.go→SniffNTP - 网络:UDP
- 路由驱动:支持
- 识别逻辑:
- 报文至少 48 字节,且总长度为 4 的倍数
- 匹配客户端模式(mode=3),版本号范围(1..4),stratum=0
- 输出:
Protocol=ntp
Postgres
- 入口:
common/protocol/postgres/sniff.go→SniffPostgres - 网络:TCP
- 路由驱动:支持
- 识别逻辑:
- 读取 StartupMessage/SSLRequest 等前 8 字节(msgLen + code)
- 根据 code 返回不同协议名:
postgrespostgres+sslpostgres+gssencpostgres+cancel
- 输出:以上之一
IKEv2
- 入口:
common/protocol/ike/sniff.go→SniffIKEv2 - 网络:UDP
- 路由驱动:支持
- 识别逻辑:
- IKE header 至少 28 字节
- 版本字节为 0x20(IKEv2)
- exchange type 为常见值(IKE_SA_INIT / IKE_AUTH / CREATE_CHILD_SA / INFORMATIONAL)
- 输出:
Protocol=ikev2
DTLS
- 入口:
common/protocol/dtls/sniff.go→SniffDTLS - 网络:UDP
- 路由驱动:支持
- 识别逻辑:
- DTLS record header 13 字节
- 校验 content type(20/21/22/23)与版本(0xfeff/0xfefe/0xfefd)
- 校验 fragment length 不越界
- 输出:
Protocol=dtls
BitTorrent(含 uTP)
- 入口:
common/protocol/bittorrent/bittorrent.go- TCP:
SniffBittorrent - UDP(uTP):
SniffUTP
- TCP:
- 网络:TCP / UDP
- 识别逻辑:
- TCP:匹配握手头
0x13 + "BitTorrent protocol" - UDP(uTP):校验 type/version、扩展链,并对 timestamp 做合理性校验(与当前时间差不超过 24h)
- TCP:匹配握手头
- 输出:
Protocol=bittorrent
STUN / TURN
- 入口:
common/protocol/stun/sniff.go→SniffSTUN - 网络:TCP / UDP
- 识别逻辑:
- 校验 STUN magic cookie
0x2112A442、消息长度、对齐等 - 根据 method 判断是
stun还是turn
- 校验 STUN magic cookie
- 输出:
Protocol=stun或Protocol=turn
SOCKS(4/5)
- 入口:
common/protocol/socks/sniff.go→SniffSOCKS4/SniffSOCKS5 - 网络:TCP
- 识别逻辑:
- SOCKS5:
0x05+ nMethods - SOCKS4:
0x04+ cmd + 最小结构(含 0 结尾 user id)
- SOCKS5:
- 输出:
Protocol=socks4/Protocol=socks5- 路由规则可用
protocol: ["socks"]进行前缀匹配
- 路由规则可用
SSH
- 入口:
common/protocol/ssh/sniff.go→SniffSSH - 网络:TCP
- 识别逻辑:
- 匹配 banner 前缀
SSH- - 在最多 255 字节内出现换行
- 匹配 banner 前缀
- 输出:
Protocol=ssh
Trojan
- 入口:
common/protocol/trojan/sniff.go→SniffTrojan(TLS ClientHello 指纹)app/dispatcher/sniffer.go(与 TLS 结果合并)
- 网络:TCP
- 启用条件:路由规则需要
protocol: "trojan"(按当前路由上下文判断) - 识别逻辑(保守指纹):
- TLS ClientHello:有 SNI、无 ALPN、无 GREASE
- cipher suites 数量在 [10, 20]
- 输出:
Protocol=trojanDomain不直接由 Trojan 提供;核心会将其与 TLS 的域名结果组合,确保域名路由/目标重置仍可用