22 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
d3m0k1d
9c3c0dbeaa docs: add config.md content
All checks were successful
build / build (push) Successful in 2m46s
CD - BanForge Release / release (push) Successful in 3m39s
2026-01-20 17:15:24 +03:00
d3m0k1d
46dc54f5a7 chore: add new formatter to .golangci.yml
All checks were successful
build / build (push) Successful in 2m23s
2026-01-19 18:57:11 +03:00
d3m0k1d
a1321300cb docs: Add Readme.md tag bage
All checks were successful
build / build (push) Successful in 2m23s
2026-01-19 18:04:23 +03:00
d3m0k1d
9eb1fa36c4 docs: add to readme status bage
All checks were successful
build / build (push) Successful in 2m22s
2026-01-19 17:56:47 +03:00
d3m0k1d
c954e929c8 fix: add delete ban from table after unban
All checks were successful
CI.yml / build (push) Successful in 2m21s
2026-01-19 16:22:47 +03:00
d3m0k1d
1225c9323a docs: add ttl flags to cli.md
All checks were successful
CI.yml / build (push) Successful in 2m21s
2026-01-19 16:07:07 +03:00
d3m0k1d
847002129d feat: Add bantime and goroutines for unban expires ban
All checks were successful
CI.yml / build (push) Successful in 2m24s
2026-01-19 16:03:12 +03:00
d3m0k1d
6f24088069 docs: upd
All checks were successful
CI.yml / build (push) Successful in 2m22s
2026-01-17 17:39:59 +03:00
d3m0k1d
03305a06f6 tests: add test for judge logic and test for new db function
All checks were successful
CI.yml / build (push) Successful in 2m5s
2026-01-16 03:15:33 +03:00
d3m0k1d
31184e009b feat: add new cli command for output banning ip table
All checks were successful
CI.yml / build (push) Successful in 2m0s
2026-01-16 02:41:37 +03:00
d3m0k1d
914168f80f chore: add skip tlas false
All checks were successful
CD - BanForge Release / release (push) Successful in 3m27s
CI.yml / build (push) Successful in 2m6s
2026-01-16 01:31:53 +03:00
d3m0k1d
3a61371e58 chore: Add gitea urls 2026-01-16 01:31:26 +03:00
d3m0k1d
d7d49ec0ed chore: delete gpg on release
Some checks failed
CI.yml / build (push) Successful in 2m37s
CD - BanForge Release / release (push) Has been cancelled
2026-01-16 01:25:19 +03:00
d3m0k1d
59e4393e82 fix: fix release
Some checks failed
CI.yml / build (push) Successful in 2m33s
CD - BanForge Release / release (push) Failing after 2m1s
2026-01-16 01:21:07 +03:00
d3m0k1d
bd73ba24e8 chore: fix cd
Some checks failed
CI.yml / build (push) Successful in 2m18s
CD - BanForge Release / release (push) Failing after 1m39s
2026-01-16 01:10:26 +03:00
d3m0k1d
28d1410d62 chore: upd gitignore add goreleaser and openrc script
Some checks failed
CI.yml / build (push) Successful in 2m40s
CD - BanForge Release / release (push) Failing after 3m53s
2026-01-16 00:53:20 +03:00
d3m0k1d
680973df3d feat: daemon add ctx and done signal, judge fix problem with double ban ip, db add new methods
All checks were successful
CI.yml / build (push) Successful in 1m58s
2026-01-15 22:32:03 +03:00
29 changed files with 1025 additions and 142 deletions

View File

