From cc6ad727061e98a462a2db8e95bfcda803d3118d Mon Sep 17 00:00:00 2001 From: Mxmilu666 Date: Sat, 12 Sep 2026 17:05:39 +0800 Subject: [PATCH] feat(udp): add EasyTier analyzer for tcp:// and udp:// transports Port of NoBrand-Cloud/OpenGFW-RS commit 1177ff7 to Go. EasyTier's ciphers cover only the payload: the 16-byte PeerManagerHeader stays in the clear, so peer ids, packet type and two redundant length fields are readable even on an encrypted mesh. The plaintext handshake also carries the network name, which lets a rule block one mesh and leave the rest alone (easytier.network_name). A Syn answered by a Sack echoing the same connection id and magic, or a handshake carrying the 0xd1e1a5e1 magic, settles a flow on its own; otherwise two consistent frames are required. Hole-punch probes are reported but never set yes. The wg://, quic:// and wss:// transports are left to the wireguard, quic and tls analyzers. fet does not cover the tcp:// transport because it classifies the plaintext handshake segment and is then done. Rules must be written as `easytier != nil && ...`: the rule engine only exposes analyzers that have published properties. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01RjQ1CTBn8j65cUZ2JKsY7L --- README.ja.md | 2 +- README.md | 2 +- README.zh.md | 6 +- analyzer/udp/easytier.go | 766 ++++++++++++++++++++++++++++++++++ analyzer/udp/easytier_test.go | 562 +++++++++++++++++++++++++ cmd/root.go | 1 + docs/easytier.zh.md | 241 +++++++++++ 7 files changed, 1577 insertions(+), 3 deletions(-) create mode 100644 analyzer/udp/easytier.go create mode 100644 analyzer/udp/easytier_test.go create mode 100644 docs/easytier.zh.md diff --git a/README.ja.md b/README.ja.md index 49223f0..caa9a32 100644 --- a/README.ja.md +++ b/README.ja.md @@ -18,7 +18,7 @@ Telegram グループ: https://t.me/OpGFW ## 特徴 - フル IP/TCP 再アセンブル、各種プロトコルアナライザー - - HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、OpenVPN、その他多数 + - HTTP、TLS、QUIC、DNS、SSH、SOCKS4/5、WireGuard、OpenVPN、Minecraft、EasyTier、その他多数 - Shadowsocks、VMess の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/) - Trojan プロキシプロトコルの検出 - [WIP] 機械学習に基づくトラフィック分類 diff --git a/README.md b/README.md index d8d072a..f626bfb 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ Telegram group: https://t.me/OpGFW ## Features - Full IP/TCP reassembly, various protocol analyzers - - HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, and many more to come + - HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, EasyTier, and many more to come - "Fully encrypted traffic" detection for Shadowsocks, VMess, etc. (https://gfw.report/publications/usenixsecurity23/en/) - Trojan (proxy protocol) detection diff --git a/README.zh.md b/README.zh.md index e46f6a6..5711c81 100644 --- a/README.zh.md +++ b/README.zh.md @@ -15,10 +15,13 @@ Telegram 群组: https://t.me/OpGFW > [!CAUTION] > 本项目仍处于早期开发阶段。测试时自行承担风险。我们正在寻求贡献者一起完善本项目。 +> [!CAUTION] +> 此版本为 LoliaFRP 二次开发版本,加入了更多的识别规则,如果您正在寻找原版请移步 original 分支 + ## 功能 - 完整的 IP/TCP 重组,各种协议解析器 - - HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, 更多协议正在开发中 + - HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, EasyTier, 更多协议正在开发中 - Shadowsocks, VMess 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/) - Trojan 协议检测 - [开发中] 基于机器学习的流量分类 @@ -34,6 +37,7 @@ Telegram 群组: https://t.me/OpGFW ## 本仓库内的文档 - [Minecraft 分析器](docs/minecraft.zh.md) +- [EasyTier 分析器](docs/easytier.zh.md) - [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md) ## 使用场景 diff --git a/analyzer/udp/easytier.go b/analyzer/udp/easytier.go new file mode 100644 index 0000000..d6afb94 --- /dev/null +++ b/analyzer/udp/easytier.go @@ -0,0 +1,766 @@ +package udp + +import ( + "encoding/binary" + "maps" + "unicode" + "unicode/utf8" + + "github.com/apernet/OpenGFW/analyzer" + "github.com/apernet/OpenGFW/analyzer/utils" +) + +// EasyTierAnalyzer 识别 EasyTier(https://github.com/EasyTier/EasyTier) +type EasyTierAnalyzer struct{} + +var ( + _ analyzer.UDPAnalyzer = (*EasyTierAnalyzer)(nil) + _ analyzer.TCPAnalyzer = (*EasyTierAnalyzer)(nil) + _ analyzer.UDPStream = (*easyTierUDPStream)(nil) + _ analyzer.TCPStream = (*easyTierTCPStream)(nil) +) + +const ( + // easyTierUDPTunnelHeaderSize UDPTunnelHeader:conn_id u32、msg_type u8、padding u8、len u16,全部小端 + easyTierUDPTunnelHeaderSize = 8 + // easyTierTCPTunnelHeaderSize TCPTunnelHeader:一个小端 u32 帧体长度 + easyTierTCPTunnelHeaderSize = 4 + // easyTierPeerManagerHeaderSize PeerManagerHeader:from u32、to u32、type u8、flags u8、fwd u8、reserved u8、len u32 + easyTierPeerManagerHeaderSize = 16 + // easyTierAEADTailSize AEAD 加密追加的 16 字节 tag 加 12 字节 nonce,头里声明的长度不包含它 + easyTierAEADTailSize = 28 + + // easyTierMaxFrameBody 帧体长度上限。上游自己的限制更低(TCP 隧道 2000,字节流隧道 4096, + // UDP 会话 8192),这里取最大的那个,精确的约束交给长度不变量 + easyTierMaxFrameBody = 8192 + // easyTierMaxForwardCounter 路由在 >7 时丢弃后再自增,所以线上最大能见到 8 + easyTierMaxForwardCounter = 8 + + // easyTierHandshakeMagic HandshakeRequest.magic,见上游 peers/conn/peer_conn.rs + easyTierHandshakeMagic uint64 = 0xd1e1a5e1 + // easyTierSecretDigestLen NetworkSecretDigest = [u8; 32] + easyTierSecretDigestLen = 32 + // easyTierNoiseEphemeralLen Noise XX 首条消息以 32 字节临时公钥开头 + easyTierNoiseEphemeralLen = 32 + + // easyTierFramesForYes 没有握手可指的情况下,置 yes 需要攒够的自洽帧数 + easyTierFramesForYes = 2 + // easyTierUDPInvalidCountThreshold UDP 流上连续无法解析的数据报容忍数,与 wireguard 分析器一致 + easyTierUDPInvalidCountThreshold = 4 + // easyTierPacketLimit 超过这个包数仍未定性就结束该流,与 openvpn 分析器一致 + easyTierPacketLimit = 256 +) + +// easyTierHolePunchBodyLens 上游打洞包的两种体长:连通性路径 16 字节,UDP 会话层 32 字节。 +// 体本身是随机数据,长度是唯一能看的东西。 +var easyTierHolePunchBodyLens = [...]int{16, 32} + +// UDP 隧道消息类型(UdpPacketType) +const ( + easyTierMsgSyn = 1 + easyTierMsgSack = 2 + easyTierMsgData = 3 + easyTierMsgFin = 4 + easyTierMsgHolePunch = 5 + easyTierMsgV4HolePunch = 6 + easyTierMsgV6HolePunch = 7 +) + +// 需要特殊处理的 PeerManagerHeader packet_type(PacketType) +const ( + easyTierPktHandshake = 2 + easyTierPktNoiseMsg1 = 13 + easyTierPktNoiseMsg3 = 15 +) + +// PeerManagerHeaderFlags +const ( + easyTierFlagEncrypted = 0b0000_0001 + easyTierFlagLatencyFirst = 0b0000_0010 + easyTierFlagExitNode = 0b0000_0100 + easyTierFlagNoProxy = 0b0000_1000 + easyTierFlagCompressed = 0b0001_0000 + // easyTierFlagLiveness 两个存活探测标志,是仅有的让 reserved 字节有意义的标志 + easyTierFlagLiveness = 0b1010_0000 +) + +func (a *EasyTierAnalyzer) Name() string { + return "easytier" +} + +func (a *EasyTierAnalyzer) Limit() int { + // 定长帧协议,按字节截断会让帧读取失步;用 easyTierPacketLimit 约束开销 + return 0 +} + +func (a *EasyTierAnalyzer) NewUDP(info analyzer.UDPInfo, logger analyzer.Logger) analyzer.UDPStream { + return newEasyTierUDPStream(logger) +} + +func (a *EasyTierAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream { + return newEasyTierTCPStream(logger) +} + +// easyTierEvidence 一帧能证明多少 +type easyTierEvidence int + +const ( + // easyTierEvidenceWeak 格式正确但单独不足为据:体是随机数据的短定长数据报。字段照发,不推进 yes + easyTierEvidenceWeak easyTierEvidence = iota + // easyTierEvidenceFrame 带 PeerManagerHeader 且通过全部校验的帧 + easyTierEvidenceFrame + // easyTierEvidenceConclusive 匹配上的握手,别的东西不会碰巧产生它 + easyTierEvidenceConclusive +) + +// easyTierCommon 对外发布的属性,加上 yes 背后的计数。 +// +// 描述最近一帧的字段每次都会覆盖;从握手里读出来的字段是粘性的, +// 因为网络名只发一次,而按 mesh 名写的规则必须在握手滚过去之后还能用。 +type easyTierCommon struct { + props analyzer.PropMap + frames int + conclusive bool + packetCount int + rxPacketCount int + txPacketCount int +} + +func newEasyTierCommon(transport string) easyTierCommon { + return easyTierCommon{ + props: analyzer.PropMap{ + "yes": false, + "transport": transport, + "tx_pkt_cnt": 0, + "rx_pkt_cnt": 0, + }, + } +} + +func (c *easyTierCommon) countPacket(rev bool) { + c.packetCount++ + if rev { + c.rxPacketCount++ + c.props["rx_pkt_cnt"] = c.rxPacketCount + } else { + c.txPacketCount++ + c.props["tx_pkt_cnt"] = c.txPacketCount + } +} + +func (c *easyTierCommon) record(evidence easyTierEvidence) { + switch evidence { + case easyTierEvidenceConclusive: + c.conclusive = true + case easyTierEvidenceFrame: + c.frames++ + } + c.props["yes"] = c.conclusive || c.frames >= easyTierFramesForYes +} + +func (c *easyTierCommon) set(key string, value interface{}) { + c.props[key] = value +} + +// setSticky 只写一次、之后不再覆盖,用于从握手里读出的字段 +func (c *easyTierCommon) setSticky(key string, value interface{}) { + if _, ok := c.props[key]; !ok { + c.props[key] = value + } +} + +// update 发布一份属性快照。引擎会持有这份 map,所以必须复制,不能把内部的交出去 +func (c *easyTierCommon) update() *analyzer.PropUpdate { + return &analyzer.PropUpdate{ + Type: analyzer.PropUpdateReplace, + M: maps.Clone(c.props), + } +} + +func (c *easyTierCommon) exhausted() bool { + return c.packetCount > easyTierPacketLimit +} + +// applyHeader 记录 PeerManagerHeader 的字段,并返回这一帧能证明多少 +func (c *easyTierCommon) applyHeader(h easyTierPeerManagerHeader, body []byte) easyTierEvidence { + c.set("packet_type", h.packetType) + c.set("packet_type_name", easyTierPacketTypeName(h.packetType)) + c.set("from_peer_id", h.fromPeerID) + c.set("to_peer_id", h.toPeerID) + c.set("forward_counter", h.forwardCounter) + c.set("payload_len", h.length) + c.set("encrypted", h.flags&easyTierFlagEncrypted != 0) + c.set("compressed", h.flags&easyTierFlagCompressed != 0) + c.set("latency_first", h.flags&easyTierFlagLatencyFirst != 0) + c.set("exit_node", h.flags&easyTierFlagExitNode != 0) + c.set("no_proxy", h.flags&easyTierFlagNoProxy != 0) + + payload := body[easyTierPeerManagerHeaderSize:] + switch h.packetType { + case easyTierPktHandshake: + return c.applyPlainHandshake(payload) + case easyTierPktNoiseMsg1: + c.setSticky("secure", true) + c.applyNoiseMsg1(payload) + // 只有临时公钥和一段 protobuf,随机流也能凑出来,不算确证 + return easyTierEvidenceFrame + case easyTierPktNoiseMsg3: + c.setSticky("secure", true) + return easyTierEvidenceFrame + default: + return easyTierEvidenceFrame + } +} + +// applyPlainHandshake 明文 HandshakeRequest,magic 对上就定性 +func (c *easyTierCommon) applyPlainHandshake(payload []byte) easyTierEvidence { + hs, ok := easyTierParseHandshakeRequest(payload) + if !ok { + return easyTierEvidenceFrame + } + c.setSticky("magic_ok", true) + c.setSticky("secure", false) + if hs.hasVersion { + c.setSticky("version", hs.version) + } + if hs.networkName != "" { + c.setSticky("network_name", hs.networkName) + } + return easyTierEvidenceConclusive +} + +// applyNoiseMsg1 Noise XX 的首条消息此时还没有密钥,protobuf 载荷是明文。 +// 机会性地读:解析不干净就什么都不说,不猜。 +func (c *easyTierCommon) applyNoiseMsg1(payload []byte) { + if len(payload) <= easyTierNoiseEphemeralLen { + return + } + msg, ok := easyTierParseNoiseMsg1(payload[easyTierNoiseEphemeralLen:]) + if !ok { + return + } + if msg.hasVersion { + c.setSticky("version", msg.version) + } + if msg.networkName != "" { + c.setSticky("network_name", msg.networkName) + } +} + +type easyTierPeerManagerHeader struct { + fromPeerID uint32 + toPeerID uint32 + packetType uint8 + flags uint8 + forwardCounter uint8 + length uint32 +} + +// easyTierReadPeerManagerHeader 读取并校验帧体开头的 PeerManagerHeader。 +// +// 声明的载荷长度要和实际收到的帧体对照。AEAD 加密会追加 28 字节尾部而不改声明长度, +// 压缩会缩短载荷也不改声明长度,所以只有两个标志都没置位时关系才是精确相等。 +func easyTierReadPeerManagerHeader(body []byte) (easyTierPeerManagerHeader, bool) { + var h easyTierPeerManagerHeader + if len(body) < easyTierPeerManagerHeaderSize { + return h, false + } + packetType := body[8] + if !easyTierIsKnownPacketType(packetType) { + return h, false + } + flags := body[9] + forwardCounter := body[10] + if forwardCounter > easyTierMaxForwardCounter { + return h, false + } + // reserved 只在存活探测时装 token,其余时候恒为 0 + if body[11] != 0 && flags&easyTierFlagLiveness == 0 { + return h, false + } + + length := binary.LittleEndian.Uint32(body[12:16]) + if length > easyTierMaxFrameBody { + return h, false + } + wire := uint32(len(body) - easyTierPeerManagerHeaderSize) + var ok bool + switch { + case flags&easyTierFlagCompressed != 0: + // 压缩器只在能省下自己那 1 字节尾时才压 + ok = wire >= 1 && wire <= length+easyTierAEADTailSize + case flags&easyTierFlagEncrypted != 0: + // XOR 不改长度;AEAD 追加尾部 + ok = wire == length || wire == length+easyTierAEADTailSize + default: + ok = wire == length + } + if !ok { + return h, false + } + + return easyTierPeerManagerHeader{ + fromPeerID: binary.LittleEndian.Uint32(body[0:4]), + toPeerID: binary.LittleEndian.Uint32(body[4:8]), + packetType: packetType, + flags: flags, + forwardCounter: forwardCounter, + length: length, + }, true +} + +func easyTierIsKnownPacketType(packetType uint8) bool { + return packetType >= 1 && packetType <= 21 +} + +func easyTierPacketTypeName(packetType uint8) string { + switch packetType { + case 1: + return "data" + case 2: + return "handshake" + case 3: + return "route_packet" + case 4: + return "ping" + case 5: + return "pong" + case 6: + return "ta_rpc" + case 7: + return "route" + case 8: + return "rpc_req" + case 9: + return "rpc_resp" + case 10: + return "foreign_network_packet" + case 11: + return "kcp_src" + case 12: + return "kcp_dst" + case 13: + return "noise_handshake_msg1" + case 14: + return "noise_handshake_msg2" + case 15: + return "noise_handshake_msg3" + case 16: + return "quic_src" + case 17: + return "quic_dst" + case 18: + return "data_with_kcp_src_modified" + case 19: + return "data_with_quic_src_modified" + case 20: + return "relay_handshake" + case 21: + return "relay_handshake_ack" + default: + return "unknown" + } +} + +func easyTierMsgTypeName(msgType uint8) string { + switch msgType { + case easyTierMsgSyn: + return "syn" + case easyTierMsgSack: + return "sack" + case easyTierMsgData: + return "data" + case easyTierMsgFin: + return "fin" + case easyTierMsgHolePunch: + return "hole_punch" + case easyTierMsgV4HolePunch: + return "v4_hole_punch" + case easyTierMsgV6HolePunch: + return "v6_hole_punch" + default: + return "unknown" + } +} + +// easyTierPendingSyn 一个等着 Sack 的 Syn +type easyTierPendingSyn struct { + connID uint32 + magic uint64 + rev bool +} + +type easyTierUDPStream struct { + logger analyzer.Logger + common easyTierCommon + invalidCount int + pendingSyn *easyTierPendingSyn +} + +func newEasyTierUDPStream(logger analyzer.Logger) *easyTierUDPStream { + return &easyTierUDPStream{ + logger: logger, + common: newEasyTierCommon("udp"), + } +} + +func (s *easyTierUDPStream) Feed(rev bool, data []byte) (u *analyzer.PropUpdate, done bool) { + // EasyTier 把 STUN 复用在同一个 socket 上,并且在自己的帧解析之前先分流 STUN; + // 照做:既不算证据,也不算错误 + if easyTierIsSTUNDatagram(data) { + s.common.packetCount++ + return nil, s.common.exhausted() + } + + evidence, ok := s.parseDatagram(rev, data) + if !ok { + s.invalidCount++ + return nil, s.invalidCount >= easyTierUDPInvalidCountThreshold + } + + s.invalidCount = 0 + s.common.countPacket(rev) + s.common.record(evidence) + return s.common.update(), s.common.exhausted() +} + +func (s *easyTierUDPStream) Close(limited bool) *analyzer.PropUpdate { + return nil +} + +func (s *easyTierUDPStream) parseDatagram(rev bool, data []byte) (easyTierEvidence, bool) { + if len(data) < easyTierUDPTunnelHeaderSize { + return 0, false + } + connID := binary.LittleEndian.Uint32(data[0:4]) + msgType := data[4] + // padding 只为对齐长度字段而存在 + if data[5] != 0 { + return 0, false + } + declared := int(binary.LittleEndian.Uint16(data[6:8])) + body := data[easyTierUDPTunnelHeaderSize:] + // 上游要求这两者相等,否则整个数据报直接丢弃 + if declared != len(body) { + return 0, false + } + + var evidence easyTierEvidence + switch msgType { + case easyTierMsgSyn: + if len(body) != 8 { + return 0, false + } + s.pendingSyn = &easyTierPendingSyn{connID: connID, magic: binary.LittleEndian.Uint64(body), rev: rev} + evidence = easyTierEvidenceWeak + case easyTierMsgSack: + // 反向回来、同一 conn_id、同一随机 64 位 magic 的 Sack,别的东西做不到 + if len(body) != 8 { + return 0, false + } + magic := binary.LittleEndian.Uint64(body) + syn := s.pendingSyn + if syn != nil && syn.rev != rev && syn.connID == connID && syn.magic == magic { + evidence = easyTierEvidenceConclusive + } else { + evidence = easyTierEvidenceWeak + } + case easyTierMsgData: + h, ok := easyTierReadPeerManagerHeader(body) + if !ok { + return 0, false + } + evidence = s.common.applyHeader(h, body) + case easyTierMsgFin: + if len(body) != 0 { + return 0, false + } + evidence = easyTierEvidenceWeak + case easyTierMsgHolePunch: + // 打洞探测是定长随机体,往可能根本没跑 EasyTier 的地址上喷, + // 不足以下结论,但 msg_type_name 照样说明它是什么 + if !easyTierIsHolePunchBodyLen(len(body)) { + return 0, false + } + evidence = easyTierEvidenceWeak + case easyTierMsgV4HolePunch: + // 两种定向变体在 conn_id 里回显目标端口。上游只从回环地址接受它们,主机间线上少见 + if len(body) != 6 || connID != uint32(binary.LittleEndian.Uint16(body[4:6])) { + return 0, false + } + evidence = easyTierEvidenceWeak + case easyTierMsgV6HolePunch: + if len(body) != 38 || connID != uint32(binary.LittleEndian.Uint16(body[16:18])) { + return 0, false + } + evidence = easyTierEvidenceWeak + default: + return 0, false + } + + s.common.set("conn_id", connID) + s.common.set("msg_type", msgType) + s.common.set("msg_type_name", easyTierMsgTypeName(msgType)) + return evidence, true +} + +func easyTierIsHolePunchBodyLen(n int) bool { + for _, l := range easyTierHolePunchBodyLens { + if n == l { + return true + } + } + return false +} + +// easyTierIsSTUNDatagram 和 EasyTier 自己先做的判断一样: +// 它的长度字段位置上是 STUN magic cookie,且前两位为零 +func easyTierIsSTUNDatagram(data []byte) bool { + return len(data) >= easyTierUDPTunnelHeaderSize && + data[4] == 0x21 && data[5] == 0x12 && data[6] == 0xa4 && data[7] == 0x42 && + data[0]&0xc0 == 0 +} + +type easyTierTCPStream struct { + logger analyzer.Logger + common easyTierCommon + reqBuf utils.ByteBuffer + respBuf utils.ByteBuffer +} + +func newEasyTierTCPStream(logger analyzer.Logger) *easyTierTCPStream { + return &easyTierTCPStream{ + logger: logger, + common: newEasyTierCommon("tcp"), + } +} + +// easyTierFrameStatus 从一个方向的缓冲里读一帧的结果 +type easyTierFrameStatus int + +const ( + // easyTierFrameRead 头已校验通过,帧体可以发布 + easyTierFrameRead easyTierFrameStatus = iota + // easyTierFrameNeedMore 声明的帧体还没到齐 + easyTierFrameNeedMore + // easyTierFrameNo 不可能是 EasyTier 的帧 + easyTierFrameNo +) + +func (s *easyTierTCPStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) { + // 重组有空洞时帧边界无从推断 + if skip != 0 { + return nil, true + } + if len(data) == 0 { + return nil, false + } + + buf := &s.reqBuf + if rev { + buf = &s.respBuf + } + buf.Append(data) + + updated := false + for { + h, body, status := easyTierReadFrame(buf) + switch status { + case easyTierFrameRead: + evidence := s.common.applyHeader(h, body) + s.common.countPacket(rev) + s.common.record(evidence) + updated = true + if s.common.exhausted() { + return s.common.update(), true + } + case easyTierFrameNeedMore: + if updated { + return s.common.update(), false + } + return nil, false + case easyTierFrameNo: + if updated { + return s.common.update(), true + } + return nil, true + } + } +} + +func (s *easyTierTCPStream) Close(limited bool) *analyzer.PropUpdate { + s.reqBuf.Reset() + s.respBuf.Reset() + return nil +} + +// easyTierReadFrame 帧结构是 [u32 LE 帧体长度][帧体],帧体总是以 PeerManagerHeader 开头, +// 上游会把更短的帧体按 "body too short" 拒绝。 +func easyTierReadFrame(buf *utils.ByteBuffer) (easyTierPeerManagerHeader, []byte, easyTierFrameStatus) { + var h easyTierPeerManagerHeader + bodyLen, ok := buf.GetUint32(true, false) + if !ok { + return h, nil, easyTierFrameNeedMore + } + if bodyLen < easyTierPeerManagerHeaderSize || bodyLen > easyTierMaxFrameBody { + return h, nil, easyTierFrameNo + } + total := easyTierTCPTunnelHeaderSize + int(bodyLen) + frame, ok := buf.Get(total, false) + if !ok { + return h, nil, easyTierFrameNeedMore + } + body := frame[easyTierTCPTunnelHeaderSize:] + h, ok = easyTierReadPeerManagerHeader(body) + if !ok { + return h, nil, easyTierFrameNo + } + // 只在帧确认合法后才消费;被拒绝的流反正要结束,留着字节让缓冲状态更好推理 + buf.Skip(total) + return h, body, easyTierFrameRead +} + +// easyTierHandshakeInfo 从握手里读出来的东西。networkName 为空表示没有可用的名字 +type easyTierHandshakeInfo struct { + version uint64 + hasVersion bool + networkName string +} + +// easyTierParseHandshakeRequest 解析 HandshakeRequest { magic = 1, my_peer_id = 2, +// version = 3, features = 4, network_name = 5, network_secret_digest = 6 }。 +// +// 只有 magic 存在且正确才返回 true,调用方可以把 true 当作证据而不是提示。 +func easyTierParseHandshakeRequest(payload []byte) (easyTierHandshakeInfo, bool) { + var info easyTierHandshakeInfo + var magic uint64 + hasMagic := false + digestLen := -1 + + ok := easyTierForEachProtoField(payload, func(f easyTierProtoField) { + switch { + case f.isVarint && f.number == 1: + magic, hasMagic = f.varint, true + case f.isVarint && f.number == 3: + info.version, info.hasVersion = f.varint, true + case f.isBytes && f.number == 5: + info.networkName, _ = easyTierPrintableUTF8(f.bytes) + case f.isBytes && f.number == 6: + digestLen = len(f.bytes) + } + }) + if !ok || !hasMagic || magic != easyTierHandshakeMagic { + return easyTierHandshakeInfo{}, false + } + // 上游会拒绝 digest 宽度不对的握手 + if digestLen >= 0 && digestLen != easyTierSecretDigestLen { + return easyTierHandshakeInfo{}, false + } + return info, true +} + +// easyTierParseNoiseMsg1 解析 PeerConnNoiseMsg1Pb { version = 1, a_network_name = 2, ... }。 +// +// 这里没有 magic 可查,所以消息必须完整解析并声明一个合理的版本,否则返回 false。 +func easyTierParseNoiseMsg1(payload []byte) (easyTierHandshakeInfo, bool) { + var info easyTierHandshakeInfo + + ok := easyTierForEachProtoField(payload, func(f easyTierProtoField) { + switch { + case f.isVarint && f.number == 1: + info.version, info.hasVersion = f.varint, true + case f.isBytes && f.number == 2: + info.networkName, _ = easyTierPrintableUTF8(f.bytes) + } + }) + // 协议版本从有这个字段起就一直是 1,很大的值说明这些字节不是这条消息 + if !ok || !info.hasVersion || info.version > 16 { + return easyTierHandshakeInfo{}, false + } + return info, true +} + +type easyTierProtoField struct { + number uint32 + isVarint bool + varint uint64 + isBytes bool + bytes []byte +} + +// easyTierForEachProtoField 遍历 protobuf 字段,跳过调用方不认识的字段, +// 遇到编码站不住脚(截断、字段号为 0、这两条消息从不使用的 wire type)时返回 false。 +func easyTierForEachProtoField(data []byte, fn func(easyTierProtoField)) bool { + pos := 0 + for pos < len(data) { + tag, ok := easyTierReadVarint(data, &pos) + if !ok { + return false + } + number := uint32(tag >> 3) + if number == 0 { + return false + } + switch tag & 0x7 { + case 0: + value, ok := easyTierReadVarint(data, &pos) + if !ok { + return false + } + fn(easyTierProtoField{number: number, isVarint: true, varint: value}) + case 2: + length, ok := easyTierReadVarint(data, &pos) + if !ok || length > uint64(len(data)-pos) { + return false + } + end := pos + int(length) + fn(easyTierProtoField{number: number, isBytes: true, bytes: data[pos:end]}) + pos = end + case 5: + if len(data)-pos < 4 { + return false + } + pos += 4 + case 1: + if len(data)-pos < 8 { + return false + } + pos += 8 + default: + // group 类型,两条消息都不用 + return false + } + } + return true +} + +func easyTierReadVarint(data []byte, pos *int) (uint64, bool) { + var value uint64 + for i := 0; i < 10; i++ { + if *pos >= len(data) { + return 0, false + } + b := data[*pos] + *pos++ + value |= uint64(b&0x7f) << (7 * i) + if b&0x80 == 0 { + return value, true + } + } + return 0, false +} + +// easyTierPrintableUTF8 网络名是运营者自己起的文本。拒绝控制字符, +// 免得一段碰巧的字节最后被引用进日志或审计记录里。 +func easyTierPrintableUTF8(b []byte) (string, bool) { + if len(b) == 0 || !utf8.Valid(b) { + return "", false + } + s := string(b) + for _, r := range s { + if unicode.IsControl(r) { + return "", false + } + } + return s, true +} diff --git a/analyzer/udp/easytier_test.go b/analyzer/udp/easytier_test.go new file mode 100644 index 0000000..fd7f7e9 --- /dev/null +++ b/analyzer/udp/easytier_test.go @@ -0,0 +1,562 @@ +package udp + +import ( + "bytes" + "encoding/binary" + "fmt" + "testing" + + "github.com/apernet/OpenGFW/analyzer" + "github.com/apernet/OpenGFW/analyzer/tcp" +) + +func etVarint(v uint64) []byte { + var out []byte + for { + b := byte(v & 0x7f) + v >>= 7 + if v != 0 { + b |= 0x80 + } + out = append(out, b) + if v == 0 { + return out + } + } +} + +func etProtoVarint(field uint32, v uint64) []byte { + return append(etVarint(uint64(field)<<3), etVarint(v)...) +} + +func etProtoBytes(field uint32, v []byte) []byte { + out := etVarint(uint64(field)<<3 | 2) + out = append(out, etVarint(uint64(len(v)))...) + return append(out, v...) +} + +// etHandshakeRequest HandshakeRequest,按 send_handshake 的构造方式 +func etHandshakeRequest(peerID uint32, networkName string) []byte { + var out []byte + out = append(out, etProtoVarint(1, easyTierHandshakeMagic)...) + out = append(out, etProtoVarint(2, uint64(peerID))...) + out = append(out, etProtoVarint(3, 1)...) + out = append(out, etProtoBytes(4, []byte("liveness-echo-v1"))...) + out = append(out, etProtoBytes(5, []byte(networkName))...) + out = append(out, etProtoBytes(6, make([]byte, easyTierSecretDigestLen))...) + return out +} + +// etNoiseMsg1 Noise XX 先写的临时公钥,后面跟 PeerConnNoiseMsg1Pb +func etNoiseMsg1(networkName string) []byte { + out := bytes.Repeat([]byte{0xab}, easyTierNoiseEphemeralLen) + out = append(out, etProtoVarint(1, 1)...) + return append(out, etProtoBytes(2, []byte(networkName))...) +} + +// etBody 帧体:16 字节 PeerManagerHeader 加载荷 +func etBody(packetType uint8, from, to uint32, payload []byte) []byte { + return etBodyRaw(packetType, from, to, 0, 1, 0, uint32(len(payload)), payload) +} + +func etBodyRaw(packetType uint8, from, to uint32, flags, forwardCounter, reserved uint8, declaredLen uint32, payload []byte) []byte { + out := make([]byte, 0, easyTierPeerManagerHeaderSize+len(payload)) + out = binary.LittleEndian.AppendUint32(out, from) + out = binary.LittleEndian.AppendUint32(out, to) + out = append(out, packetType, flags, forwardCounter, reserved) + out = binary.LittleEndian.AppendUint32(out, declaredLen) + return append(out, payload...) +} + +// etDatagram 8 字节 UDPTunnelHeader 加帧体 +func etDatagram(connID uint32, msgType uint8, body []byte) []byte { + out := make([]byte, 0, easyTierUDPTunnelHeaderSize+len(body)) + out = binary.LittleEndian.AppendUint32(out, connID) + out = append(out, msgType, 0) + out = binary.LittleEndian.AppendUint16(out, uint16(len(body))) + return append(out, body...) +} + +func etSyn(connID uint32, magic uint64) []byte { + return etDatagram(connID, easyTierMsgSyn, binary.LittleEndian.AppendUint64(nil, magic)) +} + +func etSack(connID uint32, magic uint64) []byte { + return etDatagram(connID, easyTierMsgSack, binary.LittleEndian.AppendUint64(nil, magic)) +} + +// etSegment 4 字节 TCPTunnelHeader 加帧体 +func etSegment(body []byte) []byte { + out := binary.LittleEndian.AppendUint32(nil, uint32(len(body))) + return append(out, body...) +} + +func etUDP() analyzer.UDPStream { + return (&EasyTierAnalyzer{}).NewUDP(analyzer.UDPInfo{}, nil) +} + +func etTCP() analyzer.TCPStream { + return (&EasyTierAnalyzer{}).NewTCP(analyzer.TCPInfo{}, nil) +} + +func etFeedUDP(s analyzer.UDPStream, rev bool, data []byte) (analyzer.PropMap, bool) { + u, done := s.Feed(rev, data) + if u == nil { + return analyzer.PropMap{}, done + } + return u.M, done +} + +func etFeedTCP(s analyzer.TCPStream, rev bool, data []byte) (analyzer.PropMap, bool) { + u, done := s.Feed(rev, false, false, 0, data) + if u == nil { + return analyzer.PropMap{}, done + } + return u.M, done +} + +func etText(m analyzer.PropMap, key string) string { + v, ok := m[key] + if !ok { + return "" + } + return fmt.Sprint(v) +} + +func etFlag(m analyzer.PropMap, key string) bool { + v, _ := m[key].(bool) + return v +} + +func etExpect(t *testing.T, m analyzer.PropMap, key, want string) { + t.Helper() + if got := etText(m, key); got != want { + t.Fatalf("%s = %q, want %q (props: %v)", key, got, want, m) + } +} + +func TestEasyTier_HandshakeMagicEncodesToDocumentedBytes(t *testing.T) { + want := []byte{0x08, 0xe1, 0xcb, 0x86, 0x8f, 0x0d} + if got := etProtoVarint(1, easyTierHandshakeMagic); !bytes.Equal(got, want) { + t.Fatalf("magic encodes to % x, want % x", got, want) + } +} + +func TestEasyTier_UDPHandshakeNamesTheMeshOnFirstDatagram(t *testing.T) { + payload := etHandshakeRequest(0x1a2b3c4d, "office-mesh") + m, done := etFeedUDP(etUDP(), false, etDatagram(7, easyTierMsgData, etBody(easyTierPktHandshake, 0x1a2b3c4d, 0, payload))) + + if done { + t.Fatal("stream done after a single handshake") + } + if !etFlag(m, "yes") { + t.Fatal("the magic alone should settle it") + } + if !etFlag(m, "magic_ok") { + t.Fatal("magic_ok not set") + } + etExpect(t, m, "network_name", "office-mesh") + etExpect(t, m, "version", "1") + etExpect(t, m, "transport", "udp") + etExpect(t, m, "msg_type_name", "data") + etExpect(t, m, "packet_type_name", "handshake") + etExpect(t, m, "from_peer_id", "439041101") + etExpect(t, m, "to_peer_id", "0") + etExpect(t, m, "secure", "false") +} + +func TestEasyTier_TCPHandshakeNamesTheMeshOnFirstFrame(t *testing.T) { + payload := etHandshakeRequest(9, "office-mesh") + m, done := etFeedTCP(etTCP(), false, etSegment(etBody(easyTierPktHandshake, 9, 0, payload))) + + if done { + t.Fatal("stream done after a single handshake") + } + if !etFlag(m, "yes") { + t.Fatal("yes not set") + } + etExpect(t, m, "network_name", "office-mesh") + etExpect(t, m, "transport", "tcp") + etExpect(t, m, "tx_pkt_cnt", "1") +} + +func TestEasyTier_HandshakeWithWrongMagicIsNotConclusive(t *testing.T) { + payload := append(etProtoVarint(1, 0xd1e1a5e0), etProtoBytes(5, []byte("office-mesh"))...) + m, _ := etFeedUDP(etUDP(), false, etDatagram(7, easyTierMsgData, etBody(easyTierPktHandshake, 1, 0, payload))) + + // 帧结构仍然自洽,字段照发;但一帧不够,而且对网络不做任何声明 + if etFlag(m, "yes") { + t.Fatal("yes set on a wrong magic") + } + etExpect(t, m, "network_name", "") + etExpect(t, m, "magic_ok", "") +} + +func TestEasyTier_HandshakeWithWrongDigestWidthIsRejected(t *testing.T) { + // 上游这种情况会拒连,报匹配等于报一个根本连不起来的 mesh 名 + payload := etProtoVarint(1, easyTierHandshakeMagic) + payload = append(payload, etProtoBytes(5, []byte("office-mesh"))...) + payload = append(payload, etProtoBytes(6, make([]byte, 16))...) + m, _ := etFeedUDP(etUDP(), false, etDatagram(7, easyTierMsgData, etBody(easyTierPktHandshake, 1, 0, payload))) + + if etFlag(m, "yes") { + t.Fatal("yes set on a wrong digest width") + } + etExpect(t, m, "network_name", "") +} + +func TestEasyTier_NoiseFirstMessageStillGivesUpTheNetworkName(t *testing.T) { + // Noise XX 写 msg1 时还没有密钥,临时公钥后面的载荷是明文 + s := etUDP() + m, _ := etFeedUDP(s, false, etDatagram(7, easyTierMsgData, etBody(easyTierPktNoiseMsg1, 5, 0, etNoiseMsg1("secure-mesh")))) + + if !etFlag(m, "secure") { + t.Fatal("secure not set") + } + etExpect(t, m, "network_name", "secure-mesh") + if etFlag(m, "yes") { + t.Fatal("one frame with no magic to check should not settle it") + } + + // 反方向再来一帧就到阈值了 + m, _ = etFeedUDP(s, true, etDatagram(7, easyTierMsgData, etBody(14, 6, 5, bytes.Repeat([]byte{0xcd}, 48)))) + if !etFlag(m, "yes") { + t.Fatal("two consistent frames should settle it") + } + etExpect(t, m, "packet_type_name", "noise_handshake_msg2") +} + +func TestEasyTier_OpaqueNoiseMessageSaysNothingAboutTheNetwork(t *testing.T) { + m, _ := etFeedUDP(etUDP(), false, etDatagram(7, easyTierMsgData, etBody(easyTierPktNoiseMsg1, 5, 0, bytes.Repeat([]byte{0x5a}, 96)))) + + if !etFlag(m, "secure") { + t.Fatal("secure not set") + } + etExpect(t, m, "network_name", "") +} + +func TestEasyTier_SynAnsweredByItsSackSettlesTheFlow(t *testing.T) { + s := etUDP() + m, _ := etFeedUDP(s, false, etSyn(0x12345678, 0x0102030405060708)) + if etFlag(m, "yes") { + t.Fatal("a lone syn is 16 bytes of nearly nothing") + } + etExpect(t, m, "msg_type_name", "syn") + + m, _ = etFeedUDP(s, true, etSack(0x12345678, 0x0102030405060708)) + if !etFlag(m, "yes") { + t.Fatal("a matching sack should settle it") + } + etExpect(t, m, "msg_type_name", "sack") + etExpect(t, m, "conn_id", "305419896") + etExpect(t, m, "tx_pkt_cnt", "1") + etExpect(t, m, "rx_pkt_cnt", "1") +} + +func TestEasyTier_SackEchoingTheWrongMagicSettlesNothing(t *testing.T) { + s := etUDP() + etFeedUDP(s, false, etSyn(0x12345678, 1)) + m, _ := etFeedUDP(s, true, etSack(0x12345678, 2)) + if etFlag(m, "yes") { + t.Fatal("yes set on a mismatched magic") + } +} + +func TestEasyTier_SackDownTheSameDirectionSettlesNothing(t *testing.T) { + s := etUDP() + etFeedUDP(s, false, etSyn(9, 42)) + m, _ := etFeedUDP(s, false, etSack(9, 42)) + if etFlag(m, "yes") { + t.Fatal("yes set on a same-direction sack") + } +} + +func TestEasyTier_HolePunchProbesAreReportedButDoNotSettleTheFlow(t *testing.T) { + // 往可能根本没跑 EasyTier 的地址上喷的,规则得点名才拦 + s := etUDP() + var m analyzer.PropMap + for i := 0; i < 3; i++ { + m, _ = etFeedUDP(s, false, etDatagram(1, easyTierMsgHolePunch, bytes.Repeat([]byte{0x7f}, 16))) + } + if etFlag(m, "yes") { + t.Fatal("hole punches settled the flow") + } + etExpect(t, m, "msg_type_name", "hole_punch") +} + +func TestEasyTier_BothHolePunchBodyLengthsAreRecognised(t *testing.T) { + // 连通性路径 16 字节,UDP 会话层 32 字节;只有长度能把它们和噪声分开,两个都得列 + for _, n := range []int{16, 32} { + m, _ := etFeedUDP(etUDP(), false, etDatagram(1, easyTierMsgHolePunch, bytes.Repeat([]byte{0x7f}, n))) + if got := etText(m, "msg_type_name"); got != "hole_punch" { + t.Fatalf("body length %d: msg_type_name = %q", n, got) + } + } + m, _ := etFeedUDP(etUDP(), false, etDatagram(1, easyTierMsgHolePunch, bytes.Repeat([]byte{0x7f}, 24))) + etExpect(t, m, "msg_type_name", "") +} + +func TestEasyTier_DirectedHolePunchMustEchoTheDestinationPortInConnID(t *testing.T) { + punch := []byte{203, 0, 113, 9} + punch = binary.LittleEndian.AppendUint16(punch, 11010) + + m, _ := etFeedUDP(etUDP(), false, etDatagram(11010, easyTierMsgV4HolePunch, punch)) + etExpect(t, m, "msg_type_name", "v4_hole_punch") + + m, done := etFeedUDP(etUDP(), false, etDatagram(11011, easyTierMsgV4HolePunch, punch)) + etExpect(t, m, "msg_type_name", "") + if done { + t.Fatal("one bad datagram is not enough to give up") + } +} + +func TestEasyTier_UDPLengthFieldMustMatchTheDatagram(t *testing.T) { + wrong := etSyn(1, 2) + wrong[6] = 9 // 声明 9,体是 8 + m, _ := etFeedUDP(etUDP(), false, wrong) + etExpect(t, m, "msg_type_name", "") +} + +func TestEasyTier_UDPPaddingByteMustBeZero(t *testing.T) { + wrong := etSyn(1, 2) + wrong[5] = 1 + m, _ := etFeedUDP(etUDP(), false, wrong) + etExpect(t, m, "msg_type_name", "") +} + +func TestEasyTier_FourUnparseableDatagramsEndAUDPFlow(t *testing.T) { + s := etUDP() + for i := 0; i < 3; i++ { + if _, done := etFeedUDP(s, false, []byte("not easytier at all")); done { + t.Fatalf("gave up after %d datagrams", i+1) + } + } + if _, done := etFeedUDP(s, false, []byte("not easytier at all")); !done { + t.Fatal("did not give up after four unparseable datagrams") + } +} + +func TestEasyTier_TwoLengthFieldsOfATCPFrameMustAgree(t *testing.T) { + // 16 字节头后面声明载荷 4,实际到了 8 + frame := etBodyRaw(1, 1, 2, 0, 1, 0, 4, bytes.Repeat([]byte{0xaa}, 8)) + m, done := etFeedTCP(etTCP(), false, etSegment(frame)) + if !done { + t.Fatal("stream not done on a length mismatch") + } + etExpect(t, m, "packet_type_name", "") +} + +func TestEasyTier_AEADTailIsAllowedToOutrunTheDeclaredLength(t *testing.T) { + // 加密器追加 16 字节 tag 和 12 字节 nonce,不改头里声明的长度 + payload := bytes.Repeat([]byte{0x11}, 40+easyTierAEADTailSize) + frame := etBodyRaw(1, 1, 2, easyTierFlagEncrypted, 1, 0, 40, payload) + m, _ := etFeedTCP(etTCP(), false, etSegment(frame)) + + if !etFlag(m, "encrypted") { + t.Fatal("encrypted not set") + } + etExpect(t, m, "payload_len", "40") +} + +func TestEasyTier_CompressedPayloadMayComeInUnderTheDeclaredLength(t *testing.T) { + frame := etBodyRaw(1, 1, 2, easyTierFlagCompressed, 1, 0, 900, bytes.Repeat([]byte{0x22}, 61)) + m, _ := etFeedTCP(etTCP(), false, etSegment(frame)) + + if !etFlag(m, "compressed") { + t.Fatal("compressed not set") + } + etExpect(t, m, "payload_len", "900") +} + +func TestEasyTier_UnencryptedPayloadMustMatchTheDeclaredLengthExactly(t *testing.T) { + frame := etBodyRaw(1, 1, 2, 0, 1, 0, 40, bytes.Repeat([]byte{0x11}, 40+easyTierAEADTailSize)) + if _, done := etFeedTCP(etTCP(), false, etSegment(frame)); !done { + t.Fatal("stream not done on an unencrypted length mismatch") + } +} + +func TestEasyTier_ReservedByteIsZeroUnlessALivenessFlagIsSet(t *testing.T) { + withFlag := etBodyRaw(1, 1, 2, 0b0010_0000, 1, 0x5c, 4, make([]byte, 4)) + m, _ := etFeedTCP(etTCP(), false, etSegment(withFlag)) + etExpect(t, m, "packet_type_name", "data") + + withoutFlag := etBodyRaw(1, 1, 2, 0, 1, 0x5c, 4, make([]byte, 4)) + m, done := etFeedTCP(etTCP(), false, etSegment(withoutFlag)) + if !done { + t.Fatal("stream not done on a non-zero reserved byte") + } + etExpect(t, m, "packet_type_name", "") +} + +func TestEasyTier_ForwardCounterPastTheRoutersOwnCapIsRejected(t *testing.T) { + frame := etBodyRaw(1, 1, 2, 0, 9, 0, 4, make([]byte, 4)) + m, done := etFeedTCP(etTCP(), false, etSegment(frame)) + if !done { + t.Fatal("stream not done on forward counter 9") + } + etExpect(t, m, "packet_type_name", "") +} + +func TestEasyTier_UnnamedPacketTypeIsRejected(t *testing.T) { + frame := etBodyRaw(22, 1, 2, 0, 1, 0, 4, make([]byte, 4)) + if _, done := etFeedTCP(etTCP(), false, etSegment(frame)); !done { + t.Fatal("stream not done on packet type 22") + } +} + +func TestEasyTier_FrameSplitAcrossSegmentsIsReassembled(t *testing.T) { + wire := etSegment(etBody(easyTierPktHandshake, 9, 0, etHandshakeRequest(9, "office-mesh"))) + + s := etTCP() + m, done := etFeedTCP(s, false, wire[:3]) + if done { + t.Fatal("gave up on a length field that had not fully arrived") + } + etExpect(t, m, "packet_type_name", "") + + m, done = etFeedTCP(s, false, wire[3:]) + if done { + t.Fatal("stream done after a valid handshake") + } + if !etFlag(m, "yes") { + t.Fatal("yes not set after reassembly") + } + etExpect(t, m, "network_name", "office-mesh") +} + +func TestEasyTier_ByteAtATimeDeliveryStillReadsTheFrame(t *testing.T) { + wire := etSegment(etBody(easyTierPktHandshake, 9, 0, etHandshakeRequest(9, "mesh"))) + s := etTCP() + last := analyzer.PropMap{} + for _, b := range wire { + m, done := etFeedTCP(s, false, []byte{b}) + if done { + t.Fatal("stream done mid-frame") + } + if len(m) != 0 { + last = m + } + } + if !etFlag(last, "yes") { + t.Fatal("yes not set after byte-at-a-time delivery") + } + etExpect(t, last, "network_name", "mesh") +} + +func TestEasyTier_SeveralFramesPipelinedIntoOneSegmentAreAllRead(t *testing.T) { + wire := etSegment(etBody(1, 9, 8, bytes.Repeat([]byte{0x01}, 32))) + wire = append(wire, etSegment(etBody(4, 9, 8, nil))...) + wire = append(wire, etSegment(etBody(1, 9, 8, bytes.Repeat([]byte{0x03}, 64)))...) + + m, done := etFeedTCP(etTCP(), false, wire) + if done { + t.Fatal("stream done after valid frames") + } + if !etFlag(m, "yes") { + t.Fatal("yes not set after three consistent frames") + } + etExpect(t, m, "tx_pkt_cnt", "3") + // map 描述的是最新一帧 + etExpect(t, m, "payload_len", "64") +} + +func TestEasyTier_NetworkNameSurvivesLaterFrames(t *testing.T) { + // 只发一次。按 mesh 名写的规则得在握手滚过去之后还能用 + s := etTCP() + etFeedTCP(s, false, etSegment(etBody(easyTierPktHandshake, 9, 0, etHandshakeRequest(9, "office-mesh")))) + m, _ := etFeedTCP(s, false, etSegment(etBody(1, 9, 8, bytes.Repeat([]byte{0xee}, 128)))) + + etExpect(t, m, "network_name", "office-mesh") + etExpect(t, m, "packet_type_name", "data") +} + +func TestEasyTier_GapInTheTCPStreamEndsIt(t *testing.T) { + u, done := etTCP().Feed(false, false, false, 7, []byte("whatever")) + if u != nil { + t.Fatal("update published on a gap") + } + if !done { + t.Fatal("stream not done on a gap") + } +} + +func TestEasyTier_OrdinaryTrafficIsNotMistakenForATunnel(t *testing.T) { + m, done := etFeedTCP(etTCP(), false, []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")) + if !done || etFlag(m, "yes") { + t.Fatalf("HTTP: done=%v yes=%v", done, etFlag(m, "yes")) + } + + // TLS ClientHello 记录:16 03 01 ... 读出来是 21 MB 的帧体长度,直接拒绝 + m, done = etFeedTCP(etTCP(), false, []byte{0x16, 0x03, 0x01, 0x01, 0x4a, 0x01, 0x00, 0x01, 0x46}) + if !done || etFlag(m, "yes") { + t.Fatalf("TLS: done=%v yes=%v", done, etFlag(m, "yes")) + } +} + +func TestEasyTier_FETDoesNotCatchTheTCPHandshake(t *testing.T) { + // fet 只看客户端第一个分段,判完就 done。EasyTier 连接的第一个分段永远是明文握手, + // 整条连接上唯一低熵的东西,所以 fet 读作"非全加密",引擎放行,后面真正加密的帧再也不看。 + // 而且能不能中取决于握手里碰巧有多少随机内容(ex1 落在 3.4 豁免线附近,随网络名长短摇摆), + // 一个随运营者起的名字长短而变的检测器不是检测器。 + digest := make([]byte, 32) + for i := range digest { + digest[i] = byte(uint32(i) * 2654435761) + } + payload := etProtoVarint(1, easyTierHandshakeMagic) + payload = append(payload, etProtoVarint(2, 0x1a2b3c4d)...) + payload = append(payload, etProtoVarint(3, 1)...) + payload = append(payload, etProtoBytes(4, []byte("liveness-echo-v1"))...) + payload = append(payload, etProtoBytes(5, []byte("mesh"))...) + payload = append(payload, etProtoBytes(6, digest)...) + wire := etSegment(etBody(easyTierPktHandshake, 0x1a2b3c4d, 0, payload)) + + fet := (&tcp.FETAnalyzer{}).NewTCP(analyzer.TCPInfo{}, nil) + u, done := fet.Feed(false, true, false, 0, wire) + if !done { + t.Fatal("fet should decide on the first segment and not look again") + } + if u != nil && etFlag(u.M, "yes") { + t.Fatal("fet claimed this handshake, so this analyzer would be redundant") + } + + // 同样的字节,给本分析器 + m, _ := etFeedTCP(etTCP(), false, wire) + if !etFlag(m, "yes") { + t.Fatal("easytier did not claim its own handshake") + } + etExpect(t, m, "network_name", "mesh") +} + +func TestEasyTier_WireGuardAnalyzerDoesNotClaimUDPTransport(t *testing.T) { + // wireguard 覆盖的是 EasyTier 的 wg:// 监听,六分之一。udp:// 上没有任何东西读起来像 WireGuard 报文 + datagrams := [][]byte{ + etSyn(0x12345678, 0x0102030405060708), + etDatagram(7, easyTierMsgData, etBody(easyTierPktHandshake, 0x1a2b3c4d, 0, etHandshakeRequest(9, "mesh"))), + etDatagram(1, easyTierMsgHolePunch, bytes.Repeat([]byte{0x7f}, 16)), + } + for i, data := range datagrams { + wg := (&WireGuardAnalyzer{}).NewUDP(analyzer.UDPInfo{}, nil) + if u, _ := wg.Feed(false, data); u != nil { + t.Fatalf("wireguard claimed easytier datagram %d: %v", i, u.M) + } + } +} + +func TestEasyTier_STUNOnTheSameSocketIsNeitherEvidenceNorHeldAgainstTheFlow(t *testing.T) { + // EasyTier 把 STUN 复用在自己的 UDP socket 上,并在自己的帧解析之前分流;四个 STUN 不能结束流 + stun := append([]byte{0x00, 0x01, 0x00, 0x00, 0x21, 0x12, 0xa4, 0x42}, bytes.Repeat([]byte{0x11}, 12)...) + + s := etUDP() + for i := 0; i < 8; i++ { + m, done := etFeedUDP(s, false, stun) + if done { + t.Fatalf("STUN datagram %d ended the flow", i+1) + } + if len(m) != 0 { + t.Fatalf("STUN datagram %d published props: %v", i+1, m) + } + } + + m, _ := etFeedUDP(s, false, etSyn(1, 2)) + etExpect(t, m, "msg_type_name", "syn") +} diff --git a/cmd/root.go b/cmd/root.go index dc44dd2..59180e9 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -96,6 +96,7 @@ var analyzers = []analyzer.Analyzer{ &tcp.TLSAnalyzer{}, &tcp.TrojanAnalyzer{}, &udp.DNSAnalyzer{}, + &udp.EasyTierAnalyzer{}, &udp.OpenVPNAnalyzer{}, &udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{}, diff --git a/docs/easytier.zh.md b/docs/easytier.zh.md new file mode 100644 index 0000000..e13cbd3 --- /dev/null +++ b/docs/easytier.zh.md @@ -0,0 +1,241 @@ +# 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 +```