5 Commits
Author SHA1 Message Date
Mxmilu666 c879e10ac1 feat(tls): add support for RDP negotiation before TLS handshake 2026-09-30 16:54:53 +08:00
Mxmilu666andClaude Opus 5.5 ae7135ab64 feat(tls): reassemble hello messages split across TLS records
A handshake message may span any number of records, and splitting the
ClientHello into tiny records hides the SNI from analyzers that expect it
in the first one. The tls analyzer read the first record header and then
cut the handshake body straight out of the stream, so fragmented hellos
either stalled until Limit() or failed to parse, and every SNI rule was
bypassed.

The record layer is now peeled off and the hello rebuilt from the record
bodies before parsing, for both ClientHello and ServerHello. The number of
records a hello spanned is exposed as tls.req_records / tls.resp_records,
kept outside req/resp so a non-nil req still means a fully parsed hello.
Fragmentation is published as soon as a second record shows up, since
1-byte records can stretch a hello past Limit() and it never completes.

docs: add TLS record fragmentation doc, correct the claim in the TCP
window evasion doc that tls is unaffected by splitting.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-24 17:31:57 +08:00
Mxmilu666 8f8cc1d4d9 feat(tcp, udp): implement stream state release after verdict and adjust flush interval logic 2026-09-12 17:20:24 +08:00
Mxmilu666andClaude Fable 5.1 cc6ad72706 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RjQ1CTBn8j65cUZ2JKsY7L
2026-09-12 17:05:39 +08:00
Mxmilu666 f6342c4bcf feat(tcp): add Minecraft analyzer for handshake detection
feat(tcp): implement FETAnalyzer with segment buffering
feat(tcp): enhance TCP engine with window scale detection
test(tcp): add tests for Minecraft and FET analyzers
docs: add documentation for Minecraft analyzer and TCP evasion
2026-07-27 01:09:41 +08:00
24 changed files with 3495 additions and 221 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ Telegram グループ: https://t.me/OpGFW
## 特徴 ## 特徴
- フル IP/TCP 再アセンブル、各種プロトコルアナライザー - フル 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/) - Shadowsocks、VMess の「完全に暗号化されたトラフィック」の検出など (https://gfw.report/publications/usenixsecurity23/en/)
- Trojan プロキシプロトコルの検出 - Trojan プロキシプロトコルの検出
- [WIP] 機械学習に基づくトラフィック分類 - [WIP] 機械学習に基づくトラフィック分類
+4 -1
View File
@@ -9,6 +9,9 @@
**[中文文档](README.zh.md)** **[中文文档](README.zh.md)**
**[日本語ドキュメント](README.ja.md)** **[日本語ドキュメント](README.ja.md)**
> [!CAUTION]
> 此版本为 LoliaFRP 二次开发版本,加入了更多的识别规则,如果您正在寻找原版请移步 original 分支
OpenGFW is your very own DIY Great Firewall of China (https://en.wikipedia.org/wiki/Great_Firewall), available as a flexible, easy-to-use open source program on Linux. Why let the powers that be have all the fun? It's time to give power to the people and democratize censorship. Bring the thrill of cyber-sovereignty right into your home router and start filtering like a pro - you too can play Big Brother. OpenGFW is your very own DIY Great Firewall of China (https://en.wikipedia.org/wiki/Great_Firewall), available as a flexible, easy-to-use open source program on Linux. Why let the powers that be have all the fun? It's time to give power to the people and democratize censorship. Bring the thrill of cyber-sovereignty right into your home router and start filtering like a pro - you too can play Big Brother.
**Documentation site: https://gfw.dev/** **Documentation site: https://gfw.dev/**
@@ -21,7 +24,7 @@ Telegram group: https://t.me/OpGFW
## Features ## Features
- Full IP/TCP reassembly, various protocol analyzers - Full IP/TCP reassembly, various protocol analyzers
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, 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, - "Fully encrypted traffic" detection for Shadowsocks, VMess,
etc. (https://gfw.report/publications/usenixsecurity23/en/) etc. (https://gfw.report/publications/usenixsecurity23/en/)
- Trojan (proxy protocol) detection - Trojan (proxy protocol) detection
+12 -1
View File
@@ -15,10 +15,13 @@ Telegram 群组: https://t.me/OpGFW
> [!CAUTION] > [!CAUTION]
> 本项目仍处于早期开发阶段。测试时自行承担风险。我们正在寻求贡献者一起完善本项目。 > 本项目仍处于早期开发阶段。测试时自行承担风险。我们正在寻求贡献者一起完善本项目。
> [!CAUTION]
> 此版本为 LoliaFRP 二次开发版本,加入了更多的识别规则,如果您正在寻找原版请移步 original 分支
## 功能 ## 功能
- 完整的 IP/TCP 重组,各种协议解析器 - 完整的 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/zh/) - Shadowsocks, VMess 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
- Trojan 协议检测 - Trojan 协议检测
- [开发中] 基于机器学习的流量分类 - [开发中] 基于机器学习的流量分类
@@ -31,6 +34,14 @@ Telegram 群组: https://t.me/OpGFW
- 可扩展的 IO 实现 (目前只有 NFQueue) - 可扩展的 IO 实现 (目前只有 NFQueue)
- [开发中] Web UI - [开发中] Web UI
## 本仓库内的文档
- [Minecraft 分析器](docs/minecraft.zh.md)
- [EasyTier 分析器](docs/easytier.zh.md)
- [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md)
- [TLS record 分片规避与检测](docs/tls-record-fragmentation.zh.md)
- [拦截 TLS、放行 RDP](docs/rdp-tls.zh.md)
## 使用场景 ## 使用场景
- 广告拦截 - 广告拦截
+51 -6
View File
@@ -1,6 +1,21 @@
package tcp package tcp
import "github.com/apernet/OpenGFW/analyzer" import (
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/utils"
)
const (
// fetMinDataLen is how many bytes we want before classifying a stream.
// The metrics below are meaningless on a handful of bytes - a peer can
// force a tiny first segment by advertising a small TCP receive window in
// its SYN-ACK, which used to be enough to make us exempt the connection.
fetMinDataLen = 16
// fetMaxSegments bounds how long we wait for fetMinDataLen bytes. Without
// it, a peer that sends one byte and then goes quiet would keep the stream
// active - and therefore out of the connmark bypass - until the TCP timeout.
fetMaxSegments = 4
)
var _ analyzer.TCPAnalyzer = (*FETAnalyzer)(nil) var _ analyzer.TCPAnalyzer = (*FETAnalyzer)(nil)
@@ -25,6 +40,8 @@ func (a *FETAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) anal
type fetStream struct { type fetStream struct {
logger analyzer.Logger logger analyzer.Logger
buf utils.ByteBuffer
segs int
} }
func newFETStream(logger analyzer.Logger) *fetStream { func newFETStream(logger analyzer.Logger) *fetStream {
@@ -38,6 +55,38 @@ func (s *fetStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
if len(data) == 0 { if len(data) == 0 {
return nil, false return nil, false
} }
if rev {
// We classify what the client sends first. If the server speaks before
// the client does, this isn't a handshake we can say anything about.
if s.buf.Len() == 0 {
return nil, true
}
return nil, false
}
s.segs++
if s.buf.Len() == 0 && len(data) >= fetMinDataLen {
// Enough to decide on, and nothing buffered yet - classify in place.
return fetResult(data), true
}
s.buf.Append(data)
if s.buf.Len() < fetMinDataLen && s.segs < fetMaxSegments {
return nil, false
}
u = fetResult(s.buf.Buf)
s.buf.Reset()
return u, true
}
func (s *fetStream) Close(limited bool) *analyzer.PropUpdate {
if s.buf.Len() == 0 {
return nil
}
u := fetResult(s.buf.Buf)
s.buf.Reset()
return u
}
func fetResult(data []byte) *analyzer.PropUpdate {
ex1 := averagePopCount(data) ex1 := averagePopCount(data)
ex2 := isFirstSixPrintable(data) ex2 := isFirstSixPrintable(data)
ex3 := printablePercentage(data) ex3 := printablePercentage(data)
@@ -54,11 +103,7 @@ func (s *fetStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
"ex5": ex5, "ex5": ex5,
"yes": !exempt, "yes": !exempt,
}, },
}, true }
}
func (s *fetStream) Close(limited bool) *analyzer.PropUpdate {
return nil
} }
func popCount(b byte) int { func popCount(b byte) int {
+115
View File
@@ -0,0 +1,115 @@
package tcp
import (
"math/rand"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
// feedFET runs a sequence of client-to-server segments through a fresh stream
// and returns the props it settled on, or nil if it never reached a verdict.
func feedFET(t *testing.T, segments ...[]byte) analyzer.PropMap {
t.Helper()
s := newFETStream(nil)
for _, seg := range segments {
u, done := s.Feed(false, false, false, 0, seg)
if done {
if u == nil {
return nil
}
return u.M
}
}
if u := s.Close(false); u != nil {
return u.M
}
return nil
}
// encryptedSample returns deterministic high-entropy bytes, standing in for the
// first segment of a fully encrypted proxy protocol such as Shadowsocks.
func encryptedSample(n int) []byte {
r := rand.New(rand.NewSource(1))
b := make([]byte, n)
for i := range b {
b[i] = byte(r.Intn(256))
}
return b
}
func tlsClientHelloSample() []byte {
return append([]byte{0x16, 0x03, 0x01, 0x02, 0x00, 0x01, 0x00, 0x01, 0xfc}, encryptedSample(64)...)
}
// A peer can force a one-byte first segment by advertising a tiny TCP receive
// window in its SYN-ACK. The verdict must not depend on where the boundary
// falls: classifying the first segment alone used to exempt these streams.
func TestFETVerdictIsSegmentationIndependent(t *testing.T) {
samples := map[string][]byte{
"encrypted": encryptedSample(64),
"tls": tlsClientHelloSample(),
"http": []byte("GET /index.html HTTP/1.1\r\nHost: example.com\r\n\r\n"),
}
for name, data := range samples {
t.Run(name, func(t *testing.T) {
whole := feedFET(t, data)
if whole == nil {
t.Fatal("no verdict on unsplit data")
}
split := feedFET(t, data[:1], data[1:])
if got, want := split.Get("yes"), whole.Get("yes"); got != want {
t.Errorf("split verdict yes = %v, unsplit = %v", got, want)
}
})
}
}
func TestFETDetectsEncryptedFirstSegment(t *testing.T) {
data := encryptedSample(64)
if got := feedFET(t, data).Get("yes"); got != true {
t.Fatalf("unsplit: yes = %v, want true", got)
}
if got := feedFET(t, data[:1], data[1:]).Get("yes"); got != true {
t.Errorf("one-byte first segment: yes = %v, want true", got)
}
}
func TestFETExemptsTLS(t *testing.T) {
data := tlsClientHelloSample()
if got := feedFET(t, data).Get("yes"); got != false {
t.Fatalf("unsplit: yes = %v, want false", got)
}
if got := feedFET(t, data[:1], data[1:]).Get("yes"); got != false {
t.Errorf("one-byte first segment: yes = %v, want false", got)
}
}
// Waiting for more data must be bounded, or a peer that sends one byte and then
// stops would hold the stream open until the TCP timeout, keeping every packet
// of the connection in userspace.
func TestFETGivesUpAfterMaxSegments(t *testing.T) {
s := newFETStream(nil)
for i := 0; i < fetMaxSegments-1; i++ {
if _, done := s.Feed(false, false, false, 0, []byte{0x00}); done {
t.Fatalf("verdict reached after %d segments, want to keep waiting", i+1)
}
}
u, done := s.Feed(false, false, false, 0, []byte{0x00})
if !done || u == nil {
t.Fatalf("no verdict after %d segments", fetMaxSegments)
}
}
// The metrics only describe a client's opening bytes, so a server that speaks
// first should end the stream rather than be classified as if it were a client.
func TestFETServerSpeaksFirst(t *testing.T) {
s := newFETStream(nil)
u, done := s.Feed(true, false, false, 0, encryptedSample(64))
if !done {
t.Error("stream not done after server-first data")
}
if u != nil {
t.Errorf("props = %#v, want none", u.M)
}
}
+179
View File
@@ -0,0 +1,179 @@
package tcp
import (
"unicode/utf8"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/utils"
)
const (
minecraftMaxHandshakePacketLen = 2048
minecraftMaxServerAddrLen = 255 * 4
minecraftPacketHandshake = 0
minecraftNextStateStatus = 1
minecraftNextStateLogin = 2
minecraftNextStateTransfer = 3
)
var _ analyzer.TCPAnalyzer = (*MinecraftAnalyzer)(nil)
type MinecraftAnalyzer struct{}
func (a *MinecraftAnalyzer) Name() string {
return "minecraft"
}
func (a *MinecraftAnalyzer) Limit() int {
return minecraftMaxHandshakePacketLen + 5
}
func (a *MinecraftAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) analyzer.TCPStream {
return newMinecraftStream(logger)
}
type minecraftStream struct {
logger analyzer.Logger
reqBuf *utils.ByteBuffer
}
func newMinecraftStream(logger analyzer.Logger) *minecraftStream {
return &minecraftStream{logger: logger, reqBuf: &utils.ByteBuffer{}}
}
func (s *minecraftStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) {
if skip != 0 {
return minecraftResult(false, false, nil), true
}
if len(data) == 0 {
return nil, false
}
if rev {
return nil, false
}
s.reqBuf.Append(data)
m, needMore, valid := parseMinecraftHandshake(s.reqBuf.Buf)
if needMore {
return minecraftResult(false, true, nil), false
}
if !valid {
return minecraftResult(false, false, nil), true
}
return minecraftResult(true, false, m), true
}
func (s *minecraftStream) Close(limited bool) *analyzer.PropUpdate {
s.reqBuf.Reset()
return minecraftResult(false, false, nil)
}
func minecraftResult(yes, candidate bool, m analyzer.PropMap) *analyzer.PropUpdate {
if m == nil {
m = analyzer.PropMap{}
}
m["yes"] = yes
m["candidate"] = candidate
return &analyzer.PropUpdate{
Type: analyzer.PropUpdateReplace,
M: m,
}
}
func parseMinecraftHandshake(buf []byte) (m analyzer.PropMap, needMore, valid bool) {
packetLen, off, ok, invalid := readMinecraftVarInt(buf, 0)
if invalid || packetLen <= 0 || packetLen > minecraftMaxHandshakePacketLen {
return nil, false, false
}
if !ok {
return nil, true, false
}
packetEnd := off + int(packetLen)
if len(buf) < packetEnd {
return nil, true, false
}
packet := buf[off:packetEnd]
packetID, pos, ok, invalid := readMinecraftVarInt(packet, 0)
if invalid || !ok || packetID != minecraftPacketHandshake {
return nil, false, false
}
protocolVersion, pos, ok, invalid := readMinecraftVarInt(packet, pos)
if invalid || !ok {
return nil, false, false
}
addrLen, pos, ok, invalid := readMinecraftVarInt(packet, pos)
if invalid || !ok || addrLen <= 0 || addrLen > minecraftMaxServerAddrLen {
return nil, false, false
}
addrEnd := pos + int(addrLen)
if addrEnd+2 > len(packet) {
return nil, false, false
}
addrBytes := packet[pos:addrEnd]
if !utf8.Valid(addrBytes) {
return nil, false, false
}
pos = addrEnd
serverPort := uint16(packet[pos])<<8 | uint16(packet[pos+1])
pos += 2
nextState, pos, ok, invalid := readMinecraftVarInt(packet, pos)
if invalid || !ok || !isMinecraftNextState(nextState) || pos != len(packet) {
return nil, false, false
}
return analyzer.PropMap{
"protocol": int(protocolVersion),
"server_addr": string(addrBytes),
"server_port": serverPort,
"next_state": int(nextState),
"next_state_name": minecraftNextStateName(nextState),
"packet_length": int(packetLen),
}, false, true
}
func readMinecraftVarInt(buf []byte, offset int) (value int32, next int, ok bool, invalid bool) {
var result int32
for i := 0; i < 5; i++ {
if offset+i >= len(buf) {
return 0, offset, false, false
}
b := buf[offset+i]
result |= int32(b&0x7f) << (7 * i)
if b&0x80 == 0 {
return result, offset + i + 1, true, false
}
}
return 0, offset, false, true
}
func isMinecraftNextState(state int32) bool {
switch state {
case minecraftNextStateStatus, minecraftNextStateLogin, minecraftNextStateTransfer:
return true
default:
return false
}
}
func minecraftNextStateName(state int32) string {
switch state {
case minecraftNextStateStatus:
return "status"
case minecraftNextStateLogin:
return "login"
case minecraftNextStateTransfer:
return "transfer"
default:
return "unknown"
}
}
+119
View File
@@ -0,0 +1,119 @@
package tcp
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
func TestMinecraftParsing_HandshakeLogin(t *testing.T) {
handshake := buildMinecraftHandshake(t, 767, "211.136.162.178", 25565, minecraftNextStateLogin)
want := analyzer.PropMap{
"yes": true,
"candidate": false,
"protocol": 767,
"server_addr": "211.136.162.178",
"server_port": uint16(25565),
"next_state": minecraftNextStateLogin,
"next_state_name": "login",
"packet_length": len(handshake) - 1,
}
u, done := newMinecraftStream(nil).Feed(false, false, false, 0, handshake)
if !done {
t.Fatal("stream not done after full handshake")
}
if !reflect.DeepEqual(u.M, want) {
t.Errorf("parsed = %#v, want %#v", u.M, want)
}
}
func TestMinecraftParsing_FragmentedHandshake(t *testing.T) {
handshake := buildMinecraftHandshake(t, 767, "mc.example.com", 25565, minecraftNextStateStatus)
s := newMinecraftStream(nil)
u, done := s.Feed(false, false, false, 0, handshake[:2])
if done {
t.Fatal("stream done on partial handshake")
}
if got := u.M.Get("candidate"); got != true {
t.Fatalf("candidate = %v, want true", got)
}
u, done = s.Feed(false, false, false, 0, handshake[2:])
if !done {
t.Fatal("stream not done after remaining handshake")
}
if got := u.M.Get("yes"); got != true {
t.Fatalf("yes = %v, want true", got)
}
if got := u.M.Get("server_addr"); got != "mc.example.com" {
t.Fatalf("server_addr = %v, want mc.example.com", got)
}
}
func TestMinecraftParsing_InvalidAfterCandidate(t *testing.T) {
s := newMinecraftStream(nil)
u, done := s.Feed(false, false, false, 0, []byte{0x04})
if done {
t.Fatal("stream done on partial packet")
}
if got := u.M.Get("candidate"); got != true {
t.Fatalf("candidate = %v, want true", got)
}
u, done = s.Feed(false, false, false, 0, []byte{0x01, 0x02, 0x03, 0x04})
if !done {
t.Fatal("stream not done on invalid packet")
}
if got := u.M.Get("candidate"); got != false {
t.Fatalf("candidate = %v, want false", got)
}
if got := u.M.Get("yes"); got != false {
t.Fatalf("yes = %v, want false", got)
}
}
func TestMinecraftParsing_RejectsInvalidUTF8Address(t *testing.T) {
packet := []byte{0x00}
packet = append(packet, encodeMinecraftVarInt(767)...)
packet = append(packet, 0x01, 0xff)
packet = append(packet, 0x63, 0xdd)
packet = append(packet, encodeMinecraftVarInt(minecraftNextStateLogin)...)
data := append(encodeMinecraftVarInt(int32(len(packet))), packet...)
u, done := newMinecraftStream(nil).Feed(false, false, false, 0, data)
if !done {
t.Fatal("stream not done on invalid UTF-8 address")
}
if got := u.M.Get("yes"); got != false {
t.Fatalf("yes = %v, want false", got)
}
}
func buildMinecraftHandshake(t *testing.T, protocol int32, addr string, port uint16, nextState int32) []byte {
t.Helper()
packet := []byte{0x00}
packet = append(packet, encodeMinecraftVarInt(protocol)...)
packet = append(packet, encodeMinecraftVarInt(int32(len(addr)))...)
packet = append(packet, []byte(addr)...)
packet = append(packet, byte(port>>8), byte(port))
packet = append(packet, encodeMinecraftVarInt(nextState)...)
return append(encodeMinecraftVarInt(int32(len(packet))), packet...)
}
func encodeMinecraftVarInt(value int32) []byte {
var out []byte
for {
b := byte(value & 0x7f)
value = int32(uint32(value) >> 7)
if value != 0 {
b |= 0x80
}
out = append(out, b)
if value == 0 {
return out
}
}
}
+183 -169
View File
@@ -22,38 +22,72 @@ func (a *TLSAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) anal
return newTLSStream(logger) return newTLSStream(logger)
} }
const (
// tlsRecordHeaderSize is content type (1 byte) + legacy protocol version
// (2 bytes) + length (2 bytes).
tlsRecordHeaderSize = 5
// tlsMaxPlaintextLen is the largest body a plaintext record may carry.
tlsMaxPlaintextLen = 1 << 14
// tlsHandshakeHeaderSize is message type (1 byte) + length (3 bytes).
tlsHandshakeHeaderSize = 4
)
type tlsStream struct { type tlsStream struct {
logger analyzer.Logger logger analyzer.Logger
reqBuf *utils.ByteBuffer req tlsHello
reqMap analyzer.PropMap resp tlsHello
reqUpdated bool }
reqLSM *utils.LinearStateMachine
reqDone bool
respBuf *utils.ByteBuffer // tlsHello reads one hello message off one direction of the stream.
respMap analyzer.PropMap //
respUpdated bool // A handshake message may be split across any number of records (RFC 8446
respLSM *utils.LinearStateMachine // 5.1), and splitting the ClientHello into tiny records is a common way to
respDone bool // hide the SNI from middleboxes that expect it in the first record. So the
// record layer is peeled off first and the message is rebuilt from the record
// bodies before it is parsed.
type tlsHello struct {
rec utils.ByteBuffer // record layer bytes not consumed yet
msg utils.ByteBuffer // handshake layer bytes collected so far
lsm *utils.LinearStateMachine
clientHelloLen int msgType byte
serverHelloLen int x224Code byte // X.224 connection PDU that may precede the hello on RDP
minLen int // smallest valid message body
parse func(*utils.ByteBuffer) analyzer.PropMap // parses the message body
recLeft int // body bytes of the current record not consumed yet
msgLen int // message body length, -1 until the handshake header is read
records int // records that have carried the message so far
reported int // value of records last published
m analyzer.PropMap
updated bool
done bool
rdp analyzer.PropMap // RDP negotiation, nil if there was none
rdpUpdated bool
} }
func newTLSStream(logger analyzer.Logger) *tlsStream { func newTLSStream(logger analyzer.Logger) *tlsStream {
s := &tlsStream{logger: logger, reqBuf: &utils.ByteBuffer{}, respBuf: &utils.ByteBuffer{}} s := &tlsStream{logger: logger}
s.reqLSM = utils.NewLinearStateMachine( // minimal ClientHello body: protocol version (2 bytes) + random (32 bytes) +
s.tlsClientHelloPreprocess, // + session ID (1 byte) + cipher suites (4 bytes) +
s.parseClientHelloData, // + compression methods (2 bytes) + no extensions
) s.req.init(internal.TypeClientHello, x224CodeConnectionRequest, 41, internal.ParseTLSClientHelloMsgData)
s.respLSM = utils.NewLinearStateMachine( // minimal ServerHello body: server version (2 bytes) + random (32 bytes) +
s.tlsServerHelloPreprocess, // + session ID (>=1 byte) + cipher suite (2 bytes) +
s.parseServerHelloData, // + compression method (1 byte) + no extensions
) s.resp.init(internal.TypeServerHello, x224CodeConnectionConfirm, 38, internal.ParseTLSServerHelloMsgData)
return s return s
} }
func (h *tlsHello) init(msgType, x224Code byte, minLen int, parse func(*utils.ByteBuffer) analyzer.PropMap) {
h.msgType, h.x224Code, h.minLen, h.parse = msgType, x224Code, minLen, parse
h.msgLen = -1
h.lsm = utils.NewLinearStateMachine(h.readPrelude, h.readMessage, h.parseMessage)
}
func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) { func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyzer.PropUpdate, done bool) {
if skip != 0 { if skip != 0 {
return nil, true return nil, true
@@ -61,166 +95,146 @@ func (s *tlsStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
if len(data) == 0 { if len(data) == 0 {
return nil, false return nil, false
} }
var update *analyzer.PropUpdate h, key := &s.req, "req"
var cancelled bool
if rev { if rev {
s.respBuf.Append(data) h, key = &s.resp, "resp"
s.respUpdated = false }
cancelled, s.respDone = s.respLSM.Run() if h.done {
if s.respUpdated { return nil, false
update = &analyzer.PropUpdate{ }
Type: analyzer.PropUpdateMerge, h.rec.Append(data)
M: analyzer.PropMap{"resp": s.respMap}, h.updated = false
} var cancelled bool
s.respUpdated = false cancelled, h.done = h.lsm.Run()
// A fragmented hello is published as soon as it shows up, not only once
// the message is complete: a peer can make it too large to ever complete
// within Limit(), and a rule should not need the SNI to act on it.
if m := h.update(key, h.updated || (h.records > 1 && h.reported <= 1)); m != nil {
u = &analyzer.PropUpdate{Type: analyzer.PropUpdateMerge, M: m}
}
return u, cancelled || (s.req.done && s.resp.done)
}
// update returns the properties to publish for this direction, or nil if
// there is nothing to publish. The parsed message goes under key, the record
// count under key + "_records". The record count is kept out of the message
// map so that a non-nil message map still means a fully parsed hello. An RDP
// negotiation goes under "rdp_" + key as soon as it is parsed, whatever
// publish says.
func (h *tlsHello) update(key string, publish bool) analyzer.PropMap {
m := analyzer.PropMap{}
if h.rdpUpdated {
m["rdp_"+key] = h.rdp
h.rdpUpdated = false
}
if publish && h.records > 0 {
m[key+"_records"] = h.records
if h.updated {
m[key] = h.m
h.updated = false
} }
} else { h.reported = h.records
s.reqBuf.Append(data) }
s.reqUpdated = false if len(m) == 0 {
cancelled, s.reqDone = s.reqLSM.Run() return nil
if s.reqUpdated { }
update = &analyzer.PropUpdate{ return m
Type: analyzer.PropUpdateMerge, }
M: analyzer.PropMap{"req": s.reqMap},
// readMessage consumes records until a whole hello message is collected in
// h.msg.
func (h *tlsHello) readMessage() utils.LSMAction {
for {
if h.msgLen < 0 && h.msg.Len() >= tlsHandshakeHeaderSize {
header := h.msg.Buf
if header[0] != h.msgType {
return utils.LSMActionCancel
} }
s.reqUpdated = false h.msgLen = int(header[1])<<16 | int(header[2])<<8 | int(header[3])
if h.msgLen < h.minLen {
return utils.LSMActionCancel
}
}
if h.msgLen >= 0 && h.msg.Len() >= tlsHandshakeHeaderSize+h.msgLen {
return utils.LSMActionNext
}
if h.recLeft == 0 {
header, ok := h.rec.Get(tlsRecordHeaderSize, true)
if !ok {
return utils.LSMActionPause
}
if header[0] != internal.RecordTypeHandshake {
// Nothing else may come between the records of a handshake
// message.
return utils.LSMActionCancel
}
h.recLeft = int(header[3])<<8 | int(header[4])
if h.recLeft > tlsMaxPlaintextLen {
return utils.LSMActionCancel
}
// Empty records are forbidden for handshake messages, but still
// counted rather than rejected: a peer that sends them anyway is
// fragmenting, and rejecting them would stop the analysis.
h.records++
continue
}
n := min(h.recLeft, h.rec.Len())
if n == 0 {
return utils.LSMActionPause
}
body, _ := h.rec.Get(n, true)
h.recLeft -= n
if h.msg.Len() == 0 {
// Usually the whole message sits in this one chunk, so reference
// it instead of copying. The capacity is capped so that a later
// Append reallocates rather than writing into h.rec.
h.msg.Buf = body[:n:n]
} else {
h.msg.Append(body)
} }
} }
return update, cancelled || (s.reqDone && s.respDone)
} }
// tlsClientHelloPreprocess validates ClientHello message. // parseMessage converts the collected hello message into h.m.
// //
// During validation, message header and first handshake header may be removed // Anything after the message, such as the rest of the server's flight, is
// from `s.reqBuf`. // ignored.
func (s *tlsStream) tlsClientHelloPreprocess() utils.LSMAction { func (h *tlsHello) parseMessage() utils.LSMAction {
// headers size: content type (1 byte) + legacy protocol version (2 bytes) + body := &utils.ByteBuffer{Buf: h.msg.Buf[tlsHandshakeHeaderSize : tlsHandshakeHeaderSize+h.msgLen]}
// + content length (2 bytes) + message type (1 byte) + m := h.parse(body)
// + handshake length (3 bytes) h.rec.Reset()
const headersSize = 9 h.msg.Reset()
// minimal data size: protocol version (2 bytes) + random (32 bytes) +
// + session ID (1 byte) + cipher suites (4 bytes) +
// + compression methods (2 bytes) + no extensions
const minDataSize = 41
header, ok := s.reqBuf.Get(headersSize, true)
if !ok {
// not a full header yet
return utils.LSMActionPause
}
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeClientHello {
return utils.LSMActionCancel
}
s.clientHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
if s.clientHelloLen < minDataSize {
return utils.LSMActionCancel
}
// TODO: something is missing. See:
// const messageHeaderSize = 4
// fullMessageLen := int(header[3])<<8 | int(header[4])
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
// if msgNo != 1 {
// // what here?
// }
// if messageNo != int(messageNo) {
// // what here?
// }
return utils.LSMActionNext
}
// tlsServerHelloPreprocess validates ServerHello message.
//
// During validation, message header and first handshake header may be removed
// from `s.reqBuf`.
func (s *tlsStream) tlsServerHelloPreprocess() utils.LSMAction {
// header size: content type (1 byte) + legacy protocol version (2 byte) +
// + content length (2 byte) + message type (1 byte) +
// + handshake length (3 byte)
const headersSize = 9
// minimal data size: server version (2 byte) + random (32 byte) +
// + session ID (>=1 byte) + cipher suite (2 byte) +
// + compression method (1 byte) + no extensions
const minDataSize = 38
header, ok := s.respBuf.Get(headersSize, true)
if !ok {
// not a full header yet
return utils.LSMActionPause
}
if header[0] != internal.RecordTypeHandshake || header[5] != internal.TypeServerHello {
return utils.LSMActionCancel
}
s.serverHelloLen = int(header[6])<<16 | int(header[7])<<8 | int(header[8])
if s.serverHelloLen < minDataSize {
return utils.LSMActionCancel
}
// TODO: something is missing. See example:
// const messageHeaderSize = 4
// fullMessageLen := int(header[3])<<8 | int(header[4])
// msgNo := fullMessageLen / int(messageHeaderSize+s.serverHelloLen)
// if msgNo != 1 {
// // what here?
// }
// if messageNo != int(messageNo) {
// // what here?
// }
return utils.LSMActionNext
}
// parseClientHelloData converts valid ClientHello message data (without
// headers) into `analyzer.PropMap`.
//
// Parsing error may leave `s.reqBuf` in an unusable state.
func (s *tlsStream) parseClientHelloData() utils.LSMAction {
chBuf, ok := s.reqBuf.GetSubBuffer(s.clientHelloLen, true)
if !ok {
// Not a full client hello yet
return utils.LSMActionPause
}
m := internal.ParseTLSClientHelloMsgData(chBuf)
if m == nil { if m == nil {
return utils.LSMActionCancel return utils.LSMActionCancel
} else {
s.reqUpdated = true
s.reqMap = m
return utils.LSMActionNext
}
}
// parseServerHelloData converts valid ServerHello message data (without
// headers) into `analyzer.PropMap`.
//
// Parsing error may leave `s.respBuf` in an unusable state.
func (s *tlsStream) parseServerHelloData() utils.LSMAction {
shBuf, ok := s.respBuf.GetSubBuffer(s.serverHelloLen, true)
if !ok {
// Not a full server hello yet
return utils.LSMActionPause
}
m := internal.ParseTLSServerHelloMsgData(shBuf)
if m == nil {
return utils.LSMActionCancel
} else {
s.respUpdated = true
s.respMap = m
return utils.LSMActionNext
} }
h.m = m
h.updated = true
return utils.LSMActionNext
} }
func (s *tlsStream) Close(limited bool) *analyzer.PropUpdate { func (s *tlsStream) Close(limited bool) *analyzer.PropUpdate {
s.reqBuf.Reset() // Report how far a fragmented hello got if it never completed, e.g.
s.respBuf.Reset() // because it was stretched past Limit().
s.reqMap = nil m := s.req.update("req", s.req.records > 1 && s.req.records > s.req.reported)
s.respMap = nil if rm := s.resp.update("resp", s.resp.records > 1 && s.resp.records > s.resp.reported); rm != nil {
return nil if m == nil {
m = rm
} else {
for k, v := range rm {
m[k] = v
}
}
}
s.req.close()
s.resp.close()
if m == nil {
return nil
}
return &analyzer.PropUpdate{Type: analyzer.PropUpdateMerge, M: m}
}
func (h *tlsHello) close() {
h.rec.Reset()
h.msg.Reset()
h.m = nil
} }
+123
View File
@@ -0,0 +1,123 @@
package tcp
import (
"bytes"
"encoding/binary"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/internal"
"github.com/apernet/OpenGFW/analyzer/utils"
)
// RDP does not start TLS right away: the client first sends an X.224
// Connection Request and the server answers with a Connection Confirm, each in
// a TPKT packet, to agree on a security protocol ([MS-RDPBCGR] 1.3.1.1). Only
// then does the TLS handshake begin, on the same connection.
//
// The tls analyzer used to give up on the first byte of such a stream, which
// left RDP invisible to TLS rules, and with it anything that puts a fake RDP
// negotiation in front of its TLS. So the negotiation is parsed as an optional
// prelude of the hello, and published as rdp_req / rdp_resp.
const (
tpktVersion = 0x03
tpktHeaderSize = 4 // version (1 byte) + reserved (1 byte) + length (2 bytes)
// x224FixedSize is length indicator (1 byte) + code (1 byte) +
// DST-REF (2 bytes) + SRC-REF (2 bytes) + class option (1 byte).
x224FixedSize = 7
x224CodeConnectionRequest = 0xe0
x224CodeConnectionConfirm = 0xd0
rdpNegReq = 0x01
rdpNegRsp = 0x02
rdpNegFailure = 0x03
rdpNegSize = 8
)
var rdpCookiePrefix = []byte("Cookie: ")
// readPrelude consumes the X.224 connection PDU that precedes the hello on an
// RDP connection, if there is one.
func (h *tlsHello) readPrelude() utils.LSMAction {
b, ok := h.rec.GetByte(false)
if !ok {
return utils.LSMActionPause
}
if b == internal.RecordTypeHandshake {
return utils.LSMActionNext
}
if b != tpktVersion {
return utils.LSMActionCancel
}
header, ok := h.rec.Get(tpktHeaderSize, false)
if !ok {
return utils.LSMActionPause
}
n := int(binary.BigEndian.Uint16(header[2:4]))
if header[1] != 0 || n < tpktHeaderSize+x224FixedSize {
return utils.LSMActionCancel
}
pdu, ok := h.rec.Get(n, true)
if !ok {
return utils.LSMActionPause
}
m := parseX224Connection(pdu[tpktHeaderSize:], h.x224Code)
if m == nil {
return utils.LSMActionCancel
}
h.rdp = m
h.rdpUpdated = true
return utils.LSMActionNext
}
// parseX224Connection parses an X.224 Connection Request or Confirm TPDU
// carrying the RDP negotiation, or returns nil if tpdu is not one.
func parseX224Connection(tpdu []byte, code byte) analyzer.PropMap {
// The length indicator does not count itself. The low nibble of the code
// is the credit, and the high nibble of the class option the class, which
// is always 0 for RDP.
if int(tpdu[0]) != len(tpdu)-1 || tpdu[1]&0xf0 != code || tpdu[6]&0xf0 != 0 {
return nil
}
rest := tpdu[x224FixedSize:]
m := analyzer.PropMap{}
if code == x224CodeConnectionRequest {
// Both the cookie ("Cookie: mstshash=<user>") and the load balancer
// routing token ("Cookie: msts=<token>") are terminated by CRLF.
if bytes.HasPrefix(rest, rdpCookiePrefix) {
i := bytes.Index(rest, []byte("\r\n"))
if i < 0 {
return nil
}
m["cookie"] = string(rest[len(rdpCookiePrefix):i])
rest = rest[i+2:]
}
// A client that sends no RDP_NEG_REQ can only do standard RDP
// security (PROTOCOL_RDP).
m["protocols"] = 0
} else {
m["protocol"] = 0
}
if len(rest) == 0 {
return m
}
// Anything after the negotiation structure, such as the correlation info
// a client may append, is ignored.
if len(rest) < rdpNegSize || binary.LittleEndian.Uint16(rest[2:4]) != rdpNegSize {
return nil
}
v := int(binary.LittleEndian.Uint32(rest[4:8]))
switch {
case code == x224CodeConnectionRequest && rest[0] == rdpNegReq:
m["protocols"] = v
case code == x224CodeConnectionConfirm && rest[0] == rdpNegRsp:
m["protocol"] = v
case code == x224CodeConnectionConfirm && rest[0] == rdpNegFailure:
delete(m, "protocol")
m["failure"] = v
default:
return nil
}
return m
}
+131
View File
@@ -0,0 +1,131 @@
package tcp
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
)
// rdpConnectionRequest is what mstsc sends first: a cookie with the user name
// and an RDP_NEG_REQ asking for PROTOCOL_SSL | PROTOCOL_HYBRID |
// PROTOCOL_HYBRID_EX.
var rdpConnectionRequest = []byte{
0x03, 0x00, 0x00, 0x2c, // TPKT, length 44
0x27, 0xe0, 0x00, 0x00, 0x00, 0x00, 0x00, // X.224 CR
'C', 'o', 'o', 'k', 'i', 'e', ':', ' ', 'm', 's', 't', 's', 'h', 'a', 's', 'h', '=', 'e', 'l', 't', 'o', 'n', 's', '\r', '\n',
0x01, 0x00, 0x08, 0x00, 0x0b, 0x00, 0x00, 0x00, // RDP_NEG_REQ
}
func rdpConnectionConfirm(negType byte, v byte) []byte {
return []byte{
0x03, 0x00, 0x00, 0x13, // TPKT, length 19
0x0e, 0xd0, 0x00, 0x00, 0x12, 0x34, 0x00, // X.224 CC
negType, 0x00, 0x08, 0x00, v, 0x00, 0x00, 0x00,
}
}
var rdpTLSClientHelloProps = analyzer.PropMap{"cookie": "mstshash=eltons", "protocols": 11}
func TestTlsRDPClientHello(t *testing.T) {
data := append(append([]byte{}, rdpConnectionRequest...), tlsExampleClientHello...)
for name, segs := range map[string][][]byte{
"one segment": {data},
"byte by byte": splitEvery(data, 1),
"pdu, hello": {rdpConnectionRequest, tlsExampleClientHello},
} {
props, _ := feedTLS(newTLSStream(nil), false, segs...)
if got := props.Get("rdp_req"); !reflect.DeepEqual(got, rdpTLSClientHelloProps) {
t.Errorf("%s: rdp_req = %v, want %v", name, got, rdpTLSClientHelloProps)
}
if got := props.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) {
t.Errorf("%s: req = %v, want %v", name, got, tlsExampleClientHelloProps)
}
}
}
// The negotiation is published before any TLS shows up, so that rules can
// already see the stream is RDP.
func TestTlsRDPNegotiationReportedEarly(t *testing.T) {
s := newTLSStream(nil)
props, done := feedTLS(s, false, rdpConnectionRequest)
if done {
t.Fatal("stream done after connection request")
}
if got := props.Get("rdp_req"); !reflect.DeepEqual(got, rdpTLSClientHelloProps) {
t.Errorf("rdp_req = %v, want %v", got, rdpTLSClientHelloProps)
}
props, done = feedTLS(s, true, rdpConnectionConfirm(0x02, 0x02))
if done {
t.Fatal("stream done after connection confirm")
}
if got, want := props.Get("rdp_resp"), (analyzer.PropMap{"protocol": 2}); !reflect.DeepEqual(got, want) {
t.Errorf("rdp_resp = %v, want %v", got, want)
}
}
func TestTlsRDPConnectionConfirm(t *testing.T) {
for name, tc := range map[string]struct {
pdu []byte
want analyzer.PropMap
}{
"ssl": {rdpConnectionConfirm(0x02, 0x01), analyzer.PropMap{"protocol": 1}},
"hybrid_ex": {rdpConnectionConfirm(0x02, 0x08), analyzer.PropMap{"protocol": 8}},
"failure": {rdpConnectionConfirm(0x03, 0x05), analyzer.PropMap{"failure": 5}},
"no neg rsp": {[]byte{0x03, 0x00, 0x00, 0x0b, 0x06, 0xd0, 0x00, 0x00, 0x12, 0x34, 0x00}, analyzer.PropMap{"protocol": 0}},
} {
props, _ := feedTLS(newTLSStream(nil), true, tc.pdu)
if got := props.Get("rdp_resp"); !reflect.DeepEqual(got, tc.want) {
t.Errorf("%s: rdp_resp = %v, want %v", name, got, tc.want)
}
}
}
// Standard RDP security has no TLS: the client goes on with MCS, which is not
// a TLS record, so the analyzer stops with only the negotiation published.
func TestTlsRDPWithoutTLS(t *testing.T) {
mcs := []byte{0x03, 0x00, 0x01, 0xa0, 0x02, 0xf0, 0x80, 0x7f, 0x65}
props, done := feedTLS(newTLSStream(nil), false, rdpConnectionRequest, mcs)
if !done {
t.Error("stream not done")
}
if props.Get("rdp_req") == nil || props.Get("req") != nil {
t.Errorf("props = %v, want rdp_req only", props)
}
}
// truncatedNegReq is rdpConnectionRequest with the last two bytes of the
// RDP_NEG_REQ cut off and the lengths adjusted to match.
func truncatedNegReq() []byte {
pdu := append([]byte{}, rdpConnectionRequest[:len(rdpConnectionRequest)-2]...)
pdu[3] -= 2
pdu[4] -= 2
return pdu
}
func TestTlsRDPRejectsMalformedPrelude(t *testing.T) {
mutate := func(i int, b byte) []byte {
pdu := append([]byte{}, rdpConnectionRequest...)
pdu[i] = b
return pdu
}
for name, pdu := range map[string][]byte{
"reserved set": mutate(1, 0x01),
"wrong code": mutate(5, 0xd0),
"length mismatch": mutate(4, 0x26),
"class 2": mutate(10, 0x20),
"cookie w/o crlf": mutate(len(rdpConnectionRequest)-10, 'x'),
"bad neg type": mutate(len(rdpConnectionRequest)-8, 0x02),
"tpkt too short": {0x03, 0x00, 0x00, 0x05, 0x00},
"not tpkt nor tls": {0x05, 0x01, 0x00},
"truncated neg req": truncatedNegReq(),
} {
props, done := feedTLS(newTLSStream(nil), false, pdu)
if !done {
t.Errorf("%s: stream not done", name)
}
if len(props) != 0 {
t.Errorf("%s: props = %v, want none", name, props)
}
}
}
+150 -31
View File
@@ -7,39 +7,42 @@ import (
"github.com/apernet/OpenGFW/analyzer" "github.com/apernet/OpenGFW/analyzer"
) )
func TestTlsStreamParsing_ClientHello(t *testing.T) { // tlsExampleClientHello is taken from <https://tls12.xargs.org/#client-hello/annotated>.
// example packet taken from <https://tls12.xargs.org/#client-hello/annotated> var tlsExampleClientHello = []byte{
clientHello := []byte{ 0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00,
0x16, 0x03, 0x01, 0x00, 0xa5, 0x01, 0x00, 0x00, 0xa1, 0x03, 0x03, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c,
0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,
0x0d, 0x0e, 0x0f, 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8,
0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x00, 0x00, 0x20, 0xcc, 0xa8, 0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13,
0xcc, 0xa9, 0xc0, 0x2f, 0xc0, 0x30, 0xc0, 0x2b, 0xc0, 0x2c, 0xc0, 0x13, 0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f,
0xc0, 0x09, 0xc0, 0x14, 0xc0, 0x0a, 0x00, 0x9c, 0x00, 0x9d, 0x00, 0x2f, 0x00, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00,
0x00, 0x35, 0xc0, 0x12, 0x00, 0x0a, 0x01, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70,
0x00, 0x18, 0x00, 0x16, 0x00, 0x00, 0x13, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e,
0x6c, 0x65, 0x2e, 0x75, 0x6c, 0x66, 0x68, 0x65, 0x69, 0x6d, 0x2e, 0x6e, 0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00,
0x65, 0x74, 0x00, 0x05, 0x00, 0x05, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00,
0x0a, 0x00, 0x0a, 0x00, 0x08, 0x00, 0x1d, 0x00, 0x17, 0x00, 0x18, 0x00, 0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00,
0x19, 0x00, 0x0b, 0x00, 0x02, 0x01, 0x00, 0x00, 0x0d, 0x00, 0x12, 0x00, 0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06,
0x10, 0x04, 0x01, 0x04, 0x03, 0x05, 0x01, 0x05, 0x03, 0x06, 0x01, 0x06, 0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12,
0x03, 0x02, 0x01, 0x02, 0x03, 0xff, 0x01, 0x00, 0x01, 0x00, 0x00, 0x12, 0x00, 0x00,
0x00, 0x00, }
}
want := analyzer.PropMap{
"ciphers": []uint16{52392, 52393, 49199, 49200, 49195, 49196, 49171, 49161, 49172, 49162, 156, 157, 47, 53, 49170, 10},
"compression": []uint8{0},
"random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
"session": []uint8{},
"sni": "example.ulfheim.net",
"version": uint16(771),
}
var tlsExampleClientHelloProps = analyzer.PropMap{
"ciphers": []uint16{52392, 52393, 49199, 49200, 49195, 49196, 49171, 49161, 49172, 49162, 156, 157, 47, 53, 49170, 10},
"compression": []uint8{0},
"random": []uint8{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31},
"session": []uint8{},
"sni": "example.ulfheim.net",
"version": uint16(771),
}
func TestTlsStreamParsing_ClientHello(t *testing.T) {
s := newTLSStream(nil) s := newTLSStream(nil)
u, _ := s.Feed(false, false, false, 0, clientHello) u, _ := s.Feed(false, false, false, 0, tlsExampleClientHello)
got := u.M.Get("req") if got := u.M.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) {
if !reflect.DeepEqual(got, want) { t.Errorf("parsed = %v, want %v", got, tlsExampleClientHelloProps)
t.Errorf("%d B parsed = %v, want %v", len(clientHello), got, want) }
if got := u.M.Get("req_records"); got != 1 {
t.Errorf("req_records = %v, want 1", got)
} }
} }
@@ -67,3 +70,119 @@ func TestTlsStreamParsing_ServerHello(t *testing.T) {
t.Errorf("%d B parsed = %v, want %v", len(serverHello), got, want) t.Errorf("%d B parsed = %v, want %v", len(serverHello), got, want)
} }
} }
// tlsFragment re-encodes the handshake message in a single-record hello as
// records carrying at most size bytes of it each.
func tlsFragment(hello []byte, size int) []byte {
var out []byte
for msg := hello[5:]; len(msg) > 0; {
n := min(size, len(msg))
out = append(out, hello[0], hello[1], hello[2], byte(n>>8), byte(n))
out = append(out, msg[:n]...)
msg = msg[n:]
}
return out
}
// feedTLS feeds each segment in turn and returns the properties as the engine
// would have merged them, along with whether the stream said it was done.
func feedTLS(s *tlsStream, rev bool, segs ...[]byte) (analyzer.PropMap, bool) {
props := analyzer.PropMap{}
done := false
for _, seg := range segs {
var u *analyzer.PropUpdate
u, done = s.Feed(rev, false, false, 0, seg)
if u != nil {
for k, v := range u.M {
props[k] = v
}
}
}
return props, done
}
// Splitting the ClientHello across records must not hide it: the parse result
// has to be the same wherever the record boundaries fall.
func TestTlsClientHelloRecordFragmentation(t *testing.T) {
msgLen := len(tlsExampleClientHello) - 5
for _, size := range []int{1, 2, 3, 4, 5, 9, 50, msgLen - 1} {
data := tlsFragment(tlsExampleClientHello, size)
wantRecords := (msgLen + size - 1) / size
for name, segs := range map[string][][]byte{
"one segment": {data},
"byte by byte": splitEvery(data, 1),
"7 byte segs": splitEvery(data, 7),
} {
props, _ := feedTLS(newTLSStream(nil), false, segs...)
if got := props.Get("req"); !reflect.DeepEqual(got, tlsExampleClientHelloProps) {
t.Errorf("size %d, %s: parsed = %v, want %v", size, name, got, tlsExampleClientHelloProps)
}
if got := props.Get("req_records"); got != wantRecords {
t.Errorf("size %d, %s: req_records = %v, want %d", size, name, got, wantRecords)
}
}
}
}
// Fragmentation is published before the hello is complete, so that a rule can
// act on it even if the hello never completes.
func TestTlsClientHelloFragmentationReportedEarly(t *testing.T) {
data := tlsFragment(tlsExampleClientHello, 1)
s := newTLSStream(nil)
props, done := feedTLS(s, false, data[:12])
if done {
t.Fatal("stream done after two records")
}
if props.Get("req") != nil {
t.Errorf("req published early: %v", props.Get("req"))
}
if got := props.Get("req_records"); got != 2 {
t.Errorf("req_records = %v, want 2", got)
}
// Further records are not published one by one...
if u, _ := s.Feed(false, false, false, 0, data[12:60]); u != nil {
t.Errorf("unexpected update %v", u.M)
}
// ...but the count reached is, if the stream gets cut off.
u := s.Close(true)
if u == nil {
t.Fatal("no update on close")
}
if got := u.M.Get("req_records"); got != 10 {
t.Errorf("req_records on close = %v, want 10", got)
}
}
func TestTlsClientHelloRejectsInterleavedRecords(t *testing.T) {
data := tlsFragment(tlsExampleClientHello, 50)
// Turn the second record into an alert.
data[5+50] = 0x15
props, done := feedTLS(newTLSStream(nil), false, data)
if !done {
t.Error("stream not done")
}
if props.Get("req") != nil {
t.Errorf("req = %v, want nil", props.Get("req"))
}
}
func TestTlsStreamIgnoresUnfragmentedPartialHelloOnClose(t *testing.T) {
s := newTLSStream(nil)
if props, _ := feedTLS(s, false, tlsExampleClientHello[:100]); len(props) != 0 {
t.Errorf("props = %v, want none", props)
}
if u := s.Close(true); u != nil {
t.Errorf("close update = %v, want nil", u.M)
}
}
func splitEvery(data []byte, n int) [][]byte {
var segs [][]byte
for len(data) > 0 {
k := min(n, len(data))
segs = append(segs, data[:k])
data = data[k:]
}
return segs
}
+766
View File
@@ -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
}
+562
View File
@@ -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 "<missing>"
}
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", "<missing>")
etExpect(t, m, "magic_ok", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
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", "<missing>")
}
func TestEasyTier_UDPPaddingByteMustBeZero(t *testing.T) {
wrong := etSyn(1, 2)
wrong[5] = 1
m, _ := etFeedUDP(etUDP(), false, wrong)
etExpect(t, m, "msg_type_name", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
}
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", "<missing>")
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 <len> ... 读出来是 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")
}
+2
View File
@@ -90,11 +90,13 @@ var logFormatMap = map[string]zapcore.EncoderConfig{
var analyzers = []analyzer.Analyzer{ var analyzers = []analyzer.Analyzer{
&tcp.FETAnalyzer{}, &tcp.FETAnalyzer{},
&tcp.HTTPAnalyzer{}, &tcp.HTTPAnalyzer{},
&tcp.MinecraftAnalyzer{},
&tcp.SocksAnalyzer{}, &tcp.SocksAnalyzer{},
&tcp.SSHAnalyzer{}, &tcp.SSHAnalyzer{},
&tcp.TLSAnalyzer{}, &tcp.TLSAnalyzer{},
&tcp.TrojanAnalyzer{}, &tcp.TrojanAnalyzer{},
&udp.DNSAnalyzer{}, &udp.DNSAnalyzer{},
&udp.EasyTierAnalyzer{},
&udp.OpenVPNAnalyzer{}, &udp.OpenVPNAnalyzer{},
&udp.QUICAnalyzer{}, &udp.QUICAnalyzer{},
&udp.WireGuardAnalyzer{}, &udp.WireGuardAnalyzer{},
+241
View File
@@ -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
```
+67
View File
@@ -0,0 +1,67 @@
# Minecraft 分析器
识别 Minecraft Java 版客户端在连接建立后发出的第一个数据包(Handshake,packet ID `0x00`),从中取出客户端请求的服务器地址、协议版本和后续状态。基岩版走 UDP/RakNet,不在本分析器范围内。
分析器名是 `minecraft`。和其他分析器一样,**只有当某条规则的表达式里出现了 `minecraft` 这个标识符时,它才会被启用**(见 `ruleset/expr.go` 的依赖收集逻辑),不写规则就不会有任何开销。
## 属性
| 属性 | 类型 | 说明 |
| --- | --- | --- |
| `yes` | bool | 确认是 Minecraft 握手 |
| `candidate` | bool | 数据还不完整,暂时无法判定,仍在等待后续分段 |
| `protocol` | int | 协议版本号,**不是游戏版本号**,对照表见 [minecraft.wiki](https://minecraft.wiki/w/Protocol_version)(例如 767 对应 1.21) |
| `server_addr` | string | 客户端在握手包里填写的服务器地址 |
| `server_port` | uint16 | 客户端填写的端口 |
| `next_state` | int | 1 = status,2 = login,3 = transfer |
| `next_state_name` | string | 上面的可读形式:`status` / `login` / `transfer` |
| `packet_length` | int | 握手包声明的长度(不含长度字段自身) |
`yes` 和 `candidate` 一定存在,其余字段只在 `yes` 为 true 时出现。
## 规则示例
只拦截真正进服的连接,放过服务器列表里的 ping:
```yaml
- name: block-minecraft-login
action: block
log: true
expr: minecraft != nil && minecraft.yes && minecraft.next_state_name == "login"
```
只允许连接指定的服务器,其余一律拦截:
```yaml
- name: minecraft-allowlist
action: block
expr: >
minecraft != nil && minecraft.yes &&
!hasPrefix(minecraft.server_addr, "mc.example.com")
```
按协议版本卡老客户端:
```yaml
- name: block-old-minecraft-clients
action: block
expr: minecraft != nil && minecraft.yes && minecraft.protocol < 763
```
纯记录不拦截,先摸清楚流量情况:
```yaml
- name: log-minecraft
log: true
expr: minecraft != nil && minecraft.yes
```
## 写规则时要注意的几点
**`server_addr` 不一定是干净的域名。** 握手包里的地址字段被生态里几个东西复用了,客户端可能在真实域名后面追加以 `\0` 分隔的内容:Forge/FML 客户端会加 `\0FML\0`、`\0FML2\0`、`\0FML3\0` 之类的标记,BungeeCord 和 Velocity 的 IP 转发会把玩家真实 IP 和 UUID 拼在后面。分析器保存的是原始字节,不做任何清洗。所以不要用 `==` 精确比较,用 `hasPrefix(minecraft.server_addr, ...)`,或者先 `split(minecraft.server_addr, "\x00")[0]` 再比。
**这个字段是客户端自己填的,不可信。** 它反映客户端想连哪个域名(对 SRV 记录的情况是解析前的原始主机名),不是它实际连到的 IP。要认真限制目标,得配合 `ip.dst` 一起写。
**判定发生在第一个数据包。** 握手之后的登录、加密、游戏数据都不会再看。`Limit()` 是 2053 字节,正常握手包只有几十字节,绰绰有余;超过这个量还没解析成功的连接会被判为非 Minecraft。
**握手包被拆到多个 TCP 分段时也能正确解析。** 分析器内部有缓冲区,数据不够时返回 `candidate = true` 并继续等待,不会像只看单个分段的分析器那样被切包绕过。相关背景见 [TCP 窗口操纵规避](tcp-window-evasion.zh.md)。
+93
View File
@@ -0,0 +1,93 @@
# 拦截 TLS、放行 RDP
## 问题
RDP 不是一连上就开始 TLS:客户端先在 TPKT 里发一个 X.224 Connection Request,服务端回一个 Connection Confirm,双方借此协商安全协议([MS-RDPBCGR] 1.3.1.1),协商完才在同一条连接上开始 TLS 握手。
`tls` 分析器以前要求流的第一个字节就是 TLS record(`0x16`),碰到 RDP 开头的 `03 00` 直接放弃。结果是:
- RDP 里的 TLS 对 `tls` 规则完全不可见,没法按 RDP 单独写规则;
- 反过来,任何流量只要在 TLS 前面垫一段假的 X.224 协商,就能绕过"拦截所有 TLS"的规则。
## 做法
`tls` 分析器现在把 RDP 协商当作 hello 之前可选的一段前导来解析:
- 第一个字节是 `0x16`:和以前一样,直接当 TLS 解析。
- 第一个字节是 `0x03`:按 TPKT + X.224 解析。客户端方向要求是 Connection Request,服务端方向要求是 Connection Confirm;长度、class、协商结构任何一处不对都视为非法,分析器取消。
- 前导解析完后照常解析 ClientHello / ServerHello,record 分片重组等逻辑完全一样。
协商一解析出来就上报,不用等 TLS:
| 属性 | 类型 | 说明 |
| --- | --- | --- |
| `tls.rdp_req.cookie` | string | Connection Request 里的 cookie,去掉 `Cookie: ` 前缀和 CRLF,如 `mstshash=alice`;没有 cookie 时不存在 |
| `tls.rdp_req.protocols` | int | RDP_NEG_REQ 里客户端请求的协议位图;没有 RDP_NEG_REQ 时为 0 |
| `tls.rdp_resp.protocol` | int | RDP_NEG_RSP 里服务端选定的协议;没有 RDP_NEG_RSP 时为 0 |
| `tls.rdp_resp.failure` | int | 服务端回的是 RDP_NEG_FAILURE 时的失败码(此时没有 `protocol`) |
协议取值:
| 值 | 名称 | 是否走 TLS |
| --- | --- | --- |
| 0 | PROTOCOL_RDP(标准 RDP 安全,RC4) | 否 |
| 1 | PROTOCOL_SSL | 是 |
| 2 | PROTOCOL_HYBRID(CredSSP / NLA) | 是 |
| 4 | PROTOCOL_RDSTLS | 是 |
| 8 | PROTOCOL_HYBRID_EX | 是 |
| 16 | PROTOCOL_RDSAAD | 是 |
## 规则
规则按顺序匹配、第一条命中的生效,所以放行 RDP 的规则要写在拦截 TLS 的前面:
```yaml
- name: allow-rdp-tls
action: allow
log: true
expr: >
tls != nil && tls.req != nil &&
tls.rdp_req != nil && tls.rdp_resp != nil &&
tls.rdp_resp.protocol in [1, 2, 4, 8, 16]
- name: block-tls
action: block
log: true
expr: tls != nil && tls.req != nil
```
放行条件同时要求:
- **`tls.req != nil`**:ClientHello 已经完整解析。只看到协商就放行的话,协商之后跟什么都行。
- **`tls.rdp_resp != nil` 且 `protocol` 是走 TLS 的协议**:真实的 RDP 只有在服务端选了 TLS 类协议之后才会发 ClientHello。服务端选了 PROTOCOL_RDP 或者回了协商失败,后面却出现 TLS,说明是伪造的,交给 `block-tls` 拦掉。
正常 RDP 的时序是 CR → CC → ClientHello,ClientHello 到的时候 `rdp_resp` 早已上报,不存在规则先看到 `req` 再看到 `rdp_resp` 的竞争。
标准 RDP 安全(`protocol == 0`)的连接里根本没有 TLS,两条规则都不会命中,按默认放行。
### 收紧放行条件
上面的规则只验证"协商格式正确、协商结果和后续 TLS 一致"。客户端和服务端串通的话,完全可以按规范发一套协商再跑任意 TLS,所以按需叠加:
```yaml
# 只放行标准端口
expr: ... && port.dst == 3389
# 只放行指定服务器
expr: ... && ip.dst in ["10.0.0.10", "10.0.0.11"]
# 要求客户端带 mstshash cookie(mstsc 通常会带,但不保证,先观察日志再启用)
expr: ... && tls.rdp_req.cookie != nil && tls.rdp_req.cookie startsWith "mstshash="
```
## 行为变化
**以前按 `tls != nil && tls.req != nil` 写的规则现在会命中 RDP。** 以前 RDP 里的 TLS 看不到,所以不受影响;现在看得到了。不想拦 RDP 的话,必须像上面那样在前面加一条放行规则。
## 局限
**只认 RDP 这一种前导。** 其他协议里的 STARTTLS(SMTP、IMAP、XMPP 等)和任何非 TLS、非 TPKT 开头的前缀仍然会让 `tls` 分析器直接放弃。要做到"拦截所有 TLS",还需要配合 `fet` 等不依赖协议开头的检测。
**RD Gateway 不在此列。** 经由 RD 网关的 RDP 跑在 443 端口的 HTTPS 上,开头就是普通 TLS,没有 X.224 协商,只能按 SNI / IP 放行。
**RDP 的 UDP 传输不在此列。** RDP-UDP 用的是 DTLS,走 UDP,与 `tls` 分析器无关。
+112
View File
@@ -0,0 +1,112 @@
# TCP 窗口操纵规避与检测
## 规避手法
服务端在本机加一条这样的规则,就能让不少 DPI 失效:
```
table inet filter {
chain output {
type filter hook output priority filter;
oifname eth0 tcp sport <本地端口> tcp flags & (syn | ack) == (syn | ack) tcp window set 1 accept
}
}
```
它只匹配服务端发出的 SYN-ACK,把里面通告的接收窗口改成 1。按 RFC 7323,window scale 选项不作用于 SYN/SYN-ACK 自身的窗口字段,所以这里的 `1` 就是字面意义的 1 字节,不会被 wscale 放大。
握手完成后,客户端认为对端只能收 1 个字节,第一个数据包就只带 1 字节负载。服务端内核回 ACK 时通告的是真实窗口(那条规则不匹配纯 ACK),客户端随即把剩下的数据正常发出。净效果是首包被切成「1 字节 + 剩余部分」两段,代价只有一个 RTT,之后传输速度不受影响。
对于**只看第一个 TCP 分段就下结论**的分析器,这一刀就够了:拿到 1 个字节,任何需要 3 字节、6 字节的特征都匹配不上,于是误判放行。更糟的是 OpenGFW 在所有分析器都判完且无人拦截时会下发 `ACCEPT_STREAM`,通过 connmark 让这条连接的后续包在内核里直接 accept,根本不再进入用户态——一次误判就是永久放行。
## 第一层防御:分析器攒够数据再判决
`fet`(全加密流量检测)原先在第一次 `Feed` 就返回 `done = true`,是这个手法的主要受害者。现在它会缓冲到至少 `fetMinDataLen`(16)字节再分类。
为了不增加正常流量的开销,实现上做了两件事:
- **快路径**:首个分段本身就够长时(正常流量的首包都是几百字节),直接在原始切片上分类,不拷贝、不分配,开销与改动前完全一致。只有小于 16 字节的首段才会走缓冲。
- **段数上限**:最多等 `fetMaxSegments`(4)个分段。否则一个只发 1 字节然后装死的对端,就能让流一直留在活跃状态、拿不到 connmark 快速通道,直到 TCP 超时(默认 10 分钟)为止——那会变成一个可被主动利用的资源消耗面。
`http`、`tls`、`minecraft` 等分析器本来就用 `utils.ByteBuffer` 做增量解析,不受这个手法影响。注意这只针对 TCP 分段;TLS 自己记录层上的分片是另一回事,见 [TLS record 分片规避与检测](tls-record-fragmentation.zh.md)。
## 第二层防御:把窗口改写本身当指纹
分析器只能看到重组后的载荷,拿不到任何 TCP 头字段。所以这一层做在引擎里:`engine/tcp.go` 的 `Accept` 每个包都会被调用,其中检查 SYN-ACK 的窗口值,异常时把握手元数据挂到 `tcpmeta` 属性下,供规则引擎使用。
| 属性 | 类型 | 说明 |
| --- | --- | --- |
| `tcpmeta.synack_window` | int | SYN-ACK 通告的接收窗口(未经 wscale 放大的字面值) |
| `tcpmeta.synack_wscale` | int | SYN-ACK 协商的 window scale 因子,没有该选项时不存在 |
| `tcpmeta.first_seg_len` | int | 客户端第一个数据分段的字节数 |
`tcpmeta` 本身只在有东西值得报告时才会创建:SYN-ACK 窗口 ≤ `tcpSynAckWindowMax`(4096),或者客户端首段 ≤ `tcpFirstSegLenMax`(64)字节。正常连接一次分配都不会发生,代价是**规则里看不到超过门槛的值**——门槛只是为了省开销,真正的判定阈值请在规则里写。
> [!IMPORTANT]
> **每个字段都要单独判空**,不能只判 `tcpmeta != nil`。这两个信号是独立触发的:一条 SYN-ACK 窗口完全正常、只是首包偏短的连接(SSH 的客户端版本标识只有二十几字节,就是典型)会创建 `tcpmeta`,但里面没有 `synack_window`。此时 `tcpmeta != nil && tcpmeta.synack_window <= 16` 会在运行期抛 `invalid operation: <nil> <= int`。这类错误不会让进程崩溃,`ruleset/expr.go` 里的 `Match` 记录一条 `MatchError` 后就跳到下一条规则——也就是说**这条检测会静默失效**,日志里不翻出来根本发现不了。
>
> 只要 OpenGFW 看得到 SYN-ACK,`tcpmeta` 里就一定带 `synack_window`(`tcpMeta()` 在创建时会把握手字段一并填进去)。但单向可见的部署看不到返回方向,所以判空仍然是必须的。`synack_wscale` 则是本来就可能不存在——对端没协商窗口缩放时就没有这个字段。
`synack_window` 看的是**手法**,`first_seg_len` 看的是**结果**。前者要求 OpenGFW 能看到返回方向的流量,后者只需要看到客户端方向;前者能被改用窗口 40 之类的取值绕开,后者不管对方怎么调窗口都会留下痕迹。两个一起用最稳。
### 规则示例
窗口小到不可能是真实协议栈:
```yaml
- name: tcp-tiny-synack-window
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.synack_window != nil &&
tcpmeta.synack_window <= 16
```
更锋利的判据是**自相矛盾的握手**。接收缓冲区真的只有几个字节的协议栈,不会同时去协商 128 倍的窗口缩放;这两者同时出现,基本只可能是字段在传输途中被改写:
```yaml
- name: tcp-window-rewritten
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.synack_wscale != nil &&
tcpmeta.synack_window <= 16
```
直接拦被切开的首包,不管对方是怎么切的:
```yaml
- name: tcp-split-first-segment
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.first_seg_len != nil &&
tcpmeta.first_seg_len <= 8
```
配合 `fet` 一起用,把「全加密流量」和「还试图掩盖自己」两个信号叠加。因为有第二个条件兜底,首包长度的阈值可以放宽到 32:
```yaml
- name: evasive-encrypted-traffic
action: block
log: true
expr: >
tcpmeta != nil && tcpmeta.first_seg_len != nil &&
tcpmeta.first_seg_len <= 32 && fet != nil && fet.yes
```
建议先用 `log: true` 不带 `action` 跑一段时间,看清楚基线再决定要不要拦。
## 局限
**判定要等到第一个数据包。** `ruleset.Match` 只在 `ReassembledSG` 里调用,也就是必须有载荷。握手异常但从不发数据的连接不会被匹配到——不过那种连接本来也没什么可拦的。
**阈值可以被绕。** 对方把窗口改成 40 或 100,既躲开 `<= 16`,又照样能把 ClientHello 切开。所以第二层是辅助,真正根治的是第一层的缓冲——它消灭的是整类切包规避,而不是某一种参数取值。
**小窗口不等于恶意。** 部分嵌入式协议栈的初始窗口确实只有几百到一两千字节,某些 CDN 和负载均衡设备也会改写窗口。阈值别设太高,`<= 16` 是安全的,上到 1024 就要先看基线。`synack_wscale` 那条组合判据的误报率要低得多。
**`first_seg_len` 的阈值尤其要保守。** 有不少协议的首包本来就很短:Minecraft 握手包只有二十几字节,SSH 客户端版本标识约二十到四十字节,还有各种短命令的二进制 RPC。把阈值设到 16 以上会误伤这些正常流量——特别是如果你同时在用 Minecraft 分析器,`<= 32` 会命中每一条 Minecraft 连接。单独拿它当拦截依据时用 `<= 8`;想放宽到 32,就必须像上面那样再叠加一个条件(`fet.yes`、目标端口、或者 `synack_window`)。
**首包短不代表后面还有数据。** 目前只记录第一个分段的长度,不区分「被切开的大请求」和「本来就只有几字节的完整请求」。前者才是规避,后者是正常的。这也是建议把阈值压到 8 或者叠加条件的原因之一。
**只改 SYN-ACK 会留下窗口跃变。** 服务端内核对改写并不知情,后续纯 ACK 通告的还是真实窗口,线上必然出现 `1 → 64240` 这种一跳到顶的序列,而真实协议栈的接收窗口是随缓冲区占用平滑变化的。这个指纹更难规避,但要观察到它就必须持续跟踪 server→client 方向的窗口序列,也就意味着不能再提前下发 `ACCEPT_STREAM`——那会让全局最大的性能优化失效。目前没有实现,需要时建议做成只对已标记可疑的连接开启的定向跟踪。
+70
View File
@@ -0,0 +1,70 @@
# TLS record 分片规避与检测
## 规避手法
TLS 允许一个握手消息跨任意多个 record 传输(RFC 8446 §5.1)。客户端把 ClientHello 拆成很多个小 record,服务端照常把它们拼回去完成握手;但只看第一个 record 的 DPI 就拿不到完整的 ClientHello,SNI 也就看不到了。Xray 的 `fragment`(`packets: "tlshello"`)以及各类"TLS 分片"工具用的都是这个手法。
它和 [TCP 窗口操纵](tcp-window-evasion.zh.md) 是两层不同的东西:TCP 分段切的是字节流,OpenGFW 的 TCP 重组会把它抹平;record 分片切的是 TLS 自己的记录层,重组后的字节流里每一片前面都还带着 5 字节的 record 头。
`tls` 分析器以前默认 ClientHello 只在一个 record 里,读完第一个 record 头就直接按握手长度切数据,碰到分片会:
- 第一个 record 少于 4 字节握手数据时,把下一个 record 的头误当成握手长度(例如 `16 03 01` 被读成 0x160301),一直等到 8192 字节的上限;
- 其他情况下,后续 record 的头混进消息体导致解析失败,分析器取消。
两种情况都不产出任何属性,所有基于 `tls.req.sni` 的规则都会被绕过。
## 第一层防御:在 record 层重组
现在分析器先剥掉 record 层,把各个 record 的内容拼回完整的握手消息再解析,所以无论切在哪里、切成多少片,`tls.req` 的解析结果都和不分片时完全一样。ServerHello 方向也做了同样的处理。
- 握手消息中间夹了其他类型的 record(alert、change_cipher_spec 等)会被视为非法,分析器取消。
- record 长度超过 2^14 视为非法。
- 空 record 按规范不允许出现在握手中,但这里不拒绝,只是计数——拒绝的话反而给了对方一个让分析中止的开关。
- 常见情况下整个 ClientHello 在一个 record 里,此时直接引用原始缓冲区,不做额外拷贝。
## 第二层防御:把分片本身当指纹
| 属性 | 类型 | 说明 |
| --- | --- | --- |
| `tls.req_records` | int | ClientHello 跨了几个 record |
| `tls.resp_records` | int | ServerHello 跨了几个 record |
主流浏览器和 TLS 库都把 ClientHello 放在一个 record 里,即使是带后量子密钥交换、将近 2KB 的 ClientHello 也远小于 16KB 的 record 上限。所以 `req_records > 1` 本身就是很强的规避信号,误报率很低。
这个计数放在 `req` 外面,是为了保证 `tls.req != nil` 仍然表示"ClientHello 已完整解析",现有规则的语义不变。
### 提前上报
分片一出现(第二个 record 开始)就会上报一次 `req_records`,不用等消息收完。原因是分析器有 8192 字节的 `Limit()`:切成每片 1 字节时,每个字节要额外带 5 字节的 record 头,一个 1.8KB 的 ClientHello 会膨胀到 11KB 左右,永远收不完。所以规则不能依赖 SNI 才动手。
之后的 record 不会逐个上报(否则每片都要跑一次规则匹配);消息收完时,或者分析器因为超限被关闭时,再上报最终的计数。所以规则里看到的值可能是 2,也可能是最终值——写阈值时按 `> 1` 来写。
### 规则示例
直接拦截分片的 ClientHello:
```yaml
- name: tls-record-fragmentation
action: block
log: true
expr: tls != nil && tls.req_records != nil && tls.req_records > 1
```
> [!IMPORTANT]
> 要判断 `tls.req_records != nil`,不能只判 `tls != nil`:只看到服务端方向时,`tls` 里可能只有 `resp` 相关的字段。
按 SNI 拦截的规则不需要任何改动,分片后照样能匹配:
```yaml
- name: block-example
action: block
expr: tls != nil && tls.req != nil && tls.req.sni endsWith "example.com"
```
## 局限
**只统计 hello 消息本身。** ClientHello 收完就停止计数,后面的 record 不影响 `req_records`。
**超长的分片拿不到 SNI。** 分片把 ClientHello 撑过 8192 字节时,`tls.req` 不会出现,只能靠 `req_records` 拦截。需要同时拿到 SNI 的话可以调大 `Limit()`,代价是每条 TLS 连接的缓冲上限相应变大。
**QUIC 不在此列。** QUIC 的 ClientHello 在 CRYPTO 帧里,走的是 `quic` 分析器,与这里无关。
+199
View File
@@ -0,0 +1,199 @@
package engine
import (
"net"
"testing"
"time"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/analyzer/tcp"
"github.com/apernet/OpenGFW/analyzer/udp"
"github.com/apernet/OpenGFW/io"
"github.com/apernet/OpenGFW/ruleset"
"github.com/apernet/OpenGFW/ruleset/builtins/geo"
"github.com/bwmarrin/snowflake"
"github.com/google/gopacket"
"github.com/google/gopacket/layers"
"github.com/google/gopacket/reassembly"
)
type nullEngineLogger struct{}
func (nullEngineLogger) WorkerStart(int) {}
func (nullEngineLogger) WorkerStop(int) {}
func (nullEngineLogger) TCPStreamNew(int, ruleset.StreamInfo) {}
func (nullEngineLogger) TCPStreamPropUpdate(ruleset.StreamInfo, bool) {}
func (nullEngineLogger) TCPStreamAction(ruleset.StreamInfo, ruleset.Action, bool) {}
func (nullEngineLogger) TCPFlush(int, int, int) {}
func (nullEngineLogger) UDPStreamNew(int, ruleset.StreamInfo) {}
func (nullEngineLogger) UDPStreamPropUpdate(ruleset.StreamInfo, bool) {}
func (nullEngineLogger) UDPStreamAction(ruleset.StreamInfo, ruleset.Action, bool) {}
func (nullEngineLogger) ModifyError(ruleset.StreamInfo, error) {}
func (nullEngineLogger) AnalyzerDebugf(int64, string, string, ...interface{}) {}
func (nullEngineLogger) AnalyzerInfof(int64, string, string, ...interface{}) {}
func (nullEngineLogger) AnalyzerErrorf(int64, string, string, ...interface{}) {}
type nullRulesetLogger struct{}
func (nullRulesetLogger) Log(ruleset.StreamInfo, string) {}
func (nullRulesetLogger) MatchError(ruleset.StreamInfo, string, error) {}
func testRuleset(t *testing.T, rules ...ruleset.ExprRule) ruleset.Ruleset {
t.Helper()
ans := []analyzer.Analyzer{
&tcp.FETAnalyzer{}, &tcp.HTTPAnalyzer{}, &tcp.TLSAnalyzer{},
&udp.QUICAnalyzer{}, &udp.WireGuardAnalyzer{},
}
rs, err := ruleset.CompileExprRules(rules, ans, nil, &ruleset.BuiltinConfig{Logger: nullRulesetLogger{}, GeoMatcher: geo.NewGeoMatcher("", "")})
if err != nil {
t.Fatal(err)
}
return rs
}
// capturingFactory 记下工厂创建的每条 tcpStream,让测试能检查判定之后它的状态
type capturingFactory struct {
inner *tcpStreamFactory
streams []*tcpStream
}
func (f *capturingFactory) New(ipFlow, tcpFlow gopacket.Flow, tcp *layers.TCP, ac reassembly.AssemblerContext) reassembly.Stream {
s := f.inner.New(ipFlow, tcpFlow, tcp, ac).(*tcpStream)
f.streams = append(f.streams, s)
return s
}
func newTestAssembler(t *testing.T, rs ruleset.Ruleset) (*reassembly.Assembler, *capturingFactory) {
t.Helper()
node, err := snowflake.NewNode(1)
if err != nil {
t.Fatal(err)
}
cf := &capturingFactory{inner: &tcpStreamFactory{WorkerID: 1, Logger: nullEngineLogger{}, Node: node, Ruleset: rs}}
asm := reassembly.NewAssembler(reassembly.NewStreamPool(cf))
asm.MaxBufferedPagesTotal = defaultTCPMaxBufferedPagesTotal
asm.MaxBufferedPagesPerConnection = defaultTCPMaxBufferedPagesPerConnection
return asm, cf
}
var testIPFlow = gopacket.NewFlow(layers.EndpointIPv4, net.IPv4(10, 0, 0, 1).To4(), net.IPv4(203, 0, 113, 1).To4())
func tcpSegment(seq uint32, syn bool, payload []byte) *layers.TCP {
seg := &layers.TCP{SrcPort: 40000, DstPort: 80, Seq: seq, SYN: syn, ACK: !syn, PSH: len(payload) > 0, Window: 65535}
seg.Payload = payload
return seg
}
func feedTCP(asm *reassembly.Assembler, seg *layers.TCP, ts time.Time) tcpVerdict {
ctx := &tcpContext{PacketMetadata: &gopacket.PacketMetadata{CaptureInfo: gopacket.CaptureInfo{Timestamp: ts}}, Verdict: tcpVerdictAccept}
asm.AssembleWithContext(testIPFlow, seg, ctx)
return ctx.Verdict
}
var httpRequest = []byte("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n")
func TestTCPStreamReleasesStateAfterVerdict(t *testing.T) {
rs := testRuleset(t, ruleset.ExprRule{Name: "block-http", Action: "block", Expr: "http?.req != nil"})
asm, cf := newTestAssembler(t, rs)
ts := time.Now()
feedTCP(asm, tcpSegment(1000, true, nil), ts)
if v := feedTCP(asm, tcpSegment(1001, false, httpRequest), ts); v != tcpVerdictDropStream {
t.Fatalf("verdict = %v, want drop stream", v)
}
if len(cf.streams) != 1 {
t.Fatalf("streams created = %d, want 1", len(cf.streams))
}
s := cf.streams[0]
if !s.released || s.activeEntries != nil || s.info.Props != nil || s.ruleset != nil {
t.Fatalf("stream still holds state after verdict: released=%v entries=%d props=%v ruleset=%v", s.released, len(s.activeEntries), s.info.Props, s.ruleset != nil)
}
// 后续报文沿用判定,且不能碰已经释放的状态
if v := feedTCP(asm, tcpSegment(1001+uint32(len(httpRequest)), false, []byte("more")), ts); v != tcpVerdictDropStream {
t.Fatalf("verdict on a later segment = %v, want drop stream", v)
}
}
func TestTCPStreamReleasedWhenAllAnalyzersFinish(t *testing.T) {
// 规则只引用 tls;一个 HTTP 请求让 tls 分析器直接放弃,没有裁决,引擎应当发 AcceptStream 并释放
rs := testRuleset(t, ruleset.ExprRule{Name: "block-tls", Action: "block", Expr: "tls?.req != nil"})
asm, cf := newTestAssembler(t, rs)
ts := time.Now()
feedTCP(asm, tcpSegment(1000, true, nil), ts)
if v := feedTCP(asm, tcpSegment(1001, false, httpRequest), ts); v != tcpVerdictAcceptStream {
t.Fatalf("verdict = %v, want accept stream", v)
}
if s := cf.streams[0]; !s.released || s.info.Props != nil {
t.Fatalf("stream not released after all analyzers finished: released=%v props=%v", s.released, s.info.Props)
}
}
// 判定之前缓冲的乱序页会在超时 flush 时被补送到 ReassembledSG,那时流已经释放,不能崩
func TestTCPStreamSurvivesFlushDeliveringBufferedPageAfterRelease(t *testing.T) {
rs := testRuleset(t, ruleset.ExprRule{Name: "block-http", Action: "block", Expr: "http?.req != nil"})
asm, cf := newTestAssembler(t, rs)
ts := time.Now()
feedTCP(asm, tcpSegment(1000, true, nil), ts)
// 乱序段:序号跳过一大截,被缓冲起来等空洞补齐
feedTCP(asm, tcpSegment(9000, false, []byte("out of order")), ts)
if v := feedTCP(asm, tcpSegment(1001, false, httpRequest), ts); v != tcpVerdictDropStream {
t.Fatalf("verdict = %v, want drop stream", v)
}
s := cf.streams[0]
if !s.released {
t.Fatal("stream not released after verdict")
}
// 超时 flush 会带着空洞把那一页送进来
flushed, closed := asm.FlushCloseOlderThan(ts.Add(time.Hour))
if flushed == 0 || closed == 0 {
t.Fatalf("flush did nothing: flushed=%d closed=%d", flushed, closed)
}
if s.info.Props != nil || s.activeEntries != nil {
t.Fatal("flush resurrected released state")
}
}
func TestUDPStreamReleasesStateAfterVerdict(t *testing.T) {
rs := testRuleset(t, ruleset.ExprRule{Name: "allow-443", Action: "allow", Expr: "port.dst == 443"})
w, err := newWorker(workerConfig{ID: 1, Logger: nullEngineLogger{}, Ruleset: rs})
if err != nil {
t.Fatal(err)
}
pkt := &layers.UDP{SrcPort: 40000, DstPort: 443}
pkt.Payload = []byte("hello")
if v, _ := w.handleUDP(7, testIPFlow, pkt); v != io.VerdictAcceptStream {
t.Fatalf("verdict = %v, want accept stream", v)
}
value, ok := w.udpStreamManager.streams.Get(7)
if !ok {
t.Fatal("stream missing from the LRU")
}
if s := value.Stream; s.activeEntries != nil || s.info.Props != nil || s.ruleset != nil {
t.Fatalf("udp stream still holds state after verdict: entries=%d props=%v", len(s.activeEntries), s.info.Props)
}
if v, _ := w.handleUDP(7, testIPFlow, pkt); v != io.VerdictAcceptStream {
t.Fatalf("verdict on a later packet = %v, want accept stream", v)
}
}
func TestTCPFlushIntervalFollowsTimeout(t *testing.T) {
cases := []struct {
timeout, want time.Duration
}{
{30 * time.Second, 15 * time.Second},
{10 * time.Minute, time.Minute},
{time.Second, time.Second},
{0, time.Second},
}
for _, c := range cases {
if got := tcpFlushIntervalFor(c.timeout); got != c.want {
t.Errorf("timeout %v: interval = %v, want %v", c.timeout, got, c.want)
}
}
}
+119 -5
View File
@@ -25,6 +25,27 @@ const (
tcpVerdictDropStream = tcpVerdict(io.VerdictDropStream) tcpVerdictDropStream = tcpVerdict(io.VerdictDropStream)
) )
const (
// tcpMetaPropKey is the namespace under which the engine exposes TCP
// handshake metadata to the ruleset. Analyzers only ever see reassembled
// payload, so header-level signals have to come from here. It must not
// collide with the name of any analyzer.
tcpMetaPropKey = "tcpmeta"
// tcpSynAckWindowMax is the SYN-ACK receive window at or below which we
// record that metadata. Real stacks advertise tens of thousands of bytes;
// a tiny window forces the peer to split its first request across segments,
// which defeats analyzers that classify on the first segment alone. The
// threshold is deliberately loose so that normal connections allocate
// nothing - the ruleset decides what actually counts as suspicious.
tcpSynAckWindowMax = 4096
// tcpFirstSegLenMax is the size at or below which we record the length of
// the client's first data segment. It is the other half of the same signal:
// whatever the peer does to the advertised window, the observable result is
// a first request split across segments. Normal first segments run to
// hundreds of bytes, so nothing is recorded for them.
tcpFirstSegLenMax = 64
)
type tcpContext struct { type tcpContext struct {
*gopacket.PacketMetadata *gopacket.PacketMetadata
Verdict tcpVerdict Verdict tcpVerdict
@@ -85,6 +106,8 @@ func (f *tcpStreamFactory) New(ipFlow, tcpFlow gopacket.Flow, tcp *layers.TCP, a
logger: f.Logger, logger: f.Logger,
ruleset: rs, ruleset: rs,
activeEntries: entries, activeEntries: entries,
synAckWindow: -1,
synAckWscale: -1,
} }
} }
@@ -101,8 +124,11 @@ type tcpStream struct {
logger Logger logger Logger
ruleset ruleset.Ruleset ruleset ruleset.Ruleset
activeEntries []*tcpStreamEntry activeEntries []*tcpStreamEntry
doneEntries []*tcpStreamEntry
lastVerdict tcpVerdict lastVerdict tcpVerdict
released bool // 最终判定已发出,分析器和属性表已经释放
firstSegSeen bool // true once the client's first data segment was measured
synAckWindow int // window of the observed SYN-ACK, -1 if none seen yet
synAckWscale int // its window scale factor, -1 if the option was absent
} }
type tcpStreamEntry struct { type tcpStreamEntry struct {
@@ -113,6 +139,17 @@ type tcpStreamEntry struct {
} }
func (s *tcpStream) Accept(tcp *layers.TCP, ci gopacket.CaptureInfo, dir reassembly.TCPFlowDirection, nextSeq reassembly.Sequence, start *bool, ac reassembly.AssemblerContext) bool { func (s *tcpStream) Accept(tcp *layers.TCP, ci gopacket.CaptureInfo, dir reassembly.TCPFlowDirection, nextSeq reassembly.Sequence, start *bool, ac reassembly.AssemblerContext) bool {
if s.released {
// 最终判定已发出,后续报文一律沿用;Props 已经释放,不能再碰
ac.(*tcpContext).Verdict = s.lastVerdict
return false
}
if tcp.SYN && tcp.ACK && s.synAckWindow < 0 {
s.synAckWindow, s.synAckWscale = int(tcp.Window), tcpWindowScale(tcp)
if s.synAckWindow <= tcpSynAckWindowMax {
s.tcpMeta()
}
}
if len(s.activeEntries) > 0 || s.virgin { if len(s.activeEntries) > 0 || s.virgin {
// Make sure every stream matches against the ruleset at least once, // Make sure every stream matches against the ruleset at least once,
// even if there are no activeEntries, as the ruleset may have built-in // even if there are no activeEntries, as the ruleset may have built-in
@@ -125,12 +162,73 @@ func (s *tcpStream) Accept(tcp *layers.TCP, ci gopacket.CaptureInfo, dir reassem
} }
} }
// tcpWindowScale returns the window scale factor a segment negotiates, or -1
// if it carries no such option.
func tcpWindowScale(tcp *layers.TCP) int {
for _, opt := range tcp.Options {
if opt.OptionType == layers.TCPOptionKindWindowScale && len(opt.OptionData) == 1 {
return int(opt.OptionData[0])
}
}
return -1
}
// tcpMeta returns the engine's TCP metadata for this stream, creating it on
// first use. Whatever prompted the call, the handshake fields are filled in
// too, so that a rule reading one field never trips over a missing sibling.
// Only streams worth reporting on get here, so normal traffic allocates nothing.
//
// Per RFC 7323 the window scale option does not apply to the window field of
// the SYN-ACK itself, so synack_window is a literal byte count. A stack whose
// receive buffer really is a few bytes would not also negotiate a large scale
// factor, which makes a small synack_window paired with a non-zero
// synack_wscale a strong sign that the field was rewritten in flight.
func (s *tcpStream) tcpMeta() analyzer.PropMap {
m := s.info.Props[tcpMetaPropKey]
if m == nil {
m = analyzer.PropMap{}
if s.synAckWindow >= 0 {
m["synack_window"] = s.synAckWindow
if s.synAckWscale >= 0 {
m["synack_wscale"] = s.synAckWscale
}
}
s.info.Props[tcpMetaPropKey] = m
}
return m
}
// recordFirstSegLen notes an unusually short first data segment from the client.
// Only the first segment counts, whatever its size, so that a short segment
// later in the stream is never mistaken for the opening one. It reports whether
// anything was written, so the caller knows to re-run the ruleset.
func (s *tcpStream) recordFirstSegLen(n int) bool {
if s.firstSegSeen {
return false
}
s.firstSegSeen = true
if n > tcpFirstSegLenMax {
return false
}
s.tcpMeta()["first_seg_len"] = n
return true
}
func (s *tcpStream) ReassembledSG(sg reassembly.ScatterGather, ac reassembly.AssemblerContext) { func (s *tcpStream) ReassembledSG(sg reassembly.ScatterGather, ac reassembly.AssemblerContext) {
if s.released {
// 判定已发出。还会走到这里只可能是超时 flush 补送的乱序页,没有东西可处理
return
}
dir, start, end, skip := sg.Info() dir, start, end, skip := sg.Info()
rev := dir == reassembly.TCPDirServerToClient rev := dir == reassembly.TCPDirServerToClient
avail, _ := sg.Lengths() avail, _ := sg.Lengths()
data := sg.Fetch(avail) data := sg.Fetch(avail)
updated := false updated := false
if !rev && len(data) > 0 {
// Force a ruleset match if this is worth reporting, since the analyzers
// may not produce an update of their own on this segment.
updated = s.recordFirstSegLen(len(data))
}
for i := len(s.activeEntries) - 1; i >= 0; i-- { for i := len(s.activeEntries) - 1; i >= 0; i-- {
// Important: reverse order so we can remove entries // Important: reverse order so we can remove entries
entry := s.activeEntries[i] entry := s.activeEntries[i]
@@ -140,7 +238,6 @@ func (s *tcpStream) ReassembledSG(sg reassembly.ScatterGather, ac reassembly.Ass
updated = updated || up1 || up2 updated = updated || up1 || up2
if done { if done {
s.activeEntries = append(s.activeEntries[:i], s.activeEntries[i+1:]...) s.activeEntries = append(s.activeEntries[:i], s.activeEntries[i+1:]...)
s.doneEntries = append(s.doneEntries, entry)
} }
} }
ctx := ac.(*tcpContext) ctx := ac.(*tcpContext)
@@ -165,15 +262,20 @@ func (s *tcpStream) ReassembledSG(sg reassembly.ScatterGather, ac reassembly.Ass
ctx.Verdict = tcpVerdictAcceptStream ctx.Verdict = tcpVerdictAcceptStream
s.logger.TCPStreamAction(s.info, ruleset.ActionAllow, true) s.logger.TCPStreamAction(s.info, ruleset.ActionAllow, true)
} }
if len(s.activeEntries) == 0 {
// 不会再有分析器读数据,也不会再匹配规则:从这一刻起 Accept 只看 lastVerdict
s.release()
}
} }
func (s *tcpStream) ReassemblyComplete(ac reassembly.AssemblerContext) bool { func (s *tcpStream) ReassemblyComplete(ac reassembly.AssemblerContext) bool {
s.closeActiveEntries() s.closeActiveEntries()
s.release()
return true return true
} }
func (s *tcpStream) closeActiveEntries() { func (s *tcpStream) closeActiveEntries() {
// Signal close to all active entries & move them to doneEntries // Signal close to all active entries
updated := false updated := false
for _, entry := range s.activeEntries { for _, entry := range s.activeEntries {
update := entry.Stream.Close(false) update := entry.Stream.Close(false)
@@ -183,10 +285,22 @@ func (s *tcpStream) closeActiveEntries() {
if updated { if updated {
s.logger.TCPStreamPropUpdate(s.info, true) s.logger.TCPStreamPropUpdate(s.info, true)
} }
s.doneEntries = append(s.doneEntries, s.activeEntries...)
s.activeEntries = nil s.activeEntries = nil
} }
// release 在最终判定发出后丢掉不会再用到的状态。
//
// 判定之后 Accept 只看 lastVerdict,分析器和属性表都不会再被读。但这条流本身还要在
// 连接池里挂到超时被 flush,而 gopacket 回收连接槽位时并不清掉 stream 指针,
// 要等下一条连接复用那个槽位才会覆盖。不主动放手的话,每条判定完的流会带着
// 全部分析器对象一直活到那时候;节点上的 heap profile 里这部分占到了八成。
func (s *tcpStream) release() {
s.released = true
s.activeEntries = nil
s.info.Props = nil
s.ruleset = nil
}
func (s *tcpStream) feedEntry(entry *tcpStreamEntry, rev, start, end bool, skip int, data []byte) (update *analyzer.PropUpdate, closeUpdate *analyzer.PropUpdate, done bool) { func (s *tcpStream) feedEntry(entry *tcpStreamEntry, rev, start, end bool, skip int, data []byte) (update *analyzer.PropUpdate, closeUpdate *analyzer.PropUpdate, done bool) {
if !entry.HasLimit { if !entry.HasLimit {
update, done = entry.Stream.Feed(rev, start, end, skip, data) update, done = entry.Stream.Feed(rev, start, end, skip, data)
@@ -198,7 +312,7 @@ func (s *tcpStream) feedEntry(entry *tcpStreamEntry, rev, start, end bool, skip
update, done = entry.Stream.Feed(rev, start, end, skip, qData) update, done = entry.Stream.Feed(rev, start, end, skip, qData)
entry.Quota -= len(qData) entry.Quota -= len(qData)
if entry.Quota <= 0 { if entry.Quota <= 0 {
// Quota exhausted, signal close & move to doneEntries // Quota exhausted, signal close & drop the entry
closeUpdate = entry.Stream.Close(true) closeUpdate = entry.Stream.Close(true)
done = true done = true
} }
+171
View File
@@ -0,0 +1,171 @@
package engine
import (
"reflect"
"testing"
"github.com/apernet/OpenGFW/analyzer"
"github.com/apernet/OpenGFW/ruleset"
"github.com/google/gopacket/layers"
)
func newTestStream() *tcpStream {
return &tcpStream{
info: ruleset.StreamInfo{Props: analyzer.CombinedPropMap{}},
synAckWindow: -1,
synAckWscale: -1,
}
}
func TestTCPWindowScale(t *testing.T) {
mss := layers.TCPOption{OptionType: layers.TCPOptionKindMSS, OptionLength: 4, OptionData: []byte{0x05, 0xb4}}
tests := []struct {
name string
opts []layers.TCPOption
want int
}{
{
name: "present",
opts: []layers.TCPOption{mss, {OptionType: layers.TCPOptionKindWindowScale, OptionLength: 3, OptionData: []byte{7}}},
want: 7,
},
{name: "absent", opts: []layers.TCPOption{mss}, want: -1},
{
name: "malformed is ignored",
opts: []layers.TCPOption{{OptionType: layers.TCPOptionKindWindowScale, OptionLength: 2, OptionData: nil}},
want: -1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tcpWindowScale(&layers.TCP{Options: tt.opts}); got != tt.want {
t.Errorf("wscale = %d, want %d", got, tt.want)
}
})
}
}
// Whichever signal materializes the prop map, the handshake fields must come
// along with it. Otherwise a rule like `tcpmeta != nil && tcpmeta.synack_window
// <= 16` sees a map without that key and fails at runtime with a nil comparison.
func TestTCPMetaAlwaysCarriesHandshakeFields(t *testing.T) {
tests := []struct {
name string
window int // -1 for a stream where no SYN-ACK was observed
wscale int
want analyzer.PropMap
}{
{
name: "normal handshake, short first segment",
window: 64240,
wscale: 7,
want: analyzer.PropMap{"synack_window": 64240, "synack_wscale": 7, "first_seg_len": 21},
},
{
name: "rewritten window",
window: 1,
wscale: 7,
want: analyzer.PropMap{"synack_window": 1, "synack_wscale": 7, "first_seg_len": 21},
},
{
name: "no window scale negotiated",
window: 64240,
wscale: -1,
want: analyzer.PropMap{"synack_window": 64240, "first_seg_len": 21},
},
{
name: "return path not visible",
window: -1,
wscale: -1,
want: analyzer.PropMap{"first_seg_len": 21},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestStream()
s.synAckWindow, s.synAckWscale = tt.window, tt.wscale
if !s.recordFirstSegLen(21) {
t.Fatal("first segment not recorded")
}
if got := s.info.Props[tcpMetaPropKey]; !reflect.DeepEqual(got, tt.want) {
t.Errorf("props = %#v, want %#v", got, tt.want)
}
})
}
}
func TestTCPRecordFirstSegLen(t *testing.T) {
tests := []struct {
name string
segLen int
recorded bool
}{
{name: "one byte segment", segLen: 1, recorded: true},
{name: "at the threshold", segLen: tcpFirstSegLenMax, recorded: true},
{name: "just over the threshold", segLen: tcpFirstSegLenMax + 1, recorded: false},
{name: "normal first segment", segLen: 517, recorded: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestStream()
if got := s.recordFirstSegLen(tt.segLen); got != tt.recorded {
t.Errorf("recorded = %v, want %v", got, tt.recorded)
}
m := s.info.Props[tcpMetaPropKey]
if !tt.recorded {
// A normal connection must not allocate a prop map at all.
if m != nil {
t.Errorf("props = %#v, want none", m)
}
return
}
if got := m["first_seg_len"]; got != tt.segLen {
t.Errorf("first_seg_len = %v, want %d", got, tt.segLen)
}
})
}
}
func TestTCPRecordFirstSegLenIsOneShot(t *testing.T) {
s := newTestStream()
s.recordFirstSegLen(1)
if s.recordFirstSegLen(5) {
t.Error("a later segment was recorded as the first one")
}
if got := s.info.Props[tcpMetaPropKey]["first_seg_len"]; got != 1 {
t.Errorf("first_seg_len = %v, want 1", got)
}
}
// A stream that opens normally must not be able to look like a split one just
// because a short segment shows up later.
func TestTCPNormalFirstSegmentClosesTheWindow(t *testing.T) {
s := newTestStream()
if s.recordFirstSegLen(517) {
t.Fatal("a normal first segment should not be recorded")
}
if s.recordFirstSegLen(1) {
t.Error("a later short segment was recorded as the first one")
}
if m := s.info.Props[tcpMetaPropKey]; m != nil {
t.Errorf("props = %#v, want none", m)
}
}
func TestTCPSynAckWindowThreshold(t *testing.T) {
tests := []struct {
window uint16
record bool
}{
{window: 1, record: true},
{window: tcpSynAckWindowMax, record: true},
{window: tcpSynAckWindowMax + 1, record: false},
{window: 64240, record: false},
}
for _, tt := range tests {
if got := int(tt.window) <= tcpSynAckWindowMax; got != tt.record {
t.Errorf("window %d: recorded = %v, want %v", tt.window, got, tt.record)
}
}
}
+14 -5
View File
@@ -160,7 +160,6 @@ type udpStream struct {
logger Logger logger Logger
ruleset ruleset.Ruleset ruleset ruleset.Ruleset
activeEntries []*udpStreamEntry activeEntries []*udpStreamEntry
doneEntries []*udpStreamEntry
lastVerdict udpVerdict lastVerdict udpVerdict
} }
@@ -194,7 +193,6 @@ func (s *udpStream) Feed(udp *layers.UDP, rev bool, uc *udpContext) {
updated = updated || up1 || up2 updated = updated || up1 || up2
if done { if done {
s.activeEntries = append(s.activeEntries[:i], s.activeEntries[i+1:]...) s.activeEntries = append(s.activeEntries[:i], s.activeEntries[i+1:]...)
s.doneEntries = append(s.doneEntries, entry)
} }
} }
if updated || s.virgin { if updated || s.virgin {
@@ -236,14 +234,19 @@ func (s *udpStream) Feed(udp *layers.UDP, rev bool, uc *udpContext) {
uc.Verdict = udpVerdictAcceptStream uc.Verdict = udpVerdictAcceptStream
s.logger.UDPStreamAction(s.info, ruleset.ActionAllow, true) s.logger.UDPStreamAction(s.info, ruleset.ActionAllow, true)
} }
if len(s.activeEntries) == 0 {
// 同 tcpStream.release:这条流会在 LRU 里一直待到被挤出去,别让它带着分析器一起等
s.release()
}
} }
func (s *udpStream) Close() { func (s *udpStream) Close() {
s.closeActiveEntries() s.closeActiveEntries()
s.release()
} }
func (s *udpStream) closeActiveEntries() { func (s *udpStream) closeActiveEntries() {
// Signal close to all active entries & move them to doneEntries // Signal close to all active entries
updated := false updated := false
for _, entry := range s.activeEntries { for _, entry := range s.activeEntries {
update := entry.Stream.Close(false) update := entry.Stream.Close(false)
@@ -253,16 +256,22 @@ func (s *udpStream) closeActiveEntries() {
if updated { if updated {
s.logger.UDPStreamPropUpdate(s.info, true) s.logger.UDPStreamPropUpdate(s.info, true)
} }
s.doneEntries = append(s.doneEntries, s.activeEntries...)
s.activeEntries = nil s.activeEntries = nil
} }
// release 在最终判定发出后丢掉不会再用到的状态,Accept 此后只看 lastVerdict
func (s *udpStream) release() {
s.activeEntries = nil
s.info.Props = nil
s.ruleset = nil
}
func (s *udpStream) feedEntry(entry *udpStreamEntry, rev bool, data []byte) (update *analyzer.PropUpdate, closeUpdate *analyzer.PropUpdate, done bool) { func (s *udpStream) feedEntry(entry *udpStreamEntry, rev bool, data []byte) (update *analyzer.PropUpdate, closeUpdate *analyzer.PropUpdate, done bool) {
update, done = entry.Stream.Feed(rev, data) update, done = entry.Stream.Feed(rev, data)
if entry.HasLimit { if entry.HasLimit {
entry.Quota -= len(data) entry.Quota -= len(data)
if entry.Quota <= 0 { if entry.Quota <= 0 {
// Quota exhausted, signal close & move to doneEntries // Quota exhausted, signal close & drop the entry
closeUpdate = entry.Stream.Close(true) closeUpdate = entry.Stream.Close(true)
done = true done = true
} }
+11 -2
View File
@@ -20,9 +20,18 @@ const (
defaultTCPTimeout = 10 * time.Minute defaultTCPTimeout = 10 * time.Minute
defaultUDPMaxStreams = 4096 defaultUDPMaxStreams = 4096
tcpFlushInterval = 1 * time.Minute // 超时 flush 的间隔范围。判定后的流走 connmark 旁路,FIN 到不了用户态,
// 只能靠定时 flush 把它们从连接池里清掉。间隔取超时的一半:
// 之前固定 1 分钟,30 秒的超时实际会让流在池里活到 90 秒。
tcpMaxFlushInterval = 1 * time.Minute
tcpMinFlushInterval = 1 * time.Second
) )
// tcpFlushIntervalFor 返回给定 TCP 超时下的 flush 间隔
func tcpFlushIntervalFor(timeout time.Duration) time.Duration {
return min(tcpMaxFlushInterval, max(timeout/2, tcpMinFlushInterval))
}
type workerPacket struct { type workerPacket struct {
StreamID uint32 StreamID uint32
Packet gopacket.Packet Packet gopacket.Packet
@@ -122,7 +131,7 @@ func (w *worker) Run(ctx context.Context) {
w.logger.WorkerStart(w.id) w.logger.WorkerStart(w.id)
defer w.logger.WorkerStop(w.id) defer w.logger.WorkerStop(w.id)
tcpFlushTicker := time.NewTicker(tcpFlushInterval) tcpFlushTicker := time.NewTicker(tcpFlushIntervalFor(w.tcpTimeout))
defer tcpFlushTicker.Stop() defer tcpFlushTicker.Stop()
for { for {