@@ -13,55 +13,33 @@ jobs:
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - name: Install syft
- name: Create Release run: curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
env: - name: Checkout
TOKEN: ${{ secrets.TOKEN }} uses: actions/checkout@v6
run: | - name: Go setup
TAG="${{ gitea.ref_name }}" uses: actions/setup-go@v6
REPO="${{ gitea.repository }}"
SERVER="${{ gitea.server_url }}"
curl -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"tag_name": "'$TAG'",
"name": "Release '$TAG'",
"body": "# BanForge '$TAG'\n\nIntrusion Prevention System",
"draft": false,
"prerelease": false
}' \
"$SERVER/api/v1/repos/$REPO/releases"
build:
needs: release
strategy:
matrix:
include:
- goos: linux
arch: amd64
- goos: linux
arch: arm64
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with: with:
go-version: '1.25' go-version: '1.25'
cache: false cache: false
- run: go mod tidy - name: Install deps
- run: go test ./... run: go mod tidy
- name: Build ${{ matrix.goos }}-${{ matrix.arch }} - name: Golangci-lint
uses: golangci/golangci-lint-action@v9.2.0
with:
args: --timeout=5m
skip-cache: true
- name: Run tests
run: go test ./...
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env: env:
GOOS: ${{ matrix.goos }} GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }}
GOARCH: ${{ matrix.arch }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
run: go build -o banforge-${{ matrix.goos }}-${{ matrix.arch }} ./cmd/banforge GITEA_TOKEN: ${{ secrets.TOKEN }}
- name: Upload ${{ matrix.goos }}-${{ matrix.arch }}
env:
TOKEN: ${{ secrets.TOKEN }}
run: |
TAG="${{ gitea.ref_name }}"
FILE="banforge-${{ matrix.goos }}-${{ matrix.arch }}"
curl --user d3m0k1d:$TOKEN \
--upload-file $FILE \
https://gitea.d3m0k1d.ru/api/packages/d3m0k1d/generic/banforge/$TAG/$FILE

View File

@@ -1,4 +1,4 @@
name: CI.yml name: build
on: on:
push: push:

1
.gitignore vendored
View File

@@ -1 +1,2 @@
bin/ bin/
dist/

View File

@@ -12,10 +12,12 @@ linters:
- govet - govet
- staticcheck - staticcheck
- gosec - gosec
- nilerr
formatters: formatters:
enable: enable:
- gofmt - gofmt
- goimports - goimports
- golines

70
.goreleaser.yml Normal file
View File

@@ -0,0 +1,70 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: BanForge
gitea_urls:
api: https://gitea.d3m0k1d.ru/api/v1
download: https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download
skip_tls_verify: false
builds:
- id: banforge
main: ./cmd/banforge/main.go
binary: banforge
ignore:
- goos: windows
- goos: darwin
- goos: freebsd
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- "-s -w"
archives:
- format: tar.gz
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
nfpms:
- id: banforge
package_name: banforge
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
homepage: https://gitea.d3m0k1d.ru/d3m0k1d/BanForge
description: BanForge IPS log-based system
maintainer: d3m0k1d <contact@d3m0k1d.ru>
license: GPLv3.0
formats:
- apk
- deb
- rpm
- archlinux
bindir: /usr/bin
release:
gitea:
owner: d3m0k1d
name: BanForge
mode: keep-existing
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
sboms:
- artifacts: archive
documents:
- "{{ .ArtifactName }}.spdx.json"
cmd: syft
args: ["$artifact", "--output", "spdx-json=$document"]

View File

@@ -28,3 +28,6 @@ test:
test-cover: test-cover:
go test -cover ./... go test -cover ./...
lint:
golangci-lint run --fix

View File

@@ -4,6 +4,8 @@ Log-based IPS system written in Go for Linux-based system.
[![Go Reference](https://pkg.go.dev/badge/github.com/d3m0k1d/BanForge/cmd/banforge.svg)](https://pkg.go.dev/github.com/d3m0k1d/BanForge) [![Go Reference](https://pkg.go.dev/badge/github.com/d3m0k1d/BanForge/cmd/banforge.svg)](https://pkg.go.dev/github.com/d3m0k1d/BanForge)
[![License](https://img.shields.io/badge/license-%20%20GNU%20GPLv3%20-green?style=plastic)](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE) [![License](https://img.shields.io/badge/license-%20%20GNU%20GPLv3%20-green?style=plastic)](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
[![Build Status](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/actions/workflows/CI.yml/badge.svg?branch=master)](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/actions)
![GitHub Tag](https://img.shields.io/github/v/tag/d3m0k1d/BanForge)
# Table of contents # Table of contents
1. [Overview](#overview) 1. [Overview](#overview)
2. [Requirements](#requirements) 2. [Requirements](#requirements)

21
build/banforge Normal file
View File

@@ -0,0 +1,21 @@
#!/sbin/openrc-run
description="BanForge - IPS log based system"
command="/usr/bin/banforge"
command_args="daemon"
pidfile="/run/${RC_SVCNAME}.pid"
command_background="yes"
depend() {
need net
after network
}
start_post() {
einfo "BanForge is now running"
}
stop_post() {
einfo "BanForge is now stopped"
}

View File

@@ -13,5 +13,9 @@ Restart=always
StandardOutput=journal StandardOutput=journal
StandardError=journal StandardError=journal
SyslogIdentifier=banforge SyslogIdentifier=banforge
TimeoutStopSec=90
KillSignal=SIGTERM
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target

View File

@@ -1,7 +1,10 @@
package command package command
import ( import (
"context"
"os" "os"
"os/signal"
"syscall"
"time" "time"
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/internal/blocker"
@@ -17,6 +20,8 @@ var DaemonCmd = &cobra.Command{
Use: "daemon", Use: "daemon",
Short: "Run BanForge daemon process", Short: "Run BanForge daemon process",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
log := logger.New(false) log := logger.New(false)
log.Info("Starting BanForge daemon") log.Info("Starting BanForge daemon")
db, err := storage.NewDB() db, err := storage.NewDB()
@@ -45,6 +50,7 @@ var DaemonCmd = &cobra.Command{
} }
j := judge.New(db, b) j := judge.New(db, b)
j.LoadRules(r) j.LoadRules(r)
go j.UnbanChecker()
go func() { go func() {
ticker := time.NewTicker(5 * time.Second) ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop() defer ticker.Stop()
@@ -55,37 +61,95 @@ var DaemonCmd = &cobra.Command{
} }
}() }()
var scanners []*parser.Scanner
for _, svc := range cfg.Service { for _, svc := range cfg.Service {
log.Info("Processing service", "name", svc.Name, "enabled", svc.Enabled, "path", svc.LogPath) log.Info(
"Processing service",
"name", svc.Name,
"enabled", svc.Enabled,
"path", svc.LogPath,
)
if !svc.Enabled { if !svc.Enabled {
log.Info("Service disabled, skipping", "name", svc.Name) log.Info("Service disabled, skipping", "name", svc.Name)
continue continue
} }
if svc.Name != "nginx" { log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
log.Info("Only nginx supported, skipping", "name", svc.Name) if svc.Logging != "file" && svc.Logging != "journald" {
log.Error("Invalid logging type", "type", svc.Logging)
continue continue
} }
log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath) if svc.Logging == "file" {
log.Info("Logging to file", "path", svc.LogPath)
pars, err := parser.NewScanner(svc.LogPath) pars, err := parser.NewScannerTail(svc.LogPath)
if err != nil { if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err) log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue continue
} }
scanners = append(scanners, pars)
go pars.Start() go pars.Start()
go func(p *parser.Scanner, serviceName string) { go func(p *parser.Scanner, serviceName string) {
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName) log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser() ng := parser.NewNginxParser()
resultCh := make(chan *storage.LogEntry, 100) resultCh := make(chan *storage.LogEntry, 100)
ng.Parse(p.Events(), resultCh) ng.Parse(p.Events(), resultCh)
go storage.Write(db, 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) }(pars, svc.Name)
continue
} }
select {} 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()
}
}, },
} }

View File

@@ -0,0 +1,27 @@
package command
import (
"os"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra"
)
var BanListCmd = &cobra.Command{
Use: "list",
Short: "List banned IP adresses",
Run: func(cmd *cobra.Command, args []string) {
var log = logger.New(false)
d, err := storage.NewDB()
if err != nil {
log.Error("Failed to create database", "error", err)
os.Exit(1)
}
err = d.BanList()
if err != nil {
log.Error("Failed to get ban list", "error", err)
os.Exit(1)
}
},
}

View File

@@ -14,6 +14,7 @@ var (
path string path string
status string status string
method string method string
ttl string
) )
var RuleCmd = &cobra.Command{ var RuleCmd = &cobra.Command{
@@ -37,8 +38,10 @@ var AddCmd = &cobra.Command{
fmt.Printf("At least 1 rule field must be filled in.") fmt.Printf("At least 1 rule field must be filled in.")
os.Exit(1) os.Exit(1)
} }
if ttl == "" {
err := config.NewRule(name, service, path, status, method) ttl = "1y"
}
err := config.NewRule(name, service, path, status, method, ttl)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -57,7 +60,14 @@ var ListCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
for _, rule := range r { for _, rule := range r {
fmt.Printf("Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n", rule.Name, rule.ServiceName, rule.Path, rule.Status, rule.Method) fmt.Printf(
"Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n",
rule.Name,
rule.ServiceName,
rule.Path,
rule.Status,
rule.Method,
)
} }
}, },
} }
@@ -68,6 +78,7 @@ func RuleRegister() {
AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)") AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)")
AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name") AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name")
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path") AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
AddCmd.Flags().StringVarP(&status, "status", "c", "", "HTTP status code") AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method") AddCmd.Flags().StringVarP(&method, "method", "m", "", "method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time")
} }

