23 Commits

Author SHA1 Message Date
d3m0k1d
c91e552bcd chore:fix with latest tag ver goreleaser
Some checks failed
CD - BanForge Release / release (push) Failing after 2m32s
build / build (push) Successful in 2m19s
2026-03-10 00:47:01 +03:00
d3m0k1d
e7bb64d24b chore: test v7 one more time
Some checks failed
build / build (push) Successful in 2m16s
CD - BanForge Release / release (push) Failing after 2m25s
2026-03-10 00:36:08 +03:00
d3m0k1d
7b318bcc40 chore: switch goreleaser vesion one more time
Some checks failed
build / build (push) Successful in 2m23s
CD - BanForge Release / release (push) Has been cancelled
2026-03-09 23:51:03 +03:00
d3m0k1d
1aec91efa2 chore: rollback to goreleaser v6
Some checks failed
build / build (push) Successful in 2m25s
CD - BanForge Release / release (push) Has been cancelled
2026-03-09 23:35:36 +03:00
d3m0k1d
e36cf1861e chore: fix cd pipline for new version goreleaser
Some checks failed
build / build (push) Successful in 2m18s
CD - BanForge Release / release (push) Failing after 2m33s
2026-03-09 23:22:49 +03:00
d3m0k1d
69c3befa48 update libs
All checks were successful
build / build (push) Successful in 3m59s
2026-03-09 22:45:42 +03:00
d3m0k1d
8e3e940bed feat: add new test for config
All checks were successful
build / build (push) Successful in 2m21s
CD - BanForge Release / release (push) Successful in 21m28s
2026-02-25 15:16:46 +03:00
d3m0k1d
eaa03b3869 fix: fix deps
All checks were successful
build / build (push) Successful in 2m23s
2026-02-24 18:08:27 +03:00
d3m0k1d
bbc936ba5d fix: linter
All checks were successful
build / build (push) Successful in 2m23s
2026-02-24 18:05:58 +03:00
d3m0k1d
5cc61aca75 feat: integration actions to judge logic and update docs for this
Some checks failed
build / build (push) Failing after 1m55s
2026-02-24 18:03:17 +03:00
d3m0k1d
fd38af9cb0 feat: release a email action and test for him
All checks were successful
build / build (push) Successful in 2m39s
2026-02-24 17:45:28 +03:00
d3m0k1d
0929b92939 docs: add manpages
All checks were successful
build / build (push) Successful in 2m20s
CD - BanForge Release / release (push) Successful in 4m15s
2026-02-24 15:14:59 +03:00
d3m0k1d
b75541af61 feat: logic for scripts and webhooks
All checks were successful
build / build (push) Successful in 2m18s
2026-02-24 14:41:25 +03:00
d3m0k1d
4e56d7bb6c feat: recode interfaces
All checks were successful
build / build (push) Successful in 2m25s
2026-02-23 23:40:44 +03:00
d3m0k1d
efa9abb289 feat: add actions to rule struct
All checks were successful
build / build (push) Successful in 2m18s
2026-02-23 23:18:43 +03:00
d3m0k1d
2747abfc04 feat: add struct for actions 2026-02-23 23:18:26 +03:00
d3m0k1d
66d460dbfc feat: add simple actions without integration to another code
All checks were successful
build / build (push) Successful in 2m23s
2026-02-23 20:03:40 +03:00
d3m0k1d
783645c30b docs: update readme versions and add prometheus to roadmap
All checks were successful
build / build (push) Successful in 2m24s
2026-02-23 18:29:18 +03:00
d3m0k1d
d9df055765 feat: full working metrics ready
All checks were successful
build / build (push) Successful in 2m21s
CD - BanForge Release / release (push) Successful in 4m3s
2026-02-23 18:03:20 +03:00
d3m0k1d
6897ea8753 feat: new cli command and new logic for rules on dir
All checks were successful
build / build (push) Successful in 2m25s
2026-02-23 17:02:39 +03:00
d3m0k1d
d534fc79d7 feat: logic rules switch from one file to rules.d and refactoring init cli func
All checks were successful
build / build (push) Successful in 2m23s
2026-02-23 00:26:52 +03:00
d3m0k1d
9ad0a3eb12 fix: create db files on init func
All checks were successful
build / build (push) Successful in 2m25s
2026-02-22 23:26:50 +03:00
d3m0k1d
d8712037f4 feat: fix gorutines on sshd and new validators on parsers
All checks were successful
build / build (push) Successful in 2m22s
2026-02-22 23:18:07 +03:00
36 changed files with 3271 additions and 289 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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!"

