Compare commits
74 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91e552bcd | ||
|
|
e7bb64d24b | ||
|
|
7b318bcc40 | ||
|
|
1aec91efa2 | ||
|
|
e36cf1861e | ||
|
|
69c3befa48 | ||
|
|
8e3e940bed | ||
|
|
eaa03b3869 | ||
|
|
bbc936ba5d | ||
|
|
5cc61aca75 | ||
|
|
fd38af9cb0 | ||
|
|
0929b92939 | ||
|
|
b75541af61 | ||
|
|
4e56d7bb6c | ||
|
|
efa9abb289 | ||
|
|
2747abfc04 | ||
|
|
66d460dbfc | ||
|
|
783645c30b | ||
|
|
d9df055765 | ||
|
|
6897ea8753 | ||
|
|
d534fc79d7 | ||
|
|
9ad0a3eb12 | ||
|
|
d8712037f4 | ||
|
|
aef2647a82 | ||
|
|
c3b6708a98 | ||
|
|
3acd0b899c | ||
|
|
3ac1250bfc | ||
|
|
7bba444522 | ||
|
|
97eb626237 | ||
|
|
b7a1ac06d4 | ||
|
|
49f0acb777 | ||
|
|
a602207369 | ||
|
|
8c0cfcdbe7 | ||
|
|
35a1a89baf | ||
|
|
f3387b169a | ||
|
|
5782072f91 | ||
|
|
7918b3efe6 | ||
|
|
f628e24f58 | ||
|
|
7f54db0cd4 | ||
|
|
2e9b307194 | ||
|
|
726594a712 | ||
|
|
b27038a59c | ||
|
|
72025dab7d | ||
|
|
dd131477e2 | ||
|
|
670aec449a | ||
|
|
fc37e641be | ||
|
|
361de03208 | ||
|
|
a2268fda5d | ||
|
|
9dc0b6002e | ||
|
|
4953be3ef6 | ||
|
|
c386a2d6bc | ||
|
|
dea03a6f70 | ||
|
|
11f755c03c | ||
|
|
1c7a1c1778 | ||
|
|
411574cabe | ||
|
|
820c9410a1 | ||
|
|
6f261803a7 | ||
|
|
aacc98668f | ||
|
|
9519eedf4f | ||
|
|
b8b9b227a9 | ||
|
|
08d3214f22 | ||
|
|
6ebda76738 | ||
|
|
b9754f605b | ||
|
|
be6b19426b | ||
|
|
3ebffda2c7 | ||
|
|
cadbbc9080 | ||
|
|
e907fb0b1a | ||
|
|
b0fc0646d2 | ||
|
|
c2eb02afc7 | ||
|
|
262f3daee4 | ||
|
|
fb32886d4a | ||
|
|
fb624a9147 | ||
|
|
7741e08ebc | ||
|
|
5f607d0be0 |
@@ -32,7 +32,7 @@ jobs:
|
|||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: go test ./...
|
run: go test ./...
|
||||||
- name: GoReleaser
|
- name: GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v6
|
uses: goreleaser/goreleaser-action@v7
|
||||||
with:
|
with:
|
||||||
distribution: goreleaser
|
distribution: goreleaser
|
||||||
version: latest
|
version: latest
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ builds:
|
|||||||
env:
|
env:
|
||||||
- CGO_ENABLED=0
|
- CGO_ENABLED=0
|
||||||
archives:
|
archives:
|
||||||
- format: tar.gz
|
- formats: [tar.gz]
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||||
|
|
||||||
nfpms:
|
nfpms:
|
||||||
@@ -48,6 +48,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: build/postinstall.sh
|
postinstall: build/postinstall.sh
|
||||||
postremove: build/postremove.sh
|
postremove: build/postremove.sh
|
||||||
|
contents:
|
||||||
|
- src: docs/man/banforge.1
|
||||||
|
dst: /usr/share/man/man1/banforge.1
|
||||||
|
file_info:
|
||||||
|
mode: 0644
|
||||||
|
- src: docs/man/banforge.5
|
||||||
|
dst: /usr/share/man/man5/banforge.5
|
||||||
|
file_info:
|
||||||
|
mode: 0644
|
||||||
release:
|
release:
|
||||||
gitea:
|
gitea:
|
||||||
owner: d3m0k1d
|
owner: d3m0k1d
|
||||||
@@ -60,7 +69,6 @@ changelog:
|
|||||||
exclude:
|
exclude:
|
||||||
- "^docs:"
|
- "^docs:"
|
||||||
- "^test:"
|
- "^test:"
|
||||||
|
|
||||||
checksum:
|
checksum:
|
||||||
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
|
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
|
||||||
algorithm: sha256
|
algorithm: sha256
|
||||||
|
|||||||
17
Makefile
17
Makefile
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: build build-daemon build-tui clean help
|
.PHONY: build build-daemon build-tui clean help install-man check-man
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "BanForge build targets:"
|
@echo "BanForge build targets:"
|
||||||
@@ -7,6 +7,8 @@ help:
|
|||||||
@echo " make build-tui - Build only TUI"
|
@echo " make build-tui - Build only TUI"
|
||||||
@echo " make clean - Remove binaries"
|
@echo " make clean - Remove binaries"
|
||||||
@echo " make test - Run tests"
|
@echo " make test - Run tests"
|
||||||
|
@echo " make install-man - Install manpages to system"
|
||||||
|
@echo " make check-man - Validate manpage syntax"
|
||||||
|
|
||||||
build: build-daemon build-tui
|
build: build-daemon build-tui
|
||||||
@echo "✅ Build complete!"
|
@echo "✅ Build complete!"
|
||||||
@@ -31,3 +33,16 @@ test-cover:
|
|||||||
|
|
||||||
lint:
|
lint:
|
||||||
golangci-lint run --fix
|
golangci-lint run --fix
|
||||||
|
|
||||||
|
check-man:
|
||||||
|
@echo "Checking manpage syntax..."
|
||||||
|
@man -l docs/man/banforge.1 > /dev/null && echo "✅ banforge.1 OK"
|
||||||
|
@man -l docs/man/banforge.5 > /dev/null && echo "✅ banforge.5 OK"
|
||||||
|
|
||||||
|
install-man:
|
||||||
|
@echo "Installing manpages..."
|
||||||
|
install -d $(DESTDIR)/usr/share/man/man1
|
||||||
|
install -d $(DESTDIR)/usr/share/man/man5
|
||||||
|
install -m 644 docs/man/banforge.1 $(DESTDIR)/usr/share/man/man1/banforge.1
|
||||||
|
install -m 644 docs/man/banforge.5 $(DESTDIR)/usr/share/man/man5/banforge.5
|
||||||
|
@echo "✅ Manpages installed!"
|
||||||
|
|||||||
103
README.md
103
README.md
@@ -15,14 +15,17 @@ 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
|
||||||
|
- [x] Prometheus metrics
|
||||||
|
- [x] Actions (email, webhook, script)
|
||||||
|
- [ ] Add support for most popular web-service
|
||||||
|
- [ ] User regexp for custom services
|
||||||
- [ ] TUI interface
|
- [ ] TUI interface
|
||||||
|
|
||||||
# Requirements
|
# Requirements
|
||||||
@@ -31,15 +34,79 @@ 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.
|
||||||
Or clone the repo and use the Makefile.
|
In release page you can find rpm, deb, apk packages, for amd or arm architecture.
|
||||||
```
|
|
||||||
git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git
|
## Installation guide for packages
|
||||||
cd BanForge
|
|
||||||
sudo make build-daemon
|
### Debian/Ubuntu(.deb)
|
||||||
cd bin
|
```bash
|
||||||
|
# Download the latest DEB package
|
||||||
|
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.deb
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo dpkg -i banforge_0.6.0_linux_amd64.deb
|
||||||
|
|
||||||
|
# Verify installation
|
||||||
|
sudo systemctl status banforge
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### RHEL-based(.rpm)
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Download
|
||||||
|
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.rpm
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo rpm -i banforge_0.6.0_linux_amd64.rpm
|
||||||
|
|
||||||
|
# Or with dnf (CentOS 8+, AlmaLinux)
|
||||||
|
sudo dnf install banforge_0.6.0_linux_amd64.rpm
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sudo systemctl status banforge
|
||||||
|
```
|
||||||
|
|
||||||
|
### Alpine(.apk)
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Download
|
||||||
|
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.apk
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo apk add --allow-untrusted banforge_0.6.0_linux_amd64.apk
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sudo rc-service banforge status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arch Linux(.pkg.tar.zst)
|
||||||
|
```bash
|
||||||
|
|
||||||
|
# Download
|
||||||
|
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.pkg.tar.zst
|
||||||
|
|
||||||
|
# Install
|
||||||
|
sudo pacman -U banforge_0.6.0_linux_amd64.pkg.tar.zst
|
||||||
|
|
||||||
|
# Verify
|
||||||
|
sudo systemctl status banforge
|
||||||
|
```
|
||||||
|
This is examples for other versions with different architecture or new versions check release page on [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases).
|
||||||
|
|
||||||
|
## Installation guide for source code
|
||||||
|
```bash
|
||||||
|
# Download
|
||||||
|
git clone https://github.com/d3m0k1d/BanForge.git
|
||||||
|
cd BanForge
|
||||||
|
make build-daemon
|
||||||
|
cd bin
|
||||||
|
mv banforge /usr/bin/banforge
|
||||||
|
cd ..
|
||||||
|
# Add init script and uses banforge init
|
||||||
|
cd build
|
||||||
|
./postinstall.sh
|
||||||
|
```
|
||||||
# Usage
|
# Usage
|
||||||
For first steps use this commands
|
For first steps use this commands
|
||||||
```bash
|
```bash
|
||||||
@@ -48,8 +115,18 @@ banforge daemon # Start BanForge daemon (use systemd or another init system to c
|
|||||||
```
|
```
|
||||||
You can edit the config file with examples in
|
You can edit the config file with examples in
|
||||||
- `/etc/banforge/config.toml` main config file
|
- `/etc/banforge/config.toml` main config file
|
||||||
- `/etc/banforge/rules.toml` ban rules
|
- `/etc/banforge/rules.d/*.toml` individual rule files with actions support
|
||||||
|
|
||||||
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
|
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
BanForge supports actions that are executed after a successful IP ban:
|
||||||
|
- **Email** - Send email notifications via SMTP
|
||||||
|
- **Webhook** - Send HTTP requests to external services (Slack, Telegram, etc.)
|
||||||
|
- **Script** - Execute custom scripts
|
||||||
|
|
||||||
|
See [configuration docs](https://github.com/d3m0k1d/BanForge/blob/main/docs/config.md#actions) for detailed setup instructions.
|
||||||
|
|
||||||
# License
|
# License
|
||||||
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
|
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/d3m0k1d/BanForge/internal/config"
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
"github.com/d3m0k1d/BanForge/internal/judge"
|
"github.com/d3m0k1d/BanForge/internal/judge"
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
"github.com/d3m0k1d/BanForge/internal/parser"
|
"github.com/d3m0k1d/BanForge/internal/parser"
|
||||||
"github.com/d3m0k1d/BanForge/internal/storage"
|
"github.com/d3m0k1d/BanForge/internal/storage"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
@@ -25,13 +26,32 @@ var DaemonCmd = &cobra.Command{
|
|||||||
defer stop()
|
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)
|
||||||
|
}
|
||||||
|
reqDb_r, err := storage.NewRequestsRd()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to create request reader", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
banDb_r, err := storage.NewBanReader()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to create ban reader", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
banDb_w, err := storage.NewBanWriter()
|
||||||
|
if err != nil {
|
||||||
|
log.Error("Failed to create ban writter", "error", err)
|
||||||
os.Exit(1)
|
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)
|
||||||
}
|
}
|
||||||
@@ -41,6 +61,14 @@ var DaemonCmd = &cobra.Command{
|
|||||||
log.Error("Failed to load config", "error", err)
|
log.Error("Failed to load config", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.Metrics.Enabled {
|
||||||
|
go func() {
|
||||||
|
if err := metrics.StartMetricsServer(cfg.Metrics.Port); err != nil {
|
||||||
|
log.Error("Failed to start metrics server", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
var b blocker.BlockerEngine
|
var b blocker.BlockerEngine
|
||||||
fw := cfg.Firewall.Name
|
fw := cfg.Firewall.Name
|
||||||
b = blocker.GetBlocker(fw, cfg.Firewall.Config)
|
b = blocker.GetBlocker(fw, cfg.Firewall.Config)
|
||||||
@@ -49,11 +77,11 @@ 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, resultCh, entryCh)
|
j := judge.New(banDb_r, banDb_w, reqDb_r, b, resultCh, entryCh)
|
||||||
j.LoadRules(r)
|
j.LoadRules(r)
|
||||||
go j.UnbanChecker()
|
go j.UnbanChecker()
|
||||||
go j.Tribunal()
|
go j.Tribunal()
|
||||||
go storage.Write(db, resultCh)
|
go storage.WriteReq(reqDb_w, resultCh)
|
||||||
var scanners []*parser.Scanner
|
var scanners []*parser.Scanner
|
||||||
|
|
||||||
for _, svc := range cfg.Service {
|
for _, svc := range cfg.Service {
|
||||||
@@ -98,6 +126,11 @@ var DaemonCmd = &cobra.Command{
|
|||||||
ssh := parser.NewSshdParser()
|
ssh := parser.NewSshdParser()
|
||||||
ssh.Parse(p.Events(), entryCh)
|
ssh.Parse(p.Events(), entryCh)
|
||||||
}
|
}
|
||||||
|
if svc.Name == "apache" {
|
||||||
|
log.Info("Starting apache parser", "service", serviceName)
|
||||||
|
ap := parser.NewApacheParser()
|
||||||
|
ap.Parse(p.Events(), entryCh)
|
||||||
|
}
|
||||||
}(pars, svc.Name)
|
}(pars, svc.Name)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -117,14 +150,18 @@ var DaemonCmd = &cobra.Command{
|
|||||||
if svc.Name == "nginx" {
|
if svc.Name == "nginx" {
|
||||||
log.Info("Starting nginx parser", "service", serviceName)
|
log.Info("Starting nginx parser", "service", serviceName)
|
||||||
ng := parser.NewNginxParser()
|
ng := parser.NewNginxParser()
|
||||||
ng.Parse(p.Events(), resultCh)
|
ng.Parse(p.Events(), entryCh)
|
||||||
|
|
||||||
}
|
}
|
||||||
if svc.Name == "ssh" {
|
if svc.Name == "ssh" {
|
||||||
log.Info("Starting ssh parser", "service", serviceName)
|
log.Info("Starting ssh parser", "service", serviceName)
|
||||||
ssh := parser.NewSshdParser()
|
ssh := parser.NewSshdParser()
|
||||||
ssh.Parse(p.Events(), resultCh)
|
ssh.Parse(p.Events(), entryCh)
|
||||||
|
}
|
||||||
|
if svc.Name == "apache" {
|
||||||
|
log.Info("Starting apache parser", "service", serviceName)
|
||||||
|
ap := parser.NewApacheParser()
|
||||||
|
ap.Parse(p.Events(), entryCh)
|
||||||
}
|
}
|
||||||
|
|
||||||
}(pars, svc.Name)
|
}(pars, svc.Name)
|
||||||
|
|||||||
@@ -12,47 +12,57 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ip string
|
ttl_fw string
|
||||||
|
port int
|
||||||
|
protocol 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) {
|
||||||
db, err := storage.NewDB()
|
err := func() error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("IP can't be empty")
|
||||||
|
}
|
||||||
|
if ttl_fw == "" {
|
||||||
|
ttl_fw = "1y"
|
||||||
|
}
|
||||||
|
ip := args[0]
|
||||||
|
db, err := storage.NewBanWriter()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
fw := cfg.Firewall.Name
|
fw := cfg.Firewall.Name
|
||||||
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
||||||
if ip == "" {
|
if ip == "" {
|
||||||
fmt.Println("IP can't be empty")
|
return fmt.Errorf("IP can't be empty")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
if net.ParseIP(ip) == nil {
|
if net.ParseIP(ip) == nil {
|
||||||
fmt.Println("Invalid IP")
|
return fmt.Errorf("invalid IP")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
err = b.Unban(ip)
|
err = b.Unban(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
return err
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
err = db.RemoveBan(ip)
|
err = db.RemoveBan(ip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("IP unblocked successfully!")
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println("IP unblocked successfully!")
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +70,64 @@ 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) {
|
||||||
db, err := storage.NewDB()
|
err := func() error {
|
||||||
|
if len(args) == 0 {
|
||||||
|
return fmt.Errorf("IP can't be empty")
|
||||||
|
}
|
||||||
|
if ttl_fw == "" {
|
||||||
|
ttl_fw = "1y"
|
||||||
|
}
|
||||||
|
ip := args[0]
|
||||||
|
db, err := storage.NewBanWriter()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fw := cfg.Firewall.Name
|
||||||
|
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
||||||
|
if ip == "" {
|
||||||
|
return fmt.Errorf("IP can't be empty")
|
||||||
|
}
|
||||||
|
if net.ParseIP(ip) == nil {
|
||||||
|
return fmt.Errorf("invalid IP")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = b.Ban(ip)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = db.AddBan(ip, ttl_fw, "manual ban")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println("IP blocked successfully!")
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var PortCmd = &cobra.Command{
|
||||||
|
Use: "port",
|
||||||
|
Short: "Ports commands",
|
||||||
|
}
|
||||||
|
|
||||||
|
var PortOpenCmd = &cobra.Command{
|
||||||
|
Use: "open",
|
||||||
|
Short: "Open ports on firewall",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if protocol == "" {
|
||||||
|
fmt.Println("Protocol can't be empty")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
cfg, err := config.LoadConfig()
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
@@ -72,33 +135,45 @@ var BanCmd = &cobra.Command{
|
|||||||
}
|
}
|
||||||
fw := cfg.Firewall.Name
|
fw := cfg.Firewall.Name
|
||||||
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
||||||
if ip == "" {
|
err = b.PortOpen(port, protocol)
|
||||||
fmt.Println("IP can't be empty")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if net.ParseIP(ip) == nil {
|
|
||||||
fmt.Println("Invalid IP")
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err = b.Ban(ip)
|
fmt.Println("Port opened successfully!")
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var PortCloseCmd = &cobra.Command{
|
||||||
|
Use: "close",
|
||||||
|
Short: "Close ports on firewall",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if protocol == "" {
|
||||||
|
fmt.Println("Protocol can't be empty")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
cfg, err := config.LoadConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
err = db.AddBan(ip, "1y")
|
fw := cfg.Firewall.Name
|
||||||
|
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
|
||||||
|
err = b.PortClose(port, protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println("IP blocked successfully!")
|
fmt.Println("Port closed 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")
|
PortCmd.AddCommand(PortOpenCmd)
|
||||||
|
PortCmd.AddCommand(PortCloseCmd)
|
||||||
|
PortOpenCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
|
||||||
|
PortOpenCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
|
||||||
|
PortCloseCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
|
||||||
|
PortCloseCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,53 +16,11 @@ var InitCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Println("Initializing BanForge...")
|
fmt.Println("Initializing BanForge...")
|
||||||
|
|
||||||
if _, err := os.Stat("/var/log/banforge"); err == nil {
|
|
||||||
fmt.Println("/var/log/banforge already exists, skipping...")
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
err := os.Mkdir("/var/log/banforge", 0750)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Created /var/log/banforge")
|
|
||||||
} else {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
if _, err := os.Stat("/var/lib/banforge"); err == nil {
|
|
||||||
fmt.Println("/var/lib/banforge already exists, skipping...")
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
err := os.Mkdir("/var/lib/banforge", 0750)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Created /var/lib/banforge")
|
|
||||||
} else {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat("/etc/banforge"); err == nil {
|
|
||||||
fmt.Println("/etc/banforge already exists, skipping...")
|
|
||||||
} else if os.IsNotExist(err) {
|
|
||||||
err := os.Mkdir("/etc/banforge", 0750)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
fmt.Println("Created /etc/banforge")
|
|
||||||
} else {
|
|
||||||
fmt.Println(err)
|
|
||||||
os.Exit(1)
|
|
||||||
}
|
|
||||||
|
|
||||||
err := config.CreateConf()
|
err := config.CreateConf()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
fmt.Println("Config created")
|
|
||||||
|
|
||||||
err = config.FindFirewall()
|
err = config.FindFirewall()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,23 +40,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!")
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ var BanListCmd = &cobra.Command{
|
|||||||
Short: "List banned IP adresses",
|
Short: "List banned IP adresses",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
var log = logger.New(false)
|
var log = logger.New(false)
|
||||||
d, err := storage.NewDB()
|
d, err := storage.NewBanReader()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error("Failed to create database", "error", err)
|
log.Error("Failed to create database", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package command
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/config"
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,6 +17,8 @@ var (
|
|||||||
status string
|
status string
|
||||||
method string
|
method string
|
||||||
ttl string
|
ttl string
|
||||||
|
maxRetry int
|
||||||
|
editName string
|
||||||
)
|
)
|
||||||
|
|
||||||
var RuleCmd = &cobra.Command{
|
var RuleCmd = &cobra.Command{
|
||||||
@@ -24,24 +28,25 @@ var RuleCmd = &cobra.Command{
|
|||||||
|
|
||||||
var AddCmd = &cobra.Command{
|
var AddCmd = &cobra.Command{
|
||||||
Use: "add",
|
Use: "add",
|
||||||
Short: "CLI interface for add new rule to file /etc/banforge/rules.toml",
|
Short: "Add a new rule to /etc/banforge/rules.d/",
|
||||||
|
Long: "Creates a new rule file in /etc/banforge/rules.d/<name>.toml",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
fmt.Printf("Rule name can't be empty\n")
|
fmt.Println("Rule name can't be empty (use -n flag)")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if service == "" {
|
if service == "" {
|
||||||
fmt.Printf("Service name can't be empty\n")
|
fmt.Println("Service name can't be empty (use -s flag)")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if path == "" && status == "" && method == "" {
|
if path == "" && status == "" && method == "" {
|
||||||
fmt.Printf("At least 1 rule field must be filled in.")
|
fmt.Println("At least one rule field must be filled: path, status, or method")
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if ttl == "" {
|
if ttl == "" {
|
||||||
ttl = "1y"
|
ttl = "1y"
|
||||||
}
|
}
|
||||||
err := config.NewRule(name, service, path, status, method, ttl)
|
err := config.NewRule(name, service, path, status, method, ttl, maxRetry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -50,35 +55,103 @@ var AddCmd = &cobra.Command{
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
var ListCmd = &cobra.Command{
|
var EditCmd = &cobra.Command{
|
||||||
Use: "list",
|
Use: "edit",
|
||||||
Short: "List rules",
|
Short: "Edit an existing rule",
|
||||||
|
Long: "Edit rule fields by name. Only specified fields will be updated.",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
r, err := config.LoadRuleConfig()
|
if editName == "" {
|
||||||
|
fmt.Println("Rule name is required (use -n flag)")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if service == "" && path == "" && status == "" && method == "" {
|
||||||
|
fmt.Println("At least one field must be specified to edit: -s, -p, -c, or -m")
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
err := config.EditRule(editName, service, path, status, method)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
for _, rule := range r {
|
fmt.Println("Rule updated successfully!")
|
||||||
fmt.Printf(
|
},
|
||||||
"Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n",
|
}
|
||||||
|
|
||||||
|
var RemoveCmd = &cobra.Command{
|
||||||
|
Use: "remove <name>",
|
||||||
|
Short: "Remove a rule by name",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
ruleName := args[0]
|
||||||
|
fileName := config.SanitizeRuleFilename(ruleName) + ".toml"
|
||||||
|
filePath := filepath.Join("/etc/banforge/rules.d", fileName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(filePath); os.IsNotExist(err) {
|
||||||
|
fmt.Printf("Rule '%s' not found\n", ruleName)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
fmt.Printf("Failed to remove rule: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
fmt.Printf("Rule '%s' removed successfully\n", ruleName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var ListCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List all rules",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
rules, err := config.LoadRuleConfig()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rules) == 0 {
|
||||||
|
fmt.Println("No rules found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t := table.NewWriter()
|
||||||
|
t.SetOutputMirror(os.Stdout)
|
||||||
|
t.AppendHeader(table.Row{
|
||||||
|
"Name", "Service", "Path", "Status", "Method", "MaxRetry", "BanTime",
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, rule := range rules {
|
||||||
|
t.AppendRow(table.Row{
|
||||||
rule.Name,
|
rule.Name,
|
||||||
rule.ServiceName,
|
rule.ServiceName,
|
||||||
rule.Path,
|
rule.Path,
|
||||||
rule.Status,
|
rule.Status,
|
||||||
rule.Method,
|
rule.Method,
|
||||||
)
|
rule.MaxRetry,
|
||||||
|
rule.BanTime,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
t.Render()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func RuleRegister() {
|
func RuleRegister() {
|
||||||
RuleCmd.AddCommand(AddCmd)
|
RuleCmd.AddCommand(AddCmd)
|
||||||
|
RuleCmd.AddCommand(EditCmd)
|
||||||
|
RuleCmd.AddCommand(RemoveCmd)
|
||||||
RuleCmd.AddCommand(ListCmd)
|
RuleCmd.AddCommand(ListCmd)
|
||||||
|
|
||||||
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 (required)")
|
||||||
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
|
AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path")
|
||||||
AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
|
AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code")
|
||||||
AddCmd.Flags().StringVarP(&method, "method", "m", "", "method")
|
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method")
|
||||||
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time")
|
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time (e.g., 1h, 1d, 1y)")
|
||||||
|
AddCmd.Flags().IntVarP(&maxRetry, "max_retry", "r", 0, "max retry before ban")
|
||||||
|
|
||||||
|
EditCmd.Flags().StringVarP(&editName, "name", "n", "", "rule name to edit (required)")
|
||||||
|
EditCmd.Flags().StringVarP(&service, "service", "s", "", "new service name")
|
||||||
|
EditCmd.Flags().StringVarP(&path, "path", "p", "", "new path")
|
||||||
|
EditCmd.Flags().StringVarP(&status, "status", "c", "", "new status code")
|
||||||
|
EditCmd.Flags().StringVarP(&method, "method", "m", "", "new HTTP method")
|
||||||
}
|
}
|
||||||
|
|||||||
17
cmd/banforge/command/version.go
Normal file
17
cmd/banforge/command/version.go
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
package command
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var version = "0.5.2"
|
||||||
|
|
||||||
|
var VersionCmd = &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "BanForge version",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println("BanForge version:", version)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ var rootCmd = &cobra.Command{
|
|||||||
Use: "banforge",
|
Use: "banforge",
|
||||||
Short: "IPS log-based written on Golang",
|
Short: "IPS log-based written on Golang",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +27,8 @@ func Execute() {
|
|||||||
rootCmd.AddCommand(command.BanCmd)
|
rootCmd.AddCommand(command.BanCmd)
|
||||||
rootCmd.AddCommand(command.UnbanCmd)
|
rootCmd.AddCommand(command.UnbanCmd)
|
||||||
rootCmd.AddCommand(command.BanListCmd)
|
rootCmd.AddCommand(command.BanListCmd)
|
||||||
|
rootCmd.AddCommand(command.VersionCmd)
|
||||||
|
rootCmd.AddCommand(command.PortCmd)
|
||||||
command.RuleRegister()
|
command.RuleRegister()
|
||||||
command.FwRegister()
|
command.FwRegister()
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
|||||||
197
docs/cli.md
197
docs/cli.md
@@ -1,8 +1,11 @@
|
|||||||
# CLI commands BanForge
|
# CLI commands BanForge
|
||||||
|
|
||||||
BanForge provides a command-line interface (CLI) to manage IP blocking,
|
BanForge provides a command-line interface (CLI) to manage IP blocking,
|
||||||
configure detection rules, and control the daemon process.
|
configure detection rules, and control the daemon process.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
### init - create a deps file
|
|
||||||
|
### init - Create configuration files
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
banforge init
|
banforge init
|
||||||
@@ -10,7 +13,24 @@ banforge init
|
|||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
This command creates the necessary directories and base configuration files
|
This command creates the necessary directories and base configuration files
|
||||||
required for the daemon to operate.
|
required for the daemon to operate:
|
||||||
|
- `/etc/banforge/config.toml` — main configuration
|
||||||
|
- `/etc/banforge/rules.toml` — default rules file
|
||||||
|
- `/etc/banforge/rules.d/` — directory for individual rule files
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### version - Display BanForge version
|
||||||
|
|
||||||
|
```shell
|
||||||
|
banforge version
|
||||||
|
```
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
This command displays the current version of the BanForge software.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### daemon - Starts the BanForge daemon process
|
### daemon - Starts the BanForge daemon process
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
@@ -22,7 +42,10 @@ This command starts the BanForge daemon process in the background.
|
|||||||
The daemon continuously monitors incoming requests, detects anomalies,
|
The daemon continuously monitors incoming requests, detects anomalies,
|
||||||
and applies firewall rules in real-time.
|
and applies firewall rules in real-time.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### firewall - Manages firewall rules
|
### firewall - Manages firewall rules
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
banforge ban <ip>
|
banforge ban <ip>
|
||||||
banforge unban <ip>
|
banforge unban <ip>
|
||||||
@@ -31,31 +54,173 @@ 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.
|
||||||
|
|
||||||
### list - Lists the IP addresses that are currently blocked
|
| Flag | Description |
|
||||||
|
| ----------- | ------------------------------ |
|
||||||
|
| `-t`, `-ttl` | Ban duration (default: 1 year) |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Ban IP for 1 hour
|
||||||
|
banforge ban 192.168.1.100 -t 1h
|
||||||
|
|
||||||
|
# Unban IP
|
||||||
|
banforge unban 192.168.1.100
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### ports - Open and close ports on firewall
|
||||||
|
|
||||||
|
```shell
|
||||||
|
banforge open -port <port> -protocol <protocol>
|
||||||
|
banforge close -port <port> -protocol <protocol>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
|
||||||
|
|
||||||
|
| Flag | Required | Description |
|
||||||
|
| ------------- | -------- | ------------------------ |
|
||||||
|
| `-port` | + | Port number (e.g., 80) |
|
||||||
|
| `-protocol` | + | Protocol (tcp/udp) |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Open port 80 for TCP
|
||||||
|
banforge open -port 80 -protocol tcp
|
||||||
|
|
||||||
|
# Close port 443
|
||||||
|
banforge close -port 443 -protocol tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### list - List blocked IP addresses
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
banforge list
|
banforge list
|
||||||
```
|
```
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
This command output table of IP addresses that are currently blocked
|
This command outputs a table of IP addresses that are currently blocked.
|
||||||
|
|
||||||
### rule - Manages detection rules
|
---
|
||||||
|
|
||||||
|
### rule - Manage detection rules
|
||||||
|
|
||||||
|
Rules are stored in `/etc/banforge/rules.d/` as individual `.toml` files.
|
||||||
|
|
||||||
|
#### Add a new rule
|
||||||
|
|
||||||
|
```shell
|
||||||
|
banforge rule add -n <name> -s <service> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flags:**
|
||||||
|
|
||||||
|
| Flag | Required | Description |
|
||||||
|
| ------------------- | -------- | ---------------------------------------- |
|
||||||
|
| `-n`, `--name` | + | Rule name (used as filename) |
|
||||||
|
| `-s`, `--service` | + | Service name (nginx, apache, ssh, etc.) |
|
||||||
|
| `-p`, `--path` | - | Request path to match |
|
||||||
|
| `-m`, `--method` | - | HTTP method (GET, POST, etc.) |
|
||||||
|
| `-c`, `--status` | - | HTTP status code (403, 404, etc.) |
|
||||||
|
| `-t`, `--ttl` | - | Ban duration (default: 1y) |
|
||||||
|
| `-r`, `--max_retry` | - | Max retries before ban (default: 0) |
|
||||||
|
|
||||||
|
**Note:** At least one of `-p`, `-m`, or `-c` must be specified.
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Ban on 403 status
|
||||||
|
banforge rule add -n "Forbidden" -s nginx -c 403 -t 30m
|
||||||
|
|
||||||
|
# Ban on path pattern
|
||||||
|
banforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3
|
||||||
|
|
||||||
|
# SSH brute force protection
|
||||||
|
banforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### List all rules
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
banforge rule add -n rule.name -c 403
|
|
||||||
banforge rule list
|
banforge rule list
|
||||||
```
|
```
|
||||||
|
|
||||||
**Description**
|
**Description**
|
||||||
These command help you to create and manage detection rules in CLI interface.
|
Displays all configured rules in a table format.
|
||||||
|
|
||||||
| Flag | Required |
|
**Example output:**
|
||||||
| ----------- | -------- |
|
```
|
||||||
| -n -name | + |
|
+------------------+---------+--------+--------+--------+----------+---------+
|
||||||
| -s -service | + |
|
| NAME | SERVICE | PATH | STATUS | METHOD | MAXRETRY | BANTIME |
|
||||||
| -p -path | - |
|
+------------------+---------+--------+--------+--------+----------+---------+
|
||||||
| -m -method | - |
|
| SSH Bruteforce | ssh | | Failed | | 5 | 1h |
|
||||||
| -c -status | - |
|
| Nginx 404 | nginx | | 404 | | 3 | 30m |
|
||||||
| -t -ttl | -(if not used default ban 1 year) |
|
| Admin Panel | nginx | /admin | | | 2 | 2h |
|
||||||
|
+------------------+---------+--------+--------+--------+----------+---------+
|
||||||
|
```
|
||||||
|
|
||||||
You must specify at least 1 of the optional flags to create a rule.
|
---
|
||||||
|
|
||||||
|
#### Edit an existing rule
|
||||||
|
|
||||||
|
```shell
|
||||||
|
banforge rule edit -n <name> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
Edit fields of an existing rule. Only specified fields will be updated.
|
||||||
|
|
||||||
|
| Flag | Required | Description |
|
||||||
|
| ------------------- | -------- | ------------------------------- |
|
||||||
|
| `-n`, `--name` | + | Rule name to edit |
|
||||||
|
| `-s`, `--service` | - | New service name |
|
||||||
|
| `-p`, `--path` | - | New path |
|
||||||
|
| `-m`, `--method` | - | New method |
|
||||||
|
| `-c`, `--status` | - | New status code |
|
||||||
|
|
||||||
|
**Examples:**
|
||||||
|
```bash
|
||||||
|
# Update ban time for existing rule
|
||||||
|
banforge rule edit -n "SSH Bruteforce" -t 2h
|
||||||
|
|
||||||
|
# Change status code
|
||||||
|
banforge rule edit -n "Forbidden" -c 403
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Remove a rule
|
||||||
|
|
||||||
|
```shell
|
||||||
|
banforge rule remove <name>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Description**
|
||||||
|
Permanently delete a rule by name.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
banforge rule remove "Old Rule"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ban time format
|
||||||
|
|
||||||
|
Use the following suffixes for ban duration:
|
||||||
|
|
||||||
|
| Suffix | Duration |
|
||||||
|
| ------ | -------- |
|
||||||
|
| `s` | Seconds |
|
||||||
|
| `m` | Minutes |
|
||||||
|
| `h` | Hours |
|
||||||
|
| `d` | Days |
|
||||||
|
| `M` | Months (30 days) |
|
||||||
|
| `y` | Years (365 days) |
|
||||||
|
|
||||||
|
**Examples:** `30s`, `5m`, `2h`, `1d`, `1M`, `1y`
|
||||||
|
|||||||
124
docs/config.md
124
docs/config.md
@@ -40,10 +40,132 @@ Example:
|
|||||||
service = "nginx"
|
service = "nginx"
|
||||||
path = ""
|
path = ""
|
||||||
status = "304"
|
status = "304"
|
||||||
|
max_retry = 3
|
||||||
method = ""
|
method = ""
|
||||||
ban_time = "1m"
|
ban_time = "1m"
|
||||||
|
|
||||||
|
# Actions are executed after successful ban
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
email_subject = "BanForge Alert: IP Banned"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "user@example.com"
|
||||||
|
smtp_password = "password"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} has been banned for rule {rule}"
|
||||||
|
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.example.com/alert"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
|
||||||
|
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\", \"service\": \"{service}\"}"
|
||||||
|
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
script = "/usr/local/bin/notify.sh"
|
||||||
|
interpretator = "bash"
|
||||||
```
|
```
|
||||||
**Description**
|
**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.
|
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".
|
ban_time require in format "1m", "1h", "1d", "1M", "1y".
|
||||||
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*")
|
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*").
|
||||||
|
If max_retry = 0 ban on first request.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are executed after a successful IP ban. You can configure multiple actions per rule.
|
||||||
|
|
||||||
|
### Action Types
|
||||||
|
|
||||||
|
#### 1. Email Notification
|
||||||
|
|
||||||
|
Send email alerts when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
email_subject = "BanForge Alert"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "user@example.com"
|
||||||
|
smtp_password = "password"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} has been banned"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "email" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `email` | + | Recipient email address |
|
||||||
|
| `email_sender` | + | Sender email address |
|
||||||
|
| `email_subject` | - | Email subject (default: "BanForge Alert") |
|
||||||
|
| `smtp_host` | + | SMTP server host |
|
||||||
|
| `smtp_port` | + | SMTP server port |
|
||||||
|
| `smtp_user` | + | SMTP username |
|
||||||
|
| `smtp_password` | + | SMTP password |
|
||||||
|
| `smtp_tls` | - | Use TLS connection (default: false) |
|
||||||
|
| `body` | - | Email body text |
|
||||||
|
|
||||||
|
#### 2. Webhook Notification
|
||||||
|
|
||||||
|
Send HTTP webhook requests when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.example.com/alert"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
|
||||||
|
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "webhook" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `url` | + | Webhook URL |
|
||||||
|
| `method` | - | HTTP method (default: "POST") |
|
||||||
|
| `headers` | - | HTTP headers as key-value pairs |
|
||||||
|
| `body` | - | Request body (supports variables) |
|
||||||
|
|
||||||
|
#### 3. Script Execution
|
||||||
|
|
||||||
|
Execute a custom script when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
script = "/usr/local/bin/notify.sh"
|
||||||
|
interpretator = "bash"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "script" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `script` | + | Path to script file |
|
||||||
|
| `interpretator` | - | Script interpretator (e.g., "bash", "python"). If empty, script runs directly |
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
The following variables can be used in `body` fields (email, webhook):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `{ip}` | Banned IP address |
|
||||||
|
| `{rule}` | Rule name that triggered the ban |
|
||||||
|
| `{service}` | Service name |
|
||||||
|
| `{ban_time}` | Ban duration |
|
||||||
|
|||||||
251
docs/man/banforge.1
Normal file
251
docs/man/banforge.1
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
.TH BANFORGE 1 "24 February 2026" "BanForge 1.0"
|
||||||
|
.
|
||||||
|
.SH NAME
|
||||||
|
banforge \- BanForge IPS utility for Linux
|
||||||
|
.
|
||||||
|
.SH SYNOPSIS
|
||||||
|
.B banforge
|
||||||
|
[\fIOPTIONS\fR] \fICOMMAND\fR [\fIARGUMENTS\fR]
|
||||||
|
.
|
||||||
|
.SH DESCRIPTION
|
||||||
|
BanForge is an Intrusion Prevention System (IPS) utility for Linux.
|
||||||
|
It monitors service logs, detects anomalies and malicious activity,
|
||||||
|
and automatically applies firewall rules to block suspicious IP addresses.
|
||||||
|
.
|
||||||
|
.PP
|
||||||
|
The program consists of two components:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge\fR \- CLI utility for management
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge daemon\fR \- background service for real-time monitoring
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH COMMANDS
|
||||||
|
.
|
||||||
|
.SS init \- Create configuration files
|
||||||
|
.PP
|
||||||
|
\fBbanforge init\fR
|
||||||
|
.PP
|
||||||
|
Creates the necessary directories and base configuration files:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fI/etc/banforge/config.toml\fR \- main configuration
|
||||||
|
.IP \(bu 2
|
||||||
|
\fI/etc/banforge/rules.toml\fR \- default rules file
|
||||||
|
.IP \(bu 2
|
||||||
|
\fI/etc/banforge/rules.d/\fR \- directory for individual rule files
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS version \- Display BanForge version
|
||||||
|
.PP
|
||||||
|
\fBbanforge version\fR
|
||||||
|
.PP
|
||||||
|
Displays the current version of the BanForge software.
|
||||||
|
.
|
||||||
|
.SS daemon \- Start the BanForge daemon
|
||||||
|
.PP
|
||||||
|
\fBbanforge daemon\fR
|
||||||
|
.PP
|
||||||
|
Starts the BanForge daemon process in the background.
|
||||||
|
The daemon continuously monitors incoming requests, detects anomalies,
|
||||||
|
and applies firewall rules in real-time.
|
||||||
|
.
|
||||||
|
.SS firewall \- Manage firewall rules
|
||||||
|
.PP
|
||||||
|
\fBbanforge ban\fR \fI<ip>\fR [\fIOPTIONS\fR]
|
||||||
|
.br
|
||||||
|
\fBbanforge unban\fR \fI<ip>\fR
|
||||||
|
.PP
|
||||||
|
These commands provide an abstraction over your firewall.
|
||||||
|
.PP
|
||||||
|
\fBoptions:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1 year)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge ban 192.168.1.100 -t 1h\fR \- Ban IP for 1 hour
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge unban 192.168.1.100\fR \- Unban IP
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS ports \- Manage firewall ports
|
||||||
|
.PP
|
||||||
|
\fBbanforge open\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
|
||||||
|
.br
|
||||||
|
\fBbanforge close\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
|
||||||
|
.PP
|
||||||
|
Open or close ports on the firewall.
|
||||||
|
.PP
|
||||||
|
\fBflags:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-port\fR \- Port number (e.g., 80) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-protocol\fR \- Protocol (tcp/udp) \fI(required)\fR
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge open -port 80 -protocol tcp\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge close -port 443 -protocol tcp\fR
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS list \- List blocked IP addresses
|
||||||
|
.PP
|
||||||
|
\fBbanforge list\fR
|
||||||
|
.PP
|
||||||
|
Outputs a table of IP addresses that are currently blocked.
|
||||||
|
.
|
||||||
|
.SS rule \- Manage detection rules
|
||||||
|
.PP
|
||||||
|
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
|
||||||
|
.
|
||||||
|
.SS "rule add \- Add a new rule"
|
||||||
|
.PP
|
||||||
|
\fBbanforge rule add\fR \fB-n\fR \fI<name>\fR \fB-s\fR \fI<service>\fR [\fIOPTIONS\fR]
|
||||||
|
.PP
|
||||||
|
\fBflags:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-n\fR, \fB--name\fR \- Rule name (used as filename) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-s\fR, \fB--service\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-p\fR, \fB--path\fR \- Request path to match
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-m\fR, \fB--method\fR \- HTTP method (GET, POST, etc.)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-c\fR, \fB--status\fR \- HTTP status code (403, 404, etc.)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1y)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-r\fR, \fB--max_retry\fR \- Max retries before ban (default: 0)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBNote:\fR At least one of \fB-p\fR, \fB-m\fR, or \fB-c\fR must be specified.
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge rule add -n "Forbidden" -s nginx -c 403 -t 30m\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5\fR
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "rule list \- List all rules"
|
||||||
|
.PP
|
||||||
|
\fBbanforge rule list\fR
|
||||||
|
.PP
|
||||||
|
Displays all configured rules in a table format.
|
||||||
|
.
|
||||||
|
.SS "rule edit \- Edit an existing rule"
|
||||||
|
.PP
|
||||||
|
\fBbanforge rule edit\fR \fB-n\fR \fI<name>\fR [\fIOPTIONS\fR]
|
||||||
|
.PP
|
||||||
|
Edit fields of an existing rule. Only specified fields will be updated.
|
||||||
|
.PP
|
||||||
|
\fBflags:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-n\fR, \fB--name\fR \- Rule name to edit \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-s\fR, \fB--service\fR \- New service name
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-p\fR, \fB--path\fR \- New path
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-m\fR, \fB--method\fR \- New method
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB-c\fR, \fB--status\fR \- New status code
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge rule edit -n "SSH Bruteforce" -t 2h\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbanforge rule edit -n "Forbidden" -c 403\fR
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "rule remove \- Remove a rule"
|
||||||
|
.PP
|
||||||
|
\fBbanforge rule remove\fR \fI<name>\fR
|
||||||
|
.PP
|
||||||
|
Permanently delete a rule by name.
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR \fBbanforge rule remove "Old Rule"\fR
|
||||||
|
.
|
||||||
|
.SH "BAN TIME FORMAT"
|
||||||
|
.PP
|
||||||
|
Use the following suffixes for ban duration:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBs\fR \- Seconds
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBm\fR \- Minutes
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBh\fR \- Hours
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBd\fR \- Days
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBM\fR \- Months (30 days)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBy\fR \- Years (365 days)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR 30s, 5m, 2h, 1d, 1M, 1y
|
||||||
|
.
|
||||||
|
.SH "CONFIGURATION FILES"
|
||||||
|
.PP
|
||||||
|
Configuration files are stored in \fI/etc/banforge/\fR:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIconfig.toml\fR \- main daemon configuration
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIrules.toml\fR \- default rules
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIrules.d/*.toml\fR \- individual rule files
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
See \fBbanforge(5)\fR for configuration file format details including actions setup.
|
||||||
|
.
|
||||||
|
.SH "EXIT STATUS"
|
||||||
|
.PP
|
||||||
|
\fB0\fR \- Success
|
||||||
|
.br
|
||||||
|
\fB1\fR \- General error
|
||||||
|
.br
|
||||||
|
\fB2\fR \- Configuration error
|
||||||
|
.
|
||||||
|
.SH EXAMPLES
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
Initialize configuration: \fBbanforge init\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
Start daemon: \fBbanforge daemon\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
Ban an IP: \fBbanforge ban 192.168.1.100 -t 1h\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
Add a rule: \fBbanforge rule add -n "404" -s nginx -c 404 -t 30m\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
List blocked IPs: \fBbanforge list\fR
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "SEE ALSO"
|
||||||
|
.BR iptables (8),
|
||||||
|
.BR nftables (8),
|
||||||
|
.BR fail2ban (1),
|
||||||
|
.BR nginx (8)
|
||||||
|
.
|
||||||
|
.SH AUTHOR
|
||||||
|
.PP
|
||||||
|
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru
|
||||||
405
docs/man/banforge.5
Normal file
405
docs/man/banforge.5
Normal file
@@ -0,0 +1,405 @@
|
|||||||
|
.TH BANFORGE 5 "24 February 2026" "BanForge 1.0"
|
||||||
|
.
|
||||||
|
.SH NAME
|
||||||
|
banforge \- BanForge configuration file format
|
||||||
|
.
|
||||||
|
.SH DESCRIPTION
|
||||||
|
BanForge uses TOML configuration files stored in \fI/etc/banforge/\fR:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIconfig.toml\fR \- main daemon configuration
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIrules.toml\fR \- default rules (legacy)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fIrules.d/*.toml\fR \- individual rule files (recommended)
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "CONFIG.TOML FORMAT"
|
||||||
|
.PP
|
||||||
|
Main configuration file for BanForge daemon.
|
||||||
|
.PP
|
||||||
|
\fBStructure:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB[firewall]\fR \- firewall parameters
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB[[service]]\fR \- service monitoring configuration (multiple allowed)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB[metrics]\fR \- Prometheus metrics configuration (optional)
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Firewall Section"
|
||||||
|
.PP
|
||||||
|
\fB[firewall]\fR
|
||||||
|
.PP
|
||||||
|
Defines firewall parameters. The \fBbanforge init\fR command automatically
|
||||||
|
detects your installed firewall (nftables, iptables, ufw, firewalld).
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBname\fR \- Firewall type (nftables, iptables, ufw, firewalld)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBconfig\fR \- Path to firewall configuration file
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[firewall]
|
||||||
|
name = "nftables"
|
||||||
|
config = "/etc/nftables.conf"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Service Section"
|
||||||
|
.PP
|
||||||
|
\fB[[service]]\fR
|
||||||
|
.PP
|
||||||
|
Configures service monitoring. Multiple service blocks are allowed.
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBname\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBlogging\fR \- Log source type: "file" or "journald" \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBlog_path\fR \- Path to log file or journal unit name \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBenabled\fR \- Enable/disable service monitoring (true/false)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
# File-based logging
|
||||||
|
[[service]]
|
||||||
|
name = "nginx"
|
||||||
|
logging = "file"
|
||||||
|
log_path = "/var/log/nginx/access.log"
|
||||||
|
enabled = true
|
||||||
|
|
||||||
|
# Journald logging
|
||||||
|
[[service]]
|
||||||
|
name = "nginx"
|
||||||
|
logging = "journald"
|
||||||
|
log_path = "nginx"
|
||||||
|
enabled = false
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBNote:\fR When using journald logging, specify the service name in \fBlog_path\fR.
|
||||||
|
.
|
||||||
|
.SS "Metrics Section"
|
||||||
|
.PP
|
||||||
|
\fB[metrics]\fR
|
||||||
|
.PP
|
||||||
|
Configures Prometheus metrics endpoint (optional).
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBenabled\fR \- Enable/disable metrics (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBport\fR \- Port for metrics endpoint
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[metrics]
|
||||||
|
enabled = true
|
||||||
|
port = 9090
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "RULES.TOML FORMAT"
|
||||||
|
.PP
|
||||||
|
Detection rules define conditions for blocking IP addresses.
|
||||||
|
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
|
||||||
|
.
|
||||||
|
.SS "Rule Section"
|
||||||
|
.PP
|
||||||
|
\fB[[rule]]\fR
|
||||||
|
.PP
|
||||||
|
Defines a single detection rule.
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBname\fR \- Rule name (used as filename) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBservice\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBpath\fR \- Request path to match (e.g., "/admin/*", "*.php")
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBstatus\fR \- HTTP status code (403, 404, 304, etc.)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBmethod\fR \- HTTP method (GET, POST, etc.)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBmax_retry\fR \- Max retries before ban (0 = ban on first request)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBban_time\fR \- Ban duration (e.g., "1m", "1h", "1d", "1M", "1y")
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBNote:\fR At least one of \fBpath\fR, \fBstatus\fR, or \fBmethod\fR must be specified.
|
||||||
|
.PP
|
||||||
|
\fBExamples:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
# Ban on HTTP 304 status
|
||||||
|
[[rule]]
|
||||||
|
name = "304 http"
|
||||||
|
service = "nginx"
|
||||||
|
path = ""
|
||||||
|
status = "304"
|
||||||
|
max_retry = 3
|
||||||
|
method = ""
|
||||||
|
ban_time = "1m"
|
||||||
|
|
||||||
|
# Ban on path pattern (admin panel)
|
||||||
|
[[rule]]
|
||||||
|
name = "Admin Access"
|
||||||
|
service = "nginx"
|
||||||
|
path = "/admin/*"
|
||||||
|
status = ""
|
||||||
|
method = ""
|
||||||
|
max_retry = 2
|
||||||
|
ban_time = "2h"
|
||||||
|
|
||||||
|
# SSH brute force protection
|
||||||
|
[[rule]]
|
||||||
|
name = "SSH Bruteforce"
|
||||||
|
service = "ssh"
|
||||||
|
path = ""
|
||||||
|
status = "Failed"
|
||||||
|
method = ""
|
||||||
|
max_retry = 5
|
||||||
|
ban_time = "1h"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "BAN TIME FORMAT"
|
||||||
|
.PP
|
||||||
|
Use the following suffixes for ban duration:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBs\fR \- Seconds
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBm\fR \- Minutes
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBh\fR \- Hours
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBd\fR \- Days
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBM\fR \- Months (30 days)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBy\fR \- Years (365 days)
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "ACTIONS"
|
||||||
|
.PP
|
||||||
|
Rules can trigger custom actions when an IP is banned.
|
||||||
|
Multiple actions can be configured per rule.
|
||||||
|
Actions are executed after successful ban at firewall level.
|
||||||
|
.
|
||||||
|
.SS "Supported Action Types"
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBemail\fR \- Send email notification via SMTP
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBwebhook\fR \- Send HTTP request to external service
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBscript\fR \- Execute custom script
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Variables"
|
||||||
|
.PP
|
||||||
|
The following variables can be used in \fBbody\fR fields:
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB{ip}\fR \- Banned IP address
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB{rule}\fR \- Rule name that triggered the ban
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB{service}\fR \- Service name
|
||||||
|
.IP \(bu 2
|
||||||
|
\fB{ban_time}\fR \- Ban duration
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Script Action"
|
||||||
|
.PP
|
||||||
|
Execute a custom script when an IP is banned.
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBtype\fR \- "script"
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBinterpretator\fR \- Script interpretator (e.g., "bash", "python")
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBscript\fR \- Path to script file
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[[rule]]
|
||||||
|
name = "Notify on Ban"
|
||||||
|
service = "nginx"
|
||||||
|
status = "403"
|
||||||
|
ban_time = "1h"
|
||||||
|
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
interpretator = "bash"
|
||||||
|
script = "/opt/banforge/scripts/notify.sh"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Webhook Action"
|
||||||
|
.PP
|
||||||
|
Send HTTP webhook when an IP is banned.
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBtype\fR \- "webhook"
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBurl\fR \- Webhook URL \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBmethod\fR \- HTTP method (POST, GET, etc.)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBheaders\fR \- Custom headers (key-value pairs)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbody\fR \- Request body (supports variables)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.example.com/ban"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer TOKEN" }
|
||||||
|
body = "{\\\"ip\\\": \\\"{ip}\\\", \\\"rule\\\": \\\"{rule}\\\"}"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SS "Email Action"
|
||||||
|
.PP
|
||||||
|
Send email notification when an IP is banned.
|
||||||
|
.PP
|
||||||
|
\fBFields:\fR
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBtype\fR \- "email"
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBemail\fR \- Recipient email address \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBemail_sender\fR \- Sender email address \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBemail_subject\fR \- Email subject (default: "BanForge Alert")
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBsmtp_host\fR \- SMTP server host \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBsmtp_port\fR \- SMTP server port \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBsmtp_user\fR \- SMTP username \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBsmtp_password\fR \- SMTP password \fI(required)\fR
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBsmtp_tls\fR \- Enable TLS (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbody\fR \- Email body text (supports variables)
|
||||||
|
.RE
|
||||||
|
.PP
|
||||||
|
\fBExample:\fR
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
email_subject = "IP Banned"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "banforge"
|
||||||
|
smtp_password = "secret"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} has been banned for rule {rule}"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "COMPLETE RULE EXAMPLE WITH ACTIONS"
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.nf
|
||||||
|
[[rule]]
|
||||||
|
name = "nginx-403"
|
||||||
|
service = "nginx"
|
||||||
|
status = "403"
|
||||||
|
max_retry = 3
|
||||||
|
ban_time = "1h"
|
||||||
|
|
||||||
|
# Email notification
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "banforge"
|
||||||
|
smtp_password = "secret"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} banned by rule {rule}"
|
||||||
|
|
||||||
|
# Slack webhook
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.slack.com/services/XXX/YYY/ZZZ"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json" }
|
||||||
|
body = "{\\\"text\\\": \\\"IP {ip} banned for rule {rule}\\\"}"
|
||||||
|
|
||||||
|
# Custom script
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
script = "/usr/local/bin/ban-notify.sh"
|
||||||
|
interpretator = "bash"
|
||||||
|
.fi
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH FILES
|
||||||
|
.PP
|
||||||
|
.RS
|
||||||
|
.IP \(bu 2
|
||||||
|
\fI/etc/banforge/config.toml\fR \- main configuration
|
||||||
|
.IP \(bu 2
|
||||||
|
\fI/etc/banforge/rules.d/\fR \- rule files directory
|
||||||
|
.RE
|
||||||
|
.
|
||||||
|
.SH "SEE ALSO"
|
||||||
|
.BR banforge (1),
|
||||||
|
.BR iptables (8),
|
||||||
|
.BR nftables (8),
|
||||||
|
.BR systemd (1)
|
||||||
|
.
|
||||||
|
.SH AUTHOR
|
||||||
|
.PP
|
||||||
|
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru
|
||||||
13
go.mod
13
go.mod
@@ -7,23 +7,22 @@ require (
|
|||||||
github.com/jedib0t/go-pretty/v6 v6.7.8
|
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
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
modernc.org/sqlite v1.44.3
|
modernc.org/sqlite v1.46.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // 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-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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.42.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/text v0.34.0 // indirect
|
||||||
golang.org/x/text v0.32.0 // indirect
|
modernc.org/libc v1.70.0 // indirect
|
||||||
modernc.org/libc v1.67.6 // indirect
|
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.11.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
47
go.sum
47
go.sum
@@ -1,5 +1,7 @@
|
|||||||
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/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||||
|
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -17,17 +19,14 @@ github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc
|
|||||||
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
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.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
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=
|
||||||
@@ -37,19 +36,17 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
|||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
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/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/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 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
@@ -57,18 +54,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
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.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
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.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||||
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
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 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
@@ -77,8 +74,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
|
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||||
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
|||||||
129
internal/actions/email.go
Normal file
129
internal/actions/email.go
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SendEmail(action config.Action) error {
|
||||||
|
if !action.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.SMTPHost == "" {
|
||||||
|
return fmt.Errorf("SMTP host is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.Email == "" {
|
||||||
|
return fmt.Errorf("recipient email is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if action.EmailSender == "" {
|
||||||
|
return fmt.Errorf("sender email is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", action.SMTPHost, action.SMTPPort)
|
||||||
|
|
||||||
|
subject := action.EmailSubject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "BanForge Alert"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body strings.Builder
|
||||||
|
body.WriteString("From: " + action.EmailSender + "\r\n")
|
||||||
|
body.WriteString("To: " + action.Email + "\r\n")
|
||||||
|
body.WriteString("Subject: " + subject + "\r\n")
|
||||||
|
body.WriteString("MIME-Version: 1.0\r\n")
|
||||||
|
body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||||
|
body.WriteString("\r\n")
|
||||||
|
body.WriteString(action.Body)
|
||||||
|
|
||||||
|
auth := smtp.PlainAuth("", action.SMTPUser, action.SMTPPassword, action.SMTPHost)
|
||||||
|
|
||||||
|
if action.SMTPTLS {
|
||||||
|
return sendEmailWithTLS(
|
||||||
|
addr,
|
||||||
|
auth,
|
||||||
|
action.EmailSender,
|
||||||
|
[]string{action.Email},
|
||||||
|
body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return smtp.SendMail(
|
||||||
|
addr,
|
||||||
|
auth,
|
||||||
|
action.EmailSender,
|
||||||
|
[]string{action.Email},
|
||||||
|
[]byte(body.String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendEmailWithTLS(addr string, auth smtp.Auth, from string, to []string, msg string) error {
|
||||||
|
host, _, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("split host port: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
ServerName: host,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("dial TLS: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c, err := smtp.NewClient(conn, host)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create SMTP client: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = c.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if auth != nil {
|
||||||
|
if ok, _ := c.Extension("AUTH"); !ok {
|
||||||
|
return fmt.Errorf("SMTP server does not support AUTH")
|
||||||
|
}
|
||||||
|
if err = c.Auth(auth); err != nil {
|
||||||
|
return fmt.Errorf("authenticate: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = c.Mail(from); err != nil {
|
||||||
|
return fmt.Errorf("mail from: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range to {
|
||||||
|
if err = c.Rcpt(addr); err != nil {
|
||||||
|
return fmt.Errorf("rcpt to: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := c.Data()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write([]byte(msg))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("write message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = w.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("close data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Quit()
|
||||||
|
}
|
||||||
338
internal/actions/email_test.go
Normal file
338
internal/actions/email_test.go
Normal file
@@ -0,0 +1,338 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
type simpleSMTPServer struct {
|
||||||
|
listener net.Listener
|
||||||
|
messages []string
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSimpleSMTPServer(t *testing.T, useTLS bool) *simpleSMTPServer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create listener: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := &simpleSMTPServer{
|
||||||
|
listener: l,
|
||||||
|
messages: make([]string, 0),
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
go s.serve(t, useTLS)
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simpleSMTPServer) serve(t *testing.T, useTLS bool) {
|
||||||
|
defer close(s.done)
|
||||||
|
|
||||||
|
for {
|
||||||
|
conn, err := s.listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func(c net.Conn) {
|
||||||
|
defer c.Close()
|
||||||
|
|
||||||
|
_, _ = c.Write([]byte("220 localhost ESMTP Test Server\r\n"))
|
||||||
|
|
||||||
|
reader := bufio.NewReader(c)
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
parts := strings.SplitN(line, " ", 2)
|
||||||
|
cmd := strings.ToUpper(parts[0])
|
||||||
|
|
||||||
|
switch cmd {
|
||||||
|
case "EHLO", "HELO":
|
||||||
|
if useTLS {
|
||||||
|
_, _ = c.Write([]byte("250-localhost\r\n250 STARTTLS\r\n"))
|
||||||
|
} else {
|
||||||
|
_, _ = c.Write([]byte("250-localhost\r\n250 AUTH PLAIN\r\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
case "STARTTLS":
|
||||||
|
if !useTLS {
|
||||||
|
_, _ = c.Write([]byte("454 TLS not available\r\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = c.Write([]byte("220 Ready to start TLS\r\n"))
|
||||||
|
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
InsecureSkipVerify: true,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
}
|
||||||
|
tlsConn := tls.Server(c, tlsConfig)
|
||||||
|
if err := tlsConn.Handshake(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reader = bufio.NewReader(tlsConn)
|
||||||
|
c = tlsConn
|
||||||
|
|
||||||
|
case "AUTH":
|
||||||
|
_, _ = c.Write([]byte("235 Authentication successful\r\n"))
|
||||||
|
|
||||||
|
case "MAIL":
|
||||||
|
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||||
|
|
||||||
|
case "RCPT":
|
||||||
|
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||||
|
|
||||||
|
case "DATA":
|
||||||
|
_, _ = c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
|
||||||
|
|
||||||
|
var msgBuilder strings.Builder
|
||||||
|
for {
|
||||||
|
msgLine, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(msgLine) == "." {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
msgBuilder.WriteString(msgLine)
|
||||||
|
}
|
||||||
|
s.messages = append(s.messages, msgBuilder.String())
|
||||||
|
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||||
|
|
||||||
|
case "QUIT":
|
||||||
|
_, _ = c.Write([]byte("221 Bye\r\n"))
|
||||||
|
return
|
||||||
|
|
||||||
|
default:
|
||||||
|
_, _ = c.Write([]byte("502 Command not implemented\r\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simpleSMTPServer) Addr() string {
|
||||||
|
return s.listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simpleSMTPServer) Close() {
|
||||||
|
_ = s.listener.Close()
|
||||||
|
<-s.done
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *simpleSMTPServer) MessageCount() int {
|
||||||
|
return len(s.messages)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmail_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
action config.Action
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "disabled action",
|
||||||
|
action: config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: false,
|
||||||
|
Email: "test@example.com",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
SMTPHost: "smtp.example.com",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty SMTP host",
|
||||||
|
action: config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "test@example.com",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
SMTPHost: "",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "SMTP host is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty recipient email",
|
||||||
|
action: config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
SMTPHost: "smtp.example.com",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "recipient email is empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty sender email",
|
||||||
|
action: config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "test@example.com",
|
||||||
|
EmailSender: "",
|
||||||
|
SMTPHost: "smtp.example.com",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "sender email is empty",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := SendEmail(tt.action)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("SendEmail() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tt.wantErr && err != nil && tt.errMsg != "" {
|
||||||
|
if err.Error() != tt.errMsg {
|
||||||
|
t.Errorf("SendEmail() error message = %v, want %v", err.Error(), tt.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmail_WithoutTLS(t *testing.T) {
|
||||||
|
server := newSimpleSMTPServer(t, false)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||||
|
var port int
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
|
||||||
|
action := config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "recipient@example.com",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
EmailSubject: "Test Subject",
|
||||||
|
SMTPHost: host,
|
||||||
|
SMTPPort: port,
|
||||||
|
SMTPUser: "user",
|
||||||
|
SMTPPassword: "pass",
|
||||||
|
SMTPTLS: false,
|
||||||
|
Body: "Test message body",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := SendEmail(action)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.MessageCount() != 1 {
|
||||||
|
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmail_WithTLS(t *testing.T) {
|
||||||
|
t.Skip("TLS test requires proper TLS handshake handling")
|
||||||
|
server := newSimpleSMTPServer(t, true)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||||
|
var port int
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
|
||||||
|
action := config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "recipient@example.com",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
EmailSubject: "Test Subject TLS",
|
||||||
|
SMTPHost: host,
|
||||||
|
SMTPPort: port,
|
||||||
|
SMTPUser: "user",
|
||||||
|
SMTPPassword: "pass",
|
||||||
|
SMTPTLS: true,
|
||||||
|
Body: "Test TLS message body",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := SendEmail(action)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.MessageCount() != 1 {
|
||||||
|
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmail_DefaultSubject(t *testing.T) {
|
||||||
|
server := newSimpleSMTPServer(t, false)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||||
|
var port int
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
|
||||||
|
action := config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "test@example.com",
|
||||||
|
EmailSender: "sender@example.com",
|
||||||
|
SMTPHost: host,
|
||||||
|
SMTPPort: port,
|
||||||
|
Body: "Test body",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := SendEmail(action)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if server.MessageCount() != 1 {
|
||||||
|
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSendEmail_Integration(t *testing.T) {
|
||||||
|
server := newSimpleSMTPServer(t, false)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||||
|
var port int
|
||||||
|
fmt.Sscanf(portStr, "%d", &port)
|
||||||
|
|
||||||
|
action := config.Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "to@example.com",
|
||||||
|
EmailSender: "from@example.com",
|
||||||
|
EmailSubject: "Integration Test",
|
||||||
|
SMTPHost: host,
|
||||||
|
SMTPPort: port,
|
||||||
|
SMTPUser: "testuser",
|
||||||
|
SMTPPassword: "testpass",
|
||||||
|
SMTPTLS: false,
|
||||||
|
Body: "Integration test message",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := SendEmail(action)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SendEmail() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Email sent successfully, server received %d message(s)", server.MessageCount())
|
||||||
|
}
|
||||||
19
internal/actions/interface.go
Normal file
19
internal/actions/interface.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import "github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
|
||||||
|
type Executor struct {
|
||||||
|
Action config.Action
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Executor) Execute() error {
|
||||||
|
switch e.Action.Type {
|
||||||
|
case "email":
|
||||||
|
return SendEmail(e.Action)
|
||||||
|
case "webhook":
|
||||||
|
return SendWebhook(e.Action)
|
||||||
|
case "script":
|
||||||
|
return RunScript(e.Action)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
32
internal/actions/scripts.go
Normal file
32
internal/actions/scripts.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RunScript(action config.Action) error {
|
||||||
|
if !action.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if action.Script == "" {
|
||||||
|
return fmt.Errorf("script on config is empty")
|
||||||
|
}
|
||||||
|
if action.Interpretator == "" {
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command(action.Script)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run script: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command(action.Interpretator, action.Script)
|
||||||
|
err := cmd.Run()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("run script: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
66
internal/actions/webhooks.go
Normal file
66
internal/actions/webhooks.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package actions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var defaultClient = &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
Transport: &http.Transport{
|
||||||
|
MaxIdleConns: 100,
|
||||||
|
MaxIdleConnsPerHost: 10,
|
||||||
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func SendWebhook(action config.Action) error {
|
||||||
|
if !action.Enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if action.URL == "" {
|
||||||
|
return fmt.Errorf("URL on config is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
method := action.Method
|
||||||
|
if method == "" {
|
||||||
|
method = "POST"
|
||||||
|
}
|
||||||
|
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if action.Body != "" {
|
||||||
|
bodyReader = strings.NewReader(action.Body)
|
||||||
|
if action.Headers["Content-Type"] == "" && action.Headers["content-type"] == "" {
|
||||||
|
action.Headers["Content-Type"] = "application/json"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(method, action.URL, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range action.Headers {
|
||||||
|
req.Header.Add(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #nosec G704 - HTTP request validation by system administrators
|
||||||
|
resp, err := defaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("execute request: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return fmt.Errorf("webhook returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
package blocker
|
package blocker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Firewalld struct {
|
type Firewalld struct {
|
||||||
@@ -21,19 +24,24 @@ func (f *Firewalld) Ban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
|
metrics.IncBanAttempt("firewalld")
|
||||||
|
// #nosec G204 - ip is validated
|
||||||
|
cmd := exec.Command("firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error(err.Error())
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("Add source " + ip + " " + string(output))
|
f.logger.Info("Add source " + ip + " " + string(output))
|
||||||
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
|
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error(err.Error())
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("Reload " + string(output))
|
f.logger.Info("Reload " + string(output))
|
||||||
|
metrics.IncBan("firewalld")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,19 +50,87 @@ func (f *Firewalld) Unban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
|
metrics.IncUnbanAttempt("firewalld")
|
||||||
|
// #nosec G204 - ip is validated
|
||||||
|
cmd := exec.Command("firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error(err.Error())
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("Remove source " + ip + " " + string(output))
|
f.logger.Info("Remove source " + ip + " " + string(output))
|
||||||
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
|
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error(err.Error())
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("Reload " + string(output))
|
f.logger.Info("Reload " + string(output))
|
||||||
|
metrics.IncUnban("firewalld")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Firewalld) PortOpen(port int, protocol string) error {
|
||||||
|
// #nosec G204 - handle is extracted from Firewalld output and validated
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
f.logger.Error("invalid protocol")
|
||||||
|
return fmt.Errorf("invalid protocol")
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("open", protocol)
|
||||||
|
cmd := exec.Command(
|
||||||
|
"firewall-cmd",
|
||||||
|
"--zone=public",
|
||||||
|
"--add-port="+s+"/"+protocol,
|
||||||
|
"--permanent",
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Reload " + string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Firewalld) PortClose(port int, protocol string) error {
|
||||||
|
// #nosec G204 - handle is extracted from nftables output and validated
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
return fmt.Errorf("invalid protocol")
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("close", protocol)
|
||||||
|
cmd := exec.Command(
|
||||||
|
"firewall-cmd",
|
||||||
|
"--zone=public",
|
||||||
|
"--remove-port="+s+"/"+protocol,
|
||||||
|
"--permanent",
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Remove port " + s + " " + string(output))
|
||||||
|
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Reload " + string(output))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ type BlockerEngine interface {
|
|||||||
Ban(ip string) error
|
Ban(ip string) error
|
||||||
Unban(ip string) error
|
Unban(ip string) error
|
||||||
Setup(config string) error
|
Setup(config string) error
|
||||||
|
PortOpen(port int, protocol string) error
|
||||||
|
PortClose(port int, protocol string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetBlocker(fw string, config string) BlockerEngine {
|
func GetBlocker(fw string, config string) BlockerEngine {
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package blocker
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Iptables struct {
|
type Iptables struct {
|
||||||
@@ -23,35 +25,40 @@ func (f *Iptables) Ban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncBanAttempt("iptables")
|
||||||
err = validateConfigPath(f.config)
|
err = validateConfigPath(f.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sudo", "iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
|
cmd := exec.Command("iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error("failed to ban IP",
|
f.logger.Error("failed to ban IP",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("IP banned",
|
f.logger.Info("IP banned",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncBan("iptables")
|
||||||
|
|
||||||
err = validateConfigPath(f.config)
|
err = validateConfigPath(f.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// #nosec G204 - f.config is validated above via validateConfigPath()
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
|
cmd = exec.Command("iptables-save", "-f", f.config)
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error("failed to save config",
|
f.logger.Error("failed to save config",
|
||||||
"config_path", f.config,
|
"config_path", f.config,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("config saved",
|
f.logger.Info("config saved",
|
||||||
@@ -65,35 +72,40 @@ func (f *Iptables) Unban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncUnbanAttempt("iptables")
|
||||||
err = validateConfigPath(f.config)
|
err = validateConfigPath(f.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
cmd := exec.Command("sudo", "iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
|
cmd := exec.Command("iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error("failed to unban IP",
|
f.logger.Error("failed to unban IP",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("IP unbanned",
|
f.logger.Info("IP unbanned",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncUnban("iptables")
|
||||||
|
|
||||||
err = validateConfigPath(f.config)
|
err = validateConfigPath(f.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// #nosec G204 - f.config is validated above via validateConfigPath()
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
|
cmd = exec.Command("iptables-save", "-f", f.config)
|
||||||
output, err = cmd.CombinedOutput()
|
output, err = cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.logger.Error("failed to save config",
|
f.logger.Error("failed to save config",
|
||||||
"config_path", f.config,
|
"config_path", f.config,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
f.logger.Info("config saved",
|
f.logger.Info("config saved",
|
||||||
@@ -102,6 +114,70 @@ func (f *Iptables) Unban(ip string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *Iptables) PortOpen(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
f.logger.Error("invalid protocol")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("open", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
|
cmd = exec.Command("iptables-save", "-f", f.config)
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error("failed to save config",
|
||||||
|
"config_path", f.config,
|
||||||
|
"error", err.Error(),
|
||||||
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *Iptables) PortClose(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
f.logger.Error("invalid protocol")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("close", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
// #nosec G204 - f.config is validated above via validateConfigPath()
|
||||||
|
cmd = exec.Command("iptables-save", "-f", f.config)
|
||||||
|
output, err = cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
f.logger.Error("failed to save config",
|
||||||
|
"config_path", f.config,
|
||||||
|
"error", err.Error(),
|
||||||
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Iptables) Setup(config string) error {
|
func (f *Iptables) Setup(config string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ package blocker
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Nftables struct {
|
type Nftables struct {
|
||||||
@@ -25,8 +27,9 @@ func (n *Nftables) Ban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncBanAttempt("nftables")
|
||||||
cmd := exec.Command("sudo", "nft", "add", "rule", "inet", "banforge", "banned",
|
// #nosec G204 - ip is validated
|
||||||
|
cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned",
|
||||||
"ip", "saddr", ip, "drop")
|
"ip", "saddr", ip, "drop")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,16 +37,19 @@ func (n *Nftables) Ban(ip string) error {
|
|||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
n.logger.Info("IP banned", "ip", ip)
|
n.logger.Info("IP banned", "ip", ip)
|
||||||
|
metrics.IncBan("nftables")
|
||||||
|
|
||||||
err = saveNftablesConfig(n.config)
|
err = saveNftablesConfig(n.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.logger.Error("failed to save config",
|
n.logger.Error("failed to save config",
|
||||||
"config_path", n.config,
|
"config_path", n.config,
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,21 +62,24 @@ func (n *Nftables) Unban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncUnbanAttempt("nftables")
|
||||||
|
|
||||||
handle, err := n.findRuleHandle(ip)
|
handle, err := n.findRuleHandle(ip)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.logger.Error("failed to find rule handle",
|
n.logger.Error("failed to find rule handle",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if handle == "" {
|
if handle == "" {
|
||||||
n.logger.Warn("no rule found for IP", "ip", ip)
|
n.logger.Warn("no rule found for IP", "ip", ip)
|
||||||
|
metrics.IncError()
|
||||||
return fmt.Errorf("no rule found for IP %s", ip)
|
return fmt.Errorf("no rule found for IP %s", ip)
|
||||||
}
|
}
|
||||||
// #nosec G204 - handle is extracted from nftables output and validated
|
// #nosec G204 - handle is extracted from nftables output and validated
|
||||||
cmd := exec.Command("sudo", "nft", "delete", "rule", "inet", "banforge", "banned",
|
cmd := exec.Command("nft", "delete", "rule", "inet", "banforge", "banned",
|
||||||
"handle", handle)
|
"handle", handle)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,16 +88,19 @@ func (n *Nftables) Unban(ip string) error {
|
|||||||
"handle", handle,
|
"handle", handle,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
n.logger.Info("IP unbanned", "ip", ip, "handle", handle)
|
n.logger.Info("IP unbanned", "ip", ip, "handle", handle)
|
||||||
|
metrics.IncUnban("nftables")
|
||||||
|
|
||||||
err = saveNftablesConfig(n.config)
|
err = saveNftablesConfig(n.config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.logger.Error("failed to save config",
|
n.logger.Error("failed to save config",
|
||||||
"config_path", n.config,
|
"config_path", n.config,
|
||||||
"error", err.Error())
|
"error", err.Error())
|
||||||
|
metrics.IncError()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +124,8 @@ func (n *Nftables) Setup(config string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
cmd := exec.Command("sudo", "tee", config)
|
// #nosec G204 - config is managed by adminstartor
|
||||||
|
cmd := exec.Command("tee", config)
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||||
@@ -134,8 +147,8 @@ func (n *Nftables) Setup(config string) error {
|
|||||||
if err = cmd.Wait(); err != nil {
|
if err = cmd.Wait(); err != nil {
|
||||||
return fmt.Errorf("failed to save config: %w", err)
|
return fmt.Errorf("failed to save config: %w", err)
|
||||||
}
|
}
|
||||||
|
// #nosec G204 - config is managed by adminstartor
|
||||||
cmd = exec.Command("sudo", "nft", "-f", config)
|
cmd = exec.Command("nft", "-f", config)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load nftables config: %s", string(output))
|
return fmt.Errorf("failed to load nftables config: %s", string(output))
|
||||||
@@ -145,7 +158,7 @@ func (n *Nftables) Setup(config string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (n *Nftables) findRuleHandle(ip string) (string, error) {
|
func (n *Nftables) findRuleHandle(ip string) (string, error) {
|
||||||
cmd := exec.Command("sudo", "nft", "-a", "list", "chain", "inet", "banforge", "banned")
|
cmd := exec.Command("nft", "-a", "list", "chain", "inet", "banforge", "banned")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to list chain rules: %w", err)
|
return "", fmt.Errorf("failed to list chain rules: %w", err)
|
||||||
@@ -166,19 +179,102 @@ func (n *Nftables) findRuleHandle(ip string) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (n *Nftables) PortOpen(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
n.logger.Error("invalid protocol")
|
||||||
|
metrics.IncError()
|
||||||
|
return fmt.Errorf("invalid protocol")
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("open", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command(
|
||||||
|
"nft",
|
||||||
|
"add",
|
||||||
|
"rule",
|
||||||
|
"inet",
|
||||||
|
"banforge",
|
||||||
|
"input",
|
||||||
|
protocol,
|
||||||
|
"dport",
|
||||||
|
s,
|
||||||
|
"accept",
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
n.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
err = saveNftablesConfig(n.config)
|
||||||
|
if err != nil {
|
||||||
|
n.logger.Error("failed to save config",
|
||||||
|
"config_path", n.config,
|
||||||
|
"error", err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (n *Nftables) PortClose(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
n.logger.Error("invalid protocol")
|
||||||
|
metrics.IncError()
|
||||||
|
return fmt.Errorf("invalid protocol")
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("close", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command(
|
||||||
|
"nft",
|
||||||
|
"add",
|
||||||
|
"rule",
|
||||||
|
"inet",
|
||||||
|
"banforge",
|
||||||
|
"input",
|
||||||
|
protocol,
|
||||||
|
"dport",
|
||||||
|
s,
|
||||||
|
"drop",
|
||||||
|
)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
n.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
err = saveNftablesConfig(n.config)
|
||||||
|
if err != nil {
|
||||||
|
n.logger.Error("failed to save config",
|
||||||
|
"config_path", n.config,
|
||||||
|
"error", err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func saveNftablesConfig(configPath string) error {
|
func saveNftablesConfig(configPath string) error {
|
||||||
err := validateConfigPath(configPath)
|
err := validateConfigPath(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd := exec.Command("sudo", "nft", "list", "ruleset")
|
cmd := exec.Command("nft", "list", "ruleset")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to get nftables ruleset: %w", err)
|
return fmt.Errorf("failed to get nftables ruleset: %w", err)
|
||||||
}
|
}
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
cmd = exec.Command("sudo", "tee", configPath)
|
cmd = exec.Command("tee", configPath)
|
||||||
stdin, err := cmd.StdinPipe()
|
stdin, err := cmd.StdinPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
return fmt.Errorf("failed to create stdin pipe: %w", err)
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ package blocker
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Ufw struct {
|
type Ufw struct {
|
||||||
@@ -22,18 +24,21 @@ func (u *Ufw) Ban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncBanAttempt("ufw")
|
||||||
cmd := exec.Command("sudo", "ufw", "--force", "deny", "from", ip)
|
// #nosec G204 - ip is validated
|
||||||
|
cmd := exec.Command("ufw", "--force", "deny", "from", ip)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("failed to ban IP",
|
u.logger.Error("failed to ban IP",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return fmt.Errorf("failed to ban IP %s: %w", ip, err)
|
return fmt.Errorf("failed to ban IP %s: %w", ip, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.logger.Info("IP banned", "ip", ip, "output", string(output))
|
u.logger.Info("IP banned", "ip", ip, "output", string(output))
|
||||||
|
metrics.IncBan("ufw")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (u *Ufw) Unban(ip string) error {
|
func (u *Ufw) Unban(ip string) error {
|
||||||
@@ -41,25 +46,72 @@ func (u *Ufw) Unban(ip string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
metrics.IncUnbanAttempt("ufw")
|
||||||
cmd := exec.Command("sudo", "ufw", "--force", "delete", "deny", "from", ip)
|
// #nosec G204 - ip is validated
|
||||||
|
cmd := exec.Command("ufw", "--force", "delete", "deny", "from", ip)
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("failed to unban IP",
|
u.logger.Error("failed to unban IP",
|
||||||
"ip", ip,
|
"ip", ip,
|
||||||
"error", err.Error(),
|
"error", err.Error(),
|
||||||
"output", string(output))
|
"output", string(output))
|
||||||
|
metrics.IncError()
|
||||||
return fmt.Errorf("failed to unban IP %s: %w", ip, err)
|
return fmt.Errorf("failed to unban IP %s: %w", ip, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
u.logger.Info("IP unbanned", "ip", ip, "output", string(output))
|
u.logger.Info("IP unbanned", "ip", ip, "output", string(output))
|
||||||
|
metrics.IncUnban("ufw")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Ufw) PortOpen(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
u.logger.Error("invalid protocol")
|
||||||
|
metrics.IncError()
|
||||||
|
return fmt.Errorf("invalid protocol")
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("open", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command("ufw", "allow", s+"/"+protocol)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *Ufw) PortClose(port int, protocol string) error {
|
||||||
|
if port >= 0 && port <= 65535 {
|
||||||
|
if protocol != "tcp" && protocol != "udp" {
|
||||||
|
u.logger.Error("invalid protocol")
|
||||||
|
metrics.IncError()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s := strconv.Itoa(port)
|
||||||
|
metrics.IncPortOperation("close", protocol)
|
||||||
|
// #nosec G204 - managed by system adminstartor
|
||||||
|
cmd := exec.Command("ufw", "deny", s+"/"+protocol)
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
u.logger.Error(err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
u.logger.Info("Add port " + s + " " + string(output))
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *Ufw) Setup(config string) error {
|
func (u *Ufw) Setup(config string) error {
|
||||||
if config != "" {
|
if config != "" {
|
||||||
fmt.Printf("Ufw dont support config file\n")
|
fmt.Printf("Ufw dont support config file\n")
|
||||||
cmd := exec.Command("sudo", "ufw", "enable")
|
cmd := exec.Command("ufw", "enable")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("failed to enable ufw",
|
u.logger.Error("failed to enable ufw",
|
||||||
@@ -69,7 +121,7 @@ func (u *Ufw) Setup(config string) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if config == "" {
|
if config == "" {
|
||||||
cmd := exec.Command("sudo", "ufw", "enable")
|
cmd := exec.Command("ufw", "enable")
|
||||||
output, err := cmd.CombinedOutput()
|
output, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
u.logger.Error("failed to enable ufw",
|
u.logger.Error("failed to enable ufw",
|
||||||
|
|||||||
@@ -3,147 +3,179 @@ package config
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func LoadRuleConfig() ([]Rule, error) {
|
func LoadRuleConfig() ([]Rule, error) {
|
||||||
log := logger.New(false)
|
const rulesDir = "/etc/banforge/rules.d"
|
||||||
|
|
||||||
var cfg Rules
|
var cfg Rules
|
||||||
|
|
||||||
_, err := toml.DecodeFile("/etc/banforge/rules.toml", &cfg)
|
files, err := os.ReadDir(rulesDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(fmt.Sprintf("failed to decode config: %v", err))
|
return nil, fmt.Errorf("failed to read rules directory: %w", err)
|
||||||
return nil, err
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
filePath := filepath.Join(rulesDir, file.Name())
|
||||||
|
var fileCfg Rules
|
||||||
|
|
||||||
|
if _, err := toml.DecodeFile(filePath, &fileCfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse rule file %s: %w", filePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.Rules = append(cfg.Rules, fileCfg.Rules...)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info(fmt.Sprintf("loaded %d rules", len(cfg.Rules)))
|
|
||||||
return cfg.Rules, nil
|
return cfg.Rules, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRule(
|
func NewRule(
|
||||||
Name string,
|
name string,
|
||||||
ServiceName string,
|
serviceName string,
|
||||||
Path string,
|
path string,
|
||||||
Status string,
|
status string,
|
||||||
Method string,
|
method string,
|
||||||
ttl string,
|
ttl string,
|
||||||
|
maxRetry int,
|
||||||
) error {
|
) error {
|
||||||
r, err := LoadRuleConfig()
|
if name == "" {
|
||||||
if err != nil {
|
return fmt.Errorf("rule name can't be empty")
|
||||||
r = []Rule{}
|
|
||||||
}
|
}
|
||||||
if Name == "" {
|
|
||||||
fmt.Printf("Rule name can't be empty\n")
|
rule := Rule{
|
||||||
return nil
|
Name: name,
|
||||||
}
|
ServiceName: serviceName,
|
||||||
r = append(
|
Path: path,
|
||||||
r,
|
Status: status,
|
||||||
Rule{
|
Method: method,
|
||||||
Name: Name,
|
|
||||||
ServiceName: ServiceName,
|
|
||||||
Path: Path,
|
|
||||||
Status: Status,
|
|
||||||
Method: Method,
|
|
||||||
BanTime: ttl,
|
BanTime: ttl,
|
||||||
},
|
MaxRetry: maxRetry,
|
||||||
)
|
}
|
||||||
file, err := os.Create("/etc/banforge/rules.toml")
|
|
||||||
|
filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
|
||||||
|
|
||||||
|
if _, err := os.Stat(filePath); err == nil {
|
||||||
|
return fmt.Errorf("rule with name '%s' already exists", name)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Rules{Rules: []Rule{rule}}
|
||||||
|
|
||||||
|
// #nosec G304 - validate by sanitizeRuleFilename
|
||||||
|
file, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to create rule file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err = file.Close()
|
if closeErr := file.Close(); closeErr != nil {
|
||||||
if err != nil {
|
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
|
||||||
fmt.Println(err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
cfg := Rules{Rules: r}
|
|
||||||
|
|
||||||
err = toml.NewEncoder(file).Encode(cfg)
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to encode rule: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func EditRule(Name string, ServiceName string, Path string, Status string, Method string) error {
|
func EditRule(name string, serviceName string, path string, status string, method string) error {
|
||||||
if Name == "" {
|
if name == "" {
|
||||||
return fmt.Errorf("Rule name can't be empty")
|
return fmt.Errorf("rule name can't be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
r, err := LoadRuleConfig()
|
rules, err := LoadRuleConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("rules is empty, please use 'banforge add rule' or create rules.toml")
|
return fmt.Errorf("failed to load rules: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
found := false
|
found := false
|
||||||
for i, rule := range r {
|
var updatedRule *Rule
|
||||||
if rule.Name == Name {
|
for i, rule := range rules {
|
||||||
|
if rule.Name == name {
|
||||||
found = true
|
found = true
|
||||||
|
updatedRule = &rules[i]
|
||||||
|
|
||||||
if ServiceName != "" {
|
if serviceName != "" {
|
||||||
r[i].ServiceName = ServiceName
|
updatedRule.ServiceName = serviceName
|
||||||
}
|
}
|
||||||
if Path != "" {
|
if path != "" {
|
||||||
r[i].Path = Path
|
updatedRule.Path = path
|
||||||
}
|
}
|
||||||
if Status != "" {
|
if status != "" {
|
||||||
r[i].Status = Status
|
updatedRule.Status = status
|
||||||
}
|
}
|
||||||
if Method != "" {
|
if method != "" {
|
||||||
r[i].Method = Method
|
updatedRule.Method = method
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
return fmt.Errorf("rule '%s' not found", Name)
|
return fmt.Errorf("rule '%s' not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Create("/etc/banforge/rules.toml")
|
filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
|
||||||
|
cfg := Rules{Rules: []Rule{*updatedRule}}
|
||||||
|
|
||||||
|
// #nosec G304 - validate by sanitizeRuleFilename
|
||||||
|
file, err := os.Create(filePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to update rule file: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err = file.Close()
|
if closeErr := file.Close(); closeErr != nil {
|
||||||
if err != nil {
|
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
|
||||||
fmt.Println(err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
cfg := Rules{Rules: r}
|
|
||||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
return fmt.Errorf("failed to encode config: %w", err)
|
return fmt.Errorf("failed to encode updated rule: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SanitizeRuleFilename(name string) string {
|
||||||
|
result := strings.Map(func(r rune) rune {
|
||||||
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
|
||||||
|
(r >= '0' && r <= '9') || r == '-' || r == '_' {
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
return '_'
|
||||||
|
}, name)
|
||||||
|
return strings.ToLower(result)
|
||||||
|
}
|
||||||
|
|
||||||
func ParseDurationWithYears(s string) (time.Duration, error) {
|
func ParseDurationWithYears(s string) (time.Duration, error) {
|
||||||
if strings.HasSuffix(s, "y") {
|
if ss, ok := strings.CutSuffix(s, "y"); ok {
|
||||||
years, err := strconv.Atoi(strings.TrimSuffix(s, "y"))
|
years, err := strconv.Atoi(ss)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return time.Duration(years) * 365 * 24 * time.Hour, nil
|
return time.Duration(years) * 365 * 24 * time.Hour, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(s, "M") {
|
if ss, ok := strings.CutSuffix(s, "M"); ok {
|
||||||
months, err := strconv.Atoi(strings.TrimSuffix(s, "M"))
|
months, err := strconv.Atoi(ss)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
return time.Duration(months) * 30 * 24 * time.Hour, nil
|
return time.Duration(months) * 30 * 24 * time.Hour, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasSuffix(s, "d") {
|
if ss, ok := strings.CutSuffix(s, "d"); ok {
|
||||||
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
|
days, err := strconv.Atoi(ss)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
943
internal/config/config_test.go
Normal file
943
internal/config/config_test.go
Normal file
@@ -0,0 +1,943 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/BurntSushi/toml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for SanitizeRuleFilename
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestSanitizeRuleFilename(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "simple alphanumeric",
|
||||||
|
input: "nginx404",
|
||||||
|
expected: "nginx404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with spaces",
|
||||||
|
input: "nginx 404 error",
|
||||||
|
expected: "nginx_404_error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with special chars",
|
||||||
|
input: "nginx/404:error",
|
||||||
|
expected: "nginx_404_error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with dashes and underscores",
|
||||||
|
input: "nginx-404_error",
|
||||||
|
expected: "nginx-404_error",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "uppercase to lowercase",
|
||||||
|
input: "NGINX-404",
|
||||||
|
expected: "nginx-404",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed case",
|
||||||
|
input: "Nginx-Admin-Access",
|
||||||
|
expected: "nginx-admin-access",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with dots",
|
||||||
|
input: "nginx.error.page",
|
||||||
|
expected: "nginx_error_page",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only special chars",
|
||||||
|
input: "!@#$%^&*()",
|
||||||
|
expected: "__________",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "russian chars",
|
||||||
|
input: "nginxошибка",
|
||||||
|
expected: "nginx______",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := SanitizeRuleFilename(tt.input)
|
||||||
|
if result != tt.expected {
|
||||||
|
t.Errorf("SanitizeRuleFilename(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for ParseDurationWithYears
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestParseDurationWithYears(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected time.Duration
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "1 year",
|
||||||
|
input: "1y",
|
||||||
|
expected: 365 * 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "2 years",
|
||||||
|
input: "2y",
|
||||||
|
expected: 2 * 365 * 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1 month",
|
||||||
|
input: "1M",
|
||||||
|
expected: 30 * 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "6 months",
|
||||||
|
input: "6M",
|
||||||
|
expected: 6 * 30 * 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "30 days",
|
||||||
|
input: "30d",
|
||||||
|
expected: 30 * 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1 day",
|
||||||
|
input: "1d",
|
||||||
|
expected: 24 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1 hour",
|
||||||
|
input: "1h",
|
||||||
|
expected: time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "30 minutes",
|
||||||
|
input: "30m",
|
||||||
|
expected: 30 * time.Minute,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "30 seconds",
|
||||||
|
input: "30s",
|
||||||
|
expected: 30 * time.Second,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex duration",
|
||||||
|
input: "1h30m",
|
||||||
|
expected: 1*time.Hour + 30*time.Minute,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid year format",
|
||||||
|
input: "abc",
|
||||||
|
expected: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid month format",
|
||||||
|
input: "xM",
|
||||||
|
expected: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid day format",
|
||||||
|
input: "xd",
|
||||||
|
expected: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
input: "",
|
||||||
|
expected: 0,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative duration",
|
||||||
|
input: "-1h",
|
||||||
|
expected: -1 * time.Hour,
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := ParseDurationWithYears(tt.input)
|
||||||
|
if (err != nil) != tt.expectError {
|
||||||
|
t.Errorf("ParseDurationWithYears(%q) error = %v, expectError %v", tt.input, err, tt.expectError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !tt.expectError && result != tt.expected {
|
||||||
|
t.Errorf("ParseDurationWithYears(%q) = %v, want %v", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Rule validation (NewRule)
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestNewRule_EmptyName(t *testing.T) {
|
||||||
|
err := NewRule("", "nginx", "", "", "", "1h", 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewRule with empty name should return error")
|
||||||
|
}
|
||||||
|
if err.Error() != "rule name can't be empty" {
|
||||||
|
t.Errorf("Unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRule_DuplicateRule(t *testing.T) {
|
||||||
|
// Create temp directory for rules
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create first rule
|
||||||
|
firstRulePath := filepath.Join(tmpDir, "test-rule.toml")
|
||||||
|
cfg := Rules{Rules: []Rule{{Name: "test-rule"}}}
|
||||||
|
file, _ := os.Create(firstRulePath)
|
||||||
|
toml.NewEncoder(file).Encode(cfg)
|
||||||
|
file.Close()
|
||||||
|
|
||||||
|
// Try to create duplicate
|
||||||
|
err := NewRule("test-rule", "nginx", "", "", "", "1h", 0)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("NewRule with duplicate name should return error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for EditRule validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestEditRule_EmptyName(t *testing.T) {
|
||||||
|
err := EditRule("", "nginx", "", "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("EditRule with empty name should return error")
|
||||||
|
}
|
||||||
|
if err.Error() != "rule name can't be empty" {
|
||||||
|
t.Errorf("Unexpected error message: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Rule struct validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestRule_StructTags(t *testing.T) {
|
||||||
|
// Test that Rule struct has correct TOML tags
|
||||||
|
rule := Rule{
|
||||||
|
Name: "test-rule",
|
||||||
|
ServiceName: "nginx",
|
||||||
|
Path: "/admin/*",
|
||||||
|
Status: "403",
|
||||||
|
Method: "POST",
|
||||||
|
MaxRetry: 5,
|
||||||
|
BanTime: "1h",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode to TOML and verify
|
||||||
|
cfg := Rules{Rules: []Rule{rule}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "test.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Rules
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded.Rules) != 1 {
|
||||||
|
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedRule := decoded.Rules[0]
|
||||||
|
if decodedRule.Name != rule.Name {
|
||||||
|
t.Errorf("Name mismatch: %q != %q", decodedRule.Name, rule.Name)
|
||||||
|
}
|
||||||
|
if decodedRule.ServiceName != rule.ServiceName {
|
||||||
|
t.Errorf("ServiceName mismatch: %q != %q", decodedRule.ServiceName, rule.ServiceName)
|
||||||
|
}
|
||||||
|
if decodedRule.Path != rule.Path {
|
||||||
|
t.Errorf("Path mismatch: %q != %q", decodedRule.Path, rule.Path)
|
||||||
|
}
|
||||||
|
if decodedRule.MaxRetry != rule.MaxRetry {
|
||||||
|
t.Errorf("MaxRetry mismatch: %d != %d", decodedRule.MaxRetry, rule.MaxRetry)
|
||||||
|
}
|
||||||
|
if decodedRule.BanTime != rule.BanTime {
|
||||||
|
t.Errorf("BanTime mismatch: %q != %q", decodedRule.BanTime, rule.BanTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Action struct validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestAction_EmailAction(t *testing.T) {
|
||||||
|
action := Action{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "admin@example.com",
|
||||||
|
EmailSender: "banforge@example.com",
|
||||||
|
EmailSubject: "Alert",
|
||||||
|
SMTPHost: "smtp.example.com",
|
||||||
|
SMTPPort: 587,
|
||||||
|
SMTPUser: "user",
|
||||||
|
SMTPTLS: true,
|
||||||
|
Body: "IP {ip} banned",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Rules{Rules: []Rule{{
|
||||||
|
Name: "test",
|
||||||
|
Action: []Action{action},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "action.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Rules
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedAction := decoded.Rules[0].Action[0]
|
||||||
|
if decodedAction.Type != "email" {
|
||||||
|
t.Errorf("Expected action type 'email', got %q", decodedAction.Type)
|
||||||
|
}
|
||||||
|
if decodedAction.Email != "admin@example.com" {
|
||||||
|
t.Errorf("Expected email 'admin@example.com', got %q", decodedAction.Email)
|
||||||
|
}
|
||||||
|
if decodedAction.SMTPPort != 587 {
|
||||||
|
t.Errorf("Expected SMTP port 587, got %d", decodedAction.SMTPPort)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAction_WebhookAction(t *testing.T) {
|
||||||
|
action := Action{
|
||||||
|
Type: "webhook",
|
||||||
|
Enabled: true,
|
||||||
|
URL: "https://hooks.example.com/alert",
|
||||||
|
Method: "POST",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer token123",
|
||||||
|
},
|
||||||
|
Body: `{"ip": "{ip}", "rule": "{rule}"}`,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Rules{Rules: []Rule{{
|
||||||
|
Name: "test",
|
||||||
|
Action: []Action{action},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "webhook.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode webhook action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Rules
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode webhook action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedAction := decoded.Rules[0].Action[0]
|
||||||
|
if decodedAction.Type != "webhook" {
|
||||||
|
t.Errorf("Expected action type 'webhook', got %q", decodedAction.Type)
|
||||||
|
}
|
||||||
|
if decodedAction.URL != "https://hooks.example.com/alert" {
|
||||||
|
t.Errorf("Expected URL 'https://hooks.example.com/alert', got %q", decodedAction.URL)
|
||||||
|
}
|
||||||
|
if decodedAction.Headers["Content-Type"] != "application/json" {
|
||||||
|
t.Errorf("Expected Content-Type header, got %q", decodedAction.Headers["Content-Type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAction_ScriptAction(t *testing.T) {
|
||||||
|
action := Action{
|
||||||
|
Type: "script",
|
||||||
|
Enabled: true,
|
||||||
|
Script: "/usr/local/bin/notify.sh",
|
||||||
|
Interpretator: "bash",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Rules{Rules: []Rule{{
|
||||||
|
Name: "test",
|
||||||
|
Action: []Action{action},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "script.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode script action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Rules
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode script action: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedAction := decoded.Rules[0].Action[0]
|
||||||
|
if decodedAction.Type != "script" {
|
||||||
|
t.Errorf("Expected action type 'script', got %q", decodedAction.Type)
|
||||||
|
}
|
||||||
|
if decodedAction.Script != "/usr/local/bin/notify.sh" {
|
||||||
|
t.Errorf("Expected script path '/usr/local/bin/notify.sh', got %q", decodedAction.Script)
|
||||||
|
}
|
||||||
|
if decodedAction.Interpretator != "bash" {
|
||||||
|
t.Errorf("Expected interpretator 'bash', got %q", decodedAction.Interpretator)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Service struct validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestService_FileLogging(t *testing.T) {
|
||||||
|
service := Service{
|
||||||
|
Name: "nginx",
|
||||||
|
Logging: "file",
|
||||||
|
LogPath: "/var/log/nginx/access.log",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Service: []Service{service}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "service.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(decoded.Service) != 1 {
|
||||||
|
t.Fatalf("Expected 1 service, got %d", len(decoded.Service))
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedService := decoded.Service[0]
|
||||||
|
if decodedService.Name != "nginx" {
|
||||||
|
t.Errorf("Expected service name 'nginx', got %q", decodedService.Name)
|
||||||
|
}
|
||||||
|
if decodedService.Logging != "file" {
|
||||||
|
t.Errorf("Expected logging type 'file', got %q", decodedService.Logging)
|
||||||
|
}
|
||||||
|
if decodedService.LogPath != "/var/log/nginx/access.log" {
|
||||||
|
t.Errorf("Expected log path '/var/log/nginx/access.log', got %q", decodedService.LogPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestService_JournaldLogging(t *testing.T) {
|
||||||
|
service := Service{
|
||||||
|
Name: "sshd",
|
||||||
|
Logging: "journald",
|
||||||
|
LogPath: "sshd",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Service: []Service{service}}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "journald.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode journald service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode journald service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decodedService := decoded.Service[0]
|
||||||
|
if decodedService.Logging != "journald" {
|
||||||
|
t.Errorf("Expected logging type 'journald', got %q", decodedService.Logging)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Metrics struct validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestMetrics_Enabled(t *testing.T) {
|
||||||
|
metrics := Metrics{
|
||||||
|
Enabled: true,
|
||||||
|
Port: 9090,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Metrics: metrics}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "metrics.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !decoded.Metrics.Enabled {
|
||||||
|
t.Error("Expected metrics to be enabled")
|
||||||
|
}
|
||||||
|
if decoded.Metrics.Port != 9090 {
|
||||||
|
t.Errorf("Expected metrics port 9090, got %d", decoded.Metrics.Port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMetrics_Disabled(t *testing.T) {
|
||||||
|
metrics := Metrics{
|
||||||
|
Enabled: false,
|
||||||
|
Port: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Metrics: metrics}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "metrics-disabled.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode disabled metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode disabled metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Metrics.Enabled {
|
||||||
|
t.Error("Expected metrics to be disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Tests for Firewall struct validation
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestFirewall_Nftables(t *testing.T) {
|
||||||
|
firewall := Firewall{
|
||||||
|
Name: "nftables",
|
||||||
|
Config: "/etc/nftables.conf",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Firewall: firewall}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "firewall.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode firewall: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode firewall: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Firewall.Name != "nftables" {
|
||||||
|
t.Errorf("Expected firewall name 'nftables', got %q", decoded.Firewall.Name)
|
||||||
|
}
|
||||||
|
if decoded.Firewall.Config != "/etc/nftables.conf" {
|
||||||
|
t.Errorf("Expected firewall config '/etc/nftables.conf', got %q", decoded.Firewall.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewall_Iptables(t *testing.T) {
|
||||||
|
firewall := Firewall{
|
||||||
|
Name: "iptables",
|
||||||
|
Config: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Firewall: firewall}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "iptables.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode iptables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode iptables: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Firewall.Name != "iptables" {
|
||||||
|
t.Errorf("Expected firewall name 'iptables', got %q", decoded.Firewall.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewall_Ufw(t *testing.T) {
|
||||||
|
firewall := Firewall{
|
||||||
|
Name: "ufw",
|
||||||
|
Config: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Firewall: firewall}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "ufw.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode ufw: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode ufw: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Firewall.Name != "ufw" {
|
||||||
|
t.Errorf("Expected firewall name 'ufw', got %q", decoded.Firewall.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewall_Firewalld(t *testing.T) {
|
||||||
|
firewall := Firewall{
|
||||||
|
Name: "firewalld",
|
||||||
|
Config: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg := Config{Firewall: firewall}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "firewalld.toml")
|
||||||
|
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||||
|
t.Fatalf("Failed to encode firewalld: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read back and verify
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode firewalld: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if decoded.Firewall.Name != "firewalld" {
|
||||||
|
t.Errorf("Expected firewall name 'firewalld', got %q", decoded.Firewall.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Integration test: Full config round-trip
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestConfig_FullRoundTrip(t *testing.T) {
|
||||||
|
fullConfig := Config{
|
||||||
|
Firewall: Firewall{
|
||||||
|
Name: "nftables",
|
||||||
|
Config: "/etc/nftables.conf",
|
||||||
|
},
|
||||||
|
Metrics: Metrics{
|
||||||
|
Enabled: true,
|
||||||
|
Port: 9090,
|
||||||
|
},
|
||||||
|
Service: []Service{
|
||||||
|
{
|
||||||
|
Name: "nginx",
|
||||||
|
Logging: "file",
|
||||||
|
LogPath: "/var/log/nginx/access.log",
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "sshd",
|
||||||
|
Logging: "journald",
|
||||||
|
LogPath: "sshd",
|
||||||
|
Enabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "full-config.toml")
|
||||||
|
|
||||||
|
// Encode
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(fullConfig); err != nil {
|
||||||
|
t.Fatalf("Failed to encode full config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode
|
||||||
|
var decoded Config
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode full config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify firewall
|
||||||
|
if decoded.Firewall.Name != fullConfig.Firewall.Name {
|
||||||
|
t.Errorf("Firewall.Name mismatch: %q != %q", decoded.Firewall.Name, fullConfig.Firewall.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify metrics
|
||||||
|
if decoded.Metrics.Enabled != fullConfig.Metrics.Enabled {
|
||||||
|
t.Errorf("Metrics.Enabled mismatch: %v != %v", decoded.Metrics.Enabled, fullConfig.Metrics.Enabled)
|
||||||
|
}
|
||||||
|
if decoded.Metrics.Port != fullConfig.Metrics.Port {
|
||||||
|
t.Errorf("Metrics.Port mismatch: %d != %d", decoded.Metrics.Port, fullConfig.Metrics.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify services
|
||||||
|
if len(decoded.Service) != len(fullConfig.Service) {
|
||||||
|
t.Fatalf("Services count mismatch: %d != %d", len(decoded.Service), len(fullConfig.Service))
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, expected := range fullConfig.Service {
|
||||||
|
actual := decoded.Service[i]
|
||||||
|
if actual.Name != expected.Name {
|
||||||
|
t.Errorf("Service[%d].Name mismatch: %q != %q", i, actual.Name, expected.Name)
|
||||||
|
}
|
||||||
|
if actual.Logging != expected.Logging {
|
||||||
|
t.Errorf("Service[%d].Logging mismatch: %q != %q", i, actual.Logging, expected.Logging)
|
||||||
|
}
|
||||||
|
if actual.Enabled != expected.Enabled {
|
||||||
|
t.Errorf("Service[%d].Enabled mismatch: %v != %v", i, actual.Enabled, expected.Enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Integration test: Full rule with actions round-trip
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
func TestRule_FullRoundTrip(t *testing.T) {
|
||||||
|
fullRule := Rules{
|
||||||
|
Rules: []Rule{
|
||||||
|
{
|
||||||
|
Name: "nginx-bruteforce",
|
||||||
|
ServiceName: "nginx",
|
||||||
|
Path: "/admin/*",
|
||||||
|
Status: "403",
|
||||||
|
Method: "POST",
|
||||||
|
MaxRetry: 5,
|
||||||
|
BanTime: "2h",
|
||||||
|
Action: []Action{
|
||||||
|
{
|
||||||
|
Type: "email",
|
||||||
|
Enabled: true,
|
||||||
|
Email: "admin@example.com",
|
||||||
|
EmailSender: "banforge@example.com",
|
||||||
|
EmailSubject: "Ban Alert",
|
||||||
|
SMTPHost: "smtp.example.com",
|
||||||
|
SMTPPort: 587,
|
||||||
|
SMTPUser: "user",
|
||||||
|
SMTPPassword: "pass",
|
||||||
|
SMTPTLS: true,
|
||||||
|
Body: "IP {ip} banned for rule {rule}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "webhook",
|
||||||
|
Enabled: true,
|
||||||
|
URL: "https://hooks.slack.com/services/xxx",
|
||||||
|
Method: "POST",
|
||||||
|
Headers: map[string]string{
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
Body: `{"text": "IP {ip} banned"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Type: "script",
|
||||||
|
Enabled: true,
|
||||||
|
Script: "/usr/local/bin/notify.sh",
|
||||||
|
Interpretator: "bash",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
tmpFile := filepath.Join(tmpDir, "full-rule.toml")
|
||||||
|
|
||||||
|
// Encode
|
||||||
|
file, err := os.Create(tmpFile)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
if err := toml.NewEncoder(file).Encode(fullRule); err != nil {
|
||||||
|
t.Fatalf("Failed to encode full rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode
|
||||||
|
var decoded Rules
|
||||||
|
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||||
|
t.Fatalf("Failed to decode full rule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify rule
|
||||||
|
if len(decoded.Rules) != 1 {
|
||||||
|
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
|
||||||
|
}
|
||||||
|
|
||||||
|
rule := decoded.Rules[0]
|
||||||
|
if rule.Name != fullRule.Rules[0].Name {
|
||||||
|
t.Errorf("Rule.Name mismatch: %q != %q", rule.Name, fullRule.Rules[0].Name)
|
||||||
|
}
|
||||||
|
if rule.ServiceName != fullRule.Rules[0].ServiceName {
|
||||||
|
t.Errorf("Rule.ServiceName mismatch: %q != %q", rule.ServiceName, fullRule.Rules[0].ServiceName)
|
||||||
|
}
|
||||||
|
if rule.MaxRetry != fullRule.Rules[0].MaxRetry {
|
||||||
|
t.Errorf("Rule.MaxRetry mismatch: %d != %d", rule.MaxRetry, fullRule.Rules[0].MaxRetry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify actions
|
||||||
|
if len(rule.Action) != 3 {
|
||||||
|
t.Fatalf("Expected 3 actions, got %d", len(rule.Action))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Email action
|
||||||
|
emailAction := rule.Action[0]
|
||||||
|
if emailAction.Type != "email" {
|
||||||
|
t.Errorf("Action[0].Type mismatch: %q != 'email'", emailAction.Type)
|
||||||
|
}
|
||||||
|
if emailAction.Email != "admin@example.com" {
|
||||||
|
t.Errorf("Email action email mismatch: %q != 'admin@example.com'", emailAction.Email)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook action
|
||||||
|
webhookAction := rule.Action[1]
|
||||||
|
if webhookAction.Type != "webhook" {
|
||||||
|
t.Errorf("Action[1].Type mismatch: %q != 'webhook'", webhookAction.Type)
|
||||||
|
}
|
||||||
|
if webhookAction.Headers["Content-Type"] != "application/json" {
|
||||||
|
t.Errorf("Webhook Content-Type header mismatch")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Script action
|
||||||
|
scriptAction := rule.Action[2]
|
||||||
|
if scriptAction.Type != "script" {
|
||||||
|
t.Errorf("Action[2].Type mismatch: %q != 'script'", scriptAction.Type)
|
||||||
|
}
|
||||||
|
if scriptAction.Script != "/usr/local/bin/notify.sh" {
|
||||||
|
t.Errorf("Script action script mismatch: %q != '/usr/local/bin/notify.sh'", scriptAction.Script)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,24 @@ const (
|
|||||||
ConfigFile = "config.toml"
|
ConfigFile = "config.toml"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func createFileWithPermissions(path string, perm os.FileMode) error {
|
||||||
|
// #nosec G304 - path is controlled by config package not user
|
||||||
|
file, err := os.Create(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(path, perm); err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := file.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func CreateConf() error {
|
func CreateConf() error {
|
||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
return fmt.Errorf("you must be root to run this command, use sudo/doas")
|
return fmt.Errorf("you must be root to run this command, use sudo/doas")
|
||||||
@@ -28,53 +46,49 @@ func CreateConf() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
file, err := os.Create("/etc/banforge/config.toml")
|
if err := os.MkdirAll(ConfigDir, 0750); err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("failed to create config directory: %w", err)
|
||||||
return fmt.Errorf("failed to create config file: %w", err)
|
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
err = file.Close()
|
if err := os.WriteFile(configPath, []byte(Base_config), 0600); err != nil {
|
||||||
if err != nil {
|
|
||||||
fmt.Println(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
if err := os.Chmod(configPath, 0600); err != nil {
|
|
||||||
return fmt.Errorf("failed to set permissions: %w", err)
|
|
||||||
}
|
|
||||||
err = os.WriteFile(configPath, []byte(Base_config), 0600)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write config file: %w", err)
|
return fmt.Errorf("failed to write config file: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" Config file created: %s\n", configPath)
|
fmt.Printf("Config file created: %s\n", configPath)
|
||||||
file, err = os.Create("/etc/banforge/rules.toml")
|
|
||||||
if err != nil {
|
rulesDir := filepath.Join(ConfigDir, "rules.d")
|
||||||
return fmt.Errorf("failed to create rules file: %w", err)
|
if err := os.MkdirAll(rulesDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create rules directory: %w", err)
|
||||||
}
|
}
|
||||||
file, err = os.Create("/var/lib/banforge/storage.db")
|
fmt.Printf("Rules directory created: %s\n", rulesDir)
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create database file: %w", err)
|
bansDBDir := filepath.Dir("/var/lib/banforge/bans.db")
|
||||||
|
if err := os.MkdirAll(bansDBDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create bans database directory: %w", err)
|
||||||
}
|
}
|
||||||
err = os.Chmod("/var/lib/banforge/storage.db", 0600)
|
|
||||||
if err != nil {
|
reqDBDir := filepath.Dir("/var/lib/banforge/requests.db")
|
||||||
return fmt.Errorf("failed to set permissions: %w", err)
|
if err := os.MkdirAll(reqDBDir, 0750); err != nil {
|
||||||
|
return fmt.Errorf("failed to create requests database directory: %w", err)
|
||||||
}
|
}
|
||||||
defer func() {
|
|
||||||
err = file.Close()
|
bansDBPath := "/var/lib/banforge/bans.db"
|
||||||
if err != nil {
|
if err := createFileWithPermissions(bansDBPath, 0600); err != nil {
|
||||||
fmt.Println(err)
|
return fmt.Errorf("failed to create bans database file: %w", err)
|
||||||
}
|
}
|
||||||
}()
|
fmt.Printf("Bans database file created: %s\n", bansDBPath)
|
||||||
if err := os.Chmod(configPath, 0600); err != nil {
|
|
||||||
return fmt.Errorf("failed to set permissions: %w", err)
|
reqDBPath := "/var/lib/banforge/requests.db"
|
||||||
|
if err := createFileWithPermissions(reqDBPath, 0600); err != nil {
|
||||||
|
return fmt.Errorf("failed to create requests database file: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" Rules file created: %s\n", configPath)
|
fmt.Printf("Requests database file created: %s\n", reqDBPath)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FindFirewall() error {
|
func FindFirewall() error {
|
||||||
if os.Getegid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
fmt.Printf("Firewall settings needs sudo privileges\n")
|
return fmt.Errorf("firewall settings needs sudo privileges")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
|
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
|
||||||
@@ -107,10 +121,7 @@ func FindFirewall() error {
|
|||||||
|
|
||||||
encoder := toml.NewEncoder(file)
|
encoder := toml.NewEncoder(file)
|
||||||
if err := encoder.Encode(cfg); err != nil {
|
if err := encoder.Encode(cfg); err != nil {
|
||||||
err = file.Close()
|
_ = file.Close()
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to close file: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to encode config: %w", err)
|
return fmt.Errorf("failed to encode config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ const Base_config = `
|
|||||||
name = ""
|
name = ""
|
||||||
config = "/etc/nftables.conf"
|
config = "/etc/nftables.conf"
|
||||||
|
|
||||||
|
[metrics]
|
||||||
|
enabled = false
|
||||||
|
port = 2122
|
||||||
|
|
||||||
[[service]]
|
[[service]]
|
||||||
name = "nginx"
|
name = "nginx"
|
||||||
logging = "file"
|
logging = "file"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ type Service struct {
|
|||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Firewall Firewall `toml:"firewall"`
|
Firewall Firewall `toml:"firewall"`
|
||||||
|
Metrics Metrics `toml:"metrics"`
|
||||||
Service []Service `toml:"service"`
|
Service []Service `toml:"service"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,5 +29,32 @@ 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"`
|
||||||
|
MaxRetry int `toml:"max_retry"`
|
||||||
BanTime string `toml:"ban_time"`
|
BanTime string `toml:"ban_time"`
|
||||||
|
Action []Action `toml:"action"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Metrics struct {
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
Port int `toml:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
type Action struct {
|
||||||
|
Type string `toml:"type"`
|
||||||
|
Enabled bool `toml:"enabled"`
|
||||||
|
URL string `toml:"url"`
|
||||||
|
Method string `toml:"method"`
|
||||||
|
Headers map[string]string `toml:"headers"`
|
||||||
|
Body string `toml:"body"`
|
||||||
|
Email string `toml:"email"`
|
||||||
|
EmailSender string `toml:"email_sender"`
|
||||||
|
EmailSubject string `toml:"email_subject"`
|
||||||
|
SMTPHost string `toml:"smtp_host"`
|
||||||
|
SMTPPort int `toml:"smtp_port"`
|
||||||
|
SMTPUser string `toml:"smtp_user"`
|
||||||
|
SMTPPassword string `toml:"smtp_password"`
|
||||||
|
SMTPTLS bool `toml:"smtp_tls"`
|
||||||
|
Interpretator string `toml:"interpretator"`
|
||||||
|
Script string `toml:"script"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,18 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/actions"
|
||||||
"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/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
"github.com/d3m0k1d/BanForge/internal/storage"
|
"github.com/d3m0k1d/BanForge/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Judge struct {
|
type Judge struct {
|
||||||
db *storage.DB
|
db_r *storage.BanReader
|
||||||
|
db_w *storage.BanWriter
|
||||||
|
db_rq *storage.RequestReader
|
||||||
logger *logger.Logger
|
logger *logger.Logger
|
||||||
Blocker blocker.BlockerEngine
|
Blocker blocker.BlockerEngine
|
||||||
rulesByService map[string][]config.Rule
|
rulesByService map[string][]config.Rule
|
||||||
@@ -21,13 +25,17 @@ type Judge struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func New(
|
func New(
|
||||||
db *storage.DB,
|
db_r *storage.BanReader,
|
||||||
|
db_w *storage.BanWriter,
|
||||||
|
db_rq *storage.RequestReader,
|
||||||
b blocker.BlockerEngine,
|
b blocker.BlockerEngine,
|
||||||
resultCh chan *storage.LogEntry,
|
resultCh chan *storage.LogEntry,
|
||||||
entryCh chan *storage.LogEntry,
|
entryCh chan *storage.LogEntry,
|
||||||
) *Judge {
|
) *Judge {
|
||||||
return &Judge{
|
return &Judge{
|
||||||
db: db,
|
db_w: db_w,
|
||||||
|
db_r: db_r,
|
||||||
|
db_rq: db_rq,
|
||||||
logger: logger.New(false),
|
logger: logger.New(false),
|
||||||
rulesByService: make(map[string][]config.Rule),
|
rulesByService: make(map[string][]config.Rule),
|
||||||
Blocker: b,
|
Blocker: b,
|
||||||
@@ -72,32 +80,33 @@ func (j *Judge) Tribunal() {
|
|||||||
methodMatch := rule.Method == "" || entry.Method == rule.Method
|
methodMatch := rule.Method == "" || entry.Method == rule.Method
|
||||||
statusMatch := rule.Status == "" || entry.Status == rule.Status
|
statusMatch := rule.Status == "" || entry.Status == rule.Status
|
||||||
pathMatch := matchPath(entry.Path, rule.Path)
|
pathMatch := matchPath(entry.Path, rule.Path)
|
||||||
|
|
||||||
j.logger.Debug(
|
|
||||||
"Testing rule",
|
|
||||||
"rule", rule.Name,
|
|
||||||
"method_match", methodMatch,
|
|
||||||
"status_match", statusMatch,
|
|
||||||
"path_match", pathMatch,
|
|
||||||
)
|
|
||||||
|
|
||||||
if methodMatch && statusMatch && pathMatch {
|
if methodMatch && statusMatch && pathMatch {
|
||||||
ruleMatched = true
|
ruleMatched = true
|
||||||
j.logger.Info("Rule matched", "rule", rule.Name, "ip", entry.IP)
|
j.logger.Info("Rule matched", "rule", rule.Name, "ip", entry.IP)
|
||||||
|
j.resultCh <- entry
|
||||||
banned, err := j.db.IsBanned(entry.IP)
|
banned, err := j.db_r.IsBanned(entry.IP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
|
j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
|
||||||
|
metrics.IncError()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if banned {
|
if banned {
|
||||||
j.logger.Info("IP already banned", "ip", entry.IP)
|
j.logger.Info("IP already banned", "ip", entry.IP)
|
||||||
j.resultCh <- entry
|
metrics.IncLogParsed()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry)
|
||||||
err = j.db.AddBan(entry.IP, rule.BanTime)
|
if err != nil {
|
||||||
|
j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !exceeded {
|
||||||
|
j.logger.Info("Max retry not exceeded", "ip", entry.IP)
|
||||||
|
metrics.IncLogParsed()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Error(
|
j.logger.Error(
|
||||||
"Failed to add ban to database",
|
"Failed to add ban to database",
|
||||||
@@ -113,8 +122,20 @@ func (j *Judge) Tribunal() {
|
|||||||
|
|
||||||
if err := j.Blocker.Ban(entry.IP); err != nil {
|
if err := j.Blocker.Ban(entry.IP); err != nil {
|
||||||
j.logger.Error("Failed to ban IP at firewall", "ip", entry.IP, "error", err)
|
j.logger.Error("Failed to ban IP at firewall", "ip", entry.IP, "error", err)
|
||||||
|
metrics.IncError()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, action := range rule.Action {
|
||||||
|
executor := &actions.Executor{Action: action}
|
||||||
|
if err := executor.Execute(); err != nil {
|
||||||
|
j.logger.Error("Action execution failed",
|
||||||
|
"rule", rule.Name,
|
||||||
|
"action_type", action.Type,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
j.logger.Info(
|
j.logger.Info(
|
||||||
"IP banned successfully",
|
"IP banned successfully",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -124,7 +145,7 @@ func (j *Judge) Tribunal() {
|
|||||||
"ban_time",
|
"ban_time",
|
||||||
rule.BanTime,
|
rule.BanTime,
|
||||||
)
|
)
|
||||||
j.resultCh <- entry
|
metrics.IncBan(rule.ServiceName)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,22 +163,20 @@ func (j *Judge) UnbanChecker() {
|
|||||||
defer tick.Stop()
|
defer tick.Stop()
|
||||||
|
|
||||||
for range tick.C {
|
for range tick.C {
|
||||||
ips, err := j.db.CheckExpiredBans()
|
ips, err := j.db_w.RemoveExpiredBans()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
|
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
|
||||||
|
metrics.IncError()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ip := range ips {
|
for _, ip := range ips {
|
||||||
err = j.db.RemoveBan(ip)
|
|
||||||
if err != nil {
|
|
||||||
j.logger.Error(fmt.Sprintf("Failed to remove ban: %v", err))
|
|
||||||
}
|
|
||||||
if err := j.Blocker.Unban(ip); err != nil {
|
if err := j.Blocker.Unban(ip); err != nil {
|
||||||
j.logger.Error(fmt.Sprintf("Failed to unban IP %s: %v", ip, err))
|
j.logger.Error(fmt.Sprintf("Failed to unban IP at firewall: %v", err))
|
||||||
continue
|
metrics.IncError()
|
||||||
|
} else {
|
||||||
|
metrics.IncUnban("judge")
|
||||||
}
|
}
|
||||||
j.logger.Info(fmt.Sprintf("IP unbanned: %s", ip))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
131
internal/metrics/metrics.go
Normal file
131
internal/metrics/metrics.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
metricsMu sync.RWMutex
|
||||||
|
metrics = make(map[string]int64)
|
||||||
|
)
|
||||||
|
|
||||||
|
func IncBan(service string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["ban_count"]++
|
||||||
|
metrics[service+"_bans"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncUnban(service string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["unban_count"]++
|
||||||
|
metrics[service+"_unbans"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncRuleMatched(rule_name string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics[rule_name+"_rule_matched"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncLogParsed() {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["log_parsed"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncError() {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["error_count"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncBanAttempt(firewall string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["ban_attempt_count"]++
|
||||||
|
metrics[firewall+"_ban_attempts"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncUnbanAttempt(firewall string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics["unban_attempt_count"]++
|
||||||
|
metrics[firewall+"_unban_attempts"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncPortOperation(operation string, protocol string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
key := "port_" + operation + "_" + protocol
|
||||||
|
metrics[key]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncParserEvent(service string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics[service+"_parsed_events"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncScannerEvent(service string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics[service+"_scanner_events"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncDBOperation(operation string, table string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
key := "db_" + operation + "_" + table
|
||||||
|
metrics[key]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncRequestCount(service string) {
|
||||||
|
metricsMu.Lock()
|
||||||
|
metrics[service+"_request_count"]++
|
||||||
|
metricsMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func MetricsHandler() http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
metricsMu.RLock()
|
||||||
|
snapshot := make(map[string]int64, len(metrics))
|
||||||
|
for k, v := range metrics {
|
||||||
|
snapshot[k] = v
|
||||||
|
}
|
||||||
|
metricsMu.RUnlock()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||||
|
|
||||||
|
for name, value := range snapshot {
|
||||||
|
metricName := name + "_total"
|
||||||
|
_, _ = fmt.Fprintf(w, "# TYPE %s counter\n", metricName)
|
||||||
|
_, _ = fmt.Fprintf(w, "%s %d\n", metricName, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func StartMetricsServer(port int) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.Handle("/metrics", MetricsHandler())
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Addr: "localhost:" + strconv.Itoa(port),
|
||||||
|
Handler: mux,
|
||||||
|
ReadTimeout: 5 * time.Second,
|
||||||
|
WriteTimeout: 10 * time.Second,
|
||||||
|
IdleTimeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Starting metrics server on %s", server.Addr)
|
||||||
|
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return fmt.Errorf("metrics server failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
63
internal/parser/ApacheParser.go
Normal file
63
internal/parser/ApacheParser.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApacheParser struct {
|
||||||
|
pattern *regexp.Regexp
|
||||||
|
logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewApacheParser() *ApacheParser {
|
||||||
|
pattern := regexp.MustCompile(
|
||||||
|
`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+-\s+-\s+\[(.*?)\]\s+"(\w+)\s+(.*?)\s+HTTP/[\d.]+"\s+(\d+)\s+(\d+|-)\s+"(.*?)"\s+"(.*?)"`,
|
||||||
|
)
|
||||||
|
// Groups:
|
||||||
|
// 1: IP
|
||||||
|
// 2: Timestamp
|
||||||
|
// 3: Method (GET, POST, etc.)
|
||||||
|
// 4: Path
|
||||||
|
// 5: Status Code (200, 404, 403...)
|
||||||
|
// 6: Response Size
|
||||||
|
// 7: Referer
|
||||||
|
// 8: User-Agent
|
||||||
|
|
||||||
|
return &ApacheParser{
|
||||||
|
pattern: pattern,
|
||||||
|
logger: logger.New(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ApacheParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
|
||||||
|
// Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
|
||||||
|
for event := range eventCh {
|
||||||
|
matches := p.pattern.FindStringSubmatch(event.Data)
|
||||||
|
if matches == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
path := matches[4]
|
||||||
|
status := matches[5]
|
||||||
|
method := matches[3]
|
||||||
|
|
||||||
|
resultCh <- &storage.LogEntry{
|
||||||
|
Service: "apache",
|
||||||
|
IP: matches[1],
|
||||||
|
Path: path,
|
||||||
|
Status: status,
|
||||||
|
Method: method,
|
||||||
|
}
|
||||||
|
metrics.IncParserEvent("apache")
|
||||||
|
p.logger.Info(
|
||||||
|
"Parsed apache log entry",
|
||||||
|
"ip", matches[1],
|
||||||
|
"path", path,
|
||||||
|
"status", status,
|
||||||
|
"method", method,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
"github.com/d3m0k1d/BanForge/internal/storage"
|
"github.com/d3m0k1d/BanForge/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEn
|
|||||||
Status: status,
|
Status: status,
|
||||||
Method: method,
|
Method: method,
|
||||||
}
|
}
|
||||||
|
metrics.IncParserEvent("nginx")
|
||||||
p.logger.Info(
|
p.logger.Info(
|
||||||
"Parsed nginx log entry",
|
"Parsed nginx log entry",
|
||||||
"ip",
|
"ip",
|
||||||
|
|||||||
@@ -2,11 +2,16 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Event struct {
|
type Event struct {
|
||||||
@@ -23,7 +28,56 @@ type Scanner struct {
|
|||||||
pollDelay time.Duration
|
pollDelay time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateLogPath(path string) error {
|
||||||
|
if path == "" {
|
||||||
|
return fmt.Errorf("log path cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
return fmt.Errorf("log path must be absolute: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(path, "..") {
|
||||||
|
return fmt.Errorf("log path contains '..': %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("log file does not exist: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to stat log file: %w", err)
|
||||||
|
}
|
||||||
|
if info.Mode()&os.ModeSymlink != 0 {
|
||||||
|
return fmt.Errorf("log path is a symlink: %s", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateJournaldUnit(unit string) error {
|
||||||
|
if unit == "" {
|
||||||
|
return fmt.Errorf("journald unit cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !regexp.MustCompile(`^[a-zA-Z0-9._-]+$`).MatchString(unit) {
|
||||||
|
return fmt.Errorf("invalid journald unit name: %s", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(unit, "-") {
|
||||||
|
return fmt.Errorf("journald unit cannot start with '-': %s", unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func NewScannerTail(path string) (*Scanner, error) {
|
func NewScannerTail(path string) (*Scanner, error) {
|
||||||
|
if err := validateLogPath(path); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid log path: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #nosec G204 - path is validated above via validateLogPath()
|
||||||
cmd := exec.Command("tail", "-F", "-n", "10", path)
|
cmd := exec.Command("tail", "-F", "-n", "10", path)
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -46,6 +100,11 @@ func NewScannerTail(path string) (*Scanner, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewScannerJournald(unit string) (*Scanner, error) {
|
func NewScannerJournald(unit string) (*Scanner, error) {
|
||||||
|
if err := validateJournaldUnit(unit); err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid journald unit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// #nosec G204 - unit is validated above via validateJournaldUnit()
|
||||||
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "short", "--no-pager")
|
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "short", "--no-pager")
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -79,6 +138,7 @@ func (s *Scanner) Start() {
|
|||||||
|
|
||||||
default:
|
default:
|
||||||
if s.scanner.Scan() {
|
if s.scanner.Scan() {
|
||||||
|
metrics.IncScannerEvent("scanner")
|
||||||
s.ch <- Event{
|
s.ch <- Event{
|
||||||
Data: s.scanner.Text(),
|
Data: s.scanner.Text(),
|
||||||
}
|
}
|
||||||
@@ -86,6 +146,7 @@ func (s *Scanner) Start() {
|
|||||||
} else {
|
} else {
|
||||||
if err := s.scanner.Err(); err != nil {
|
if err := s.scanner.Err(); err != nil {
|
||||||
s.logger.Error("Scanner error")
|
s.logger.Error("Scanner error")
|
||||||
|
metrics.IncError()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package parser
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -281,3 +282,201 @@ func BenchmarkScanner(b *testing.B) {
|
|||||||
<-scanner.Events()
|
<-scanner.Events()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateLogPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
setup func() (string, func())
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
path: "",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "log path cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path",
|
||||||
|
path: "logs/test.log",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "log path must be absolute",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "path with traversal",
|
||||||
|
path: "/var/log/../etc/passwd",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "log path contains '..'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent file",
|
||||||
|
path: "/var/log/nonexistent.log",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "log file does not exist",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid file",
|
||||||
|
path: "/tmp/test-valid.log",
|
||||||
|
setup: func() (string, func()) {
|
||||||
|
_, _ = os.Create("/tmp/test-valid.log")
|
||||||
|
return "/tmp/test-valid.log", func() { os.Remove("/tmp/test-valid.log") }
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
var cleanup func()
|
||||||
|
if tt.setup != nil {
|
||||||
|
tt.path, cleanup = tt.setup()
|
||||||
|
defer cleanup()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := validateLogPath(tt.path)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("validateLogPath() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr && tt.errMsg != "" && err != nil {
|
||||||
|
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("validateLogPath() error = %v, want message containing %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateJournaldUnit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unit string
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty unit",
|
||||||
|
unit: "",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "journald unit cannot be empty",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unit starting with dash",
|
||||||
|
unit: "-dangerous",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "journald unit cannot start with '-'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unit with special chars",
|
||||||
|
unit: "test;rm -rf /",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "invalid journald unit name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unit with spaces",
|
||||||
|
unit: "test unit",
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "invalid journald unit name",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid unit simple",
|
||||||
|
unit: "nginx",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid unit with dash",
|
||||||
|
unit: "ssh-agent",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid unit with dot",
|
||||||
|
unit: "systemd-journald.service",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid unit with underscore",
|
||||||
|
unit: "my_service",
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
err := validateJournaldUnit(tt.unit)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("validateJournaldUnit() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
if tt.wantErr && tt.errMsg != "" && err != nil {
|
||||||
|
if !strings.Contains(err.Error(), tt.errMsg) {
|
||||||
|
t.Errorf("validateJournaldUnit() error = %v, want message containing %q", err, tt.errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewScannerTailValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty path",
|
||||||
|
path: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "relative path",
|
||||||
|
path: "test.log",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-existent path",
|
||||||
|
path: "/nonexistent/path/file.log",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := NewScannerTail(tt.path)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("NewScannerTail() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewScannerJournaldValidation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
unit string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty unit",
|
||||||
|
unit: "",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unit with semicolon",
|
||||||
|
unit: "test;rm -rf /",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unit starting with dash",
|
||||||
|
unit: "-dangerous",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
_, err := NewScannerJournald(tt.unit)
|
||||||
|
if (err != nil) != tt.wantErr {
|
||||||
|
t.Errorf("NewScannerJournald() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
"github.com/d3m0k1d/BanForge/internal/storage"
|
"github.com/d3m0k1d/BanForge/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +25,6 @@ func NewSshdParser() *SshdParser {
|
|||||||
|
|
||||||
func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
|
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
|
// 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 {
|
for event := range eventCh {
|
||||||
matches := p.pattern.FindStringSubmatch(event.Data)
|
matches := p.pattern.FindStringSubmatch(event.Data)
|
||||||
if matches == nil {
|
if matches == nil {
|
||||||
@@ -37,6 +37,7 @@ func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEnt
|
|||||||
Status: "Failed",
|
Status: "Failed",
|
||||||
Method: matches[4], // method auth
|
Method: matches[4], // method auth
|
||||||
}
|
}
|
||||||
|
metrics.IncParserEvent("ssh")
|
||||||
p.logger.Info(
|
p.logger.Info(
|
||||||
"Parsed ssh log entry",
|
"Parsed ssh log entry",
|
||||||
"ip",
|
"ip",
|
||||||
@@ -49,5 +50,4 @@ func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEnt
|
|||||||
"Failed",
|
"Failed",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}()
|
|
||||||
}
|
}
|
||||||
|
|||||||
228
internal/storage/ban_db.go
Normal file
228
internal/storage/ban_db.go
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
|
"github.com/jedib0t/go-pretty/v6/table"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Writer block
|
||||||
|
type BanWriter struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBanWriter() (*BanWriter, error) {
|
||||||
|
db, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
buildSqliteDsn(banDBPath, pragmas),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &BanWriter{
|
||||||
|
logger: logger.New(false),
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanWriter) CreateTable() error {
|
||||||
|
_, err := d.db.Exec(CreateBansTable)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metrics.IncDBOperation("create_table", "bans")
|
||||||
|
d.logger.Info("Created tables")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
|
||||||
|
duration, err := config.ParseDurationWithYears(ttl)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return fmt.Errorf("invalid duration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
expiredAt := now.Add(duration)
|
||||||
|
|
||||||
|
_, err = d.db.Exec(
|
||||||
|
"INSERT INTO bans (ip, reason, banned_at, expired_at) VALUES (?, ?, ?, ?)",
|
||||||
|
ip,
|
||||||
|
reason,
|
||||||
|
now.Format(time.RFC3339),
|
||||||
|
expiredAt.Format(time.RFC3339),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("Failed to add ban", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics.IncDBOperation("insert", "bans")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanWriter) RemoveBan(ip string) error {
|
||||||
|
_, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("Failed to remove ban", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
metrics.IncDBOperation("delete", "bans")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
|
||||||
|
var ips []string
|
||||||
|
now := time.Now().Format(time.RFC3339)
|
||||||
|
|
||||||
|
rows, err := w.db.Query(
|
||||||
|
"SELECT ip FROM bans WHERE expired_at < ?",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Failed to get expired bans", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := rows.Close(); err != nil {
|
||||||
|
w.logger.Error("Failed to close rows", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var ip string
|
||||||
|
err := rows.Scan(&ip)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Failed to scan ban", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ips = append(ips, ip)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := w.db.Exec(
|
||||||
|
"DELETE FROM bans WHERE expired_at < ?",
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
w.logger.Error("Failed to remove expired bans", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsAffected, err := result.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if rowsAffected > 0 {
|
||||||
|
w.logger.Info("Removed expired bans", "count", rowsAffected, "ips", len(ips))
|
||||||
|
metrics.IncDBOperation("delete_expired", "bans")
|
||||||
|
}
|
||||||
|
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanWriter) Close() error {
|
||||||
|
d.logger.Info("Closing database connection")
|
||||||
|
err := d.db.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reader block
|
||||||
|
|
||||||
|
type BanReader struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewBanReader() (*BanReader, error) {
|
||||||
|
db, err := sql.Open("sqlite",
|
||||||
|
"/var/lib/banforge/bans.db?"+
|
||||||
|
"mode=ro&"+
|
||||||
|
"_pragma=journal_mode(WAL)&"+
|
||||||
|
"_pragma=mmap_size(268435456)&"+
|
||||||
|
"_pragma=cache_size(-2000)&"+
|
||||||
|
"_pragma=query_only(1)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &BanReader{
|
||||||
|
logger: logger.New(false),
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanReader) IsBanned(ip string) (bool, error) {
|
||||||
|
var bannedIP string
|
||||||
|
err := d.db.QueryRow("SELECT ip FROM bans WHERE ip = ? ", ip).Scan(&bannedIP)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
metrics.IncError()
|
||||||
|
return false, fmt.Errorf("failed to check ban status: %w", err)
|
||||||
|
}
|
||||||
|
metrics.IncDBOperation("select", "bans")
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanReader) BanList() error {
|
||||||
|
var count int
|
||||||
|
t := table.NewWriter()
|
||||||
|
t.SetOutputMirror(os.Stdout)
|
||||||
|
t.SetStyle(table.StyleBold)
|
||||||
|
t.AppendHeader(table.Row{"№", "IP", "Banned At", "Reason", "Expires At"})
|
||||||
|
rows, err := d.db.Query("SELECT ip, banned_at, reason, expired_at FROM bans")
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("Failed to get ban list", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
count++
|
||||||
|
var ip string
|
||||||
|
var bannedAt string
|
||||||
|
var reason string
|
||||||
|
var expiredAt string
|
||||||
|
err := rows.Scan(&ip, &bannedAt, &reason, &expiredAt)
|
||||||
|
if err != nil {
|
||||||
|
d.logger.Error("Failed to get ban list", "error", err)
|
||||||
|
metrics.IncError()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t.AppendRow(table.Row{count, ip, bannedAt, reason, expiredAt})
|
||||||
|
|
||||||
|
}
|
||||||
|
t.Render()
|
||||||
|
metrics.IncDBOperation("select", "bans")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *BanReader) Close() error {
|
||||||
|
d.logger.Info("Closing database connection")
|
||||||
|
err := d.db.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
380
internal/storage/ban_db_test.go
Normal file
380
internal/storage/ban_db_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,158 +2,60 @@ package storage
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/config"
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
|
||||||
"github.com/jedib0t/go-pretty/v6/table"
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DB struct {
|
const (
|
||||||
logger *logger.Logger
|
DBDir = "/var/lib/banforge/"
|
||||||
db *sql.DB
|
ReqDBPath = DBDir + "requests.db"
|
||||||
|
banDBPath = DBDir + "bans.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
var pragmas = map[string]string{
|
||||||
|
`journal_mode`: `wal`,
|
||||||
|
`synchronous`: `normal`,
|
||||||
|
`busy_timeout`: `30000`,
|
||||||
|
// also consider these
|
||||||
|
// `temp_store`: `memory`,
|
||||||
|
// `cache_size`: `1000000000`,
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDB() (*DB, error) {
|
func buildSqliteDsn(path string, pragmas map[string]string) string {
|
||||||
db, err := sql.Open(
|
pragmastrs := make([]string, len(pragmas))
|
||||||
"sqlite",
|
i := 0
|
||||||
"/var/lib/banforge/storage.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)",
|
for k, v := range pragmas {
|
||||||
)
|
pragmastrs[i] = (fmt.Sprintf(`pragma=%s(%s)`, k, v))
|
||||||
db.SetMaxOpenConns(1)
|
i++
|
||||||
db.SetMaxIdleConns(1)
|
|
||||||
db.SetConnMaxLifetime(0)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
|
return path + "?" + "mode=rwc&" + strings.Join(pragmastrs, "&")
|
||||||
if err := db.Ping(); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return &DB{
|
|
||||||
logger: logger.New(false),
|
|
||||||
db: db,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) Close() error {
|
func initDB(dsn, sqlstr string) (err error) {
|
||||||
d.logger.Info("Closing database connection")
|
db, err := sql.Open("sqlite", dsn)
|
||||||
err := d.db.Close()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open %q: %w", dsn, err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
closeErr := db.Close()
|
||||||
|
if closeErr != nil {
|
||||||
|
err = errors.Join(err, fmt.Errorf("failed to close %q: %w", dsn, closeErr))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, err = db.Exec(sqlstr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create table: %w", err)
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *DB) CreateTable() error {
|
func CreateTables() (err error) {
|
||||||
_, err := d.db.Exec(CreateTables)
|
// Requests DB
|
||||||
if err != nil {
|
err1 := initDB(buildSqliteDsn(ReqDBPath, pragmas), CreateRequestsTable)
|
||||||
return err
|
err2 := initDB(buildSqliteDsn(banDBPath, pragmas), CreateBansTable)
|
||||||
}
|
|
||||||
d.logger.Info("Created tables")
|
return errors.Join(err1, err2)
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) IsBanned(ip string) (bool, error) {
|
|
||||||
var bannedIP string
|
|
||||||
err := d.db.QueryRow("SELECT ip FROM bans WHERE ip = ? ", ip).Scan(&bannedIP)
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err != nil {
|
|
||||||
return false, fmt.Errorf("failed to check ban status: %w", err)
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) AddBan(ip string, ttl string) error {
|
|
||||||
duration, err := config.ParseDurationWithYears(ttl)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
|
|
||||||
return fmt.Errorf("invalid duration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
now := time.Now()
|
|
||||||
expiredAt := now.Add(duration)
|
|
||||||
|
|
||||||
_, err = d.db.Exec(
|
|
||||||
"INSERT INTO bans (ip, reason, banned_at, expired_at) VALUES (?, ?, ?, ?)",
|
|
||||||
ip,
|
|
||||||
"1",
|
|
||||||
now.Format(time.RFC3339),
|
|
||||||
expiredAt.Format(time.RFC3339),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to add ban", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) 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 (d *DB) BanList() error {
|
|
||||||
|
|
||||||
var count int
|
|
||||||
t := table.NewWriter()
|
|
||||||
t.SetOutputMirror(os.Stdout)
|
|
||||||
t.SetStyle(table.StyleBold)
|
|
||||||
t.AppendHeader(table.Row{"№", "IP", "Banned At"})
|
|
||||||
rows, err := d.db.Query("SELECT ip, banned_at FROM bans")
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to get ban list", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
count++
|
|
||||||
var ip string
|
|
||||||
var bannedAt string
|
|
||||||
err := rows.Scan(&ip, &bannedAt)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to get ban list", "error", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
t.AppendRow(table.Row{count, ip, bannedAt})
|
|
||||||
|
|
||||||
}
|
|
||||||
t.Render()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *DB) CheckExpiredBans() ([]string, error) {
|
|
||||||
var ips []string
|
|
||||||
rows, err := d.db.Query(
|
|
||||||
"SELECT ip FROM bans WHERE expired_at < ?",
|
|
||||||
time.Now().Format(time.RFC3339),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to get ban list", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var ip string
|
|
||||||
r, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to get ban list", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
d.logger.Info("Ban removed", "ip", ip, "rows", r)
|
|
||||||
err = rows.Scan(&ip)
|
|
||||||
if err != nil {
|
|
||||||
d.logger.Error("Failed to get ban list", "error", err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
ips = append(ips, ip)
|
|
||||||
}
|
|
||||||
return ips, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
package storage
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
|
||||||
_ "modernc.org/sqlite"
|
|
||||||
"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("sqlite", 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("sqlite", 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 TestIsBanned(t *testing.T) {
|
|
||||||
d := createTestDBStruct(t)
|
|
||||||
|
|
||||||
err := d.CreateTable()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = d.db.Exec("INSERT INTO bans (ip, banned_at) VALUES (?, ?)", "127.0.0.1", time.Now().Format(time.RFC3339))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
isBanned, err := d.IsBanned("127.0.0.1")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isBanned {
|
|
||||||
t.Fatal("should be banned")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAddBan(t *testing.T) {
|
|
||||||
d := createTestDBStruct(t)
|
|
||||||
|
|
||||||
err := d.CreateTable()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = d.AddBan("127.0.0.1", "7h")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var ip string
|
|
||||||
err = d.db.QueryRow("SELECT ip FROM bans WHERE ip = ?", "127.0.0.1").Scan(&ip)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ip != "127.0.0.1" {
|
|
||||||
t.Fatal("ip should be 127.0.0.1")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBanList(t *testing.T) {
|
|
||||||
d := createTestDBStruct(t)
|
|
||||||
|
|
||||||
err := d.CreateTable()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = d.db.Exec("INSERT INTO bans (ip, banned_at) VALUES (?, ?)", "127.0.0.1", time.Now().Format(time.RFC3339))
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = d.BanList()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestClose(t *testing.T) {
|
|
||||||
d := createTestDBStruct(t)
|
|
||||||
|
|
||||||
err := d.Close()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
||||||
@@ -12,6 +11,14 @@ CREATE TABLE IF NOT EXISTS requests (
|
|||||||
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,
|
||||||
@@ -20,9 +27,5 @@ CREATE TABLE IF NOT EXISTS bans (
|
|||||||
expired_at DATETIME
|
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);
|
|
||||||
`
|
|
||||||
|
|||||||
69
internal/storage/requests_db.go
Normal file
69
internal/storage/requests_db.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RequestWriter struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestsWr() (*RequestWriter, error) {
|
||||||
|
db, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
buildSqliteDsn(ReqDBPath, pragmas),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
return &RequestWriter{
|
||||||
|
logger: logger.New(false),
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type RequestReader struct {
|
||||||
|
logger *logger.Logger
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRequestsRd() (*RequestReader, error) {
|
||||||
|
db, err := sql.Open(
|
||||||
|
"sqlite",
|
||||||
|
buildSqliteDsn(ReqDBPath, pragmas),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
return &RequestReader{
|
||||||
|
logger: logger.New(false),
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RequestReader) IsMaxRetryExceeded(ip string, maxRetry int) (bool, error) {
|
||||||
|
var count int
|
||||||
|
if maxRetry == 0 {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
err := r.db.QueryRow("SELECT COUNT(*) FROM requests WHERE ip = ?", ip).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
r.logger.Error("error query count: " + err.Error())
|
||||||
|
metrics.IncError()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
r.logger.Info("Current request count for IP", "ip", ip, "count", count, "maxRetry", maxRetry)
|
||||||
|
metrics.IncDBOperation("select", "requests")
|
||||||
|
return count >= maxRetry, nil
|
||||||
|
}
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
package storage
|
package storage
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Write(db *DB, resultCh <-chan *LogEntry) {
|
func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
|
||||||
db.logger.Info("Starting log writer")
|
db.logger.Info("Starting log writer")
|
||||||
const batchSize = 100
|
const batchSize = 100
|
||||||
const flushInterval = 1 * time.Second
|
const flushInterval = 1 * time.Second
|
||||||
@@ -14,31 +19,36 @@ func Write(db *DB, resultCh <-chan *LogEntry) {
|
|||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
|
||||||
flush := func() {
|
flush := func() {
|
||||||
|
defer db.logger.Debug("Flushed batch", "count", len(batch))
|
||||||
|
err := func() (err error) {
|
||||||
if len(batch) == 0 {
|
if len(batch) == 0 {
|
||||||
return
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
tx, err := db.db.Begin()
|
tx, err := db.db.Begin()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.logger.Error("Failed to begin transaction", "error", err)
|
return fmt.Errorf("failed to begin transaction: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
if rollbackErr := tx.Rollback(); rollbackErr != nil &&
|
||||||
|
!errors.Is(rollbackErr, sql.ErrTxDone) {
|
||||||
|
err = errors.Join(
|
||||||
|
err,
|
||||||
|
fmt.Errorf("failed to rollback transaction: %w", rollbackErr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
stmt, err := tx.Prepare(
|
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 (?, ?, ?, ?, ?, ?)",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err := tx.Rollback()
|
err = fmt.Errorf("failed to prepare statement: %w", err)
|
||||||
if err != nil {
|
return err
|
||||||
db.logger.Error("Failed to rollback transaction", "error", err)
|
|
||||||
}
|
|
||||||
db.logger.Error("Failed to prepare statement", "error", err)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
err := stmt.Close()
|
if closeErr := stmt.Close(); closeErr != nil {
|
||||||
if err != nil {
|
err = errors.Join(err, fmt.Errorf("failed to close statement: %w", closeErr))
|
||||||
db.logger.Error("Failed to close statement", "error", err)
|
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -52,17 +62,24 @@ func Write(db *DB, resultCh <-chan *LogEntry) {
|
|||||||
time.Now().Format(time.RFC3339),
|
time.Now().Format(time.RFC3339),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
db.logger.Error("Failed to insert entry", "error", err)
|
db.logger.Error(fmt.Errorf("failed to insert entry: %w", err).Error())
|
||||||
|
metrics.IncError()
|
||||||
|
} else {
|
||||||
|
metrics.IncRequestCount(entry.Service)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
db.logger.Error("Failed to commit transaction", "error", err)
|
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||||
} else {
|
|
||||||
db.logger.Debug("Flushed batch", "count", len(batch))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
batch = batch[:0]
|
batch = batch[:0]
|
||||||
|
metrics.IncDBOperation("insert", "requests")
|
||||||
|
return err
|
||||||
|
}()
|
||||||
|
if err != nil {
|
||||||
|
db.logger.Error(err.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
@@ -83,3 +100,13 @@ func Write(db *DB, resultCh <-chan *LogEntry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *RequestWriter) GetRequestCount() (int, error) {
|
||||||
|
var count int
|
||||||
|
err := w.db.QueryRow("SELECT COUNT(*) FROM requests").Scan(&count)
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RequestWriter) Close() error {
|
||||||
|
return w.db.Close()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,40 +1,301 @@
|
|||||||
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, 100) // ← Добавь буфер
|
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(2 * time.Second)
|
<-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) (*RequestWriter, error) {
|
||||||
|
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetMaxIdleConns(1)
|
||||||
|
db.SetConnMaxLifetime(0)
|
||||||
|
return &RequestWriter{
|
||||||
|
logger: logger.New(false),
|
||||||
|
db: db,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *RequestWriter) CreateTable() error {
|
||||||
|
_, err := w.db.Exec(CreateRequestsTable)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
w.logger.Info("Created requests table")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user