63 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
d3m0k1d
1603fbee35 feat: add simple setup func to blockerengine, fix init and db, version for realease v0.2.0
All checks were successful
CD - BanForge Release / release (push) Successful in 20s
CI.yml / build (push) Successful in 2m1s
CD - BanForge Release / build (amd64, linux) (push) Successful in 3m3s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m52s
2026-01-15 19:14:44 +03:00
d3m0k1d
bbb152dfb8 docs: typo and update readme.md
All checks were successful
CI.yml / build (push) Successful in 1m56s
2026-01-15 18:16:54 +03:00
d3m0k1d
a7b79d0e27 docs: typo
All checks were successful
CI.yml / build (push) Successful in 1m55s
2026-01-15 18:14:04 +03:00
d3m0k1d
eaf276bd3f docs: Add new docs and fix rule command
All checks were successful
CI.yml / build (push) Successful in 1m53s
2026-01-15 18:06:48 +03:00
d3m0k1d
14c6c64989 tests: update makefile and add test for validators and writter
All checks were successful
CI.yml / build (push) Successful in 2m20s
2026-01-15 17:27:46 +03:00
d3m0k1d
623bd87b4c tests: Add tests for storage package
All checks were successful
CI.yml / build (push) Successful in 1m54s
2026-01-15 17:01:49 +03:00
d3m0k1d
7d9645b3e3 refactoring(cmd/banforge/main.go): command logic on command dir in different files
All checks were successful
CI.yml / build (push) Successful in 1m49s
2026-01-14 21:52:13 +03:00
d3m0k1d
bf6ff50da8 fix: fix go bage url
All checks were successful
CI.yml / build (push) Successful in 1m47s
2026-01-14 20:56:44 +03:00
d3m0k1d
85f6919bda docs: Add bages to readme
All checks were successful
CI.yml / build (push) Successful in 1m48s
2026-01-14 20:54:42 +03:00
d3m0k1d
7a7f57f5ae feat: add new command to control firewall in banfogre interface
All checks were successful
CI.yml / build (push) Successful in 1m44s
2026-01-14 17:47:29 +03:00
d3m0k1d
36508201ad feat: Add rule control command to cli interface
All checks were successful
CI.yml / build (push) Successful in 1m46s
2026-01-14 17:20:08 +03:00
d3m0k1d
3cb9bcbcf3 docs(README.md): update docs for first realease version
All checks were successful
CI.yml / build (push) Successful in 1m51s
2026-01-14 15:32:26 +03:00
d3m0k1d
8b6dc88233 chore: fix cd
All checks were successful
CD - BanForge Release / release (push) Successful in 28s
CI.yml / build (push) Successful in 3m43s
CD - BanForge Release / build (amd64, linux) (push) Successful in 3m8s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m8s
2026-01-14 14:40:48 +03:00
d3m0k1d
511b708737 chore: fix cd from fratifact to generic pakage
All checks were successful
CD - BanForge Release / release (push) Successful in 31s
CI.yml / build (push) Successful in 3m24s
CD - BanForge Release / build (amd64, linux) (push) Successful in 2m53s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m41s
2026-01-14 14:21:31 +03:00
d3m0k1d
803e9db7b4 chore: fix one more time
All checks were successful
CI.yml / build (push) Successful in 1m41s
2026-01-14 01:45:43 +03:00
d3m0k1d
12c40a5748 chore: Add upload artifacts
All checks were successful
CI.yml / build (push) Successful in 1m45s
2026-01-14 01:41:36 +03:00
d3m0k1d
24fe951e49 fix: judge creator, daemon logic
All checks were successful
CI.yml / build (push) Successful in 1m45s
feat: first version for alpha test daemon on server

fix: add second template for fix bug with slice

Fix: add chek if path exists

Fix: template one more time

feat: Add file db on init command

feat: add create dit

feat: Add to init command create table to db

feat: Add new logs for debug on server

feat: Add CD, first release version

chore:fix cd

fix: change artifact ver from v4->v2

fix: ci one more time

