22 Commits

Author SHA1 Message Date
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
d3m0k1d
aef2647a82 chore: fix deps
All checks were successful
build / build (push) Successful in 2m28s
CD - BanForge Release / release (push) Successful in 4m10s
2026-02-22 19:52:22 +03:00
d3m0k1d
c3b6708a98 fix: gofmt
Some checks failed
build / build (push) Has been cancelled
2026-02-22 19:48:37 +03:00
d3m0k1d
3acd0b899c fix: linter run and gosec fix
Some checks failed
build / build (push) Failing after 1m52s
2026-02-22 19:45:59 +03:00
d3m0k1d
3ac1250bfc feat: add metrics support 2026-02-22 19:45:47 +03:00
d3m0k1d
7bba444522 feat: upgrade max_retry logic and change version
All checks were successful
build / build (push) Successful in 2m9s
2026-02-22 18:27:21 +03:00
37 changed files with 3352 additions and 269 deletions

View File

@@ -28,7 +28,7 @@ builds:
env:
- CGO_ENABLED=0
archives:
- format: tar.gz
- formats: [tar.gz]
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
nfpms:
@@ -48,6 +48,15 @@ nfpms:
scripts:
postinstall: build/postinstall.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:
gitea:
owner: d3m0k1d
@@ -60,7 +69,6 @@ changelog:
exclude:
- "^docs:"
- "^test:"
checksum:
name_template: "{{ .ProjectName }}_{{ .Version }}_checksums.txt"
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:
@echo "BanForge build targets:"
@@ -6,7 +6,9 @@ help:
@echo " make build-daemon - Build only daemon"
@echo " make build-tui - Build only TUI"
@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
@echo "✅ Build complete!"
@@ -31,3 +33,16 @@ test-cover:
lint:
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] Nginx and Sshd support
- [x] Working with ufw/iptables/nftables/firewalld
- [x] Prometheus metrics
- [x] Actions (email, webhook, script)
- [ ] Add support for most popular web-service
- [ ] User regexp for custom services
- [ ] TUI interface
@@ -40,10 +42,10 @@ In release page you can find rpm, deb, apk packages, for amd or arm architecture
### Debian/Ubuntu(.deb)
```bash
# 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
sudo dpkg -i banforge_0.4.0_linux_amd64.deb
sudo dpkg -i banforge_0.6.0_linux_amd64.deb
# Verify installation
sudo systemctl status banforge
@@ -53,13 +55,13 @@ sudo systemctl status banforge
```bash
# 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
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)
sudo dnf install banforge_0.4.0_linux_amd64.rpm
sudo dnf install banforge_0.6.0_linux_amd64.rpm
# Verify
sudo systemctl status banforge
@@ -69,10 +71,10 @@ sudo systemctl status banforge
```bash
# 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
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
sudo rc-service banforge status
@@ -82,10 +84,10 @@ sudo rc-service banforge status
```bash
# 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
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
sudo systemctl status banforge
@@ -111,10 +113,20 @@ For first steps use this commands
banforge init # Create config files and database
banforge daemon # Start BanForge daemon (use systemd or another init system to create a service)
```
You can edit the config file with examples in
You can edit the config file with examples in
- `/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).
## 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
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/judge"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/parser"
"github.com/d3m0k1d/BanForge/internal/storage"
"github.com/spf13/cobra"
@@ -60,6 +61,14 @@ var DaemonCmd = &cobra.Command{
log.Error("Failed to load config", "error", err)
os.Exit(1)
}
if cfg.Metrics.Enabled {
go func() {
if err := metrics.StartMetricsServer(cfg.Metrics.Port); err != nil {
log.Error("Failed to start metrics server", "error", err)
}
}()
}
var b blocker.BlockerEngine
fw := cfg.Firewall.Name
b = blocker.GetBlocker(fw, cfg.Firewall.Config)

View File

