17 Commits

Author SHA1 Message Date
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
27 changed files with 627 additions and 77 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

72
.goreleaser.yml Normal file
View File

@@ -0,0 +1,72 @@
# 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-{{ .Version }}-{{ .Os }}-{{ .Arch }}
ignore:
- goos: windows
- goos: darwin
- goos: freebsd
goos:
- linux
goarch:
- amd64
- arm64
env:
- CGO_ENABLED=0
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()
@@ -56,7 +62,15 @@ var DaemonCmd = &cobra.Command{
}() }()
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)
@@ -77,6 +91,7 @@ var DaemonCmd = &cobra.Command{
} }
go pars.Start() go pars.Start()
defer pars.Stop()
go func(p *parser.Scanner, serviceName string) { go func(p *parser.Scanner, serviceName string) {
log.Info("Starting nginx parser", "service", serviceName) log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser() ng := parser.NewNginxParser()
@@ -85,7 +100,7 @@ var DaemonCmd = &cobra.Command{
go storage.Write(db, resultCh) go storage.Write(db, resultCh)
}(pars, svc.Name) }(pars, svc.Name)
} }
<-ctx.Done()
select {} log.Info("Shutdown signal received")
}, },
} }

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,45 @@
# Configs
## config.toml
Main configuration file for BanForge.
Example:
```toml
[firewall]
name = "nftables"
config = "/etc/nftables.conf"
[[service]]
name = "nginx"
log_path = "/home/d3m0k1d/test.log"
enabled = true
[[service]]
name = "nginx"
log_path = "/var/log/nginx/access.log"
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.
## 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,7 +7,6 @@ const Base_config = `
[firewall] [firewall]
name = "" name = ""
config = "/etc/nftables.conf" config = "/etc/nftables.conf"
ban_time = 1200
[[service]] [[service]]
name = "nginx" name = "nginx"
@@ -18,7 +17,4 @@ enabled = true
name = "nginx" name = "nginx"
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,7 +3,6 @@ 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 {
@@ -28,4 +27,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

@@ -22,7 +22,9 @@ type Scanner struct {
} }
func NewScanner(path string) (*Scanner, error) { func NewScanner(path string) (*Scanner, error) {
file, err := os.Open(path) // #nosec G304 -- admin tool, runs as root, path controlled by operator file, err := os.Open(
path,
) // #nosec G304 -- admin tool, runs as root, path controlled by operator
if err != nil { if err != nil {
return nil, err return nil, err
} }

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);