50 Commits

Author SHA1 Message Date
d3m0k1d
3ebffda2c7 feat: improve table on cli interface
All checks were successful
build / build (push) Successful in 3m14s
CD - BanForge Release / release (push) Successful in 5m8s
2026-01-26 14:21:35 +03:00
d3m0k1d
cadbbc9080 feat: improve reason string on db
All checks were successful
build / build (push) Successful in 3m8s
2026-01-26 14:04:30 +03:00
d3m0k1d
e907fb0b1a feat: update ban/unban command
All checks were successful
build / build (push) Successful in 3m13s
2026-01-25 21:13:56 +03:00
d3m0k1d
b0fc0646d2 fix: typo
All checks were successful
build / build (push) Successful in 3m23s
2026-01-24 20:30:27 +03:00
d3m0k1d
c2eb02afc7 docs: fix roadmap
All checks were successful
build / build (push) Successful in 3m22s
2026-01-23 18:22:18 +03:00
d3m0k1d
262f3daee4 docs: update reaadme.md Roadmap and overview
All checks were successful
build / build (push) Successful in 3m3s
2026-01-23 17:58:03 +03:00
d3m0k1d
fb32886d4a refactoring: rename func writer
All checks were successful
build / build (push) Successful in 3m31s
2026-01-22 21:08:55 +03:00
d3m0k1d
fb624a9147 fix: errcheck
All checks were successful
build / build (push) Successful in 3m10s
2026-01-22 20:34:49 +03:00
d3m0k1d
7741e08ebc fix: linter 2026-01-22 20:34:36 +03:00
d3m0k1d
5f607d0be0 refactoring: full refactoring the database structure from 1 file to 2 file db struct to avoid conflict 2 writters and sqllite busy, improve tests
Some checks failed
build / build (push) Has been cancelled
2026-01-22 20:29:19 +03:00
d3m0k1d
9a7e5a4796 fix: fix matchPath logic
All checks were successful
build / build (push) Successful in 3m26s
CD - BanForge Release / release (push) Successful in 5m21s
2026-01-22 00:37:57 +03:00
d3m0k1d
95bc7683ea fix: fix test for test server
All checks were successful
build / build (push) Successful in 3m18s
2026-01-22 00:25:50 +03:00
d3m0k1d
dca0241f17 fix: golangci-lint --fix run
Some checks failed
build / build (push) Failing after 3m51s
2026-01-22 00:11:29 +03:00
d3m0k1d
791d64ae4d feat: Recode logic for add logs to db
Some checks failed
build / build (push) Has been cancelled
2026-01-22 00:09:56 +03:00
d3m0k1d
7df9925f94 fix: db connection bug and delete debug logs
Some checks failed
build / build (push) Failing after 5m6s
CD - BanForge Release / release (push) Failing after 6m11s
2026-01-21 22:43:08 +03:00
d3m0k1d
211e019c68 fix: fix gosec and err checl
Some checks failed
build / build (push) Failing after 3m10s
2026-01-21 22:30:27 +03:00
d3m0k1d
de000ab5b6 fix: fix Nginx parser start without gourutine
Some checks failed
build / build (push) Failing after 2m54s
2026-01-21 21:40:57 +03:00
d3m0k1d
0fe34d1537 Fix: fix daemon logic
Some checks failed
build / build (push) Failing after 2m33s
2026-01-21 21:36:38 +03:00
d3m0k1d
341f49c4b4 feat: improve db logic and logger(untested)
Some checks failed
build / build (push) Failing after 2m48s
2026-01-21 20:44:28 +03:00
d3m0k1d
7522071a03 fix: fix tests
Some checks failed
build / build (push) Failing after 2m47s
2026-01-21 19:16:25 +03:00
d3m0k1d
4e8dc51ac8 chore: switch driver one more time ncruses -> modernc
Some checks failed
build / build (push) Has been cancelled
2026-01-21 19:15:35 +03:00
d3m0k1d
11453bd0d9 chore: delete old driver from deps
All checks were successful
build / build (push) Successful in 1m43s
CD - BanForge Release / release (push) Successful in 3m18s
2026-01-21 16:56:26 +03:00
d3m0k1d
f03ec114b1 fix: fix test, new driver 2026-01-21 16:56:07 +03:00
d3m0k1d
26f4f17760 fix: golangci --fix run
Some checks failed
build / build (push) Failing after 2m11s
2026-01-21 16:52:07 +03:00
d3m0k1d
3001282d88 chore: Add script for postinstall and postremove package, add scripts to gorealeaser
Some checks failed
build / build (push) Failing after 1m8s
2026-01-21 16:44:42 +03:00
d3m0k1d
9198f19805 chore: new driver for sqlite3 with wasm and edit goreleaser env to compiler CGO_ENABLED = 0, add empty file for postinstall script
Some checks failed
build / build (push) Failing after 1m15s
2026-01-21 16:26:55 +03:00
d3m0k1d
b6e92a2a57 feat: Add new logic for judge path with *
Some checks failed
build / build (push) Failing after 1m38s
2026-01-21 16:07:31 +03:00
d3m0k1d
16a174cf56 Fix: fix init nftables, fix logic ban/unban command
All checks were successful
build / build (push) Successful in 2m39s
2026-01-20 23:41:22 +03:00
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
41 changed files with 2227 additions and 504 deletions

View File

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

View File

@@ -1,4 +1,4 @@
name: CI.yml name: build
on: on:
push: push:

1
.gitignore vendored
View File

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

View File

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