View File

@@ -22,6 +22,8 @@ If you have any questions or suggestions, create issue on [Github](https://githu
- [x] Rule system - [x] Rule system
- [x] Nginx and Sshd support - [x] Nginx and Sshd support
- [x] Working with ufw/iptables/nftables/firewalld - [x] Working with ufw/iptables/nftables/firewalld
- [x] Prometheus metrics
- [x] Actions (email, webhook, script)
- [ ] Add support for most popular web-service - [ ] Add support for most popular web-service
- [ ] User regexp for custom services - [ ] User regexp for custom services
- [ ] TUI interface - [ ] TUI interface
@@ -40,10 +42,10 @@ In release page you can find rpm, deb, apk packages, for amd or arm architecture
### Debian/Ubuntu(.deb) ### Debian/Ubuntu(.deb)
```bash ```bash
# Download the latest DEB package # Download the latest DEB package
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.deb wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.deb
# Install # Install
sudo dpkg -i banforge_0.4.0_linux_amd64.deb sudo dpkg -i banforge_0.6.0_linux_amd64.deb
# Verify installation # Verify installation
sudo systemctl status banforge sudo systemctl status banforge
@@ -53,13 +55,13 @@ sudo systemctl status banforge
```bash ```bash
# Download # Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.rpm wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.rpm
# Install # Install
sudo rpm -i banforge_0.4.0_linux_amd64.rpm sudo rpm -i banforge_0.6.0_linux_amd64.rpm
# Or with dnf (CentOS 8+, AlmaLinux) # Or with dnf (CentOS 8+, AlmaLinux)
sudo dnf install banforge_0.4.0_linux_amd64.rpm sudo dnf install banforge_0.6.0_linux_amd64.rpm
# Verify # Verify
sudo systemctl status banforge sudo systemctl status banforge
@@ -69,10 +71,10 @@ sudo systemctl status banforge
```bash ```bash
# Download # Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.apk wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.apk
# Install # Install
sudo apk add --allow-untrusted banforge_0.4.0_linux_amd64.apk sudo apk add --allow-untrusted banforge_0.6.0_linux_amd64.apk
# Verify # Verify
sudo rc-service banforge status sudo rc-service banforge status
@@ -82,10 +84,10 @@ sudo rc-service banforge status
```bash ```bash
# Download # Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.pkg.tar.zst wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.6.0/banforge_0.6.0_linux_amd64.pkg.tar.zst
# Install # Install
sudo pacman -U banforge_0.4.0_linux_amd64.pkg.tar.zst sudo pacman -U banforge_0.6.0_linux_amd64.pkg.tar.zst
# Verify # Verify
sudo systemctl status banforge sudo systemctl status banforge
@@ -113,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)

View File

@@ -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"
@@ -60,10 +61,13 @@ 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)
} }
_, err = config.LoadMetricsConfig()
if err != nil { if cfg.Metrics.Enabled {
log.Error("Failed to load metrics config", "error", err) go func() {
os.Exit(1) 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

View File

@@ -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 {

View File

@@ -3,19 +3,22 @@ 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"
) )
var ( var (
name string name string
service string service string
path string path string
status string status string
method string method string
ttl string ttl string
max_retry int maxRetry int
editName string
) )
var RuleCmd = &cobra.Command{ var RuleCmd = &cobra.Command{
@@ -25,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, max_retry) 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)
@@ -51,37 +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\n MaxRetry: %d\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.MaxRetry,
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(&max_retry, "max_retry", "r", 0, "max retry") AddCmd.Flags().IntVarP(&maxRetry, "max_retry", "r", 0, "max retry before ban")
EditCmd.Flags().StringVarP(&editName, "name", "n", "", "rule name to edit (required)")
EditCmd.Flags().StringVarP(&service, "service", "s", "", "new service name")
EditCmd.Flags().StringVarP(&path, "path", "p", "", "new path")
EditCmd.Flags().StringVarP(&status, "status", "c", "", "new status code")
EditCmd.Flags().StringVarP(&method, "method", "m", "", "new HTTP method")
} }

