19 Commits

Author SHA1 Message Date
d3m0k1d
914168f80f chore: add skip tlas false
All checks were successful
CD - BanForge Release / release (push) Successful in 3m27s
CI.yml / build (push) Successful in 2m6s
2026-01-16 01:31:53 +03:00
d3m0k1d
3a61371e58 chore: Add gitea urls 2026-01-16 01:31:26 +03:00
d3m0k1d
d7d49ec0ed chore: delete gpg on release
Some checks failed
CI.yml / build (push) Successful in 2m37s
CD - BanForge Release / release (push) Has been cancelled
2026-01-16 01:25:19 +03:00
d3m0k1d
59e4393e82 fix: fix release
Some checks failed
CI.yml / build (push) Successful in 2m33s
CD - BanForge Release / release (push) Failing after 2m1s
2026-01-16 01:21:07 +03:00
d3m0k1d
bd73ba24e8 chore: fix cd
Some checks failed
CI.yml / build (push) Successful in 2m18s
CD - BanForge Release / release (push) Failing after 1m39s
2026-01-16 01:10:26 +03:00
d3m0k1d
28d1410d62 chore: upd gitignore add goreleaser and openrc script
Some checks failed
CI.yml / build (push) Successful in 2m40s
CD - BanForge Release / release (push) Failing after 3m53s
2026-01-16 00:53:20 +03:00
d3m0k1d
680973df3d feat: daemon add ctx and done signal, judge fix problem with double ban ip, db add new methods
All checks were successful
CI.yml / build (push) Successful in 1m58s
2026-01-15 22:32:03 +03:00
d3m0k1d
1603fbee35 feat: add simple setup func to blockerengine, fix init and db, version for realease v0.2.0
All checks were successful
CD - BanForge Release / release (push) Successful in 20s
CI.yml / build (push) Successful in 2m1s
CD - BanForge Release / build (amd64, linux) (push) Successful in 3m3s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m52s
2026-01-15 19:14:44 +03:00
d3m0k1d
bbb152dfb8 docs: typo and update readme.md
All checks were successful
CI.yml / build (push) Successful in 1m56s
2026-01-15 18:16:54 +03:00
d3m0k1d
a7b79d0e27 docs: typo
All checks were successful
CI.yml / build (push) Successful in 1m55s
2026-01-15 18:14:04 +03:00
d3m0k1d
eaf276bd3f docs: Add new docs and fix rule command
All checks were successful
CI.yml / build (push) Successful in 1m53s
2026-01-15 18:06:48 +03:00
d3m0k1d
14c6c64989 tests: update makefile and add test for validators and writter
All checks were successful
CI.yml / build (push) Successful in 2m20s
2026-01-15 17:27:46 +03:00
d3m0k1d
623bd87b4c tests: Add tests for storage package
All checks were successful
CI.yml / build (push) Successful in 1m54s
2026-01-15 17:01:49 +03:00
d3m0k1d
7d9645b3e3 refactoring(cmd/banforge/main.go): command logic on command dir in different files
All checks were successful
CI.yml / build (push) Successful in 1m49s
2026-01-14 21:52:13 +03:00
d3m0k1d
bf6ff50da8 fix: fix go bage url
All checks were successful
CI.yml / build (push) Successful in 1m47s
2026-01-14 20:56:44 +03:00
d3m0k1d
85f6919bda docs: Add bages to readme
All checks were successful
CI.yml / build (push) Successful in 1m48s
2026-01-14 20:54:42 +03:00
d3m0k1d
7a7f57f5ae feat: add new command to control firewall in banfogre interface
All checks were successful
CI.yml / build (push) Successful in 1m44s
2026-01-14 17:47:29 +03:00
d3m0k1d
36508201ad feat: Add rule control command to cli interface
All checks were successful
CI.yml / build (push) Successful in 1m46s
2026-01-14 17:20:08 +03:00
d3m0k1d
3cb9bcbcf3 docs(README.md): update docs for first realease version
All checks were successful
CI.yml / build (push) Successful in 1m51s
2026-01-14 15:32:26 +03:00
24 changed files with 1019 additions and 238 deletions

View File

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

1
.gitignore vendored
View File

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

72
.goreleaser.yml Normal file
View File

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

View File

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

View File

