73 Commits

Author SHA1 Message Date
d3m0k1d
e275a73460 chore: fix cgo gp-sqlite3 needed cgo
All checks were successful
build / build (push) Successful in 4m23s
CD - BanForge Release / release (push) Successful in 5m13s
2026-01-20 22:43:20 +03:00
d3m0k1d
2dcc3eaa7b chore: fix binary name
All checks were successful
build / build (push) Successful in 3m34s
CD - BanForge Release / release (push) Successful in 4m12s
2026-01-20 22:33:22 +03:00
d3m0k1d
322f5161cb chore: fix realease ver file name
All checks were successful
build / build (push) Successful in 2m22s
CD - BanForge Release / release (push) Successful in 3m9s
2026-01-20 21:34:58 +03:00
d3m0k1d
4e80b5148d docs: add new docs
All checks were successful
CD - BanForge Release / release (push) Successful in 3m23s
2026-01-20 21:14:07 +03:00
d3m0k1d
1d74c6142b feat: recode scanner logic, add sshd service, add journald support, recode test for parser, update daemon, update config template
All checks were successful
build / build (push) Successful in 2m27s
2026-01-20 20:47:45 +03:00
d3m0k1d
9c3c0dbeaa docs: add config.md content
All checks were successful
build / build (push) Successful in 2m46s
CD - BanForge Release / release (push) Successful in 3m39s
2026-01-20 17:15:24 +03:00
d3m0k1d
46dc54f5a7 chore: add new formatter to .golangci.yml
All checks were successful
build / build (push) Successful in 2m23s
2026-01-19 18:57:11 +03:00
d3m0k1d
a1321300cb docs: Add Readme.md tag bage
All checks were successful
build / build (push) Successful in 2m23s
2026-01-19 18:04:23 +03:00
d3m0k1d
9eb1fa36c4 docs: add to readme status bage
All checks were successful
build / build (push) Successful in 2m22s
2026-01-19 17:56:47 +03:00
d3m0k1d
c954e929c8 fix: add delete ban from table after unban
All checks were successful
CI.yml / build (push) Successful in 2m21s
2026-01-19 16:22:47 +03:00
d3m0k1d
1225c9323a docs: add ttl flags to cli.md
All checks were successful
CI.yml / build (push) Successful in 2m21s
2026-01-19 16:07:07 +03:00
d3m0k1d
847002129d feat: Add bantime and goroutines for unban expires ban
All checks were successful
CI.yml / build (push) Successful in 2m24s
2026-01-19 16:03:12 +03:00
d3m0k1d
6f24088069 docs: upd
All checks were successful
CI.yml / build (push) Successful in 2m22s
2026-01-17 17:39:59 +03:00
d3m0k1d
03305a06f6 tests: add test for judge logic and test for new db function
All checks were successful
CI.yml / build (push) Successful in 2m5s
2026-01-16 03:15:33 +03:00
d3m0k1d
31184e009b feat: add new cli command for output banning ip table
All checks were successful
CI.yml / build (push) Successful in 2m0s
2026-01-16 02:41:37 +03:00
d3m0k1d
914168f80f chore: add skip tlas false
All checks were successful
CD - BanForge Release / release (push) Successful in 3m27s
CI.yml / build (push) Successful in 2m6s
2026-01-16 01:31:53 +03:00
d3m0k1d
3a61371e58 chore: Add gitea urls 2026-01-16 01:31:26 +03:00
d3m0k1d
d7d49ec0ed chore: delete gpg on release
Some checks failed
CI.yml / build (push) Successful in 2m37s
CD - BanForge Release / release (push) Has been cancelled
2026-01-16 01:25:19 +03:00
d3m0k1d
59e4393e82 fix: fix release
Some checks failed
CI.yml / build (push) Successful in 2m33s
CD - BanForge Release / release (push) Failing after 2m1s
2026-01-16 01:21:07 +03:00
d3m0k1d
bd73ba24e8 chore: fix cd
Some checks failed
CI.yml / build (push) Successful in 2m18s
CD - BanForge Release / release (push) Failing after 1m39s
2026-01-16 01:10:26 +03:00
d3m0k1d
28d1410d62 chore: upd gitignore add goreleaser and openrc script
Some checks failed
CI.yml / build (push) Successful in 2m40s
CD - BanForge Release / release (push) Failing after 3m53s
2026-01-16 00:53:20 +03:00
d3m0k1d
680973df3d feat: daemon add ctx and done signal, judge fix problem with double ban ip, db add new methods
All checks were successful
CI.yml / build (push) Successful in 1m58s
2026-01-15 22:32:03 +03:00
d3m0k1d
1603fbee35 feat: add simple setup func to blockerengine, fix init and db, version for realease v0.2.0
All checks were successful
CD - BanForge Release / release (push) Successful in 20s
CI.yml / build (push) Successful in 2m1s
CD - BanForge Release / build (amd64, linux) (push) Successful in 3m3s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m52s
2026-01-15 19:14:44 +03:00
d3m0k1d
bbb152dfb8 docs: typo and update readme.md
All checks were successful
CI.yml / build (push) Successful in 1m56s
2026-01-15 18:16:54 +03:00
d3m0k1d
a7b79d0e27 docs: typo
All checks were successful
CI.yml / build (push) Successful in 1m55s
2026-01-15 18:14:04 +03:00
d3m0k1d
eaf276bd3f docs: Add new docs and fix rule command
All checks were successful
CI.yml / build (push) Successful in 1m53s
2026-01-15 18:06:48 +03:00
d3m0k1d
14c6c64989 tests: update makefile and add test for validators and writter
All checks were successful
CI.yml / build (push) Successful in 2m20s
2026-01-15 17:27:46 +03:00
d3m0k1d
623bd87b4c tests: Add tests for storage package
All checks were successful
CI.yml / build (push) Successful in 1m54s
2026-01-15 17:01:49 +03:00
d3m0k1d
7d9645b3e3 refactoring(cmd/banforge/main.go): command logic on command dir in different files
All checks were successful
CI.yml / build (push) Successful in 1m49s
2026-01-14 21:52:13 +03:00
d3m0k1d
bf6ff50da8 fix: fix go bage url
All checks were successful
CI.yml / build (push) Successful in 1m47s
2026-01-14 20:56:44 +03:00
d3m0k1d
85f6919bda docs: Add bages to readme
All checks were successful
CI.yml / build (push) Successful in 1m48s
2026-01-14 20:54:42 +03:00
d3m0k1d
7a7f57f5ae feat: add new command to control firewall in banfogre interface
All checks were successful
CI.yml / build (push) Successful in 1m44s
2026-01-14 17:47:29 +03:00
d3m0k1d
36508201ad feat: Add rule control command to cli interface
All checks were successful
CI.yml / build (push) Successful in 1m46s
2026-01-14 17:20:08 +03:00
d3m0k1d
3cb9bcbcf3 docs(README.md): update docs for first realease version
All checks were successful
CI.yml / build (push) Successful in 1m51s
2026-01-14 15:32:26 +03:00
d3m0k1d
8b6dc88233 chore: fix cd
All checks were successful
CD - BanForge Release / release (push) Successful in 28s
CI.yml / build (push) Successful in 3m43s
CD - BanForge Release / build (amd64, linux) (push) Successful in 3m8s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m8s
2026-01-14 14:40:48 +03:00
d3m0k1d
511b708737 chore: fix cd from fratifact to generic pakage
All checks were successful
CD - BanForge Release / release (push) Successful in 31s
CI.yml / build (push) Successful in 3m24s
CD - BanForge Release / build (amd64, linux) (push) Successful in 2m53s
CD - BanForge Release / build (arm64, linux) (push) Successful in 2m41s
2026-01-14 14:21:31 +03:00
d3m0k1d
803e9db7b4 chore: fix one more time
All checks were successful
CI.yml / build (push) Successful in 1m41s
2026-01-14 01:45:43 +03:00
d3m0k1d
12c40a5748 chore: Add upload artifacts
All checks were successful
CI.yml / build (push) Successful in 1m45s
2026-01-14 01:41:36 +03:00
d3m0k1d
24fe951e49 fix: judge creator, daemon logic
All checks were successful
CI.yml / build (push) Successful in 1m45s
feat: first version for alpha test daemon on server

