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
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:
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user