fix: ci
2026-01-14 01:21:30 +03:00
d3m0k1d
2d699af630 feat: add base daemon cli command
Some checks failed
CI.yml / build (push) Failing after 1m37s
2026-01-13 21:28:16 +03:00
d3m0k1d
17faaa5c27 Fix errchecl
All checks were successful
CI.yml / build (push) Successful in 1m45s
2026-01-13 21:03:50 +03:00
d3m0k1d
f0180b4bbe feat: fix db and recode judge 2026-01-13 21:03:10 +03:00
d3m0k1d
b2d03a4008 feat: Add simple systemd unit
All checks were successful
CI.yml / build (push) Successful in 1m47s
2026-01-13 19:30:24 +03:00
d3m0k1d
95a58dc780 feat: add new block judge
All checks were successful
CI.yml / build (push) Successful in 1m48s
2026-01-13 19:08:11 +03:00
d3m0k1d
0421d9ef40 Fix: fix db migrations and add new row viewed
All checks were successful
CI.yml / build (push) Successful in 1m54s
2026-01-13 18:22:15 +03:00
d3m0k1d
5362761b82 feat: add new logic for rule based bans
All checks were successful
CI.yml / build (push) Successful in 1m51s
2026-01-13 18:02:22 +03:00
d3m0k1d
9767bb70f1 feat: recode NginxParser, add writer to db
All checks were successful
CI.yml / build (push) Successful in 1m54s
2026-01-13 17:26:53 +03:00
d3m0k1d
b63da17043 Feat: add storage block(first methods to db, migrations, models) add nginx parser with regular expression, add to deps sqlite driver
All checks were successful
CI.yml / build (push) Successful in 1m51s
2026-01-13 16:53:46 +03:00
d3m0k1d
fb66a23e33 chore: add .gitignore for bin/ dir
All checks were successful
CI.yml / build (push) Successful in 48s
2026-01-13 14:58:18 +03:00
d3m0k1d
db9c94f2c5 Delete: bin after test
All checks were successful
CI.yml / build (push) Successful in 47s
2026-01-13 14:53:32 +03:00
d3m0k1d
72018eb69e feat: Rename and set as method NftablesSetup -> Setup, fix template and types config, add create template config in system, update logic finds firewalls on system, add BurntSushi/toml as dependencies 2026-01-13 14:53:16 +03:00
d3m0k1d
9e9505e8d5 refactoring(nftables): recode logic setup table and chains
All checks were successful
CI.yml / build (push) Successful in 44s
2026-01-13 13:58:47 +03:00
d3m0k1d
11eac77f5b Clean code after fucking AI
All checks were successful
CI.yml / build (push) Successful in 44s
2026-01-13 13:31:44 +03:00
d3m0k1d
3732ef21d9 Delete: typo
All checks were successful
CI.yml / build (push) Successful in 45s
2026-01-13 13:24:41 +03:00
Ilya Chernishev
06ded14fb4 Delete internal/blocker/factory.go
Some checks failed
CI.yml / build (push) Failing after 37s
2026-01-12 18:01:19 +03:00
Ilya Chernishev
1c9a1f2d3e docs: add example of using BlockerFactory with different firewall engines
Some checks failed
CI.yml / build (push) Failing after 39s
2026-01-12 17:51:46 +03:00
Ilya Chernishev
74dd666ff6 feat: add BlockerFactory for flexible blocker instantiation 2026-01-12 17:51:37 +03:00
Ilya Chernishev
e4b9993748 refactor: implement full BlockerEngine interface for UFW 2026-01-12 17:51:28 +03:00
Ilya Chernishev
9afe4ac1b9 refactor: implement full BlockerEngine interface for Nftables 2026-01-12 17:51:17 +03:00
Ilya Chernishev
dc915b1e17 refactor: expand BlockerEngine interface with Setup, List, Close methods and blocker info 2026-01-12 17:50:58 +03:00
d3m0k1d
1689340223 feat: Add nftables Ban/Unban methods(no tested)
All checks were successful
CI.yml / build (push) Successful in 47s
2026-01-12 17:03:52 +03:00
d3m0k1d
adff028281 fix(README.md): typo
All checks were successful
CI.yml / build (push) Successful in 46s
2026-01-12 16:06:58 +03:00
d3m0k1d
36dcdca210 docs: add Requirements and License block on README.md
All checks were successful
CI.yml / build (push) Successful in 48s
2026-01-12 16:04:31 +03:00
d3m0k1d
871965f437 Update path validator
All checks were successful
CI.yml / build (push) Successful in 56s
2026-01-12 15:56:28 +03:00
d3m0k1d
7c0bdc2dfa fix(security): fix gosec G204 warnings
All checks were successful
CI.yml / build (push) Successful in 55s
- Use separate arguments instead of string concat in firewall-cmd
- Add validateConfigPath() for iptables config path validation
- Blocks path traversal and restricts to trusted directories

Fixes G204 warnings.
2026-01-11 22:41:27 +03:00
d3m0k1d
41ff13fa66 Add firewalld and iptables ban realization, add firewall config file in config types
Some checks failed
CI.yml / build (push) Failing after 42s
2026-01-11 22:17:57 +03:00
d3m0k1d
99b97836ff Delete Flash and IsBanning from interface
All checks were successful
CI.yml / build (push) Successful in 48s
2026-01-11 20:40:46 +03:00
d3m0k1d
b3431d248b feat: initialize BanForge with CI/CD pipeline and linting configuration
All checks were successful
CI.yml / build (push) Successful in 49s
2026-01-11 20:33:56 +03:00
40 changed files with 2324 additions and 58 deletions

45
.gitea/workflows/CD.yml Normal file
View File

@@ -0,0 +1,45 @@
name: CD - BanForge Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Install syft
run: curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
- name: Checkout
uses: actions/checkout@v6
- name: Go setup
uses: actions/setup-go@v6
with:
go-version: '1.25'
cache: false
- name: Install deps
run: go mod tidy
- 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:
GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GITEA_TOKEN: ${{ secrets.TOKEN }}

View File

@@ -1,4 +1,4 @@
name: CI.yml name: build
on: on:
push: push:
@@ -20,8 +20,11 @@ jobs:
cache: false cache: false
- name: Install deps - name: Install deps
run: go mod tidy run: go mod tidy
- name: Run linter - name: Golangci-lint
run: golangci-lint run ./... uses: golangci/golangci-lint-action@v9.2.0
with:
args: --timeout=5m
skip-cache: true
- name: Run tests - name: Run tests
run: go test ./... run: go test ./...
- name: Build - name: Build

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
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

@@ -25,3 +25,9 @@ clean:
test: test:
go test ./... go test ./...
test-cover:
go test -cover ./...
lint:
golangci-lint run --fix

View File