fix: add second template for fix bug with slice

Fix: add chek if path exists

Fix: template one more time

feat: Add file db on init command

feat: add create dit

feat: Add to init command create table to db

feat: Add new logs for debug on server

feat: Add CD, first release version

chore:fix cd

fix: change artifact ver from v4->v2

fix: ci one more time

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

Fixes G204 warnings.
2026-01-11 22:41:27 +03:00
d3m0k1d
41ff13fa66 Add firewalld and iptables ban realization, add firewall config file in config types
Some checks failed
CI.yml / build (push) Failing after 42s
2026-01-11 22:17:57 +03:00
d3m0k1d
99b97836ff Delete Flash and IsBanning from interface
All checks were successful
CI.yml / build (push) Successful in 48s
2026-01-11 20:40:46 +03:00
d3m0k1d
b3431d248b feat: initialize BanForge with CI/CD pipeline and linting configuration
All checks were successful
CI.yml / build (push) Successful in 49s
2026-01-11 20:33:56 +03:00
d3m0k1d
577f7ef0b9 Add linter and fix more errs in proj
Some checks failed
CI.yml / build (push) Failing after 14s
2026-01-11 19:41:20 +03:00
d3m0k1d
95ce6441d1 Add 2026-01-11 19:41:04 +03:00
d3m0k1d
424f5db9af Update ufw
All checks were successful
CI.yml / build (push) Successful in 38s
2026-01-11 17:56:47 +03:00
Ilya Chernishev
1cc9f3d191 Revise README with English content and roadmap
Updated README to include English descriptions and roadmap.
2026-01-11 17:54:42 +03:00
Ilya Chernishev
f46d3242b6 docs: write comprehensive README with project overview and roadmap 2026-01-11 17:51:48 +03:00
43 changed files with 2792 additions and 100 deletions

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