74
.goreleaser.yml Normal file
View File

@@ -0,0 +1,74 @@
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
version: 2
project_name: BanForge
gitea_urls:
api: https://gitea.d3m0k1d.ru/api/v1
download: https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download
skip_tls_verify: false
builds:
- id: banforge
main: ./cmd/banforge/main.go
binary: banforge
ignore:
- goos: windows
- goos: darwin
- goos: freebsd
goos:
- linux
goarch:
- amd64
- arm64
ldflags:
- "-s -w"
env:
- CGO_ENABLED=0
archives:
- 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
scripts:
postinstall: build/postinstall.sh
postremove: build/postremove.sh
release:
gitea:
owner: d3m0k1d
name: BanForge
mode: keep-existing
changelog:
sort: asc
filters:
exclude:
- "^docs:"
- "^test:"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
algorithm: sha256
sboms:
- artifacts: archive
documents:
- "{{ .ArtifactName }}.spdx.json"
cmd: syft
args: ["$artifact", "--output", "spdx-json=$document"]

View File

@@ -28,3 +28,6 @@ test:
test-cover: test-cover:
go test -cover ./... go test -cover ./...
lint:
golangci-lint run --fix

View File

@@ -4,6 +4,8 @@ Log-based IPS system written in Go for Linux-based system.
[![Go Reference](https://pkg.go.dev/badge/github.com/d3m0k1d/BanForge/cmd/banforge.svg)](https://pkg.go.dev/github.com/d3m0k1d/BanForge) [![Go Reference](https://pkg.go.dev/badge/github.com/d3m0k1d/BanForge/cmd/banforge.svg)](https://pkg.go.dev/github.com/d3m0k1d/BanForge)
[![License](https://img.shields.io/badge/license-%20%20GNU%20GPLv3%20-green?style=plastic)](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE) [![License](https://img.shields.io/badge/license-%20%20GNU%20GPLv3%20-green?style=plastic)](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
[![Build Status](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/actions/workflows/CI.yml/badge.svg?branch=master)](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/actions)
![GitHub Tag](https://img.shields.io/github/v/tag/d3m0k1d/BanForge)
# Table of contents # Table of contents
1. [Overview](#overview) 1. [Overview](#overview)
2. [Requirements](#requirements) 2. [Requirements](#requirements)
@@ -13,14 +15,15 @@ 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. All release are available on my self-hosted [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge) after release v1.0.0 are available on Github release page.
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
- [x] Real-time Nginx log monitoring - [x] Rule system
- [ ] Add support for other service - [x] Nginx and Sshd support
- [ ] Add support for user service with regular expressions - [x] Working with ufw/iptables/nftables/firewalld
- [ ] Add support for most popular web-service
- [ ] User regexp for custom services
- [ ] TUI interface - [ ] TUI interface
# Requirements # Requirements
@@ -29,7 +32,7 @@ If you have any questions or suggestions, create issue on [Github](https://githu
- ufw/iptables/nftables/firewalld - ufw/iptables/nftables/firewalld
# Installation # 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. Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it. Then create or copy(/build dir) a systemd unit(openrc script) file.
Or clone the repo and use the Makefile. Or clone the repo and use the Makefile.
``` ```
git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git

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

61
build/postinstall.sh Normal file
View File

@@ -0,0 +1,61 @@
#!/bin/sh
if command -v systemctl >/dev/null 2>&1; then
# for systemd based systems
banforge init
cat > /etc/systemd/system/banforge.service << 'EOF'
[Unit]
Description=BanForge - IPS log based system
After=network-online.target
Wants=network-online.target
Documentation=https://github.com/d3m0k1d/BanForge
[Service]
Type=simple
ExecStart=/usr/local/bin/banforge daemon
User=root
Group=root
Restart=always
StandardOutput=journal
StandardError=journal
SyslogIdentifier=banforge
TimeoutStopSec=90
KillSignal=SIGTERM
[Install]
WantedBy=multi-user.target
EOF
chmod 644 /etc/systemd/system/banforge.service
systemctl daemon-reload
systemctl enable banforge
fi
if command -v rc-service >/dev/null 2>&1; then
# for openrc based systems
banforge init
cat > /etc/init.d/banforge << 'EOF'
#!/sbin/openrc-run
description="BanForge - IPS log based system"
command="/usr/bin/banforge"
command_args="daemon"
pidfile="/run/${RC_SVCNAME}.pid"
command_background="yes"
depend() {
need net
after network
}
start_post() {
einfo "BanForge is now running"
}
stop_post() {
einfo "BanForge is now stopped"
}
EOF
chmod 755 /etc/init.d/banforge
rc-update add banforge
fi

20
build/postremove.sh Normal file
View File

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

View File

@@ -1,8 +1,10 @@
package command package command
import ( import (
"context"
"os" "os"
"time" "os/signal"
"syscall"
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config" "github.com/d3m0k1d/BanForge/internal/config"
@@ -17,15 +19,33 @@ var DaemonCmd = &cobra.Command{
Use: "daemon", Use: "daemon",
Short: "Run BanForge daemon process", Short: "Run BanForge daemon process",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
entryCh := make(chan *storage.LogEntry, 1000)
resultCh := make(chan *storage.LogEntry, 100)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
log := logger.New(false) log := logger.New(false)
log.Info("Starting BanForge daemon") log.Info("Starting BanForge daemon")
db, err := storage.NewDB() reqDb_w, err := storage.NewRequestsWr()
if err != nil { if err != nil {
log.Error("Failed to create database", "error", err) log.Error("Failed to create request writer", "error", err)
os.Exit(1)
}
banDb_r, err := storage.NewBanReader()
if err != nil {
log.Error("Failed to create ban reader", "error", err)
os.Exit(1)
}
banDb_w, err := storage.NewBanWriter()
if err != nil {
log.Error("Failed to create ban writter", "error", err)
os.Exit(1) os.Exit(1)
} }
defer func() { defer func() {
err = db.Close() err = banDb_r.Close()
if err != nil {
log.Error("Failed to close database connection", "error", err)
}
err = banDb_w.Close()
if err != nil { if err != nil {
log.Error("Failed to close database connection", "error", err) log.Error("Failed to close database connection", "error", err)
} }
@@ -43,49 +63,94 @@ var DaemonCmd = &cobra.Command{
log.Error("Failed to load rules", "error", err) log.Error("Failed to load rules", "error", err)
os.Exit(1) os.Exit(1)
} }
j := judge.New(db, b) j := judge.New(banDb_r, banDb_w, b, resultCh, entryCh)
j.LoadRules(r) j.LoadRules(r)
go func() { go j.UnbanChecker()
ticker := time.NewTicker(5 * time.Second) go j.Tribunal()
defer ticker.Stop() go storage.WriteReq(reqDb_w, resultCh)
for range ticker.C { var scanners []*parser.Scanner
if err := j.ProcessUnviewed(); err != nil {
log.Error("Failed to process unviewed", "error", err)
}
}
}()
for _, svc := range cfg.Service { for _, svc := range cfg.Service {
log.Info("Processing service", "name", svc.Name, "enabled", svc.Enabled, "path", svc.LogPath) log.Info(
"Processing service",
"name", svc.Name,
"enabled", svc.Enabled,
"path", svc.LogPath,
)
if !svc.Enabled { if !svc.Enabled {
log.Info("Service disabled, skipping", "name", svc.Name) log.Info("Service disabled, skipping", "name", svc.Name)
continue continue
} }
if svc.Name != "nginx" { log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath)
log.Info("Only nginx supported, skipping", "name", svc.Name) if svc.Logging != "file" && svc.Logging != "journald" {
log.Error("Invalid logging type", "type", svc.Logging)
continue continue
} }
log.Info("Starting parser for service", "name", svc.Name, "path", svc.LogPath) if svc.Logging == "file" {
log.Info("Logging to file", "path", svc.LogPath)
pars, err := parser.NewScanner(svc.LogPath) pars, err := parser.NewScannerTail(svc.LogPath)
if err != nil { if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err) log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue continue
} }
scanners = append(scanners, pars)
go pars.Start() go pars.Start()
go func(p *parser.Scanner, serviceName string) { go func(p *parser.Scanner, serviceName string) {
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName) log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser() ng := parser.NewNginxParser()
resultCh := make(chan *storage.LogEntry, 100) ng.Parse(p.Events(), entryCh)
ng.Parse(p.Events(), resultCh) }
go storage.Write(db, resultCh) if svc.Name == "ssh" {
log.Info("Starting ssh parser", "service", serviceName)
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), entryCh)
}
}(pars, svc.Name) }(pars, svc.Name)
continue
} }
select {} if svc.Logging == "journald" {
log.Info("Logging to journald", "path", svc.LogPath)
pars, err := parser.NewScannerJournald(svc.LogPath)
if err != nil {
log.Error("Failed to create scanner", "service", svc.Name, "error", err)
continue
}
scanners = append(scanners, pars)
go pars.Start()
go func(p *parser.Scanner, serviceName string) {
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
ng.Parse(p.Events(), resultCh)
}
if svc.Name == "ssh" {
log.Info("Starting ssh parser", "service", serviceName)
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), resultCh)
}
}(pars, svc.Name)
continue
}
}
<-ctx.Done()
log.Info("Shutdown signal received")
for _, s := range scanners {
s.Stop()
}
}, },
} }