@@ -1,7 +1,9 @@
# BanForge # BanForge
Log-based IPS system written in Go for Linux based system. Log-based IPS system written in Go for Linux-based system.
[![Go Reference](https://pkg.go.dev/badge/github.com/d3m0k1d/BanForge/cmd/banforge.svg)](https://pkg.go.dev/github.com/d3m0k1d/BanForge)
[![License](https://img.shields.io/badge/license-%20%20GNU%20GPLv3%20-green?style=plastic)](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
# Table of contents # Table of contents
1. [Overview](#overview) 1. [Overview](#overview)
2. [Requirements](#requirements) 2. [Requirements](#requirements)
@@ -12,24 +14,40 @@ Log-based IPS system written in Go for Linux based system.
# Overview # Overview
BanForge is a simple IPS for replacement fail2ban in Linux system. BanForge is a simple IPS for replacement fail2ban in Linux system.
The project is currently in its early stages of development. The project is currently in its early stages of development.
All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) because Github have limit for Actions. All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) because Github has limits for Actions.
If you have any questions or suggestions, create issue on [Github](https://github.com/d3m0k1d/BanForge/issues). If you have any questions or suggestions, create issue on [Github](https://github.com/d3m0k1d/BanForge/issues).
## Roadmap ## Roadmap
- [ ] Real-time Nginx log monitoring - [x] Real-time Nginx log monitoring
- [ ] Add support for other service - [ ] Add support for other service
- [ ] Add support for user service with regular expressions - [ ] Add support for user service with regular expressions
- [ ] TUI interface - [ ] TUI interface
# Requirements # Requirements
- Go 1.21+ - Go 1.25+
- ufw/iptables/nftables/firewalld - ufw/iptables/nftables/firewalld
# Installation # Installation
currently no binary file if you wanna build the project yourself, you can use [Makefile](https://github.com/d3m0k1d/BanForge/blob/master/Makefile) Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it. Then create or copy a systemd unit file.
Or clone the repo and use the Makefile.
```
git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git
cd BanForge
sudo make build-daemon
cd bin
```
# Usage # Usage
For first steps use this commands
```bash
banforge init # Create config files and database
banforge daemon # Start BanForge daemon (use systemd or another init system to create a service)
```
You can edit the config file with examples in
- `/etc/banforge/config.toml` main config file
- `/etc/banforge/rules.toml` ban rules
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
# License # License
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE) The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)

21
build/banforge Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,73 @@
package command
import (
"fmt"
"os"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/spf13/cobra"
)
var (
name string
service string
path string
status string
method string
)
var RuleCmd = &cobra.Command{
Use: "rule",
Short: "Manage rules",
}
var AddCmd = &cobra.Command{
Use: "add",
Short: "CLI interface for add new rule to file /etc/banforge/rules.toml",
Run: func(cmd *cobra.Command, args []string) {
if name == "" {
fmt.Printf("Rule name can't be empty\n")
os.Exit(1)
}
if service == "" {
fmt.Printf("Service name can't be empty\n")
os.Exit(1)
}
if path == "" && status == "" && method == "" {
fmt.Printf("At least 1 rule field must be filled in.")
os.Exit(1)
}
err := config.NewRule(name, service, path, status, method)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Rule added successfully!")
},
}
var ListCmd = &cobra.Command{
Use: "list",
Short: "List rules",
Run: func(cmd *cobra.Command, args []string) {
r, err := config.LoadRuleConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, rule := range r {
fmt.Printf("Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n", rule.Name, rule.ServiceName, rule.Path, rule.Status, rule.Method)
}
},
}
func RuleRegister() {
RuleCmd.AddCommand(AddCmd)
RuleCmd.AddCommand(ListCmd)
AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)")
AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name")
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
AddCmd.Flags().StringVarP(&status, "status", "c", "", "HTTP status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method")
}

View File

@@ -3,14 +3,9 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/cmd/banforge/command"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/judge"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/parser"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -22,184 +17,18 @@ var rootCmd = &cobra.Command{
}, },
} }
var initCmd = &cobra.Command{
Use: "init",
Short: "Initialize BanForge",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Initializing BanForge...")
if _, err := os.Stat("/var/log/banforge"); err == nil {
fmt.Println("/var/log/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/log/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/log/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/var/lib/banforge"); err == nil {
fmt.Println("/var/lib/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/lib/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/lib/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/etc/banforge"); err == nil {
fmt.Println("/etc/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/etc/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /etc/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
err := config.CreateConf()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Config created")
err = config.FindFirewall()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
db, err := storage.NewDB()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = db.CreateTable()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
defer func() {
err = db.Close()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
}()
fmt.Println("Firewall detected and configured")
fmt.Println("BanForge initialized successfully!")
},
}
var daemonCmd = &cobra.Command{
Use: "daemon",
Short: "Run BanForge daemon process",
Run: func(cmd *cobra.Command, args []string) {
log := logger.New(false)
log.Info("Starting BanForge daemon")
db, err := storage.NewDB()
if err != nil {
log.Error("Failed to create database", "error", err)
os.Exit(1)
}
defer func() {
err = db.Close()
if err != nil {
log.Error("Failed to close database connection", "error", err)
}
}()
cfg, err := config.LoadConfig()
if err != nil {
log.Error("Failed to load config", "error", err)
os.Exit(1)
}
var b blocker.BlockerEngine
fw := cfg.Firewall.Name
switch fw {
case "ufw":
b = blocker.NewUfw(log)
case "iptables":
b = blocker.NewIptables(log, cfg.Firewall.Config)
case "nftables":
b = blocker.NewNftables(log, cfg.Firewall.Config)
case "firewalld":
b = blocker.NewFirewalld(log)
default:
log.Error("Unknown firewall", "firewall", fw)
os.Exit(1)
}
r, err := config.LoadRuleConfig()
if err != nil {
log.Error("Failed to load rules", "error", err)
os.Exit(1)
}
j := judge.New(db, b)
j.LoadRules(r)
go func() {
ticker := time.NewTicker(5 * time.Second)
defer ticker.Stop()
for range ticker.C {
if err := j.ProcessUnviewed(); err != nil {
log.Error("Failed to process unviewed", "error", err)
}
}
}()
for _, svc := range cfg.Service {
log.Info("Processing service", "name", svc.Name, "enabled", svc.Enabled, "path", svc.LogPath)
if !svc.Enabled {
log.Info("Service disabled, skipping", "name", svc.Name)
continue
}
if svc.Name != "nginx" {
log.Info("Only nginx supported, skipping", "name", svc.Name)
continue
}
log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
pars, err := parser.NewScanner(svc.LogPath)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue
}
go pars.Start()
go func(p *parser.Scanner, serviceName string) {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
resultCh := make(chan *storage.LogEntry, 100)
ng.Parse(p.Events(), resultCh)
go storage.Write(db, resultCh)
}(pars, svc.Name)
}
select {}
},
}
func Init() { func Init() {
} }
func Execute() { func Execute() {
rootCmd.AddCommand(daemonCmd) rootCmd.AddCommand(command.DaemonCmd)
rootCmd.AddCommand(initCmd) rootCmd.AddCommand(command.InitCmd)
rootCmd.AddCommand(command.RuleCmd)
rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd)
command.RuleRegister()
command.FwRegister()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)

51
docs/cli.md Normal file
View File

@@ -0,0 +1,51 @@
# CLI commands BanForge
BanForge provides a command-line interface (CLI) to manage IP blocking,
configure detection rules, and control the daemon process.
## Commands
### init - create a deps file
```shell
banforge init
```
**Description**
This command creates the necessary directories and base configuration files
required for the daemon to operate.
### daemon - Starts the BanForge daemon process
```shell
banforge daemon
```
**Description**
This command starts the BanForge daemon process in the background.
The daemon continuously monitors incoming requests, detects anomalies,
and applies firewall rules in real-time.
### firewall - Manages firewall rules
```shell
banforge ban <ip>
banforge unban <ip>
```
**Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
### rule - Manages detection rules
```shell
banforge rule add -n rule.name -c 403
banforge rule list
```
**Description**
These command help you to create and manage detection rules in CLI interface.
| Flag | Required |
| ----------- | -------- |
| -n -name | + |
| -s -service | + |
| -p -path | - |
| -m -method | - |
| -c -status | - |
You must specify at least 1 of the optional flags to create a rule.

0
docs/config.md Normal file
View File

View File

@@ -57,3 +57,7 @@ func (f *Firewalld) Unban(ip string) error {
f.logger.Info("Reload " + string(output)) f.logger.Info("Reload " + string(output))
return nil return nil
} }
func (f *Firewalld) Setup(config string) error {
return nil
}

View File

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

@@ -101,3 +101,7 @@ func (f *Iptables) Unban(ip string) error {
"output", string(output)) "output", string(output))
return nil return nil
} }
func (f *Iptables) Setup(config string) error {
return nil
}

