5 Commits

Author SHA1 Message Date
Ilya Chernishev
5fd2a53541 revert: restore original ufw.go
All checks were successful
CI.yml / build (push) Successful in 51s
2026-01-12 17:53:39 +03:00
Ilya Chernishev
5dbacc765f revert: restore original nftables.go 2026-01-12 17:53:33 +03:00
Ilya Chernishev
d437512d24 revert: restore original interface.go 2026-01-12 17:53:17 +03:00
Ilya Chernishev
c1a3110b79 revert: remove factory.go 2026-01-12 17:53:13 +03:00
Ilya Chernishev
4e52d43d04 revert: remove blocker_factory_usage.go 2026-01-12 17:52:52 +03:00
57 changed files with 197 additions and 6849 deletions

View File

@@ -1,45 +0,0 @@
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@v7
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: build
name: CI.yml
on:
push:

2
.gitignore vendored
View File

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

View File

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

View File

@@ -1,82 +0,0 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: BanForge
gitea_urls:
api: https://gitea.d3m0k1d.ru/api/v1
download: https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download
skip_tls_verify: false
builds:
- id: banforge
main: ./cmd/banforge/main.go
binary: banforge
ignore:
- goos: windows
- goos: darwin
- goos: freebsd
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- "-s -w"
env:
- CGO_ENABLED=0
archives:
- formats: [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
scripts:
postinstall: build/postinstall.sh
postremove: build/postremove.sh
contents:
- src: docs/man/banforge.1
dst: /usr/share/man/man1/banforge.1
file_info:
mode: 0644
- src: docs/man/banforge.5
dst: /usr/share/man/man5/banforge.5
file_info:
mode: 0644
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

@@ -1,4 +1,4 @@
.PHONY: build build-daemon build-tui clean help install-man check-man
.PHONY: build build-daemon build-tui clean help
help:
@echo "BanForge build targets:"
@@ -6,9 +6,7 @@ help:
@echo " make build-daemon - Build only daemon"
@echo " make build-tui - Build only TUI"
@echo " make clean - Remove binaries"
@echo " make test - Run tests"
@echo " make install-man - Install manpages to system"
@echo " make check-man - Validate manpage syntax"
@echo " make test - Run tests"
build: build-daemon build-tui
@echo "✅ Build complete!"
@@ -27,22 +25,3 @@ clean:
test:
go test ./...
test-cover:
go test -cover ./...
lint:
golangci-lint run --fix
check-man:
@echo "Checking manpage syntax..."
@man -l docs/man/banforge.1 > /dev/null && echo "✅ banforge.1 OK"
@man -l docs/man/banforge.5 > /dev/null && echo "✅ banforge.5 OK"
install-man:
@echo "Installing manpages..."
install -d $(DESTDIR)/usr/share/man/man1
install -d $(DESTDIR)/usr/share/man/man5
install -m 644 docs/man/banforge.1 $(DESTDIR)/usr/share/man/man1/banforge.1
install -m 644 docs/man/banforge.5 $(DESTDIR)/usr/share/man/man5/banforge.5
@echo "✅ Manpages installed!"

113
README.md
View File

@@ -1,11 +1,7 @@
# 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
1. [Overview](#overview)
2. [Requirements](#requirements)
@@ -15,118 +11,25 @@ Log-based IPS system written in Go for Linux-based system.
# Overview
BanForge is a simple IPS for replacement fail2ban in Linux system.
All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) after release v1.0.0 are available on Github release page.
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.
If you have any questions or suggestions, create issue on [Github](https://github.com/d3m0k1d/BanForge/issues).
## Roadmap
- [x] Rule system
- [x] Nginx and Sshd support
- [x] Working with ufw/iptables/nftables/firewalld
- [x] Prometheus metrics
- [x] Actions (email, webhook, script)
- [ ] Add support for most popular web-service
- [ ] User regexp for custom services
- [ ] Real-time Nginx log monitoring
- [ ] Add support for other service
- [ ] Add support for user service with regular expressions
- [ ] TUI interface
# Requirements
- Go 1.25+
- Go 1.21+
- ufw/iptables/nftables/firewalld
# Installation
Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it.
In release page you can find rpm, deb, apk packages, for amd or arm architecture.
currently no binary file if you wanna build the project yourself, you can use [Makefile](https://github.com/d3m0k1d/BanForge/blob/master/Makefile)
## Installation guide for packages
### Debian/Ubuntu(.deb)
```bash
# Download the latest DEB package
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.deb
# Install
sudo dpkg -i banforge_0.6.0_linux_amd64.deb
# Verify installation
sudo systemctl status banforge
```
### RHEL-based(.rpm)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.rpm
# Install
sudo rpm -i banforge_0.6.0_linux_amd64.rpm
# Or with dnf (CentOS 8+, AlmaLinux)
sudo dnf install banforge_0.6.0_linux_amd64.rpm
# Verify
sudo systemctl status banforge
```
### Alpine(.apk)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.apk
# Install
sudo apk add --allow-untrusted banforge_0.6.0_linux_amd64.apk
# Verify
sudo rc-service banforge status
```
### Arch Linux(.pkg.tar.zst)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.pkg.tar.zst
# Install
sudo pacman -U banforge_0.6.0_linux_amd64.pkg.tar.zst
# Verify
sudo systemctl status banforge
```
This is examples for other versions with different architecture or new versions check release page on [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases).
## Installation guide for source code
```bash
# Download
git clone https://github.com/d3m0k1d/BanForge.git
cd BanForge
make build-daemon
cd bin
mv banforge /usr/bin/banforge
cd ..
# Add init script and uses banforge init
cd build
./postinstall.sh
```
# 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.d/*.toml` individual rule files with actions support
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
## Actions
BanForge supports actions that are executed after a successful IP ban:
- **Email** - Send email notifications via SMTP
- **Webhook** - Send HTTP requests to external services (Slack, Telegram, etc.)
- **Script** - Execute custom scripts
See [configuration docs](https://github.com/d3m0k1d/BanForge/blob/main/docs/config.md#actions) for detailed setup instructions.
# License
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)

View File

@@ -1,21 +0,0 @@
#!/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

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

@@ -1,61 +0,0 @@
#!/bin/sh
if command -v systemctl >/dev/null 2>&1; then
# for systemd based systems
banforge init
cat > /etc/systemd/system/banforge.service << 'EOF'
[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
EOF
chmod 644 /etc/systemd/system/banforge.service
systemctl daemon-reload
systemctl enable banforge
fi
if command -v rc-service >/dev/null 2>&1; then
# for openrc based systems
banforge init
cat > /etc/init.d/banforge << 'EOF'
#!/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"
}
EOF
chmod 755 /etc/init.d/banforge
rc-update add banforge
fi

View File

@@ -1,20 +0,0 @@
#!/bin/sh
if command -v systemctl >/dev/null 2>&1; then
# for systemd based systems
systemctl stop banforge 2>/dev/null || true
systemctl disable banforge 2>/dev/null || true
rm -f /etc/systemd/system/banforge.service
systemctl daemon-reload
fi
if command -v rc-service >/dev/null 2>&1; then
# for openrc based systems
rc-service banforge stop 2>/dev/null || true
rc-update del banforge 2>/dev/null || true
rm -f /etc/init.d/banforge
fi
rm -rf /etc/banforge/
rm -rf /var/lib/banforge/
rm -rf /var/log/banforge/

View File

@@ -1,179 +0,0 @@
package command
import (
"context"
"os"
"os/signal"
"syscall"
"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/metrics"
"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) {
entryCh := make(chan *storage.LogEntry, 1000)
resultCh := make(chan *storage.LogEntry, 100)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
log := logger.New(false)
log.Info("Starting BanForge daemon")
reqDb_w, err := storage.NewRequestsWr()
if err != nil {
log.Error("Failed to create request writer", "error", err)
os.Exit(1)
}
reqDb_r, err := storage.NewRequestsRd()
if err != nil {
log.Error("Failed to create request reader", "error", err)
os.Exit(1)
}
banDb_r, err := storage.NewBanReader()
if err != nil {
log.Error("Failed to create ban reader", "error", err)
os.Exit(1)
}
banDb_w, err := storage.NewBanWriter()
if err != nil {
log.Error("Failed to create ban writter", "error", err)
os.Exit(1)
}
defer func() {
err = banDb_r.Close()
if err != nil {
log.Error("Failed to close database connection", "error", err)
}
err = banDb_w.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)
}
if cfg.Metrics.Enabled {
go func() {
if err := metrics.StartMetricsServer(cfg.Metrics.Port); err != nil {
log.Error("Failed to start metrics server", "error", err)
}
}()
}
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(banDb_r, banDb_w, reqDb_r, b, resultCh, entryCh)
j.LoadRules(r)
go j.UnbanChecker()
go j.Tribunal()
go storage.WriteReq(reqDb_w, resultCh)
var scanners []*parser.Scanner
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
}
log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
if svc.Logging != "file" && svc.Logging != "journald" {
log.Error("Invalid logging type", "type", svc.Logging)
continue
}
if svc.Logging == "file" {
log.Info("Logging to file", "path", svc.LogPath)
pars, err := parser.NewScannerTail(svc.LogPath)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue
}
scanners = append(scanners, pars)
go pars.Start()
go func(p *parser.Scanner, serviceName string) {
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
ng.Parse(p.Events(), entryCh)
}
if svc.Name == "ssh" {
log.Info("Starting ssh parser", "service", serviceName)
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), entryCh)
}
if svc.Name == "apache" {
log.Info("Starting apache parser", "service", serviceName)
ap := parser.NewApacheParser()
ap.Parse(p.Events(), entryCh)
}
}(pars, svc.Name)
continue
}
if svc.Logging == "journald" {
log.Info("Logging to journald", "path", svc.LogPath)
pars, err := parser.NewScannerJournald(svc.LogPath)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue
}
scanners = append(scanners, pars)
go pars.Start()
go func(p *parser.Scanner, serviceName string) {
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
ng.Parse(p.Events(), entryCh)
}
if svc.Name == "ssh" {
log.Info("Starting ssh parser", "service", serviceName)
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), entryCh)
}
if svc.Name == "apache" {
log.Info("Starting apache parser", "service", serviceName)
ap := parser.NewApacheParser()
ap.Parse(p.Events(), entryCh)
}
}(pars, svc.Name)
continue
}
}
<-ctx.Done()
log.Info("Shutdown signal received")
for _, s := range scanners {
s.Stop()
}
},
}

View File

@@ -1,179 +0,0 @@
package command
import (
"fmt"
"net"
"os"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra"
)
var (
ttl_fw string
port int
protocol string
)
var UnbanCmd = &cobra.Command{
Use: "unban",
Short: "Unban IP",
Run: func(cmd *cobra.Command, args []string) {
err := func() error {
if len(args) == 0 {
return fmt.Errorf("IP can't be empty")
}
if ttl_fw == "" {
ttl_fw = "1y"
}
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
return err
}
cfg, err := config.LoadConfig()
if err != nil {
return err
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
return fmt.Errorf("IP can't be empty")
}
if net.ParseIP(ip) == nil {
return fmt.Errorf("invalid IP")
}
if err != nil {
return err
}
err = b.Unban(ip)
if err != nil {
return err
}
err = db.RemoveBan(ip)
if err != nil {
return err
}
fmt.Println("IP unblocked successfully!")
return nil
}()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
var BanCmd = &cobra.Command{
Use: "ban",
Short: "Ban IP",
Run: func(cmd *cobra.Command, args []string) {
err := func() error {
if len(args) == 0 {
return fmt.Errorf("IP can't be empty")
}
if ttl_fw == "" {
ttl_fw = "1y"
}
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
return err
}
cfg, err := config.LoadConfig()
if err != nil {
return err
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
return fmt.Errorf("IP can't be empty")
}
if net.ParseIP(ip) == nil {
return fmt.Errorf("invalid IP")
}
if err != nil {
return err
}
err = b.Ban(ip)
if err != nil {
return err
}
err = db.AddBan(ip, ttl_fw, "manual ban")
if err != nil {
return err
}
fmt.Println("IP blocked successfully!")
return nil
}()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
var PortCmd = &cobra.Command{
Use: "port",
Short: "Ports commands",
}
var PortOpenCmd = &cobra.Command{
Use: "open",
Short: "Open ports on firewall",
Run: func(cmd *cobra.Command, args []string) {
if protocol == "" {
fmt.Println("Protocol can't be empty")
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
err = b.PortOpen(port, protocol)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Port opened successfully!")
},
}
var PortCloseCmd = &cobra.Command{
Use: "close",
Short: "Close ports on firewall",
Run: func(cmd *cobra.Command, args []string) {
if protocol == "" {
fmt.Println("Protocol can't be empty")
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
err = b.PortClose(port, protocol)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Port closed successfully!")
},
}
func FwRegister() {
BanCmd.Flags().StringVarP(&ttl_fw, "ttl", "t", "", "ban time")
PortCmd.AddCommand(PortOpenCmd)
PortCmd.AddCommand(PortCloseCmd)
PortOpenCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
PortOpenCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
PortCloseCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
PortCloseCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
}

View File

@@ -1,52 +0,0 @@
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...")
err := config.CreateConf()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
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")
err = storage.CreateTables()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Firewall detected and configured")
fmt.Println("BanForge initialized successfully!")
},
}

View File

@@ -1,27 +0,0 @@
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.NewBanReader()
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

@@ -1,157 +0,0 @@
package command
import (
"fmt"
"os"
"path/filepath"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
var (
name string
service string
path string
status string
method string
ttl string
maxRetry int
editName string
)
var RuleCmd = &cobra.Command{
Use: "rule",
Short: "Manage rules",
}
var AddCmd = &cobra.Command{
Use: "add",
Short: "Add a new rule to /etc/banforge/rules.d/",
Long: "Creates a new rule file in /etc/banforge/rules.d/<name>.toml",
Run: func(cmd *cobra.Command, args []string) {
if name == "" {
fmt.Println("Rule name can't be empty (use -n flag)")
os.Exit(1)
}
if service == "" {
fmt.Println("Service name can't be empty (use -s flag)")
os.Exit(1)
}
if path == "" && status == "" && method == "" {
fmt.Println("At least one rule field must be filled: path, status, or method")
os.Exit(1)
}
if ttl == "" {
ttl = "1y"
}
err := config.NewRule(name, service, path, status, method, ttl, maxRetry)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Rule added successfully!")
},
}
var EditCmd = &cobra.Command{
Use: "edit",
Short: "Edit an existing rule",
Long: "Edit rule fields by name. Only specified fields will be updated.",
Run: func(cmd *cobra.Command, args []string) {
if editName == "" {
fmt.Println("Rule name is required (use -n flag)")
os.Exit(1)
}
if service == "" && path == "" && status == "" && method == "" {
fmt.Println("At least one field must be specified to edit: -s, -p, -c, or -m")
os.Exit(1)
}
err := config.EditRule(editName, service, path, status, method)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Rule updated successfully!")
},
}
var RemoveCmd = &cobra.Command{
Use: "remove <name>",
Short: "Remove a rule by name",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
ruleName := args[0]
fileName := config.SanitizeRuleFilename(ruleName) + ".toml"
filePath := filepath.Join("/etc/banforge/rules.d", fileName)
if _, err := os.Stat(filePath); os.IsNotExist(err) {
fmt.Printf("Rule '%s' not found\n", ruleName)
os.Exit(1)
}
if err := os.Remove(filePath); err != nil {
fmt.Printf("Failed to remove rule: %v\n", err)
os.Exit(1)
}
fmt.Printf("Rule '%s' removed successfully\n", ruleName)
},
}
var ListCmd = &cobra.Command{
Use: "list",
Short: "List all rules",
Run: func(cmd *cobra.Command, args []string) {
rules, err := config.LoadRuleConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
if len(rules) == 0 {
fmt.Println("No rules found")
return
}
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.AppendHeader(table.Row{
"Name", "Service", "Path", "Status", "Method", "MaxRetry", "BanTime",
})
for _, rule := range rules {
t.AppendRow(table.Row{
rule.Name,
rule.ServiceName,
rule.Path,
rule.Status,
rule.Method,
rule.MaxRetry,
rule.BanTime,
})
}
t.Render()
},
}
func RuleRegister() {
RuleCmd.AddCommand(AddCmd)
RuleCmd.AddCommand(EditCmd)
RuleCmd.AddCommand(RemoveCmd)
RuleCmd.AddCommand(ListCmd)
AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)")
AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name (required)")
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time (e.g., 1h, 1d, 1y)")
AddCmd.Flags().IntVarP(&maxRetry, "max_retry", "r", 0, "max retry before ban")
EditCmd.Flags().StringVarP(&editName, "name", "n", "", "rule name to edit (required)")
EditCmd.Flags().StringVarP(&service, "service", "s", "", "new service name")
EditCmd.Flags().StringVarP(&path, "path", "p", "", "new path")
EditCmd.Flags().StringVarP(&status, "status", "c", "", "new status code")
EditCmd.Flags().StringVarP(&method, "method", "m", "", "new HTTP method")
}

View File

@@ -1,17 +0,0 @@
package command
import (
"fmt"
"github.com/spf13/cobra"
)
var version = "0.5.2"
var VersionCmd = &cobra.Command{
Use: "version",
Short: "BanForge version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("BanForge version:", version)
},
}