View File

@@ -27,6 +27,7 @@ func Execute() {
rootCmd.AddCommand(command.RuleCmd) rootCmd.AddCommand(command.RuleCmd)
rootCmd.AddCommand(command.BanCmd) rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd) rootCmd.AddCommand(command.UnbanCmd)
rootCmd.AddCommand(command.BanListCmd)
command.RuleRegister() command.RuleRegister()
command.FwRegister() command.FwRegister()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {

View File

@@ -31,6 +31,14 @@ banforge unban <ip>
**Description** **Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands. These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
### list - Lists the IP addresses that are currently blocked
```shell
banforge list
```
**Description**
This command output table of IP addresses that are currently blocked
### rule - Manages detection rules ### rule - Manages detection rules
```shell ```shell
@@ -48,4 +56,6 @@ These command help you to create and manage detection rules in CLI interface.
| -p -path | - | | -p -path | - |
| -m -method | - | | -m -method | - |
| -c -status | - | | -c -status | - |
| -t -ttl | -(if not used default ban 1 year) |
You must specify at least 1 of the optional flags to create a rule. You must specify at least 1 of the optional flags to create a rule.

View File

@@ -0,0 +1,48 @@
# Configs
## config.toml
Main configuration file for BanForge.
Example:
```toml
[firewall]
name = "nftables"
config = "/etc/nftables.conf"
[[service]]
name = "nginx"
logging = "file"
log_path = "/home/d3m0k1d/test.log"
enabled = true
[[service]]
name = "nginx"
logging = "journald"
log_path = "nginx"
enabled = false
```
**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 [[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 configuration file for BanForge.
If you wanna configure rules by cli command see [here](https://github.com/d3m0k1d/BanForge/blob/main/docs/cli.md)
Example:
```toml
[[rule]]
name = "304 http"
service = "nginx"
path = ""
status = "304"
method = ""
ban_time = "1m"
```
**Description**
The [[rule]] section require name and one of the following parameters: service, path, status, method. To add a rule, create a [[rule]] block and specify the parameters.
ban_time require in format "1m", "1h", "1d", "1M", "1y"

5
go.mod
View File

@@ -4,11 +4,16 @@ go 1.25.5
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/jedib0t/go-pretty/v6 v6.7.8
github.com/mattn/go-sqlite3 v1.14.33 github.com/mattn/go-sqlite3 v1.14.33
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
) )
require ( require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
) )

19
go.sum
View File

@@ -1,15 +1,34 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,6 +3,9 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -22,7 +25,14 @@ func LoadRuleConfig() ([]Rule, error) {
return cfg.Rules, nil return cfg.Rules, nil
} }
func NewRule(Name string, ServiceName string, Path string, Status string, Method string) error { func NewRule(
Name string,
ServiceName string,
Path string,
Status string,
Method string,
ttl string,
) error {
r, err := LoadRuleConfig() r, err := LoadRuleConfig()
if err != nil { if err != nil {
r = []Rule{} r = []Rule{}
@@ -31,7 +41,17 @@ func NewRule(Name string, ServiceName string, Path string, Status string, Method
fmt.Printf("Rule name can't be empty\n") fmt.Printf("Rule name can't be empty\n")
return nil return nil
} }
r = append(r, Rule{Name: Name, ServiceName: ServiceName, Path: Path, Status: Status, Method: Method}) r = append(
r,
Rule{
Name: Name,
ServiceName: ServiceName,
Path: Path,
Status: Status,
Method: Method,
BanTime: ttl,
},
)
file, err := os.Create("/etc/banforge/rules.toml") file, err := os.Create("/etc/banforge/rules.toml")
if err != nil { if err != nil {
return err return err
@@ -104,3 +124,31 @@ func EditRule(Name string, ServiceName string, Path string, Status string, Metho
return nil return nil
} }
func ParseDurationWithYears(s string) (time.Duration, error) {
if strings.HasSuffix(s, "y") {
years, err := strconv.Atoi(strings.TrimSuffix(s, "y"))
if err != nil {
return 0, err
}
return time.Duration(years) * 365 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "M") {
months, err := strconv.Atoi(strings.TrimSuffix(s, "M"))
if err != nil {
return 0, err
}
return time.Duration(months) * 30 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}

View File

@@ -7,18 +7,16 @@ const Base_config = `
[firewall] [firewall]
name = "" name = ""
config = "/etc/nftables.conf" config = "/etc/nftables.conf"
ban_time = 1200
[[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
` `
// TODO: fix types for use 1 or any services"

View File

@@ -3,11 +3,11 @@ package config
type Firewall struct { type Firewall struct {
Name string `toml:"name"` Name string `toml:"name"`
Config string `toml:"config"` Config string `toml:"config"`
BanTime int `toml:"ban_time"`
} }
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"`
} }
@@ -28,4 +28,5 @@ type Rule struct {
Path string `toml:"path"` Path string `toml:"path"`
Status string `toml:"status"` Status string `toml:"status"`
Method string `toml:"method"` Method string `toml:"method"`
BanTime string `toml:"ban_time"`
} }

View File

@@ -2,6 +2,7 @@ package judge
import ( import (
"fmt" "fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config" "github.com/d3m0k1d/BanForge/internal/config"
@@ -48,10 +49,18 @@ func (j *Judge) ProcessUnviewed() error {
j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err)) j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err))
} }
}() }()
for rows.Next() { for rows.Next() {
var entry storage.LogEntry var entry storage.LogEntry
err = rows.Scan(&entry.ID, &entry.Service, &entry.IP, &entry.Path, &entry.Status, &entry.Method, &entry.IsViewed, &entry.CreatedAt) err = rows.Scan(
&entry.ID,
&entry.Service,
&entry.IP,
&entry.Path,
&entry.Status,
&entry.Method,
&entry.IsViewed,
&entry.CreatedAt,
)
if err != nil { if err != nil {
j.logger.Error(fmt.Sprintf("Failed to scan database row: %v", err)) j.logger.Error(fmt.Sprintf("Failed to scan database row: %v", err))
continue continue
@@ -64,12 +73,29 @@ func (j *Judge) ProcessUnviewed() error {
(rule.Status == "" || entry.Status == rule.Status) && (rule.Status == "" || entry.Status == rule.Status) &&
(rule.Path == "" || entry.Path == rule.Path) { (rule.Path == "" || entry.Path == rule.Path) {
j.logger.Info(fmt.Sprintf("Rule matched for IP: %s, Service: %s", entry.IP, entry.Service)) j.logger.Info(
fmt.Sprintf(
"Rule matched for IP: %s, Service: %s",
entry.IP,
entry.Service,
),
)
ban_status, err := j.db.IsBanned(entry.IP)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check ban status: %v", err))
return err
}
if !ban_status {
err = j.Blocker.Ban(entry.IP) err = j.Blocker.Ban(entry.IP)
if err != nil { if err != nil {
j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err)) j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err))
} }
j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP)) j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP))
err = j.db.AddBan(entry.IP, rule.BanTime)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to add ban: %v", err))
}
}
break break
} }
} }
@@ -90,3 +116,24 @@ func (j *Judge) ProcessUnviewed() error {
return nil return nil
} }
func (j *Judge) UnbanChecker() {
tick := time.NewTicker(5 * time.Minute)
defer tick.Stop()
for range tick.C {
ips, err := j.db.CheckExpiredBans()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
continue
}
for _, ip := range ips {
if err := j.Blocker.Unban(ip); err != nil {
j.logger.Error(fmt.Sprintf("Failed to unban IP %s: %v", ip, err))
continue
}
j.logger.Info(fmt.Sprintf("IP unbanned: %s", ip))
}
}
}