@@ -1,7 +1,11 @@
# BanForge # BanForge
Log-based IPS system written in Go for Linux based system. 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)
[![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)
@@ -12,11 +16,40 @@ Log-based IPS system written in Go for Linux based system.
# Overview # Overview
BanForge is a simple IPS for replacement fail2ban in Linux system. BanForge is a simple IPS for replacement fail2ban in Linux system.
The project is currently in its early stages of development. The project is currently in its early stages of development.
All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) because Github have limit for Actions. All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) because Github has limits for Actions.
If you have any questions or suggestions, create issue on [Github](https://github.com/d3m0k1d/BanForge/issues). If you have any questions or suggestions, create issue on [Github](https://github.com/d3m0k1d/BanForge/issues).
## Roadmap ## Roadmap
- [ ] Real-time Nginx log monitoring - [x] Real-time Nginx log monitoring
- [ ] Add support for other service - [ ] Add support for other service
- [ ] Add support for user service with regular expressions - [ ] Add support for user service with regular expressions
- [ ] TUI interface - [ ] TUI interface
# Requirements
- Go 1.25+
- ufw/iptables/nftables/firewalld
# Installation
Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it. Then create or copy a systemd unit file.
Or clone the repo and use the Makefile.
```
git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git
cd BanForge
sudo make build-daemon
cd bin
```
# Usage
For first steps use this commands
```bash
banforge init # Create config files and database
banforge daemon # Start BanForge daemon (use systemd or another init system to create a service)
```
You can edit the config file with examples in
- `/etc/banforge/config.toml` main config file
- `/etc/banforge/rules.toml` ban rules
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
# License
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)

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"
}

21
build/banforge.service Normal file
View File

@@ -0,0 +1,21 @@
[Unit]
Description=BanForge - IPS log based system
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/d3m0k1d/BanForge
[Service]
Type=simple
ExecStart=/usr/local/bin/banforge daemon
User=root
Group=root
Restart=always
StandardOutput=journal
StandardError=journal
SyslogIdentifier=banforge
TimeoutStopSec=90
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,106 @@
package command
import (
"context"
"os"
"os/signal"
"syscall"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/judge"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/parser"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra"
)
var DaemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run BanForge daemon process",
Run: func(cmd *cobra.Command, args []string) {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
log := logger.New(false)
log.Info("Starting BanForge daemon")
db, err := storage.NewDB()
if err != nil {
log.Error("Failed to create database", "error", err)
os.Exit(1)
}
defer func() {
err = db.Close()
if err != nil {
log.Error("Failed to close database connection", "error", err)
}
}()
cfg, err := config.LoadConfig()
if err != nil {
log.Error("Failed to load config", "error", err)
os.Exit(1)
}
var b blocker.BlockerEngine
fw := cfg.Firewall.Name
b = blocker.GetBlocker(fw, cfg.Firewall.Config)
r, err := config.LoadRuleConfig()
if err != nil {
log.Error("Failed to load rules", "error", err)
os.Exit(1)
}
j := judge.New(db, b)
j.LoadRules(r)
go j.UnbanChecker()
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := j.ProcessUnviewed(); err != nil {
log.Error("Failed to process unviewed", "error", err)
}
}
}()
for _, svc := range cfg.Service {
log.Info(
"Processing service",
"name",
svc.Name,
"enabled",
svc.Enabled,
"path",
svc.LogPath,
)
if !svc.Enabled {
log.Info("Service disabled, skipping", "name", svc.Name)
continue
}
if svc.Name != "nginx" {
log.Info("Only nginx supported, skipping", "name", svc.Name)
continue
}
log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
pars, err := parser.NewScanner(svc.LogPath)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue
}
go pars.Start()
defer pars.Stop()
go func(p *parser.Scanner, serviceName string) {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
resultCh := make(chan *storage.LogEntry, 100)
ng.Parse(p.Events(), resultCh)
go storage.Write(db, resultCh)
}(pars, svc.Name)
}
<-ctx.Done()
log.Info("Shutdown signal received")
},
}

View File

@@ -0,0 +1,84 @@
package command
import (
"fmt"
"net"
"os"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/spf13/cobra"
)
var (
ip string
)
var UnbanCmd = &cobra.Command{
Use: "unban",
Short: "Unban IP",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
fmt.Println("IP can't be empty")
os.Exit(1)
}
if net.ParseIP(ip) == nil {
fmt.Println("Invalid IP")
os.Exit(1)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = b.Unban(ip)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP unblocked successfully!")
},
}
var BanCmd = &cobra.Command{
Use: "ban",
Short: "Ban IP",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
fmt.Println("IP can't be empty")
os.Exit(1)
}
if net.ParseIP(ip) == nil {
fmt.Println("Invalid IP")
os.Exit(1)
}
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = b.Ban(ip)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP unblocked successfully!")
},
}
func FwRegister() {
BanCmd.Flags().StringVarP(&ip, "ip", "i", "", "ip to ban")
UnbanCmd.Flags().StringVarP(&ip, "ip", "i", "", "ip to unban")
}

View File

@@ -0,0 +1,106 @@
package command
import (
"fmt"
"os"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra"
)
var InitCmd = &cobra.Command{
Use: "init",
Short: "Initialize BanForge",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Initializing BanForge...")
if _, err := os.Stat("/var/log/banforge"); err == nil {
fmt.Println("/var/log/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/log/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/log/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/var/lib/banforge"); err == nil {
fmt.Println("/var/lib/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/lib/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/lib/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/etc/banforge"); err == nil {
fmt.Println("/etc/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/etc/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /etc/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
err := config.CreateConf()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Config created")
err = config.FindFirewall()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
b := blocker.GetBlocker(cfg.Firewall.Name, cfg.Firewall.Config)
err = b.Setup(cfg.Firewall.Config)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Firewall configured")
db, err := storage.NewDB()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = db.CreateTable()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer func() {
err = db.Close()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}()
fmt.Println("Firewall detected and configured")
fmt.Println("BanForge initialized successfully!")
},
}

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

@@ -0,0 +1,84 @@
package command
import (
"fmt"
"os"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/spf13/cobra"
)
var (
name string
service string
path string
status string
method string
ttl string
)
var RuleCmd = &cobra.Command{
Use: "rule",
Short: "Manage rules",
}
var AddCmd = &cobra.Command{
Use: "add",
Short: "CLI interface for add new rule to file /etc/banforge/rules.toml",
Run: func(cmd *cobra.Command, args []string) {
if name == "" {
fmt.Printf("Rule name can't be empty\n")
os.Exit(1)
}
if service == "" {
fmt.Printf("Service name can't be empty\n")
os.Exit(1)
}
if path == "" && status == "" && method == "" {
fmt.Printf("At least 1 rule field must be filled in.")
os.Exit(1)
}
if ttl == "" {
ttl = "1y"
}
err := config.NewRule(name, service, path, status, method, ttl)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Rule added successfully!")
},
}
var ListCmd = &cobra.Command{
Use: "list",
Short: "List rules",
Run: func(cmd *cobra.Command, args []string) {
r, err := config.LoadRuleConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
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,
)
}
},
}
func RuleRegister() {
RuleCmd.AddCommand(AddCmd)
RuleCmd.AddCommand(ListCmd)
AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)")
AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name")
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time")
}

