Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e3e940bed | ||
|
|
eaa03b3869 | ||
|
|
bbc936ba5d | ||
|
|
5cc61aca75 | ||
|
|
fd38af9cb0 |
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] Nginx and Sshd support
|
||||||
- [x] Working with ufw/iptables/nftables/firewalld
|
- [x] Working with ufw/iptables/nftables/firewalld
|
||||||
- [x] Prometheus metrics
|
- [x] Prometheus metrics
|
||||||
|
- [x] Actions (email, webhook, script)
|
||||||
- [ ] Add support for most popular web-service
|
- [ ] Add support for most popular web-service
|
||||||
- [ ] User regexp for custom services
|
- [ ] User regexp for custom services
|
||||||
- [ ] TUI interface
|
- [ ] TUI interface
|
||||||
@@ -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
|
You can edit the config file with examples in
|
||||||
- `/etc/banforge/config.toml` main config file
|
- `/etc/banforge/config.toml` main config file
|
||||||
- `/etc/banforge/rules.toml` ban rules
|
- `/etc/banforge/rules.d/*.toml` individual rule files with actions support
|
||||||
|
|
||||||
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
|
For more information see the [docs](https://github.com/d3m0k1d/BanForge/docs).
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
BanForge supports actions that are executed after a successful IP ban:
|
||||||
|
- **Email** - Send email notifications via SMTP
|
||||||
|
- **Webhook** - Send HTTP requests to external services (Slack, Telegram, etc.)
|
||||||
|
- **Script** - Execute custom scripts
|
||||||
|
|
||||||
|
See [configuration docs](https://github.com/d3m0k1d/BanForge/blob/main/docs/config.md#actions) for detailed setup instructions.
|
||||||
|
|
||||||
# License
|
# License
|
||||||
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
|
The project is licensed under the [GPL-3.0](https://github.com/d3m0k1d/BanForge/blob/master/LICENSE)
|
||||||
|
|||||||
120
docs/config.md
120
docs/config.md
@@ -43,9 +43,129 @@ Example:
|
|||||||
max_retry = 3
|
max_retry = 3
|
||||||
method = ""
|
method = ""
|
||||||
ban_time = "1m"
|
ban_time = "1m"
|
||||||
|
|
||||||
|
# Actions are executed after successful ban
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
email_subject = "BanForge Alert: IP Banned"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "user@example.com"
|
||||||
|
smtp_password = "password"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} has been banned for rule {rule}"
|
||||||
|
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.example.com/alert"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
|
||||||
|
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\", \"service\": \"{service}\"}"
|
||||||
|
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
script = "/usr/local/bin/notify.sh"
|
||||||
|
interpretator = "bash"
|
||||||
```
|
```
|
||||||
**Description**
|
**Description**
|
||||||
The [[rule]] section require name and one of the following parameters: service, path, status, method. To add a rule, create a [[rule]] block and specify the parameters.
|
The [[rule]] section require name and one of the following parameters: service, path, status, method. To add a rule, create a [[rule]] block and specify the parameters.
|
||||||
ban_time require in format "1m", "1h", "1d", "1M", "1y".
|
ban_time require in format "1m", "1h", "1d", "1M", "1y".
|
||||||
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*").
|
If you want to ban all requests to PHP files (e.g., path = "*.php") or requests to the admin panel (e.g., path = "/admin/*").
|
||||||
If max_retry = 0 ban on first request.
|
If max_retry = 0 ban on first request.
|
||||||
|
|
||||||
|
## Actions
|
||||||
|
|
||||||
|
Actions are executed after a successful IP ban. You can configure multiple actions per rule.
|
||||||
|
|
||||||
|
### Action Types
|
||||||
|
|
||||||
|
#### 1. Email Notification
|
||||||
|
|
||||||
|
Send email alerts when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "email"
|
||||||
|
enabled = true
|
||||||
|
email = "admin@example.com"
|
||||||
|
email_sender = "banforge@example.com"
|
||||||
|
email_subject = "BanForge Alert"
|
||||||
|
smtp_host = "smtp.example.com"
|
||||||
|
smtp_port = 587
|
||||||
|
smtp_user = "user@example.com"
|
||||||
|
smtp_password = "password"
|
||||||
|
smtp_tls = true
|
||||||
|
body = "IP {ip} has been banned"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "email" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `email` | + | Recipient email address |
|
||||||
|
| `email_sender` | + | Sender email address |
|
||||||
|
| `email_subject` | - | Email subject (default: "BanForge Alert") |
|
||||||
|
| `smtp_host` | + | SMTP server host |
|
||||||
|
| `smtp_port` | + | SMTP server port |
|
||||||
|
| `smtp_user` | + | SMTP username |
|
||||||
|
| `smtp_password` | + | SMTP password |
|
||||||
|
| `smtp_tls` | - | Use TLS connection (default: false) |
|
||||||
|
| `body` | - | Email body text |
|
||||||
|
|
||||||
|
#### 2. Webhook Notification
|
||||||
|
|
||||||
|
Send HTTP webhook requests when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "webhook"
|
||||||
|
enabled = true
|
||||||
|
url = "https://hooks.example.com/alert"
|
||||||
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer token" }
|
||||||
|
body = "{\"ip\": \"{ip}\", \"rule\": \"{rule}\"}"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "webhook" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `url` | + | Webhook URL |
|
||||||
|
| `method` | - | HTTP method (default: "POST") |
|
||||||
|
| `headers` | - | HTTP headers as key-value pairs |
|
||||||
|
| `body` | - | Request body (supports variables) |
|
||||||
|
|
||||||
|
#### 3. Script Execution
|
||||||
|
|
||||||
|
Execute a custom script when an IP is banned.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[rule.action]]
|
||||||
|
type = "script"
|
||||||
|
enabled = true
|
||||||
|
script = "/usr/local/bin/notify.sh"
|
||||||
|
interpretator = "bash"
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|-------|----------|-------------|
|
||||||
|
| `type` | + | Must be "script" |
|
||||||
|
| `enabled` | + | Enable/disable this action |
|
||||||
|
| `script` | + | Path to script file |
|
||||||
|
| `interpretator` | - | Script interpretator (e.g., "bash", "python"). If empty, script runs directly |
|
||||||
|
|
||||||
|
### Variables
|
||||||
|
|
||||||
|
The following variables can be used in `body` fields (email, webhook):
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `{ip}` | Banned IP address |
|
||||||
|
| `{rule}` | Rule name that triggered the ban |
|
||||||
|
| `{service}` | Service name |
|
||||||
|
| `{ban_time}` | Ban duration |
|
||||||
|
|||||||
@@ -214,6 +214,8 @@ Configuration files are stored in \fI/etc/banforge/\fR:
|
|||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fIrules.d/*.toml\fR \- individual rule files
|
\fIrules.d/*.toml\fR \- individual rule files
|
||||||
.RE
|
.RE
|
||||||
|
.PP
|
||||||
|
See \fBbanforge(5)\fR for configuration file format details including actions setup.
|
||||||
.
|
.
|
||||||
.SH "EXIT STATUS"
|
.SH "EXIT STATUS"
|
||||||
.PP
|
.PP
|
||||||
|
|||||||
@@ -198,9 +198,36 @@ Use the following suffixes for ban duration:
|
|||||||
\fBy\fR \- Years (365 days)
|
\fBy\fR \- Years (365 days)
|
||||||
.RE
|
.RE
|
||||||
.
|
.
|
||||||
.SH "ACTIONS(NOT WORKING ON THIS VERSION)"
|
.SH "ACTIONS"
|
||||||
.PP
|
.PP
|
||||||
Rules can trigger custom actions when an IP is banned.
|
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"
|
.SS "Script Action"
|
||||||
.PP
|
.PP
|
||||||
@@ -213,7 +240,7 @@ Execute a custom script when an IP is banned.
|
|||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBenabled\fR \- Enable/disable action (true/false)
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBinterpretator\fR \- Script interpretator (e.g., "/bin/bash")
|
\fBinterpretator\fR \- Script interpretator (e.g., "bash", "python")
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBscript\fR \- Path to script file
|
\fBscript\fR \- Path to script file
|
||||||
.RE
|
.RE
|
||||||
@@ -230,7 +257,7 @@ Execute a custom script when an IP is banned.
|
|||||||
[[rule.action]]
|
[[rule.action]]
|
||||||
type = "script"
|
type = "script"
|
||||||
enabled = true
|
enabled = true
|
||||||
interpretator = "/bin/bash"
|
interpretator = "bash"
|
||||||
script = "/opt/banforge/scripts/notify.sh"
|
script = "/opt/banforge/scripts/notify.sh"
|
||||||
.fi
|
.fi
|
||||||
.RE
|
.RE
|
||||||
@@ -246,13 +273,13 @@ Send HTTP webhook when an IP is banned.
|
|||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBenabled\fR \- Enable/disable action (true/false)
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBurl\fR \- Webhook URL
|
\fBurl\fR \- Webhook URL \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBmethod\fR \- HTTP method (POST, GET)
|
\fBmethod\fR \- HTTP method (POST, GET, etc.)
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBheaders\fR \- Custom headers (key-value pairs)
|
\fBheaders\fR \- Custom headers (key-value pairs)
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBbody\fR \- Request body (supports templates)
|
\fBbody\fR \- Request body (supports variables)
|
||||||
.RE
|
.RE
|
||||||
.PP
|
.PP
|
||||||
\fBExample:\fR
|
\fBExample:\fR
|
||||||
@@ -263,14 +290,8 @@ Send HTTP webhook when an IP is banned.
|
|||||||
enabled = true
|
enabled = true
|
||||||
url = "https://hooks.example.com/ban"
|
url = "https://hooks.example.com/ban"
|
||||||
method = "POST"
|
method = "POST"
|
||||||
|
headers = { "Content-Type" = "application/json", "Authorization" = "Bearer TOKEN" }
|
||||||
[rule.action.headers]
|
body = "{\\\"ip\\\": \\\"{ip}\\\", \\\"rule\\\": \\\"{rule}\\\"}"
|
||||||
Content-Type = "application/json"
|
|
||||||
Authorization = "Bearer TOKEN"
|
|
||||||
|
|
||||||
[rule.action.body]
|
|
||||||
ip = "{{.IP}}"
|
|
||||||
reason = "{{.Rule}}"
|
|
||||||
.fi
|
.fi
|
||||||
.RE
|
.RE
|
||||||
.
|
.
|
||||||
@@ -285,21 +306,23 @@ Send email notification when an IP is banned.
|
|||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBenabled\fR \- Enable/disable action (true/false)
|
\fBenabled\fR \- Enable/disable action (true/false)
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBemail\fR \- Recipient email address
|
\fBemail\fR \- Recipient email address \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBemail_sender\fR \- Sender email address
|
\fBemail_sender\fR \- Sender email address \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBemail_subject\fR \- Email subject
|
\fBemail_subject\fR \- Email subject (default: "BanForge Alert")
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBsmtp_host\fR \- SMTP server host
|
\fBsmtp_host\fR \- SMTP server host \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBsmtp_port\fR \- SMTP server port
|
\fBsmtp_port\fR \- SMTP server port \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBsmtp_user\fR \- SMTP username
|
\fBsmtp_user\fR \- SMTP username \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBsmtp_password\fR \- SMTP password
|
\fBsmtp_password\fR \- SMTP password \fI(required)\fR
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\fBsmtp_tls\fR \- Enable TLS (true/false)
|
\fBsmtp_tls\fR \- Enable TLS (true/false)
|
||||||
|
.IP \(bu 2
|
||||||
|
\fBbody\fR \- Email body text (supports variables)
|
||||||
.RE
|
.RE
|
||||||
.PP
|
.PP
|
||||||
\fBExample:\fR
|
\fBExample:\fR
|
||||||
@@ -316,6 +339,49 @@ Send email notification when an IP is banned.
|
|||||||
smtp_user = "banforge"
|
smtp_user = "banforge"
|
||||||
smtp_password = "secret"
|
smtp_password = "secret"
|
||||||
smtp_tls = true
|
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
|
.fi
|
||||||
.RE
|
.RE
|
||||||
.
|
.
|
||||||
|
|||||||
@@ -1,9 +1,129 @@
|
|||||||
package actions
|
package actions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/smtp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/d3m0k1d/BanForge/internal/config"
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func SendEmail(action config.Action) error {
|
func SendEmail(action config.Action) error {
|
||||||
|
if !action.Enabled {
|
||||||
return nil
|
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())
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,7 +31,7 @@ type Rule struct {
|
|||||||
Method string `toml:"method"`
|
Method string `toml:"method"`
|
||||||
MaxRetry int `toml:"max_retry"`
|
MaxRetry int `toml:"max_retry"`
|
||||||
BanTime string `toml:"ban_time"`
|
BanTime string `toml:"ban_time"`
|
||||||
Action []Action
|
Action []Action `toml:"action"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Metrics struct {
|
type Metrics struct {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/d3m0k1d/BanForge/internal/actions"
|
||||||
"github.com/d3m0k1d/BanForge/internal/blocker"
|
"github.com/d3m0k1d/BanForge/internal/blocker"
|
||||||
"github.com/d3m0k1d/BanForge/internal/config"
|
"github.com/d3m0k1d/BanForge/internal/config"
|
||||||
"github.com/d3m0k1d/BanForge/internal/logger"
|
"github.com/d3m0k1d/BanForge/internal/logger"
|
||||||
@@ -124,6 +125,17 @@ func (j *Judge) Tribunal() {
|
|||||||
metrics.IncError()
|
metrics.IncError()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, action := range rule.Action {
|
||||||
|
executor := &actions.Executor{Action: action}
|
||||||
|
if err := executor.Execute(); err != nil {
|
||||||
|
j.logger.Error("Action execution failed",
|
||||||
|
"rule", rule.Name,
|
||||||
|
"action_type", action.Type,
|
||||||
|
"error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
j.logger.Info(
|
j.logger.Info(
|
||||||
"IP banned successfully",
|
"IP banned successfully",
|
||||||
"ip",
|
"ip",
|
||||||
|
|||||||
Reference in New Issue
Block a user