@@ -0,0 +1,45 @@
name: CD - BanForge Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Install syft
run: curl -sSfL https://get.anchore.io/syft | sudo sh -s -- -b /usr/local/bin
- name: Checkout
uses: actions/checkout@v6
- name: Go setup
uses: actions/setup-go@v6
with:
go-version: '1.25'
cache: false
- name: Install deps
run: go mod tidy
- name: Golangci-lint
uses: golangci/golangci-lint-action@v9.2.0
with:
args: --timeout=5m
skip-cache: true
- name: Run tests
run: go test ./...
- name: GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }}
GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }}
GITEA_TOKEN: ${{ secrets.TOKEN }}

View File

@@ -1,4 +1,4 @@
name: CI.yml
name: build
on:
push:
@@ -20,6 +20,11 @@ jobs:
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: Build

2
.gitignore vendored Normal file
View File

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

23
.golangci.yml Normal file
View File

@@ -0,0 +1,23 @@
version: "2"
run:
timeout: 5m
tests: false
build-tags:
- integration
linters:
enable:
- errcheck
- errname
- govet
- staticcheck
- gosec
- nilerr
formatters:
enable:
- gofmt
- goimports
- golines

70
.goreleaser.yml Normal file
View File

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

View File

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

View File

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

21
build/banforge Normal file
View File

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

21
build/banforge.service Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

61
docs/cli.md Normal file
View File

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

48
docs/config.md Normal file
View File