View File

@@ -4,6 +4,8 @@ import (
"fmt" "fmt"
"os" "os"
"github.com/d3m0k1d/BanForge/cmd/banforge/command"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -15,30 +17,19 @@ var rootCmd = &cobra.Command{
}, },
} }
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize BanForge",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Initializing BanForge...")
err := os.Mkdir("/var/log/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = os.Mkdir("/etc/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
func Init() { func Init() {
} }
func Execute() { func Execute() {
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(command.DaemonCmd)
rootCmd.AddCommand(command.InitCmd)
rootCmd.AddCommand(command.RuleCmd)
rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd)
rootCmd.AddCommand(command.BanListCmd)
command.RuleRegister()
command.FwRegister()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)

61
docs/cli.md Normal file
View File

@@ -0,0 +1,61 @@
# CLI commands BanForge
BanForge provides a command-line interface (CLI) to manage IP blocking,
configure detection rules, and control the daemon process.
## Commands
### init - create a deps file
```shell
banforge init
```
**Description**
This command creates the necessary directories and base configuration files
required for the daemon to operate.
### daemon - Starts the BanForge daemon process
```shell
banforge daemon
```
**Description**
This command starts the BanForge daemon process in the background.
The daemon continuously monitors incoming requests, detects anomalies,
and applies firewall rules in real-time.
### firewall - Manages firewall rules
```shell
banforge ban <ip>
banforge unban <ip>
```
**Description**
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
```shell
banforge rule add -n rule.name -c 403
banforge rule list
```
**Description**
These command help you to create and manage detection rules in CLI interface.
| Flag | Required |
| ----------- | -------- |
| -n -name | + |
| -s -service | + |
| -p -path | - |
| -m -method | - |
| -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.

45
docs/config.md Normal file
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"

11
go.mod
View File

@@ -2,9 +2,18 @@ module github.com/d3m0k1d/BanForge
go 1.25.5 go 1.25.5
require github.com/spf13/cobra v1.10.2 require (
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/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
) )

23
go.sum
View File

@@ -1,11 +1,34 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
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/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

@@ -0,0 +1,63 @@
package blocker
import (
"os/exec"
"github.com/d3m0k1d/BanForge/internal/logger"
)
type Firewalld struct {
logger *logger.Logger
}
func NewFirewalld(logger *logger.Logger) *Firewalld {
return &Firewalld{
logger: logger,
}
}
func (f *Firewalld) Ban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Add source " + ip + " " + string(output))
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Reload " + string(output))
return nil
}
func (f *Firewalld) Unban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Remove source " + ip + " " + string(output))
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Reload " + string(output))
return nil
}
func (f *Firewalld) Setup(config string) error {
return nil
}

View File

@@ -1,8 +1,28 @@
package blocker package blocker
import (
"fmt"
"github.com/d3m0k1d/BanForge/internal/logger"
)
type BlockerEngine interface { type BlockerEngine interface {
Ban(ip string) error Ban(ip string) error
Unban(ip string) error Unban(ip string) error
IsBanned(ip string) (bool, error) Setup(config string) error
Flush() error }
func GetBlocker(fw string, config string) BlockerEngine {
switch fw {
case "ufw":
return NewUfw(logger.New(false))
case "iptables":
return NewIptables(logger.New(false), config)
case "nftables":
return NewNftables(logger.New(false), config)
case "firewalld":
return NewFirewalld(logger.New(false))
default:
panic(fmt.Sprintf("Unknown firewall: %s", fw))
}
} }

View File

@@ -0,0 +1,107 @@
package blocker
import (
"os/exec"
"github.com/d3m0k1d/BanForge/internal/logger"
)
type Iptables struct {
logger *logger.Logger
config string
}
func NewIptables(logger *logger.Logger, config string) *Iptables {
return &Iptables{
logger: logger,
config: config,
}
}
func (f *Iptables) Ban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
err = validateConfigPath(f.config)
if err != nil {
return err
}
cmd := exec.Command("sudo", "iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to ban IP",
"ip", ip,
"error", err.Error(),
"output", string(output))
return err
}
f.logger.Info("IP banned",
"ip", ip,
"output", string(output))
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
"config_path", f.config,
"error", err.Error(),
"output", string(output))
return err
}
f.logger.Info("config saved",
"config_path", f.config,
"output", string(output))
return nil
}
func (f *Iptables) Unban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
err = validateConfigPath(f.config)
if err != nil {
return err
}
cmd := exec.Command("sudo", "iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to unban IP",
"ip", ip,
"error", err.Error(),
"output", string(output))
return err
}
f.logger.Info("IP unbanned",
"ip", ip,
"output", string(output))
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
"config_path", f.config,
"error", err.Error(),
"output", string(output))
return err
}
f.logger.Info("config saved",
"config_path", f.config,
"output", string(output))
return nil
}
func (f *Iptables) Setup(config string) error {
return nil
}

View File