@@ -16,53 +16,11 @@ var InitCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Initializing BanForge...")
if _, err := os.Stat("/var/log/banforge"); err == nil {
fmt.Println("/var/log/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/log/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/log/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/var/lib/banforge"); err == nil {
fmt.Println("/var/lib/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/var/lib/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /var/lib/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
if _, err := os.Stat("/etc/banforge"); err == nil {
fmt.Println("/etc/banforge already exists, skipping...")
} else if os.IsNotExist(err) {
err := os.Mkdir("/etc/banforge", 0750)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Created /etc/banforge")
} else {
fmt.Println(err)
os.Exit(1)
}
err := config.CreateConf()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("Config created")
err = config.FindFirewall()
if err != nil {

View File

@@ -3,18 +3,22 @@ package command
import (
"fmt"
"os"
"path/filepath"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/jedib0t/go-pretty/v6/table"
"github.com/spf13/cobra"
)
var (
name string
service string
path string
status string
method string
ttl string
name string
service string
path string
status string
method string
ttl string
maxRetry int
editName string
)
var RuleCmd = &cobra.Command{
@@ -24,24 +28,25 @@ var RuleCmd = &cobra.Command{
var AddCmd = &cobra.Command{
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) {
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)
}
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)
}
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)
}
if ttl == "" {
ttl = "1y"
}
err := config.NewRule(name, service, path, status, method, ttl)
err := config.NewRule(name, service, path, status, method, ttl, maxRetry)
if err != nil {
fmt.Println(err)
os.Exit(1)
@@ -50,36 +55,103 @@ var AddCmd = &cobra.Command{
},
}
var ListCmd = &cobra.Command{
Use: "list",
Short: "List rules",
var EditCmd = &cobra.Command{
Use: "edit",
Short: "Edit an existing rule",
Long: "Edit rule fields by name. Only specified fields will be updated.",
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 {
fmt.Println(err)
os.Exit(1)
}
for _, rule := range r {
fmt.Printf(
"Name: %s\nService: %s\nPath: %s\nStatus: %s\n MaxRetry: %d\nMethod: %s\n\n",
fmt.Println("Rule updated successfully!")
},
}
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.ServiceName,
rule.Path,
rule.Status,
rule.MaxRetry,
rule.Method,
)
rule.MaxRetry,
rule.BanTime,
})
}
t.Render()
},
}
func RuleRegister() {
RuleCmd.AddCommand(AddCmd)
RuleCmd.AddCommand(EditCmd)
RuleCmd.AddCommand(RemoveCmd)
RuleCmd.AddCommand(ListCmd)
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(&status, "status", "c", "", "status code")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time")
AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method")
AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time (e.g., 1h, 1d, 1y)")
AddCmd.Flags().IntVarP(&maxRetry, "max_retry", "r", 0, "max retry before ban")
EditCmd.Flags().StringVarP(&editName, "name", "n", "", "rule name to edit (required)")
EditCmd.Flags().StringVarP(&service, "service", "s", "", "new service name")
EditCmd.Flags().StringVarP(&path, "path", "p", "", "new path")
EditCmd.Flags().StringVarP(&status, "status", "c", "", "new status code")
EditCmd.Flags().StringVarP(&method, "method", "m", "", "new HTTP method")
}

View File

