From d6df2681689ca778904cb38cb7299363c10e9c06 Mon Sep 17 00:00:00 2001 From: yuluo-yx Date: Sun, 20 Sep 2026 13:55:38 +0800 Subject: [PATCH] chore: upgrade Go to 1.27 and align CI checks --- .github/workflows/check.yaml | 8 +- .github/workflows/release.yaml | 2 +- analyzer/interface.go | 14 +- analyzer/tcp/fet.go | 26 ++-- analyzer/tcp/fet_test.go | 42 ++++++ analyzer/tcp/http.go | 10 +- analyzer/tcp/http_test.go | 4 + .../udp/internal/quic/packet_protector.go | 2 +- analyzer/udp/internal/quic/payload.go | 5 +- analyzer/udp/internal/quic/payload_test.go | 24 ++++ cmd/root.go | 6 +- engine/engine.go | 9 +- engine/engine_test.go | 133 ++++++++++++++++++ engine/interface.go | 6 +- engine/tcp.go | 5 +- engine/utils.go | 6 +- go.mod | 2 +- modifier/interface.go | 4 +- modifier/udp/dns.go | 2 +- ruleset/builtins/geo/matchers_v2geo.go | 10 +- ruleset/builtins/geo/matchers_v2geo_test.go | 30 ++++ ruleset/expr.go | 8 +- 22 files changed, 292 insertions(+), 66 deletions(-) create mode 100644 analyzer/tcp/fet_test.go create mode 100644 analyzer/udp/internal/quic/payload_test.go create mode 100644 engine/engine_test.go create mode 100644 ruleset/builtins/geo/matchers_v2geo_test.go diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index ac9e66d..b077032 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -21,13 +21,14 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: '1.27' - run: go vet ./... - name: staticcheck - uses: dominikh/staticcheck-action@v1.3.0 + uses: dominikh/staticcheck-action@v1.4.1 with: + version: '2026.2.1' install-go: false tests: @@ -42,6 +43,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: 'stable' + go-version: '1.27' - run: go test ./... + - run: go test -race -shuffle=on ./... diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 0da1054..b616a2c 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,7 +22,7 @@ jobs: - name: Setup Go uses: actions/setup-go@v5 with: - go-version: "1.22" + go-version: "1.27" - name: Build env: diff --git a/analyzer/interface.go b/analyzer/interface.go index 80ad418..e142d0b 100644 --- a/analyzer/interface.go +++ b/analyzer/interface.go @@ -20,9 +20,9 @@ type Analyzer interface { } type Logger interface { - Debugf(format string, args ...interface{}) - Infof(format string, args ...interface{}) - Errorf(format string, args ...interface{}) + Debugf(format string, args ...any) + Infof(format string, args ...any) + Errorf(format string, args ...any) } type TCPAnalyzer interface { @@ -82,19 +82,19 @@ type UDPStream interface { } type ( - PropMap map[string]interface{} + PropMap map[string]any CombinedPropMap map[string]PropMap ) // Get returns the value of the property with the given key. // The key can be a nested key, e.g. "foo.bar.baz". // Returns nil if the key does not exist. -func (m PropMap) Get(key string) interface{} { +func (m PropMap) Get(key string) any { keys := strings.Split(key, ".") if len(keys) == 0 { return nil } - var current interface{} = m + var current any = m for _, k := range keys { currentMap, ok := current.(PropMap) if !ok { @@ -108,7 +108,7 @@ func (m PropMap) Get(key string) interface{} { // Get returns the value of the property with the given analyzer & key. // The key can be a nested key, e.g. "foo.bar.baz". // Returns nil if the key does not exist. -func (cm CombinedPropMap) Get(an string, key string) interface{} { +func (cm CombinedPropMap) Get(an string, key string) any { m, ok := cm[an] if !ok { return nil diff --git a/analyzer/tcp/fet.go b/analyzer/tcp/fet.go index 8b727f5..d663d36 100644 --- a/analyzer/tcp/fet.go +++ b/analyzer/tcp/fet.go @@ -1,6 +1,10 @@ package tcp -import "github.com/apernet/OpenGFW/analyzer" +import ( + "math/bits" + + "github.com/apernet/OpenGFW/analyzer" +) var _ analyzer.TCPAnalyzer = (*FETAnalyzer)(nil) @@ -61,15 +65,6 @@ func (s *fetStream) Close(limited bool) *analyzer.PropUpdate { return nil } -func popCount(b byte) int { - count := 0 - for b != 0 { - count += int(b & 1) - b >>= 1 - } - return count -} - // averagePopCount returns the average popcount of the given bytes. // This is the "Ex1" metric in the paper. func averagePopCount(bytes []byte) float32 { @@ -78,7 +73,7 @@ func averagePopCount(bytes []byte) float32 { } total := 0 for _, b := range bytes { - total += popCount(b) + total += bits.OnesCount8(b) } return float32(total) / float32(len(bytes)) } @@ -125,16 +120,11 @@ func contiguousPrintable(bytes []byte) int { if isPrintable(bytes[i]) { current++ } else { - if current > maxCount { - maxCount = current - } + maxCount = max(maxCount, current) current = 0 } } - if current > maxCount { - maxCount = current - } - return maxCount + return max(maxCount, current) } // isTLSorHTTP returns true if the given bytes look like TLS or HTTP. diff --git a/analyzer/tcp/fet_test.go b/analyzer/tcp/fet_test.go new file mode 100644 index 0000000..7d36c2e --- /dev/null +++ b/analyzer/tcp/fet_test.go @@ -0,0 +1,42 @@ +package tcp + +import "testing" + +func TestAveragePopCount(t *testing.T) { + tests := []struct { + name string + data []byte + want float32 + }{ + {name: "empty", want: 0}, + {name: "zero", data: []byte{0, 0}, want: 0}, + {name: "full", data: []byte{0xff}, want: 8}, + {name: "mixed", data: []byte{0x00, 0xff}, want: 4}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := averagePopCount(tt.data); got != tt.want { + t.Fatalf("averagePopCount(%v) = %v, want %v", tt.data, got, tt.want) + } + }) + } +} + +func TestContiguousPrintable(t *testing.T) { + tests := []struct { + name string + data []byte + want int + }{ + {name: "empty"}, + {name: "middle", data: []byte{0, 'a', 'b', 0, 'c'}, want: 2}, + {name: "end", data: []byte{'a', 0, 'b', 'c', 'd'}, want: 3}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := contiguousPrintable(tt.data); got != tt.want { + t.Fatalf("contiguousPrintable(%v) = %d, want %d", tt.data, got, tt.want) + } + }) + } +} diff --git a/analyzer/tcp/http.go b/analyzer/tcp/http.go index 9d5289f..2a8db74 100644 --- a/analyzer/tcp/http.go +++ b/analyzer/tcp/http.go @@ -152,16 +152,14 @@ func (s *httpStream) parseHeaders(buf *utils.ByteBuffer) (utils.LSMAction, analy } headers = headers[:len(headers)-4] // Strip \r\n\r\n headerMap := make(analyzer.PropMap) - for _, line := range bytes.Split(headers, []byte("\r\n")) { - fields := bytes.SplitN(line, []byte(":"), 2) - if len(fields) != 2 { + for line := range bytes.SplitSeq(headers, []byte("\r\n")) { + key, value, ok := bytes.Cut(line, []byte(":")) + if !ok { // Invalid header return utils.LSMActionCancel, nil } - key := string(bytes.TrimSpace(fields[0])) - value := string(bytes.TrimSpace(fields[1])) // Normalize header keys to lowercase - headerMap[strings.ToLower(key)] = value + headerMap[strings.ToLower(string(bytes.TrimSpace(key)))] = string(bytes.TrimSpace(value)) } return utils.LSMActionNext, headerMap } diff --git a/analyzer/tcp/http_test.go b/analyzer/tcp/http_test.go index dee4f57..0305aec 100644 --- a/analyzer/tcp/http_test.go +++ b/analyzer/tcp/http_test.go @@ -19,6 +19,10 @@ func TestHTTPParsing_Request(t *testing.T) { "PUT /world HTTP/1.1\r\nContent-Length: 4\r\n\r\nbody": { "method": "PUT", "path": "/world", "version": "HTTP/1.1", "headers": analyzer.PropMap{"content-length": "4"}, }, + "GET /example HTTP/1.1\r\nHost: example.com\r\nX-Request-ID: a:b\r\n\r\n": { + "method": "GET", "path": "/example", "version": "HTTP/1.1", + "headers": analyzer.PropMap{"host": "example.com", "x-request-id": "a:b"}, + }, "DELETE /goodbye HTTP/2.0\r\n": { "method": "DELETE", "path": "/goodbye", "version": "HTTP/2.0", }, diff --git a/analyzer/udp/internal/quic/packet_protector.go b/analyzer/udp/internal/quic/packet_protector.go index 42de841..bcbe445 100644 --- a/analyzer/udp/internal/quic/packet_protector.go +++ b/analyzer/udp/internal/quic/packet_protector.go @@ -64,7 +64,7 @@ func (pp *PacketProtector) UnProtect(packet []byte, pnOffset, pnMax int64) ([]by pnLen := packet[0]&0x3 + 1 pn := int64(0) - for i := uint8(0); i < pnLen; i++ { + for i := range pnLen { packet[pnOffset:][i] ^= mask[1+i] pn = (pn << 8) | int64(packet[pnOffset:][i]) } diff --git a/analyzer/udp/internal/quic/payload.go b/analyzer/udp/internal/quic/payload.go index 87a0179..5bf4a00 100644 --- a/analyzer/udp/internal/quic/payload.go +++ b/analyzer/udp/internal/quic/payload.go @@ -2,11 +2,12 @@ package quic import ( "bytes" + "cmp" "crypto" "errors" "fmt" "io" - "sort" + "slices" "github.com/quic-go/quic-go/quicvarint" "golang.org/x/crypto/hkdf" @@ -106,7 +107,7 @@ func assembleCryptoFrames(frames []cryptoFrame) []byte { return frames[0].Data } // sort the frames by offset - sort.Slice(frames, func(i, j int) bool { return frames[i].Offset < frames[j].Offset }) + slices.SortFunc(frames, func(a, b cryptoFrame) int { return cmp.Compare(a.Offset, b.Offset) }) // check if the frames are contiguous for i := 1; i < len(frames); i++ { if frames[i].Offset != frames[i-1].Offset+int64(len(frames[i-1].Data)) { diff --git a/analyzer/udp/internal/quic/payload_test.go b/analyzer/udp/internal/quic/payload_test.go new file mode 100644 index 0000000..2756847 --- /dev/null +++ b/analyzer/udp/internal/quic/payload_test.go @@ -0,0 +1,24 @@ +package quic + +import ( + "bytes" + "testing" +) + +func TestAssembleCryptoFrames(t *testing.T) { + frames := []cryptoFrame{ + {Offset: 2, Data: []byte("cd")}, + {Offset: 0, Data: []byte("ab")}, + } + if got := assembleCryptoFrames(frames); !bytes.Equal(got, []byte("abcd")) { + t.Fatalf("assembled frames = %q, want abcd", got) + } + + gap := []cryptoFrame{ + {Offset: 0, Data: []byte("ab")}, + {Offset: 3, Data: []byte("d")}, + } + if got := assembleCryptoFrames(gap); got != nil { + t.Fatalf("assembled noncontiguous frames = %q, want nil", got) + } +} diff --git a/cmd/root.go b/cmd/root.go index 756513a..ed16c34 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -382,21 +382,21 @@ func (l *engineLogger) ModifyError(info ruleset.StreamInfo, err error) { zap.Error(err)) } -func (l *engineLogger) AnalyzerDebugf(streamID int64, name string, format string, args ...interface{}) { +func (l *engineLogger) AnalyzerDebugf(streamID int64, name string, format string, args ...any) { logger.Debug("analyzer debug message", zap.Int64("id", streamID), zap.String("name", name), zap.String("msg", fmt.Sprintf(format, args...))) } -func (l *engineLogger) AnalyzerInfof(streamID int64, name string, format string, args ...interface{}) { +func (l *engineLogger) AnalyzerInfof(streamID int64, name string, format string, args ...any) { logger.Info("analyzer info message", zap.Int64("id", streamID), zap.String("name", name), zap.String("msg", fmt.Sprintf(format, args...))) } -func (l *engineLogger) AnalyzerErrorf(streamID int64, name string, format string, args ...interface{}) { +func (l *engineLogger) AnalyzerErrorf(streamID int64, name string, format string, args ...any) { logger.Error("analyzer error message", zap.Int64("id", streamID), zap.String("name", name), diff --git a/engine/engine.go b/engine/engine.go index 7c93e0a..f49e1b5 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -3,6 +3,7 @@ package engine import ( "context" "runtime" + "sync" "github.com/apernet/OpenGFW/io" "github.com/apernet/OpenGFW/ruleset" @@ -58,11 +59,15 @@ func (e *engine) UpdateRuleset(r ruleset.Ruleset) error { func (e *engine) Run(ctx context.Context) error { ioCtx, ioCancel := context.WithCancel(ctx) - defer ioCancel() // Stop workers & IO + var workers sync.WaitGroup + defer func() { + ioCancel() + workers.Wait() + }() // Start workers for _, w := range e.workers { - go w.Run(ioCtx) + workers.Go(func() { w.Run(ioCtx) }) } // Register IO callback diff --git a/engine/engine_test.go b/engine/engine_test.go new file mode 100644 index 0000000..12a38fd --- /dev/null +++ b/engine/engine_test.go @@ -0,0 +1,133 @@ +package engine + +import ( + "bytes" + "context" + "sync" + "testing" + "time" + + "github.com/apernet/OpenGFW/analyzer" + "github.com/apernet/OpenGFW/io" +) + +type lifecyclePacketIO struct { + io.PacketIO + registered chan struct{} +} + +func (p *lifecyclePacketIO) Register(context.Context, io.PacketCallback) error { + close(p.registered) + return nil +} + +type lifecycleLogger struct { + Logger + started chan struct{} + release chan struct{} + stopped chan struct{} +} + +func (l *lifecycleLogger) WorkerStart(int) { + l.started <- struct{}{} +} + +func (l *lifecycleLogger) WorkerStop(int) { + <-l.release + l.stopped <- struct{}{} +} + +func TestEngineWaitsForWorkers(t *testing.T) { + const workerCount = 2 + packetIO := &lifecyclePacketIO{registered: make(chan struct{})} + logger := &lifecycleLogger{ + started: make(chan struct{}, workerCount), + release: make(chan struct{}), + stopped: make(chan struct{}, workerCount), + } + release := sync.OnceFunc(func() { close(logger.release) }) + t.Cleanup(release) + en, err := NewEngine(Config{IO: packetIO, Logger: logger, Workers: workerCount}) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + finished := make(chan error, 1) + go func() { finished <- en.Run(ctx) }() + + select { + case <-packetIO.registered: + case <-time.After(5 * time.Second): + t.Fatal("packet IO was not registered") + } + for range workerCount { + select { + case <-logger.started: + case <-time.After(5 * time.Second): + t.Fatal("worker did not start") + } + } + + cancel() + select { + case <-finished: + t.Fatal("engine returned before workers stopped") + case <-time.After(100 * time.Millisecond): + } + + release() + select { + case err := <-finished: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("engine did not return after workers stopped") + } + if got := len(logger.stopped); got != workerCount { + t.Fatalf("stopped workers = %d, want %d", got, workerCount) + } +} + +type quotaTCPStream struct { + data []byte + closed bool + limited bool +} + +func (s *quotaTCPStream) Feed(_, _, _ bool, _ int, data []byte) (*analyzer.PropUpdate, bool) { + s.data = append(s.data, data...) + return nil, false +} + +func (s *quotaTCPStream) Close(limited bool) *analyzer.PropUpdate { + s.closed = true + s.limited = limited + return nil +} + +func TestTCPAnalyzerByteQuota(t *testing.T) { + tests := []struct { + name string + quota int + wantData string + wantRemain int + wantDone bool + }{ + {name: "quota exhausted", quota: 3, wantData: "abc", wantDone: true}, + {name: "quota remaining", quota: 8, wantData: "abcdef", wantRemain: 2}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stream := "aTCPStream{} + entry := &tcpStreamEntry{Stream: stream, HasLimit: true, Quota: tt.quota} + _, _, done := new(tcpStream).feedEntry(entry, false, false, false, 0, []byte("abcdef")) + if !bytes.Equal(stream.data, []byte(tt.wantData)) || entry.Quota != tt.wantRemain || + done != tt.wantDone || stream.closed != tt.wantDone || stream.limited != tt.wantDone { + t.Fatalf("data=%q, quota=%d, done=%t, closed=%t, limited=%t", stream.data, entry.Quota, done, stream.closed, stream.limited) + } + }) + } +} diff --git a/engine/interface.go b/engine/interface.go index fe25de5..b5a4f57 100644 --- a/engine/interface.go +++ b/engine/interface.go @@ -43,7 +43,7 @@ type Logger interface { ModifyError(info ruleset.StreamInfo, err error) - AnalyzerDebugf(streamID int64, name string, format string, args ...interface{}) - AnalyzerInfof(streamID int64, name string, format string, args ...interface{}) - AnalyzerErrorf(streamID int64, name string, format string, args ...interface{}) + AnalyzerDebugf(streamID int64, name string, format string, args ...any) + AnalyzerInfof(streamID int64, name string, format string, args ...any) + AnalyzerErrorf(streamID int64, name string, format string, args ...any) } diff --git a/engine/tcp.go b/engine/tcp.go index 1874055..4bf776c 100644 --- a/engine/tcp.go +++ b/engine/tcp.go @@ -191,10 +191,7 @@ func (s *tcpStream) feedEntry(entry *tcpStreamEntry, rev, start, end bool, skip if !entry.HasLimit { update, done = entry.Stream.Feed(rev, start, end, skip, data) } else { - qData := data - if len(qData) > entry.Quota { - qData = qData[:entry.Quota] - } + qData := data[:min(len(data), entry.Quota)] update, done = entry.Stream.Feed(rev, start, end, skip, qData) entry.Quota -= len(qData) if entry.Quota <= 0 { diff --git a/engine/utils.go b/engine/utils.go index 9b86ade..3b08c83 100644 --- a/engine/utils.go +++ b/engine/utils.go @@ -10,15 +10,15 @@ type analyzerLogger struct { Logger Logger } -func (l *analyzerLogger) Debugf(format string, args ...interface{}) { +func (l *analyzerLogger) Debugf(format string, args ...any) { l.Logger.AnalyzerDebugf(l.StreamID, l.Name, format, args...) } -func (l *analyzerLogger) Infof(format string, args ...interface{}) { +func (l *analyzerLogger) Infof(format string, args ...any) { l.Logger.AnalyzerInfof(l.StreamID, l.Name, format, args...) } -func (l *analyzerLogger) Errorf(format string, args ...interface{}) { +func (l *analyzerLogger) Errorf(format string, args ...any) { l.Logger.AnalyzerErrorf(l.StreamID, l.Name, format, args...) } diff --git a/go.mod b/go.mod index 70e653a..f812230 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/apernet/OpenGFW -go 1.21 +go 1.27 require ( github.com/bwmarrin/snowflake v0.3.0 diff --git a/modifier/interface.go b/modifier/interface.go index 0340a87..12bf1e7 100644 --- a/modifier/interface.go +++ b/modifier/interface.go @@ -4,10 +4,10 @@ type Modifier interface { // Name returns the name of the modifier. Name() string // New returns a new modifier instance. - New(args map[string]interface{}) (Instance, error) + New(args map[string]any) (Instance, error) } -type Instance interface{} +type Instance any type UDPModifierInstance interface { Instance diff --git a/modifier/udp/dns.go b/modifier/udp/dns.go index afab276..c3c30de 100644 --- a/modifier/udp/dns.go +++ b/modifier/udp/dns.go @@ -24,7 +24,7 @@ func (m *DNSModifier) Name() string { return "dns" } -func (m *DNSModifier) New(args map[string]interface{}) (modifier.Instance, error) { +func (m *DNSModifier) New(args map[string]any) (modifier.Instance, error) { i := &dnsModifierInstance{} aStr, ok := args["a"].(string) if ok { diff --git a/ruleset/builtins/geo/matchers_v2geo.go b/ruleset/builtins/geo/matchers_v2geo.go index 0271f33..b8c37b0 100644 --- a/ruleset/builtins/geo/matchers_v2geo.go +++ b/ruleset/builtins/geo/matchers_v2geo.go @@ -5,7 +5,7 @@ import ( "errors" "net" "regexp" - "sort" + "slices" "strings" "github.com/apernet/OpenGFW/ruleset/builtins/geo/v2geo" @@ -80,11 +80,11 @@ func newGeoIPMatcher(list *v2geo.GeoIP) (*geoipMatcher, error) { } } // Sort the IPNets, so we can do binary search later. - sort.Slice(n4, func(i, j int) bool { - return bytes.Compare(n4[i].IP, n4[j].IP) < 0 + slices.SortFunc(n4, func(a, b *net.IPNet) int { + return bytes.Compare(a.IP, b.IP) }) - sort.Slice(n6, func(i, j int) bool { - return bytes.Compare(n6[i].IP, n6[j].IP) < 0 + slices.SortFunc(n6, func(a, b *net.IPNet) int { + return bytes.Compare(a.IP, b.IP) }) return &geoipMatcher{ N4: n4, diff --git a/ruleset/builtins/geo/matchers_v2geo_test.go b/ruleset/builtins/geo/matchers_v2geo_test.go new file mode 100644 index 0000000..4efaedd --- /dev/null +++ b/ruleset/builtins/geo/matchers_v2geo_test.go @@ -0,0 +1,30 @@ +package geo + +import ( + "net" + "testing" + + "github.com/apernet/OpenGFW/ruleset/builtins/geo/v2geo" +) + +func TestGeoIPMatcherSortsNetworks(t *testing.T) { + list := &v2geo.GeoIP{Cidr: []*v2geo.CIDR{ + {Ip: []byte{192, 0, 2, 0}, Prefix: 24}, + {Ip: []byte{10, 0, 0, 0}, Prefix: 24}, + }} + matcher, err := newGeoIPMatcher(list) + if err != nil { + t.Fatal(err) + } + if got := matcher.N4[0].IP.String(); got != "10.0.0.0" { + t.Fatalf("first network = %s, want 10.0.0.0", got) + } + for _, ip := range []net.IP{net.IPv4(10, 0, 0, 42), net.IPv4(192, 0, 2, 42)} { + if !matcher.Match(HostInfo{IPv4: ip}) { + t.Errorf("expected %s to match", ip) + } + } + if matcher.Match(HostInfo{IPv4: net.IPv4(203, 0, 113, 42)}) { + t.Fatal("unexpected match outside configured networks") + } +} diff --git a/ruleset/expr.go b/ruleset/expr.go index 868a115..29d57c2 100644 --- a/ruleset/expr.go +++ b/ruleset/expr.go @@ -33,8 +33,8 @@ type ExprRule struct { } type ModifierEntry struct { - Name string `yaml:"name"` - Args map[string]interface{} `yaml:"args"` + Name string `yaml:"name"` + Args map[string]any `yaml:"args"` } func ExprRulesFromYAML(file string) ([]ExprRule, error) { @@ -187,8 +187,8 @@ func CompileExprRules(rules []ExprRule, ans []analyzer.Analyzer, mods []modifier }, nil } -func streamInfoToExprEnv(info StreamInfo) map[string]interface{} { - m := map[string]interface{}{ +func streamInfoToExprEnv(info StreamInfo) map[string]any { + m := map[string]any{ "id": info.ID, "proto": info.Protocol.String(), "ip": map[string]string{