@@ -0,0 +1,206 @@
package blocker
import (
"fmt"
"os/exec"
"strings"
"github.com/d3m0k1d/BanForge/internal/logger"
)
type Nftables struct {
logger *logger.Logger
config string
}
func NewNftables(logger *logger.Logger, config string) *Nftables {
return &Nftables{
logger: logger,
config: config,
}
}
func (n *Nftables) Ban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
cmd := exec.Command("sudo", "nft", "add", "rule", "inet", "banforge", "banned",
"ip", "saddr", ip, "drop")
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error("failed to ban IP",
"ip", ip,
"error", err.Error(),
"output", string(output))
return err
}
n.logger.Info("IP banned", "ip", ip)
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
return err
}
n.logger.Info("config saved", "config_path", n.config)
return nil
}
func (n *Nftables) Unban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
handle, err := n.findRuleHandle(ip)
if err != nil {
n.logger.Error("failed to find rule handle",
"ip", ip,
"error", err.Error())
return err
}
if handle == "" {
n.logger.Warn("no rule found for IP", "ip", ip)
return fmt.Errorf("no rule found for IP %s", ip)
}
// #nosec G204 - handle is extracted from nftables output and validated
cmd := exec.Command("sudo", "nft", "delete", "rule", "inet", "banforge", "banned",
"handle", handle)
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error("failed to unban IP",
"ip", ip,
"handle", handle,
"error", err.Error(),
"output", string(output))
return err
}
n.logger.Info("IP unbanned", "ip", ip, "handle", handle)
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
return err
}
n.logger.Info("config saved", "config_path", n.config)
return nil
}
func (n *Nftables) Setup(config string) error {
err := validateConfigPath(config)
if err != nil {
return fmt.Errorf("path error: %w", err)
}
nftConfig := `table inet banforge {
chain input {
type filter hook input priority 0
policy accept
}
chain banned {
}
}
`
cmd := exec.Command("sudo", "tee", config)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start tee command: %w", err)
}
_, err = stdin.Write([]byte(nftConfig))
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
err = stdin.Close()
if err != nil {
return fmt.Errorf("failed to close stdin pipe: %w", err)
}
if err = cmd.Wait(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
cmd = exec.Command("sudo", "nft", "-f", config)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to load nftables config: %s", string(output))
}
return nil
}
func (n *Nftables) findRuleHandle(ip string) (string, error) {
cmd := exec.Command("sudo", "nft", "-a", "list", "chain", "inet", "banforge", "banned")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to list chain rules: %w", err)
}
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.Contains(line, ip) && strings.Contains(line, "drop") {
if idx := strings.Index(line, "# handle"); idx != -1 {
parts := strings.Fields(line[idx:])
if len(parts) >= 3 && parts[1] == "handle" {
return parts[2], nil
}
}
}
}
return "", nil
}
func saveNftablesConfig(configPath string) error {
err := validateConfigPath(configPath)
if err != nil {
return err
}
cmd := exec.Command("sudo", "nft", "list", "ruleset")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get nftables ruleset: %w", err)
}
cmd = exec.Command("sudo", "tee", configPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
}
if err := cmd.Start(); err != nil {
return fmt.Errorf("failed to start tee command: %w", err)
}
_, err = stdin.Write(output)
if err != nil {
return fmt.Errorf("failed to write to config file: %w", err)
}
err = stdin.Close()
if err != nil {
return fmt.Errorf("failed to close stdin pipe: %w", err)
}
if err := cmd.Wait(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
return nil
}

View File

@@ -1,6 +1,7 @@
package blocker package blocker
import ( import (
"fmt"
"os/exec" "os/exec"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -16,32 +17,66 @@ func NewUfw(logger *logger.Logger) *Ufw {
} }
} }
func (ufw *Ufw) Ban(ip string) error { func (u *Ufw) Ban(ip string) error {
err := validateIP(ip) err := validateIP(ip)
if err != nil { if err != nil {
return err return err
} }
cmd := exec.Command("sudo", "ufw", "--force", "deny", "from", ip) cmd := exec.Command("sudo", "ufw", "--force", "deny", "from", ip)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
ufw.logger.Error(err.Error()) u.logger.Error("failed to ban IP",
return err "ip", ip,
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to ban IP %s: %w", ip, err)
} }
ufw.logger.Info("Banning " + ip + " " + string(output))
u.logger.Info("IP banned", "ip", ip, "output", string(output))
return nil return nil
} }
func (u *Ufw) Unban(ip string) error {
func (ufw *Ufw) Unban(ip string) error {
err := validateIP(ip) err := validateIP(ip)
if err != nil { if err != nil {
return err return err
} }
cmd := exec.Command("sudo", "ufw", "--force", "delete", "deny", "from", ip) cmd := exec.Command("sudo", "ufw", "--force", "delete", "deny", "from", ip)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
ufw.logger.Error(err.Error()) u.logger.Error("failed to unban IP",
return err "ip", ip,
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to unban IP %s: %w", ip, err)
}
u.logger.Info("IP unbanned", "ip", ip, "output", string(output))
return nil
}
func (u *Ufw) Setup(config string) error {
if config != "" {
fmt.Printf("Ufw dont support config file\n")
cmd := exec.Command("sudo", "ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to enable ufw: %w", err)
}
}
if config == "" {
cmd := exec.Command("sudo", "ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to enable ufw: %w", err)
}
} }
ufw.logger.Info("Unbanning " + ip + " " + string(output))
return nil return nil
} }

View File

