# EasyTier 分析器 识别 [EasyTier](https://github.com/EasyTier/EasyTier) 去中心化 Mesh VPN 的对等连接。 ## 传输方式与可见性 EasyTier 的对等连接可以走六种传输,节点默认全部监听: | scheme | 默认端口 | 线上是什么样 | 谁负责 | | --- | --- | --- | --- | | `tcp://` | 11010 | EasyTier 自己的定长帧,明文头 | **本分析器** | | `udp://` | 11010 | EasyTier 自己的数据报头,明文头 | **本分析器** | | `ws://` | 11011 | HTTP Upgrade,之后是 WebSocket 二进制帧 | `http`(握手可见),帧内容不解 | | `wss://` | 11012 | TLS(自签证书,CN=`localhost`) | `tls`(SNI/证书层面) | | `wg://` | 11013 | 标准 WireGuard 报文 | `wireguard` | | `quic://` | 11010 | QUIC,未设置 ALPN | `quic` | 只有前两种把 EasyTier 的帧结构直接放在网络层上,也是唯二能拿到网络名、对端 ID 这些信息的。下面说的都是这两种。 ## 为什么 `wireguard` 和 `fet` 不够 这两个现有分析器看起来应该能覆盖,实测都不行。 ### `wireguard`:只挡得住六分之一 它匹配的是真正的 WireGuard 报文,也就是 EasyTier 的 `wg://` 传输(默认 11013)。问题在于**节点默认把六种传输全部监听起来**,连接方按拿到的 URI 列表逐个尝试,哪个通用哪个。只挡 `wg://`,mesh 换 `udp://` 照样建起来,而 `udp://` 恰恰是 NAT 穿透场景下的首选。 另外 WireGuard 报文里没有网络名,拦了也不知道拦的是哪个 mesh,而且会一并拦掉合规的 WireGuard VPN。粒度太粗。 `TestEasyTier_WireGuardAnalyzerDoesNotClaimUDPTransport` 这个用例把 `udp://` 上的 Syn、握手、打洞包依次喂给 `wireguard` 分析器,确认它一个都不认。 ### `fet`:判在了整条连接熵最低的那一段上 `fet` 检测「全加密流量」,只看客户端**第一个**分段,判完就 `done`。而 EasyTier 连接的第一个分段永远是明文握手帧,整条连接上唯一不加密的东西。 那一帧长什么样:`to_peer_id` 四个零、`flags`/`reserved` 是零、两个长度字段的高位字节是零、`liveness-echo-v1` 和网络名是 ASCII。实测 `ex1`(平均 popcount)落在 3.4 这条豁免线附近,而且**结果取决于网络名有多长**: | 网络名 | `fet.yes` 命中率(200 组随机 digest) | `ex1` 区间 | | --- | --- | --- | | `a` | 0/200 | 3.045..3.258 | | `mesh` | 0/200 | 3.076..3.283 | | `office-mesh` | 3/200 | 3.242..3.434 | | `my-home-network` | 52/200 | 3.320..3.505 | 握手不带真实 digest 时(跨网络/共享节点场景,字段填 32 个零)更低,四种名字全是 0/200,`ex1` 只有 1.6..2.2。 也就是说 `fet` 在这里是**大概率漏、小概率中**,而且能不能中取决于对方给自己的网络起了个多长的名字。而后续真正加密的数据帧 `ex1` 有 3.92,`fet` 本来能认,但它早就判完退出了。 更麻烦的是漏判的后果:`fet` 报 `yes=false` 且没有别的规则拦截时,引擎会把这条流放进快速通道(connmark bypass),之后的包**根本不再进用户态**。一次漏判就是永久放行,和 `docs/tcp-window-evasion.zh.md` 里描述的是同一个后果。 `TestEasyTier_FETDoesNotCatchTheTCPHandshake` 这个用例把这一点钉住了。 ## 报文结构 三个头,全部小端,全部 `#[repr(C, packed)]`,没有任何对齐填充。 **`UDPTunnelHeader`(8 字节,仅 UDP)** | 偏移 | 字段 | 说明 | | --- | --- | --- | | 0..4 | `conn_id` u32 | 会话 ID,发起方随机选取 | | 4 | `msg_type` u8 | 1=Syn 2=Sack 3=Data 4=Fin\* 5=HolePunch 6=V4HolePunch 7=V6HolePunch(\* 枚举里有,当前实现从不发送) | | 5 | `padding` u8 | 恒为 0,只为对齐长度字段而存在 | | 6..8 | `len` u16 | 后面的字节数,**必须**等于 `数据报长度 - 8` | **`TCPTunnelHeader`(4 字节,仅 TCP)** 一个 u32 长度前缀,值等于后面帧体的字节数。上游要求 `16 ≤ len ≤ max_packet_size`(TCP 隧道 2000,字节流隧道 4096)。 **`PeerManagerHeader`(16 字节,两种传输都有)** | 偏移 | 字段 | 说明 | | --- | --- | --- | | 0..4 | `from_peer_id` u32 | 源节点 ID | | 4..8 | `to_peer_id` u32 | 目的节点 ID,首包为 0 | | 8 | `packet_type` u8 | 见下表,取值 1..=21 | | 9 | `flags` u8 | bit0 加密 bit1 延迟优先 bit2 出口节点 bit3 不代理 bit4 压缩 bit5/7 存活探测 bit6 不送 TUN | | 10 | `forward_counter` u8 | 中继跳数,路由在 >7 时丢弃后再自增,所以线上最大 8 | | 11 | `reserved` u8 | 存活标志位(bit5/bit7)置位时是探测 token,否则恒为 0 | | 12..16 | `len` u32 | 载荷长度 | `packet_type` 取值:1 Data、2 HandShake、3 RoutePacket*、4 Ping、5 Pong、6 TaRpc*、7 Route*、8 RpcReq、9 RpcResp、10 ForeignNetworkPacket、11 KcpSrc、12 KcpDst、13/14/15 NoiseHandshakeMsg1/2/3、16 QuicSrc、17 QuicDst、18/19 DataWith{Kcp,Quic}SrcModified、20 RelayHandshake、21 RelayHandshakeAck(\* = 已废弃但仍占位)。 ### 关键点:头不加密 EasyTier 的三种加密(AES-GCM / ChaCha20 / XOR)**只覆盖载荷**,`PeerManagerHeader` 自始至终是明文,加密只是把 `flags` 的 bit0 置位(`tunnel/encrypt/aes_gcm.rs` 等)。压缩同理,置 bit4。 所以哪怕整个 mesh 开了加密,对端 ID、报文类型、跳数、声明的载荷长度仍然一直挂在明面上。这是本分析器的立足点。 ## 判定信号 分三档,从弱到强。 ### 一、帧结构自洽(`easyTierEvidenceFrame`) 每一帧里有两个互相冗余的长度字段,必须和实际收到的字节数对得上: ``` UDP: len(6..8) == 数据报长度 - 8 TCP: len(0..4) == 帧体长度 两者: PeerManagerHeader.len 与实际载荷长度的关系 ``` 最后一条要分情况,因为**加密和压缩都不更新 `PeerManagerHeader.len`**: | flags | 实际载荷长度 | | --- | --- | | 都没置位 | `== len`,精确相等 | | 仅加密 | `== len`(XOR)或 `== len + 28`(AEAD 的 16 字节 tag + 12 字节 nonce) | | 压缩 | `≤ len + 28`(压缩器只在能省下自己那 1 字节尾时才压) | 再加上 `padding == 0`、`packet_type ∈ 1..=21`、`forward_counter ≤ 8`、`reserved` 的取值约束,单帧大约值三十几个比特。分析器要求**攒够 2 帧**才把 `yes` 置真。 ### 二、UDP 会话握手(`easyTierEvidenceConclusive`) 发起方发一个 16 字节的 `Syn`(`len == 8`,体是随机 u64 magic),对端回一个 16 字节的 `Sack`,**同一个 `conn_id`、同一个 magic**,方向相反。96 比特的巧合概率,一次就够。 单独一个 `Syn` 或 magic 对不上的 `Sack` 只算「见过」,不推进 `yes`。 ### 三、对等握手(`easyTierEvidenceConclusive`) 默认(非 secure)模式下,连接建立后第一帧就是 `packet_type = 2` 的 protobuf `HandshakeRequest`,第一个字段是常量 `magic = 0xd1e1a5e1`。protobuf 编码后是固定的 6 字节: ``` 08 e1 cb 86 8f 0d ``` 在 TCP 上,它落在整条连接载荷的固定偏移 20..26: ``` 0..4 TCPTunnelHeader.len 4..20 PeerManagerHeader(type=2, to_peer_id=0) 20..26 08 e1 cb 86 8f 0d ``` 同一条消息还带着 `network_name`(字段 5,明文)和 32 字节的 `network_secret_digest`(字段 6)。**digest 宽度不对的握手上游会直接拒连**,所以分析器也把它当作否定条件,避免报一个根本连不起来的 mesh 名。 ### 关于 secure mode `--enable-secure-mode` 会把首帧换成 Noise XX 的 msg1(`packet_type = 13`)。Noise XX 的第一条消息此时还没有密钥,载荷是**明文**,所以 32 字节临时公钥后面的 protobuf 仍然能读出 `a_network_name`。 分析器对这段是「能读就读、读不出就不说」:protobuf 必须完整解析、version 合理、网络名是不含控制字符的 UTF-8,否则只置 `secure`,不填 `network_name`。secure mode 下没有 magic 可查,所以要靠第一档攒够 2 帧才判 `yes`。 ## 属性 | 属性 | 类型 | 说明 | | --- | --- | --- | | `yes` | bool | 综合判定。一定存在 | | `transport` | string | `udp` / `tcp`。一定存在 | | `tx_pkt_cnt` / `rx_pkt_cnt` | int | 两个方向已解析的帧数。一定存在 | | `msg_type` | uint8 | UDP 隧道消息类型,仅 UDP | | `msg_type_name` | string | `syn` / `sack` / `data` / `fin` / `hole_punch` / `v4_hole_punch` / `v6_hole_punch` | | `conn_id` | uint32 | UDP 会话 ID,仅 UDP | | `packet_type` | uint8 | `PeerManagerHeader.packet_type` | | `packet_type_name` | string | 上表的可读形式,如 `handshake`、`ping`、`noise_handshake_msg1` | | `from_peer_id` / `to_peer_id` | uint32 | 对端 ID | | `forward_counter` | uint8 | 中继跳数 | | `payload_len` | uint32 | 头里声明的载荷长度 | | `encrypted` / `compressed` / `latency_first` / `exit_node` / `no_proxy` | bool | `flags` 各位 | | `magic_ok` | bool | 明文握手 magic 匹配。**粘性** | | `secure` | bool | true = 见到 Noise 握手,false = 见到明文握手。**粘性** | | `version` | uint64 | 握手里的协议版本。**粘性** | | `network_name` | string | mesh 名。**粘性** | 标「粘性」的字段一旦写入就不再被后续帧覆盖:网络名只在握手里出现一次,一条按 mesh 名写的规则必须在握手滚过去之后还能用。其余字段描述的是**最近一帧**。整数字段的具体宽度不影响规则,expr 比较时会自动对齐。 `packet_type` 及其后的字段只在该帧带 `PeerManagerHeader` 时存在(UDP 的 `Data`、全部 TCP 帧)。`Syn`/`Sack`/打洞包没有。 > [!NOTE] > 规则里要写 `easytier != nil && ...`。规则引擎只把已经发布过属性的分析器放进表达式环境(见 `ruleset/expr.go` 的 `streamInfoToExprEnv`),流上一帧都没解析出来时 `easytier` 是 `nil`,直接取 `easytier.yes` 会报错。map 里不存在的键读出来是 `nil`,比较结果为假,不报错:所以 `easytier.exit_node == true` 在 `Syn`/`Sack`/打洞包上只是**不匹配**,`exit_node` 要等一个带 `PeerManagerHeader` 的帧到达才有值;同理 `easytier.network_name == "..."` 在握手到达之前一直不匹配。 ## 规则示例 拦截所有 EasyTier 对等连接: ```yaml - name: block-easytier action: block log: true expr: easytier != nil && easytier.yes ``` 只拦某一个 mesh,其余放行(网络名明文可见,这是最精确的一档): ```yaml - name: block-one-easytier-mesh action: block log: true expr: easytier != nil && easytier.network_name == "office-mesh" ``` 只拦出口节点,即把本网流量往公网转发的那些,通常是策略上真正在意的: ```yaml - name: block-easytier-exit-node action: block log: true expr: easytier != nil && easytier.yes && easytier.exit_node == true ``` 打洞包不参与 `yes`,要单独点名(见下节的取舍说明): ```yaml - name: log-easytier-hole-punch log: true expr: easytier != nil && easytier.msg_type_name == "hole_punch" ``` 只观察不拦截,先看清网络里有什么: ```yaml - name: log-easytier log: true expr: easytier != nil && easytier.yes ``` 规则按顺序匹配,第一条带 action 的命中即生效。想同时用「只拦一个 mesh」和「拦全部」,具体的要写在前面,否则被兜底的那条挡住永远到不了。 配套的 WireGuard 传输要另外拦,走 `wireguard` 分析器。 ## 检不到什么 - **`wg://` / `quic://` / `wss://` 传输**。线上分别就是 WireGuard、QUIC、TLS,交给对应分析器。`wss://` 的服务端用的是 `rcgen` 现签的自签证书,CN 为 `localhost`,这本身是个可用的特征,但它属于 `tls` 的范畴。 - **`ws://` 帧内容**。HTTP Upgrade 握手 `http` 能看到,之后的 WebSocket 二进制帧里才是 `PeerManagerHeader`(无长度前缀,`ZCPacketType::DummyTunnel`),本分析器不做 WebSocket 拆帧。 - **中途接入的流**。TCP 侧遇到 `skip != 0`(重组有空洞)会直接放弃,因为帧边界无从推断。 - **只发打洞包的流**。打洞探测是往可能根本没跑 EasyTier 的地址上喷的随机数据,除了长度没有任何结构(连通性路径 16 字节,UDP 会话层 32 字节,两个值都认),把它算作确证会在正常网络里产生误报,所以它只填 `msg_type_name`、不推进 `yes`。要抓就按上面的例子点名。 ## 误报边界 - UDP 侧连续 4 个无法解析的数据报就放弃该流(和 `wireguard` 分析器一致);TCP 侧一旦帧结构对不上立刻结束。 - EasyTier 把 STUN 复用在同一个 UDP socket 上,并且在自己的帧解析**之前**先分流 STUN。分析器照做:识别出 STUN(`data[4..8] == 21 12 A4 42` 且 `data[0] & 0xC0 == 0`)的数据报既不算证据也不算错误,只计入总包数,免得一串 STUN 把流提前判死。 - 任一传输超过 256 个包仍未定性就结束该流(和 `openvpn` 分析器一致),避免长流一直占着分析器状态。 - `Limit()` 返回 0。这是个定长帧协议,按字节数截断会让帧读取失步;上面的包数上限替代它约束开销。 ## 复现 字节级的边界条件都在 `analyzer/udp/easytier_test.go` 里,包括 magic 的编码、两个长度字段的关系、AEAD 尾、压缩、`reserved` 的取值规则、帧切在长度前缀中间的重组、逐字节投递、STUN 复用,以及上面提到的 `fet` 和 `wireguard` 对照用例: ```bash go test ./analyzer/udp -run EasyTier -v ```