Compare commits
16 Commits
66d460dbfc
...
v0.7.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91e552bcd | ||
|
|
e7bb64d24b | ||
|
|
7b318bcc40 | ||
|
|
1aec91efa2 | ||
|
|
e36cf1861e | ||
|
|
69c3befa48 | ||
|
|
8e3e940bed | ||
|
|
eaa03b3869 | ||
|
|
bbc936ba5d | ||
|
|
5cc61aca75 | ||
|
|
fd38af9cb0 | ||
|
|
0929b92939 | ||
|
|
b75541af61 | ||
|
|
4e56d7bb6c | ||
|
|
efa9abb289 | ||
|
|
2747abfc04 |
@@ -32,7 +32,7 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test ./...
|
||||
- name: GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v6
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
|
||||
@@ -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
|
||||
|
||||
17
Makefile
17
Makefile
@@ -1,4 +1,4 @@
|
||||
.PHONY: build build-daemon build-tui clean help
|
||||
.PHONY: build build-daemon build-tui clean help install-man check-man
|
||||
|
||||
help:
|
||||
@echo "BanForge build targets:"
|
||||
@@ -7,6 +7,8 @@ help:
|
||||
@echo " make build-tui - Build only TUI"
|
||||
@echo " make clean - Remove binaries"
|
||||
@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!"
|
||||
|
||||
13
README.md
13
README.md
@@ -23,6 +23,7 @@ If you have any questions or suggestions, create issue on [Github](https://githu
|
||||
- [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
|
||||
@@ -114,8 +115,18 @@ banforge daemon # Start BanForge daemon (use systemd or another init system to c
|
||||
```
|
||||
You can edit the config file with examples in
|
||||
- `/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)
|
||||
|
||||
120
docs/config.md
120
docs/config.md
@@ -43,9 +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 max_retry = 0 ban on first request.
|
||||
|
||||
## Actions
|
||||
|
||||
Actions are executed after a successful IP ban. You can configure multiple actions per rule.
|
||||
|
||||
### Action Types
|
||||
|
||||
#### 1. Email Notification
|
||||
|
||||
Send email alerts when an IP is banned.
|
||||
|
||||
```toml
|
||||
[[rule.action]]
|
||||
type = "email"
|
||||
enabled = true
|
||||
email = "admin@example.com"
|
||||
email_sender = "banforge@example.com"
|
||||
email_subject = "BanForge Alert"
|
||||
smtp_host = "smtp.example.com"
|
||||
smtp_port = 587
|
||||
smtp_user = "user@example.com"
|
||||
smtp_password = "password"
|
||||
smtp_tls = true
|
||||
body = "IP {ip} has been banned"
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | + | Must be "email" |
|
||||
| `enabled` | + | Enable/disable this action |
|
||||
| `email` | + | Recipient email address |
|
||||
| `email_sender` | + | Sender email address |
|
||||
| `email_subject` | - | Email subject (default: "BanForge Alert") |
|
||||
| `smtp_host` | + | SMTP server host |
|
||||
| `smtp_port` | + | SMTP server port |
|
||||
| `smtp_user` | + | SMTP username |
|
||||
| `smtp_password` | + | SMTP password |
|
||||
| `smtp_tls` | - | Use TLS connection (default: false) |
|
||||
| `body` | - | Email body text |
|
||||
|
||||
#### 2. Webhook Notification
|
||||
|
||||
Send HTTP webhook requests when an IP is banned.
|
||||
|
||||
```toml
|
||||
[[rule.action]]
|
||||
type = "webhook"
|
||||
enabled = true
|
||||
url = "https://hooks.example.com/alert"
|
||||
method = "POST"
|
||||
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
|
||||
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\"}"
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | + | Must be "webhook" |
|
||||
| `enabled` | + | Enable/disable this action |
|
||||
| `url` | + | Webhook URL |
|
||||
| `method` | - | HTTP method (default: "POST") |
|
||||
| `headers` | - | HTTP headers as key-value pairs |
|
||||
| `body` | - | Request body (supports variables) |
|
||||
|
||||
#### 3. Script Execution
|
||||
|
||||
Execute a custom script when an IP is banned.
|
||||
|
||||
```toml
|
||||
[[rule.action]]
|
||||
type = "script"
|
||||
enabled = true
|
||||
script = "/usr/local/bin/notify.sh"
|
||||
interpretator = "bash"
|
||||
```
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | + | Must be "script" |
|
||||
| `enabled` | + | Enable/disable this action |
|
||||
| `script` | + | Path to script file |
|
||||
| `interpretator` | - | Script interpretator (e.g., "bash", "python"). If empty, script runs directly |
|
||||
|
||||
### Variables
|
||||
|
||||
The following variables can be used in `body` fields (email, webhook):
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `{ip}` | Banned IP address |
|
||||
| `{rule}` | Rule name that triggered the ban |
|
||||
| `{service}` | Service name |
|
||||
| `{ban_time}` | Ban duration |
|
||||
|
||||
251
docs/man/banforge.1
Normal file
251
docs/man/banforge.1
Normal file
@@ -0,0 +1,251 @@
|
||||
.TH BANFORGE 1 "24 February 2026" "BanForge 1.0"
|
||||
.
|
||||
.SH NAME
|
||||
banforge \- BanForge IPS utility for Linux
|
||||
.
|
||||
.SH SYNOPSIS
|
||||
.B banforge
|
||||
[\fIOPTIONS\fR] \fICOMMAND\fR [\fIARGUMENTS\fR]
|
||||
.
|
||||
.SH DESCRIPTION
|
||||
BanForge is an Intrusion Prevention System (IPS) utility for Linux.
|
||||
It monitors service logs, detects anomalies and malicious activity,
|
||||
and automatically applies firewall rules to block suspicious IP addresses.
|
||||
.
|
||||
.PP
|
||||
The program consists of two components:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBbanforge\fR \- CLI utility for management
|
||||
.IP \(bu 2
|
||||
\fBbanforge daemon\fR \- background service for real-time monitoring
|
||||
.RE
|
||||
.
|
||||
.SH COMMANDS
|
||||
.
|
||||
.SS init \- Create configuration files
|
||||
.PP
|
||||
\fBbanforge init\fR
|
||||
.PP
|
||||
Creates the necessary directories and base configuration files:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fI/etc/banforge/config.toml\fR \- main configuration
|
||||
.IP \(bu 2
|
||||
\fI/etc/banforge/rules.toml\fR \- default rules file
|
||||
.IP \(bu 2
|
||||
\fI/etc/banforge/rules.d/\fR \- directory for individual rule files
|
||||
.RE
|
||||
.
|
||||
.SS version \- Display BanForge version
|
||||
.PP
|
||||
\fBbanforge version\fR
|
||||
.PP
|
||||
Displays the current version of the BanForge software.
|
||||
.
|
||||
.SS daemon \- Start the BanForge daemon
|
||||
.PP
|
||||
\fBbanforge daemon\fR
|
||||
.PP
|
||||
Starts the BanForge daemon process in the background.
|
||||
The daemon continuously monitors incoming requests, detects anomalies,
|
||||
and applies firewall rules in real-time.
|
||||
.
|
||||
.SS firewall \- Manage firewall rules
|
||||
.PP
|
||||
\fBbanforge ban\fR \fI<ip>\fR [\fIOPTIONS\fR]
|
||||
.br
|
||||
\fBbanforge unban\fR \fI<ip>\fR
|
||||
.PP
|
||||
These commands provide an abstraction over your firewall.
|
||||
.PP
|
||||
\fBoptions:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1 year)
|
||||
.RE
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBbanforge ban 192.168.1.100 -t 1h\fR \- Ban IP for 1 hour
|
||||
.IP \(bu 2
|
||||
\fBbanforge unban 192.168.1.100\fR \- Unban IP
|
||||
.RE
|
||||
.
|
||||
.SS ports \- Manage firewall ports
|
||||
.PP
|
||||
\fBbanforge open\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
|
||||
.br
|
||||
\fBbanforge close\fR \fB-port\fR \fI<port>\fR \fB-protocol\fR \fI<protocol>\fR
|
||||
.PP
|
||||
Open or close ports on the firewall.
|
||||
.PP
|
||||
\fBflags:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB-port\fR \- Port number (e.g., 80) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fB-protocol\fR \- Protocol (tcp/udp) \fI(required)\fR
|
||||
.RE
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBbanforge open -port 80 -protocol tcp\fR
|
||||
.IP \(bu 2
|
||||
\fBbanforge close -port 443 -protocol tcp\fR
|
||||
.RE
|
||||
.
|
||||
.SS list \- List blocked IP addresses
|
||||
.PP
|
||||
\fBbanforge list\fR
|
||||
.PP
|
||||
Outputs a table of IP addresses that are currently blocked.
|
||||
.
|
||||
.SS rule \- Manage detection rules
|
||||
.PP
|
||||
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
|
||||
.
|
||||
.SS "rule add \- Add a new rule"
|
||||
.PP
|
||||
\fBbanforge rule add\fR \fB-n\fR \fI<name>\fR \fB-s\fR \fI<service>\fR [\fIOPTIONS\fR]
|
||||
.PP
|
||||
\fBflags:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB-n\fR, \fB--name\fR \- Rule name (used as filename) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fB-s\fR, \fB--service\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fB-p\fR, \fB--path\fR \- Request path to match
|
||||
.IP \(bu 2
|
||||
\fB-m\fR, \fB--method\fR \- HTTP method (GET, POST, etc.)
|
||||
.IP \(bu 2
|
||||
\fB-c\fR, \fB--status\fR \- HTTP status code (403, 404, etc.)
|
||||
.IP \(bu 2
|
||||
\fB-t\fR, \fB--ttl\fR \- Ban duration (default: 1y)
|
||||
.IP \(bu 2
|
||||
\fB-r\fR, \fB--max_retry\fR \- Max retries before ban (default: 0)
|
||||
.RE
|
||||
.PP
|
||||
\fBNote:\fR At least one of \fB-p\fR, \fB-m\fR, or \fB-c\fR must be specified.
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBbanforge rule add -n "Forbidden" -s nginx -c 403 -t 30m\fR
|
||||
.IP \(bu 2
|
||||
\fBbanforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3\fR
|
||||
.IP \(bu 2
|
||||
\fBbanforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5\fR
|
||||
.RE
|
||||
.
|
||||
.SS "rule list \- List all rules"
|
||||
.PP
|
||||
\fBbanforge rule list\fR
|
||||
.PP
|
||||
Displays all configured rules in a table format.
|
||||
.
|
||||
.SS "rule edit \- Edit an existing rule"
|
||||
.PP
|
||||
\fBbanforge rule edit\fR \fB-n\fR \fI<name>\fR [\fIOPTIONS\fR]
|
||||
.PP
|
||||
Edit fields of an existing rule. Only specified fields will be updated.
|
||||
.PP
|
||||
\fBflags:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB-n\fR, \fB--name\fR \- Rule name to edit \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fB-s\fR, \fB--service\fR \- New service name
|
||||
.IP \(bu 2
|
||||
\fB-p\fR, \fB--path\fR \- New path
|
||||
.IP \(bu 2
|
||||
\fB-m\fR, \fB--method\fR \- New method
|
||||
.IP \(bu 2
|
||||
\fB-c\fR, \fB--status\fR \- New status code
|
||||
.RE
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBbanforge rule edit -n "SSH Bruteforce" -t 2h\fR
|
||||
.IP \(bu 2
|
||||
\fBbanforge rule edit -n "Forbidden" -c 403\fR
|
||||
.RE
|
||||
.
|
||||
.SS "rule remove \- Remove a rule"
|
||||
.PP
|
||||
\fBbanforge rule remove\fR \fI<name>\fR
|
||||
.PP
|
||||
Permanently delete a rule by name.
|
||||
.PP
|
||||
\fBExample:\fR \fBbanforge rule remove "Old Rule"\fR
|
||||
.
|
||||
.SH "BAN TIME FORMAT"
|
||||
.PP
|
||||
Use the following suffixes for ban duration:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBs\fR \- Seconds
|
||||
.IP \(bu 2
|
||||
\fBm\fR \- Minutes
|
||||
.IP \(bu 2
|
||||
\fBh\fR \- Hours
|
||||
.IP \(bu 2
|
||||
\fBd\fR \- Days
|
||||
.IP \(bu 2
|
||||
\fBM\fR \- Months (30 days)
|
||||
.IP \(bu 2
|
||||
\fBy\fR \- Years (365 days)
|
||||
.RE
|
||||
.PP
|
||||
\fBExamples:\fR 30s, 5m, 2h, 1d, 1M, 1y
|
||||
.
|
||||
.SH "CONFIGURATION FILES"
|
||||
.PP
|
||||
Configuration files are stored in \fI/etc/banforge/\fR:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fIconfig.toml\fR \- main daemon configuration
|
||||
.IP \(bu 2
|
||||
\fIrules.toml\fR \- default rules
|
||||
.IP \(bu 2
|
||||
\fIrules.d/*.toml\fR \- individual rule files
|
||||
.RE
|
||||
.PP
|
||||
See \fBbanforge(5)\fR for configuration file format details including actions setup.
|
||||
.
|
||||
.SH "EXIT STATUS"
|
||||
.PP
|
||||
\fB0\fR \- Success
|
||||
.br
|
||||
\fB1\fR \- General error
|
||||
.br
|
||||
\fB2\fR \- Configuration error
|
||||
.
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
Initialize configuration: \fBbanforge init\fR
|
||||
.IP \(bu 2
|
||||
Start daemon: \fBbanforge daemon\fR
|
||||
.IP \(bu 2
|
||||
Ban an IP: \fBbanforge ban 192.168.1.100 -t 1h\fR
|
||||
.IP \(bu 2
|
||||
Add a rule: \fBbanforge rule add -n "404" -s nginx -c 404 -t 30m\fR
|
||||
.IP \(bu 2
|
||||
List blocked IPs: \fBbanforge list\fR
|
||||
.RE
|
||||
.
|
||||
.SH "SEE ALSO"
|
||||
.BR iptables (8),
|
||||
.BR nftables (8),
|
||||
.BR fail2ban (1),
|
||||
.BR nginx (8)
|
||||
.
|
||||
.SH AUTHOR
|
||||
.PP
|
||||
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru
|
||||
405
docs/man/banforge.5
Normal file
405
docs/man/banforge.5
Normal file
@@ -0,0 +1,405 @@
|
||||
.TH BANFORGE 5 "24 February 2026" "BanForge 1.0"
|
||||
.
|
||||
.SH NAME
|
||||
banforge \- BanForge configuration file format
|
||||
.
|
||||
.SH DESCRIPTION
|
||||
BanForge uses TOML configuration files stored in \fI/etc/banforge/\fR:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fIconfig.toml\fR \- main daemon configuration
|
||||
.IP \(bu 2
|
||||
\fIrules.toml\fR \- default rules (legacy)
|
||||
.IP \(bu 2
|
||||
\fIrules.d/*.toml\fR \- individual rule files (recommended)
|
||||
.RE
|
||||
.
|
||||
.SH "CONFIG.TOML FORMAT"
|
||||
.PP
|
||||
Main configuration file for BanForge daemon.
|
||||
.PP
|
||||
\fBStructure:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB[firewall]\fR \- firewall parameters
|
||||
.IP \(bu 2
|
||||
\fB[[service]]\fR \- service monitoring configuration (multiple allowed)
|
||||
.IP \(bu 2
|
||||
\fB[metrics]\fR \- Prometheus metrics configuration (optional)
|
||||
.RE
|
||||
.
|
||||
.SS "Firewall Section"
|
||||
.PP
|
||||
\fB[firewall]\fR
|
||||
.PP
|
||||
Defines firewall parameters. The \fBbanforge init\fR command automatically
|
||||
detects your installed firewall (nftables, iptables, ufw, firewalld).
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBname\fR \- Firewall type (nftables, iptables, ufw, firewalld)
|
||||
.IP \(bu 2
|
||||
\fBconfig\fR \- Path to firewall configuration file
|
||||
.RE
|
||||
.PP
|
||||
\fBExample:\fR
|
||||
.RS
|
||||
.nf
|
||||
[firewall]
|
||||
name = "nftables"
|
||||
config = "/etc/nftables.conf"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SS "Service Section"
|
||||
.PP
|
||||
\fB[[service]]\fR
|
||||
.PP
|
||||
Configures service monitoring. Multiple service blocks are allowed.
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBname\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBlogging\fR \- Log source type: "file" or "journald" \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBlog_path\fR \- Path to log file or journal unit name \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBenabled\fR \- Enable/disable service monitoring (true/false)
|
||||
.RE
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.nf
|
||||
# File-based logging
|
||||
[[service]]
|
||||
name = "nginx"
|
||||
logging = "file"
|
||||
log_path = "/var/log/nginx/access.log"
|
||||
enabled = true
|
||||
|
||||
# Journald logging
|
||||
[[service]]
|
||||
name = "nginx"
|
||||
logging = "journald"
|
||||
log_path = "nginx"
|
||||
enabled = false
|
||||
.fi
|
||||
.RE
|
||||
.PP
|
||||
\fBNote:\fR When using journald logging, specify the service name in \fBlog_path\fR.
|
||||
.
|
||||
.SS "Metrics Section"
|
||||
.PP
|
||||
\fB[metrics]\fR
|
||||
.PP
|
||||
Configures Prometheus metrics endpoint (optional).
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBenabled\fR \- Enable/disable metrics (true/false)
|
||||
.IP \(bu 2
|
||||
\fBport\fR \- Port for metrics endpoint
|
||||
.RE
|
||||
.PP
|
||||
\fBExample:\fR
|
||||
.RS
|
||||
.nf
|
||||
[metrics]
|
||||
enabled = true
|
||||
port = 9090
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SH "RULES.TOML FORMAT"
|
||||
.PP
|
||||
Detection rules define conditions for blocking IP addresses.
|
||||
Rules are stored in \fI/etc/banforge/rules.d/\fR as individual \fI.toml\fR files.
|
||||
.
|
||||
.SS "Rule Section"
|
||||
.PP
|
||||
\fB[[rule]]\fR
|
||||
.PP
|
||||
Defines a single detection rule.
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBname\fR \- Rule name (used as filename) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBservice\fR \- Service name (nginx, apache, ssh, etc.) \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBpath\fR \- Request path to match (e.g., "/admin/*", "*.php")
|
||||
.IP \(bu 2
|
||||
\fBstatus\fR \- HTTP status code (403, 404, 304, etc.)
|
||||
.IP \(bu 2
|
||||
\fBmethod\fR \- HTTP method (GET, POST, etc.)
|
||||
.IP \(bu 2
|
||||
\fBmax_retry\fR \- Max retries before ban (0 = ban on first request)
|
||||
.IP \(bu 2
|
||||
\fBban_time\fR \- Ban duration (e.g., "1m", "1h", "1d", "1M", "1y")
|
||||
.RE
|
||||
.PP
|
||||
\fBNote:\fR At least one of \fBpath\fR, \fBstatus\fR, or \fBmethod\fR must be specified.
|
||||
.PP
|
||||
\fBExamples:\fR
|
||||
.RS
|
||||
.nf
|
||||
# Ban on HTTP 304 status
|
||||
[[rule]]
|
||||
name = "304 http"
|
||||
service = "nginx"
|
||||
path = ""
|
||||
status = "304"
|
||||
max_retry = 3
|
||||
method = ""
|
||||
ban_time = "1m"
|
||||
|
||||
# Ban on path pattern (admin panel)
|
||||
[[rule]]
|
||||
name = "Admin Access"
|
||||
service = "nginx"
|
||||
path = "/admin/*"
|
||||
status = ""
|
||||
method = ""
|
||||
max_retry = 2
|
||||
ban_time = "2h"
|
||||
|
||||
# SSH brute force protection
|
||||
[[rule]]
|
||||
name = "SSH Bruteforce"
|
||||
service = "ssh"
|
||||
path = ""
|
||||
status = "Failed"
|
||||
method = ""
|
||||
max_retry = 5
|
||||
ban_time = "1h"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SH "BAN TIME FORMAT"
|
||||
.PP
|
||||
Use the following suffixes for ban duration:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBs\fR \- Seconds
|
||||
.IP \(bu 2
|
||||
\fBm\fR \- Minutes
|
||||
.IP \(bu 2
|
||||
\fBh\fR \- Hours
|
||||
.IP \(bu 2
|
||||
\fBd\fR \- Days
|
||||
.IP \(bu 2
|
||||
\fBM\fR \- Months (30 days)
|
||||
.IP \(bu 2
|
||||
\fBy\fR \- Years (365 days)
|
||||
.RE
|
||||
.
|
||||
.SH "ACTIONS"
|
||||
.PP
|
||||
Rules can trigger custom actions when an IP is banned.
|
||||
Multiple actions can be configured per rule.
|
||||
Actions are executed after successful ban at firewall level.
|
||||
.
|
||||
.SS "Supported Action Types"
|
||||
.PP
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBemail\fR \- Send email notification via SMTP
|
||||
.IP \(bu 2
|
||||
\fBwebhook\fR \- Send HTTP request to external service
|
||||
.IP \(bu 2
|
||||
\fBscript\fR \- Execute custom script
|
||||
.RE
|
||||
.
|
||||
.SS "Variables"
|
||||
.PP
|
||||
The following variables can be used in \fBbody\fR fields:
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fB{ip}\fR \- Banned IP address
|
||||
.IP \(bu 2
|
||||
\fB{rule}\fR \- Rule name that triggered the ban
|
||||
.IP \(bu 2
|
||||
\fB{service}\fR \- Service name
|
||||
.IP \(bu 2
|
||||
\fB{ban_time}\fR \- Ban duration
|
||||
.RE
|
||||
.
|
||||
.SS "Script Action"
|
||||
.PP
|
||||
Execute a custom script when an IP is banned.
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBtype\fR \- "script"
|
||||
.IP \(bu 2
|
||||
\fBenabled\fR \- Enable/disable action (true/false)
|
||||
.IP \(bu 2
|
||||
\fBinterpretator\fR \- Script interpretator (e.g., "bash", "python")
|
||||
.IP \(bu 2
|
||||
\fBscript\fR \- Path to script file
|
||||
.RE
|
||||
.PP
|
||||
\fBExample:\fR
|
||||
.RS
|
||||
.nf
|
||||
[[rule]]
|
||||
name = "Notify on Ban"
|
||||
service = "nginx"
|
||||
status = "403"
|
||||
ban_time = "1h"
|
||||
|
||||
[[rule.action]]
|
||||
type = "script"
|
||||
enabled = true
|
||||
interpretator = "bash"
|
||||
script = "/opt/banforge/scripts/notify.sh"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SS "Webhook Action"
|
||||
.PP
|
||||
Send HTTP webhook when an IP is banned.
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBtype\fR \- "webhook"
|
||||
.IP \(bu 2
|
||||
\fBenabled\fR \- Enable/disable action (true/false)
|
||||
.IP \(bu 2
|
||||
\fBurl\fR \- Webhook URL \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBmethod\fR \- HTTP method (POST, GET, etc.)
|
||||
.IP \(bu 2
|
||||
\fBheaders\fR \- Custom headers (key-value pairs)
|
||||
.IP \(bu 2
|
||||
\fBbody\fR \- Request body (supports variables)
|
||||
.RE
|
||||
.PP
|
||||
\fBExample:\fR
|
||||
.RS
|
||||
.nf
|
||||
[[rule.action]]
|
||||
type = "webhook"
|
||||
enabled = true
|
||||
url = "https://hooks.example.com/ban"
|
||||
method = "POST"
|
||||
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer TOKEN" }
|
||||
body = "{\\\"ip\\\": \\\"{ip}\\\", \\\"rule\\\": \\\"{rule}\\\"}"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SS "Email Action"
|
||||
.PP
|
||||
Send email notification when an IP is banned.
|
||||
.PP
|
||||
\fBFields:\fR
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fBtype\fR \- "email"
|
||||
.IP \(bu 2
|
||||
\fBenabled\fR \- Enable/disable action (true/false)
|
||||
.IP \(bu 2
|
||||
\fBemail\fR \- Recipient email address \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBemail_sender\fR \- Sender email address \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBemail_subject\fR \- Email subject (default: "BanForge Alert")
|
||||
.IP \(bu 2
|
||||
\fBsmtp_host\fR \- SMTP server host \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBsmtp_port\fR \- SMTP server port \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBsmtp_user\fR \- SMTP username \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBsmtp_password\fR \- SMTP password \fI(required)\fR
|
||||
.IP \(bu 2
|
||||
\fBsmtp_tls\fR \- Enable TLS (true/false)
|
||||
.IP \(bu 2
|
||||
\fBbody\fR \- Email body text (supports variables)
|
||||
.RE
|
||||
.PP
|
||||
\fBExample:\fR
|
||||
.RS
|
||||
.nf
|
||||
[[rule.action]]
|
||||
type = "email"
|
||||
enabled = true
|
||||
email = "admin@example.com"
|
||||
email_sender = "banforge@example.com"
|
||||
email_subject = "IP Banned"
|
||||
smtp_host = "smtp.example.com"
|
||||
smtp_port = 587
|
||||
smtp_user = "banforge"
|
||||
smtp_password = "secret"
|
||||
smtp_tls = true
|
||||
body = "IP {ip} has been banned for rule {rule}"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SH "COMPLETE RULE EXAMPLE WITH ACTIONS"
|
||||
.PP
|
||||
.RS
|
||||
.nf
|
||||
[[rule]]
|
||||
name = "nginx-403"
|
||||
service = "nginx"
|
||||
status = "403"
|
||||
max_retry = 3
|
||||
ban_time = "1h"
|
||||
|
||||
# Email notification
|
||||
[[rule.action]]
|
||||
type = "email"
|
||||
enabled = true
|
||||
email = "admin@example.com"
|
||||
email_sender = "banforge@example.com"
|
||||
smtp_host = "smtp.example.com"
|
||||
smtp_port = 587
|
||||
smtp_user = "banforge"
|
||||
smtp_password = "secret"
|
||||
smtp_tls = true
|
||||
body = "IP {ip} banned by rule {rule}"
|
||||
|
||||
# Slack webhook
|
||||
[[rule.action]]
|
||||
type = "webhook"
|
||||
enabled = true
|
||||
url = "https://hooks.slack.com/services/XXX/YYY/ZZZ"
|
||||
method = "POST"
|
||||
headers = { "Content-Type" = "application/json" }
|
||||
body = "{\\\"text\\\": \\\"IP {ip} banned for rule {rule}\\\"}"
|
||||
|
||||
# Custom script
|
||||
[[rule.action]]
|
||||
type = "script"
|
||||
enabled = true
|
||||
script = "/usr/local/bin/ban-notify.sh"
|
||||
interpretator = "bash"
|
||||
.fi
|
||||
.RE
|
||||
.
|
||||
.SH FILES
|
||||
.PP
|
||||
.RS
|
||||
.IP \(bu 2
|
||||
\fI/etc/banforge/config.toml\fR \- main configuration
|
||||
.IP \(bu 2
|
||||
\fI/etc/banforge/rules.d/\fR \- rule files directory
|
||||
.RE
|
||||
.
|
||||
.SH "SEE ALSO"
|
||||
.BR banforge (1),
|
||||
.BR iptables (8),
|
||||
.BR nftables (8),
|
||||
.BR systemd (1)
|
||||
.
|
||||
.SH AUTHOR
|
||||
.PP
|
||||
Ilya "d3m0k1d" Chernishev contact@d3m0k1d.ru
|
||||
7
go.mod
7
go.mod
@@ -16,14 +16,13 @@ require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.21 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/libc v1.70.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
22
go.sum
22
go.sum
@@ -19,8 +19,8 @@ github.com/jedib0t/go-pretty/v6 v6.7.8 h1:BVYrDy5DPBA3Qn9ICT+PokP9cvCv1KaHv2i+Hc
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.8/go.mod h1:YwC5CE4fJ1HFUDeivSV1r//AmANFHyqczZk+U6BDALU=
|
||||
github.com/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.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=
|
||||
github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
|
||||
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -36,15 +36,13 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/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-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
@@ -56,18 +54,18 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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.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/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
|
||||
modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
|
||||
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
|
||||
129
internal/actions/email.go
Normal file
129
internal/actions/email.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strings"
|
||||
|
||||
"github.com/d3m0k1d/BanForge/internal/config"
|
||||
)
|
||||
|
||||
func SendEmail(action config.Action) error {
|
||||
if !action.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if action.SMTPHost == "" {
|
||||
return fmt.Errorf("SMTP host is empty")
|
||||
}
|
||||
|
||||
if action.Email == "" {
|
||||
return fmt.Errorf("recipient email is empty")
|
||||
}
|
||||
|
||||
if action.EmailSender == "" {
|
||||
return fmt.Errorf("sender email is empty")
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", action.SMTPHost, action.SMTPPort)
|
||||
|
||||
subject := action.EmailSubject
|
||||
if subject == "" {
|
||||
subject = "BanForge Alert"
|
||||
}
|
||||
|
||||
var body strings.Builder
|
||||
body.WriteString("From: " + action.EmailSender + "\r\n")
|
||||
body.WriteString("To: " + action.Email + "\r\n")
|
||||
body.WriteString("Subject: " + subject + "\r\n")
|
||||
body.WriteString("MIME-Version: 1.0\r\n")
|
||||
body.WriteString("Content-Type: text/plain; charset=UTF-8\r\n")
|
||||
body.WriteString("\r\n")
|
||||
body.WriteString(action.Body)
|
||||
|
||||
auth := smtp.PlainAuth("", action.SMTPUser, action.SMTPPassword, action.SMTPHost)
|
||||
|
||||
if action.SMTPTLS {
|
||||
return sendEmailWithTLS(
|
||||
addr,
|
||||
auth,
|
||||
action.EmailSender,
|
||||
[]string{action.Email},
|
||||
body.String(),
|
||||
)
|
||||
}
|
||||
|
||||
return smtp.SendMail(
|
||||
addr,
|
||||
auth,
|
||||
action.EmailSender,
|
||||
[]string{action.Email},
|
||||
[]byte(body.String()),
|
||||
)
|
||||
}
|
||||
|
||||
func sendEmailWithTLS(addr string, auth smtp.Auth, from string, to []string, msg string) error {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("split host port: %w", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: host,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial TLS: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
c, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create SMTP client: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = c.Close()
|
||||
}()
|
||||
|
||||
if auth != nil {
|
||||
if ok, _ := c.Extension("AUTH"); !ok {
|
||||
return fmt.Errorf("SMTP server does not support AUTH")
|
||||
}
|
||||
if err = c.Auth(auth); err != nil {
|
||||
return fmt.Errorf("authenticate: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err = c.Mail(from); err != nil {
|
||||
return fmt.Errorf("mail from: %w", err)
|
||||
}
|
||||
|
||||
for _, addr := range to {
|
||||
if err = c.Rcpt(addr); err != nil {
|
||||
return fmt.Errorf("rcpt to: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
w, err := c.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("data: %w", err)
|
||||
}
|
||||
|
||||
_, err = w.Write([]byte(msg))
|
||||
if err != nil {
|
||||
return fmt.Errorf("write message: %w", err)
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("close data: %w", err)
|
||||
}
|
||||
|
||||
return c.Quit()
|
||||
}
|
||||
338
internal/actions/email_test.go
Normal file
338
internal/actions/email_test.go
Normal file
@@ -0,0 +1,338 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/d3m0k1d/BanForge/internal/config"
|
||||
)
|
||||
|
||||
type simpleSMTPServer struct {
|
||||
listener net.Listener
|
||||
messages []string
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newSimpleSMTPServer(t *testing.T, useTLS bool) *simpleSMTPServer {
|
||||
t.Helper()
|
||||
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create listener: %v", err)
|
||||
}
|
||||
|
||||
s := &simpleSMTPServer{
|
||||
listener: l,
|
||||
messages: make([]string, 0),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
go s.serve(t, useTLS)
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *simpleSMTPServer) serve(t *testing.T, useTLS bool) {
|
||||
defer close(s.done)
|
||||
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func(c net.Conn) {
|
||||
defer c.Close()
|
||||
|
||||
_, _ = c.Write([]byte("220 localhost ESMTP Test Server\r\n"))
|
||||
|
||||
reader := bufio.NewReader(c)
|
||||
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
line = strings.TrimSpace(line)
|
||||
parts := strings.SplitN(line, " ", 2)
|
||||
cmd := strings.ToUpper(parts[0])
|
||||
|
||||
switch cmd {
|
||||
case "EHLO", "HELO":
|
||||
if useTLS {
|
||||
_, _ = c.Write([]byte("250-localhost\r\n250 STARTTLS\r\n"))
|
||||
} else {
|
||||
_, _ = c.Write([]byte("250-localhost\r\n250 AUTH PLAIN\r\n"))
|
||||
}
|
||||
|
||||
case "STARTTLS":
|
||||
if !useTLS {
|
||||
_, _ = c.Write([]byte("454 TLS not available\r\n"))
|
||||
return
|
||||
}
|
||||
_, _ = c.Write([]byte("220 Ready to start TLS\r\n"))
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
InsecureSkipVerify: true,
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
tlsConn := tls.Server(c, tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
reader = bufio.NewReader(tlsConn)
|
||||
c = tlsConn
|
||||
|
||||
case "AUTH":
|
||||
_, _ = c.Write([]byte("235 Authentication successful\r\n"))
|
||||
|
||||
case "MAIL":
|
||||
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||
|
||||
case "RCPT":
|
||||
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||
|
||||
case "DATA":
|
||||
_, _ = c.Write([]byte("354 End data with <CR><LF>.<CR><LF>\r\n"))
|
||||
|
||||
var msgBuilder strings.Builder
|
||||
for {
|
||||
msgLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(msgLine) == "." {
|
||||
break
|
||||
}
|
||||
msgBuilder.WriteString(msgLine)
|
||||
}
|
||||
s.messages = append(s.messages, msgBuilder.String())
|
||||
_, _ = c.Write([]byte("250 OK\r\n"))
|
||||
|
||||
case "QUIT":
|
||||
_, _ = c.Write([]byte("221 Bye\r\n"))
|
||||
return
|
||||
|
||||
default:
|
||||
_, _ = c.Write([]byte("502 Command not implemented\r\n"))
|
||||
}
|
||||
}
|
||||
}(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *simpleSMTPServer) Addr() string {
|
||||
return s.listener.Addr().String()
|
||||
}
|
||||
|
||||
func (s *simpleSMTPServer) Close() {
|
||||
_ = s.listener.Close()
|
||||
<-s.done
|
||||
}
|
||||
|
||||
func (s *simpleSMTPServer) MessageCount() int {
|
||||
return len(s.messages)
|
||||
}
|
||||
|
||||
func TestSendEmail_Validation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
action config.Action
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "disabled action",
|
||||
action: config.Action{
|
||||
Type: "email",
|
||||
Enabled: false,
|
||||
Email: "test@example.com",
|
||||
EmailSender: "sender@example.com",
|
||||
SMTPHost: "smtp.example.com",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty SMTP host",
|
||||
action: config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "test@example.com",
|
||||
EmailSender: "sender@example.com",
|
||||
SMTPHost: "",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "SMTP host is empty",
|
||||
},
|
||||
{
|
||||
name: "empty recipient email",
|
||||
action: config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "",
|
||||
EmailSender: "sender@example.com",
|
||||
SMTPHost: "smtp.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "recipient email is empty",
|
||||
},
|
||||
{
|
||||
name: "empty sender email",
|
||||
action: config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "test@example.com",
|
||||
EmailSender: "",
|
||||
SMTPHost: "smtp.example.com",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "sender email is empty",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := SendEmail(tt.action)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("SendEmail() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr && err != nil && tt.errMsg != "" {
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("SendEmail() error message = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_WithoutTLS(t *testing.T) {
|
||||
server := newSimpleSMTPServer(t, false)
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
|
||||
action := config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "recipient@example.com",
|
||||
EmailSender: "sender@example.com",
|
||||
EmailSubject: "Test Subject",
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
SMTPUser: "user",
|
||||
SMTPPassword: "pass",
|
||||
SMTPTLS: false,
|
||||
Body: "Test message body",
|
||||
}
|
||||
|
||||
err := SendEmail(action)
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if server.MessageCount() != 1 {
|
||||
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_WithTLS(t *testing.T) {
|
||||
t.Skip("TLS test requires proper TLS handshake handling")
|
||||
server := newSimpleSMTPServer(t, true)
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
|
||||
action := config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "recipient@example.com",
|
||||
EmailSender: "sender@example.com",
|
||||
EmailSubject: "Test Subject TLS",
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
SMTPUser: "user",
|
||||
SMTPPassword: "pass",
|
||||
SMTPTLS: true,
|
||||
Body: "Test TLS message body",
|
||||
}
|
||||
|
||||
err := SendEmail(action)
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if server.MessageCount() != 1 {
|
||||
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_DefaultSubject(t *testing.T) {
|
||||
server := newSimpleSMTPServer(t, false)
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
|
||||
action := config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "test@example.com",
|
||||
EmailSender: "sender@example.com",
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
Body: "Test body",
|
||||
}
|
||||
|
||||
err := SendEmail(action)
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmail() unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if server.MessageCount() != 1 {
|
||||
t.Errorf("Expected 1 message, got %d", server.MessageCount())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSendEmail_Integration(t *testing.T) {
|
||||
server := newSimpleSMTPServer(t, false)
|
||||
defer server.Close()
|
||||
|
||||
host, portStr, _ := net.SplitHostPort(server.Addr())
|
||||
var port int
|
||||
fmt.Sscanf(portStr, "%d", &port)
|
||||
|
||||
action := config.Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "to@example.com",
|
||||
EmailSender: "from@example.com",
|
||||
EmailSubject: "Integration Test",
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
SMTPUser: "testuser",
|
||||
SMTPPassword: "testpass",
|
||||
SMTPTLS: false,
|
||||
Body: "Integration test message",
|
||||
}
|
||||
|
||||
err := SendEmail(action)
|
||||
if err != nil {
|
||||
t.Fatalf("SendEmail() failed: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Email sent successfully, server received %d message(s)", server.MessageCount())
|
||||
}
|
||||
@@ -1,7 +1,19 @@
|
||||
package actions
|
||||
|
||||
type Action struct {
|
||||
Name string
|
||||
Type string
|
||||
Args []string
|
||||
import "github.com/d3m0k1d/BanForge/internal/config"
|
||||
|
||||
type Executor struct {
|
||||
Action config.Action
|
||||
}
|
||||
|
||||
func (e *Executor) Execute() error {
|
||||
switch e.Action.Type {
|
||||
case "email":
|
||||
return SendEmail(e.Action)
|
||||
case "webhook":
|
||||
return SendWebhook(e.Action)
|
||||
case "script":
|
||||
return RunScript(e.Action)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
32
internal/actions/scripts.go
Normal file
32
internal/actions/scripts.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/d3m0k1d/BanForge/internal/config"
|
||||
)
|
||||
|
||||
func RunScript(action config.Action) error {
|
||||
if !action.Enabled {
|
||||
return nil
|
||||
}
|
||||
if action.Script == "" {
|
||||
return fmt.Errorf("script on config is empty")
|
||||
}
|
||||
if action.Interpretator == "" {
|
||||
// #nosec G204 - managed by system adminstartor
|
||||
cmd := exec.Command(action.Script)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("run script: %w", err)
|
||||
}
|
||||
}
|
||||
// #nosec G204 - managed by system adminstartor
|
||||
cmd := exec.Command(action.Interpretator, action.Script)
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
return fmt.Errorf("run script: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,24 +1,66 @@
|
||||
package actions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/d3m0k1d/BanForge/internal/config"
|
||||
)
|
||||
|
||||
func SendWebhook(url string, data []byte) (int, error) {
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
req, err := http.NewRequest("POST", url, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// #nosec G704 validating by admin
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return resp.StatusCode, nil
|
||||
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
|
||||
}
|
||||
|
||||
943
internal/config/config_test.go
Normal file
943
internal/config/config_test.go
Normal file
@@ -0,0 +1,943 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// Tests for SanitizeRuleFilename
|
||||
// ============================================
|
||||
|
||||
func TestSanitizeRuleFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple alphanumeric",
|
||||
input: "nginx404",
|
||||
expected: "nginx404",
|
||||
},
|
||||
{
|
||||
name: "with spaces",
|
||||
input: "nginx 404 error",
|
||||
expected: "nginx_404_error",
|
||||
},
|
||||
{
|
||||
name: "with special chars",
|
||||
input: "nginx/404:error",
|
||||
expected: "nginx_404_error",
|
||||
},
|
||||
{
|
||||
name: "with dashes and underscores",
|
||||
input: "nginx-404_error",
|
||||
expected: "nginx-404_error",
|
||||
},
|
||||
{
|
||||
name: "uppercase to lowercase",
|
||||
input: "NGINX-404",
|
||||
expected: "nginx-404",
|
||||
},
|
||||
{
|
||||
name: "mixed case",
|
||||
input: "Nginx-Admin-Access",
|
||||
expected: "nginx-admin-access",
|
||||
},
|
||||
{
|
||||
name: "with dots",
|
||||
input: "nginx.error.page",
|
||||
expected: "nginx_error_page",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "only special chars",
|
||||
input: "!@#$%^&*()",
|
||||
expected: "__________",
|
||||
},
|
||||
{
|
||||
name: "russian chars",
|
||||
input: "nginxошибка",
|
||||
expected: "nginx______",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := SanitizeRuleFilename(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("SanitizeRuleFilename(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for ParseDurationWithYears
|
||||
// ============================================
|
||||
|
||||
func TestParseDurationWithYears(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected time.Duration
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "1 year",
|
||||
input: "1y",
|
||||
expected: 365 * 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "2 years",
|
||||
input: "2y",
|
||||
expected: 2 * 365 * 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "1 month",
|
||||
input: "1M",
|
||||
expected: 30 * 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "6 months",
|
||||
input: "6M",
|
||||
expected: 6 * 30 * 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "30 days",
|
||||
input: "30d",
|
||||
expected: 30 * 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "1 day",
|
||||
input: "1d",
|
||||
expected: 24 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "1 hour",
|
||||
input: "1h",
|
||||
expected: time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "30 minutes",
|
||||
input: "30m",
|
||||
expected: 30 * time.Minute,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "30 seconds",
|
||||
input: "30s",
|
||||
expected: 30 * time.Second,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "complex duration",
|
||||
input: "1h30m",
|
||||
expected: 1*time.Hour + 30*time.Minute,
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid year format",
|
||||
input: "abc",
|
||||
expected: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid month format",
|
||||
input: "xM",
|
||||
expected: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid day format",
|
||||
input: "xd",
|
||||
expected: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: 0,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "negative duration",
|
||||
input: "-1h",
|
||||
expected: -1 * time.Hour,
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ParseDurationWithYears(tt.input)
|
||||
if (err != nil) != tt.expectError {
|
||||
t.Errorf("ParseDurationWithYears(%q) error = %v, expectError %v", tt.input, err, tt.expectError)
|
||||
return
|
||||
}
|
||||
if !tt.expectError && result != tt.expected {
|
||||
t.Errorf("ParseDurationWithYears(%q) = %v, want %v", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Rule validation (NewRule)
|
||||
// ============================================
|
||||
|
||||
func TestNewRule_EmptyName(t *testing.T) {
|
||||
err := NewRule("", "nginx", "", "", "", "1h", 0)
|
||||
if err == nil {
|
||||
t.Error("NewRule with empty name should return error")
|
||||
}
|
||||
if err.Error() != "rule name can't be empty" {
|
||||
t.Errorf("Unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRule_DuplicateRule(t *testing.T) {
|
||||
// Create temp directory for rules
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Create first rule
|
||||
firstRulePath := filepath.Join(tmpDir, "test-rule.toml")
|
||||
cfg := Rules{Rules: []Rule{{Name: "test-rule"}}}
|
||||
file, _ := os.Create(firstRulePath)
|
||||
toml.NewEncoder(file).Encode(cfg)
|
||||
file.Close()
|
||||
|
||||
// Try to create duplicate
|
||||
err := NewRule("test-rule", "nginx", "", "", "", "1h", 0)
|
||||
if err == nil {
|
||||
t.Error("NewRule with duplicate name should return error")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for EditRule validation
|
||||
// ============================================
|
||||
|
||||
func TestEditRule_EmptyName(t *testing.T) {
|
||||
err := EditRule("", "nginx", "", "", "")
|
||||
if err == nil {
|
||||
t.Error("EditRule with empty name should return error")
|
||||
}
|
||||
if err.Error() != "rule name can't be empty" {
|
||||
t.Errorf("Unexpected error message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Rule struct validation
|
||||
// ============================================
|
||||
|
||||
func TestRule_StructTags(t *testing.T) {
|
||||
// Test that Rule struct has correct TOML tags
|
||||
rule := Rule{
|
||||
Name: "test-rule",
|
||||
ServiceName: "nginx",
|
||||
Path: "/admin/*",
|
||||
Status: "403",
|
||||
Method: "POST",
|
||||
MaxRetry: 5,
|
||||
BanTime: "1h",
|
||||
}
|
||||
|
||||
// Encode to TOML and verify
|
||||
cfg := Rules{Rules: []Rule{rule}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "test.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode rule: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Rules
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode rule: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Rules) != 1 {
|
||||
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
|
||||
}
|
||||
|
||||
decodedRule := decoded.Rules[0]
|
||||
if decodedRule.Name != rule.Name {
|
||||
t.Errorf("Name mismatch: %q != %q", decodedRule.Name, rule.Name)
|
||||
}
|
||||
if decodedRule.ServiceName != rule.ServiceName {
|
||||
t.Errorf("ServiceName mismatch: %q != %q", decodedRule.ServiceName, rule.ServiceName)
|
||||
}
|
||||
if decodedRule.Path != rule.Path {
|
||||
t.Errorf("Path mismatch: %q != %q", decodedRule.Path, rule.Path)
|
||||
}
|
||||
if decodedRule.MaxRetry != rule.MaxRetry {
|
||||
t.Errorf("MaxRetry mismatch: %d != %d", decodedRule.MaxRetry, rule.MaxRetry)
|
||||
}
|
||||
if decodedRule.BanTime != rule.BanTime {
|
||||
t.Errorf("BanTime mismatch: %q != %q", decodedRule.BanTime, rule.BanTime)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Action struct validation
|
||||
// ============================================
|
||||
|
||||
func TestAction_EmailAction(t *testing.T) {
|
||||
action := Action{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "admin@example.com",
|
||||
EmailSender: "banforge@example.com",
|
||||
EmailSubject: "Alert",
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUser: "user",
|
||||
SMTPTLS: true,
|
||||
Body: "IP {ip} banned",
|
||||
}
|
||||
|
||||
cfg := Rules{Rules: []Rule{{
|
||||
Name: "test",
|
||||
Action: []Action{action},
|
||||
}}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "action.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode action: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Rules
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode action: %v", err)
|
||||
}
|
||||
|
||||
decodedAction := decoded.Rules[0].Action[0]
|
||||
if decodedAction.Type != "email" {
|
||||
t.Errorf("Expected action type 'email', got %q", decodedAction.Type)
|
||||
}
|
||||
if decodedAction.Email != "admin@example.com" {
|
||||
t.Errorf("Expected email 'admin@example.com', got %q", decodedAction.Email)
|
||||
}
|
||||
if decodedAction.SMTPPort != 587 {
|
||||
t.Errorf("Expected SMTP port 587, got %d", decodedAction.SMTPPort)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_WebhookAction(t *testing.T) {
|
||||
action := Action{
|
||||
Type: "webhook",
|
||||
Enabled: true,
|
||||
URL: "https://hooks.example.com/alert",
|
||||
Method: "POST",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer token123",
|
||||
},
|
||||
Body: `{"ip": "{ip}", "rule": "{rule}"}`,
|
||||
}
|
||||
|
||||
cfg := Rules{Rules: []Rule{{
|
||||
Name: "test",
|
||||
Action: []Action{action},
|
||||
}}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "webhook.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode webhook action: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Rules
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode webhook action: %v", err)
|
||||
}
|
||||
|
||||
decodedAction := decoded.Rules[0].Action[0]
|
||||
if decodedAction.Type != "webhook" {
|
||||
t.Errorf("Expected action type 'webhook', got %q", decodedAction.Type)
|
||||
}
|
||||
if decodedAction.URL != "https://hooks.example.com/alert" {
|
||||
t.Errorf("Expected URL 'https://hooks.example.com/alert', got %q", decodedAction.URL)
|
||||
}
|
||||
if decodedAction.Headers["Content-Type"] != "application/json" {
|
||||
t.Errorf("Expected Content-Type header, got %q", decodedAction.Headers["Content-Type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAction_ScriptAction(t *testing.T) {
|
||||
action := Action{
|
||||
Type: "script",
|
||||
Enabled: true,
|
||||
Script: "/usr/local/bin/notify.sh",
|
||||
Interpretator: "bash",
|
||||
}
|
||||
|
||||
cfg := Rules{Rules: []Rule{{
|
||||
Name: "test",
|
||||
Action: []Action{action},
|
||||
}}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "script.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode script action: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Rules
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode script action: %v", err)
|
||||
}
|
||||
|
||||
decodedAction := decoded.Rules[0].Action[0]
|
||||
if decodedAction.Type != "script" {
|
||||
t.Errorf("Expected action type 'script', got %q", decodedAction.Type)
|
||||
}
|
||||
if decodedAction.Script != "/usr/local/bin/notify.sh" {
|
||||
t.Errorf("Expected script path '/usr/local/bin/notify.sh', got %q", decodedAction.Script)
|
||||
}
|
||||
if decodedAction.Interpretator != "bash" {
|
||||
t.Errorf("Expected interpretator 'bash', got %q", decodedAction.Interpretator)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Service struct validation
|
||||
// ============================================
|
||||
|
||||
func TestService_FileLogging(t *testing.T) {
|
||||
service := Service{
|
||||
Name: "nginx",
|
||||
Logging: "file",
|
||||
LogPath: "/var/log/nginx/access.log",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
cfg := Config{Service: []Service{service}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "service.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode service: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode service: %v", err)
|
||||
}
|
||||
|
||||
if len(decoded.Service) != 1 {
|
||||
t.Fatalf("Expected 1 service, got %d", len(decoded.Service))
|
||||
}
|
||||
|
||||
decodedService := decoded.Service[0]
|
||||
if decodedService.Name != "nginx" {
|
||||
t.Errorf("Expected service name 'nginx', got %q", decodedService.Name)
|
||||
}
|
||||
if decodedService.Logging != "file" {
|
||||
t.Errorf("Expected logging type 'file', got %q", decodedService.Logging)
|
||||
}
|
||||
if decodedService.LogPath != "/var/log/nginx/access.log" {
|
||||
t.Errorf("Expected log path '/var/log/nginx/access.log', got %q", decodedService.LogPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_JournaldLogging(t *testing.T) {
|
||||
service := Service{
|
||||
Name: "sshd",
|
||||
Logging: "journald",
|
||||
LogPath: "sshd",
|
||||
Enabled: true,
|
||||
}
|
||||
|
||||
cfg := Config{Service: []Service{service}}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "journald.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode journald service: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode journald service: %v", err)
|
||||
}
|
||||
|
||||
decodedService := decoded.Service[0]
|
||||
if decodedService.Logging != "journald" {
|
||||
t.Errorf("Expected logging type 'journald', got %q", decodedService.Logging)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Metrics struct validation
|
||||
// ============================================
|
||||
|
||||
func TestMetrics_Enabled(t *testing.T) {
|
||||
metrics := Metrics{
|
||||
Enabled: true,
|
||||
Port: 9090,
|
||||
}
|
||||
|
||||
cfg := Config{Metrics: metrics}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "metrics.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode metrics: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode metrics: %v", err)
|
||||
}
|
||||
|
||||
if !decoded.Metrics.Enabled {
|
||||
t.Error("Expected metrics to be enabled")
|
||||
}
|
||||
if decoded.Metrics.Port != 9090 {
|
||||
t.Errorf("Expected metrics port 9090, got %d", decoded.Metrics.Port)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetrics_Disabled(t *testing.T) {
|
||||
metrics := Metrics{
|
||||
Enabled: false,
|
||||
Port: 0,
|
||||
}
|
||||
|
||||
cfg := Config{Metrics: metrics}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "metrics-disabled.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode disabled metrics: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode disabled metrics: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Metrics.Enabled {
|
||||
t.Error("Expected metrics to be disabled")
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Tests for Firewall struct validation
|
||||
// ============================================
|
||||
|
||||
func TestFirewall_Nftables(t *testing.T) {
|
||||
firewall := Firewall{
|
||||
Name: "nftables",
|
||||
Config: "/etc/nftables.conf",
|
||||
}
|
||||
|
||||
cfg := Config{Firewall: firewall}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "firewall.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode firewall: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode firewall: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Firewall.Name != "nftables" {
|
||||
t.Errorf("Expected firewall name 'nftables', got %q", decoded.Firewall.Name)
|
||||
}
|
||||
if decoded.Firewall.Config != "/etc/nftables.conf" {
|
||||
t.Errorf("Expected firewall config '/etc/nftables.conf', got %q", decoded.Firewall.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewall_Iptables(t *testing.T) {
|
||||
firewall := Firewall{
|
||||
Name: "iptables",
|
||||
Config: "",
|
||||
}
|
||||
|
||||
cfg := Config{Firewall: firewall}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "iptables.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode iptables: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode iptables: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Firewall.Name != "iptables" {
|
||||
t.Errorf("Expected firewall name 'iptables', got %q", decoded.Firewall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewall_Ufw(t *testing.T) {
|
||||
firewall := Firewall{
|
||||
Name: "ufw",
|
||||
Config: "",
|
||||
}
|
||||
|
||||
cfg := Config{Firewall: firewall}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "ufw.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode ufw: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode ufw: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Firewall.Name != "ufw" {
|
||||
t.Errorf("Expected firewall name 'ufw', got %q", decoded.Firewall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirewall_Firewalld(t *testing.T) {
|
||||
firewall := Firewall{
|
||||
Name: "firewalld",
|
||||
Config: "",
|
||||
}
|
||||
|
||||
cfg := Config{Firewall: firewall}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "firewalld.toml")
|
||||
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(cfg); err != nil {
|
||||
t.Fatalf("Failed to encode firewalld: %v", err)
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode firewalld: %v", err)
|
||||
}
|
||||
|
||||
if decoded.Firewall.Name != "firewalld" {
|
||||
t.Errorf("Expected firewall name 'firewalld', got %q", decoded.Firewall.Name)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Integration test: Full config round-trip
|
||||
// ============================================
|
||||
|
||||
func TestConfig_FullRoundTrip(t *testing.T) {
|
||||
fullConfig := Config{
|
||||
Firewall: Firewall{
|
||||
Name: "nftables",
|
||||
Config: "/etc/nftables.conf",
|
||||
},
|
||||
Metrics: Metrics{
|
||||
Enabled: true,
|
||||
Port: 9090,
|
||||
},
|
||||
Service: []Service{
|
||||
{
|
||||
Name: "nginx",
|
||||
Logging: "file",
|
||||
LogPath: "/var/log/nginx/access.log",
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
Name: "sshd",
|
||||
Logging: "journald",
|
||||
LogPath: "sshd",
|
||||
Enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "full-config.toml")
|
||||
|
||||
// Encode
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(fullConfig); err != nil {
|
||||
t.Fatalf("Failed to encode full config: %v", err)
|
||||
}
|
||||
|
||||
// Decode
|
||||
var decoded Config
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode full config: %v", err)
|
||||
}
|
||||
|
||||
// Verify firewall
|
||||
if decoded.Firewall.Name != fullConfig.Firewall.Name {
|
||||
t.Errorf("Firewall.Name mismatch: %q != %q", decoded.Firewall.Name, fullConfig.Firewall.Name)
|
||||
}
|
||||
|
||||
// Verify metrics
|
||||
if decoded.Metrics.Enabled != fullConfig.Metrics.Enabled {
|
||||
t.Errorf("Metrics.Enabled mismatch: %v != %v", decoded.Metrics.Enabled, fullConfig.Metrics.Enabled)
|
||||
}
|
||||
if decoded.Metrics.Port != fullConfig.Metrics.Port {
|
||||
t.Errorf("Metrics.Port mismatch: %d != %d", decoded.Metrics.Port, fullConfig.Metrics.Port)
|
||||
}
|
||||
|
||||
// Verify services
|
||||
if len(decoded.Service) != len(fullConfig.Service) {
|
||||
t.Fatalf("Services count mismatch: %d != %d", len(decoded.Service), len(fullConfig.Service))
|
||||
}
|
||||
|
||||
for i, expected := range fullConfig.Service {
|
||||
actual := decoded.Service[i]
|
||||
if actual.Name != expected.Name {
|
||||
t.Errorf("Service[%d].Name mismatch: %q != %q", i, actual.Name, expected.Name)
|
||||
}
|
||||
if actual.Logging != expected.Logging {
|
||||
t.Errorf("Service[%d].Logging mismatch: %q != %q", i, actual.Logging, expected.Logging)
|
||||
}
|
||||
if actual.Enabled != expected.Enabled {
|
||||
t.Errorf("Service[%d].Enabled mismatch: %v != %v", i, actual.Enabled, expected.Enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Integration test: Full rule with actions round-trip
|
||||
// ============================================
|
||||
|
||||
func TestRule_FullRoundTrip(t *testing.T) {
|
||||
fullRule := Rules{
|
||||
Rules: []Rule{
|
||||
{
|
||||
Name: "nginx-bruteforce",
|
||||
ServiceName: "nginx",
|
||||
Path: "/admin/*",
|
||||
Status: "403",
|
||||
Method: "POST",
|
||||
MaxRetry: 5,
|
||||
BanTime: "2h",
|
||||
Action: []Action{
|
||||
{
|
||||
Type: "email",
|
||||
Enabled: true,
|
||||
Email: "admin@example.com",
|
||||
EmailSender: "banforge@example.com",
|
||||
EmailSubject: "Ban Alert",
|
||||
SMTPHost: "smtp.example.com",
|
||||
SMTPPort: 587,
|
||||
SMTPUser: "user",
|
||||
SMTPPassword: "pass",
|
||||
SMTPTLS: true,
|
||||
Body: "IP {ip} banned for rule {rule}",
|
||||
},
|
||||
{
|
||||
Type: "webhook",
|
||||
Enabled: true,
|
||||
URL: "https://hooks.slack.com/services/xxx",
|
||||
Method: "POST",
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
Body: `{"text": "IP {ip} banned"}`,
|
||||
},
|
||||
{
|
||||
Type: "script",
|
||||
Enabled: true,
|
||||
Script: "/usr/local/bin/notify.sh",
|
||||
Interpretator: "bash",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
tmpFile := filepath.Join(tmpDir, "full-rule.toml")
|
||||
|
||||
// Encode
|
||||
file, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create temp file: %v", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := toml.NewEncoder(file).Encode(fullRule); err != nil {
|
||||
t.Fatalf("Failed to encode full rule: %v", err)
|
||||
}
|
||||
|
||||
// Decode
|
||||
var decoded Rules
|
||||
if _, err := toml.DecodeFile(tmpFile, &decoded); err != nil {
|
||||
t.Fatalf("Failed to decode full rule: %v", err)
|
||||
}
|
||||
|
||||
// Verify rule
|
||||
if len(decoded.Rules) != 1 {
|
||||
t.Fatalf("Expected 1 rule, got %d", len(decoded.Rules))
|
||||
}
|
||||
|
||||
rule := decoded.Rules[0]
|
||||
if rule.Name != fullRule.Rules[0].Name {
|
||||
t.Errorf("Rule.Name mismatch: %q != %q", rule.Name, fullRule.Rules[0].Name)
|
||||
}
|
||||
if rule.ServiceName != fullRule.Rules[0].ServiceName {
|
||||
t.Errorf("Rule.ServiceName mismatch: %q != %q", rule.ServiceName, fullRule.Rules[0].ServiceName)
|
||||
}
|
||||
if rule.MaxRetry != fullRule.Rules[0].MaxRetry {
|
||||
t.Errorf("Rule.MaxRetry mismatch: %d != %d", rule.MaxRetry, fullRule.Rules[0].MaxRetry)
|
||||
}
|
||||
|
||||
// Verify actions
|
||||
if len(rule.Action) != 3 {
|
||||
t.Fatalf("Expected 3 actions, got %d", len(rule.Action))
|
||||
}
|
||||
|
||||
// Email action
|
||||
emailAction := rule.Action[0]
|
||||
if emailAction.Type != "email" {
|
||||
t.Errorf("Action[0].Type mismatch: %q != 'email'", emailAction.Type)
|
||||
}
|
||||
if emailAction.Email != "admin@example.com" {
|
||||
t.Errorf("Email action email mismatch: %q != 'admin@example.com'", emailAction.Email)
|
||||
}
|
||||
|
||||
// Webhook action
|
||||
webhookAction := rule.Action[1]
|
||||
if webhookAction.Type != "webhook" {
|
||||
t.Errorf("Action[1].Type mismatch: %q != 'webhook'", webhookAction.Type)
|
||||
}
|
||||
if webhookAction.Headers["Content-Type"] != "application/json" {
|
||||
t.Errorf("Webhook Content-Type header mismatch")
|
||||
}
|
||||
|
||||
// Script action
|
||||
scriptAction := rule.Action[2]
|
||||
if scriptAction.Type != "script" {
|
||||
t.Errorf("Action[2].Type mismatch: %q != 'script'", scriptAction.Type)
|
||||
}
|
||||
if scriptAction.Script != "/usr/local/bin/notify.sh" {
|
||||
t.Errorf("Script action script mismatch: %q != '/usr/local/bin/notify.sh'", scriptAction.Script)
|
||||
}
|
||||
}
|
||||
@@ -24,16 +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"`
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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"
|
||||
@@ -124,6 +125,17 @@ func (j *Judge) Tribunal() {
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user