View File

@@ -55,3 +55,28 @@ func (u *Ufw) Unban(ip string) error {
u.logger.Info("IP unbanned", "ip", ip, "output", string(output)) u.logger.Info("IP unbanned", "ip", ip, "output", string(output))
return nil return nil
} }
func (u *Ufw) Setup(config string) error {
if config != "" {
fmt.Printf("Ufw dont support config file\n")
cmd := exec.Command("sudo", "ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to enable ufw: %w", err)
}
}
if config == "" {
cmd := exec.Command("sudo", "ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",
"error", err.Error(),
"output", string(output))
return fmt.Errorf("failed to enable ufw: %w", err)
}
}
return nil
}

View File

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

View File

@@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"os"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -20,3 +21,86 @@ func LoadRuleConfig() ([]Rule, error) {
log.Info(fmt.Sprintf("loaded %d rules", len(cfg.Rules))) log.Info(fmt.Sprintf("loaded %d rules", len(cfg.Rules)))
return cfg.Rules, nil return cfg.Rules, nil
} }
func NewRule(Name string, ServiceName string, Path string, Status string, Method string) error {
r, err := LoadRuleConfig()
if err != nil {
r = []Rule{}
}
if Name == "" {
fmt.Printf("Rule name can't be empty\n")
return nil
}
r = append(r, Rule{Name: Name, ServiceName: ServiceName, Path: Path, Status: Status, Method: Method})
file, err := os.Create("/etc/banforge/rules.toml")
if err != nil {
return err
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
cfg := Rules{Rules: r}
err = toml.NewEncoder(file).Encode(cfg)
if err != nil {
return err
}
return nil
}
func EditRule(Name string, ServiceName string, Path string, Status string, Method string) error {
if Name == "" {
return fmt.Errorf("Rule name can't be empty")
}
r, err := LoadRuleConfig()
if err != nil {
return fmt.Errorf("rules is empty, please use 'banforge add rule' or create rules.toml")
}
found := false
for i, rule := range r {
if rule.Name == Name {
found = true
if ServiceName != "" {
r[i].ServiceName = ServiceName
}
if Path != "" {
r[i].Path = Path
}
if Status != "" {
r[i].Status = Status
}
if Method != "" {
r[i].Method = Method
}
break
}
}
if !found {
return fmt.Errorf("rule '%s' not found", Name)
}
file, err := os.Create("/etc/banforge/rules.toml")
if err != nil {
return err
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
cfg := Rules{Rules: r}
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
return fmt.Errorf("failed to encode config: %w", err)
}
return nil
}

View File

@@ -48,7 +48,6 @@ func (j *Judge) ProcessUnviewed() error {
j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err)) j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err))
} }
}() }()
for rows.Next() { for rows.Next() {
var entry storage.LogEntry var entry storage.LogEntry
err = rows.Scan(&entry.ID, &entry.Service, &entry.IP, &entry.Path, &entry.Status, &entry.Method, &entry.IsViewed, &entry.CreatedAt) err = rows.Scan(&entry.ID, &entry.Service, &entry.IP, &entry.Path, &entry.Status, &entry.Method, &entry.IsViewed, &entry.CreatedAt)
@@ -65,11 +64,22 @@ func (j *Judge) ProcessUnviewed() error {
(rule.Path == "" || entry.Path == rule.Path) { (rule.Path == "" || entry.Path == rule.Path) {
j.logger.Info(fmt.Sprintf("Rule matched for IP: %s, Service: %s", entry.IP, entry.Service)) j.logger.Info(fmt.Sprintf("Rule matched for IP: %s, Service: %s", entry.IP, entry.Service))
ban_status, err := j.db.IsBanned(entry.IP)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check ban status: %v", err))
return err
}
if !ban_status {
err = j.Blocker.Ban(entry.IP) err = j.Blocker.Ban(entry.IP)
if err != nil { if err != nil {
j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err)) j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err))
} }
j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP)) j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP))
err = j.db.AddBan(entry.IP)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to add ban: %v", err))
}
}
break break
} }
} }

