5 Commits

Author SHA1 Message Date
d3m0k1d
e275a73460 chore: fix cgo gp-sqlite3 needed cgo
All checks were successful
build / build (push) Successful in 4m23s
CD - BanForge Release / release (push) Successful in 5m13s
2026-01-20 22:43:20 +03:00
d3m0k1d
2dcc3eaa7b chore: fix binary name
All checks were successful
build / build (push) Successful in 3m34s
CD - BanForge Release / release (push) Successful in 4m12s
2026-01-20 22:33:22 +03:00
d3m0k1d
322f5161cb chore: fix realease ver file name
All checks were successful
build / build (push) Successful in 2m22s
CD - BanForge Release / release (push) Successful in 3m9s
2026-01-20 21:34:58 +03:00
d3m0k1d
4e80b5148d docs: add new docs
All checks were successful
CD - BanForge Release / release (push) Successful in 3m23s
2026-01-20 21:14:07 +03:00
d3m0k1d
1d74c6142b feat: recode scanner logic, add sshd service, add journald support, recode test for parser, update daemon, update config template
All checks were successful
build / build (push) Successful in 2m27s
2026-01-20 20:47:45 +03:00
8 changed files with 414 additions and 81 deletions

View File

@@ -13,7 +13,7 @@ gitea_urls:
builds: builds:
- id: banforge - id: banforge
main: ./cmd/banforge/main.go main: ./cmd/banforge/main.go
binary: banforge-{{ .Version }}-{{ .Os }}-{{ .Arch }} binary: banforge
ignore: ignore:
- goos: windows - goos: windows
- goos: darwin - goos: darwin
@@ -23,8 +23,6 @@ builds:
goarch: goarch:
- amd64 - amd64
- arm64 - arm64
env:
- CGO_ENABLED=0
ldflags: ldflags:
- "-s -w" - "-s -w"
archives: archives:

View File

@@ -61,15 +61,14 @@ var DaemonCmd = &cobra.Command{
} }
}() }()
var scanners []*parser.Scanner
for _, svc := range cfg.Service { for _, svc := range cfg.Service {
log.Info( log.Info(
"Processing service", "Processing service",
"name", "name", svc.Name,
svc.Name, "enabled", svc.Enabled,
"enabled", "path", svc.LogPath,
svc.Enabled,
"path",
svc.LogPath,
) )
if !svc.Enabled { if !svc.Enabled {
@@ -77,30 +76,80 @@ var DaemonCmd = &cobra.Command{
continue 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) log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
if svc.Logging != "file" && svc.Logging != "journald" {
pars, err := parser.NewScanner(svc.LogPath) log.Error("Invalid logging type", "type", svc.Logging)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue continue
} }
go pars.Start() if svc.Logging == "file" {
defer pars.Stop() log.Info("Logging to file", "path", svc.LogPath)
go func(p *parser.Scanner, serviceName string) { pars, err := parser.NewScannerTail(svc.LogPath)
log.Info("Starting nginx parser", "service", serviceName) if err != nil {
ng := parser.NewNginxParser() log.Error("Failed to create scanner", "service", svc.Name, "error", err)
resultCh := make(chan *storage.LogEntry, 100) continue
ng.Parse(p.Events(), resultCh) }
go storage.Write(db, resultCh)
}(pars, svc.Name) 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() <-ctx.Done()
log.Info("Shutdown signal received") log.Info("Shutdown signal received")
for _, s := range scanners {
s.Stop()
}
}, },
} }

View File

@@ -11,19 +11,22 @@ Example:
[[service]] [[service]]
name = "nginx" name = "nginx"
logging = "file"
log_path = "/home/d3m0k1d/test.log" log_path = "/home/d3m0k1d/test.log"
enabled = true enabled = true
[[service]] [[service]]
name = "nginx" name = "nginx"
log_path = "/var/log/nginx/access.log" logging = "journald"
log_path = "nginx"
enabled = false enabled = false
``` ```
**Description** **Description**
The [firewall] section defines firewall parameters. The banforge init command automatically detects your installed firewall (nftables, iptables, ufw, firewalld). For firewalls that require a configuration file, specify the path in the config parameter. The [firewall] section defines firewall parameters. The banforge init command automatically detects your installed firewall (nftables, iptables, ufw, firewalld). For firewalls that require a configuration file, specify the path in the config parameter.
The [[service]] section is configured manually. Currently, only nginx is supported. To add a service, create a [[service]] block and specify the log_path to the nginx log file you want to monitor. The [[service]] section is configured manually. Currently, only nginx is supported. To add a service, create a [[service]] block and specify the log_path to the nginx log file you want to monitor.
logging require in format "file" or "journald"
if you use journald logging, log_path require in format "service_name"
## rules.toml ## rules.toml
Rules configuration file for BanForge. Rules configuration file for BanForge.

View File

@@ -10,11 +10,13 @@ config = "/etc/nftables.conf"
[[service]] [[service]]
name = "nginx" name = "nginx"
logging = "file"
log_path = "/var/log/nginx/access.log" log_path = "/var/log/nginx/access.log"
enabled = true enabled = true
[[service]] [[service]]
name = "nginx" name = "nginx"
logging = "journald"
log_path = "/var/log/nginx/access.log" log_path = "/var/log/nginx/access.log"
enabled = false enabled = false
` `