View File

@@ -0,0 +1,60 @@
package judge
import (
"testing"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/storage"
)
func TestJudgeLogic(t *testing.T) {
tests := []struct {
name string
inputRule config.Rule
inputLog storage.LogEntry
wantErr bool
wantMatch bool
}{
{
name: "Empty rule",
inputRule: config.Rule{Name: "", ServiceName: "", Path: "", Status: "", Method: ""},
inputLog: storage.LogEntry{ID: 0, Service: "nginx", IP: "127.0.0.1", Path: "/api", Status: "200", Method: "GET", IsViewed: false, CreatedAt: ""},
wantErr: true,
wantMatch: false,
},
{
name: "Matching rule",
inputRule: config.Rule{Name: "test", ServiceName: "nginx", Path: "/api", Status: "200", Method: "GET"},
inputLog: storage.LogEntry{ID: 1, Service: "nginx", IP: "127.0.0.1", Path: "/api", Status: "200", Method: "GET", IsViewed: false, CreatedAt: ""},
wantErr: false,
wantMatch: true,
},
{
name: "Non-matching status",
inputRule: config.Rule{Name: "test", ServiceName: "nginx", Path: "/api", Status: "404", Method: "GET"},
inputLog: storage.LogEntry{ID: 2, Service: "nginx", IP: "127.0.0.1", Path: "/api", Status: "200", Method: "GET", IsViewed: false, CreatedAt: ""},
wantErr: false,
wantMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.inputRule.Name == "" {
if !tt.wantErr {
t.Errorf("Expected error for empty rule name, but got none")
}
return
}
result := (tt.inputRule.Method == "" || tt.inputLog.Method == tt.inputRule.Method) &&
(tt.inputRule.Status == "" || tt.inputLog.Status == tt.inputRule.Status) &&
(tt.inputRule.Path == "" || tt.inputLog.Path == tt.inputRule.Path) &&
(tt.inputRule.ServiceName == "" || tt.inputLog.Service == tt.inputRule.ServiceName)
if result != tt.wantMatch {
t.Errorf("Expected error: %v, but got: %v", tt.wantErr, result)
}
})
}
}