View File

@@ -7,16 +7,30 @@ import (
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config" "github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var ( var (
ip string ttl_fw string
) )
var UnbanCmd = &cobra.Command{ var UnbanCmd = &cobra.Command{
Use: "unban", Use: "unban",
Short: "Unban IP", Short: "Unban IP",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
fmt.Println("IP can't be empty")
os.Exit(1)
}
if ttl_fw == "" {
ttl_fw = "1y"
}
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@@ -41,6 +55,11 @@ var UnbanCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
err = db.RemoveBan(ip)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP unblocked successfully!") fmt.Println("IP unblocked successfully!")
}, },
} }
@@ -49,7 +68,19 @@ var BanCmd = &cobra.Command{
Use: "ban", Use: "ban",
Short: "Ban IP", Short: "Ban IP",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
if len(args) == 0 {
fmt.Println("IP can't be empty")
os.Exit(1)
}
if ttl_fw == "" {
ttl_fw = "1y"
}
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
cfg, err := config.LoadConfig() cfg, err := config.LoadConfig()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
@@ -74,11 +105,15 @@ var BanCmd = &cobra.Command{
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
} }
fmt.Println("IP unblocked successfully!") err = db.AddBan(ip, ttl_fw, "manual ban")
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP blocked successfully!")
}, },
} }
func FwRegister() { func FwRegister() {
BanCmd.Flags().StringVarP(&ip, "ip", "i", "", "ip to ban") BanCmd.Flags().StringVarP(&ttl_fw, "ttl", "t", "", "ban time")
UnbanCmd.Flags().StringVarP(&ip, "ip", "i", "", "ip to unban")
} }