View File

@@ -1,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,12 @@ 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 ### version - Display BanForge version
@@ -21,6 +29,8 @@ banforge version
**Description** **Description**
This command displays the current version of the BanForge software. This command displays the current version of the BanForge software.
---
### daemon - Starts the BanForge daemon process ### daemon - Starts the BanForge daemon process
```shell ```shell
@@ -32,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>
@@ -41,9 +54,22 @@ banforge unban <ip>
**Description** **Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands. These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
Flag -t or -ttl add bantime if not used default ban 1 year | Flag | Description |
| ----------- | ------------------------------ |
| `-t`, `-ttl` | Ban duration (default: 1 year) |
### ports - Open and Close ports on firewall **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 ```shell
banforge open -port <port> -protocol <protocol> banforge open -port <port> -protocol <protocol>
@@ -53,32 +79,148 @@ banforge close -port <port> -protocol <protocol>
**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 | 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 |
| -r -max_retry | - | +------------------+---------+--------+--------+--------+----------+---------+
```
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`

View File

@@ -43,9 +43,129 @@ Example:
max_retry = 3 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. 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
View 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
View 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

7
go.mod
View File

@@ -16,14 +16,13 @@ require (
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.20 // 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/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect golang.org/x/text v0.34.0 // indirect
modernc.org/libc v1.68.0 // indirect modernc.org/libc v1.70.0 // 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
) )

22
go.sum
View File

@@ -19,8 +19,8 @@ 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.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= 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=
@@ -36,15 +36,13 @@ 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-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0 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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
@@ -56,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.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= 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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= 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=

129
internal/actions/email.go Normal file
View 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()
}

View 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())
}

View 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
}

View 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
}

View 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
}

View File