@@ -1,8 +1,11 @@
package blocker package blocker
import ( import (
"errors"
"fmt" "fmt"
"net" "net"
"path/filepath"
"strings"
) )
func validateIP(ip string) error { func validateIP(ip string) error {
@@ -16,3 +19,21 @@ func validateIP(ip string) error {
return nil return nil
} }
func validateConfigPath(pathIn string) error {
if pathIn == "" {
return errors.New("config path cannot be empty")
}
cleanPath := filepath.Clean(pathIn)
if !filepath.IsAbs(cleanPath) {
return fmt.Errorf("config path must be absolute, got: %s", cleanPath)
}
if strings.Contains(cleanPath, "..") {
return fmt.Errorf("config path contains path traversal: %s", cleanPath)
}
return nil
}

View File

@@ -0,0 +1,47 @@
package blocker
import (
"testing"
)
func TestValidateConfigPath(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "empty", input: "", wantErr: true},
{name: "valid path", input: "/path/to/config", wantErr: false},
{name: "invalid path", input: "path/to/config", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateConfigPath(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateConfigPath(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}
func TestValidateIP(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{name: "empty", input: "", wantErr: true},
{name: "invalid IP", input: "1.1.1", wantErr: true},
{name: "valid IP", input: "1.1.1.1", wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateIP(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("validateIP(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

154
internal/config/appconf.go Normal file
View File

@@ -0,0 +1,154 @@
package config
import (
"fmt"
"os"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/d3m0k1d/BanForge/internal/logger"
)
func LoadRuleConfig() ([]Rule, error) {
log := logger.New(false)
var cfg Rules
_, err := toml.DecodeFile("/etc/banforge/rules.toml", &cfg)
if err != nil {
log.Error(fmt.Sprintf("failed to decode config: %v", err))
return nil, err
}
log.Info(fmt.Sprintf("loaded %d rules", len(cfg.Rules)))
return cfg.Rules, nil
}
func NewRule(
Name string,
ServiceName string,
Path string,
Status string,
Method string,
ttl string,
) error {
r, err := LoadRuleConfig()
if err != nil {
r = []Rule{}
}
if Name == "" {
fmt.Printf("Rule name can't be empty\n")
return nil
}
r = append(
r,
Rule{
Name: Name,
ServiceName: ServiceName,
Path: Path,
Status: Status,
Method: Method,
BanTime: ttl,
},
)
file, err := os.Create("/etc/banforge/rules.toml")
if err != nil {
return err
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
cfg := Rules{Rules: r}
err = toml.NewEncoder(file).Encode(cfg)
if err != nil {
return err
}
return nil
}
func EditRule(Name string, ServiceName string, Path string, Status string, Method string) error {
if Name == "" {
return fmt.Errorf("Rule name can't be empty")
}
r, err := LoadRuleConfig()
if err != nil {
return fmt.Errorf("rules is empty, please use 'banforge add rule' or create rules.toml")
}
found := false
for i, rule := range r {
if rule.Name == Name {
found = true
if ServiceName != "" {
r[i].ServiceName = ServiceName
}
if Path != "" {
r[i].Path = Path
}
if Status != "" {
r[i].Status = Status
}
if Method != "" {
r[i].Method = Method
}
break
}
}
if !found {
return fmt.Errorf("rule '%s' not found", Name)
}
file, err := os.Create("/etc/banforge/rules.toml")
if err != nil {
return err
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
cfg := Rules{Rules: r}
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
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

@@ -5,6 +5,8 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"github.com/BurntSushi/toml"
) )
var DetectedFirewall string var DetectedFirewall string
@@ -39,31 +41,96 @@ func CreateConf() error {
if err := os.Chmod(configPath, 0600); err != nil { if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err) return fmt.Errorf("failed to set permissions: %w", err)
} }
err = os.WriteFile(configPath, []byte(Base_config), 0600)
if err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
fmt.Printf(" Config file created: %s\n", configPath) fmt.Printf(" Config file created: %s\n", configPath)
file, err = os.Create("/etc/banforge/rules.toml")
if err != nil {
return fmt.Errorf("failed to create rules file: %w", err)
}
file, err = os.Create("/var/lib/banforge/storage.db")
if err != nil {
return fmt.Errorf("failed to create database file: %w", err)
}
err = os.Chmod("/var/lib/banforge/storage.db", 0600)
if err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
fmt.Printf(" Rules file created: %s\n", configPath)
return nil return nil
} }
func FindFirewall() error { func FindFirewall() error {
if os.Getegid() != 0 { if os.Getegid() != 0 {
fmt.Printf("Firewall settings needs sudo privileges\n") fmt.Printf("Firewall settings needs sudo privileges\n")
os.Exit(1) os.Exit(1)
} }
firewalls := []string{"iptables", "nft", "firewall-cmd", "ufw"}
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
for _, firewall := range firewalls { for _, firewall := range firewalls {
_, err := exec.LookPath(firewall) _, err := exec.LookPath(firewall)
if err == nil { if err == nil {
if firewall == "firewall-cmd" { switch firewall {
case "firewall-cmd":
DetectedFirewall = "firewalld" DetectedFirewall = "firewalld"
} case "nft":
if firewall == "nft" {
DetectedFirewall = "nftables" DetectedFirewall = "nftables"
default:
DetectedFirewall = firewall
} }
DetectedFirewall = firewall
fmt.Printf("Detected firewall: %s\n", firewall) fmt.Printf("Detected firewall: %s\n", DetectedFirewall)
cfg := &Config{}
_, err := toml.DecodeFile("/etc/banforge/config.toml", cfg)
if err != nil {
return fmt.Errorf("failed to decode config: %w", err)
}
cfg.Firewall.Name = DetectedFirewall
file, err := os.Create("/etc/banforge/config.toml")
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
encoder := toml.NewEncoder(file)
if err := encoder.Encode(cfg); err != nil {
err = file.Close()
if err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
return fmt.Errorf("failed to encode config: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
fmt.Printf("Config updated with firewall: %s\n", DetectedFirewall)
return nil return nil
} }
} }
return fmt.Errorf("no firewall found (checked ufw, firewall-cmd, iptables, nft) please install one of them")
return fmt.Errorf("firewall not found")
}
func LoadConfig() (*Config, error) {
cfg := &Config{}
_, err := toml.DecodeFile("/etc/banforge/config.toml", cfg)
if err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
return cfg, nil
} }

View File

@@ -1,15 +1,20 @@
package config package config
const Base_config = `# This is a TOML config file for BanForge it's a simple config file const Base_config = `
# https://github.com/d3m0k1d/BanForge # This is a TOML config file for BanForge
# [https://github.com/d3m0k1d/BanForge](https://github.com/d3m0k1d/BanForge)
# Firewall settings block
[firewall] [firewall]
name = "iptables" # Name one of the support firewall(iptables, nftables, firewalld, ufw) name = ""
ban_time = 1200 config = "/etc/nftables.conf"
[Service] [[service]]
name = "nginx" name = "nginx"
log_path = "/var/log/nginx/access.log" log_path = "/var/log/nginx/access.log"
enabled = true enabled = true
[[service]]
name = "nginx"
log_path = "/var/log/nginx/access.log"
enabled = false
` `

View File

@@ -1,12 +1,31 @@
package config package config
type Firewall struct { type Firewall struct {
Name string `toml:"name"` Name string `toml:"name"`
BanTime int `toml:"ban_time"` Config string `toml:"config"`
} }
type Service struct { type Service struct {
Name string `toml:"name"` Name string `toml:"name"`
Log_path string `toml:"log_path"` LogPath string `toml:"log_path"`
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
}
type Config struct {
Firewall Firewall `toml:"firewall"`
Service []Service `toml:"service"`
}
// Rules
type Rules struct {
Rules []Rule `toml:"rule"`
}
type Rule struct {
Name string `toml:"name"`
ServiceName string `toml:"service"`
Path string `toml:"path"`
Status string `toml:"status"`
Method string `toml:"method"`
BanTime string `toml:"ban_time"`
} }

139
internal/judge/judge.go Normal file
View File

@@ -0,0 +1,139 @@
package judge
import (
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type Judge struct {
db *storage.DB
logger *logger.Logger
Blocker blocker.BlockerEngine
rulesByService map[string][]config.Rule
}
func New(db *storage.DB, b blocker.BlockerEngine) *Judge {
return &Judge{
db: db,
logger: logger.New(false),
rulesByService: make(map[string][]config.Rule),
Blocker: b,
}
}
func (j *Judge) LoadRules(rules []config.Rule) {
j.rulesByService = make(map[string][]config.Rule)
for _, rule := range rules {
j.rulesByService[rule.ServiceName] = append(
j.rulesByService[rule.ServiceName],
rule,
)
}
j.logger.Info("Rules loaded and indexed by service")
}
func (j *Judge) ProcessUnviewed() error {
rows, err := j.db.SearchUnViewed()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to query database: %v", err))
return err
}
defer func() {
err = rows.Close()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err))
}
}()
for rows.Next() {
var entry storage.LogEntry
err = rows.Scan(
&entry.ID,
&entry.Service,
&entry.IP,
&entry.Path,
&entry.Status,
&entry.Method,
&entry.IsViewed,
&entry.CreatedAt,
)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to scan database row: %v", err))
continue
}
rules, serviceExists := j.rulesByService[entry.Service]
if serviceExists {
for _, rule := range rules {
if (rule.Method == "" || entry.Method == rule.Method) &&
(rule.Status == "" || entry.Status == rule.Status) &&
(rule.Path == "" || entry.Path == rule.Path) {
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)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err))
}
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
}
}
}
err = j.db.MarkAsViewed(entry.ID)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to mark entry as viewed: %v", err))
} else {
j.logger.Info(fmt.Sprintf("Entry marked as viewed: ID=%d", entry.ID))
}
}
if err = rows.Err(); err != nil {
j.logger.Error(fmt.Sprintf("Error iterating rows: %v", err))
return err
}
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