View File

@@ -82,23 +82,11 @@ var InitCmd = &cobra.Command{
} }
fmt.Println("Firewall configured") fmt.Println("Firewall configured")
db, err := storage.NewDB() err = storage.CreateTables()
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) 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("Firewall detected and configured")
fmt.Println("BanForge initialized successfully!") 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.NewBanReader()
if err != nil {
log.Error("Failed to create database", "error", err)
os.Exit(1)
}
err = d.BanList()
if err != nil {
log.Error("Failed to get ban list", "error", err)
os.Exit(1)
}
},
}

View File

@@ -14,6 +14,7 @@ var (
path string path string
status string status string
method string method string
ttl string
) )
var RuleCmd = &cobra.Command{ var RuleCmd = &cobra.Command{
@@ -37,8 +38,10 @@ var AddCmd = &cobra.Command{
fmt.Printf("At least 1 rule field must be filled in.") fmt.Printf("At least 1 rule field must be filled in.")
os.Exit(1) os.Exit(1)
} }
if ttl == "" {
err := config.NewRule(name, service, path, status, method) ttl = "1y"
}
err := config.NewRule(name, service, path, status, method, ttl)
if err != nil { if err != nil {
fmt.Println(err) fmt.Println(err)
os.Exit(1) os.Exit(1)
@@ -57,7 +60,14 @@ var ListCmd = &cobra.Command{
os.Exit(1) os.Exit(1)
} }
for _, rule := range r { for _, rule := range r {
fmt.Printf("Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n", rule.Name, rule.ServiceName, rule.Path, rule.Status, rule.Method) fmt.Printf(
"Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n",
rule.Name,
rule.ServiceName,
rule.Path,
rule.Status,
rule.Method,
)
} }
}, },
} }
@@ -68,6 +78,7 @@ func RuleRegister() {
AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)") AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)")
AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name") AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name")
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path") AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
AddCmd.Flags().StringVarP(&status, "status", "c", "", "HTTP status code") AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method") AddCmd.Flags().StringVarP(&method, "method", "m", "", "method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time")
} }

View File