View File

@@ -3,6 +3,9 @@ package storage
import ( import (
"database/sql" "database/sql"
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
_ "github.com/mattn/go-sqlite3" _ "github.com/mattn/go-sqlite3"
) )
@@ -13,10 +16,14 @@ type DB struct {
} }
func NewDB() (*DB, error) { func NewDB() (*DB, error) {
db, err := sql.Open("sqlite3", "/var/lib/banforge/storage.db") db, err := sql.Open("sqlite3", "/var/lib/banforge/storage.db?mode=rwc&_journal_mode=WAL&_busy_timeout=10000&cache=shared")
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := db.Ping(); err != nil {
return nil, err
}
return &DB{ return &DB{
logger: logger.New(false), logger: logger.New(false),
db: db, db: db,
@@ -58,3 +65,24 @@ func (d *DB) MarkAsViewed(id int) error {
} }
return nil return nil
} }
func (d *DB) IsBanned(ip string) (bool, error) {
var bannedIP string
err := d.db.QueryRow("SELECT ip FROM bans WHERE ip = ? ", ip).Scan(&bannedIP)
if err == sql.ErrNoRows {
return false, nil
}
if err != nil {
return false, fmt.Errorf("failed to check ban status: %w", err)
}
return true, nil
}
func (d *DB) AddBan(ip string) error {
_, err := d.db.Exec("INSERT INTO bans (ip, reason, banned_at) VALUES (?, ?, ?)", ip, "1", time.Now().Format(time.RFC3339))
if err != nil {
d.logger.Error("Failed to add ban", "error", err)
return err
}
return nil
}

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

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

View File

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