Some checks failed
Build RPM Package / Build RPM Packages (CentOS 7, Rocky 8/9/10) (push) Has been cancelled
Co-authored-by: aider (openrouter/openai/gpt-5.3-codex) <aider@aider.chat>
468 lines
9.6 KiB
Go
468 lines
9.6 KiB
Go
package output
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"ja4sentinel/api"
|
|
)
|
|
|
|
func TestStdoutWriter(t *testing.T) {
|
|
// Capture stdout by replacing it temporarily
|
|
oldStdout := os.Stdout
|
|
r, w, _ := os.Pipe()
|
|
os.Stdout = w
|
|
|
|
writer := NewStdoutWriter()
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t12s0102ab_1234567890ab",
|
|
}
|
|
|
|
err := writer.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
w.Close()
|
|
os.Stdout = oldStdout
|
|
|
|
var buf bytes.Buffer
|
|
buf.ReadFrom(r)
|
|
output := buf.String()
|
|
|
|
if output == "" {
|
|
t.Error("Write() produced no output")
|
|
}
|
|
|
|
// Verify it's valid JSON
|
|
var result api.LogRecord
|
|
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
|
t.Errorf("Output is not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestFileWriter(t *testing.T) {
|
|
// Create a temporary file
|
|
tmpFile := "/tmp/ja4sentinel_test.log"
|
|
defer os.Remove(tmpFile)
|
|
|
|
writer, err := NewFileWriter(tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer writer.Close()
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t12s0102ab_1234567890ab",
|
|
}
|
|
|
|
err = writer.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Read the file and verify
|
|
data, err := os.ReadFile(tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read file: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("Write() produced no output")
|
|
}
|
|
|
|
// Verify it's valid JSON
|
|
var result api.LogRecord
|
|
if err := json.Unmarshal(data, &result); err != nil {
|
|
t.Errorf("Output is not valid JSON: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMultiWriter(t *testing.T) {
|
|
multiWriter := NewMultiWriter()
|
|
|
|
// Create a temporary file writer
|
|
tmpFile := "/tmp/ja4sentinel_multi_test.log"
|
|
defer os.Remove(tmpFile)
|
|
|
|
fileWriter, err := NewFileWriter(tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("NewFileWriter() error = %v", err)
|
|
}
|
|
defer fileWriter.Close()
|
|
|
|
multiWriter.Add(fileWriter)
|
|
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "t12s0102ab_1234567890ab",
|
|
}
|
|
|
|
err = multiWriter.Write(rec)
|
|
if err != nil {
|
|
t.Errorf("Write() error = %v", err)
|
|
}
|
|
|
|
// Verify file output
|
|
data, err := os.ReadFile(tmpFile)
|
|
if err != nil {
|
|
t.Fatalf("Failed to read file: %v", err)
|
|
}
|
|
|
|
if len(data) == 0 {
|
|
t.Error("MultiWriter.Write() produced no file output")
|
|
}
|
|
}
|
|
|
|
func TestBuilderNewFromConfig(t *testing.T) {
|
|
builder := NewBuilder()
|
|
|
|
tests := []struct {
|
|
name string
|
|
cfg api.AppConfig
|
|
wantErr bool
|
|
}{
|
|
{
|
|
name: "stdout output",
|
|
cfg: api.AppConfig{
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "stdout", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "file output",
|
|
cfg: api.AppConfig{
|
|
Outputs: []api.OutputConfig{
|
|
{
|
|
Type: "file",
|
|
Enabled: true,
|
|
Params: map[string]string{"path": "/tmp/ja4sentinel_builder_test.log"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "file output without path",
|
|
cfg: api.AppConfig{
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "file", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "unix socket output",
|
|
cfg: api.AppConfig{
|
|
Outputs: []api.OutputConfig{
|
|
{
|
|
Type: "unix_socket",
|
|
Enabled: true,
|
|
Params: map[string]string{"socket_path": "/tmp/ja4sentinel_test.sock"},
|
|
},
|
|
},
|
|
},
|
|
wantErr: false,
|
|
},
|
|
{
|
|
name: "unknown output type",
|
|
cfg: api.AppConfig{
|
|
Outputs: []api.OutputConfig{
|
|
{Type: "unknown", Enabled: true},
|
|
},
|
|
},
|
|
wantErr: true,
|
|
},
|
|
{
|
|
name: "no outputs (should default to stdout)",
|
|
cfg: api.AppConfig{},
|
|
wantErr: false,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
writer, err := builder.NewFromConfig(tt.cfg)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("NewFromConfig() error = %v, wantErr %v", err, tt.wantErr)
|
|
return
|
|
}
|
|
if !tt.wantErr && writer == nil {
|
|
t.Error("NewFromConfig() returned nil writer")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestUnixSocketWriter(t *testing.T) {
|
|
// Test creation without socket (should not fail)
|
|
socketPath := "/tmp/ja4sentinel_nonexistent.sock"
|
|
writer, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
|
|
// Write should fail since socket doesn't exist
|
|
rec := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 12345,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
}
|
|
|
|
err = writer.Write(rec)
|
|
if err == nil {
|
|
t.Error("Write() should fail for non-existent socket")
|
|
}
|
|
|
|
writer.Close()
|
|
}
|
|
|
|
func TestUnixSocketWriter_Write_NonexistentSocket_ReturnsQuickly(t *testing.T) {
|
|
socketPath := filepath.Join(t.TempDir(), "ja4sentinel_missing.sock")
|
|
writer, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
defer writer.Close()
|
|
|
|
start := time.Now()
|
|
err = writer.Write(api.LogRecord{
|
|
SrcIP: "192.168.1.10",
|
|
SrcPort: 44444,
|
|
DstIP: "10.0.0.10",
|
|
DstPort: 443,
|
|
})
|
|
elapsed := time.Since(start)
|
|
|
|
if err == nil {
|
|
t.Fatal("Write() should fail for non-existent socket")
|
|
}
|
|
if elapsed >= 3*time.Second {
|
|
t.Fatalf("Write() took too long: %v (expected < 3s)", elapsed)
|
|
}
|
|
}
|
|
|
|
type timeoutError struct{}
|
|
|
|
func (timeoutError) Error() string { return "i/o timeout" }
|
|
func (timeoutError) Timeout() bool { return true }
|
|
func (timeoutError) Temporary() bool { return true }
|
|
|
|
type mockAddr string
|
|
|
|
func (a mockAddr) Network() string { return "unix" }
|
|
func (a mockAddr) String() string { return string(a) }
|
|
|
|
type mockConn struct {
|
|
writeCalls int
|
|
closeCalled bool
|
|
setWriteDeadlineCalled bool
|
|
setReadDeadlineCalled bool
|
|
setAnyDeadlineWasCalled bool
|
|
}
|
|
|
|
func (m *mockConn) Read(_ []byte) (int, error) { return 0, errors.New("not implemented") }
|
|
|
|
func (m *mockConn) Write(_ []byte) (int, error) {
|
|
m.writeCalls++
|
|
return 0, timeoutError{}
|
|
}
|
|
|
|
func (m *mockConn) Close() error {
|
|
m.closeCalled = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockConn) LocalAddr() net.Addr { return mockAddr("local") }
|
|
func (m *mockConn) RemoteAddr() net.Addr { return mockAddr("remote") }
|
|
|
|
func (m *mockConn) SetDeadline(_ time.Time) error {
|
|
m.setAnyDeadlineWasCalled = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockConn) SetReadDeadline(_ time.Time) error {
|
|
m.setReadDeadlineCalled = true
|
|
return nil
|
|
}
|
|
|
|
func (m *mockConn) SetWriteDeadline(_ time.Time) error {
|
|
m.setWriteDeadlineCalled = true
|
|
return nil
|
|
}
|
|
|
|
func TestUnixSocketWriter_Write_UsesWriteDeadline(t *testing.T) {
|
|
mc := &mockConn{}
|
|
writer := &UnixSocketWriter{
|
|
socketPath: filepath.Join(t.TempDir(), "missing.sock"),
|
|
conn: mc,
|
|
dialTimeout: 100 * time.Millisecond,
|
|
writeTimeout: 100 * time.Millisecond,
|
|
}
|
|
|
|
err := writer.Write(api.LogRecord{
|
|
SrcIP: "192.168.1.20",
|
|
SrcPort: 55555,
|
|
DstIP: "10.0.0.20",
|
|
DstPort: 443,
|
|
})
|
|
if err == nil {
|
|
t.Fatal("Write() should fail because reconnect target does not exist")
|
|
}
|
|
if !mc.setWriteDeadlineCalled {
|
|
t.Fatal("expected SetWriteDeadline to be called before write")
|
|
}
|
|
if !mc.closeCalled {
|
|
t.Fatal("expected connection to be closed after first write failure")
|
|
}
|
|
if mc.writeCalls != 1 {
|
|
t.Fatalf("expected exactly 1 write on initial conn, got %d", mc.writeCalls)
|
|
}
|
|
if !strings.Contains(err.Error(), "reconnect failed") {
|
|
t.Fatalf("expected reconnect failure error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
type unixTestServer struct {
|
|
listener net.Listener
|
|
received chan string
|
|
mu sync.Mutex
|
|
conns map[net.Conn]struct{}
|
|
}
|
|
|
|
func newUnixTestServer(path string) (*unixTestServer, error) {
|
|
_ = os.Remove(path)
|
|
ln, err := net.Listen("unix", path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
s := &unixTestServer{
|
|
listener: ln,
|
|
received: make(chan string, 10),
|
|
conns: make(map[net.Conn]struct{}),
|
|
}
|
|
|
|
go s.serve()
|
|
return s, nil
|
|
}
|
|
|
|
func (s *unixTestServer) serve() {
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
s.mu.Lock()
|
|
s.conns[conn] = struct{}{}
|
|
s.mu.Unlock()
|
|
|
|
go func(c net.Conn) {
|
|
defer func() {
|
|
s.mu.Lock()
|
|
delete(s.conns, c)
|
|
s.mu.Unlock()
|
|
_ = c.Close()
|
|
}()
|
|
|
|
scanner := bufio.NewScanner(c)
|
|
for scanner.Scan() {
|
|
s.received <- scanner.Text()
|
|
}
|
|
}(conn)
|
|
}
|
|
}
|
|
|
|
func (s *unixTestServer) close(path string) {
|
|
_ = s.listener.Close()
|
|
|
|
s.mu.Lock()
|
|
for c := range s.conns {
|
|
_ = c.Close()
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
_ = os.Remove(path)
|
|
}
|
|
|
|
func TestUnixSocketWriter_ReconnectAndWrite(t *testing.T) {
|
|
socketPath := filepath.Join(t.TempDir(), "ja4sentinel.sock")
|
|
|
|
server1, err := newUnixTestServer(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to start first unix test server: %v", err)
|
|
}
|
|
|
|
writer, err := NewUnixSocketWriter(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("NewUnixSocketWriter() error = %v", err)
|
|
}
|
|
defer writer.Close()
|
|
|
|
rec1 := api.LogRecord{
|
|
SrcIP: "192.168.1.1",
|
|
SrcPort: 11111,
|
|
DstIP: "10.0.0.1",
|
|
DstPort: 443,
|
|
JA4: "first",
|
|
}
|
|
if err := writer.Write(rec1); err != nil {
|
|
t.Fatalf("first Write() error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-server1.received:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting first message on unix socket")
|
|
}
|
|
|
|
server1.close(socketPath)
|
|
|
|
server2, err := newUnixTestServer(socketPath)
|
|
if err != nil {
|
|
t.Fatalf("failed to restart unix test server: %v", err)
|
|
}
|
|
defer server2.close(socketPath)
|
|
|
|
rec2 := api.LogRecord{
|
|
SrcIP: "192.168.1.2",
|
|
SrcPort: 22222,
|
|
DstIP: "10.0.0.2",
|
|
DstPort: 443,
|
|
JA4: "second",
|
|
}
|
|
if err := writer.Write(rec2); err != nil {
|
|
t.Fatalf("second Write() after reconnect error = %v", err)
|
|
}
|
|
|
|
select {
|
|
case <-server2.received:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatal("timeout waiting second message after reconnect")
|
|
}
|
|
}
|