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
This commit is contained in:
@@ -9,6 +9,9 @@
|
||||
**[中文文档](README.zh.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.
|
||||
|
||||
**Documentation site: https://gfw.dev/**
|
||||
@@ -21,7 +24,7 @@ Telegram group: https://t.me/OpGFW
|
||||
## Features
|
||||
|
||||
- Full IP/TCP reassembly, various protocol analyzers
|
||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, and many more to come
|
||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, and many more to come
|
||||
- "Fully encrypted traffic" detection for Shadowsocks, VMess,
|
||||
etc. (https://gfw.report/publications/usenixsecurity23/en/)
|
||||
- Trojan (proxy protocol) detection
|
||||
|
||||
+6
-1
@@ -18,7 +18,7 @@ Telegram 群组: https://t.me/OpGFW
|
||||
## 功能
|
||||
|
||||
- 完整的 IP/TCP 重组,各种协议解析器
|
||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, 更多协议正在开发中
|
||||
- HTTP, TLS, QUIC, DNS, SSH, SOCKS4/5, WireGuard, OpenVPN, Minecraft, 更多协议正在开发中
|
||||
- Shadowsocks, VMess 等 "全加密流量" 检测 (https://gfw.report/publications/usenixsecurity23/zh/)
|
||||
- Trojan 协议检测
|
||||
- [开发中] 基于机器学习的流量分类
|
||||
@@ -31,6 +31,11 @@ Telegram 群组: https://t.me/OpGFW
|
||||
- 可扩展的 IO 实现 (目前只有 NFQueue)
|
||||
- [开发中] Web UI
|
||||
|
||||
## 本仓库内的文档
|
||||
|
||||
- [Minecraft 分析器](docs/minecraft.zh.md)
|
||||
- [TCP 窗口操纵规避与检测](docs/tcp-window-evasion.zh.md)
|
||||
|
||||
## 使用场景
|
||||
|
||||
- 广告拦截
|
||||
|
||||
+50
-5
@@ -1,6 +1,21 @@
|
||||
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)
|
||||
|
||||
@@ -25,6 +40,8 @@ func (a *FETAnalyzer) NewTCP(info analyzer.TCPInfo, logger analyzer.Logger) anal
|
||||
|
||||
type fetStream struct {
|
||||
logger analyzer.Logger
|
||||
buf utils.ByteBuffer
|
||||
segs int
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
ex2 := isFirstSixPrintable(data)
|
||||
ex3 := printablePercentage(data)
|
||||
@@ -54,11 +103,7 @@ func (s *fetStream) Feed(rev, start, end bool, skip int, data []byte) (u *analyz
|
||||
"ex5": ex5,
|
||||
"yes": !exempt,
|
||||
},
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *fetStream) Close(limited bool) *analyzer.PropUpdate {
|
||||
return nil
|
||||
}
|
||||
|
||||
func popCount(b byte) int {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,7 @@ var logFormatMap = map[string]zapcore.EncoderConfig{
|
||||
var analyzers = []analyzer.Analyzer{
|
||||
&tcp.FETAnalyzer{},
|
||||
&tcp.HTTPAnalyzer{},
|
||||
&tcp.MinecraftAnalyzer{},
|
||||
&tcp.SocksAnalyzer{},
|
||||
&tcp.SSHAnalyzer{},
|
||||
&tcp.TLSAnalyzer{},
|
||||
|
||||
@@ -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)。
|
||||
@@ -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 头字段。所以这一层做在引擎里:`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`——那会让全局最大的性能优化失效。目前没有实现,需要时建议做成只对已标记可疑的连接开启的定向跟踪。
|
||||
@@ -25,6 +25,27 @@ const (
|
||||
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 {
|
||||
*gopacket.PacketMetadata
|
||||
Verdict tcpVerdict
|
||||
@@ -85,6 +106,8 @@ func (f *tcpStreamFactory) New(ipFlow, tcpFlow gopacket.Flow, tcp *layers.TCP, a
|
||||
logger: f.Logger,
|
||||
ruleset: rs,
|
||||
activeEntries: entries,
|
||||
synAckWindow: -1,
|
||||
synAckWscale: -1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +126,9 @@ type tcpStream struct {
|
||||
activeEntries []*tcpStreamEntry
|
||||
doneEntries []*tcpStreamEntry
|
||||
lastVerdict tcpVerdict
|
||||
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 {
|
||||
@@ -113,6 +139,12 @@ 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 {
|
||||
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 {
|
||||
// Make sure every stream matches against the ruleset at least once,
|
||||
// even if there are no activeEntries, as the ruleset may have built-in
|
||||
@@ -125,12 +157,69 @@ 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) {
|
||||
dir, start, end, skip := sg.Info()
|
||||
rev := dir == reassembly.TCPDirServerToClient
|
||||
avail, _ := sg.Lengths()
|
||||
data := sg.Fetch(avail)
|
||||
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-- {
|
||||
// Important: reverse order so we can remove entries
|
||||
entry := s.activeEntries[i]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user