View File

@@ -4,8 +4,6 @@ import (
"fmt"
"os"
"github.com/d3m0k1d/BanForge/cmd/banforge/command"
"github.com/spf13/cobra"
)
@@ -13,6 +11,25 @@ var rootCmd = &cobra.Command{
Use: "banforge",
Short: "IPS log-based written on Golang",
Run: func(cmd *cobra.Command, args []string) {
},
}
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)
}
},
}
@@ -21,16 +38,7 @@ func Init() {
}
func Execute() {
rootCmd.AddCommand(command.DaemonCmd)
rootCmd.AddCommand(command.InitCmd)
rootCmd.AddCommand(command.RuleCmd)
rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd)
rootCmd.AddCommand(command.BanListCmd)
rootCmd.AddCommand(command.VersionCmd)
rootCmd.AddCommand(command.PortCmd)
command.RuleRegister()
command.FwRegister()
rootCmd.AddCommand(initCmd)
if err := rootCmd.Execute(); err != nil {
fmt.Println(err)
os.Exit(1)

View File

@@ -1,226 +0,0 @@
# 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 configuration files
```shell
banforge init
```
**Description**
This command creates the necessary directories and base configuration files
required for the daemon to operate:
- `/etc/banforge/config.toml` — main configuration
- `/etc/banforge/rules.toml` — default rules file
- `/etc/banforge/rules.d/` — directory for individual rule files
---
### version - Display BanForge version
```shell
banforge version
```
**Description**
This command displays the current version of the BanForge software.
---
### 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.
| Flag | Description |
| ----------- | ------------------------------ |
| `-t`, `-ttl` | Ban duration (default: 1 year) |
**Examples:**
```bash
# Ban IP for 1 hour
banforge ban 192.168.1.100 -t 1h
# Unban IP
banforge unban 192.168.1.100
```
---
### ports - Open and close ports on firewall
```shell
banforge open -port <port> -protocol <protocol>
banforge close -port <port> -protocol <protocol>
```
**Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
| Flag | Required | Description |
| ------------- | -------- | ------------------------ |
| `-port` | + | Port number (e.g., 80) |
| `-protocol` | + | Protocol (tcp/udp) |
**Examples:**
```bash
# Open port 80 for TCP
banforge open -port 80 -protocol tcp
# Close port 443
banforge close -port 443 -protocol tcp
```
---
### list - List blocked IP addresses
```shell
banforge list
```
**Description**
This command outputs a table of IP addresses that are currently blocked.
---
### rule - Manage detection rules
Rules are stored in `/etc/banforge/rules.d/` as individual `.toml` files.
#### Add a new rule
```shell
banforge rule add -n <name> -s <service> [options]
```
**Flags:**
| Flag | Required | Description |
| ------------------- | -------- | ---------------------------------------- |
| `-n`, `--name` | + | Rule name (used as filename) |
| `-s`, `--service` | + | Service name (nginx, apache, ssh, etc.) |
| `-p`, `--path` | - | Request path to match |
| `-m`, `--method` | - | HTTP method (GET, POST, etc.) |
| `-c`, `--status` | - | HTTP status code (403, 404, etc.) |
| `-t`, `--ttl` | - | Ban duration (default: 1y) |
| `-r`, `--max_retry` | - | Max retries before ban (default: 0) |
**Note:** At least one of `-p`, `-m`, or `-c` must be specified.
**Examples:**
```bash
# Ban on 403 status
banforge rule add -n "Forbidden" -s nginx -c 403 -t 30m
# Ban on path pattern
banforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3
# SSH brute force protection
banforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5
```
---
#### List all rules
```shell
banforge rule list
```
**Description**
Displays all configured rules in a table format.
**Example output:**
```
+------------------+---------+--------+--------+--------+----------+---------+
| NAME | SERVICE | PATH | STATUS | METHOD | MAXRETRY | BANTIME |
+------------------+---------+--------+--------+--------+----------+---------+
| SSH Bruteforce | ssh | | Failed | | 5 | 1h |
| Nginx 404 | nginx | | 404 | | 3 | 30m |
| Admin Panel | nginx | /admin | | | 2 | 2h |
+------------------+---------+--------+--------+--------+----------+---------+
```
---
#### Edit an existing rule
```shell
banforge rule edit -n <name> [options]
```
**Description**
Edit fields of an existing rule. Only specified fields will be updated.
| Flag | Required | Description |
| ------------------- | -------- | ------------------------------- |
| `-n`, `--name` | + | Rule name to edit |
| `-s`, `--service` | - | New service name |
| `-p`, `--path` | - | New path |
| `-m`, `--method` | - | New method |
| `-c`, `--status` | - | New status code |
**Examples:**
```bash
# Update ban time for existing rule
banforge rule edit -n "SSH Bruteforce" -t 2h
# Change status code
banforge rule edit -n "Forbidden" -c 403
```
---
#### Remove a rule
```shell
banforge rule remove <name>
```
**Description**
Permanently delete a rule by name.
**Example:**
```bash
banforge rule remove "Old Rule"
```
---
## Ban time format
Use the following suffixes for ban duration:
| Suffix | Duration |
| ------ | -------- |
| `s` | Seconds |
| `m` | Minutes |
| `h` | Hours |
| `d` | Days |
| `M` | Months (30 days) |
| `y` | Years (365 days) |
**Examples:** `30s`, `5m`, `2h`, `1d`, `1M`, `1y`

View File

@@ -1,171 +0,0 @@
# Configs
## config.toml
Main configuration file for BanForge.
Example:
```toml
[firewall]
name = "nftables"
config = "/etc/nftables.conf"
[[service]]
name = "nginx"
logging = "file"
log_path = "/home/d3m0k1d/test.log"
enabled = true
[[service]]
name = "nginx"
logging = "journald"
log_path = "nginx"
enabled = false
```
**Description**
The [firewall] section defines firewall parameters. The banforge init command automatically detects your installed firewall (nftables, iptables, ufw, firewalld). For firewalls that require a configuration file, specify the path in the config parameter.
The [[service]] section is configured manually. Currently, only nginx is supported. To add a service, create a [[service]] block and specify the log_path to the nginx log file you want to monitor.
logging require in format "file" or "journald"
if you use journald logging, log_path require in format "service_name"
## rules.toml
Rules configuration file for BanForge.
If you wanna configure rules by cli command see [here](https://github.com/d3m0k1d/BanForge/blob/main/docs/cli.md)
Example:
```toml
[[rule]]
name = "304 http"
service = "nginx"
path = ""
status = "304"
max_retry = 3
method = ""
ban_time = "1m"
# Actions are executed after successful ban
[[rule.action]]
type = "email"
enabled = true
email = "admin@example.com"
email_sender = "banforge@example.com"
email_subject = "BanForge Alert: IP Banned"
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "user@example.com"
smtp_password = "password"
smtp_tls = true
body = "IP {ip} has been banned for rule {rule}"
[[rule.action]]
type = "webhook"
enabled = true
url = "https://hooks.example.com/alert"
method = "POST"
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\", \"service\": \"{service}\"}"
[[rule.action]]
type = "script"
enabled = true
script = "/usr/local/bin/notify.sh"
interpretator = "bash"
```
**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".
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*").
If max_retry = 0 ban on first request.
## Actions
Actions are executed after a successful IP ban. You can configure multiple actions per rule.
### Action Types
#### 1. Email Notification
Send email alerts when an IP is banned.
```toml
[[rule.action]]
type = "email"
enabled = true
email = "admin@example.com"
email_sender = "banforge@example.com"
email_subject = "BanForge Alert"
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "user@example.com"
smtp_password = "password"
smtp_tls = true
body = "IP {ip} has been banned"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "email" |
| `enabled` | + | Enable/disable this action |
| `email` | + | Recipient email address |
| `email_sender` | + | Sender email address |
| `email_subject` | - | Email subject (default: "BanForge Alert") |
| `smtp_host` | + | SMTP server host |
| `smtp_port` | + | SMTP server port |
| `smtp_user` | + | SMTP username |
| `smtp_password` | + | SMTP password |
| `smtp_tls` | - | Use TLS connection (default: false) |
| `body` | - | Email body text |
#### 2. Webhook Notification
Send HTTP webhook requests when an IP is banned.
```toml
[[rule.action]]
type = "webhook"
enabled = true
url = "https://hooks.example.com/alert"
method = "POST"
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\"}"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "webhook" |
| `enabled` | + | Enable/disable this action |
| `url` | + | Webhook URL |
| `method` | - | HTTP method (default: "POST") |
| `headers` | - | HTTP headers as key-value pairs |
| `body` | - | Request body (supports variables) |
#### 3. Script Execution
Execute a custom script when an IP is banned.
```toml
[[rule.action]]
type = "script"
enabled = true
script = "/usr/local/bin/notify.sh"
interpretator = "bash"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "script" |
| `enabled` | + | Enable/disable this action |
| `script` | + | Path to script file |
| `interpretator` | - | Script interpretator (e.g., "bash", "python"). If empty, script runs directly |
### Variables
The following variables can be used in `body` fields (email, webhook):
| Variable | Description |
|----------|-------------|
| `{ip}` | Banned IP address |
| `{rule}` | Rule name that triggered the ban |
| `{service}` | Service name |
| `{ban_time}` | Ban duration |

View File

@@ -1,251 +0,0 @@
.TH BANFORGE 1 "24 February 2026" "BanForge 1.0"
.
.SH NAME
banforge \- BanForge IPS utility for Linux
.
.SH SYNOPSIS
.B banforge
[\fIOPTIONS\fR] \fICOMMAND\fR [\fIARGUMENTS\fR]
.
.SH DESCRIPTION
BanForge is an Intrusion Prevention System (IPS) utility for Linux.
It monitors service logs, detects anomalies and malicious activity,
and automatically applies firewall rules to block suspicious IP addresses.
.
.PP
The program consists of two components:
.RS
.IP \(bu 2
\fBbanforge\fR \- CLI utility for management
.IP \(bu 2
\fBbanforge daemon\fR \- background service for real-time monitoring
.RE
.
.SH COMMANDS
.
.SS init \- Create configuration files
.PP
\fBbanforge init\fR
.PP
Creates the necessary directories and base configuration files:
.RS
.IP \(bu 2
\fI/etc/banforge/config.toml\fR \- main configuration
.IP \(bu 2
\fI/etc/banforge/rules.toml\fR \- default rules file
.IP \(bu 2
\fI/etc/banforge/rules.d/\fR \- directory for individual rule files
.RE
.
.SS version \- Display BanForge version
.PP
\fBbanforge version\fR
.PP
Displays the current version of the BanForge software.
.
.SS daemon \- Start the BanForge daemon
.PP
\fBbanforge daemon\fR
.PP
Starts the BanForge daemon process in the background.
The daemon continuously monitors incoming requests, detects anomalies,
and applies firewall rules in real-time.
.
.SS firewall \- Manage firewall rules
.PP
\fBbanforge ban\fR \fI<ip>\fR [\fIOPTIONS\fR]
.br
\fBbanforge unban\fR \fI<ip>\fR
.PP
These commands provide an abstraction over your firewall.
.PP
\fBoptions:\fR
.RS
.IP \(bu 2
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1 year)
.RE
.PP
\fBExamples:\fR
.RS
.IP \(bu 2
\fBbanforge ban 192.168.1.100 -t 1h\fR \- Ban IP for 1 hour
.IP \(bu 2
\fBbanforge unban 192.168.1.100\fR \- Unban IP
.RE
.
.SS ports \- Manage firewall ports
.PP
\fBbanforge open\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
.br
\fBbanforge close\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
.PP
Open or close ports on the firewall.
.PP
\fBflags:\fR
.RS
.IP \(bu 2
\fB-port\fR \- Port number (e.g., 80) \fI(required)\fR
.IP \(bu 2
\fB-protocol\fR \- Protocol (tcp/udp) \fI(required)\fR
.RE
.PP
\fBExamples:\fR
.RS
.IP \(bu 2
\fBbanforge open -port 80 -protocol tcp\fR
.IP \(bu 2
\fBbanforge close -port 443 -protocol tcp\fR
.RE
.
.SS list \- List blocked IP addresses
.PP
\fBbanforge list\fR
.PP
Outputs a table of IP addresses that are currently blocked.
.
.SS rule \- Manage detection rules
.PP
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
.
.SS "rule add \- Add a new rule"
.PP
\fBbanforge rule add\fR \fB-n\fR \fI<name>\fR \fB-s\fR \fI<service>\fR [\fIOPTIONS\fR]
.PP
\fBflags:\fR
.RS
.IP \(bu 2
\fB-n\fR, \fB--name\fR \- Rule name (used as filename) \fI(required)\fR
.IP \(bu 2
\fB-s\fR, \fB--service\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
.IP \(bu 2
\fB-p\fR, \fB--path\fR \- Request path to match
.IP \(bu 2
\fB-m\fR, \fB--method\fR \- HTTP method (GET, POST, etc.)
.IP \(bu 2
\fB-c\fR, \fB--status\fR \- HTTP status code (403, 404, etc.)
.IP \(bu 2
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1y)
.IP \(bu 2
\fB-r\fR, \fB--max_retry\fR \- Max retries before ban (default: 0)
.RE
.PP
\fBNote:\fR At least one of \fB-p\fR, \fB-m\fR, or \fB-c\fR must be specified.
.PP
\fBExamples:\fR
.RS
.IP \(bu 2
\fBbanforge rule add -n "Forbidden" -s nginx -c 403 -t 30m\fR
.IP \(bu 2
\fBbanforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3\fR
.IP \(bu 2
\fBbanforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5\fR
.RE
.
.SS "rule list \- List all rules"
.PP
\fBbanforge rule list\fR
.PP
Displays all configured rules in a table format.
.
.SS "rule edit \- Edit an existing rule"
.PP
\fBbanforge rule edit\fR \fB-n\fR \fI<name>\fR [\fIOPTIONS\fR]
.PP
Edit fields of an existing rule. Only specified fields will be updated.
.PP
\fBflags:\fR
.RS
.IP \(bu 2
\fB-n\fR, \fB--name\fR \- Rule name to edit \fI(required)\fR
.IP \(bu 2
\fB-s\fR, \fB--service\fR \- New service name
.IP \(bu 2
\fB-p\fR, \fB--path\fR \- New path
.IP \(bu 2
\fB-m\fR, \fB--method\fR \- New method
.IP \(bu 2
\fB-c\fR, \fB--status\fR \- New status code
.RE
.PP
\fBExamples:\fR
.RS
.IP \(bu 2
\fBbanforge rule edit -n "SSH Bruteforce" -t 2h\fR
.IP \(bu 2
\fBbanforge rule edit -n "Forbidden" -c 403\fR
.RE
.
.SS "rule remove \- Remove a rule"
.PP
\fBbanforge rule remove\fR \fI<name>\fR
.PP
Permanently delete a rule by name.
.PP
\fBExample:\fR \fBbanforge rule remove "Old Rule"\fR
.
.SH "BAN TIME FORMAT"
.PP
Use the following suffixes for ban duration:
.RS
.IP \(bu 2
\fBs\fR \- Seconds
.IP \(bu 2
\fBm\fR \- Minutes
.IP \(bu 2
\fBh\fR \- Hours
.IP \(bu 2
\fBd\fR \- Days
.IP \(bu 2
\fBM\fR \- Months (30 days)
.IP \(bu 2
\fBy\fR \- Years (365 days)
.RE
.PP
\fBExamples:\fR 30s, 5m, 2h, 1d, 1M, 1y
.
.SH "CONFIGURATION FILES"
.PP
Configuration files are stored in \fI/etc/banforge/\fR:
.RS
.IP \(bu 2
\fIconfig.toml\fR \- main daemon configuration
.IP \(bu 2
\fIrules.toml\fR \- default rules
.IP \(bu 2
\fIrules.d/*.toml\fR \- individual rule files
.RE
.PP
See \fBbanforge(5)\fR for configuration file format details including actions setup.
.
.SH "EXIT STATUS"
.PP
\fB0\fR \- Success
.br
\fB1\fR \- General error
.br
\fB2\fR \- Configuration error
.
.SH EXAMPLES
.PP
.RS
.IP \(bu 2
Initialize configuration: \fBbanforge init\fR
.IP \(bu 2
Start daemon: \fBbanforge daemon\fR
.IP \(bu 2
Ban an IP: \fBbanforge ban 192.168.1.100 -t 1h\fR
.IP \(bu 2
Add a rule: \fBbanforge rule add -n "404" -s nginx -c 404 -t 30m\fR
.IP \(bu 2
List blocked IPs: \fBbanforge list\fR
.RE
.
.SH "SEE ALSO"
.BR iptables (8),
.BR nftables (8),
.BR fail2ban (1),
.BR nginx (8)
.
.SH AUTHOR
.PP
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru

View File

@@ -1,405 +0,0 @@
.TH BANFORGE 5 "24 February 2026" "BanForge 1.0"
.
.SH NAME
banforge \- BanForge configuration file format
.
.SH DESCRIPTION
BanForge uses TOML configuration files stored in \fI/etc/banforge/\fR:
.RS
.IP \(bu 2
\fIconfig.toml\fR \- main daemon configuration
.IP \(bu 2
\fIrules.toml\fR \- default rules (legacy)
.IP \(bu 2
\fIrules.d/*.toml\fR \- individual rule files (recommended)
.RE
.
.SH "CONFIG.TOML FORMAT"
.PP
Main configuration file for BanForge daemon.
.PP
\fBStructure:\fR
.RS
.IP \(bu 2
\fB[firewall]\fR \- firewall parameters
.IP \(bu 2
\fB[[service]]\fR \- service monitoring configuration (multiple allowed)
.IP \(bu 2
\fB[metrics]\fR \- Prometheus metrics configuration (optional)
.RE
.
.SS "Firewall Section"
.PP
\fB[firewall]\fR
.PP
Defines firewall parameters. The \fBbanforge init\fR command automatically
detects your installed firewall (nftables, iptables, ufw, firewalld).
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBname\fR \- Firewall type (nftables, iptables, ufw, firewalld)
.IP \(bu 2
\fBconfig\fR \- Path to firewall configuration file
.RE
.PP
\fBExample:\fR
.RS
.nf
[firewall]
name = "nftables"
config = "/etc/nftables.conf"
.fi
.RE
.
.SS "Service Section"
.PP
\fB[[service]]\fR
.PP
Configures service monitoring. Multiple service blocks are allowed.
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBname\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
.IP \(bu 2
\fBlogging\fR \- Log source type: "file" or "journald" \fI(required)\fR
.IP \(bu 2
\fBlog_path\fR \- Path to log file or journal unit name \fI(required)\fR
.IP \(bu 2
\fBenabled\fR \- Enable/disable service monitoring (true/false)
.RE
.PP
\fBExamples:\fR
.RS
.nf
# File-based logging
[[service]]
name = "nginx"
logging = "file"
log_path = "/var/log/nginx/access.log"
enabled = true
# Journald logging
[[service]]
name = "nginx"
logging = "journald"
log_path = "nginx"
enabled = false
.fi
.RE
.PP
\fBNote:\fR When using journald logging, specify the service name in \fBlog_path\fR.
.
.SS "Metrics Section"
.PP
\fB[metrics]\fR
.PP
Configures Prometheus metrics endpoint (optional).
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBenabled\fR \- Enable/disable metrics (true/false)
.IP \(bu 2
\fBport\fR \- Port for metrics endpoint
.RE
.PP
\fBExample:\fR
.RS
.nf
[metrics]
enabled = true
port = 9090
.fi
.RE
.
.SH "RULES.TOML FORMAT"
.PP
Detection rules define conditions for blocking IP addresses.
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
.
.SS "Rule Section"
.PP
\fB[[rule]]\fR
.PP
Defines a single detection rule.
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBname\fR \- Rule name (used as filename) \fI(required)\fR
.IP \(bu 2
\fBservice\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
.IP \(bu 2
\fBpath\fR \- Request path to match (e.g., "/admin/*", "*.php")
.IP \(bu 2
\fBstatus\fR \- HTTP status code (403, 404, 304, etc.)
.IP \(bu 2
\fBmethod\fR \- HTTP method (GET, POST, etc.)
.IP \(bu 2
\fBmax_retry\fR \- Max retries before ban (0 = ban on first request)
.IP \(bu 2
\fBban_time\fR \- Ban duration (e.g., "1m", "1h", "1d", "1M", "1y")
.RE
.PP
\fBNote:\fR At least one of \fBpath\fR, \fBstatus\fR, or \fBmethod\fR must be specified.
.PP
\fBExamples:\fR
.RS
.nf
# Ban on HTTP 304 status
[[rule]]
name = "304 http"
service = "nginx"
path = ""
status = "304"
max_retry = 3
method = ""
ban_time = "1m"
# Ban on path pattern (admin panel)
[[rule]]
name = "Admin Access"
service = "nginx"
path = "/admin/*"
status = ""
method = ""
max_retry = 2
ban_time = "2h"
# SSH brute force protection
[[rule]]
name = "SSH Bruteforce"
service = "ssh"
path = ""
status = "Failed"
method = ""
max_retry = 5
ban_time = "1h"
.fi
.RE
.
.SH "BAN TIME FORMAT"
.PP
Use the following suffixes for ban duration:
.RS
.IP \(bu 2
\fBs\fR \- Seconds
.IP \(bu 2
\fBm\fR \- Minutes
.IP \(bu 2
\fBh\fR \- Hours
.IP \(bu 2
\fBd\fR \- Days
.IP \(bu 2
\fBM\fR \- Months (30 days)
.IP \(bu 2
\fBy\fR \- Years (365 days)
.RE
.
.SH "ACTIONS"
.PP
Rules can trigger custom actions when an IP is banned.
Multiple actions can be configured per rule.
Actions are executed after successful ban at firewall level.
.
.SS "Supported Action Types"
.PP
.RS
.IP \(bu 2
\fBemail\fR \- Send email notification via SMTP
.IP \(bu 2
\fBwebhook\fR \- Send HTTP request to external service
.IP \(bu 2
\fBscript\fR \- Execute custom script
.RE
.
.SS "Variables"
.PP
The following variables can be used in \fBbody\fR fields:
.RS
.IP \(bu 2
\fB{ip}\fR \- Banned IP address
.IP \(bu 2
\fB{rule}\fR \- Rule name that triggered the ban
.IP \(bu 2
\fB{service}\fR \- Service name
.IP \(bu 2
\fB{ban_time}\fR \- Ban duration
.RE
.
.SS "Script Action"
.PP
Execute a custom script when an IP is banned.
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBtype\fR \- "script"
.IP \(bu 2
\fBenabled\fR \- Enable/disable action (true/false)
.IP \(bu 2
\fBinterpretator\fR \- Script interpretator (e.g., "bash", "python")
.IP \(bu 2
\fBscript\fR \- Path to script file
.RE
.PP
\fBExample:\fR
.RS
.nf
[[rule]]
name = "Notify on Ban"
service = "nginx"
status = "403"
ban_time = "1h"
[[rule.action]]
type = "script"
enabled = true
interpretator = "bash"
script = "/opt/banforge/scripts/notify.sh"
.fi
.RE
.
.SS "Webhook Action"
.PP
Send HTTP webhook when an IP is banned.
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBtype\fR \- "webhook"
.IP \(bu 2
\fBenabled\fR \- Enable/disable action (true/false)
.IP \(bu 2
\fBurl\fR \- Webhook URL \fI(required)\fR
.IP \(bu 2
\fBmethod\fR \- HTTP method (POST, GET, etc.)
.IP \(bu 2
\fBheaders\fR \- Custom headers (key-value pairs)
.IP \(bu 2
\fBbody\fR \- Request body (supports variables)
.RE
.PP
\fBExample:\fR
.RS
.nf
[[rule.action]]
type = "webhook"
enabled = true
url = "https://hooks.example.com/ban"
method = "POST"
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer TOKEN" }
body = "{\\\"ip\\\": \\\"{ip}\\\", \\\"rule\\\": \\\"{rule}\\\"}"
.fi
.RE
.
.SS "Email Action"
.PP
Send email notification when an IP is banned.
.PP
\fBFields:\fR
.RS
.IP \(bu 2
\fBtype\fR \- "email"
.IP \(bu 2
\fBenabled\fR \- Enable/disable action (true/false)
.IP \(bu 2
\fBemail\fR \- Recipient email address \fI(required)\fR
.IP \(bu 2
\fBemail_sender\fR \- Sender email address \fI(required)\fR
.IP \(bu 2
\fBemail_subject\fR \- Email subject (default: "BanForge Alert")
.IP \(bu 2
\fBsmtp_host\fR \- SMTP server host \fI(required)\fR
.IP \(bu 2
\fBsmtp_port\fR \- SMTP server port \fI(required)\fR
.IP \(bu 2
\fBsmtp_user\fR \- SMTP username \fI(required)\fR
.IP \(bu 2
\fBsmtp_password\fR \- SMTP password \fI(required)\fR
.IP \(bu 2
\fBsmtp_tls\fR \- Enable TLS (true/false)
.IP \(bu 2
\fBbody\fR \- Email body text (supports variables)
.RE
.PP
\fBExample:\fR
.RS
.nf
[[rule.action]]
type = "email"
enabled = true
email = "admin@example.com"
email_sender = "banforge@example.com"
email_subject = "IP Banned"
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "banforge"
smtp_password = "secret"
smtp_tls = true
body = "IP {ip} has been banned for rule {rule}"
.fi
.RE
.
.SH "COMPLETE RULE EXAMPLE WITH ACTIONS"
.PP
.RS
.nf
[[rule]]
name = "nginx-403"
service = "nginx"
status = "403"
max_retry = 3
ban_time = "1h"
# Email notification
[[rule.action]]
type = "email"
enabled = true
email = "admin@example.com"
email_sender = "banforge@example.com"
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "banforge"
smtp_password = "secret"
smtp_tls = true
body = "IP {ip} banned by rule {rule}"
# Slack webhook
[[rule.action]]
type = "webhook"
enabled = true
url = "https://hooks.slack.com/services/XXX/YYY/ZZZ"
method = "POST"
headers = { "Content-Type" = "application/json" }
body = "{\\\"text\\\": \\\"IP {ip} banned for rule {rule}\\\"}"
# Custom script
[[rule.action]]
type = "script"
enabled = true
script = "/usr/local/bin/ban-notify.sh"
interpretator = "bash"
.fi
.RE
.
.SH FILES
.PP
.RS
.IP \(bu 2
\fI/etc/banforge/config.toml\fR \- main configuration
.IP \(bu 2
\fI/etc/banforge/rules.d/\fR \- rule files directory
.RE
.
.SH "SEE ALSO"
.BR banforge (1),
.BR iptables (8),
.BR nftables (8),
.BR systemd (1)
.
.SH AUTHOR
.PP
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru

20
go.mod
View File

@@ -2,27 +2,9 @@ module github.com/d3m0k1d/BanForge
go 1.25.5
require (
github.com/BurntSushi/toml v1.6.0
github.com/jedib0t/go-pretty/v6 v6.7.8
github.com/spf13/cobra v1.10.2
gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.46.1
)
require github.com/spf13/cobra v1.10.2
require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

71
go.sum
View File

@@ -1,82 +1,11 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
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-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
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/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=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -1,129 +0,0 @@
package actions
import (
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"github.com/d3m0k1d/BanForge/internal/config"
)
func SendEmail(action config.Action) error {
if !action.Enabled {
return nil
}
if action.SMTPHost == "" {
return fmt.Errorf("SMTP host is empty")
}
if action.Email == "" {
return fmt.Errorf("recipient email is empty")
}
if action.EmailSender == "" {
return fmt.Errorf("sender email is empty")
}
addr := fmt.Sprintf("%s:%d", action.SMTPHost, action.SMTPPort)
subject := action.EmailSubject
if subject == "" {
subject = "BanForge Alert"
}
var body strings.Builder
body.WriteString("From: " + action.EmailSender + "\r\n")
body.WriteString("To: " + action.Email + "\r\n")
body.WriteString("Subject: " + subject + "\r\n")
body.WriteString("MIME-Version: 1.0\r\n")
body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
body.WriteString("\r\n")
body.WriteString(action.Body)
auth := smtp.PlainAuth("", action.SMTPUser, action.SMTPPassword, action.SMTPHost)
if action.SMTPTLS {
return sendEmailWithTLS(
addr,
auth,
action.EmailSender,
[]string{action.Email},
body.String(),
)
}
return smtp.SendMail(
addr,
auth,
action.EmailSender,
[]string{action.Email},
[]byte(body.String()),
)
}
func sendEmailWithTLS(addr string, auth smtp.Auth, from string, to []string, msg string) error {
host, _, err := net.SplitHostPort(addr)
if err != nil {
return fmt.Errorf("split host port: %w", err)
}
tlsConfig := &tls.Config{
ServerName: host,
MinVersion: tls.VersionTLS12,
}
conn, err := tls.Dial("tcp", addr, tlsConfig)
if err != nil {
return fmt.Errorf("dial TLS: %w", err)
}
defer func() {
_ = conn.Close()
}()
c, err := smtp.NewClient(conn, host)
if err != nil {
return fmt.Errorf("create SMTP client: %w", err)
}
defer func() {
_ = c.Close()
}()
if auth != nil {
if ok, _ := c.Extension("AUTH"); !ok {
return fmt.Errorf("SMTP server does not support AUTH")
}
if err = c.Auth(auth); err != nil {
return fmt.Errorf("authenticate: %w", err)
}
}
if err = c.Mail(from); err != nil {
return fmt.Errorf("mail from: %w", err)
}
for _, addr := range to {
if err = c.Rcpt(addr); err != nil {
return fmt.Errorf("rcpt to: %w", err)
}
}
w, err := c.Data()
if err != nil {
return fmt.Errorf("data: %w", err)
}
_, err = w.Write([]byte(msg))
if err != nil {
return fmt.Errorf("write message: %w", err)
}
err = w.Close()
if err != nil {
return fmt.Errorf("close data: %w", err)
}
return c.Quit()
}

View File

@@ -1,338 +0,0 @@
package actions
import (
"bufio"
"crypto/tls"
"fmt"
"net"
"strings"
"testing"
"time"
"github.com/d3m0k1d/BanForge/internal/config"
)
type simpleSMTPServer struct {
listener net.Listener
messages []string
done chan struct{}
}
func newSimpleSMTPServer(t *testing.T, useTLS bool) *simpleSMTPServer {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("Failed to create listener: %v", err)
}
s := &simpleSMTPServer{
listener: l,
messages: make([]string, 0),
done: make(chan struct{}),
}
go s.serve(t, useTLS)
time.Sleep(50 * time.Millisecond)
return s
}
func (s *simpleSMTPServer) serve(t *testing.T, useTLS bool) {
defer close(s.done)
for {
conn, err := s.listener.Accept()
if err != nil {
return
}
go func(c net.Conn) {
defer c.Close()
_, _ = c.Write([]byte("220 localhost ESMTP Test Server\r\n"))
reader := bufio.NewReader(c)
for {
line, err := reader.ReadString('\n')
if err != nil {
return
}
line = strings.TrimSpace(line)
parts := strings.SplitN(line, " ", 2)
cmd := strings.ToUpper(parts[0])
switch cmd {
case "EHLO", "HELO":
if useTLS {
_, _ = c.Write([]byte("250-localhost\r\n250 STARTTLS\r\n"))
} else {
_, _ = c.Write([]byte("250-localhost\r\n250 AUTH PLAIN\r\n"))
}
case "STARTTLS":
if !useTLS {
_, _ = c.Write([]byte("454 TLS not available\r\n"))
return
}
_, _ = c.Write([]byte("220 Ready to start TLS\r\n"))
tlsConfig := &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
tlsConn := tls.Server(c, tlsConfig)
if err := tlsConn.Handshake(); err != nil {
return
}
reader = bufio.NewReader(tlsConn)
c = tlsConn
case "AUTH":
_, _ = c.Write([]byte("235 Authentication successful\r\n"))
case "MAIL":
_, _ = c.Write([]byte("250 OK\r\n"))
case "RCPT":
_, _ = c.Write([]byte("250 OK\r\n"))
case "DATA":
_, _ = c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
var msgBuilder strings.Builder
for {
msgLine, err := reader.ReadString('\n')
if err != nil {
return
}
if strings.TrimSpace(msgLine) == "." {
break
}
msgBuilder.WriteString(msgLine)
}
s.messages = append(s.messages, msgBuilder.String())
_, _ = c.Write([]byte("250 OK\r\n"))
case "QUIT":
_, _ = c.Write([]byte("221 Bye\r\n"))
return
default:
_, _ = c.Write([]byte("502 Command not implemented\r\n"))
}
}
}(conn)
}
}
func (s *simpleSMTPServer) Addr() string {
return s.listener.Addr().String()
}
func (s *simpleSMTPServer) Close() {
_ = s.listener.Close()
<-s.done
}
func (s *simpleSMTPServer) MessageCount() int {
return len(s.messages)
}
func TestSendEmail_Validation(t *testing.T) {
tests := []struct {
name string
action config.Action
wantErr bool
errMsg string
}{
{
name: "disabled action",
action: config.Action{
Type: "email",
Enabled: false,
Email: "test@example.com",
EmailSender: "sender@example.com",
SMTPHost: "smtp.example.com",
},
wantErr: false,
},
{
name: "empty SMTP host",
action: config.Action{
Type: "email",
Enabled: true,
Email: "test@example.com",
EmailSender: "sender@example.com",
SMTPHost: "",
},
wantErr: true,
errMsg: "SMTP host is empty",
},
{
name: "empty recipient email",
action: config.Action{
Type: "email",
Enabled: true,
Email: "",
EmailSender: "sender@example.com",
SMTPHost: "smtp.example.com",
},
wantErr: true,
errMsg: "recipient email is empty",
},
{
name: "empty sender email",
action: config.Action{
Type: "email",
Enabled: true,
Email: "test@example.com",
EmailSender: "",
SMTPHost: "smtp.example.com",
},
wantErr: true,
errMsg: "sender email is empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := SendEmail(tt.action)
if (err != nil) != tt.wantErr {
t.Errorf("SendEmail() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr && err != nil && tt.errMsg != "" {
if err.Error() != tt.errMsg {
t.Errorf("SendEmail() error message = %v, want %v", err.Error(), tt.errMsg)
}
}
})
}
}
func TestSendEmail_WithoutTLS(t *testing.T) {
server := newSimpleSMTPServer(t, false)
defer server.Close()
host, portStr, _ := net.SplitHostPort(server.Addr())
var port int
fmt.Sscanf(portStr, "%d", &port)
action := config.Action{
Type: "email",
Enabled: true,
Email: "recipient@example.com",
EmailSender: "sender@example.com",
EmailSubject: "Test Subject",
SMTPHost: host,
SMTPPort: port,
SMTPUser: "user",
SMTPPassword: "pass",
SMTPTLS: false,
Body: "Test message body",
}
err := SendEmail(action)
if err != nil {
t.Fatalf("SendEmail() unexpected error: %v", err)
}
if server.MessageCount() != 1 {
t.Errorf("Expected 1 message, got %d", server.MessageCount())
}
}
func TestSendEmail_WithTLS(t *testing.T) {
t.Skip("TLS test requires proper TLS handshake handling")
server := newSimpleSMTPServer(t, true)
defer server.Close()
host, portStr, _ := net.SplitHostPort(server.Addr())
var port int
fmt.Sscanf(portStr, "%d", &port)
action := config.Action{
Type: "email",
Enabled: true,
Email: "recipient@example.com",
EmailSender: "sender@example.com",
EmailSubject: "Test Subject TLS",
SMTPHost: host,
SMTPPort: port,
SMTPUser: "user",
SMTPPassword: "pass",
SMTPTLS: true,
Body: "Test TLS message body",
}
err := SendEmail(action)
if err != nil {
t.Fatalf("SendEmail() unexpected error: %v", err)
}
if server.MessageCount() != 1 {
t.Errorf("Expected 1 message, got %d", server.MessageCount())
}
}
func TestSendEmail_DefaultSubject(t *testing.T) {
server := newSimpleSMTPServer(t, false)
defer server.Close()
host, portStr, _ := net.SplitHostPort(server.Addr())
var port int
fmt.Sscanf(portStr, "%d", &port)
action := config.Action{
Type: "email",
Enabled: true,
Email: "test@example.com",
EmailSender: "sender@example.com",
SMTPHost: host,
SMTPPort: port,
Body: "Test body",
}
err := SendEmail(action)
if err != nil {
t.Fatalf("SendEmail() unexpected error: %v", err)
}
if server.MessageCount() != 1 {
t.Errorf("Expected 1 message, got %d", server.MessageCount())
}
}
func TestSendEmail_Integration(t *testing.T) {
server := newSimpleSMTPServer(t, false)
defer server.Close()
host, portStr, _ := net.SplitHostPort(server.Addr())
var port int
fmt.Sscanf(portStr, "%d", &port)
action := config.Action{
Type: "email",
Enabled: true,
Email: "to@example.com",
EmailSender: "from@example.com",
EmailSubject: "Integration Test",
SMTPHost: host,
SMTPPort: port,
SMTPUser: "testuser",
SMTPPassword: "testpass",
SMTPTLS: false,
Body: "Integration test message",
}
err := SendEmail(action)
if err != nil {
t.Fatalf("SendEmail() failed: %v", err)
}
t.Logf("Email sent successfully, server received %d message(s)", server.MessageCount())
}

View File

@@ -1,19 +0,0 @@
package actions
import "github.com/d3m0k1d/BanForge/internal/config"
type Executor struct {
Action config.Action
}
func (e *Executor) Execute() error {
switch e.Action.Type {
case "email":
return SendEmail(e.Action)
case "webhook":
return SendWebhook(e.Action)
case "script":
return RunScript(e.Action)
}
return nil
}

View File

@@ -1,32 +0,0 @@
package actions
import (
"fmt"
"os/exec"
"github.com/d3m0k1d/BanForge/internal/config"
)
func RunScript(action config.Action) error {
if !action.Enabled {
return nil
}
if action.Script == "" {
return fmt.Errorf("script on config is empty")
}
if action.Interpretator == "" {
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(action.Script)
err := cmd.Run()
if err != nil {
return fmt.Errorf("run script: %w", err)
}
}
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(action.Interpretator, action.Script)
err := cmd.Run()
if err != nil {
return fmt.Errorf("run script: %w", err)
}
return nil
}

View File

@@ -1,66 +0,0 @@
package actions
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/config"
)
var defaultClient = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
func SendWebhook(action config.Action) error {
if !action.Enabled {
return nil
}
if action.URL == "" {
return fmt.Errorf("URL on config is empty")
}
method := action.Method
if method == "" {
method = "POST"
}
var bodyReader io.Reader
if action.Body != "" {
bodyReader = strings.NewReader(action.Body)
if action.Headers["Content-Type"] == "" && action.Headers["content-type"] == "" {
action.Headers["Content-Type"] = "application/json"
}
}
req, err := http.NewRequest(method, action.URL, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
for key, value := range action.Headers {
req.Header.Add(key, value)
}
// #nosec G704 - HTTP request validation by system administrators
resp, err := defaultClient.Do(req)
if err != nil {
return fmt.Errorf("execute request: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
}
return nil
}

View File

@@ -1,12 +1,9 @@
package blocker
import (
"fmt"
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Firewalld struct {
@@ -24,24 +21,19 @@ func (f *Firewalld) Ban(ip string) error {
if err != nil {
return err
}
metrics.IncBanAttempt("firewalld")
// #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add source " + ip + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Reload " + string(output))
metrics.IncBan("firewalld")
return nil
}
@@ -50,90 +42,18 @@ func (f *Firewalld) Unban(ip string) error {
if err != nil {
return err
}
metrics.IncUnbanAttempt("firewalld")
// #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Remove source " + ip + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Reload " + string(output))
metrics.IncUnban("firewalld")
return nil
}
func (f *Firewalld) PortOpen(port int, protocol string) error {
// #nosec G204 - handle is extracted from Firewalld output and validated
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
cmd := exec.Command(
"firewall-cmd",
"--zone=public",
"--add-port="+s+"/"+protocol,
"--permanent",
)
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add port " + s + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Reload " + string(output))
}
return nil
}
func (f *Firewalld) PortClose(port int, protocol string) error {
// #nosec G204 - handle is extracted from nftables output and validated
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
cmd := exec.Command(
"firewall-cmd",
"--zone=public",
"--remove-port="+s+"/"+protocol,
"--permanent",
)
output, err := cmd.CombinedOutput()
if err != nil {
metrics.IncError()
return err
}
f.logger.Info("Remove port " + s + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
metrics.IncError()
return err
}
f.logger.Info("Reload " + string(output))
}
return nil
}
func (f *Firewalld) Setup(config string) error {
return nil
}

View File

@@ -1,30 +1,6 @@
package blocker
import (
"fmt"
"github.com/d3m0k1d/BanForge/internal/logger"
)
type BlockerEngine interface {
Ban(ip string) error
Unban(ip string) error
Setup(config string) error
PortOpen(port int, protocol string) error
PortClose(port int, protocol string) 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

@@ -2,10 +2,8 @@ package blocker
import (
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Iptables struct {
@@ -25,40 +23,35 @@ func (f *Iptables) Ban(ip string) error {
if err != nil {
return err
}
metrics.IncBanAttempt("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd := exec.Command("iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
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))
metrics.IncError()
return err
}
f.logger.Info("IP banned",
"ip", ip,
"output", string(output))
metrics.IncBan("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("iptables-save", "-f", f.config)
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))
metrics.IncError()
return err
}
f.logger.Info("config saved",
@@ -72,40 +65,35 @@ func (f *Iptables) Unban(ip string) error {
if err != nil {
return err
}
metrics.IncUnbanAttempt("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd := exec.Command("iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
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))
metrics.IncError()
return err
}
f.logger.Info("IP unbanned",
"ip", ip,
"output", string(output))
metrics.IncUnban("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("iptables-save", "-f", f.config)
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))
metrics.IncError()
return err
}
f.logger.Info("config saved",
@@ -113,71 +101,3 @@ func (f *Iptables) Unban(ip string) error {
"output", string(output))
return nil
}
func (f *Iptables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return nil
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add port " + s + " " + string(output))
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("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))
metrics.IncError()
return err
}
}
return nil
}
func (f *Iptables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return nil
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add port " + s + " " + string(output))
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("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))
metrics.IncError()
return err
}
}
return nil
}
func (f *Iptables) Setup(config string) error {
return nil
}

View File

@@ -3,11 +3,9 @@ package blocker
import (
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Nftables struct {
@@ -22,14 +20,65 @@ func NewNftables(logger *logger.Logger, config string) *Nftables {
}
}
func SetupNftables(config string) error {
err := validateConfigPath(config)
if err != nil {
return err
}
cmd := exec.Command("sudo", "nft", "list", "table", "inet", "banforge")
if err := cmd.Run(); err != nil {
cmd = exec.Command("sudo", "nft", "add", "table", "inet", "banforge")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create table: %s", string(output))
}
}
cmd = exec.Command("sudo", "nft", "list", "chain", "inet", "banforge", "input")
if err := cmd.Run(); err != nil {
script := "sudo nft 'add chain inet banforge input { type filter hook input priority 0; policy accept; }'"
cmd = exec.Command("bash", "-c", script)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create input chain: %s", string(output))
}
}
cmd = exec.Command("sudo", "nft", "list", "chain", "inet", "banforge", "banned")
if err := cmd.Run(); err != nil {
cmd = exec.Command("sudo", "nft", "add", "chain", "inet", "banforge", "banned")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to create banned chain: %s", string(output))
}
}
cmd = exec.Command("sudo", "nft", "-a", "list", "chain", "inet", "banforge", "input")
output, err := cmd.CombinedOutput()
if err == nil && !strings.Contains(string(output), "jump banned") {
cmd = exec.Command("sudo", "nft", "add", "rule", "inet", "banforge", "input", "jump", "banned")
output, err = cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to add jump rule: %s", string(output))
}
}
err = saveNftablesConfig(config)
if err != nil {
return fmt.Errorf("failed to save nftables config: %w", err)
}
return nil
}
func (n *Nftables) Ban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
metrics.IncBanAttempt("nftables")
// #nosec G204 - ip is validated
cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned",
cmd := exec.Command("sudo", "nft", "add", "rule", "inet", "banforge", "banned",
"ip", "saddr", ip, "drop")
output, err := cmd.CombinedOutput()
if err != nil {
@@ -37,19 +86,16 @@ func (n *Nftables) Ban(ip string) error {
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
n.logger.Info("IP banned", "ip", ip)
metrics.IncBan("nftables")
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
@@ -62,24 +108,21 @@ func (n *Nftables) Unban(ip string) error {
if err != nil {
return err
}
metrics.IncUnbanAttempt("nftables")
handle, err := n.findRuleHandle(ip)
if err != nil {
n.logger.Error("failed to find rule handle",
"ip", ip,
"error", err.Error())
metrics.IncError()
return err
}
if handle == "" {
n.logger.Warn("no rule found for IP", "ip", ip)
metrics.IncError()
return fmt.Errorf("no rule found for IP %s", ip)
}
// #nosec G204 - handle is extracted from nftables output and validated
cmd := exec.Command("nft", "delete", "rule", "inet", "banforge", "banned",
cmd := exec.Command("sudo", "nft", "delete", "rule", "inet", "banforge", "banned",
"handle", handle)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -88,19 +131,16 @@ func (n *Nftables) Unban(ip string) error {
"handle", handle,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
n.logger.Info("IP unbanned", "ip", ip, "handle", handle)
metrics.IncUnban("nftables")
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
@@ -108,57 +148,8 @@ func (n *Nftables) Unban(ip string) error {
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 filter; policy accept;
jump banned
}
chain banned {
}
}
`
// #nosec G204 - config is managed by adminstartor
cmd := exec.Command("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)
}
// #nosec G204 - config is managed by adminstartor
cmd = exec.Command("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("nft", "-a", "list", "chain", "inet", "banforge", "banned")
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)
@@ -179,102 +170,19 @@ func (n *Nftables) findRuleHandle(ip string) (string, error) {
return "", nil
}
func (n *Nftables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
"add",
"rule",
"inet",
"banforge",
"input",
protocol,
"dport",
s,
"accept",
)
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
metrics.IncError()
return err
}
n.logger.Info("Add port " + s + " " + string(output))
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
}
return nil
}
func (n *Nftables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
"add",
"rule",
"inet",
"banforge",
"input",
protocol,
"dport",
s,
"drop",
)
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
metrics.IncError()
return err
}
n.logger.Info("Add port " + s + " " + string(output))
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
}
return nil
}
func saveNftablesConfig(configPath string) error {
err := validateConfigPath(configPath)
if err != nil {
return err
}
cmd := exec.Command("nft", "list", "ruleset")
cmd := exec.Command("sudo", "nft", "list", "ruleset")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get nftables ruleset: %w", err)
}
// #nosec G204 - managed by system adminstartor
cmd = exec.Command("tee", configPath)
cmd = exec.Command("sudo", "tee", configPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)