@@ -0,0 +1,48 @@
# Configs
## config.toml
Main configuration file for BanForge.
Example:
```toml
[firewall]
name = "nftables"
config = "/etc/nftables.conf"
[[service]]
name = "nginx"
logging = "file"
log_path = "/home/d3m0k1d/test.log"
enabled = true
[[service]]
name = "nginx"
logging = "journald"
log_path = "nginx"
enabled = false
```
**Description**
The [firewall] section defines firewall parameters. The banforge init command automatically detects your installed firewall (nftables, iptables, ufw, firewalld). For firewalls that require a configuration file, specify the path in the config parameter.
The [[service]] section is configured manually. Currently, only nginx is supported. To add a service, create a [[service]] block and specify the log_path to the nginx log file you want to monitor.
logging require in format "file" or "journald"
if you use journald logging, log_path require in format "service_name"
## rules.toml
Rules configuration file for BanForge.
If you wanna configure rules by cli command see [here](https://github.com/d3m0k1d/BanForge/blob/main/docs/cli.md)
Example:
```toml
[[rule]]
name = "304 http"
service = "nginx"
path = ""
status = "304"
method = ""
ban_time = "1m"
```
**Description**
The [[rule]] section require name and one of the following parameters: service, path, status, method. To add a rule, create a [[rule]] block and specify the parameters.
ban_time require in format "1m", "1h", "1d", "1M", "1y"

11
go.mod
View File

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

23
go.sum
View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -5,6 +5,8 @@ import (
"os"
"os/exec"
"path/filepath"
"github.com/BurntSushi/toml"
)
var DetectedFirewall string
@@ -19,10 +21,6 @@ func CreateConf() error {
return fmt.Errorf("you must be root to run this command, use sudo/doas")
}
if err := os.MkdirAll(ConfigDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
configPath := filepath.Join(ConfigDir, ConfigFile)
if _, err := os.Stat(configPath); err == nil {
@@ -30,40 +28,109 @@ func CreateConf() error {
return nil
}
file, err := os.Create(configPath)
file, err := os.Create("/etc/banforge/config.toml")
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer file.Close()
if err := os.Chmod(configPath, 0644); err != nil {
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)
}
err = os.WriteFile(configPath, []byte(Base_config), 0600)
if err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
fmt.Printf(" Config file created: %s\n", configPath)
file, err = os.Create("/etc/banforge/rules.toml")
if err != nil {
return fmt.Errorf("failed to create rules file: %w", err)
}
file, err = os.Create("/var/lib/banforge/storage.db")
if err != nil {
return fmt.Errorf("failed to create database file: %w", err)
}
err = os.Chmod("/var/lib/banforge/storage.db", 0600)
if err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
fmt.Printf(" Rules file created: %s\n", configPath)
return nil
}
func FindFirewall() error {
if os.Getegid() != 0 {
fmt.Printf("Firewall settings needs sudo privileges\n")
os.Exit(1)
}
firewalls := []string{"iptables", "nft", "firewall-cmd", "ufw"}
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
for _, firewall := range firewalls {
_, err := exec.LookPath(firewall)
if err == nil {
if firewall == "firewall-cmd" {
switch firewall {
case "firewall-cmd":
DetectedFirewall = "firewalld"
}
if firewall == "nft" {
case "nft":
DetectedFirewall = "nftables"
}
default:
DetectedFirewall = firewall
fmt.Printf("Detected firewall: %s\n", firewall)
}
fmt.Printf("Detected firewall: %s\n", DetectedFirewall)
cfg := &Config{}
_, err := toml.DecodeFile("/etc/banforge/config.toml", cfg)
if err != nil {
return fmt.Errorf("failed to decode config: %w", err)
}
cfg.Firewall.Name = DetectedFirewall
file, err := os.Create("/etc/banforge/config.toml")
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
encoder := toml.NewEncoder(file)
if err := encoder.Encode(cfg); err != nil {
err = file.Close()
if err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
return fmt.Errorf("failed to encode config: %w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
fmt.Printf("Config updated with firewall: %s\n", DetectedFirewall)
return nil
}
}
return fmt.Errorf("no firewall found (checked ufw, firewall-cmd, iptables, nft) please install one of them")
return fmt.Errorf("firewall not found")
}
func LoadConfig() (*Config, error) {
cfg := &Config{}
_, err := toml.DecodeFile("/etc/banforge/config.toml", cfg)
if err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
return cfg, nil
}

View File

@@ -1,15 +1,22 @@
package config
const Base_config = `# This is a TOML config file for BanForge it's a simple config file
# https://github.com/d3m0k1d/BanForge
const Base_config = `
# This is a TOML config file for BanForge
# [https://github.com/d3m0k1d/BanForge](https://github.com/d3m0k1d/BanForge)
# Firewall settings block
[firewall]
name = "iptables" # Name one of the support firewall(iptables, nftables, firewalld, ufw)
ban_time = 1200
name = ""
config = "/etc/nftables.conf"
[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

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

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

@@ -0,0 +1,139 @@
package judge
import (
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type Judge struct {
db *storage.DB
logger *logger.Logger
Blocker blocker.BlockerEngine
rulesByService map[string][]config.Rule
}
func New(db *storage.DB, b blocker.BlockerEngine) *Judge {
return &Judge{
db: db,
logger: logger.New(false),
rulesByService: make(map[string][]config.Rule),
Blocker: b,
}
}
func (j *Judge) LoadRules(rules []config.Rule) {
j.rulesByService = make(map[string][]config.Rule)
for _, rule := range rules {
j.rulesByService[rule.ServiceName] = append(
j.rulesByService[rule.ServiceName],
rule,
)
}
j.logger.Info("Rules loaded and indexed by service")
}
func (j *Judge) ProcessUnviewed() error {
rows, err := j.db.SearchUnViewed()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to query database: %v", err))
return err
}
defer func() {
err = rows.Close()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to close database connection: %v", err))
}
}()
for rows.Next() {
var entry storage.LogEntry
err = rows.Scan(
&entry.ID,
&entry.Service,
&entry.IP,
&entry.Path,
&entry.Status,
&entry.Method,
&entry.IsViewed,
&entry.CreatedAt,
)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to scan database row: %v", err))
continue
}
rules, serviceExists := j.rulesByService[entry.Service]
if serviceExists {
for _, rule := range rules {
if (rule.Method == "" || entry.Method == rule.Method) &&
(rule.Status == "" || entry.Status == rule.Status) &&
(rule.Path == "" || entry.Path == rule.Path) {
j.logger.Info(
fmt.Sprintf(
"Rule matched for IP: %s, Service: %s",
entry.IP,
entry.Service,
),
)
ban_status, err := j.db.IsBanned(entry.IP)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check ban status: %v", err))
return err
}
if !ban_status {
err = j.Blocker.Ban(entry.IP)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err))
}
j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP))
err = j.db.AddBan(entry.IP, rule.BanTime)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to add ban: %v", err))
}
}
break
}
}
}
err = j.db.MarkAsViewed(entry.ID)
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to mark entry as viewed: %v", err))
} else {
j.logger.Info(fmt.Sprintf("Entry marked as viewed: ID=%d", entry.ID))
}
}
if err = rows.Err(); err != nil {
j.logger.Error(fmt.Sprintf("Error iterating rows: %v", err))
return err
}
return nil
}
func (j *Judge) UnbanChecker() {
tick := time.NewTicker(5 * time.Minute)
defer tick.Stop()
for range tick.C {
ips, err := j.db.CheckExpiredBans()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
continue
}
for _, ip := range ips {
if err := j.Blocker.Unban(ip); err != nil {
j.logger.Error(fmt.Sprintf("Failed to unban IP %s: %v", ip, err))
continue
}
j.logger.Info(fmt.Sprintf("IP unbanned: %s", ip))
}
}
}