@@ -6,6 +6,7 @@ import (
"strconv" "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 {
@@ -23,20 +24,24 @@ func (f *Firewalld) Ban(ip string) error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncBanAttempt("firewalld")
// #nosec G204 - ip is validated // #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent") 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("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
} }
@@ -45,20 +50,24 @@ func (f *Firewalld) Unban(ip string) error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncUnbanAttempt("firewalld")
// #nosec G204 - ip is validated // #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent") 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("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 return nil
} }
@@ -70,6 +79,7 @@ func (f *Firewalld) PortOpen(port int, protocol string) error {
return fmt.Errorf("invalid protocol") return fmt.Errorf("invalid protocol")
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
cmd := exec.Command( cmd := exec.Command(
"firewall-cmd", "firewall-cmd",
"--zone=public", "--zone=public",
@@ -79,12 +89,14 @@ func (f *Firewalld) PortOpen(port int, protocol string) error {
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 port " + s + " " + string(output)) f.logger.Info("Add port " + s + " " + string(output))
output, err = exec.Command("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))
@@ -99,6 +111,7 @@ func (f *Firewalld) PortClose(port int, protocol string) error {
return fmt.Errorf("invalid protocol") return fmt.Errorf("invalid protocol")
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
cmd := exec.Command( cmd := exec.Command(
"firewall-cmd", "firewall-cmd",
"--zone=public", "--zone=public",
@@ -107,11 +120,13 @@ func (f *Firewalld) PortClose(port int, protocol string) error {
) )
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
metrics.IncError()
return err return err
} }
f.logger.Info("Remove port " + s + " " + string(output)) f.logger.Info("Remove port " + s + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput() output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil { if err != nil {
metrics.IncError()
return err return err
} }
f.logger.Info("Reload " + string(output)) f.logger.Info("Reload " + string(output))

View File

@@ -5,6 +5,7 @@ import (
"strconv" "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 {
@@ -24,6 +25,7 @@ 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
@@ -36,11 +38,13 @@ func (f *Iptables) 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
} }
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 {
@@ -54,6 +58,7 @@ func (f *Iptables) Ban(ip string) error {
"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",
@@ -67,6 +72,7 @@ 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
@@ -79,11 +85,13 @@ func (f *Iptables) Unban(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
} }
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 {
@@ -97,6 +105,7 @@ func (f *Iptables) Unban(ip string) error {
"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",
@@ -112,11 +121,13 @@ func (f *Iptables) PortOpen(port int, protocol string) error {
return nil return nil
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT") cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
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 port " + s + " " + string(output)) f.logger.Info("Add port " + s + " " + string(output))
@@ -128,6 +139,7 @@ func (f *Iptables) PortOpen(port int, protocol string) error {
"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
} }
} }
@@ -141,11 +153,13 @@ func (f *Iptables) PortClose(port int, protocol string) error {
return nil return nil
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT") cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
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 port " + s + " " + string(output)) f.logger.Info("Add port " + s + " " + string(output))
@@ -157,6 +171,7 @@ func (f *Iptables) PortClose(port int, protocol string) error {
"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
} }
} }

View File

@@ -7,6 +7,7 @@ import (
"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 {
@@ -26,6 +27,7 @@ func (n *Nftables) Ban(ip string) error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncBanAttempt("nftables")
// #nosec G204 - ip is validated // #nosec G204 - ip is validated
cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned", cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned",
"ip", "saddr", ip, "drop") "ip", "saddr", ip, "drop")
@@ -35,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
} }
@@ -57,17 +62,20 @@ 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
@@ -80,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
} }
@@ -172,9 +183,11 @@ func (n *Nftables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 { if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol") n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol") return fmt.Errorf("invalid protocol")
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command( cmd := exec.Command(
"nft", "nft",
@@ -191,6 +204,7 @@ func (n *Nftables) PortOpen(port int, protocol string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
n.logger.Error(err.Error()) n.logger.Error(err.Error())
metrics.IncError()
return err return err
} }
n.logger.Info("Add port " + s + " " + string(output)) n.logger.Info("Add port " + s + " " + string(output))
@@ -199,6 +213,7 @@ func (n *Nftables) PortOpen(port int, protocol string) error {
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
} }
} }
@@ -209,9 +224,11 @@ func (n *Nftables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 { if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol") n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol") return fmt.Errorf("invalid protocol")
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command( cmd := exec.Command(
"nft", "nft",
@@ -228,6 +245,7 @@ func (n *Nftables) PortClose(port int, protocol string) error {
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
n.logger.Error(err.Error()) n.logger.Error(err.Error())
metrics.IncError()
return err return err
} }
n.logger.Info("Add port " + s + " " + string(output)) n.logger.Info("Add port " + s + " " + string(output))
@@ -236,6 +254,7 @@ func (n *Nftables) PortClose(port int, protocol string) error {
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
} }

View File

@@ -6,6 +6,7 @@ import (
"strconv" "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 {
@@ -23,6 +24,7 @@ func (u *Ufw) Ban(ip string) error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncBanAttempt("ufw")
// #nosec G204 - ip is validated // #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "deny", "from", ip) cmd := exec.Command("ufw", "--force", "deny", "from", ip)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
@@ -31,10 +33,12 @@ func (u *Ufw) Ban(ip string) error {
"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 {
@@ -42,6 +46,7 @@ func (u *Ufw) Unban(ip string) error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncUnbanAttempt("ufw")
// #nosec G204 - ip is validated // #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "delete", "deny", "from", ip) cmd := exec.Command("ufw", "--force", "delete", "deny", "from", ip)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
@@ -50,10 +55,12 @@ func (u *Ufw) Unban(ip string) error {
"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 return nil
} }
@@ -61,14 +68,17 @@ func (u *Ufw) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 { if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol") u.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol") return fmt.Errorf("invalid protocol")
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "allow", s+"/"+protocol) cmd := exec.Command("ufw", "allow", s+"/"+protocol)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
u.logger.Error(err.Error()) u.logger.Error(err.Error())
metrics.IncError()
return err return err
} }
u.logger.Info("Add port " + s + " " + string(output)) u.logger.Info("Add port " + s + " " + string(output))
@@ -80,14 +90,17 @@ func (u *Ufw) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 { if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" { if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol") u.logger.Error("invalid protocol")
metrics.IncError()
return nil return nil
} }
s := strconv.Itoa(port) s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor // #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "deny", s+"/"+protocol) cmd := exec.Command("ufw", "deny", s+"/"+protocol)
output, err := cmd.CombinedOutput() output, err := cmd.CombinedOutput()
if err != nil { if err != nil {
u.logger.Error(err.Error()) u.logger.Error(err.Error())
metrics.IncError()
return err return err
} }
u.logger.Info("Add port " + s + " " + string(output)) u.logger.Info("Add port " + s + " " + string(output))