View File

@@ -1,12 +1,9 @@
package blocker
import (
"fmt"
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Ufw struct {
@@ -19,116 +16,32 @@ func NewUfw(logger *logger.Logger) *Ufw {
}
}
func (u *Ufw) Ban(ip string) error {
func (ufw *Ufw) Ban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
metrics.IncBanAttempt("ufw")
// #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "deny", "from", ip)
cmd := exec.Command("sudo", "ufw", "--force", "deny", "from", ip)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to ban IP",
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return fmt.Errorf("failed to ban IP %s: %w", ip, err)
ufw.logger.Error(err.Error())
return err
}
u.logger.Info("IP banned", "ip", ip, "output", string(output))
metrics.IncBan("ufw")
ufw.logger.Info("Banning " + ip + " " + string(output))
return nil
}
func (u *Ufw) Unban(ip string) error {
func (ufw *Ufw) Unban(ip string) error {
err := validateIP(ip)
if err != nil {
return err
}
metrics.IncUnbanAttempt("ufw")
// #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "delete", "deny", "from", ip)
cmd := exec.Command("sudo", "ufw", "--force", "delete", "deny", "from", ip)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to unban IP",
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return fmt.Errorf("failed to unban IP %s: %w", ip, err)
}
u.logger.Info("IP unbanned", "ip", ip, "output", string(output))
metrics.IncUnban("ufw")
return nil
}
func (u *Ufw) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "allow", s+"/"+protocol)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error(err.Error())
metrics.IncError()
return err
}
u.logger.Info("Add port " + s + " " + string(output))
}
return nil
}
func (u *Ufw) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol")
metrics.IncError()
return nil
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "deny", s+"/"+protocol)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error(err.Error())
metrics.IncError()
return err
}
u.logger.Info("Add port " + s + " " + string(output))
}
return nil
}
func (u *Ufw) Setup(config string) error {
if config != "" {
fmt.Printf("Ufw dont support config file\n")
cmd := exec.Command("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("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.Error(err.Error())
return err
}
ufw.logger.Info("Unbanning " + ip + " " + string(output))
return nil
}

View File

@@ -1,47 +0,0 @@
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)
}
})
}
}

