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>
1062 lines
24 KiB
Go
1062 lines
24 KiB
Go
package output
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"ja4sentinel/api"
|
|
)
|
|
|
|
func TestStdoutWriter(t *testing.T) {
|
|
w := NewStdoutWriter()
|
|
if w == nil {
|
|
t.Fatal("NewStdoutWriter() returned nil")
|
|
}
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t13d1516h2_test",
|
|
}
|
|
|
|
// Write should not fail (but we can't easily test stdout output)
|
|
err := w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Close should be no-op
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileWriter(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t13d1516h2_test",
|
|
}
|
|
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Close the writer to flush
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Verify file was created and contains data
|
|
data, err := os.ReadFile(testFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read test file: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("File is empty")
|
|
}
|
|
|
|
// Verify it's valid JSON
|
|
var got api.LogRecord
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Errorf("Output is not valid JSON: %v", err)
|
|
}
|
|
|
|
if got.SrcIP != rec.SrcIP {
|
|
t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP)
|
|
}
|
|
}
|
|
|
|
func TestFileWriter_CreatesDirectory(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "subdir", "nested", "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "test",
|
|
}
|
|
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Verify file exists
|
|
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
|
t.Error("File was not created")
|
|
}
|
|
}
|
|
|
|
func TestMultiWriter(t *testing.T) {
|
|
mw := NewMultiWriter()
|
|
if mw == nil {
|
|
t.Fatal("NewMultiWriter() returned nil")
|
|
}
|
|
|
|
// Create a test writer that tracks writes
|
|
var writeCount int
|
|
testWriter := &testWriter{
|
|
writeFunc: func(rec api.LogRecord) error {
|
|
writeCount++
|
|
return nil
|
|
},
|
|
}
|
|
|
|
mw.Add(testWriter)
|
|
mw.Add(NewStdoutWriter())
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
JA4: "test",
|
|
}
|
|
|
|
err := mw.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
if writeCount != 1 {
|
|
t.Errorf("writeCount = %d, want 1", writeCount)
|
|
}
|
|
|
|
// CloseAll should not fail
|
|
if err := mw.CloseAll(); err != nil {
|
|
t.Errorf("CloseAll() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMultiWriter_WriteError(t *testing.T) {
|
|
mw := NewMultiWriter()
|
|
|
|
// Create a writer that always fails
|
|
failWriter := &testWriter{
|
|
writeFunc: func(rec api.LogRecord) error {
|
|
return os.ErrPermission
|
|
},
|
|
}
|
|
|
|
mw.Add(failWriter)
|
|
|
|
rec := api.LogRecord{SrcIP: "192.168.1.1"}
|
|
err := mw.Write(rec)
|
|
|
|
// Should return the last error
|
|
if err != os.ErrPermission {
|
|
t.Errorf("Write() error = %v, want %v", err, os.ErrPermission)
|
|
}
|
|
}
|
|
|
|
func TestBuilder_NewFromConfig(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
tests := []struct {
|
|
name string
|
|
config api.AppConfig
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "empty config defaults to stdout",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "stdout output",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "stdout", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "disabled output ignored",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "stdout", Enabled: false},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "file output without path fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "file", Enabled: true, Params: map[string]string{}},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unix socket without socket_path fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "unix_socket", Enabled: true, Params: map[string]string{}},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unknown output type fails",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "unknown", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unix socket with custom AsyncBuffer",
|
|
config: api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{
|
|
Type: "unix_socket",
|
|
Enabled: true,
|
|
AsyncBuffer: 5000,
|
|
Params: map[string]string{"socket_path": "test.sock"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
// Set up paths for tests that need them (only for valid configs)
|
|
if !tt.wantErr {
|
|
for i := range tt.config.Outputs {
|
|
if tt.config.Outputs[i].Type == "file" {
|
|
if tt.config.Outputs[i].Params == nil {
|
|
tt.config.Outputs[i].Params = make(map[string]string)
|
|
}
|
|
tt.config.Outputs[i].Params["path"] = filepath.Join(tmpDir, "test.log")
|
|
}
|
|
if tt.config.Outputs[i].Type == "unix_socket" {
|
|
if tt.config.Outputs[i].Params == nil {
|
|
tt.config.Outputs[i].Params = make(map[string]string)
|
|
}
|
|
tt.config.Outputs[i].Params["socket_path"] = filepath.Join(tmpDir, "test.sock")
|
|
}
|
|
}
|
|
}
|
|
|
|
_, err := builder.NewFromConfig(tt.config)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
// Create writer (socket doesn't need to exist yet)
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
// Write should queue the message (won't fail if socket doesn't exist)
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Logf("Write() error (expected if socket doesn't exist) = %v", err)
|
|
}
|
|
|
|
// Close should clean up properly
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriterWithConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriterWithConfig(socketPath, 1*time.Second, 1*time.Second, 100)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.dialTimeout != 1*time.Second {
|
|
t.Errorf("dialTimeout = %v, want 1s", w.dialTimeout)
|
|
}
|
|
if w.writeTimeout != 1*time.Second {
|
|
t.Errorf("writeTimeout = %v, want 1s", w.writeTimeout)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter_CloseTwice(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
|
|
// First close
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() first error = %v", err)
|
|
}
|
|
|
|
// Second close should be safe (no-op)
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() second error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter_WriteAfterClose(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
rec := api.LogRecord{SrcIP: "192.168.1.1"}
|
|
err = w.Write(rec)
|
|
if err == nil {
|
|
t.Error("Write() after Close() should return error")
|
|
}
|
|
}
|
|
|
|
// testWriter is a mock writer for testing
|
|
type testWriter struct {
|
|
writeFunc func(api.LogRecord) error
|
|
closeFunc func() error
|
|
}
|
|
|
|
func (w *testWriter) Write(rec api.LogRecord) error {
|
|
if w.writeFunc != nil {
|
|
return w.writeFunc(rec)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (w *testWriter) Close() error {
|
|
if w.closeFunc != nil {
|
|
return w.closeFunc()
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Test to verify LogRecord JSON serialization
|
|
func TestLogRecordJSONSerialization(t *testing.T) {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.100",
|
|
SrcPort: 54321,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
IPTTL: 64,
|
|
IPTotalLen: 512,
|
|
IPID: 12345,
|
|
IPDF: true,
|
|
TCPWindow: 65535,
|
|
TCPOptions: "MSS,WS,SACK,TS",
|
|
// New fields per architecture.yml
|
|
ConnID: "flow-abc123",
|
|
SensorID: "sensor-01",
|
|
TLSVersion: "1.3",
|
|
SNI: "example.com",
|
|
ALPN: "h2",
|
|
// Fingerprints - note: JA4Hash is NOT in LogRecord per architecture
|
|
JA4: "t13d1516h2_8daaf6152771_02cb136f2775",
|
|
JA3: "771,4865-4866-4867,0-23-65281-10-11-35-16-5-13-18-51-45-43-27-17513,29-23-24,0",
|
|
JA3Hash: "a0e6f06c7a6d15e5e3f0f0e6f06c7a6d",
|
|
Timestamp: time.Now().UnixNano(),
|
|
}
|
|
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Verify it can be unmarshaled
|
|
var got api.LogRecord
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Errorf("json.Unmarshal() error = %v", err)
|
|
}
|
|
|
|
// Verify key fields
|
|
if got.SrcIP != rec.SrcIP {
|
|
t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec.SrcIP)
|
|
}
|
|
if got.JA4 != rec.JA4 {
|
|
t.Errorf("JA4 = %v, want %v", got.JA4, rec.JA4)
|
|
}
|
|
// Verify JA4Hash is NOT present (architecture decision)
|
|
// JA4Hash field doesn't exist in LogRecord anymore
|
|
// Verify new fields
|
|
if got.ConnID != rec.ConnID {
|
|
t.Errorf("ConnID = %v, want %v", got.ConnID, rec.ConnID)
|
|
}
|
|
if got.SNI != rec.SNI {
|
|
t.Errorf("SNI = %v, want %v", got.SNI, rec.SNI)
|
|
}
|
|
}
|
|
|
|
// Test to verify optional fields are omitted when empty
|
|
func TestLogRecordOptionalFieldsOmitted(t *testing.T) {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
// Optional fields not set
|
|
TCPMSS: nil,
|
|
TCPWScale: nil,
|
|
JA3: "",
|
|
JA3Hash: "",
|
|
}
|
|
|
|
data, err := json.Marshal(rec)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
|
|
// Check that optional fields are not present in JSON
|
|
jsonStr := string(data)
|
|
if contains(jsonStr, `"tcp_meta_mss"`) {
|
|
t.Error("tcp_meta_mss should be omitted when nil")
|
|
}
|
|
if contains(jsonStr, `"tcp_meta_window_scale"`) {
|
|
t.Error("tcp_meta_window_scale should be omitted when nil")
|
|
}
|
|
}
|
|
|
|
func contains(s, substr string) bool {
|
|
return bytes.Contains([]byte(s), []byte(substr))
|
|
}
|
|
|
|
// TestUnixSocketWriter_ErrorCallback tests that errors are reported via callback
|
|
func TestUnixSocketWriter_ErrorCallback(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "nonexistent.sock")
|
|
|
|
// Track callback invocations
|
|
var errorCalls []struct {
|
|
path string
|
|
err error
|
|
attempt int
|
|
}
|
|
|
|
callback := func(path string, err error, attempt int) {
|
|
errorCalls = append(errorCalls, struct {
|
|
path string
|
|
err error
|
|
attempt int
|
|
}{path, err, attempt})
|
|
}
|
|
|
|
w, err := NewUnixSocketWriterWithConfig(
|
|
socketPath,
|
|
100*time.Millisecond,
|
|
100*time.Millisecond,
|
|
10,
|
|
WithErrorCallback(callback),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
// Write should queue the message
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() unexpected error = %v", err)
|
|
}
|
|
|
|
// Wait for queue processor to attempt write and trigger callback
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Callback should have been invoked at least once
|
|
if len(errorCalls) == 0 {
|
|
t.Error("ErrorCallback was not invoked")
|
|
} else {
|
|
// Verify callback parameters
|
|
lastCall := errorCalls[len(errorCalls)-1]
|
|
if lastCall.path != socketPath {
|
|
t.Errorf("Callback path = %v, want %v", lastCall.path, socketPath)
|
|
}
|
|
if lastCall.err == nil {
|
|
t.Error("Callback err should not be nil")
|
|
}
|
|
if lastCall.attempt < 1 {
|
|
t.Errorf("Callback attempt = %d, want >= 1", lastCall.attempt)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestBuilder_WithErrorCallback tests that the builder propagates error callbacks
|
|
func TestBuilder_WithErrorCallback(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
callback := func(path string, err error, attempt int) {
|
|
// Callback tracked for verification
|
|
}
|
|
|
|
builder := NewBuilder().WithErrorCallback(callback)
|
|
|
|
config := api.AppConfig{
|
|
Core: api.Config{
|
|
Interface: "eth0",
|
|
ListenPorts: []uint16{443},
|
|
},
|
|
Outputs: []api.OutputConfig{
|
|
{
|
|
Type: "unix_socket",
|
|
Enabled: true,
|
|
AsyncBuffer: 100,
|
|
Params: map[string]string{"socket_path": socketPath},
|
|
},
|
|
},
|
|
}
|
|
|
|
writer, err := builder.NewFromConfig(config)
|
|
if err != nil {
|
|
t.Fatalf("NewFromConfig() error = %v", err)
|
|
}
|
|
|
|
// Verify writer is a MultiWriter
|
|
mw, ok := writer.(*MultiWriter)
|
|
if !ok {
|
|
t.Fatal("Writer is not a MultiWriter")
|
|
}
|
|
|
|
// Verify the UnixSocketWriter has the callback set
|
|
if len(mw.writers) != 1 {
|
|
t.Fatalf("Expected 1 writer, got %d", len(mw.writers))
|
|
}
|
|
|
|
unixWriter, ok := mw.writers[0].(*UnixSocketWriter)
|
|
if !ok {
|
|
t.Fatal("Writer is not a UnixSocketWriter")
|
|
}
|
|
|
|
if unixWriter.errorCallback == nil {
|
|
t.Error("UnixSocketWriter.errorCallback is nil")
|
|
}
|
|
|
|
_ = writer
|
|
}
|
|
|
|
// TestUnixSocketWriter_NoCallback tests that writer works without callback
|
|
func TestUnixSocketWriter_NoCallback(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "nonexistent.sock")
|
|
|
|
// Create writer without callback
|
|
w, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
// Write should not panic even without callback
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Logf("Write() error (expected) = %v", err)
|
|
}
|
|
|
|
// Give queue processor time to run
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
// Should not panic
|
|
}
|
|
|
|
// TestUnixSocketWriter_CallbackResetOnSuccess tests that failure counter resets on success
|
|
func TestUnixSocketWriter_CallbackResetOnSuccess(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
// Create a real socket
|
|
listener, err := net.Listen("unix", socketPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create socket: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
|
|
// Start a goroutine to accept and read connections
|
|
done := make(chan struct{})
|
|
go func() {
|
|
for {
|
|
conn, err := listener.Accept()
|
|
if err != nil {
|
|
select {
|
|
case <-done:
|
|
return
|
|
default:
|
|
}
|
|
continue
|
|
}
|
|
// Read and discard data
|
|
buf := make([]byte, 1024)
|
|
conn.SetReadDeadline(time.Now().Add(100 * time.Millisecond))
|
|
conn.Read(buf)
|
|
conn.Close()
|
|
}
|
|
}()
|
|
defer close(done)
|
|
|
|
var errorCalls int
|
|
callback := func(path string, err error, attempt int) {
|
|
errorCalls++
|
|
}
|
|
|
|
w, err := NewUnixSocketWriterWithConfig(
|
|
socketPath,
|
|
100*time.Millisecond,
|
|
100*time.Millisecond,
|
|
10,
|
|
WithErrorCallback(callback),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Write successfully
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Wait for write to complete
|
|
time.Sleep(200 * time.Millisecond)
|
|
|
|
// Callback should not have been called since connection succeeded
|
|
if errorCalls > 0 {
|
|
t.Errorf("ErrorCallback called %d times, want 0 for successful connection", errorCalls)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_Reopen tests the Reopen method for logrotate support
|
|
func TestFileWriter_Reopen(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
|
|
// Write initial data
|
|
rec1 := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test1",
|
|
}
|
|
|
|
err = w.Write(rec1)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Reopen the file (for logrotate - file is typically moved externally)
|
|
err = w.Reopen()
|
|
if err != nil {
|
|
t.Errorf("Reopen() error = %v", err)
|
|
}
|
|
|
|
// Write more data after reopen
|
|
rec2 := api.LogRecord{
|
|
SrcIP: "192.168.1.2",
|
|
SrcPort: 54321,
|
|
JA4: "test2",
|
|
}
|
|
|
|
err = w.Write(rec2)
|
|
if err != nil {
|
|
t.Errorf("Write() after reopen error = %v", err)
|
|
}
|
|
|
|
// Close and verify
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Read the file - should contain both records (Reopen uses O_APPEND)
|
|
data, err := os.ReadFile(testFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read file: %v", err)
|
|
}
|
|
|
|
// Parse JSON lines
|
|
lines := bytes.Split(bytes.TrimSpace(data), []byte("\n"))
|
|
if len(lines) != 2 {
|
|
t.Fatalf("Expected 2 lines, got %d", len(lines))
|
|
}
|
|
|
|
// Verify second record
|
|
var got api.LogRecord
|
|
if err := json.Unmarshal(lines[1], &got); err != nil {
|
|
t.Errorf("Invalid JSON on line 2: %v", err)
|
|
}
|
|
|
|
if got.SrcIP != rec2.SrcIP {
|
|
t.Errorf("SrcIP = %v, want %v", got.SrcIP, rec2.SrcIP)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_Rotate tests the log rotation functionality
|
|
func TestFileWriter_Rotate(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
// Create writer with very small max size to trigger rotation
|
|
// Minimum useful size is ~100 bytes for a log record
|
|
w, err := NewFileWriterWithConfig(testFile, 200, 3)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriterWithConfig() error = %v", err)
|
|
}
|
|
|
|
// Write multiple records to trigger rotation
|
|
records := []api.LogRecord{
|
|
{SrcIP: "192.168.1.1", SrcPort: 1111, JA4: "record1"},
|
|
{SrcIP: "192.168.1.2", SrcPort: 2222, JA4: "record2"},
|
|
{SrcIP: "192.168.1.3", SrcPort: 3333, JA4: "record3"},
|
|
{SrcIP: "192.168.1.4", SrcPort: 4444, JA4: "record4"},
|
|
}
|
|
|
|
for i, rec := range records {
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() record %d error = %v", i, err)
|
|
}
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Check that rotation occurred (backup file should exist)
|
|
backupFile := testFile + ".1"
|
|
if _, err := os.Stat(backupFile); os.IsNotExist(err) {
|
|
t.Log("Note: Rotation may not have occurred if total data < maxSize")
|
|
}
|
|
|
|
// Verify main file exists and has content
|
|
if _, err := os.Stat(testFile); os.IsNotExist(err) {
|
|
t.Errorf("Main file %s does not exist", testFile)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_Rotate_MaxBackups tests that old backups are cleaned up
|
|
func TestFileWriter_Rotate_MaxBackups(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
// Create writer with small max size and only 2 backups
|
|
w, err := NewFileWriterWithConfig(testFile, 150, 2)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriterWithConfig() error = %v", err)
|
|
}
|
|
|
|
// Write enough records to trigger multiple rotations
|
|
for i := 0; i < 10; i++ {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: uint16(1000 + i),
|
|
JA4: "test",
|
|
}
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
|
|
// Count backup files
|
|
backupCount := 0
|
|
for i := 1; i <= 5; i++ {
|
|
backupPath := testFile + "." + string(rune('0'+i))
|
|
if _, err := os.Stat(backupPath); err == nil {
|
|
backupCount++
|
|
}
|
|
}
|
|
|
|
// Should have at most 2 backups
|
|
if backupCount > 2 {
|
|
t.Errorf("Too many backup files: %d, want <= 2", backupCount)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_Reopen_Error tests Reopen after external file removal
|
|
func TestFileWriter_Reopen_Error(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
w, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
|
|
// Write initial data
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
err = w.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Remove the file externally (simulating logrotate move)
|
|
os.Remove(testFile)
|
|
|
|
// Reopen should succeed - it will create a new file
|
|
err = w.Reopen()
|
|
if err != nil {
|
|
t.Errorf("Reopen() should succeed after file removal, error = %v", err)
|
|
}
|
|
|
|
if err := w.Close(); err != nil {
|
|
t.Errorf("Close() error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_NewFileWriterWithConfig tests custom configuration
|
|
func TestFileWriter_NewFileWriterWithConfig(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
// Test with custom max size and backups
|
|
w, err := NewFileWriterWithConfig(testFile, 50*1024*1024, 5)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
if w.maxSize != 50*1024*1024 {
|
|
t.Errorf("maxSize = %d, want %d", w.maxSize, 50*1024*1024)
|
|
}
|
|
if w.maxBackups != 5 {
|
|
t.Errorf("maxBackups = %d, want 5", w.maxBackups)
|
|
}
|
|
}
|
|
|
|
// TestFileWriter_NewFileWriterWithConfig_InvalidPath tests error handling
|
|
func TestFileWriter_NewFileWriterWithConfig_InvalidPath(t *testing.T) {
|
|
// Try to create file in a path that should fail (e.g., /proc which is read-only)
|
|
_, err := NewFileWriterWithConfig("/proc/test/test.log", 1024, 3)
|
|
if err == nil {
|
|
t.Error("NewFileWriterWithConfig() with invalid path should return error")
|
|
}
|
|
}
|
|
|
|
// TestMultiWriter_Reopen tests Reopen on MultiWriter
|
|
func TestMultiWriter_Reopen(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
testFile := filepath.Join(tmpDir, "test.log")
|
|
|
|
mw := NewMultiWriter()
|
|
|
|
// Add a FileWriter (which is Reopenable)
|
|
fw, err := NewFileWriter(testFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
mw.Add(fw)
|
|
|
|
// Add a StdoutWriter (which is NOT Reopenable)
|
|
mw.Add(NewStdoutWriter())
|
|
|
|
// Write initial data
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
JA4: "test",
|
|
}
|
|
|
|
err = mw.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Reopen should work (FileWriter is reopenable, StdoutWriter is skipped)
|
|
err = mw.Reopen()
|
|
if err != nil {
|
|
t.Errorf("Reopen() error = %v", err)
|
|
}
|
|
|
|
// Write after reopen
|
|
rec2 := api.LogRecord{
|
|
SrcIP: "192.168.1.2",
|
|
SrcPort: 54321,
|
|
JA4: "test2",
|
|
}
|
|
|
|
err = mw.Write(rec2)
|
|
if err != nil {
|
|
t.Errorf("Write() after reopen error = %v", err)
|
|
}
|
|
|
|
if err := mw.CloseAll(); err != nil {
|
|
t.Errorf("CloseAll() error = %v", err)
|
|
}
|
|
}
|
|
|
|
// TestUnixSocketWriter_QueueFull tests behavior when queue is full
|
|
func TestUnixSocketWriter_QueueFull(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "test.sock")
|
|
|
|
// Create writer with very small queue
|
|
w, err := NewUnixSocketWriterWithConfig(socketPath, 10*time.Millisecond, 10*time.Millisecond, 2)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Fill the queue with records
|
|
for i := 0; i < 10; i++ {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: uint16(1000 + i),
|
|
JA4: "test",
|
|
}
|
|
_ = w.Write(rec) // May succeed or fail depending on queue state
|
|
}
|
|
|
|
// Should not panic - queue full messages are dropped
|
|
}
|
|
|
|
// TestUnixSocketWriter_ReconnectBackoff tests exponential backoff behavior
|
|
func TestUnixSocketWriter_ReconnectBackoff(t *testing.T) {
|
|
tmpDir := t.TempDir()
|
|
socketPath := filepath.Join(tmpDir, "nonexistent.sock")
|
|
|
|
var errorCount int
|
|
callback := func(path string, err error, attempt int) {
|
|
errorCount++
|
|
}
|
|
|
|
w, err := NewUnixSocketWriterWithConfig(
|
|
socketPath,
|
|
10*time.Millisecond,
|
|
10*time.Millisecond,
|
|
5,
|
|
WithErrorCallback(callback),
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriterWithConfig() error = %v", err)
|
|
}
|
|
defer w.Close()
|
|
|
|
// Write multiple records to trigger reconnection attempts
|
|
for i := 0; i < 3; i++ {
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: uint16(1000 + i),
|
|
JA4: "test",
|
|
}
|
|
_ = w.Write(rec)
|
|
}
|
|
|
|
// Wait for queue processor to attempt writes
|
|
time.Sleep(500 * time.Millisecond)
|
|
|
|
// Should have attempted reconnection
|
|
if errorCount == 0 {
|
|
t.Error("Expected at least one error callback for nonexistent socket")
|
|
}
|
|
}
|