View File

@@ -1,146 +1,162 @@
package config package config
import ( import (
"errors"
"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"
"github.com/d3m0k1d/BanForge/internal/metrics"
) )
func LoadMetricsConfig() (*Metrics, error) {
cfg := &Metrics{}
_, err := toml.DecodeFile("/etc/banforge/config.toml", cfg)
if err != nil {
return nil, fmt.Errorf("failed to decode config: %w", err)
}
if cfg.Enabled && cfg.Port > 0 && cfg.Port < 65535 {
go metrics.StartMetricsServer(cfg.Port)
} else if cfg.Enabled {
fmt.Println("Metrics enabled but port invalid, not starting server")
}
return cfg, nil
}
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,
max_retry int, 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,
Path: path,
Status: status,
Method: method,
BanTime: ttl,
MaxRetry: maxRetry,
} }
r = append(
r, filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
Rule{
Name: Name, if _, err := os.Stat(filePath); err == nil {
ServiceName: ServiceName, return fmt.Errorf("rule with name '%s' already exists", name)
Path: Path, }
Status: Status,
Method: Method, cfg := Rules{Rules: []Rule{rule}}
BanTime: ttl,
MaxRetry: max_retry, // #nosec G304 - validate by sanitizeRuleFilename
}, file, err := os.Create(filePath)
)
file, err := os.Create("/etc/banforge/rules.toml")
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create rule file: %w", err)
} }
defer func() { defer func() {
err = errors.Join(err, file.Close()) if closeErr := file.Close(); closeErr != nil {
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
}
}() }()
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 ss, ok := strings.CutSuffix(s, "y"); ok { if ss, ok := strings.CutSuffix(s, "y"); ok {
years, err := strconv.Atoi(ss) years, err := strconv.Atoi(ss)

View 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)
}
}

View File

@@ -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)
}
}()
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
} }
fmt.Printf(" Rules file created: %s\n", configPath) fmt.Printf("Bans database file created: %s\n", bansDBPath)
reqDBPath := "/var/lib/banforge/requests.db"
if err := createFileWithPermissions(reqDBPath, 0600); err != nil {
return fmt.Errorf("failed to create requests database file: %w", err)
}
fmt.Printf("Requests database file created: %s\n", reqDBPath)
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)
} }

View File