View File

@@ -42,7 +42,17 @@ func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEn
Method: method, Method: method,
IsViewed: false, IsViewed: false,
} }
p.logger.Info("Parsed nginx log entry", "ip", matches[1], "path", path, "status", status, "method", method) p.logger.Info(
"Parsed nginx log entry",
"ip",
matches[1],
"path",
path,
"status",
status,
"method",
method,
)
} }
}() }()
} }

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,22 +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(path) // #nosec G304 -- admin tool, runs as root, path controlled by operator cmd := exec.Command("tail", "-F", "-n", "10", path)
stdout, err := cmd.StdoutPipe()
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
} }
@@ -58,7 +88,6 @@ func (s *Scanner) Start() {
s.logger.Error("Scanner error") s.logger.Error("Scanner error")
return return
} }
time.Sleep(s.pollDelay)
} }
} }
} }
@@ -67,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 {
s.logger.Info("Stopping process", "pid", s.cmd.Process.Pid)
err := s.cmd.Process.Kill()
if err != nil { if err != nil {
s.logger.Error("Failed to close file") 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",
)
}
}()
}

View File

@@ -2,8 +2,14 @@ package storage
import ( import (
"database/sql" "database/sql"
"os"
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
"github.com/jedib0t/go-pretty/v6/table"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -13,7 +19,10 @@ type DB struct {
} }
func NewDB() (*DB, error) { func NewDB() (*DB, error) {
db, err := sql.Open("sqlite3", "/var/lib/banforge/storage.db?mode=rwc&_journal_mode=WAL&_busy_timeout=10000&cache=shared") db, err := sql.Open(
"sqlite3",
"/var/lib/banforge/storage.db?mode=rwc&_journal_mode=WAL&_busy_timeout=10000&cache=shared",
)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -46,7 +55,9 @@ func (d *DB) CreateTable() error {
} }
func (d *DB) SearchUnViewed() (*sql.Rows, error) { func (d *DB) SearchUnViewed() (*sql.Rows, error) {
rows, err := d.db.Query("SELECT id, service, ip, path, status, method, viewed, created_at FROM requests WHERE viewed = 0") rows, err := d.db.Query(
"SELECT id, service, ip, path, status, method, viewed, created_at FROM requests WHERE viewed = 0",
)
if err != nil { if err != nil {
d.logger.Error("Failed to query database") d.logger.Error("Failed to query database")
return nil, err return nil, err
@@ -62,3 +73,96 @@ func (d *DB) MarkAsViewed(id int) error {
} }
return nil return nil
} }
func (d *DB) IsBanned(ip string) (bool, error) {
var bannedIP string
err := d.db.QueryRow("SELECT ip FROM bans WHERE ip = ? ", ip).Scan(&bannedIP)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check ban status: %w", err)
}
return true, nil
}
func (d *DB) AddBan(ip string, ttl string) error {
duration, err := config.ParseDurationWithYears(ttl)
if err != nil {
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
return fmt.Errorf("invalid duration: %w", err)
}
now := time.Now()
expiredAt := now.Add(duration)
_, err = d.db.Exec(
"INSERT INTO bans (ip, reason, banned_at, expired_at) VALUES (?, ?, ?, ?)",
ip,
"1",
now.Format(time.RFC3339),
expiredAt.Format(time.RFC3339),
)
if err != nil {
d.logger.Error("Failed to add ban", "error", err)
return err
}
return nil
}
func (d *DB) BanList() error {
var count int
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.SetStyle(table.StyleBold)
t.AppendHeader(table.Row{"№", "IP", "Banned At"})
rows, err := d.db.Query("SELECT ip, banned_at FROM bans")
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
return err
}
for rows.Next() {
count++
var ip string
var bannedAt string
err := rows.Scan(&ip, &bannedAt)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
return err
}
t.AppendRow(table.Row{count, ip, bannedAt})
}
t.Render()
return nil
}
func (d *DB) CheckExpiredBans() ([]string, error) {
var ips []string
rows, err := d.db.Query(
"SELECT ip FROM bans WHERE expired_at < ?",
time.Now().Format(time.RFC3339),
)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
return nil, err
}
for rows.Next() {
var ip string
r, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
return nil, err
}
d.logger.Info("Ban removed", "ip", ip, "rows", r)
err = rows.Scan(&ip)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
return nil, err
}
ips = append(ips, ip)
}
return ips, nil
}