View File

@@ -1,186 +0,0 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
)
func LoadRuleConfig() ([]Rule, error) {
const rulesDir = "/etc/banforge/rules.d"
var cfg Rules
files, err := os.ReadDir(rulesDir)
if err != nil {
return nil, fmt.Errorf("failed to read rules directory: %w", err)
}
for _, file := range files {
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
continue
}
filePath := filepath.Join(rulesDir, file.Name())
var fileCfg Rules
if _, err := toml.DecodeFile(filePath, &fileCfg); err != nil {
return nil, fmt.Errorf("failed to parse rule file %s: %w", filePath, err)
}
cfg.Rules = append(cfg.Rules, fileCfg.Rules...)
}
return cfg.Rules, nil
}
func NewRule(
name string,
serviceName string,
path string,
status string,
method string,
ttl string,
maxRetry int,
) error {
if name == "" {
return fmt.Errorf("rule name can't be empty")
}
rule := Rule{
Name: name,
ServiceName: serviceName,
Path: path,
Status: status,
Method: method,
BanTime: ttl,
MaxRetry: maxRetry,
}
filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
if _, err := os.Stat(filePath); err == nil {
return fmt.Errorf("rule with name '%s' already exists", name)
}
cfg := Rules{Rules: []Rule{rule}}
// #nosec G304 - validate by sanitizeRuleFilename
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to create rule file: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
}
}()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
return fmt.Errorf("failed to encode rule: %w", 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")
}
rules, err := LoadRuleConfig()
if err != nil {
return fmt.Errorf("failed to load rules: %w", err)
}
found := false
var updatedRule *Rule
for i, rule := range rules {
if rule.Name == name {
found = true
updatedRule = &rules[i]
if serviceName != "" {
updatedRule.ServiceName = serviceName
}
if path != "" {
updatedRule.Path = path
}
if status != "" {
updatedRule.Status = status
}
if method != "" {
updatedRule.Method = method
}
break
}
}
if !found {
return fmt.Errorf("rule '%s' not found", name)
}
filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
cfg := Rules{Rules: []Rule{*updatedRule}}
// #nosec G304 - validate by sanitizeRuleFilename
file, err := os.Create(filePath)
if err != nil {
return fmt.Errorf("failed to update rule file: %w", err)
}
defer func() {
if closeErr := file.Close(); closeErr != nil {
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
}
}()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
return fmt.Errorf("failed to encode updated rule: %w", err)
}
return nil
}
func SanitizeRuleFilename(name string) string {
result := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, name)
return strings.ToLower(result)
}
func ParseDurationWithYears(s string) (time.Duration, error) {
if ss, ok := strings.CutSuffix(s, "y"); ok {
years, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}
return time.Duration(years) * 365 * 24 * time.Hour, nil
}
if ss, ok := strings.CutSuffix(s, "M"); ok {
months, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}
return time.Duration(months) * 30 * 24 * time.Hour, nil
}
if ss, ok := strings.CutSuffix(s, "d"); ok {
days, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}