@@ -24,16 +24,37 @@ type Rules struct {
} }
type Rule struct { type Rule struct {
Name string `toml:"name"` Name string `toml:"name"`
ServiceName string `toml:"service"` ServiceName string `toml:"service"`
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"` MaxRetry int `toml:"max_retry"`
BanTime string `toml:"ban_time"` BanTime string `toml:"ban_time"`
Action []Action `toml:"action"`
} }
type Metrics struct { type Metrics struct {
Enabled bool `toml:"enabled"` Enabled bool `toml:"enabled"`
Port int `toml:"port"` 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"`
}

View File

@@ -5,9 +5,11 @@ 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"
) )
@@ -85,19 +87,23 @@ func (j *Judge) Tribunal() {
banned, err := j.db_r.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)
metrics.IncLogParsed()
break break
} }
exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry) exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry)
if err != nil { if err != nil {
j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err) j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err)
metrics.IncError()
break break
} }
if !exceeded { if !exceeded {
j.logger.Info("Max retry not exceeded", "ip", entry.IP) j.logger.Info("Max retry not exceeded", "ip", entry.IP)
metrics.IncLogParsed()
break break
} }
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name) err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
@@ -116,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",
@@ -127,6 +145,7 @@ func (j *Judge) Tribunal() {
"ban_time", "ban_time",
rule.BanTime, rule.BanTime,
) )
metrics.IncBan(rule.ServiceName)
break break
} }
} }
@@ -147,12 +166,16 @@ func (j *Judge) UnbanChecker() {
ips, err := j.db_w.RemoveExpiredBans() 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 {
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 at firewall: %v", err)) j.logger.Error(fmt.Sprintf("Failed to unban IP at firewall: %v", err))
metrics.IncError()
} else {
metrics.IncUnban("judge")
} }
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"log" "log"
"net/http" "net/http"
"strconv"
"sync" "sync"
"time" "time"
) )
@@ -39,6 +40,58 @@ func IncLogParsed() {
metricsMu.Unlock() 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 { func MetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metricsMu.RLock() metricsMu.RLock()
@@ -58,12 +111,12 @@ func MetricsHandler() http.Handler {
}) })
} }
func StartMetricsServer(port int) { func StartMetricsServer(port int) error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/metrics", MetricsHandler()) mux.Handle("/metrics", MetricsHandler())
server := &http.Server{ server := &http.Server{
Addr: fmt.Sprintf(":%d", port), Addr: "localhost:" + strconv.Itoa(port),
Handler: mux, Handler: mux,
ReadTimeout: 5 * time.Second, ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second,
@@ -72,6 +125,7 @@ func StartMetricsServer(port int) {
log.Printf("Starting metrics server on %s", server.Addr) log.Printf("Starting metrics server on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Printf("Metrics server error: %v", err) return fmt.Errorf("metrics server failed: %w", err)
} }
return nil
} }

View File

@@ -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"
) )
@@ -50,6 +51,7 @@ func (p *ApacheParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogE
Status: status, Status: status,
Method: method, Method: method,
} }
metrics.IncParserEvent("apache")
p.logger.Info( p.logger.Info(
"Parsed apache log entry", "Parsed apache log entry",
"ip", matches[1], "ip", matches[1],

View File

@@ -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",

View File

@@ -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,8 +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) {
// #nosec G204 - managed by system adminstartor 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 {
@@ -47,7 +100,11 @@ func NewScannerTail(path string) (*Scanner, error) {
} }
func NewScannerJournald(unit string) (*Scanner, error) { func NewScannerJournald(unit string) (*Scanner, error) {
// #nosec G204 - managed by system adminstartor 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 {
@@ -81,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(),
} }
@@ -88,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
} }
} }

View File

@@ -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)
}
})
}
}

View File

@@ -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,30 +25,29 @@ 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 { continue
continue
}
resultCh <- &storage.LogEntry{
Service: "ssh",
IP: matches[6],
Path: matches[5], // user
Status: "Failed",
Method: matches[4], // method auth
}
p.logger.Info(
"Parsed ssh log entry",
"ip",
matches[6],
"user",
matches[5],
"method",
matches[4],
"status",
"Failed",
)
} }
}() resultCh <- &storage.LogEntry{
Service: "ssh",
IP: matches[6],
Path: matches[5], // user
Status: "Failed",
Method: matches[4], // method auth
}
metrics.IncParserEvent("ssh")
p.logger.Info(
"Parsed ssh log entry",
"ip",
matches[6],
"user",
matches[5],
"method",
matches[4],
"status",
"Failed",
)
}
} }

View File

