From 1d74c6142bc034bc2d310fe9eee492a38c5bd076 Mon Sep 17 00:00:00 2001 From: d3m0k1d Date: Tue, 20 Jan 2026 20:47:45 +0300 Subject: [PATCH] feat: recode scanner logic, add sshd service, add journald support, recode test for parser, update daemon, update config template --- cmd/banforge/command/daemon.go | 97 +++++++++--- internal/config/template.go | 2 + internal/config/types.go | 1 + internal/parser/parser.go | 65 ++++++-- internal/parser/parser_test.go | 266 ++++++++++++++++++++++++++++----- internal/parser/sshd.go | 54 +++++++ 6 files changed, 409 insertions(+), 76 deletions(-) create mode 100644 internal/parser/sshd.go diff --git a/cmd/banforge/command/daemon.go b/cmd/banforge/command/daemon.go index 1aa85df..f398fbc 100644 --- a/cmd/banforge/command/daemon.go +++ b/cmd/banforge/command/daemon.go @@ -61,15 +61,14 @@ var DaemonCmd = &cobra.Command{ } }() + var scanners []*parser.Scanner + for _, svc := range cfg.Service { log.Info( "Processing service", - "name", - svc.Name, - "enabled", - svc.Enabled, - "path", - svc.LogPath, + "name", svc.Name, + "enabled", svc.Enabled, + "path", svc.LogPath, ) if !svc.Enabled { @@ -77,30 +76,80 @@ var DaemonCmd = &cobra.Command{ continue } - if svc.Name != "nginx" { - log.Info("Only nginx supported, skipping", "name", svc.Name) - continue - } - log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath) - - pars, err := parser.NewScanner(svc.LogPath) - if err != nil { - log.Error("Failed to create scanner", "service", svc.Name, "error", err) + if svc.Logging != "file" && svc.Logging != "journald" { + log.Error("Invalid logging type", "type", svc.Logging) continue } - go pars.Start() - defer pars.Stop() - go func(p *parser.Scanner, serviceName string) { - log.Info("Starting nginx parser", "service", serviceName) - ng := parser.NewNginxParser() - resultCh := make(chan *storage.LogEntry, 100) - ng.Parse(p.Events(), resultCh) - go storage.Write(db, resultCh) - }(pars, svc.Name) + if svc.Logging == "file" { + log.Info("Logging to file", "path", svc.LogPath) + pars, err := parser.NewScannerTail(svc.LogPath) + if err != nil { + log.Error("Failed to create scanner", "service", svc.Name, "error", err) + continue + } + + scanners = append(scanners, pars) + + go pars.Start() + + go func(p *parser.Scanner, serviceName string) { + if svc.Name == "nginx" { + log.Info("Starting nginx parser", "service", serviceName) + ng := parser.NewNginxParser() + resultCh := make(chan *storage.LogEntry, 100) + ng.Parse(p.Events(), resultCh) + go storage.Write(db, resultCh) + } + if svc.Name == "ssh" { + log.Info("Starting ssh parser", "service", serviceName) + ssh := parser.NewSshdParser() + resultCh := make(chan *storage.LogEntry, 100) + ssh.Parse(p.Events(), resultCh) + go storage.Write(db, resultCh) + } + }(pars, svc.Name) + continue + } + + if svc.Logging == "journald" { + log.Info("Logging to journald", "path", svc.LogPath) + pars, err := parser.NewScannerJournald(svc.LogPath) + if err != nil { + log.Error("Failed to create scanner", "service", svc.Name, "error", err) + continue + } + + scanners = append(scanners, pars) + + go pars.Start() + go func(p *parser.Scanner, serviceName string) { + if svc.Name == "nginx" { + log.Info("Starting nginx parser", "service", serviceName) + ng := parser.NewNginxParser() + resultCh := make(chan *storage.LogEntry, 100) + ng.Parse(p.Events(), resultCh) + go storage.Write(db, resultCh) + } + if svc.Name == "ssh" { + log.Info("Starting ssh parser", "service", serviceName) + ssh := parser.NewSshdParser() + resultCh := make(chan *storage.LogEntry, 100) + ssh.Parse(p.Events(), resultCh) + go storage.Write(db, resultCh) + } + + }(pars, svc.Name) + continue + } } + <-ctx.Done() log.Info("Shutdown signal received") + + for _, s := range scanners { + s.Stop() + } }, } diff --git a/internal/config/template.go b/internal/config/template.go index 6f468ea..21a7fcc 100644 --- a/internal/config/template.go +++ b/internal/config/template.go @@ -10,11 +10,13 @@ config = "/etc/nftables.conf" [[service]] name = "nginx" +logging = "file" log_path = "/var/log/nginx/access.log" enabled = true [[service]] name = "nginx" +logging = "journald" log_path = "/var/log/nginx/access.log" enabled = false ` diff --git a/internal/config/types.go b/internal/config/types.go index 43d2d11..5ad8353 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -7,6 +7,7 @@ type Firewall struct { type Service struct { Name string `toml:"name"` + Logging string `toml:"logging"` LogPath string `toml:"log_path"` Enabled bool `toml:"enabled"` } diff --git a/internal/parser/parser.go b/internal/parser/parser.go index e4b062c..8f7afcf 100644 --- a/internal/parser/parser.go +++ b/internal/parser/parser.go @@ -3,6 +3,7 @@ package parser import ( "bufio" "os" + "os/exec" "time" "github.com/d3m0k1d/BanForge/internal/logger" @@ -17,24 +18,52 @@ type Scanner struct { ch chan Event stopCh chan struct{} logger *logger.Logger + cmd *exec.Cmd file *os.File pollDelay time.Duration } -func NewScanner(path string) (*Scanner, error) { - file, err := os.Open( - path, - ) // #nosec G304 -- admin tool, runs as root, path controlled by operator +func NewScannerTail(path string) (*Scanner, error) { + cmd := exec.Command("tail", "-F", "-n", "10", path) + stdout, err := cmd.StdoutPipe() if err != nil { return nil, err } + if err := cmd.Start(); err != nil { + return nil, err + } + return &Scanner{ - scanner: bufio.NewScanner(file), + scanner: bufio.NewScanner(stdout), ch: make(chan Event, 100), stopCh: make(chan struct{}), logger: logger.New(false), - file: file, + file: nil, + cmd: cmd, + pollDelay: 100 * time.Millisecond, + }, nil +} + +func NewScannerJournald(unit string) (*Scanner, error) { + cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "cat", "--no-pager") + + stdout, err := cmd.StdoutPipe() + if err != nil { + return nil, err + } + + if err := cmd.Start(); err != nil { + return nil, err + } + + return &Scanner{ + scanner: bufio.NewScanner(stdout), + ch: make(chan Event, 100), + stopCh: make(chan struct{}), + logger: logger.New(false), + cmd: cmd, + file: nil, pollDelay: 100 * time.Millisecond, }, nil } @@ -60,7 +89,6 @@ func (s *Scanner) Start() { s.logger.Error("Scanner error") return } - time.Sleep(s.pollDelay) } } } @@ -69,11 +97,26 @@ func (s *Scanner) Start() { func (s *Scanner) Stop() { close(s.stopCh) - time.Sleep(150 * time.Millisecond) - err := s.file.Close() - if err != nil { - s.logger.Error("Failed to close file") + + if s.cmd != nil && s.cmd.Process != nil { + s.logger.Info("Stopping process", "pid", s.cmd.Process.Pid) + err := s.cmd.Process.Kill() + if err != nil { + s.logger.Error("Failed to kill process", "err", err) + } + err = s.cmd.Wait() + if err != nil { + s.logger.Error("Failed to wait process", "err", err) + } + } + + if s.file != nil { + if err := s.file.Close(); err != nil { + s.logger.Error("Failed to close file", "err", err) + } + } + time.Sleep(150 * time.Millisecond) close(s.ch) } diff --git a/internal/parser/parser_test.go b/internal/parser/parser_test.go index d7e2bbd..1bb555e 100644 --- a/internal/parser/parser_test.go +++ b/internal/parser/parser_test.go @@ -6,48 +6,67 @@ import ( "time" ) -func TestNewScanner(t *testing.T) { - file, err := os.CreateTemp("", "test.log") +func TestNewScannerTail(t *testing.T) { + + file, err := os.CreateTemp("", "test-*.log") if err != nil { t.Fatal(err) } - defer file.Close() defer os.Remove(file.Name()) - s, err := NewScanner(file.Name()) + file.Close() + + scanner, err := NewScannerTail(file.Name()) if err != nil { - t.Fatal(err) + t.Fatalf("NewScannerTail() error = %v", err) } - if s == nil { + + if scanner == nil { t.Fatal("Scanner is nil") } + + if scanner.cmd == nil { + t.Fatal("cmd is nil") + } + + if scanner.cmd.Process == nil { + t.Fatal("process is nil") + } + + scanner.Stop() } -func TestScannerStart(t *testing.T) { +func TestScannerTailEvents(t *testing.T) { tests := []struct { name string - input string - wantErr bool + lines []string wantLines int }{ { - name: "correct file", - input: `Failed password for root from 192.168.1.1 -Invalid user admin from 192.168.1.1 -Accepted publickey for user from 192.168.1.2`, - wantErr: false, + name: "multiple lines", + lines: []string{ + "Failed password for root from 192.168.1.1", + "Invalid user admin from 192.168.1.2", + "Accepted publickey for user from 192.168.1.3", + }, wantLines: 3, }, { - name: "empty file", - input: "", - wantErr: false, - wantLines: 0, + name: "single line", + lines: []string{ + "Failed password for root", + }, + wantLines: 1, }, { - name: "single line", - input: `Failed password for root`, - wantErr: false, - wantLines: 1, + name: "many lines", + lines: []string{ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + }, + wantLines: 5, }, } @@ -59,41 +78,206 @@ Accepted publickey for user from 192.168.1.2`, t.Fatal(err) } filePath := file.Name() - - if _, err := file.WriteString(tt.input); err != nil { - t.Fatal(err) - } file.Close() defer os.Remove(filePath) - scanner, err := NewScanner(filePath) - if (err != nil) != tt.wantErr { - t.Errorf("NewScanner() error = %v, wantErr %v", err, tt.wantErr) - return - } - - if tt.wantErr { - return + scanner, err := NewScannerTail(filePath) + if err != nil { + t.Fatalf("NewScannerTail() error = %v", err) } defer scanner.Stop() scanner.Start() - timeout := time.After(500 * time.Millisecond) - linesRead := 0 + time.Sleep(200 * time.Millisecond) + file, err = os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + t.Fatal(err) + } + + for _, line := range tt.lines { + if _, err := file.WriteString(line + "\n"); err != nil { + t.Fatal(err) + } + } + + if err := file.Sync(); err != nil { + t.Fatal(err) + } + file.Close() + + // 5. Собираем события + timeout := time.After(1 * time.Second) + var events []Event + + eventLoop: for { select { case event := <-scanner.Events(): - linesRead++ + events = append(events, event) t.Logf("Read: %s", event.Data) - case <-timeout: - if linesRead != tt.wantLines { - t.Errorf("got %d lines, want %d", linesRead, tt.wantLines) + + if len(events) == tt.wantLines { + break eventLoop } - return + + case <-timeout: + break eventLoop + } + } + + if len(events) != tt.wantLines { + t.Errorf("got %d lines, want %d", len(events), tt.wantLines) + } + + for i, event := range events { + if event.Data != tt.lines[i] { + t.Errorf("line %d: got %q, want %q", i, event.Data, tt.lines[i]) } } }) } } + +func TestScannerStop(t *testing.T) { + + file, err := os.CreateTemp("", "test-*.log") + if err != nil { + t.Fatal(err) + } + filePath := file.Name() + file.Close() + defer os.Remove(filePath) + + scanner, err := NewScannerTail(filePath) + if err != nil { + t.Fatal(err) + } + + scanner.Start() + time.Sleep(100 * time.Millisecond) + + scanner.Stop() + + err = scanner.cmd.Process.Signal(os.Signal(nil)) + if err == nil { + t.Error("Process still alive after Stop()") + } + + select { + case _, ok := <-scanner.Events(): + if ok { + t.Error("Channel still open after Stop()") + } + case <-time.After(100 * time.Millisecond): + t.Error("Channel not closed after Stop()") + } +} +func TestMultipleScanners(t *testing.T) { + + file1, err := os.CreateTemp("", "test1-*.log") + if err != nil { + t.Fatal(err) + } + path1 := file1.Name() + file1.Close() + defer os.Remove(path1) + + file2, err := os.CreateTemp("", "test2-*.log") + if err != nil { + t.Fatal(err) + } + path2 := file2.Name() + file2.Close() + defer os.Remove(path2) + + scanner1, err := NewScannerTail(path1) + if err != nil { + t.Fatal(err) + } + defer scanner1.Stop() + + scanner2, err := NewScannerTail(path2) + if err != nil { + t.Fatal(err) + } + defer scanner2.Stop() + + scanner1.Start() + scanner2.Start() + + time.Sleep(200 * time.Millisecond) + + f1, _ := os.OpenFile(path1, os.O_APPEND|os.O_WRONLY, 0644) + f1.WriteString("scanner1 line\n") + f1.Sync() + f1.Close() + + f2, _ := os.OpenFile(path2, os.O_APPEND|os.O_WRONLY, 0644) + f2.WriteString("scanner2 line\n") + f2.Sync() + f2.Close() + + timeout := time.After(1 * time.Second) + + var event1, event2 Event + got1, got2 := false, false + + for !got1 || !got2 { + select { + case event1 = <-scanner1.Events(): + got1 = true + t.Logf("Scanner1: %s", event1.Data) + + case event2 = <-scanner2.Events(): + got2 = true + t.Logf("Scanner2: %s", event2.Data) + + case <-timeout: + if !got1 { + t.Error("Scanner1 did not receive event") + } + if !got2 { + t.Error("Scanner2 did not receive event") + } + return + } + } + + if event1.Data != "scanner1 line" { + t.Errorf("Scanner1 got wrong data: %q", event1.Data) + } + + if event2.Data != "scanner2 line" { + t.Errorf("Scanner2 got wrong data: %q", event2.Data) + } +} +func BenchmarkScanner(b *testing.B) { + file, err := os.CreateTemp("", "bench-*.log") + if err != nil { + b.Fatal(err) + } + filePath := file.Name() + file.Close() + defer os.Remove(filePath) + + scanner, err := NewScannerTail(filePath) + if err != nil { + b.Fatal(err) + } + defer scanner.Stop() + + scanner.Start() + time.Sleep(200 * time.Millisecond) + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + f, _ := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644) + f.WriteString("benchmark line\n") + f.Sync() + f.Close() + <-scanner.Events() + } +} diff --git a/internal/parser/sshd.go b/internal/parser/sshd.go new file mode 100644 index 0000000..87e4a4c --- /dev/null +++ b/internal/parser/sshd.go @@ -0,0 +1,54 @@ +package parser + +import ( + "regexp" + + "github.com/d3m0k1d/BanForge/internal/logger" + "github.com/d3m0k1d/BanForge/internal/storage" +) + +type SshdParser struct { + pattern *regexp.Regexp + logger *logger.Logger +} + +func NewSshdParser() *SshdParser { + pattern := regexp.MustCompile( + `^([A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(\S+)\s+sshd(?:-session)?\[(\d+)\]:\s+Failed\s+(\w+)\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`, + ) + return &SshdParser{ + pattern: pattern, + logger: logger.New(false), + } +} + +func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) { + // Group 1: Timestamp, Group 2: hostame, Group 3: pid, Group 4: Method auth, Group 5: User, Group 6: IP, Group 7: port + go func() { + for event := range eventCh { + matches := p.pattern.FindStringSubmatch(event.Data) + if matches == nil { + continue + } + resultCh <- &storage.LogEntry{ + Service: "ssh", + IP: matches[6], + Path: matches[5], // user + Status: "Failed", + Method: matches[4], // method auth + IsViewed: false, + } + p.logger.Info( + "Parsed ssh log entry", + "ip", + matches[6], + "user", + matches[5], + "method", + matches[4], + "status", + "Failed", + ) + } + }() +}