View File

@@ -1,943 +0,0 @@
package config
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/BurntSushi/toml"
)
// ============================================
// Tests for SanitizeRuleFilename
// ============================================
func TestSanitizeRuleFilename(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "simple alphanumeric",
input: "nginx404",
expected: "nginx404",
},
{
name: "with spaces",
input: "nginx 404 error",
expected: "nginx_404_error",
},
{
name: "with special chars",
input: "nginx/404:error",
expected: "nginx_404_error",
},
{
name: "with dashes and underscores",
input: "nginx-404_error",
expected: "nginx-404_error",
},
{
name: "uppercase to lowercase",
input: "NGINX-404",
expected: "nginx-404",
},
{
name: "mixed case",
input: "Nginx-Admin-Access",
expected: "nginx-admin-access",
},
{
name: "with dots",
input: "nginx.error.page",
expected: "nginx_error_page",
},
{
name: "empty string",
input: "",
expected: "",
},
{
name: "only special chars",
input: "!@#$%^&*()",
expected: "__________",
},
{
name: "russian chars",
input: "nginxошибка",
expected: "nginx______",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := SanitizeRuleFilename(tt.input)
if result != tt.expected {
t.Errorf("SanitizeRuleFilename(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
// ============================================
// Tests for ParseDurationWithYears
// ============================================
func TestParseDurationWithYears(t *testing.T) {
tests := []struct {
name string
input string
expected time.Duration
expectError bool
}{
{
name: "1 year",
input: "1y",
expected: 365 * 24 * time.Hour,
expectError: false,
},
{
name: "2 years",
input: "2y",
expected: 2 * 365 * 24 * time.Hour,
expectError: false,
},
{
name: "1 month",
input: "1M",
expected: 30 * 24 * time.Hour,
expectError: false,
},
{
name: "6 months",
input: "6M",
expected: 6 * 30 * 24 * time.Hour,
expectError: false,
},
{
name: "30 days",
input: "30d",
expected: 30 * 24 * time.Hour,
expectError: false,
},
{
name: "1 day",
input: "1d",
expected: 24 * time.Hour,
expectError: false,
},
{
name: "1 hour",
input: "1h",
expected: time.Hour,
expectError: false,
},
{
name: "30 minutes",
input: "30m",
expected: 30 * time.Minute,
expectError: false,
},
{
name: "30 seconds",
input: "30s",
expected: 30 * time.Second,
expectError: false,
},
{
name: "complex duration",
input: "1h30m",
expected: 1*time.Hour + 30*time.Minute,
expectError: false,
},
{
name: "invalid year format",
input: "abc",
expected: 0,
expectError: true,
},
{
name: "invalid month format",
input: "xM",
expected: 0,
expectError: true,
},
{
name: "invalid day format",
input: "xd",
expected: 0,
expectError: true,
},
{
name: "empty string",
input: "",
expected: 0,
expectError: true,
},
{
name: "negative duration",
input: "-1h",
expected: -1 * time.Hour,
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDurationWithYears(tt.input)
if (err != nil) != tt.expectError {
t.Errorf("ParseDurationWithYears(%q) error = %v, expectError %v", tt.input, err, tt.expectError)
return
}
if !tt.expectError && result != tt.expected {
t.Errorf("ParseDurationWithYears(%q) = %v, want %v", tt.input, result, tt.expected)
}
})
}
}
// ============================================
// Tests for Rule validation (NewRule)
// ============================================
func TestNewRule_EmptyName(t *testing.T) {
err := NewRule("", "nginx", "", "", "", "1h", 0)
if err == nil {
t.Error("NewRule with empty name should return error")
}
if err.Error() != "rule name can't be empty" {
t.Errorf("Unexpected error message: %v", err)
}
}
func TestNewRule_DuplicateRule(t *testing.T) {
// Create temp directory for rules
tmpDir := t.TempDir()
// Create first rule
firstRulePath := filepath.Join(tmpDir, "test-rule.toml")
cfg := Rules{Rules: []Rule{{Name: "test-rule"}}}
file, _ := os.Create(firstRulePath)
toml.NewEncoder(file).Encode(cfg)
file.Close()
// Try to create duplicate
err := NewRule("test-rule", "nginx", "", "", "", "1h", 0)
if err == nil {
t.Error("NewRule with duplicate name should return error")
}
}
// ============================================
// Tests for EditRule validation
// ============================================
func TestEditRule_EmptyName(t *testing.T) {
err := EditRule("", "nginx", "", "", "")
if err == nil {
t.Error("EditRule with empty name should return error")
}
if err.Error() != "rule name can't be empty" {
t.Errorf("Unexpected error message: %v", err)
}
}
// ============================================
// Tests for Rule struct validation
// ============================================
func TestRule_StructTags(t *testing.T) {
// Test that Rule struct has correct TOML tags
rule := Rule{
Name: "test-rule",
ServiceName: "nginx",
Path: "/admin/*",
Status: "403",
Method: "POST",
MaxRetry: 5,
BanTime: "1h",
}
// Encode to TOML and verify
cfg := Rules{Rules: []Rule{rule}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "test.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode rule: %v", err)
}
// Read back and verify
var decoded Rules
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode rule: %v", err)
}
if len(decoded.Rules) != 1 {
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
}
decodedRule := decoded.Rules[0]
if decodedRule.Name != rule.Name {
t.Errorf("Name mismatch: %q != %q", decodedRule.Name, rule.Name)
}
if decodedRule.ServiceName != rule.ServiceName {
t.Errorf("ServiceName mismatch: %q != %q", decodedRule.ServiceName, rule.ServiceName)
}
if decodedRule.Path != rule.Path {
t.Errorf("Path mismatch: %q != %q", decodedRule.Path, rule.Path)
}
if decodedRule.MaxRetry != rule.MaxRetry {
t.Errorf("MaxRetry mismatch: %d != %d", decodedRule.MaxRetry, rule.MaxRetry)
}
if decodedRule.BanTime != rule.BanTime {
t.Errorf("BanTime mismatch: %q != %q", decodedRule.BanTime, rule.BanTime)
}
}
// ============================================
// Tests for Action struct validation
// ============================================
func TestAction_EmailAction(t *testing.T) {
action := Action{
Type: "email",
Enabled: true,
Email: "admin@example.com",
EmailSender: "banforge@example.com",
EmailSubject: "Alert",
SMTPHost: "smtp.example.com",
SMTPPort: 587,
SMTPUser: "user",
SMTPTLS: true,
Body: "IP {ip} banned",
}
cfg := Rules{Rules: []Rule{{
Name: "test",
Action: []Action{action},
}}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "action.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode action: %v", err)
}
// Read back and verify
var decoded Rules
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode action: %v", err)
}
decodedAction := decoded.Rules[0].Action[0]
if decodedAction.Type != "email" {
t.Errorf("Expected action type 'email', got %q", decodedAction.Type)
}
if decodedAction.Email != "admin@example.com" {
t.Errorf("Expected email 'admin@example.com', got %q", decodedAction.Email)
}
if decodedAction.SMTPPort != 587 {
t.Errorf("Expected SMTP port 587, got %d", decodedAction.SMTPPort)
}
}
func TestAction_WebhookAction(t *testing.T) {
action := Action{
Type: "webhook",
Enabled: true,
URL: "https://hooks.example.com/alert",
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer token123",
},
Body: `{"ip": "{ip}", "rule": "{rule}"}`,
}
cfg := Rules{Rules: []Rule{{
Name: "test",
Action: []Action{action},
}}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "webhook.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode webhook action: %v", err)
}
// Read back and verify
var decoded Rules
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode webhook action: %v", err)
}
decodedAction := decoded.Rules[0].Action[0]
if decodedAction.Type != "webhook" {
t.Errorf("Expected action type 'webhook', got %q", decodedAction.Type)
}
if decodedAction.URL != "https://hooks.example.com/alert" {
t.Errorf("Expected URL 'https://hooks.example.com/alert', got %q", decodedAction.URL)
}
if decodedAction.Headers["Content-Type"] != "application/json" {
t.Errorf("Expected Content-Type header, got %q", decodedAction.Headers["Content-Type"])
}
}
func TestAction_ScriptAction(t *testing.T) {
action := Action{
Type: "script",
Enabled: true,
Script: "/usr/local/bin/notify.sh",
Interpretator: "bash",
}
cfg := Rules{Rules: []Rule{{
Name: "test",
Action: []Action{action},
}}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "script.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode script action: %v", err)
}
// Read back and verify
var decoded Rules
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode script action: %v", err)
}
decodedAction := decoded.Rules[0].Action[0]
if decodedAction.Type != "script" {
t.Errorf("Expected action type 'script', got %q", decodedAction.Type)
}
if decodedAction.Script != "/usr/local/bin/notify.sh" {
t.Errorf("Expected script path '/usr/local/bin/notify.sh', got %q", decodedAction.Script)
}
if decodedAction.Interpretator != "bash" {
t.Errorf("Expected interpretator 'bash', got %q", decodedAction.Interpretator)
}
}
// ============================================
// Tests for Service struct validation
// ============================================
func TestService_FileLogging(t *testing.T) {
service := Service{
Name: "nginx",
Logging: "file",
LogPath: "/var/log/nginx/access.log",
Enabled: true,
}
cfg := Config{Service: []Service{service}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "service.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode service: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode service: %v", err)
}
if len(decoded.Service) != 1 {
t.Fatalf("Expected 1 service, got %d", len(decoded.Service))
}
decodedService := decoded.Service[0]
if decodedService.Name != "nginx" {
t.Errorf("Expected service name 'nginx', got %q", decodedService.Name)
}
if decodedService.Logging != "file" {
t.Errorf("Expected logging type 'file', got %q", decodedService.Logging)
}
if decodedService.LogPath != "/var/log/nginx/access.log" {
t.Errorf("Expected log path '/var/log/nginx/access.log', got %q", decodedService.LogPath)
}
}
func TestService_JournaldLogging(t *testing.T) {
service := Service{
Name: "sshd",
Logging: "journald",
LogPath: "sshd",
Enabled: true,
}
cfg := Config{Service: []Service{service}}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "journald.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode journald service: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode journald service: %v", err)
}
decodedService := decoded.Service[0]
if decodedService.Logging != "journald" {
t.Errorf("Expected logging type 'journald', got %q", decodedService.Logging)
}
}
// ============================================
// Tests for Metrics struct validation
// ============================================
func TestMetrics_Enabled(t *testing.T) {
metrics := Metrics{
Enabled: true,
Port: 9090,
}
cfg := Config{Metrics: metrics}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "metrics.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode metrics: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode metrics: %v", err)
}
if !decoded.Metrics.Enabled {
t.Error("Expected metrics to be enabled")
}
if decoded.Metrics.Port != 9090 {
t.Errorf("Expected metrics port 9090, got %d", decoded.Metrics.Port)
}
}
func TestMetrics_Disabled(t *testing.T) {
metrics := Metrics{
Enabled: false,
Port: 0,
}
cfg := Config{Metrics: metrics}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "metrics-disabled.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode disabled metrics: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode disabled metrics: %v", err)
}
if decoded.Metrics.Enabled {
t.Error("Expected metrics to be disabled")
}
}
// ============================================
// Tests for Firewall struct validation
// ============================================
func TestFirewall_Nftables(t *testing.T) {
firewall := Firewall{
Name: "nftables",
Config: "/etc/nftables.conf",
}
cfg := Config{Firewall: firewall}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "firewall.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode firewall: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode firewall: %v", err)
}
if decoded.Firewall.Name != "nftables" {
t.Errorf("Expected firewall name 'nftables', got %q", decoded.Firewall.Name)
}
if decoded.Firewall.Config != "/etc/nftables.conf" {
t.Errorf("Expected firewall config '/etc/nftables.conf', got %q", decoded.Firewall.Config)
}
}
func TestFirewall_Iptables(t *testing.T) {
firewall := Firewall{
Name: "iptables",
Config: "",
}
cfg := Config{Firewall: firewall}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "iptables.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode iptables: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode iptables: %v", err)
}
if decoded.Firewall.Name != "iptables" {
t.Errorf("Expected firewall name 'iptables', got %q", decoded.Firewall.Name)
}
}
func TestFirewall_Ufw(t *testing.T) {
firewall := Firewall{
Name: "ufw",
Config: "",
}
cfg := Config{Firewall: firewall}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "ufw.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode ufw: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode ufw: %v", err)
}
if decoded.Firewall.Name != "ufw" {
t.Errorf("Expected firewall name 'ufw', got %q", decoded.Firewall.Name)
}
}
func TestFirewall_Firewalld(t *testing.T) {
firewall := Firewall{
Name: "firewalld",
Config: "",
}
cfg := Config{Firewall: firewall}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "firewalld.toml")
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
t.Fatalf("Failed to encode firewalld: %v", err)
}
// Read back and verify
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode firewalld: %v", err)
}
if decoded.Firewall.Name != "firewalld" {
t.Errorf("Expected firewall name 'firewalld', got %q", decoded.Firewall.Name)
}
}
// ============================================
// Integration test: Full config round-trip
// ============================================
func TestConfig_FullRoundTrip(t *testing.T) {
fullConfig := Config{
Firewall: Firewall{
Name: "nftables",
Config: "/etc/nftables.conf",
},
Metrics: Metrics{
Enabled: true,
Port: 9090,
},
Service: []Service{
{
Name: "nginx",
Logging: "file",
LogPath: "/var/log/nginx/access.log",
Enabled: true,
},
{
Name: "sshd",
Logging: "journald",
LogPath: "sshd",
Enabled: true,
},
},
}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "full-config.toml")
// Encode
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(fullConfig); err != nil {
t.Fatalf("Failed to encode full config: %v", err)
}
// Decode
var decoded Config
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode full config: %v", err)
}
// Verify firewall
if decoded.Firewall.Name != fullConfig.Firewall.Name {
t.Errorf("Firewall.Name mismatch: %q != %q", decoded.Firewall.Name, fullConfig.Firewall.Name)
}
// Verify metrics
if decoded.Metrics.Enabled != fullConfig.Metrics.Enabled {
t.Errorf("Metrics.Enabled mismatch: %v != %v", decoded.Metrics.Enabled, fullConfig.Metrics.Enabled)
}
if decoded.Metrics.Port != fullConfig.Metrics.Port {
t.Errorf("Metrics.Port mismatch: %d != %d", decoded.Metrics.Port, fullConfig.Metrics.Port)
}
// Verify services
if len(decoded.Service) != len(fullConfig.Service) {
t.Fatalf("Services count mismatch: %d != %d", len(decoded.Service), len(fullConfig.Service))
}
for i, expected := range fullConfig.Service {
actual := decoded.Service[i]
if actual.Name != expected.Name {
t.Errorf("Service[%d].Name mismatch: %q != %q", i, actual.Name, expected.Name)
}
if actual.Logging != expected.Logging {
t.Errorf("Service[%d].Logging mismatch: %q != %q", i, actual.Logging, expected.Logging)
}
if actual.Enabled != expected.Enabled {
t.Errorf("Service[%d].Enabled mismatch: %v != %v", i, actual.Enabled, expected.Enabled)
}
}
}
// ============================================
// Integration test: Full rule with actions round-trip
// ============================================
func TestRule_FullRoundTrip(t *testing.T) {
fullRule := Rules{
Rules: []Rule{
{
Name: "nginx-bruteforce",
ServiceName: "nginx",
Path: "/admin/*",
Status: "403",
Method: "POST",
MaxRetry: 5,
BanTime: "2h",
Action: []Action{
{
Type: "email",
Enabled: true,
Email: "admin@example.com",
EmailSender: "banforge@example.com",
EmailSubject: "Ban Alert",
SMTPHost: "smtp.example.com",
SMTPPort: 587,
SMTPUser: "user",
SMTPPassword: "pass",
SMTPTLS: true,
Body: "IP {ip} banned for rule {rule}",
},
{
Type: "webhook",
Enabled: true,
URL: "https://hooks.slack.com/services/xxx",
Method: "POST",
Headers: map[string]string{
"Content-Type": "application/json",
},
Body: `{"text": "IP {ip} banned"}`,
},
{
Type: "script",
Enabled: true,
Script: "/usr/local/bin/notify.sh",
Interpretator: "bash",
},
},
},
},
}
tmpDir := t.TempDir()
tmpFile := filepath.Join(tmpDir, "full-rule.toml")
// Encode
file, err := os.Create(tmpFile)
if err != nil {
t.Fatalf("Failed to create temp file: %v", err)
}
defer file.Close()
if err := toml.NewEncoder(file).Encode(fullRule); err != nil {
t.Fatalf("Failed to encode full rule: %v", err)
}
// Decode
var decoded Rules
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
t.Fatalf("Failed to decode full rule: %v", err)
}
// Verify rule
if len(decoded.Rules) != 1 {
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
}
rule := decoded.Rules[0]
if rule.Name != fullRule.Rules[0].Name {
t.Errorf("Rule.Name mismatch: %q != %q", rule.Name, fullRule.Rules[0].Name)
}
if rule.ServiceName != fullRule.Rules[0].ServiceName {
t.Errorf("Rule.ServiceName mismatch: %q != %q", rule.ServiceName, fullRule.Rules[0].ServiceName)
}
if rule.MaxRetry != fullRule.Rules[0].MaxRetry {
t.Errorf("Rule.MaxRetry mismatch: %d != %d", rule.MaxRetry, fullRule.Rules[0].MaxRetry)
}
// Verify actions
if len(rule.Action) != 3 {
t.Fatalf("Expected 3 actions, got %d", len(rule.Action))
}
// Email action
emailAction := rule.Action[0]
if emailAction.Type != "email" {
t.Errorf("Action[0].Type mismatch: %q != 'email'", emailAction.Type)
}
if emailAction.Email != "admin@example.com" {
t.Errorf("Email action email mismatch: %q != 'admin@example.com'", emailAction.Email)
}
// Webhook action
webhookAction := rule.Action[1]
if webhookAction.Type != "webhook" {
t.Errorf("Action[1].Type mismatch: %q != 'webhook'", webhookAction.Type)
}
if webhookAction.Headers["Content-Type"] != "application/json" {
t.Errorf("Webhook Content-Type header mismatch")
}
// Script action
scriptAction := rule.Action[2]
if scriptAction.Type != "script" {
t.Errorf("Action[2].Type mismatch: %q != 'script'", scriptAction.Type)
}
if scriptAction.Script != "/usr/local/bin/notify.sh" {
t.Errorf("Script action script mismatch: %q != '/usr/local/bin/notify.sh'", scriptAction.Script)
}
}