@@ -0,0 +1,58 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type NginxParser struct {
pattern *regexp.Regexp
logger *logger.Logger
}
func NewNginxParser() *NginxParser {
pattern := regexp.MustCompile(
`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*\[(.*?)\]\s+"(\w+)\s+(.*?)\s+HTTP.*"\s+(\d+)`,
)
return &NginxParser{
pattern: pattern,
logger: logger.New(false),
}
}
func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
go func() {
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
path := matches[4]
status := matches[5]
method := matches[3]
resultCh <- &storage.LogEntry{
Service: "nginx",
IP: matches[1],
Path: path,
Status: status,
Method: method,
IsViewed: false,
}
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
} }
@@ -52,6 +54,7 @@ func (s *Scanner) Start() {
s.ch <- Event{ s.ch <- Event{
Data: s.scanner.Text(), Data: s.scanner.Text(),
} }
s.logger.Info("Scanner event", "data", s.scanner.Text())
} else { } else {
if err := s.scanner.Err(); err != nil { if err := s.scanner.Err(); err != nil {
s.logger.Error("Scanner error") s.logger.Error("Scanner error")

168
internal/storage/db.go Normal file
View File

@@ -0,0 +1,168 @@
package storage
import (
"database/sql"
"os"
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/jedib0t/go-pretty/v6/table"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
logger *logger.Logger
db *sql.DB
}
func NewDB() (*DB, error) {
db, err := sql.Open(
"sqlite3",
"/var/lib/banforge/storage.db?mode=rwc&_journal_mode=WAL&_busy_timeout=10000&cache=shared",
)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
return &DB{
logger: logger.New(false),
db: db,
}, nil
}
func (d *DB) Close() error {
d.logger.Info("Closing database connection")
err := d.db.Close()
if err != nil {
return err
}
return nil
}
func (d *DB) CreateTable() error {
_, err := d.db.Exec(CreateTables)
if err != nil {
return err
}
d.logger.Info("Created tables")
return nil
}
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",
)
if err != nil {
d.logger.Error("Failed to query database")
return nil, err
}
return rows, nil
}
func (d *DB) MarkAsViewed(id int) error {
_, err := d.db.Exec("UPDATE requests SET viewed = 1 WHERE id = ?", id)
if err != nil {
d.logger.Error("Failed to mark as viewed", "error", err)
return err
}
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
}

243
internal/storage/db_test.go Normal file
View File

@@ -0,0 +1,243 @@
package storage
import (
"database/sql"
"github.com/d3m0k1d/BanForge/internal/logger"
_ "github.com/mattn/go-sqlite3"
"os"
"path/filepath"
"testing"
"time"
)
func createTestDB(t *testing.T) *sql.DB {
tmpDir, err := os.MkdirTemp("", "banforge-test-*")
if err != nil {
t.Fatal(err)
}
filePath := filepath.Join(tmpDir, "test.db")
db, err := sql.Open("sqlite3", filePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
db.Close()
os.RemoveAll(tmpDir)
})
return db
}
func createTestDBStruct(t *testing.T) *DB {
tmpDir, err := os.MkdirTemp("", "banforge-test-*")
if err != nil {
t.Fatal(err)
}
filePath := filepath.Join(tmpDir, "test.db")
sqlDB, err := sql.Open("sqlite3", filePath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
sqlDB.Close()
os.RemoveAll(tmpDir)
})
return &DB{
logger: logger.New(false),
db: sqlDB,
}
}
func TestCreateTable(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
rows, err := d.db.Query("SELECT 1 FROM requests LIMIT 1")
if err != nil {
t.Fatal("requests table should exist:", err)
}
rows.Close()
rows, err = d.db.Query("SELECT 1 FROM bans LIMIT 1")
if err != nil {
t.Fatal("bans table should exist:", err)
}
rows.Close()
}
func TestMarkAsViewed(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
_, err = d.db.Exec(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"test",
"127.0.0.1",
"/test",
"GET",
"200",
time.Now().Format(time.RFC3339),
)
if err != nil {
t.Fatal(err)
}
err = d.MarkAsViewed(1)
if err != nil {
t.Fatal(err)
}
var isViewed bool
err = d.db.QueryRow("SELECT viewed FROM requests WHERE id = 1").Scan(&isViewed)
if err != nil {
t.Fatal(err)
}
if !isViewed {
t.Fatal("viewed should be true")
}
}
func TestSearchUnViewed(t *testing.T) {
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
for i := 0; i < 2; i++ {
_, err := d.db.Exec(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
"test",
"127.0.0.1",
"/test",
"GET",
"200",
time.Now().Format(time.RFC3339),
)
if err != nil {
t.Fatal(err)
}
}
rows, err := d.SearchUnViewed()
if err != nil {
t.Fatal(err)
}
defer rows.Close()
count := 0
for rows.Next() {
var id int
var service, ip, path, status, method string
var viewed bool
var createdAt string
err := rows.Scan(&id, &service, &ip, &path, &status, &method, &viewed, &createdAt)
if err != nil {
t.Fatal(err)
}
if viewed {
t.Fatal("should be unviewed")
}
count++
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if count != 2 {
t.Fatalf("expected 2 unviewed requests, got %d", count)
}
}
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) {
d := createTestDBStruct(t)
err := d.Close()
if err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,29 @@
package storage
const CreateTables = `
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY,
service TEXT NOT NULL,
ip TEXT NOT NULL,
path TEXT,
method TEXT,
status TEXT,
viewed BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY,
ip TEXT UNIQUE NOT NULL,
reason TEXT,
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_ip ON requests(ip);
CREATE INDEX IF NOT EXISTS idx_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_created_at ON requests(created_at);
CREATE INDEX IF NOT EXISTS idx_ban_ip ON bans(ip);
`

View File

@@ -0,0 +1,19 @@
package storage
type LogEntry struct {
ID int `db:"id"`
Service string `db:"service"`
IP string `db:"ip"`
Path string `db:"path"`
Status string `db:"status"`
Method string `db:"method"`
IsViewed bool `db:"viewed"`
CreatedAt string `db:"created_at"`
}
type Ban struct {
ID int `db:"id"`
IP string `db:"ip"`
Reason string `db:"reason"`
BannedAt string `db:"banned_at"`
}

View File

@@ -0,0 +1,22 @@
package storage
import (
"time"
)
func Write(db *DB, resultCh <-chan *LogEntry) {
for result := range resultCh {
_, err := db.db.Exec(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
result.Service,
result.IP,
result.Path,
result.Method,
result.Status,
time.Now().Format(time.RFC3339),
)
if err != nil {
db.logger.Error("Failed to write to database", "error", err)
}
}
}

View File

@@ -0,0 +1,40 @@
package storage
import (
"testing"
"time"
)
func TestWrite(t *testing.T) {
var ip string
d := createTestDBStruct(t)
err := d.CreateTable()
if err != nil {
t.Fatal(err)
}
resultCh := make(chan *LogEntry)
go Write(d, resultCh)
resultCh <- &LogEntry{
Service: "test",
IP: "127.0.0.1",
Path: "/test",
Method: "GET",
Status: "200",
}
close(resultCh)
time.Sleep(100 * time.Millisecond)
err = d.db.QueryRow("SELECT ip FROM requests LIMIT 1").Scan(&ip)
if err != nil {
t.Fatal(err)
}
if ip != "127.0.0.1" {
t.Fatal("ip should be 127.0.0.1")
}
}