@@ -27,6 +27,7 @@ func Execute() {
rootCmd.AddCommand(command.RuleCmd) rootCmd.AddCommand(command.RuleCmd)
rootCmd.AddCommand(command.BanCmd) rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd) rootCmd.AddCommand(command.UnbanCmd)
rootCmd.AddCommand(command.BanListCmd)
command.RuleRegister() command.RuleRegister()
command.FwRegister() command.FwRegister()
if err := rootCmd.Execute(); err != nil { if err := rootCmd.Execute(); err != nil {

View File

@@ -31,6 +31,15 @@ banforge unban <ip>
**Description** **Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands. These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
Flag -t or -ttl add bantime if not used default ban 1 year
### list - Lists the IP addresses that are currently blocked
```shell
banforge list
```
**Description**
This command output table of IP addresses that are currently blocked
### rule - Manages detection rules ### rule - Manages detection rules
```shell ```shell
@@ -48,4 +57,6 @@ These command help you to create and manage detection rules in CLI interface.
| -p -path | - | | -p -path | - |
| -m -method | - | | -m -method | - |
| -c -status | - | | -c -status | - |
| -t -ttl | -(if not used default ban 1 year) |
You must specify at least 1 of the optional flags to create a rule. You must specify at least 1 of the optional flags to create a rule.

View File

@@ -0,0 +1,49 @@
# 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".
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*")

17
go.mod
View File

@@ -4,11 +4,26 @@ go 1.25.5
require ( require (
github.com/BurntSushi/toml v1.6.0 github.com/BurntSushi/toml v1.6.0
github.com/mattn/go-sqlite3 v1.14.33 github.com/jedib0t/go-pretty/v6 v6.7.8
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
gopkg.in/natefinch/lumberjack.v2 v2.2.1
modernc.org/sqlite v1.44.3
) )
require ( require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.32.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
) )

74
go.sum
View File

@@ -1,15 +1,85 @@
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0= github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc8sr5o=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -104,15 +104,14 @@ func (n *Nftables) Setup(config string) error {
nftConfig := `table inet banforge { nftConfig := `table inet banforge {
chain input { chain input {
type filter hook input priority 0 type filter hook input priority filter; policy accept;
policy accept jump banned
} }
chain banned { chain banned {
} }
} }
` `
cmd := exec.Command("sudo", "tee", config) cmd := exec.Command("sudo", "tee", config)
stdin, err := cmd.StdinPipe() stdin, err := cmd.StdinPipe()
if err != nil { if err != nil {

View File

@@ -3,6 +3,9 @@ package config
import ( import (
"fmt" "fmt"
"os" "os"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml" "github.com/BurntSushi/toml"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -22,7 +25,14 @@ func LoadRuleConfig() ([]Rule, error) {
return cfg.Rules, nil return cfg.Rules, nil
} }
func NewRule(Name string, ServiceName string, Path string, Status string, Method string) error { func NewRule(
Name string,
ServiceName string,
Path string,
Status string,
Method string,
ttl string,
) error {
r, err := LoadRuleConfig() r, err := LoadRuleConfig()
if err != nil { if err != nil {
r = []Rule{} r = []Rule{}
@@ -31,7 +41,17 @@ func NewRule(Name string, ServiceName string, Path string, Status string, Method
fmt.Printf("Rule name can't be empty\n") fmt.Printf("Rule name can't be empty\n")
return nil return nil
} }
r = append(r, Rule{Name: Name, ServiceName: ServiceName, Path: Path, Status: Status, Method: Method}) r = append(
r,
Rule{
Name: Name,
ServiceName: ServiceName,
Path: Path,
Status: Status,
Method: Method,
BanTime: ttl,
},
)
file, err := os.Create("/etc/banforge/rules.toml") file, err := os.Create("/etc/banforge/rules.toml")
if err != nil { if err != nil {
return err return err
@@ -104,3 +124,31 @@ func EditRule(Name string, ServiceName string, Path string, Status string, Metho
return nil return nil
} }
func ParseDurationWithYears(s string) (time.Duration, error) {
if strings.HasSuffix(s, "y") {
years, err := strconv.Atoi(strings.TrimSuffix(s, "y"))
if err != nil {
return 0, err
}
return time.Duration(years) * 365 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "M") {
months, err := strconv.Atoi(strings.TrimSuffix(s, "M"))
if err != nil {
return 0, err
}
return time.Duration(months) * 30 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if err != nil {
return 0, err
}
return time.Duration(days) * 24 * time.Hour, nil
}
return time.ParseDuration(s)
}

View File

@@ -7,18 +7,16 @@ const Base_config = `
[firewall] [firewall]
name = "" name = ""
config = "/etc/nftables.conf" config = "/etc/nftables.conf"
ban_time = 1200
[[service]] [[service]]
name = "nginx" name = "nginx"
logging = "file"
log_path = "/var/log/nginx/access.log" log_path = "/var/log/nginx/access.log"
enabled = true enabled = true
[[service]] [[service]]
name = "nginx" name = "nginx"
logging = "journald"
log_path = "/var/log/nginx/access.log" log_path = "/var/log/nginx/access.log"
enabled = false enabled = false
` `
// TODO: fix types for use 1 or any services"

View File

@@ -3,11 +3,11 @@ package config
type Firewall struct { type Firewall struct {
Name string `toml:"name"` Name string `toml:"name"`
Config string `toml:"config"` Config string `toml:"config"`
BanTime int `toml:"ban_time"`
} }
type Service struct { type Service struct {
Name string `toml:"name"` Name string `toml:"name"`
Logging string `toml:"logging"`
LogPath string `toml:"log_path"` LogPath string `toml:"log_path"`
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
} }
@@ -28,4 +28,5 @@ type Rule struct {
Path string `toml:"path"` Path string `toml:"path"`
Status string `toml:"status"` Status string `toml:"status"`
Method string `toml:"method"` Method string `toml:"method"`
BanTime string `toml:"ban_time"`
} }

View File

@@ -2,6 +2,8 @@ package judge
import ( import (
"fmt" "fmt"
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/blocker" "github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config" "github.com/d3m0k1d/BanForge/internal/config"
@@ -10,18 +12,30 @@ import (
) )
type Judge struct { type Judge struct {
db *storage.DB db_r *storage.BanReader
db_w *storage.BanWriter
logger *logger.Logger logger *logger.Logger
Blocker blocker.BlockerEngine Blocker blocker.BlockerEngine
rulesByService map[string][]config.Rule rulesByService map[string][]config.Rule
entryCh chan *storage.LogEntry
resultCh chan *storage.LogEntry
} }
func New(db *storage.DB, b blocker.BlockerEngine) *Judge { func New(
db_r *storage.BanReader,
db_w *storage.BanWriter,
b blocker.BlockerEngine,
resultCh chan *storage.LogEntry,
entryCh chan *storage.LogEntry,
) *Judge {
return &Judge{ return &Judge{
db: db, db_w: db_w,
db_r: db_r,
logger: logger.New(false), logger: logger.New(false),
rulesByService: make(map[string][]config.Rule), rulesByService: make(map[string][]config.Rule),
Blocker: b, Blocker: b,
entryCh: entryCh,
resultCh: resultCh,
} }
} }
@@ -36,57 +50,133 @@ func (j *Judge) LoadRules(rules []config.Rule) {
j.logger.Info("Rules loaded and indexed by service") j.logger.Info("Rules loaded and indexed by service")
} }
func (j *Judge) ProcessUnviewed() error { func (j *Judge) Tribunal() {
rows, err := j.db.SearchUnViewed() j.logger.Info("Tribunal started")
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() { for entry := range j.entryCh {
var entry storage.LogEntry j.logger.Debug(
err = rows.Scan(&entry.ID, &entry.Service, &entry.IP, &entry.Path, &entry.Status, &entry.Method, &entry.IsViewed, &entry.CreatedAt) "Processing entry",
if err != nil { "ip",
j.logger.Error(fmt.Sprintf("Failed to scan database row: %v", err)) entry.IP,
"service",
entry.Service,
"status",
entry.Status,
)
rules, serviceExists := j.rulesByService[entry.Service]
if !serviceExists {
j.logger.Debug("No rules for service", "service", entry.Service)
continue continue
} }
rules, serviceExists := j.rulesByService[entry.Service] ruleMatched := false
if serviceExists {
for _, rule := range rules { for _, rule := range rules {
if (rule.Method == "" || entry.Method == rule.Method) && methodMatch := rule.Method == "" || entry.Method == rule.Method
(rule.Status == "" || entry.Status == rule.Status) && statusMatch := rule.Status == "" || entry.Status == rule.Status
(rule.Path == "" || entry.Path == rule.Path) { pathMatch := matchPath(entry.Path, rule.Path)
j.logger.Info(fmt.Sprintf("Rule matched for IP: %s, Service: %s", entry.IP, entry.Service)) j.logger.Debug(
err = j.Blocker.Ban(entry.IP) "Testing rule",
"rule", rule.Name,
"method_match", methodMatch,
"status_match", statusMatch,
"path_match", pathMatch,
)
if methodMatch && statusMatch && pathMatch {
ruleMatched = true
j.logger.Info("Rule matched", "rule", rule.Name, "ip", entry.IP)
banned, err := j.db_r.IsBanned(entry.IP)
if err != nil { if err != nil {
j.logger.Error(fmt.Sprintf("Failed to ban IP: %v", err)) j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
break
} }
j.logger.Info(fmt.Sprintf("IP banned: %s", entry.IP))
if banned {
j.logger.Info("IP already banned", "ip", entry.IP)
j.resultCh <- entry
break
}
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
if err != nil {
j.logger.Error(
"Failed to add ban to database",
"ip",
entry.IP,
"ban_time",
rule.BanTime,
"error",
err,
)
break
}
if err := j.Blocker.Ban(entry.IP); err != nil {
j.logger.Error("Failed to ban IP at firewall", "ip", entry.IP, "error", err)
break
}
j.logger.Info(
"IP banned successfully",
"ip",
entry.IP,
"rule",
rule.Name,
"ban_time",
rule.BanTime,
)
j.resultCh <- entry
break break
} }
} }
}
err = j.db.MarkAsViewed(entry.ID) if !ruleMatched {
if err != nil { j.logger.Debug("No rules matched", "ip", entry.IP, "service", entry.Service)
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.Info("Tribunal stopped - entryCh closed")
j.logger.Error(fmt.Sprintf("Error iterating rows: %v", err)) }
return err
} func (j *Judge) UnbanChecker() {
tick := time.NewTicker(5 * time.Minute)
return nil defer tick.Stop()
for range tick.C {
ips, err := j.db_w.RemoveExpiredBans()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
continue
}
for _, ip := range ips {
if err := j.Blocker.Unban(ip); err != nil {
j.logger.Error(fmt.Sprintf("Failed to unban IP at firewall: %v", err))
}
}
}
}
func matchPath(path string, rulePath string) bool {
if rulePath == "" {
return true
}
if strings.HasPrefix(rulePath, "*") {
suffix := strings.TrimPrefix(rulePath, "*")
return strings.HasSuffix(path, suffix)
}
if strings.HasPrefix(rulePath, "/*") {
suffix := strings.TrimPrefix(rulePath, "/*")
return strings.HasSuffix(path, suffix)
}
if strings.HasSuffix(rulePath, "*") {
prefix := strings.TrimSuffix(rulePath, "*")
return strings.HasPrefix(path, prefix)
}
return path == rulePath
} }

View File

@@ -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", CreatedAt: ""},
wantErr: true,
wantMatch: false,
},
{
name: "Matching rule",
inputRule: config.Rule{Name: "test", ServiceName: "nginx", Path: "/api", Status: "200", Method: "GET"},
inputLog: storage.LogEntry{ID: 1, Service: "nginx", IP: "127.0.0.1", Path: "/api", Status: "200", Method: "GET", CreatedAt: ""},
wantErr: false,
wantMatch: true,
},
{
name: "Non-matching status",
inputRule: config.Rule{Name: "test", ServiceName: "nginx", Path: "/api", Status: "404", Method: "GET"},
inputLog: storage.LogEntry{ID: 2, Service: "nginx", IP: "127.0.0.1", Path: "/api", Status: "200", Method: "GET", CreatedAt: ""},
wantErr: false,
wantMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.inputRule.Name == "" {
if !tt.wantErr {
t.Errorf("Expected error for empty rule name, but got none")
}
return
}
result := (tt.inputRule.Method == "" || tt.inputLog.Method == tt.inputRule.Method) &&
(tt.inputRule.Status == "" || tt.inputLog.Status == tt.inputRule.Status) &&
(tt.inputRule.Path == "" || tt.inputLog.Path == tt.inputRule.Path) &&
(tt.inputRule.ServiceName == "" || tt.inputLog.Service == tt.inputRule.ServiceName)
if result != tt.wantMatch {
t.Errorf("Expected error: %v, but got: %v", tt.wantErr, result)
}
})
}
}

View File

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

View File

@@ -24,7 +24,6 @@ func NewNginxParser() *NginxParser {
func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) { 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 // Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
go func() {
for event := range eventCh { for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data) matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil { if matches == nil {
@@ -40,9 +39,17 @@ func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEn
Path: path, Path: path,
Status: status, Status: status,
Method: method, Method: method,
IsViewed: false,
} }
p.logger.Info("Parsed nginx log entry", "ip", matches[1], "path", path, "status", status, "method", method) p.logger.Info(
"Parsed nginx log entry",
"ip",
matches[1],
"path",
path,
"status",
status,
"method",
method,
)
} }
}()
} }

View File

@@ -3,6 +3,7 @@ package parser
import ( import (
"bufio" "bufio"
"os" "os"
"os/exec"
"time" "time"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
@@ -17,22 +18,51 @@ type Scanner struct {
ch chan Event ch chan Event
stopCh chan struct{} stopCh chan struct{}
logger *logger.Logger logger *logger.Logger
cmd *exec.Cmd
file *os.File file *os.File
pollDelay time.Duration pollDelay time.Duration
} }
func NewScanner(path string) (*Scanner, error) { func NewScannerTail(path string) (*Scanner, error) {
file, err := os.Open(path) // #nosec G304 -- admin tool, runs as root, path controlled by operator cmd := exec.Command("tail", "-F", "-n", "10", path)
stdout, err := cmd.StdoutPipe()
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := cmd.Start(); err != nil {
return nil, err
}
return &Scanner{ return &Scanner{
scanner: bufio.NewScanner(file), scanner: bufio.NewScanner(stdout),
ch: make(chan Event, 100), ch: make(chan Event, 100),
stopCh: make(chan struct{}), stopCh: make(chan struct{}),
logger: logger.New(false), 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, pollDelay: 100 * time.Millisecond,
}, nil }, nil
} }
@@ -58,7 +88,6 @@ func (s *Scanner) Start() {
s.logger.Error("Scanner error") s.logger.Error("Scanner error")
return return
} }
time.Sleep(s.pollDelay)
} }
} }
} }
@@ -67,11 +96,26 @@ func (s *Scanner) Start() {
func (s *Scanner) Stop() { func (s *Scanner) Stop() {
close(s.stopCh) close(s.stopCh)
time.Sleep(150 * time.Millisecond)
err := s.file.Close() 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 { if err != nil {
s.logger.Error("Failed to close file") 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)
close(s.ch) close(s.ch)
} }

View File

@@ -6,48 +6,67 @@ import (
"time" "time"
) )
func TestNewScanner(t *testing.T) { func TestNewScannerTail(t *testing.T) {
file, err := os.CreateTemp("", "test.log")
file, err := os.CreateTemp("", "test-*.log")
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer file.Close()
defer os.Remove(file.Name()) defer os.Remove(file.Name())
s, err := NewScanner(file.Name()) file.Close()
scanner, err := NewScannerTail(file.Name())
if err != nil { if err != nil {
t.Fatal(err) t.Fatalf("NewScannerTail() error = %v", err)
} }
if s == nil {
if scanner == nil {
t.Fatal("Scanner is 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 TestScannerStart(t *testing.T) { func TestScannerTailEvents(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string lines []string
wantErr bool
wantLines int wantLines int
}{ }{
{ {
name: "correct file", name: "multiple lines",
input: `Failed password for root from 192.168.1.1 lines: []string{
Invalid user admin from 192.168.1.1 "Failed password for root from 192.168.1.1",
Accepted publickey for user from 192.168.1.2`, "Invalid user admin from 192.168.1.2",
wantErr: false, "Accepted publickey for user from 192.168.1.3",
},
wantLines: 3, wantLines: 3,
}, },
{ {
name: "empty file", name: "single line",
input: "", lines: []string{
wantErr: false, "Failed password for root",
wantLines: 0, },
wantLines: 1,
}, },
{ {
name: "single line", name: "many lines",
input: `Failed password for root`, lines: []string{
wantErr: false, "line 1",
wantLines: 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) t.Fatal(err)
} }
filePath := file.Name() filePath := file.Name()
if _, err := file.WriteString(tt.input); err != nil {
t.Fatal(err)
}
file.Close() file.Close()
defer os.Remove(filePath) defer os.Remove(filePath)
scanner, err := NewScanner(filePath) scanner, err := NewScannerTail(filePath)
if (err != nil) != tt.wantErr { if err != nil {
t.Errorf("NewScanner() error = %v, wantErr %v", err, tt.wantErr) t.Fatalf("NewScannerTail() error = %v", err)
return
}
if tt.wantErr {
return
} }
defer scanner.Stop() defer scanner.Stop()
scanner.Start() scanner.Start()
timeout := time.After(500 * time.Millisecond) time.Sleep(200 * time.Millisecond)
linesRead := 0
file, err = os.OpenFile(filePath, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
t.Fatal(err)
}
for _, line := range tt.lines {
if _, err := file.WriteString(line + "\n"); err != nil {
t.Fatal(err)
}
}
if err := file.Sync(); err != nil {
t.Fatal(err)
}
file.Close()
// 5. Собираем события
timeout := time.After(1 * time.Second)
var events []Event
eventLoop:
for { for {
select { select {
case event := <-scanner.Events(): case event := <-scanner.Events():
linesRead++ events = append(events, event)
t.Logf("Read: %s", event.Data) t.Logf("Read: %s", event.Data)
case <-timeout:
if linesRead != tt.wantLines { if len(events) == tt.wantLines {
t.Errorf("got %d lines, want %d", linesRead, 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()
}
}

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

@@ -0,0 +1,53 @@
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
}
p.logger.Info(
"Parsed ssh log entry",
"ip",
matches[6],
"user",
matches[5],
"method",
matches[4],
"status",
"Failed",
)
}
}()
}

214
internal/storage/ban_db.go Normal file
View File

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

View File

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

View File

@@ -2,63 +2,55 @@ package storage
import ( import (
"database/sql" "database/sql"
"fmt"
"github.com/d3m0k1d/BanForge/internal/logger" _ "modernc.org/sqlite"
_ "github.com/mattn/go-sqlite3"
) )
type DB struct { func CreateTables() error {
logger *logger.Logger // Requests DB
db *sql.DB db_r, err := sql.Open("sqlite",
} "/var/lib/banforge/requests.db?"+
"mode=rwc&"+
func NewDB() (*DB, error) { "_pragma=journal_mode(WAL)&"+
db, err := sql.Open("sqlite3", "/var/lib/banforge/storage.db?mode=rwc&_journal_mode=WAL&_busy_timeout=10000&cache=shared") "_pragma=busy_timeout(30000)&"+
"_pragma=synchronous(NORMAL)")
if err != nil { if err != nil {
return nil, err return fmt.Errorf("failed to open requests db: %w", err)
} }
defer func() {
if err := db.Ping(); err != nil { err = db_r.Close()
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 { if err != nil {
return err fmt.Println(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
} }
}()
_, err = db_r.Exec(CreateRequestsTable)
if err != nil {
return fmt.Errorf("failed to create requests table: %w", err)
}
// Bans DB
db_b, err := sql.Open("sqlite",
"/var/lib/banforge/bans.db?"+
"mode=rwc&"+
"_pragma=journal_mode(WAL)&"+
"_pragma=busy_timeout(30000)&"+
"_pragma=synchronous(FULL)")
if err != nil {
return fmt.Errorf("failed to open bans db: %w", err)
}
defer func() {
err = db_b.Close()
if err != nil {
fmt.Println(err)
}
}()
_, err = db_b.Exec(CreateBansTable)
if err != nil {
return fmt.Errorf("failed to create bans table: %w", err)
}
fmt.Println("Tables created successfully!")
return nil return nil
} }

View File

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

@@ -1,7 +1,6 @@
package storage package storage
const CreateTables = ` const CreateRequestsTable = `
CREATE TABLE IF NOT EXISTS requests ( CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
service TEXT NOT NULL, service TEXT NOT NULL,
@@ -9,20 +8,24 @@ CREATE TABLE IF NOT EXISTS requests (
path TEXT, path TEXT,
method TEXT, method TEXT,
status TEXT, status TEXT,
viewed BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP created_at DATETIME DEFAULT CURRENT_TIMESTAMP
); );
CREATE INDEX IF NOT EXISTS idx_requests_service ON requests(service);
CREATE INDEX IF NOT EXISTS idx_requests_ip ON requests(ip);
CREATE INDEX IF NOT EXISTS idx_requests_status ON requests(status);
CREATE INDEX IF NOT EXISTS idx_requests_created_at ON requests(created_at);
`
// Миграция для bans.db
const CreateBansTable = `
CREATE TABLE IF NOT EXISTS bans ( CREATE TABLE IF NOT EXISTS bans (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
ip TEXT UNIQUE NOT NULL, ip TEXT UNIQUE NOT NULL,
reason TEXT, reason TEXT,
banned_at DATETIME DEFAULT CURRENT_TIMESTAMP banned_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expired_at DATETIME
); );
CREATE INDEX IF NOT EXISTS idx_service ON requests(service); CREATE INDEX IF NOT EXISTS idx_bans_ip ON bans(ip);
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

@@ -7,7 +7,6 @@ type LogEntry struct {
Path string `db:"path"` Path string `db:"path"`
Status string `db:"status"` Status string `db:"status"`
Method string `db:"method"` Method string `db:"method"`
IsViewed bool `db:"viewed"`
CreatedAt string `db:"created_at"` CreatedAt string `db:"created_at"`
} }

View File

@@ -0,0 +1,30 @@
package storage
import (
"database/sql"
"github.com/d3m0k1d/BanForge/internal/logger"
_ "modernc.org/sqlite"
)
type Request_Writer struct {
logger *logger.Logger
db *sql.DB
}
func NewRequestsWr() (*Request_Writer, error) {
db, err := sql.Open(
"sqlite",
"/var/lib/banforge/requests.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)",
)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &Request_Writer{
logger: logger.New(false),
db: db,
}, nil
}

View File

@@ -4,19 +4,80 @@ import (
"time" "time"
) )
func Write(db *DB, resultCh <-chan *LogEntry) { func WriteReq(db *Request_Writer, resultCh <-chan *LogEntry) {
for result := range resultCh { db.logger.Info("Starting log writer")
_, err := db.db.Exec( const batchSize = 100
const flushInterval = 1 * time.Second
batch := make([]*LogEntry, 0, batchSize)
ticker := time.NewTicker(flushInterval)
defer ticker.Stop()
flush := func() {
if len(batch) == 0 {
return
}
tx, err := db.db.Begin()
if err != nil {
db.logger.Error("Failed to begin transaction", "error", err)
return
}
stmt, err := tx.Prepare(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)", "INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
result.Service, )
result.IP, if err != nil {
result.Path, db.logger.Error("Failed to prepare statement", "error", err)
result.Method, if rollbackErr := tx.Rollback(); rollbackErr != nil {
result.Status, db.logger.Error("Failed to rollback transaction", "error", rollbackErr)
}
return
}
defer func() {
if closeErr := stmt.Close(); closeErr != nil {
db.logger.Error("Failed to close statement", "error", closeErr)
}
}()
for _, entry := range batch {
_, err := stmt.Exec(
entry.Service,
entry.IP,
entry.Path,
entry.Method,
entry.Status,
time.Now().Format(time.RFC3339), time.Now().Format(time.RFC3339),
) )
if err != nil { if err != nil {
db.logger.Error("Failed to write to database", "error", err) db.logger.Error("Failed to insert entry", "error", err)
}
}
if err := tx.Commit(); err != nil {
db.logger.Error("Failed to commit transaction", "error", err)
return
}
db.logger.Debug("Flushed batch", "count", len(batch))
batch = batch[:0]
}
for {
select {
case result, ok := <-resultCh:
if !ok {
flush()
return
}
batch = append(batch, result)
if len(batch) >= batchSize {
flush()
}
case <-ticker.C:
flush()
} }
} }
} }

View File

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