@@ -6,7 +6,7 @@ import (
"github.com/spf13/cobra"
)
var version = "0.5.0"
var version = "0.5.2"
var VersionCmd = &cobra.Command{
Use: "version",

View File

@@ -1,16 +1,24 @@
# 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.
## Commands
### init - create a deps file
### init - Create configuration files
```shell
banforge init
```
**Description**
This command creates the necessary directories and base configuration files
required for the daemon to operate.
**Description**
This command creates the necessary directories and base configuration files
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
@@ -18,66 +26,201 @@ required for the daemon to operate.
banforge version
```
**Description**
**Description**
This command displays the current version of the BanForge software.
---
### daemon - Starts the BanForge daemon process
```shell
banforge daemon
```
**Description**
This command starts the BanForge daemon process in the background.
The daemon continuously monitors incoming requests, detects anomalies,
**Description**
This command starts the BanForge daemon process in the background.
The daemon continuously monitors incoming requests, detects anomalies,
and applies firewall rules in real-time.
---
### firewall - Manages firewall rules
```shell
banforge ban <ip>
banforge unban <ip>
```
**Description**
**Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
Flag -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
banforge open -port <port> -protocol <protocol>
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.
### 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
banforge list
```
**Description**
This command output table of IP addresses that are currently blocked
**Description**
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 rule.name -c 403
banforge rule list
banforge rule add -n <name> -s <service> [options]
```
**Description**
These command help you to create and manage detection rules in CLI interface.
**Flags:**
| Flag | Required |
| ----------- | -------- |
| -n -name | + |
| -s -service | + |
| -p -path | - |
| -m -method | - |
| -c -status | - |
| -t -ttl | -(if not used default ban 1 year) |
| 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) |
You must specify at least 1 of the optional flags to create a rule.
**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
banforge rule list
```
**Description**
Displays all configured rules in a table format.
**Example output:**
```
+------------------+---------+--------+--------+--------+----------+---------+
| NAME | SERVICE | PATH | STATUS | METHOD | MAXRETRY | BANTIME |
+------------------+---------+--------+--------+--------+----------+---------+
| SSH Bruteforce | ssh | | Failed | | 5 | 1h |
| Nginx 404 | nginx | | 404 | | 3 | 30m |
| Admin Panel | nginx | /admin | | | 2 | 2h |
+------------------+---------+--------+--------+--------+----------+---------+
```
---
#### 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,8 +43,129 @@ Example:
max_retry = 3
method = ""
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**
The [[rule]] section require name and one of the following parameters: service, path, status, method. To add a rule, create a [[rule]] block and specify the parameters.
ban_time require in format "1m", "1h", "1d", "1M", "1y".
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*")
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*").
If max_retry = 0 ban on first request.
## Actions
Actions are executed after a successful IP ban. You can configure multiple actions per rule.
### Action Types
#### 1. Email Notification
Send email alerts when an IP is banned.
```toml
[[rule.action]]
type = "email"
enabled = true
email = "admin@example.com"
email_sender = "banforge@example.com"
email_subject = "BanForge Alert"
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "user@example.com"
smtp_password = "password"
smtp_tls = true
body = "IP {ip} has been banned"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "email" |
| `enabled` | + | Enable/disable this action |
| `email` | + | Recipient email address |
| `email_sender` | + | Sender email address |
| `email_subject` | - | Email subject (default: "BanForge Alert") |
| `smtp_host` | + | SMTP server host |
| `smtp_port` | + | SMTP server port |
| `smtp_user` | + | SMTP username |
| `smtp_password` | + | SMTP password |
| `smtp_tls` | - | Use TLS connection (default: false) |
| `body` | - | Email body text |
#### 2. Webhook Notification
Send HTTP webhook requests when an IP is banned.
```toml
[[rule.action]]
type = "webhook"
enabled = true
url = "https://hooks.example.com/alert"
method = "POST"
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\"}"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "webhook" |
| `enabled` | + | Enable/disable this action |
| `url` | + | Webhook URL |
| `method` | - | HTTP method (default: "POST") |
| `headers` | - | HTTP headers as key-value pairs |
| `body` | - | Request body (supports variables) |
#### 3. Script Execution
Execute a custom script when an IP is banned.
```toml
[[rule.action]]
type = "script"
enabled = true
script = "/usr/local/bin/notify.sh"
interpretator = "bash"
```
| Field | Required | Description |
|-------|----------|-------------|
| `type` | + | Must be "script" |
| `enabled` | + | Enable/disable this action |
| `script` | + | Path to script file |
| `interpretator` | - | Script interpretator (e.g., "bash", "python"). If empty, script runs directly |
### Variables
The following variables can be used in `body` fields (email, webhook):
| Variable | Description |
|----------|-------------|
| `{ip}` | Banned IP address |
| `{rule}` | Rule name that triggered the ban |
| `{service}` | Service name |
| `{ban_time}` | Ban duration |

251
docs/man/banforge.1 Normal file
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

1
go.mod
View File

@@ -19,7 +19,6 @@ require (
github.com/mattn/go-runewidth v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
golang.org/x/sys v0.41.0 // indirect

27
go.sum
View File

@@ -19,8 +19,6 @@ 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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -29,9 +27,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -41,27 +36,19 @@ 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/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/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.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
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/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
@@ -69,20 +56,16 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
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/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI=
modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
@@ -93,8 +76,6 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.44.3 h1:+39JvV/HWMcYslAwRxHb8067w+2zowvFOUrOWIy9PjY=
modernc.org/sqlite v1.44.3/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=

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

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Iptables struct {
@@ -24,6 +25,7 @@ func (f *Iptables) Ban(ip string) error {
if err != nil {
return err
}
metrics.IncBanAttempt("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
@@ -36,11 +38,13 @@ func (f *Iptables) Ban(ip string) error {
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
f.logger.Info("IP banned",
"ip", ip,
"output", string(output))
metrics.IncBan("iptables")
err = validateConfigPath(f.config)
if err != nil {
@@ -54,6 +58,7 @@ func (f *Iptables) Ban(ip string) error {
"config_path", f.config,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
f.logger.Info("config saved",
@@ -67,6 +72,7 @@ func (f *Iptables) Unban(ip string) error {
if err != nil {
return err
}
metrics.IncUnbanAttempt("iptables")
err = validateConfigPath(f.config)
if err != nil {
return err
@@ -79,11 +85,13 @@ func (f *Iptables) Unban(ip string) error {
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
f.logger.Info("IP unbanned",
"ip", ip,
"output", string(output))
metrics.IncUnban("iptables")
err = validateConfigPath(f.config)
if err != nil {
@@ -97,6 +105,7 @@ func (f *Iptables) Unban(ip string) error {
"config_path", f.config,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
f.logger.Info("config saved",
@@ -112,11 +121,13 @@ func (f *Iptables) PortOpen(port int, protocol string) error {
return nil
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add port " + s + " " + string(output))
@@ -128,6 +139,7 @@ func (f *Iptables) PortOpen(port int, protocol string) error {
"config_path", f.config,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
}
@@ -141,11 +153,13 @@ func (f *Iptables) PortClose(port int, protocol string) error {
return nil
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
metrics.IncError()
return err
}
f.logger.Info("Add port " + s + " " + string(output))
@@ -157,6 +171,7 @@ func (f *Iptables) PortClose(port int, protocol string) error {
"config_path", f.config,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
}

View File

@@ -7,6 +7,7 @@ import (
"strings"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Nftables struct {
@@ -26,6 +27,7 @@ func (n *Nftables) Ban(ip string) error {
if err != nil {
return err
}
metrics.IncBanAttempt("nftables")
// #nosec G204 - ip is validated
cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned",
"ip", "saddr", ip, "drop")
@@ -35,16 +37,19 @@ func (n *Nftables) Ban(ip string) error {
"ip", ip,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
n.logger.Info("IP banned", "ip", ip)
metrics.IncBan("nftables")
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
@@ -57,17 +62,20 @@ func (n *Nftables) Unban(ip string) error {
if err != nil {
return err
}
metrics.IncUnbanAttempt("nftables")
handle, err := n.findRuleHandle(ip)
if err != nil {
n.logger.Error("failed to find rule handle",
"ip", ip,
"error", err.Error())
metrics.IncError()
return err
}
if handle == "" {
n.logger.Warn("no rule found for IP", "ip", ip)
metrics.IncError()
return fmt.Errorf("no rule found for IP %s", ip)
}
// #nosec G204 - handle is extracted from nftables output and validated
@@ -80,16 +88,19 @@ func (n *Nftables) Unban(ip string) error {
"handle", handle,
"error", err.Error(),
"output", string(output))
metrics.IncError()
return err
}
n.logger.Info("IP unbanned", "ip", ip, "handle", handle)
metrics.IncUnban("nftables")
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
@@ -172,9 +183,11 @@ func (n *Nftables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("open", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
@@ -191,6 +204,7 @@ func (n *Nftables) PortOpen(port int, protocol string) error {
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
metrics.IncError()
return err
}
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",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}
}
@@ -209,9 +224,11 @@ func (n *Nftables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
metrics.IncError()
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
metrics.IncPortOperation("close", protocol)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
@@ -228,6 +245,7 @@ func (n *Nftables) PortClose(port int, protocol string) error {
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
metrics.IncError()
return err
}
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",
"config_path", n.config,
"error", err.Error())
metrics.IncError()
return err
}

View File

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

View File

@@ -1,127 +1,162 @@
package config
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/BurntSushi/toml"
"github.com/d3m0k1d/BanForge/internal/logger"
)
func LoadRuleConfig() ([]Rule, error) {
log := logger.New(false)
const rulesDir = "/etc/banforge/rules.d"
var cfg Rules
_, err := toml.DecodeFile("/etc/banforge/rules.toml", &cfg)
files, err := os.ReadDir(rulesDir)
if err != nil {
log.Error(fmt.Sprintf("failed to decode config: %v", err))
return nil, err
return nil, fmt.Errorf("failed to read rules directory: %w", 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
}
func NewRule(
Name string,
ServiceName string,
Path string,
Status string,
Method string,
name string,
serviceName string,
path string,
status string,
method string,
ttl string,
maxRetry int,
) error {
r, err := LoadRuleConfig()
if err != nil {
r = []Rule{}
if name == "" {
return fmt.Errorf("rule name can't be empty")
}
if Name == "" {
fmt.Printf("Rule name can't be empty\n")
return nil
rule := Rule{
Name: name,
ServiceName: serviceName,
Path: path,
Status: status,
Method: method,
BanTime: ttl,
MaxRetry: maxRetry,
}
r = append(
r,
Rule{
Name: Name,
ServiceName: ServiceName,
Path: Path,
Status: Status,
Method: Method,
BanTime: ttl,
},
)
file, err := os.Create("/etc/banforge/rules.toml")
filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml")
if _, err := os.Stat(filePath); err == nil {
return fmt.Errorf("rule with name '%s' already exists", name)
}
cfg := Rules{Rules: []Rule{rule}}
// #nosec G304 - validate by sanitizeRuleFilename
file, err := os.Create(filePath)
if err != nil {
return err
return fmt.Errorf("failed to create rule file: %w", err)
}
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 != nil {
return err
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
return fmt.Errorf("failed to encode rule: %w", err)
}
return nil
}
func EditRule(Name string, ServiceName string, Path string, Status string, Method string) error {
if Name == "" {
return fmt.Errorf("Rule name can't be empty")
func EditRule(name string, serviceName string, path string, status string, method string) error {
if name == "" {
return fmt.Errorf("rule name can't be empty")
}
r, err := LoadRuleConfig()
rules, err := LoadRuleConfig()
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
for i, rule := range r {
if rule.Name == Name {
var updatedRule *Rule
for i, rule := range rules {
if rule.Name == name {
found = true
updatedRule = &rules[i]
if ServiceName != "" {
r[i].ServiceName = ServiceName
if serviceName != "" {
updatedRule.ServiceName = serviceName
}
if Path != "" {
r[i].Path = Path
if path != "" {
updatedRule.Path = path
}
if Status != "" {
r[i].Status = Status
if status != "" {
updatedRule.Status = status
}
if Method != "" {
r[i].Method = Method
if method != "" {
updatedRule.Method = method
}
break
}
}
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 {
return err
return fmt.Errorf("failed to update rule file: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
if closeErr := file.Close(); closeErr != nil {
fmt.Printf("warning: failed to close rule file: %v\n", closeErr)
}
}()
cfg := Rules{Rules: r}
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
}
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) {
if ss, ok := strings.CutSuffix(s, "y"); ok {
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"
)
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 {
if os.Geteuid() != 0 {
return fmt.Errorf("you must be root to run this command, use sudo/doas")
@@ -28,53 +46,49 @@ func CreateConf() error {
return nil
}
file, err := os.Create("/etc/banforge/config.toml")
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
if err := os.MkdirAll(ConfigDir, 0750); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
}
err = os.WriteFile(configPath, []byte(Base_config), 0600)
if err != nil {
if err := os.WriteFile(configPath, []byte(Base_config), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
fmt.Printf(" Config file created: %s\n", configPath)
file, err = os.Create("/etc/banforge/rules.toml")
if err != nil {
return fmt.Errorf("failed to create rules file: %w", err)
fmt.Printf("Config file created: %s\n", configPath)
rulesDir := filepath.Join(ConfigDir, "rules.d")
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")
if err != nil {
return fmt.Errorf("failed to create database file: %w", err)
fmt.Printf("Rules directory created: %s\n", rulesDir)
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 {
return fmt.Errorf("failed to set permissions: %w", err)
reqDBDir := filepath.Dir("/var/lib/banforge/requests.db")
if err := os.MkdirAll(reqDBDir, 0750); err != nil {
return fmt.Errorf("failed to create requests database directory: %w", err)
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
}()
if err := os.Chmod(configPath, 0600); err != nil {
return fmt.Errorf("failed to set permissions: %w", err)
bansDBPath := "/var/lib/banforge/bans.db"
if err := createFileWithPermissions(bansDBPath, 0600); err != nil {
return fmt.Errorf("failed to create bans database file: %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
}
func FindFirewall() error {
if os.Getegid() != 0 {
fmt.Printf("Firewall settings needs sudo privileges\n")
os.Exit(1)
if os.Geteuid() != 0 {
return fmt.Errorf("firewall settings needs sudo privileges")
}
firewalls := []string{"nft", "firewall-cmd", "iptables", "ufw"}
@@ -107,10 +121,7 @@ func FindFirewall() error {
encoder := toml.NewEncoder(file)
if err := encoder.Encode(cfg); err != nil {
err = file.Close()
if err != nil {
return fmt.Errorf("failed to close file: %w", err)
}
_ = file.Close()
return fmt.Errorf("failed to encode config: %w", err)
}

View File

@@ -8,6 +8,10 @@ const Base_config = `
name = ""
config = "/etc/nftables.conf"
[metrics]
enabled = false
port = 2122
[[service]]
name = "nginx"
logging = "file"

View File

@@ -14,6 +14,7 @@ type Service struct {
type Config struct {
Firewall Firewall `toml:"firewall"`
Metrics Metrics `toml:"metrics"`
Service []Service `toml:"service"`
}
@@ -23,11 +24,37 @@ type Rules struct {
}
type Rule struct {
Name string `toml:"name"`
ServiceName string `toml:"service"`
Path string `toml:"path"`
Status string `toml:"status"`
Method string `toml:"method"`
MaxRetry int `toml:"max_retry"`
BanTime string `toml:"ban_time"`
Name string `toml:"name"`
ServiceName string `toml:"service"`
Path string `toml:"path"`
Status string `toml:"status"`
Method string `toml:"method"`
MaxRetry int `toml:"max_retry"`
BanTime string `toml:"ban_time"`
Action []Action `toml:"action"`
}
type Metrics struct {
Enabled bool `toml:"enabled"`
Port int `toml:"port"`
}
// Actions
type Action struct {
Type string `toml:"type"`
Enabled bool `toml:"enabled"`
URL string `toml:"url"`
Method string `toml:"method"`
Headers map[string]string `toml:"headers"`
Body string `toml:"body"`
Email string `toml:"email"`
EmailSender string `toml:"email_sender"`
EmailSubject string `toml:"email_subject"`
SMTPHost string `toml:"smtp_host"`
SMTPPort int `toml:"smtp_port"`
SMTPUser string `toml:"smtp_user"`
SMTPPassword string `toml:"smtp_password"`
SMTPTLS bool `toml:"smtp_tls"`
Interpretator string `toml:"interpretator"`
Script string `toml:"script"`
}

View File

@@ -5,9 +5,11 @@ import (
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/actions"
"github.com/d3m0k1d/BanForge/internal/blocker"
"github.com/d3m0k1d/BanForge/internal/config"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
@@ -85,19 +87,23 @@ func (j *Judge) Tribunal() {
banned, err := j.db_r.IsBanned(entry.IP)
if err != nil {
j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
metrics.IncError()
break
}
if banned {
j.logger.Info("IP already banned", "ip", entry.IP)
metrics.IncLogParsed()
break
}
exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry)
if err != nil {
j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err)
metrics.IncError()
break
}
if !exceeded {
j.logger.Info("Max retry not exceeded", "ip", entry.IP)
metrics.IncLogParsed()
break
}
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
@@ -116,8 +122,20 @@ func (j *Judge) Tribunal() {
if err := j.Blocker.Ban(entry.IP); err != nil {
j.logger.Error("Failed to ban IP at firewall", "ip", entry.IP, "error", err)
metrics.IncError()
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(
"IP banned successfully",
"ip",
@@ -127,6 +145,7 @@ func (j *Judge) Tribunal() {
"ban_time",
rule.BanTime,
)
metrics.IncBan(rule.ServiceName)
break
}
}
@@ -147,12 +166,16 @@ func (j *Judge) UnbanChecker() {
ips, err := j.db_w.RemoveExpiredBans()
if err != nil {
j.logger.Error(fmt.Sprintf("Failed to check expired bans: %v", err))
metrics.IncError()
continue
}
for _, ip := range ips {
if err := j.Blocker.Unban(ip); err != nil {
j.logger.Error(fmt.Sprintf("Failed to unban IP at firewall: %v", err))
metrics.IncError()
} else {
metrics.IncUnban("judge")
}
}
}

131
internal/metrics/metrics.go Normal file
View File

@@ -0,0 +1,131 @@
package metrics
import (
"fmt"
"log"
"net/http"
"strconv"
"sync"
"time"
)
var (
metricsMu sync.RWMutex
metrics = make(map[string]int64)
)
func IncBan(service string) {
metricsMu.Lock()
metrics["ban_count"]++
metrics[service+"_bans"]++
metricsMu.Unlock()
}
func IncUnban(service string) {
metricsMu.Lock()
metrics["unban_count"]++
metrics[service+"_unbans"]++
metricsMu.Unlock()
}
func IncRuleMatched(rule_name string) {
metricsMu.Lock()
metrics[rule_name+"_rule_matched"]++
metricsMu.Unlock()
}
func IncLogParsed() {
metricsMu.Lock()
metrics["log_parsed"]++
metricsMu.Unlock()
}
func IncError() {
metricsMu.Lock()
metrics["error_count"]++
metricsMu.Unlock()
}
func IncBanAttempt(firewall string) {
metricsMu.Lock()
metrics["ban_attempt_count"]++
metrics[firewall+"_ban_attempts"]++
metricsMu.Unlock()
}
func IncUnbanAttempt(firewall string) {
metricsMu.Lock()
metrics["unban_attempt_count"]++
metrics[firewall+"_unban_attempts"]++
metricsMu.Unlock()
}
func IncPortOperation(operation string, protocol string) {
metricsMu.Lock()
key := "port_" + operation + "_" + protocol
metrics[key]++
metricsMu.Unlock()
}
func IncParserEvent(service string) {
metricsMu.Lock()
metrics[service+"_parsed_events"]++
metricsMu.Unlock()
}
func IncScannerEvent(service string) {
metricsMu.Lock()
metrics[service+"_scanner_events"]++
metricsMu.Unlock()
}
func IncDBOperation(operation string, table string) {
metricsMu.Lock()
key := "db_" + operation + "_" + table
metrics[key]++
metricsMu.Unlock()
}
func IncRequestCount(service string) {
metricsMu.Lock()
metrics[service+"_request_count"]++
metricsMu.Unlock()
}
func MetricsHandler() http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
metricsMu.RLock()
snapshot := make(map[string]int64, len(metrics))
for k, v := range metrics {
snapshot[k] = v
}
metricsMu.RUnlock()
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
for name, value := range snapshot {
metricName := name + "_total"
_, _ = fmt.Fprintf(w, "# TYPE %s counter\n", metricName)
_, _ = fmt.Fprintf(w, "%s %d\n", metricName, value)
}
})
}
func StartMetricsServer(port int) error {
mux := http.NewServeMux()
mux.Handle("/metrics", MetricsHandler())
server := &http.Server{
Addr: "localhost:" + strconv.Itoa(port),
Handler: mux,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 15 * time.Second,
}
log.Printf("Starting metrics server on %s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return fmt.Errorf("metrics server failed: %w", err)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
@@ -50,6 +51,7 @@ func (p *ApacheParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogE
Status: status,
Method: method,
}
metrics.IncParserEvent("apache")
p.logger.Info(
"Parsed apache log entry",
"ip", matches[1],

View File

@@ -4,6 +4,7 @@ import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
@@ -40,6 +41,7 @@ func (p *NginxParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEn
Status: status,
Method: method,
}
metrics.IncParserEvent("nginx")
p.logger.Info(
"Parsed nginx log entry",
"ip",

View File

@@ -2,11 +2,16 @@ package parser
import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
type Event struct {
@@ -23,8 +28,56 @@ type Scanner struct {
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) {
// #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)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -47,7 +100,11 @@ func NewScannerTail(path 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")
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -81,6 +138,7 @@ func (s *Scanner) Start() {
default:
if s.scanner.Scan() {
metrics.IncScannerEvent("scanner")
s.ch <- Event{
Data: s.scanner.Text(),
}
@@ -88,6 +146,7 @@ func (s *Scanner) Start() {
} else {
if err := s.scanner.Err(); err != nil {
s.logger.Error("Scanner error")
metrics.IncError()
return
}
}

View File

@@ -2,6 +2,7 @@ package parser
import (
"os"
"strings"
"testing"
"time"
)
@@ -281,3 +282,201 @@ func BenchmarkScanner(b *testing.B) {
<-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"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/d3m0k1d/BanForge/internal/storage"
)
@@ -24,30 +25,29 @@ func NewSshdParser() *SshdParser {
func (p *SshdParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: Timestamp, Group 2: hostame, Group 3: pid, Group 4: Method auth, Group 5: User, Group 6: IP, Group 7: port
go func() {
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
resultCh <- &storage.LogEntry{
Service: "ssh",
IP: matches[6],
Path: matches[5], // user
Status: "Failed",
Method: matches[4], // method auth
}
p.logger.Info(
"Parsed ssh log entry",
"ip",
matches[6],
"user",
matches[5],
"method",
matches[4],
"status",
"Failed",
)
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
}()
resultCh <- &storage.LogEntry{
Service: "ssh",
IP: matches[6],
Path: matches[5], // user
Status: "Failed",
Method: matches[4], // method auth
}
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/logger"
"github.com/d3m0k1d/BanForge/internal/metrics"
"github.com/jedib0t/go-pretty/v6/table"
_ "modernc.org/sqlite"
)
@@ -37,6 +38,7 @@ func (d *BanWriter) CreateTable() error {
if err != nil {
return err
}
metrics.IncDBOperation("create_table", "bans")
d.logger.Info("Created tables")
return nil
}
@@ -45,6 +47,7 @@ func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
duration, err := config.ParseDurationWithYears(ttl)
if err != nil {
d.logger.Error("Invalid duration format", "ttl", ttl, "error", err)
metrics.IncError()
return fmt.Errorf("invalid duration: %w", err)
}
@@ -60,9 +63,11 @@ func (d *BanWriter) AddBan(ip string, ttl string, reason string) error {
)
if err != nil {
d.logger.Error("Failed to add ban", "error", err)
metrics.IncError()
return err
}
metrics.IncDBOperation("insert", "bans")
return nil
}
@@ -70,8 +75,10 @@ func (d *BanWriter) RemoveBan(ip string) error {
_, err := d.db.Exec("DELETE FROM bans WHERE ip = ?", ip)
if err != nil {
d.logger.Error("Failed to remove ban", "error", err)
metrics.IncError()
return err
}
metrics.IncDBOperation("delete", "bans")
return nil
}
@@ -85,6 +92,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
)
if err != nil {
w.logger.Error("Failed to get expired bans", "error", err)
metrics.IncError()
return nil, err
}
defer func() {
@@ -113,6 +121,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
)
if err != nil {
w.logger.Error("Failed to remove expired bans", "error", err)
metrics.IncError()
return nil, err
}
@@ -123,6 +132,7 @@ func (w *BanWriter) RemoveExpiredBans() ([]string, error) {
if rowsAffected > 0 {
w.logger.Info("Removed expired bans", "count", rowsAffected, "ips", len(ips))
metrics.IncDBOperation("delete_expired", "bans")
}
return ips, nil
@@ -169,8 +179,10 @@ func (d *BanReader) IsBanned(ip string) (bool, error) {
return false, nil
}
if err != nil {
metrics.IncError()
return false, fmt.Errorf("failed to check ban status: %w", err)
}
metrics.IncDBOperation("select", "bans")
return true, nil
}
@@ -183,6 +195,7 @@ func (d *BanReader) BanList() error {
rows, err := d.db.Query("SELECT ip, banned_at, reason, expired_at FROM bans")
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err
}
for rows.Next() {
@@ -194,12 +207,14 @@ func (d *BanReader) BanList() error {
err := rows.Scan(&ip, &bannedAt, &reason, &expiredAt)
if err != nil {
d.logger.Error("Failed to get ban list", "error", err)
metrics.IncError()
return err
}
t.AppendRow(table.Row{count, ip, bannedAt, reason, expiredAt})
}
t.Render()
metrics.IncDBOperation("select", "bans")
return nil
}

View File

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

View File

@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"time"
"github.com/d3m0k1d/BanForge/internal/metrics"
)
func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
@@ -61,6 +63,9 @@ func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
)
if err != nil {
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]
metrics.IncDBOperation("insert", "requests")
return err
}()
if err != nil {