View File

@@ -5,8 +5,6 @@ import (
"os"
"os/exec"
"path/filepath"
"github.com/BurntSushi/toml"
)
var DetectedFirewall string
@@ -16,24 +14,6 @@ const (
ConfigFile = "config.toml"
)
func createFileWithPermissions(path string, perm os.FileMode) error {
// #nosec G304 - path is controlled by config package not user
file, err := os.Create(path)
if err != nil {
return err
}
if err := os.Chmod(path, perm); err != nil {
_ = file.Close()
return err
}
if err := file.Close(); err != nil {
return err
}
return nil
}
func CreateConf() error {
if os.Geteuid() != 0 {
return fmt.Errorf("you must be root to run this command, use sudo/doas")
@@ -46,102 +26,44 @@ func CreateConf() error {
return nil
}
if err := os.MkdirAll(ConfigDir, 0750); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
file, err := os.Create("/etc/banforge/config.toml")
if err != nil {
return fmt.Errorf("failed to create config file: %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)
}
if err := os.WriteFile(configPath, []byte(Base_config), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
fmt.Printf("Config file created: %s\n", configPath)
rulesDir := filepath.Join(ConfigDir, "rules.d")
if err := os.MkdirAll(rulesDir, 0750); err != nil {
return fmt.Errorf("failed to create rules directory: %w", err)
}
fmt.Printf("Rules directory created: %s\n", rulesDir)
bansDBDir := filepath.Dir("/var/lib/banforge/bans.db")
if err := os.MkdirAll(bansDBDir, 0750); err != nil {
return fmt.Errorf("failed to create bans database directory: %w", err)
}
reqDBDir := filepath.Dir("/var/lib/banforge/requests.db")
if err := os.MkdirAll(reqDBDir, 0750); err != nil {
return fmt.Errorf("failed to create requests database directory: %w", err)
}
bansDBPath := "/var/lib/banforge/bans.db"
if err := createFileWithPermissions(bansDBPath, 0600); err != nil {
return fmt.Errorf("failed to create bans database file: %w", err)
}
fmt.Printf("Bans database file created: %s\n", bansDBPath)
reqDBPath := "/var/lib/banforge/requests.db"
if err := createFileWithPermissions(reqDBPath, 0600); err != nil {
return fmt.Errorf("failed to create requests database file: %w", err)
}
fmt.Printf("Requests database file created: %s\n", reqDBPath)
fmt.Printf(" Config file created: %s\n", configPath)
return nil
}
func FindFirewall() error {
if os.Geteuid() != 0 {
return fmt.Errorf("firewall settings needs sudo privileges")
}
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
if os.Getegid() != 0 {
fmt.Printf("Firewall settings needs sudo privileges\n")
os.Exit(1)
}
firewalls := []string{"iptables", "nft", "firewall-cmd", "ufw"}
for _, firewall := range firewalls {
_, err := exec.LookPath(firewall)
if err == nil {
switch firewall {
case "firewall-cmd":
if firewall == "firewall-cmd" {
DetectedFirewall = "firewalld"
case "nft":
}
if firewall == "nft" {
DetectedFirewall = "nftables"
default:
DetectedFirewall = 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 {
_ = file.Close()
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)
DetectedFirewall = firewall
fmt.Printf("Detected firewall: %s\n", firewall)
return nil
}
}
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
return fmt.Errorf("no firewall found (checked ufw, firewall-cmd, iptables, nft) please install one of them")
}

View File

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

View File

@@ -1,60 +1,13 @@
package config
type Firewall struct {
Name string `toml:"name"`
Config string `toml:"config"`
Name string `toml:"name"`
Config string `toml:"config"`
BanTime int `toml:"ban_time"`
}
type Service struct {
Name string `toml:"name"`
Logging string `toml:"logging"`
LogPath string `toml:"log_path"`
Enabled bool `toml:"enabled"`
}
type Config struct {
Firewall Firewall `toml:"firewall"`
Metrics Metrics `toml:"metrics"`
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"`
MaxRetry int `toml:"max_retry"`
BanTime string `toml:"ban_time"`
Action []Action `toml:"action"`
}
type Metrics struct {
Enabled bool `toml:"enabled"`
Port int `toml:"port"`
}
// Actions
type Action struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
Method string `toml:"method"`
Headers map[string]string `toml:"headers"`
Body string `toml:"body"`
Email string `toml:"email"`
EmailSender string `toml:"email_sender"`
EmailSubject string `toml:"email_subject"`
SMTPHost string `toml:"smtp_host"`
SMTPPort int `toml:"smtp_port"`
SMTPUser string `toml:"smtp_user"`
SMTPPassword string `toml:"smtp_password"`
SMTPTLS bool `toml:"smtp_tls"`
Interpretator string `toml:"interpretator"`
Script string `toml:"script"`
Name string `toml:"name"`
Log_path string `toml:"log_path"`
Enabled bool `toml:"enabled"`
}

View File

@@ -1,204 +0,0 @@
package judge
import (
"fmt"
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/actions"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type Judge struct {
db_r *storage.BanReader
db_w *storage.BanWriter
db_rq *storage.RequestReader
logger *logger.Logger
Blocker blocker.BlockerEngine
rulesByService map[string][]config.Rule
entryCh chan *storage.LogEntry
resultCh chan *storage.LogEntry
}
func New(
db_r *storage.BanReader,
db_w *storage.BanWriter,
db_rq *storage.RequestReader,
b blocker.BlockerEngine,
resultCh chan *storage.LogEntry,
entryCh chan *storage.LogEntry,
) *Judge {
return &Judge{
db_w: db_w,
db_r: db_r,
db_rq: db_rq,
logger: logger.New(false),
rulesByService: make(map[string][]config.Rule),
Blocker: b,
entryCh: entryCh,
resultCh: resultCh,
}
}
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) Tribunal() {
j.logger.Info("Tribunal started")
for entry := range j.entryCh {
j.logger.Debug(
"Processing entry",
"ip",
entry.IP,
"service",
entry.Service,
"status",
entry.Status,
)
rules, serviceExists := j.rulesByService[entry.Service]
if !serviceExists {
j.logger.Debug("No rules for service", "service", entry.Service)
continue
}
ruleMatched := false
for _, rule := range rules {
methodMatch := rule.Method == "" || entry.Method == rule.Method
statusMatch := rule.Status == "" || entry.Status == rule.Status
pathMatch := matchPath(entry.Path, rule.Path)
if methodMatch && statusMatch && pathMatch {
ruleMatched = true
j.logger.Info("Rule matched", "rule", rule.Name, "ip", entry.IP)
j.resultCh <- entry
banned, err := j.db_r.IsBanned(entry.IP)
if err != nil {
j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
metrics.IncError()
break
}
if banned {
j.logger.Info("IP already banned", "ip", entry.IP)
metrics.IncLogParsed()
break
}
exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry)
if err != nil {
j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err)
metrics.IncError()
break
}
if !exceeded {
j.logger.Info("Max retry not exceeded", "ip", entry.IP)
metrics.IncLogParsed()
break
}
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
if err != nil {
j.logger.Error(
"Failed to add ban to database",
"ip",
entry.IP,
"ban_time",
rule.BanTime,
"error",
err,
)
break
}
if err := j.Blocker.Ban(entry.IP); err != nil {
j.logger.Error("Failed to ban IP at firewall", "ip", entry.IP, "error", err)
metrics.IncError()
break
}
for _, action := range rule.Action {
executor := &actions.Executor{Action: action}
if err := executor.Execute(); err != nil {
j.logger.Error("Action execution failed",
"rule", rule.Name,
"action_type", action.Type,
"error", err)
}
}
j.logger.Info(
"IP banned successfully",
"ip",
entry.IP,
"rule",
rule.Name,
"ban_time",
rule.BanTime,
)
metrics.IncBan(rule.ServiceName)
break
}
}
if !ruleMatched {
j.logger.Debug("No rules matched", "ip", entry.IP, "service", entry.Service)
}
}
j.logger.Info("Tribunal stopped - entryCh closed")
}
func (j *Judge) UnbanChecker() {
tick := time.NewTicker(5 * time.Minute)
defer tick.Stop()
for range tick.C {
ips, err := j.db_w.RemoveExpiredBans()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
metrics.IncError()
continue
}
for _, ip := range ips {
if err := j.Blocker.Unban(ip); err != nil {
j.logger.Error(fmt.Sprintf("Failed to unban IP at firewall: %v", err))
metrics.IncError()
} else {
metrics.IncUnban("judge")
}
}
}
}
func matchPath(path string, rulePath string) bool {
if rulePath == "" {
return true
}
if strings.HasPrefix(rulePath, "*") {
suffix := strings.TrimPrefix(rulePath, "*")
return strings.HasSuffix(path, suffix)
}
if strings.HasPrefix(rulePath, "/*") {
suffix := strings.TrimPrefix(rulePath, "/*")
return strings.HasSuffix(path, suffix)
}
if strings.HasSuffix(rulePath, "*") {
prefix := strings.TrimSuffix(rulePath, "*")
return strings.HasPrefix(path, prefix)
}
return path == rulePath
}

View File

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

@@ -1,12 +1,8 @@
package logger
import (
"io"
"log/slog"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
type Logger struct {
@@ -14,28 +10,13 @@ type Logger struct {
}
func New(debug bool) *Logger {
logDir := "/var/log/banforge"
if err := os.MkdirAll(logDir, 0750); err != nil {
return nil
}
fileWriter := &lumberjack.Logger{
Filename: filepath.Join(logDir, "banforge.log"),
MaxSize: 500,
MaxBackups: 3,
MaxAge: 28,
Compress: true,
}
var level slog.Level
if debug {
level = slog.LevelDebug
} else {
level = slog.LevelInfo
}
multiWriter := io.MultiWriter(fileWriter, os.Stdout)
handler := slog.NewTextHandler(multiWriter, &slog.HandlerOptions{
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})

View File

@@ -1,131 +0,0 @@
package metrics
import (
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
var (
metricsMu sync.RWMutex
metrics = make(map[string]int64)
)
func IncBan(service string) {
metricsMu.Lock()
metrics["ban_count"]++
metrics[service+"_bans"]++
metricsMu.Unlock()
}
func IncUnban(service string) {
metricsMu.Lock()
metrics["unban_count"]++
metrics[service+"_unbans"]++
metricsMu.Unlock()
}
func IncRuleMatched(rule_name string) {
metricsMu.Lock()
metrics[rule_name+"_rule_matched"]++
metricsMu.Unlock()
}
func IncLogParsed() {
metricsMu.Lock()
metrics["log_parsed"]++
metricsMu.Unlock()
}
func IncError() {
metricsMu.Lock()
metrics["error_count"]++
metricsMu.Unlock()
}
func IncBanAttempt(firewall string) {
metricsMu.Lock()
metrics["ban_attempt_count"]++
metrics[firewall+"_ban_attempts"]++
metricsMu.Unlock()
}
func IncUnbanAttempt(firewall string) {
metricsMu.Lock()
metrics["unban_attempt_count"]++
metrics[firewall+"_unban_attempts"]++
metricsMu.Unlock()
}
func IncPortOperation(operation string, protocol string) {
metricsMu.Lock()
key := "port_" + operation + "_" + protocol
metrics[key]++
metricsMu.Unlock()
}
func IncParserEvent(service string) {
metricsMu.Lock()
metrics[service+"_parsed_events"]++
metricsMu.Unlock()
}
func IncScannerEvent(service string) {
metricsMu.Lock()
metrics[service+"_scanner_events"]++
metricsMu.Unlock()
}
func IncDBOperation(operation string, table string) {
metricsMu.Lock()
key := "db_" + operation + "_" + table
metrics[key]++
metricsMu.Unlock()
}
func IncRequestCount(service string) {
metricsMu.Lock()
metrics[service+"_request_count"]++
metricsMu.Unlock()
}
func MetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metricsMu.RLock()
snapshot := make(map[string]int64, len(metrics))
for k, v := range metrics {
snapshot[k] = v
}
metricsMu.RUnlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
for name, value := range snapshot {
metricName := name + "_total"
_, _ = fmt.Fprintf(w, "# TYPE %s counter\n", metricName)
_, _ = fmt.Fprintf(w, "%s %d\n", metricName, value)
}
})
}
func StartMetricsServer(port int) error {
mux := http.NewServeMux()
mux.Handle("/metrics", MetricsHandler())
server := &http.Server{
Addr: "localhost:" + strconv.Itoa(port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
log.Printf("Starting metrics server on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("metrics server failed: %w", err)
}
return nil
}

View File

@@ -1,63 +0,0 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type ApacheParser struct {
pattern *regexp.Regexp
logger *logger.Logger
}
func NewApacheParser() *ApacheParser {
pattern := regexp.MustCompile(
`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+-\s+-\s+\[(.*?)\]\s+"(\w+)\s+(.*?)\s+HTTP/[\d.]+"\s+(\d+)\s+(\d+|-)\s+"(.*?)"\s+"(.*?)"`,
)
// Groups:
// 1: IP
// 2: Timestamp
// 3: Method (GET, POST, etc.)
// 4: Path
// 5: Status Code (200, 404, 403...)
// 6: Response Size
// 7: Referer
// 8: User-Agent
return &ApacheParser{
pattern: pattern,
logger: logger.New(false),
}
}
func (p *ApacheParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
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: "apache",
IP: matches[1],
Path: path,
Status: status,
Method: method,
}
metrics.IncParserEvent("apache")
p.logger.Info(
"Parsed apache log entry",
"ip", matches[1],
"path", path,
"status", status,
"method", method,
)
}
}

View File

@@ -1,57 +0,0 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"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
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,
}
metrics.IncParserEvent("nginx")
p.logger.Info(
"Parsed nginx log entry",
"ip",
matches[1],
"path",
path,
"status",
status,
"method",
method,
)
}
}

View File