View File

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

View File

@@ -24,4 +24,3 @@ func New(debug bool) *Logger {
Logger: slog.New(handler),
}
}

View File

@@ -0,0 +1,58 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type NginxParser struct {
pattern *regexp.Regexp
logger *logger.Logger
}
func NewNginxParser() *NginxParser {
pattern := regexp.MustCompile(
`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}).*\[(.*?)\]\s+"(\w+)\s+(.*?)\s+HTTP.*"\s+(\d+)`,
)
return &NginxParser{
pattern: pattern,
logger: logger.New(false),
}
}
func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
go func() {
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
path := matches[4]
status := matches[5]
method := matches[3]
resultCh <- &storage.LogEntry{
Service: "nginx",
IP: matches[1],
Path: path,
Status: status,
Method: method,
IsViewed: false,
}
p.logger.Info(
"Parsed nginx log entry",
"ip",
matches[1],
"path",
path,
"status",
status,
"method",
method,
)
}
}()
}

View File

@@ -3,6 +3,7 @@ package parser
import (
"bufio"
"os"
"os/exec"
"time"
"github.com/d3m0k1d/BanForge/internal/logger"
@@ -17,22 +18,51 @@ type Scanner struct {
ch chan Event
stopCh chan struct{}
logger *logger.Logger
cmd *exec.Cmd
file *os.File
pollDelay time.Duration
}
func NewScanner(path string) (*Scanner, error) {
file, err := os.Open(path)
func NewScannerTail(path string) (*Scanner, error) {
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(file),
scanner: bufio.NewScanner(stdout),
ch: make(chan Event, 100),
stopCh: make(chan struct{}),
logger: logger.New(false),
file: file,
file: nil,
cmd: cmd,
pollDelay: 100 * time.Millisecond,
}, nil
}
func NewScannerJournald(unit string) (*Scanner, error) {
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "short", "--no-pager")
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, err
}
if err := cmd.Start(); err != nil {
return nil, err
}
return &Scanner{
scanner: bufio.NewScanner(stdout),
ch: make(chan Event, 100),
stopCh: make(chan struct{}),
logger: logger.New(false),
cmd: cmd,
file: nil,
pollDelay: 100 * time.Millisecond,
}, nil
}
@@ -52,12 +82,12 @@ func (s *Scanner) Start() {
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")
return
}
time.Sleep(s.pollDelay)
}
}
}
@@ -66,8 +96,26 @@ 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)
s.file.Close()
close(s.ch)
}