View File

@@ -167,6 +167,72 @@ func TestSearchUnViewed(t *testing.T) {
} }
} }
func TestIsBanned(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
_, err = d.db.Exec("INSERT INTO bans (ip, banned_at) VALUES (?, ?)", "127.0.0.1", time.Now().Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
isBanned, err := d.IsBanned("127.0.0.1")
if err != nil {
t.Fatal(err)
}
if !isBanned {
t.Fatal("should be banned")
}
}
func TestAddBan(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
err = d.AddBan("127.0.0.1", "7h")
if err != nil {
t.Fatal(err)
}
var ip string
err = d.db.QueryRow("SELECT ip FROM bans WHERE ip = ?", "127.0.0.1").Scan(&ip)
if err != nil {
t.Fatal(err)
}
if ip != "127.0.0.1" {
t.Fatal("ip should be 127.0.0.1")
}
}
func TestBanList(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
_, err = d.db.Exec("INSERT INTO bans (ip, banned_at) VALUES (?, ?)", "127.0.0.1", time.Now().Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
err = d.BanList()
if err != nil {
t.Fatal(err)
}
}
func TestClose(t *testing.T) { func TestClose(t *testing.T) {
d := createTestDBStruct(t) d := createTestDBStruct(t)

View File

@@ -17,7 +17,8 @@ CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ip TEXT UNIQUE NOT NULL, ip TEXT UNIQUE NOT NULL,
reason TEXT, reason TEXT,
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expired_at DATETIME
); );
CREATE INDEX IF NOT EXISTS idx_service ON requests(service); CREATE INDEX IF NOT EXISTS idx_service ON requests(service);