@@ -2,16 +2,10 @@ package parser
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Event struct {
@@ -23,105 +17,22 @@ type Scanner struct {
ch chan Event
stopCh chan struct{}
logger *logger.Logger
cmd *exec.Cmd
file *os.File
pollDelay time.Duration
}
func validateLogPath(path string) error {
if path == "" {
return fmt.Errorf("log path cannot be empty")
}
if !filepath.IsAbs(path) {
return fmt.Errorf("log path must be absolute: %s", path)
}
if strings.Contains(path, "..") {
return fmt.Errorf("log path contains '..': %s", path)
}
if _, err := os.Stat(path); os.IsNotExist(err) {
return fmt.Errorf("log file does not exist: %s", path)
}
info, err := os.Lstat(path)
func NewScanner(path string) (*Scanner, error) {
file, err := os.Open(path) // #nosec G304 -- admin tool, runs as root, path controlled by operator
if err != nil {
return fmt.Errorf("failed to stat log file: %w", err)
}
if info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("log path is a symlink: %s", path)
}
return nil
}
func validateJournaldUnit(unit string) error {
if unit == "" {
return fmt.Errorf("journald unit cannot be empty")
}
if !regexp.MustCompile(`^[a-zA-Z0-9._-]+$`).MatchString(unit) {
return fmt.Errorf("invalid journald unit name: %s", unit)
}
if strings.HasPrefix(unit, "-") {
return fmt.Errorf("journald unit cannot start with '-': %s", unit)
}
return nil
}
func NewScannerTail(path string) (*Scanner, error) {
if err := validateLogPath(path); err != nil {
return nil, fmt.Errorf("invalid log path: %w", err)
}
// #nosec G204 - path is validated above via validateLogPath()
cmd := exec.Command("tail", "-F", "-n", "10", path)
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
return &Scanner{
scanner: bufio.NewScanner(stdout),
scanner: bufio.NewScanner(file),
ch: make(chan Event, 100),
stopCh: make(chan struct{}),
logger: logger.New(false),
file: nil,
cmd: cmd,
pollDelay: 100 * time.Millisecond,
}, nil
}
func NewScannerJournald(unit string) (*Scanner, error) {
if err := validateJournaldUnit(unit); err != nil {
return nil, fmt.Errorf("invalid journald unit: %w", err)
}
// #nosec G204 - unit is validated above via validateJournaldUnit()
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "short", "--no-pager")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
return &Scanner{
scanner: bufio.NewScanner(stdout),
ch: make(chan Event, 100),
stopCh: make(chan struct{}),
logger: logger.New(false),
cmd: cmd,
file: nil,
file: file,
pollDelay: 100 * time.Millisecond,
}, nil
}
@@ -138,17 +49,15 @@ func (s *Scanner) Start() {
default:
if s.scanner.Scan() {
metrics.IncScannerEvent("scanner")
s.ch <- Event{
Data: s.scanner.Text(),
}
s.logger.Info("Scanner event", "data", s.scanner.Text())
} else {
if err := s.scanner.Err(); err != nil {
s.logger.Error("Scanner error")
metrics.IncError()
return
}
time.Sleep(s.pollDelay)
}
}
}
@@ -157,26 +66,11 @@ func (s *Scanner) Start() {
func (s *Scanner) Stop() {
close(s.stopCh)
if s.cmd != nil && s.cmd.Process != nil {
s.logger.Info("Stopping process", "pid", s.cmd.Process.Pid)
err := s.cmd.Process.Kill()
if err != nil {
s.logger.Error("Failed to kill process", "err", err)
}
err = s.cmd.Wait()
if err != nil {
s.logger.Error("Failed to wait process", "err", err)
}
}
if s.file != nil {
if err := s.file.Close(); err != nil {
s.logger.Error("Failed to close file", "err", err)
}
}
time.Sleep(150 * time.Millisecond)
err := s.file.Close()
if err != nil {
s.logger.Error("Failed to close file")
}
close(s.ch)
}

View File

@@ -2,72 +2,52 @@ package parser
import (
"os"
"strings"
"testing"
"time"
)
func TestNewScannerTail(t *testing.T) {
file, err := os.CreateTemp("", "test-*.log")
func TestNewScanner(t *testing.T) {
file, err := os.CreateTemp("", "test.log")
if err != nil {
t.Fatal(err)
}
defer file.Close()
defer os.Remove(file.Name())
file.Close()
scanner, err := NewScannerTail(file.Name())
s, err := NewScanner(file.Name())
if err != nil {
t.Fatalf("NewScannerTail() error = %v", err)
t.Fatal(err)
}
if scanner == nil {
if s == nil {
t.Fatal("Scanner is nil")
}
if scanner.cmd == nil {
t.Fatal("cmd is nil")
}
if scanner.cmd.Process == nil {
t.Fatal("process is nil")
}
scanner.Stop()
}
func TestScannerTailEvents(t *testing.T) {
func TestScannerStart(t *testing.T) {
tests := []struct {
name string
lines []string
input string
wantErr bool
wantLines int
}{
{
name: "multiple lines",
lines: []string{
"Failed password for root from 192.168.1.1",
"Invalid user admin from 192.168.1.2",
"Accepted publickey for user from 192.168.1.3",
},
name: "correct file",
input: `Failed password for root from 192.168.1.1
Invalid user admin from 192.168.1.1
Accepted publickey for user from 192.168.1.2`,
wantErr: false,
wantLines: 3,
},
{
name: "single line",
lines: []string{
"Failed password for root",
},
wantLines: 1,
name: "empty file",
input: "",
wantErr: false,
wantLines: 0,
},
{
name: "many lines",
lines: []string{
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
},
wantLines: 5,
name: "single line",
input: `Failed password for root`,
wantErr: false,
wantLines: 1,
},
}
@@ -79,404 +59,41 @@ func TestScannerTailEvents(t *testing.T) {
t.Fatal(err)
}
filePath := file.Name()
if _, err := file.WriteString(tt.input); err != nil {
t.Fatal(err)
}
file.Close()
defer os.Remove(filePath)
scanner, err := NewScannerTail(filePath)
if err != nil {
t.Fatalf("NewScannerTail() error = %v", err)
scanner, err := NewScanner(filePath)
if (err != nil) != tt.wantErr {
t.Errorf("NewScanner() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
defer scanner.Stop()
scanner.Start()
time.Sleep(200 * time.Millisecond)
timeout := time.After(500 * time.Millisecond)
linesRead := 0
file, err = os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatal(err)
}
for _, line := range tt.lines {
if _, err := file.WriteString(line + "\n"); err != nil {
t.Fatal(err)
}
}
if err := file.Sync(); err != nil {
t.Fatal(err)
}
file.Close()
// 5. Собираем события
timeout := time.After(1 * time.Second)
var events []Event
eventLoop:
for {
select {
case event := <-scanner.Events():
events = append(events, event)
linesRead++
t.Logf("Read: %s", event.Data)
if len(events) == tt.wantLines {
break eventLoop
}
case <-timeout:
break eventLoop
}
}
if len(events) != tt.wantLines {
t.Errorf("got %d lines, want %d", len(events), tt.wantLines)
}
for i, event := range events {
if event.Data != tt.lines[i] {
t.Errorf("line %d: got %q, want %q", i, event.Data, tt.lines[i])
if linesRead != tt.wantLines {
t.Errorf("got %d lines, want %d", linesRead, tt.wantLines)
}
return
}
}
})
}
}
func TestScannerStop(t *testing.T) {
file, err := os.CreateTemp("", "test-*.log")
if err != nil {
t.Fatal(err)
}
filePath := file.Name()
file.Close()
defer os.Remove(filePath)
scanner, err := NewScannerTail(filePath)
if err != nil {
t.Fatal(err)
}
scanner.Start()
time.Sleep(100 * time.Millisecond)
scanner.Stop()
err = scanner.cmd.Process.Signal(os.Signal(nil))
if err == nil {
t.Error("Process still alive after Stop()")
}
select {
case _, ok := <-scanner.Events():
if ok {
t.Error("Channel still open after Stop()")
}
case <-time.After(100 * time.Millisecond):
t.Error("Channel not closed after Stop()")
}
}
func TestMultipleScanners(t *testing.T) {
file1, err := os.CreateTemp("", "test1-*.log")
if err != nil {
t.Fatal(err)
}
path1 := file1.Name()
file1.Close()
defer os.Remove(path1)
file2, err := os.CreateTemp("", "test2-*.log")
if err != nil {
t.Fatal(err)
}
path2 := file2.Name()
file2.Close()
defer os.Remove(path2)
scanner1, err := NewScannerTail(path1)
if err != nil {
t.Fatal(err)
}
defer scanner1.Stop()
scanner2, err := NewScannerTail(path2)
if err != nil {
t.Fatal(err)
}
defer scanner2.Stop()
scanner1.Start()
scanner2.Start()
time.Sleep(200 * time.Millisecond)
f1, _ := os.OpenFile(path1, os.O_APPEND|os.O_WRONLY, 0644)
f1.WriteString("scanner1 line\n")
f1.Sync()
f1.Close()
f2, _ := os.OpenFile(path2, os.O_APPEND|os.O_WRONLY, 0644)
f2.WriteString("scanner2 line\n")
f2.Sync()
f2.Close()
timeout := time.After(1 * time.Second)
var event1, event2 Event
got1, got2 := false, false
for !got1 || !got2 {
select {
case event1 = <-scanner1.Events():
got1 = true
t.Logf("Scanner1: %s", event1.Data)
case event2 = <-scanner2.Events():
got2 = true
t.Logf("Scanner2: %s", event2.Data)
case <-timeout:
if !got1 {
t.Error("Scanner1 did not receive event")
}
if !got2 {
t.Error("Scanner2 did not receive event")
}
return
}
}
if event1.Data != "scanner1 line" {
t.Errorf("Scanner1 got wrong data: %q", event1.Data)
}
if event2.Data != "scanner2 line" {
t.Errorf("Scanner2 got wrong data: %q", event2.Data)
}
}
func BenchmarkScanner(b *testing.B) {
file, err := os.CreateTemp("", "bench-*.log")
if err != nil {
b.Fatal(err)
}
filePath := file.Name()
file.Close()
defer os.Remove(filePath)
scanner, err := NewScannerTail(filePath)
if err != nil {
b.Fatal(err)
}
defer scanner.Stop()
scanner.Start()
time.Sleep(200 * time.Millisecond)
b.ResetTimer()
for i := 0; i < b.N; i++ {
f, _ := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
f.WriteString("benchmark line\n")
f.Sync()
f.Close()
<-scanner.Events()
}
}
func TestValidateLogPath(t *testing.T) {
tests := []struct {
name string
path string
setup func() (string, func())
wantErr bool
errMsg string
}{
{
name: "empty path",
path: "",
wantErr: true,
errMsg: "log path cannot be empty",
},
{
name: "relative path",
path: "logs/test.log",
wantErr: true,
errMsg: "log path must be absolute",
},
{
name: "path with traversal",
path: "/var/log/../etc/passwd",
wantErr: true,
errMsg: "log path contains '..'",
},
{
name: "non-existent file",
path: "/var/log/nonexistent.log",
wantErr: true,
errMsg: "log file does not exist",
},
{
name: "valid file",
path: "/tmp/test-valid.log",
setup: func() (string, func()) {
_, _ = os.Create("/tmp/test-valid.log")
return "/tmp/test-valid.log", func() { os.Remove("/tmp/test-valid.log") }
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var cleanup func()
if tt.setup != nil {
tt.path, cleanup = tt.setup()
defer cleanup()
}
err := validateLogPath(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("validateLogPath() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" && err != nil {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateLogPath() error = %v, want message containing %q", err, tt.errMsg)
}
}
})
}
}
func TestValidateJournaldUnit(t *testing.T) {
tests := []struct {
name string
unit string
wantErr bool
errMsg string
}{
{
name: "empty unit",
unit: "",
wantErr: true,
errMsg: "journald unit cannot be empty",
},
{
name: "unit starting with dash",
unit: "-dangerous",
wantErr: true,
errMsg: "journald unit cannot start with '-'",
},
{
name: "unit with special chars",
unit: "test;rm -rf /",
wantErr: true,
errMsg: "invalid journald unit name",
},
{
name: "unit with spaces",
unit: "test unit",
wantErr: true,
errMsg: "invalid journald unit name",
},
{
name: "valid unit simple",
unit: "nginx",
wantErr: false,
},
{
name: "valid unit with dash",
unit: "ssh-agent",
wantErr: false,
},
{
name: "valid unit with dot",
unit: "systemd-journald.service",
wantErr: false,
},
{
name: "valid unit with underscore",
unit: "my_service",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateJournaldUnit(tt.unit)
if (err != nil) != tt.wantErr {
t.Errorf("validateJournaldUnit() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr && tt.errMsg != "" && err != nil {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateJournaldUnit() error = %v, want message containing %q", err, tt.errMsg)
}
}
})
}
}
func TestNewScannerTailValidation(t *testing.T) {
tests := []struct {
name string
path string
wantErr bool
}{
{
name: "empty path",
path: "",
wantErr: true,
},
{
name: "relative path",
path: "test.log",
wantErr: true,
},
{
name: "non-existent path",
path: "/nonexistent/path/file.log",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewScannerTail(tt.path)
if (err != nil) != tt.wantErr {
t.Errorf("NewScannerTail() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestNewScannerJournaldValidation(t *testing.T) {
tests := []struct {
name string
unit string
wantErr bool
}{
{
name: "empty unit",
unit: "",
wantErr: true,
},
{
name: "unit with semicolon",
unit: "test;rm -rf /",
wantErr: true,
},
{
name: "unit starting with dash",
unit: "-dangerous",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewScannerJournald(tt.unit)
if (err != nil) != tt.wantErr {
t.Errorf("NewScannerJournald() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@@ -1,53 +0,0 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type SshdParser struct {
pattern *regexp.Regexp
logger *logger.Logger
}
func NewSshdParser() *SshdParser {
pattern := regexp.MustCompile(
`^([A-Za-z]{3}\s+\d{1,2}\s+\d{2}:\d{2}:\d{2})\s+(\S+)\s+sshd(?:-session)?\[(\d+)\]:\s+Failed\s+(\w+)\s+for\s+(?:invalid\s+user\s+)?(\S+)\s+from\s+(\S+)\s+port\s+(\d+)`,
)
return &SshdParser{
pattern: pattern,
logger: logger.New(false),
}
}
func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: Timestamp, Group 2: hostame, Group 3: pid, Group 4: Method auth, Group 5: User, Group 6: IP, Group 7: port
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
resultCh <- &storage.LogEntry{
Service: "ssh",
IP: matches[6],
Path: matches[5], // user
Status: "Failed",
Method: matches[4], // method auth
}
metrics.IncParserEvent("ssh")
p.logger.Info(
"Parsed ssh log entry",
"ip",
matches[6],
"user",
matches[5],
"method",
matches[4],
"status",
"Failed",
)
}
}

View File

@@ -1,228 +0,0 @@
package storage
import (
"database/sql"
"fmt"
"os"
"time"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/jedib0t/go-pretty/v6/table"
_ "modernc.org/sqlite"
)
// Writer block
type BanWriter struct {
logger *logger.Logger
db *sql.DB
}
func NewBanWriter() (*BanWriter, error) {
db, err := sql.Open(
"sqlite",
buildSqliteDsn(banDBPath, pragmas),
)
if err != nil {
return nil, err
}
return &BanWriter{
logger: logger.New(false),
db: db,
}, nil
}
func (d *BanWriter) CreateTable() error {
_, err := d.db.Exec(CreateBansTable)
if err != nil {
return err
}
metrics.IncDBOperation("create_table", "bans")
d.logger.Info("Created tables")
return nil
}
func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
duration, err := config.ParseDurationWithYears(ttl)
if err != nil {
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
metrics.IncError()
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,
reason,
now.Format(time.RFC3339),
expiredAt.Format(time.RFC3339),
)
if err != nil {
d.logger.Error("Failed to add ban", "error", err)
metrics.IncError()
return err
}
metrics.IncDBOperation("insert", "bans")
return nil
}
func (d *BanWriter) RemoveBan(ip string) error {
_, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
if err != nil {
d.logger.Error("Failed to remove ban", "error", err)
metrics.IncError()
return err
}
metrics.IncDBOperation("delete", "bans")
return nil
}
func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
var ips []string
now := time.Now().Format(time.RFC3339)
rows, err := w.db.Query(
"SELECT ip FROM bans WHERE expired_at < ?",
now,
)
if err != nil {
w.logger.Error("Failed to get expired bans", "error", err)
metrics.IncError()
return nil, err
}
defer func() {
if err := rows.Close(); err != nil {
w.logger.Error("Failed to close rows", "error", err)
}
}()
for rows.Next() {
var ip string
err := rows.Scan(&ip)
if err != nil {
w.logger.Error("Failed to scan ban", "error", err)
continue
}
ips = append(ips, ip)
}
if err = rows.Err(); err != nil {
return nil, err
}
result, err := w.db.Exec(
"DELETE FROM bans WHERE expired_at < ?",
now,
)
if err != nil {
w.logger.Error("Failed to remove expired bans", "error", err)
metrics.IncError()
return nil, err
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return nil, err
}
if rowsAffected > 0 {
w.logger.Info("Removed expired bans", "count", rowsAffected, "ips", len(ips))
metrics.IncDBOperation("delete_expired", "bans")
}
return ips, nil
}
func (d *BanWriter) Close() error {
d.logger.Info("Closing database connection")
err := d.db.Close()
if err != nil {
return err
}
return nil
}
// Reader block
type BanReader struct {
logger *logger.Logger
db *sql.DB
}
func NewBanReader() (*BanReader, error) {
db, err := sql.Open("sqlite",
"/var/lib/banforge/bans.db?"+
"mode=ro&"+
"_pragma=journal_mode(WAL)&"+
"_pragma=mmap_size(268435456)&"+
"_pragma=cache_size(-2000)&"+
"_pragma=query_only(1)")
if err != nil {
return nil, err
}
return &BanReader{
logger: logger.New(false),
db: db,
}, nil
}
func (d *BanReader) 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 {
metrics.IncError()
return false, fmt.Errorf("failed to check ban status: %w", err)
}
metrics.IncDBOperation("select", "bans")
return true, nil
}
func (d *BanReader) BanList() error {
var count int
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)
t.SetStyle(table.StyleBold)
t.AppendHeader(table.Row{"№", "IP", "Banned At", "Reason", "Expires At"})
rows, err := d.db.Query("SELECT ip, banned_at, reason, expired_at FROM bans")
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err
}
for rows.Next() {
count++
var ip string
var bannedAt string
var reason string
var expiredAt string
err := rows.Scan(&ip, &bannedAt, &reason, &expiredAt)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err
}
t.AppendRow(table.Row{count, ip, bannedAt, reason, expiredAt})
}
t.Render()
metrics.IncDBOperation("select", "bans")
return nil
}
func (d *BanReader) Close() error {
d.logger.Info("Closing database connection")
err := d.db.Close()
if err != nil {
return err
}
return nil
}

View File

@@ -1,380 +0,0 @@
package storage
import (
"database/sql"
"github.com/d3m0k1d/BanForge/internal/logger"
"path/filepath"
"testing"
)
func TestBanWriter_AddBan(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
ip := "192.168.1.1"
ttl := "1h"
err = writer.AddBan(ip, ttl, "test")
if err != nil {
t.Errorf("AddBan failed: %v", err)
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
defer reader.Close()
isBanned, err := reader.IsBanned(ip)
if err != nil {
t.Errorf("IsBanned failed: %v", err)
}
if !isBanned {
t.Error("Expected IP to be banned, but it's not")
}
}
func TestBanWriter_RemoveBan(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
ip := "192.168.1.2"
err = writer.AddBan(ip, "1h", "test")
if err != nil {
t.Fatalf("Failed to add ban: %v", err)
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
defer reader.Close()
isBanned, err := reader.IsBanned(ip)
if err != nil {
t.Fatalf("IsBanned failed: %v", err)
}
if !isBanned {
t.Fatal("Expected IP to be banned before removal")
}
err = writer.RemoveBan(ip)
if err != nil {
t.Errorf("RemoveBan failed: %v", err)
}
isBanned, err = reader.IsBanned(ip)
if err != nil {
t.Errorf("IsBanned failed after removal: %v", err)
}
if isBanned {
t.Error("Expected IP to be unbanned after removal, but it's still banned")
}
}
func TestBanWriter_RemoveExpiredBans(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
expiredIP := "192.168.1.3"
err = writer.AddBan(expiredIP, "-1h", "tes")
if err != nil {
t.Fatalf("Failed to add expired ban: %v", err)
}
activeIP := "192.168.1.4"
err = writer.AddBan(activeIP, "1h", "test")
if err != nil {
t.Fatalf("Failed to add active ban: %v", err)
}
removedIPs, err := writer.RemoveExpiredBans()
if err != nil {
t.Errorf("RemoveExpiredBans failed: %v", err)
}
found := false
for _, ip := range removedIPs {
if ip == expiredIP {
found = true
break
}
}
if !found {
t.Error("Expected expired IP to be in removed list")
}
if len(removedIPs) != 1 {
t.Errorf("Expected 1 removed IP, got %d", len(removedIPs))
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
defer reader.Close()
isExpiredBanned, err := reader.IsBanned(expiredIP)
if err != nil {
t.Errorf("IsBanned failed for expired IP: %v", err)
}
if isExpiredBanned {
t.Error("Expected expired IP to be unbanned, but it's still banned")
}
isActiveBanned, err := reader.IsBanned(activeIP)
if err != nil {
t.Errorf("IsBanned failed for active IP: %v", err)
}
if !isActiveBanned {
t.Error("Expected active IP to still be banned, but it's not")
}
}
func TestBanReader_IsBanned(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
ip := "192.168.1.5"
err = writer.AddBan(ip, "1h", "test")
if err != nil {
t.Fatalf("Failed to add ban: %v", err)
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
defer reader.Close()
isBanned, err := reader.IsBanned(ip)
if err != nil {
t.Errorf("IsBanned failed for banned IP: %v", err)
}
if !isBanned {
t.Error("Expected IP to be banned")
}
isBanned, err = reader.IsBanned("192.168.1.6")
if err != nil {
t.Errorf("IsBanned failed for non-banned IP: %v", err)
}
if isBanned {
t.Error("Expected IP to not be banned")
}
}
func TestBanWriter_Close(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
err = writer.Close()
if err != nil {
t.Errorf("Close failed: %v", err)
}
_, err = writer.db.Exec("SELECT 1")
if err == nil {
t.Error("Expected error when using closed connection")
}
}
func TestBanReader_Close(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
err = reader.Close()
if err != nil {
t.Errorf("Close failed: %v", err)
}
_, err = reader.db.Query("SELECT 1")
if err == nil {
t.Error("Expected error when using closed connection")
}
}
func TestBanWriter_AddBan_InvalidDuration(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
err = writer.AddBan("192.168.1.7", "invalid_duration", "test")
if err == nil {
t.Error("Expected error for invalid duration")
} else if err.Error() == "" || err.Error() == "<nil>" {
t.Error("Expected meaningful error message for invalid duration")
}
}
func TestMultipleBans(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
ips := []string{"192.168.1.8", "192.168.1.9", "192.168.1.10"}
for _, ip := range ips {
err := writer.AddBan(ip, "1h", "test")
if err != nil {
t.Errorf("Failed to add ban for IP %s: %v", ip, err)
}
}
reader, err := NewBanReaderWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanReader: %v", err)
}
defer reader.Close()
for _, ip := range ips {
isBanned, err := reader.IsBanned(ip)
if err != nil {
t.Errorf("IsBanned failed for IP %s: %v", ip, err)
continue
}
if !isBanned {
t.Errorf("Expected IP %s to be banned", ip)
}
}
}
func TestRemoveNonExistentBan(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "bans_test.db")
writer, err := NewBanWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create BanWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
err = writer.RemoveBan("192.168.1.11")
if err != nil {
t.Errorf("RemoveBan should not return error for non-existent ban: %v", err)
}
}
func NewBanWriterWithDBPath(dbPath string) (*BanWriter, error) {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)")
if err != nil {
return nil, err
}
return &BanWriter{
logger: logger.New(false),
db: db,
}, nil
}
func NewBanReaderWithDBPath(dbPath string) (*BanReader, error) {
db, err := sql.Open("sqlite",
dbPath+"?"+
"mode=ro&"+
"_pragma=journal_mode(WAL)&"+
"_pragma=mmap_size(268435456)&"+
"_pragma=cache_size(-2000)&"+
"_pragma=query_only(1)")
if err != nil {
return nil, err
}
return &BanReader{
logger: logger.New(false),
db: db,
}, nil
}

View File

@@ -1,61 +0,0 @@
package storage
import (
"database/sql"
"errors"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
const (
DBDir = "/var/lib/banforge/"
ReqDBPath = DBDir + "requests.db"
banDBPath = DBDir + "bans.db"
)
var pragmas = map[string]string{
`journal_mode`: `wal`,
`synchronous`: `normal`,
`busy_timeout`: `30000`,
// also consider these
// `temp_store`: `memory`,
// `cache_size`: `1000000000`,
}
func buildSqliteDsn(path string, pragmas map[string]string) string {
pragmastrs := make([]string, len(pragmas))
i := 0
for k, v := range pragmas {
pragmastrs[i] = (fmt.Sprintf(`pragma=%s(%s)`, k, v))
i++
}
return path + "?" + "mode=rwc&" + strings.Join(pragmastrs, "&")
}
func initDB(dsn, sqlstr string) (err error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("failed to open %q: %w", dsn, err)
}
defer func() {
closeErr := db.Close()
if closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close %q: %w", dsn, closeErr))
}
}()
_, err = db.Exec(sqlstr)
if err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
return err
}
func CreateTables() (err error) {
// Requests DB
err1 := initDB(buildSqliteDsn(ReqDBPath, pragmas), CreateRequestsTable)
err2 := initDB(buildSqliteDsn(banDBPath, pragmas), CreateBansTable)
return errors.Join(err1, err2)
}

View File

@@ -1,31 +0,0 @@
package storage
const CreateRequestsTable = `
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY,
service TEXT NOT NULL,
ip TEXT NOT NULL,
path TEXT,
method TEXT,
status TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_requests_service ON requests(service);
CREATE INDEX IF NOT EXISTS idx_requests_ip ON requests(ip);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at);
`
// Миграция для bans.db
const CreateBansTable = `
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_bans_ip ON bans(ip);
`

View File

@@ -1,18 +0,0 @@
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"`
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

@@ -1,69 +0,0 @@
package storage
import (
"database/sql"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
_ "modernc.org/sqlite"
)
type RequestWriter struct {
logger *logger.Logger
db *sql.DB
}
func NewRequestsWr() (*RequestWriter, error) {
db, err := sql.Open(
"sqlite",
buildSqliteDsn(ReqDBPath, pragmas),
)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &RequestWriter{
logger: logger.New(false),
db: db,
}, nil
}
type RequestReader struct {
logger *logger.Logger
db *sql.DB
}
func NewRequestsRd() (*RequestReader, error) {
db, err := sql.Open(
"sqlite",
buildSqliteDsn(ReqDBPath, pragmas),
)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &RequestReader{
logger: logger.New(false),
db: db,
}, nil
}
func (r *RequestReader) IsMaxRetryExceeded(ip string, maxRetry int) (bool, error) {
var count int
if maxRetry == 0 {
return true, nil
}
err := r.db.QueryRow("SELECT COUNT(*) FROM requests WHERE ip = ?", ip).Scan(&count)
if err != nil {
r.logger.Error("error query count: " + err.Error())
metrics.IncError()
return false, err
}
r.logger.Info("Current request count for IP", "ip", ip, "count", count, "maxRetry", maxRetry)
metrics.IncDBOperation("select", "requests")
return count >= maxRetry, nil
}

View File

@@ -1,112 +0,0 @@
package storage
import (
"database/sql"
"errors"
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
db.logger.Info("Starting log writer")
const batchSize = 100
const flushInterval = 1 * time.Second
batch := make([]*LogEntry, 0, batchSize)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
flush := func() {
defer db.logger.Debug("Flushed batch", "count", len(batch))
err := func() (err error) {
if len(batch) == 0 {
return nil
}
tx, err := db.db.Begin()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil &&
!errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(
err,
fmt.Errorf("failed to rollback transaction: %w", rollbackErr),
)
}
}()
stmt, err := tx.Prepare(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
if err != nil {
err = fmt.Errorf("failed to prepare statement: %w", err)
return err
}
defer func() {
if closeErr := stmt.Close(); closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close statement: %w", closeErr))
}
}()
for _, entry := range batch {
_, err := stmt.Exec(
entry.Service,
entry.IP,
entry.Path,
entry.Method,
entry.Status,
time.Now().Format(time.RFC3339),
)
if err != nil {
db.logger.Error(fmt.Errorf("failed to insert entry: %w", err).Error())
metrics.IncError()
} else {
metrics.IncRequestCount(entry.Service)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
batch = batch[:0]
metrics.IncDBOperation("insert", "requests")
return err
}()
if err != nil {
db.logger.Error(err.Error())
}
}
for {
select {
case result, ok := <-resultCh:
if !ok {
flush()
return
}
batch = append(batch, result)
if len(batch) >= batchSize {
flush()
}
case <-ticker.C:
flush()
}
}
}
func (w *RequestWriter) GetRequestCount() (int, error) {
var count int
err := w.db.QueryRow("SELECT COUNT(*) FROM requests").Scan(&count)
return count, err
}
func (w *RequestWriter) Close() error {
return w.db.Close()
}

View File

@@ -1,301 +0,0 @@
package storage
import (
"database/sql"
"github.com/d3m0k1d/BanForge/internal/logger"
_ "modernc.org/sqlite"
"path/filepath"
"testing"
"time"
)
func TestWrite_BatchInsert(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "requests_test.db")
writer, err := NewRequestWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create RequestWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
resultCh := make(chan *LogEntry, 100)
done := make(chan bool)
go func() {
WriteReq(writer, resultCh)
close(done)
}()
entries := []*LogEntry{
{Service: "service1", IP: "192.168.1.1", Path: "/path1", Method: "GET", Status: "200"},
{Service: "service2", IP: "192.168.1.2", Path: "/path2", Method: "POST", Status: "404"},
{Service: "service3", IP: "192.168.1.3", Path: "/path3", Method: "PUT", Status: "500"},
{Service: "service4", IP: "192.168.1.4", Path: "/path4", Method: "DELETE", Status: "200"},
{Service: "service5", IP: "192.168.1.5", Path: "/path5", Method: "GET", Status: "301"},
}
for _, entry := range entries {
resultCh <- entry
}
close(resultCh)
<-done
count, err := writer.GetRequestCount()
if err != nil {
t.Fatalf("Failed to get request count: %v", err)
}
if count != len(entries) {
t.Errorf("Expected %d entries, got %d", len(entries), count)
}
rows, err := writer.db.Query("SELECT service, ip, path, method, status FROM requests ORDER BY id")
if err != nil {
t.Fatalf("Failed to query requests: %v", err)
}
defer rows.Close()
i := 0
for rows.Next() {
var service, ip, path, method, status string
err := rows.Scan(&service, &ip, &path, &method, &status)
if err != nil {
t.Fatalf("Failed to scan row: %v", err)
}
if i >= len(entries) {
t.Fatal("More rows returned than expected")
}
expected := entries[i]
if service != expected.Service {
t.Errorf("Expected service %s, got %s", expected.Service, service)
}
if ip != expected.IP {
t.Errorf("Expected IP %s, got %s", expected.IP, ip)
}
if path != expected.Path {
t.Errorf("Expected path %s, got %s", expected.Path, path)
}
if method != expected.Method {
t.Errorf("Expected method %s, got %s", expected.Method, method)
}
if status != expected.Status {
t.Errorf("Expected status %s, got %s", expected.Status, status)
}
i++
}
if i != len(entries) {
t.Errorf("Expected to read %d entries, got %d", len(entries), i)
}
}
func TestWrite_BatchSizeTrigger(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "requests_test.db")
writer, err := NewRequestWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create RequestWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
resultCh := make(chan *LogEntry, 100)
done := make(chan bool)
go func() {
WriteReq(writer, resultCh)
close(done)
}()
batchSize := 100
entries := make([]*LogEntry, batchSize)
for i := 0; i < batchSize; i++ {
entries[i] = &LogEntry{
Service: "service" + string(rune(i+'0')),
IP: "192.168.1." + string(rune(i+'0')),
Path: "/path" + string(rune(i+'0')),
Method: "GET",
Status: "200",
}
}
for _, entry := range entries {
resultCh <- entry
}
close(resultCh)
<-done
count, err := writer.GetRequestCount()
if err != nil {
t.Fatalf("Failed to get request count: %v", err)
}
if count != batchSize {
t.Errorf("Expected %d entries, got %d", batchSize, count)
}
}
func TestWrite_FlushInterval(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "requests_test.db")
writer, err := NewRequestWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create RequestWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
resultCh := make(chan *LogEntry, 100)
done := make(chan bool)
go func() {
WriteReq(writer, resultCh)
close(done)
}()
entries := []*LogEntry{
{Service: "service1", IP: "192.168.1.1", Path: "/path1", Method: "GET", Status: "200"},
{Service: "service2", IP: "192.168.1.2", Path: "/path2", Method: "POST", Status: "404"},
{Service: "service3", IP: "192.168.1.3", Path: "/path3", Method: "PUT", Status: "500"},
{Service: "service4", IP: "192.168.1.4", Path: "/path4", Method: "DELETE", Status: "200"},
{Service: "service5", IP: "192.168.1.5", Path: "/path5", Method: "GET", Status: "301"},
}
for _, entry := range entries {
resultCh <- entry
}
time.Sleep(1500 * time.Millisecond)
close(resultCh)
<-done
count, err := writer.GetRequestCount()
if err != nil {
t.Fatalf("Failed to get request count: %v", err)
}
if count != len(entries) {
t.Errorf("Expected %d entries, got %d", len(entries), count)
}
}
func TestWrite_EmptyBatch(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "requests_test.db")
writer, err := NewRequestWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create RequestWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
resultCh := make(chan *LogEntry, 100)
done := make(chan bool)
go func() {
WriteReq(writer, resultCh)
close(done)
}()
close(resultCh)
<-done
count, err := writer.GetRequestCount()
if err != nil {
t.Fatalf("Failed to get request count: %v", err)
}
if count != 0 {
t.Errorf("Expected 0 entries for empty batch, got %d", count)
}
}
func TestWrite_ChannelClosed(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "requests_test.db")
writer, err := NewRequestWriterWithDBPath(dbPath)
if err != nil {
t.Fatalf("Failed to create RequestWriter: %v", err)
}
defer writer.Close()
err = writer.CreateTable()
if err != nil {
t.Fatalf("Failed to create table: %v", err)
}
resultCh := make(chan *LogEntry, 100)
done := make(chan bool)
go func() {
WriteReq(writer, resultCh)
close(done)
}()
entries := []*LogEntry{
{Service: "service1", IP: "192.168.1.1", Path: "/path1", Method: "GET", Status: "200"},
{Service: "service2", IP: "192.168.1.2", Path: "/path2", Method: "POST", Status: "404"},
}
for _, entry := range entries {
resultCh <- entry
}
close(resultCh)
<-done
count, err := writer.GetRequestCount()
if err != nil {
t.Fatalf("Failed to get request count: %v", err)
}
if count != len(entries) {
t.Errorf("Expected %d entries, got %d", len(entries), count)
}
}
func NewRequestWriterWithDBPath(dbPath string) (*RequestWriter, error) {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)")
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &RequestWriter{
logger: logger.New(false),
db: db,
}, nil
}
func (w *RequestWriter) CreateTable() error {
_, err := w.db.Exec(CreateRequestsTable)
if err != nil {
return err
}
w.logger.Info("Created requests table")
return nil
}