View File

@@ -6,48 +6,67 @@ import (
"time"
)
func TestNewScanner(t *testing.T) {
file, err := os.CreateTemp("", "test.log")
func TestNewScannerTail(t *testing.T) {
file, err := os.CreateTemp("", "test-*.log")
if err != nil {
t.Fatal(err)
}
defer file.Close()
defer os.Remove(file.Name())
s, err := NewScanner(file.Name())
file.Close()
scanner, err := NewScannerTail(file.Name())
if err != nil {
t.Fatal(err)
}
if s == nil {
t.Fatal("Scanner is nil")
}
t.Fatalf("NewScannerTail() error = %v", err)
}
func TestScannerStart(t *testing.T) {
if scanner == 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) {
tests := []struct {
name string
input string
wantErr bool
lines []string
wantLines int
}{
{
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,
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",
},
wantLines: 3,
},
{
name: "empty file",
input: "",
wantErr: false,
wantLines: 0,
name: "single line",
lines: []string{
"Failed password for root",
},
wantLines: 1,
},
{
name: "single line",
input: `Failed password for root`,
wantErr: false,
wantLines: 1,
name: "many lines",
lines: []string{
"line 1",
"line 2",
"line 3",
"line 4",
"line 5",
},
wantLines: 5,
},
}
@@ -59,41 +78,206 @@ Accepted publickey for user from 192.168.1.2`,
t.Fatal(err)
}
filePath := file.Name()
if _, err := file.WriteString(tt.input); err != nil {
t.Fatal(err)
}
file.Close()
defer os.Remove(filePath)
scanner, err := NewScanner(filePath)
if (err != nil) != tt.wantErr {
t.Errorf("NewScanner() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
scanner, err := NewScannerTail(filePath)
if err != nil {
t.Fatalf("NewScannerTail() error = %v", err)
}
defer scanner.Stop()
scanner.Start()
timeout := time.After(500 * time.Millisecond)
linesRead := 0
time.Sleep(200 * time.Millisecond)
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():
linesRead++
events = append(events, event)
t.Logf("Read: %s", event.Data)
case <-timeout:
if linesRead != tt.wantLines {
t.Errorf("got %d lines, want %d", linesRead, tt.wantLines)
if len(events) == tt.wantLines {
break eventLoop
}
return
case <-timeout:
break eventLoop
}
}
if len(events) != tt.wantLines {
t.Errorf("got %d lines, want %d", len(events), tt.wantLines)
}
for i, event := range events {
if event.Data != tt.lines[i] {
t.Errorf("line %d: got %q, want %q", i, event.Data, tt.lines[i])
}
}
})
}
}
func TestScannerStop(t *testing.T) {
file, err := os.CreateTemp("", "test-*.log")
if err != nil {
t.Fatal(err)
}
filePath := file.Name()
file.Close()
defer os.Remove(filePath)
scanner, err := NewScannerTail(filePath)
if err != nil {
t.Fatal(err)
}
scanner.Start()
time.Sleep(100 * time.Millisecond)
scanner.Stop()
err = scanner.cmd.Process.Signal(os.Signal(nil))
if err == nil {
t.Error("Process still alive after Stop()")
}
select {
case _, ok := <-scanner.Events():
if ok {
t.Error("Channel still open after Stop()")
}
case <-time.After(100 * time.Millisecond):
t.Error("Channel not closed after Stop()")
}
}
func TestMultipleScanners(t *testing.T) {
file1, err := os.CreateTemp("", "test1-*.log")
if err != nil {
t.Fatal(err)
}
path1 := file1.Name()
file1.Close()
defer os.Remove(path1)
file2, err := os.CreateTemp("", "test2-*.log")
if err != nil {
t.Fatal(err)
}
path2 := file2.Name()
file2.Close()
defer os.Remove(path2)
scanner1, err := NewScannerTail(path1)
if err != nil {
t.Fatal(err)
}
defer scanner1.Stop()
scanner2, err := NewScannerTail(path2)
if err != nil {
t.Fatal(err)
}
defer scanner2.Stop()
scanner1.Start()
scanner2.Start()
time.Sleep(200 * time.Millisecond)
f1, _ := os.OpenFile(path1, os.O_APPEND|os.O_WRONLY, 0644)
f1.WriteString("scanner1 line\n")
f1.Sync()
f1.Close()
f2, _ := os.OpenFile(path2, os.O_APPEND|os.O_WRONLY, 0644)
f2.WriteString("scanner2 line\n")
f2.Sync()
f2.Close()
timeout := time.After(1 * time.Second)
var event1, event2 Event
got1, got2 := false, false
for !got1 || !got2 {
select {
case event1 = <-scanner1.Events():
got1 = true
t.Logf("Scanner1: %s", event1.Data)
case event2 = <-scanner2.Events():
got2 = true
t.Logf("Scanner2: %s", event2.Data)
case <-timeout:
if !got1 {
t.Error("Scanner1 did not receive event")
}
if !got2 {
t.Error("Scanner2 did not receive event")
}
return
}
}
if event1.Data != "scanner1 line" {
t.Errorf("Scanner1 got wrong data: %q", event1.Data)
}
if event2.Data != "scanner2 line" {
t.Errorf("Scanner2 got wrong data: %q", event2.Data)
}
}
func BenchmarkScanner(b *testing.B) {
file, err := os.CreateTemp("", "bench-*.log")
if err != nil {
b.Fatal(err)
}
filePath := file.Name()
file.Close()
defer os.Remove(filePath)
scanner, err := NewScannerTail(filePath)
if err != nil {
b.Fatal(err)
}
defer scanner.Stop()
scanner.Start()
time.Sleep(200 * time.Millisecond)
b.ResetTimer()
for i := 0; i < b.N; i++ {
f, _ := os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
f.WriteString("benchmark line\n")
f.Sync()
f.Close()
<-scanner.Events()
}
}

54
internal/parser/sshd.go Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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