View File

@@ -7,6 +7,7 @@ type Firewall struct {
type Service struct { type Service struct {
Name string `toml:"name"` Name string `toml:"name"`
Logging string `toml:"logging"`
LogPath string `toml:"log_path"` LogPath string `toml:"log_path"`
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
} }

View File

@@ -3,6 +3,7 @@ package parser
import ( import (
"bufio" "bufio"
"os" "os"
"os/exec"
"time" "time"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -17,24 +18,51 @@ type Scanner struct {
ch chan Event ch chan Event
stopCh chan struct{} stopCh chan struct{}
logger *logger.Logger logger *logger.Logger
cmd *exec.Cmd
file *os.File file *os.File
pollDelay time.Duration pollDelay time.Duration
} }
func NewScanner(path string) (*Scanner, error) { func NewScannerTail(path string) (*Scanner, error) {
file, err := os.Open( cmd := exec.Command("tail", "-F", "-n", "10", path)
path, stdout, err := cmd.StdoutPipe()
) // #nosec G304 -- admin tool, runs as root, path controlled by operator
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := cmd.Start(); err != nil {
return nil, err
}
return &Scanner{ return &Scanner{
scanner: bufio.NewScanner(file), scanner: bufio.NewScanner(stdout),
ch: make(chan Event, 100), ch: make(chan Event, 100),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
logger: logger.New(false), 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", "short", "--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, pollDelay: 100 * time.Millisecond,
}, nil }, nil
} }
@@ -60,7 +88,6 @@ func (s *Scanner) Start() {
s.logger.Error("Scanner error") s.logger.Error("Scanner error")
return return
} }
time.Sleep(s.pollDelay)
} }
} }
} }
@@ -69,11 +96,26 @@ func (s *Scanner) Start() {
func (s *Scanner) Stop() { func (s *Scanner) Stop() {
close(s.stopCh) close(s.stopCh)
time.Sleep(150 * time.Millisecond)
err := s.file.Close() if s.cmd != nil && s.cmd.Process != nil {
if err != nil { s.logger.Info("Stopping process", "pid", s.cmd.Process.Pid)
s.logger.Error("Failed to close file") 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) close(s.ch)
} }

View File

@@ -6,48 +6,67 @@ import (
"time" "time"
) )
func TestNewScanner(t *testing.T) { func TestNewScannerTail(t *testing.T) {
file, err := os.CreateTemp("", "test.log")
file, err := os.CreateTemp("", "test-*.log")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer file.Close()
defer os.Remove(file.Name()) defer os.Remove(file.Name())
s, err := NewScanner(file.Name()) file.Close()
scanner, err := NewScannerTail(file.Name())
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("NewScannerTail() error = %v", err)
} }
if s == nil {
if scanner == nil {
t.Fatal("Scanner is 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 { tests := []struct {
name string name string
input string lines []string
wantErr bool
wantLines int wantLines int
}{ }{
{ {
name: "correct file", name: "multiple lines",
input: `Failed password for root from 192.168.1.1 lines: []string{
Invalid user admin from 192.168.1.1 "Failed password for root from 192.168.1.1",
Accepted publickey for user from 192.168.1.2`, "Invalid user admin from 192.168.1.2",
wantErr: false, "Accepted publickey for user from 192.168.1.3",
},
wantLines: 3, wantLines: 3,
}, },
{ {
name: "empty file", name: "single line",
input: "", lines: []string{
wantErr: false, "Failed password for root",
wantLines: 0, },
wantLines: 1,
}, },
{ {
name: "single line", name: "many lines",
input: `Failed password for root`, lines: []string{
wantErr: false, "line 1",
wantLines: 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) t.Fatal(err)
} }
filePath := file.Name() filePath := file.Name()
if _, err := file.WriteString(tt.input); err != nil {
t.Fatal(err)
}
file.Close() file.Close()
defer os.Remove(filePath) defer os.Remove(filePath)
scanner, err := NewScanner(filePath) scanner, err := NewScannerTail(filePath)
if (err != nil) != tt.wantErr { if err != nil {
t.Errorf("NewScanner() error = %v, wantErr %v", err, tt.wantErr) t.Fatalf("NewScannerTail() error = %v", err)
return
}
if tt.wantErr {
return
} }
defer scanner.Stop() defer scanner.Stop()
scanner.Start() scanner.Start()
timeout := time.After(500 * time.Millisecond) time.Sleep(200 * time.Millisecond)
linesRead := 0
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 { for {
select { select {
case event := <-scanner.Events(): case event := <-scanner.Events():
linesRead++ events = append(events, event)
t.Logf("Read: %s", event.Data) t.Logf("Read: %s", event.Data)
case <-timeout:
if linesRead != tt.wantLines { if len(events) == tt.wantLines {
t.Errorf("got %d lines, want %d", linesRead, 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()
}
}

54
internal/parser/sshd.go Normal file
View File

@@ -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",
)
}
}()
}