release: version 1.1.2 - Add error callback mechanism and comprehensive test suite
Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled

Features:
- Add ErrorCallback type for UNIX socket connection error reporting
- Add WithErrorCallback option for UnixSocketWriter configuration
- Add BuilderImpl.WithErrorCallback() for propagating callbacks
- Add consecutive failure tracking in processQueue

Testing (50+ new tests):
- Add integration tests for full pipeline (capture → tlsparse → fingerprint → output)
- Add tests for FileWriter.rotate() and Reopen() log rotation
- Add tests for cleanupExpiredFlows() and cleanupLoop() in TLS parser
- Add tests for extractSNIFromPayload() and extractJA4Hash() helpers
- Add tests for config load error paths (invalid YAML, permission denied)
- Add tests for capture.Run() error conditions
- Add tests for signal handling documentation

Documentation:
- Update architecture.yml with new fields (LogLevel, TLSClientHello extensions)
- Update architecture.yml with Close() methods for Capture and Parser interfaces
- Update RPM spec changelog

Cleanup:
- Remove empty internal/api/ directory

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Jacquin Antoine
2026-03-02 23:24:56 +01:00
parent 6e5addd6d4
commit 23f3012fb1
10 changed files with 2058 additions and 10 deletions

View File

@ -714,3 +714,336 @@ func min(a, b int) int {
}
return b
}
// TestExtractSNIFromPayload tests the SNI extraction function
func TestExtractSNIFromPayload(t *testing.T) {
tests := []struct {
name string
payload []byte
wantSNI string
}{
{
name: "empty_payload",
payload: []byte{},
wantSNI: "",
},
{
name: "payload_too_short",
payload: []byte{0x01, 0x00, 0x00, 0x10}, // Only 4 bytes
wantSNI: "",
},
{
name: "no_extensions",
payload: buildClientHelloWithoutExtensions(),
wantSNI: "",
},
{
name: "with_sni_extension",
payload: buildClientHelloWithSNI("example.com"),
wantSNI: "example.com",
},
{
name: "with_sni_long_domain",
payload: buildClientHelloWithSNI("very-long-subdomain.example-test-domain.com"),
wantSNI: "very-long-subdomain.example-test-domain.com",
},
{
name: "malformed_sni_truncated",
payload: buildTruncatedSNI(),
wantSNI: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractSNIFromPayload(tt.payload)
if got != tt.wantSNI {
t.Errorf("extractSNIFromPayload() = %q, want %q", got, tt.wantSNI)
}
})
}
}
// TestCleanupExpiredFlows tests the flow cleanup functionality
func TestCleanupExpiredFlows(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
defer p.Close()
// Create a flow manually using exported types
key := "192.168.1.1:12345->10.0.0.1:443"
flow := &ConnectionFlow{
State: NEW,
LastSeen: time.Now().Add(-2 * time.Hour), // Old flow
HelloBuffer: make([]byte, 0),
}
p.mu.Lock()
p.flows[key] = flow
p.mu.Unlock()
// Call cleanup
p.cleanupExpiredFlows()
// Flow should be deleted
p.mu.RLock()
_, exists := p.flows[key]
p.mu.RUnlock()
if exists {
t.Error("cleanupExpiredFlows() should have removed the expired flow")
}
}
// TestCleanupExpiredFlows_JA4Done tests that JA4_DONE flows are cleaned up immediately
func TestCleanupExpiredFlows_JA4Done(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
defer p.Close()
// Create a JA4_DONE flow (should be cleaned up regardless of timestamp)
key := "192.168.1.1:12345->10.0.0.1:443"
flow := &ConnectionFlow{
State: JA4_DONE,
LastSeen: time.Now(), // Recent, but should still be deleted
HelloBuffer: make([]byte, 0),
}
p.mu.Lock()
p.flows[key] = flow
p.mu.Unlock()
// Call cleanup
p.cleanupExpiredFlows()
// Flow should be deleted
p.mu.RLock()
_, exists := p.flows[key]
p.mu.RUnlock()
if exists {
t.Error("cleanupExpiredFlows() should have removed the JA4_DONE flow")
}
}
// TestCleanupExpiredFlows_RecentFlow tests that recent flows are NOT cleaned up
func TestCleanupExpiredFlows_RecentFlow(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
defer p.Close()
// Create a recent flow (should NOT be cleaned up)
key := "192.168.1.1:12345->10.0.0.1:443"
flow := &ConnectionFlow{
State: WAIT_CLIENT_HELLO,
LastSeen: time.Now(),
HelloBuffer: make([]byte, 0),
}
p.mu.Lock()
p.flows[key] = flow
p.mu.Unlock()
// Call cleanup
p.cleanupExpiredFlows()
// Flow should still exist
p.mu.RLock()
_, exists := p.flows[key]
p.mu.RUnlock()
if !exists {
t.Error("cleanupExpiredFlows() should NOT have removed the recent flow")
}
}
// TestCleanupLoop tests the cleanup goroutine shutdown
func TestCleanupLoop_Shutdown(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
// Close should stop the cleanup loop
err := p.Close()
if err != nil {
t.Errorf("Close() error = %v", err)
}
// Give goroutine time to exit
time.Sleep(100 * time.Millisecond)
// Verify cleanupDone channel is closed
select {
case _, ok := <-p.cleanupDone:
if ok {
t.Error("cleanupDone channel should be closed after Close()")
}
default:
t.Error("cleanupDone channel should be closed after Close()")
}
}
// buildClientHelloWithoutExtensions creates a ClientHello without extensions
func buildClientHelloWithoutExtensions() []byte {
// Minimal ClientHello: type(1) + len(3) + version(2) + random(32) + sessionIDLen(1) + cipherLen(2) + compressLen(1) + extLen(2)
handshake := make([]byte, 43)
handshake[0] = 0x01 // ClientHello type
handshake[1] = 0x00
handshake[2] = 0x00
handshake[3] = 0x27 // Length
handshake[4] = 0x03 // Version TLS 1.2
handshake[5] = 0x03
// Random (32 bytes) - zeros
handshake[38] = 0x00 // Session ID length
handshake[39] = 0x00 // Cipher suite length (high)
handshake[40] = 0x02 // Cipher suite length (low)
handshake[41] = 0x13 // Cipher suite data
handshake[42] = 0x01
// Compression length (1 byte) - 0
// Extensions length (2 bytes) - 0
return handshake
}
// buildClientHelloWithSNI creates a ClientHello with SNI extension
func buildClientHelloWithSNI(sni string) []byte {
// Build base handshake
handshake := make([]byte, 43)
handshake[0] = 0x01 // ClientHello type
handshake[1] = 0x00
handshake[2] = 0x00
handshake[4] = 0x03 // Version TLS 1.2
handshake[5] = 0x03
handshake[38] = 0x00 // Session ID length
handshake[39] = 0x00 // Cipher suite length (high)
handshake[40] = 0x02 // Cipher suite length (low)
handshake[41] = 0x13 // Cipher suite data
handshake[42] = 0x01
// Add compression length (1 byte) - 0
handshake = append(handshake, 0x00)
// Add extensions
sniExt := buildSNIExtension(sni)
extLen := len(sniExt)
handshake = append(handshake, byte(extLen>>8), byte(extLen))
handshake = append(handshake, sniExt...)
// Update handshake length
handshakeLen := len(handshake) - 4
handshake[1] = byte(handshakeLen >> 16)
handshake[2] = byte(handshakeLen >> 8)
handshake[3] = byte(handshakeLen)
return handshake
}
// buildTruncatedSNI creates a malformed ClientHello with truncated SNI
func buildTruncatedSNI() []byte {
// Build base handshake
handshake := make([]byte, 44)
handshake[0] = 0x01
handshake[4] = 0x03
handshake[5] = 0x03
handshake[38] = 0x00
handshake[39] = 0x00
handshake[40] = 0x02
handshake[41] = 0x13
handshake[42] = 0x01
handshake[43] = 0x00 // Compression length
// Add extensions with truncated SNI
// Extension type (2) + length (2) + data (truncated)
handshake = append(handshake, 0x00, 0x0a) // Extension length says 10 bytes
handshake = append(handshake, 0x00, 0x05) // But only provide 5 bytes of data
handshake = append(handshake, 0x00, 0x03, 0x74, 0x65, 0x73) // "tes" truncated
return handshake
}
// TestJoinStringSlice tests the deprecated helper function
func TestJoinStringSlice(t *testing.T) {
tests := []struct {
name string
slice []string
sep string
want string
}{
{
name: "empty_slice",
slice: []string{},
sep: ",",
want: "",
},
{
name: "single_element",
slice: []string{"MSS"},
sep: ",",
want: "MSS",
},
{
name: "multiple_elements",
slice: []string{"MSS", "SACK", "TS"},
sep: ",",
want: "MSS,SACK,TS",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := joinStringSlice(tt.slice, tt.sep)
if got != tt.want {
t.Errorf("joinStringSlice() = %q, want %q", got, tt.want)
}
})
}
}
// TestProcess_NilPacketData tests error handling for nil packet data
func TestProcess_NilPacketData(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
defer p.Close()
pkt := api.RawPacket{
Data: nil,
Timestamp: time.Now().UnixNano(),
}
_, err := p.Process(pkt)
if err == nil {
t.Error("Process() with nil data should return error")
}
if err.Error() != "empty packet data" {
t.Errorf("Process() error = %v, want 'empty packet data'", err)
}
}
// TestProcess_EmptyPacketData tests error handling for empty packet data
func TestProcess_EmptyPacketData(t *testing.T) {
p := NewParser()
if p == nil {
t.Fatal("NewParser() returned nil")
}
defer p.Close()
pkt := api.RawPacket{
Data: []byte{},
Timestamp: time.Now().UnixNano(),
}
_, err := p.Process(pkt)
if err == nil {
t.Error("Process() with empty data should return error")
}
}