@@ -8,6 +8,7 @@ import (
"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/jedib0t/go-pretty/v6/table" "github.com/jedib0t/go-pretty/v6/table"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -37,6 +38,7 @@ func (d *BanWriter) CreateTable() error {
if err != nil { if err != nil {
return err return err
} }
metrics.IncDBOperation("create_table", "bans")
d.logger.Info("Created tables") d.logger.Info("Created tables")
return nil return nil
} }
@@ -45,6 +47,7 @@ func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
duration, err := config.ParseDurationWithYears(ttl) duration, err := config.ParseDurationWithYears(ttl)
if err != nil { if err != nil {
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err) d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
metrics.IncError()
return fmt.Errorf("invalid duration: %w", err) return fmt.Errorf("invalid duration: %w", err)
} }
@@ -60,9 +63,11 @@ func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
) )
if err != nil { if err != nil {
d.logger.Error("Failed to add ban", "error", err) d.logger.Error("Failed to add ban", "error", err)
metrics.IncError()
return err return err
} }
metrics.IncDBOperation("insert", "bans")
return nil return nil
} }
@@ -70,8 +75,10 @@ func (d *BanWriter) RemoveBan(ip string) error {
_, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip) _, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
if err != nil { if err != nil {
d.logger.Error("Failed to remove ban", "error", err) d.logger.Error("Failed to remove ban", "error", err)
metrics.IncError()
return err return err
} }
metrics.IncDBOperation("delete", "bans")
return nil return nil
} }
@@ -85,6 +92,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
) )
if err != nil { if err != nil {
w.logger.Error("Failed to get expired bans", "error", err) w.logger.Error("Failed to get expired bans", "error", err)
metrics.IncError()
return nil, err return nil, err
} }
defer func() { defer func() {
@@ -113,6 +121,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
) )
if err != nil { if err != nil {
w.logger.Error("Failed to remove expired bans", "error", err) w.logger.Error("Failed to remove expired bans", "error", err)
metrics.IncError()
return nil, err return nil, err
} }
@@ -123,6 +132,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
if rowsAffected > 0 { if rowsAffected > 0 {
w.logger.Info("Removed expired bans", "count", rowsAffected, "ips", len(ips)) w.logger.Info("Removed expired bans", "count", rowsAffected, "ips", len(ips))
metrics.IncDBOperation("delete_expired", "bans")
} }
return ips, nil return ips, nil
@@ -169,8 +179,10 @@ func (d *BanReader) IsBanned(ip string) (bool, error) {
return false, nil return false, nil
} }
if err != nil { if err != nil {
metrics.IncError()
return false, fmt.Errorf("failed to check ban status: %w", err) return false, fmt.Errorf("failed to check ban status: %w", err)
} }
metrics.IncDBOperation("select", "bans")
return true, nil return true, nil
} }
@@ -183,6 +195,7 @@ func (d *BanReader) BanList() error {
rows, err := d.db.Query("SELECT ip, banned_at, reason, expired_at FROM bans") rows, err := d.db.Query("SELECT ip, banned_at, reason, expired_at FROM bans")
if err != nil { if err != nil {
d.logger.Error("Failed to get ban list", "error", err) d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err return err
} }
for rows.Next() { for rows.Next() {
@@ -194,12 +207,14 @@ func (d *BanReader) BanList() error {
err := rows.Scan(&ip, &bannedAt, &reason, &expiredAt) err := rows.Scan(&ip, &bannedAt, &reason, &expiredAt)
if err != nil { if err != nil {
d.logger.Error("Failed to get ban list", "error", err) d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err return err
} }
t.AppendRow(table.Row{count, ip, bannedAt, reason, expiredAt}) t.AppendRow(table.Row{count, ip, bannedAt, reason, expiredAt})
} }
t.Render() t.Render()
metrics.IncDBOperation("select", "bans")
return nil return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"database/sql" "database/sql"
"github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
) )
@@ -59,8 +60,10 @@ func (r *RequestReader) IsMaxRetryExceeded(ip string, maxRetry int) (bool, error
err := r.db.QueryRow("SELECT COUNT(*) FROM requests WHERE ip = ?", ip).Scan(&count) err := r.db.QueryRow("SELECT COUNT(*) FROM requests WHERE ip = ?", ip).Scan(&count)
if err != nil { if err != nil {
r.logger.Error("error query count: " + err.Error()) r.logger.Error("error query count: " + err.Error())
metrics.IncError()
return false, err return false, err
} }
r.logger.Info("Current request count for IP", "ip", ip, "count", count, "maxRetry", maxRetry) r.logger.Info("Current request count for IP", "ip", ip, "count", count, "maxRetry", maxRetry)
metrics.IncDBOperation("select", "requests")
return count >= maxRetry, nil return count >= maxRetry, nil
} }

View File

@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"time" "time"
"github.com/d3m0k1d/BanForge/internal/metrics"
) )
func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) { func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
@@ -61,6 +63,9 @@ func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
) )
if err != nil { if err != nil {
db.logger.Error(fmt.Errorf("failed to insert entry: %w", err).Error()) db.logger.Error(fmt.Errorf("failed to insert entry: %w", err).Error())
metrics.IncError()
} else {
metrics.IncRequestCount(entry.Service)
} }
} }
@@ -69,6 +74,7 @@ func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
} }
batch = batch[:0] batch = batch[:0]
metrics.IncDBOperation("insert", "requests")
return err return err
}() }()
if err != nil { if err != nil {