35 Commits

Author SHA1 Message Date
d3m0k1d
b7a1ac06d4 feat: new ver
All checks were successful
CD - BanForge Release / release (push) Successful in 3m42s
2026-02-22 16:13:51 +03:00
d3m0k1d
49f0acb777 docs: update add to example max retry
All checks were successful
build / build (push) Successful in 2m8s
2026-02-22 16:12:52 +03:00
d3m0k1d
a602207369 feat: full working max_retry logic
All checks were successful
build / build (push) Successful in 2m45s
2026-02-22 16:06:51 +03:00
d3m0k1d
8c0cfcdbe7 refactoring: method on reader req db
All checks were successful
build / build (push) Successful in 2m8s
2026-02-19 12:36:56 +03:00
d3m0k1d
35a1a89baf fix: run tests in storage
All checks were successful
build / build (push) Successful in 2m6s
2026-02-19 11:22:52 +03:00
d3m0k1d
f3387b169a fix: gosec
Some checks failed
build / build (push) Failing after 1m59s
2026-02-19 11:17:51 +03:00
d3m0k1d
5782072f91 fix: ci one more time
Some checks failed
build / build (push) Failing after 1m42s
2026-02-19 11:14:45 +03:00
d3m0k1d
7918b3efe6 feat: add new nosec flags for fix ci
Some checks failed
build / build (push) Failing after 1m38s
2026-02-19 11:09:59 +03:00
d3m0k1d
f628e24f58 fix: golangci fix
Some checks failed
build / build (push) Failing after 1m40s
2026-02-19 11:03:52 +03:00
d3m0k1d
7f54db0cd4 feat: add new method and for db req and add to template max retry
Some checks failed
build / build (push) Failing after 1m48s
2026-02-19 10:53:55 +03:00
Ilya Chernishev
2e9b307194 Merge pull request #1 from shinyzero00/master
All checks were successful
build / build (push) Successful in 2m25s
refactoring pr by shinyzero00
2026-02-15 13:17:01 +03:00
Ilya Chernishev
726594a712 Change return value to nil on successful IP block 2026-02-15 13:13:26 +03:00
Ilya Chernishev
b27038a59c Execute SQL statement to create table in database 2026-02-15 13:08:40 +03:00
Ilya Chernishev
72025dab7d Remove comment about potential failure in encoding
Removed commented-out question regarding error handling.
2026-02-15 12:59:20 +03:00
Ilya Chernishev
dd131477e2 fix ST1005 2026-02-15 12:51:18 +03:00
Ilya Chernishev
670aec449a fix ST1005 staticcheck 2026-02-15 12:49:57 +03:00
zero@thinky
fc37e641be refactor(internal/config): use CutSuffix 2026-02-15 04:56:22 +03:00
zero@thinky
361de03208 refactor(cmd/fw): wtf is that error handling 2026-02-15 04:56:22 +03:00
zero@thinky
a2268fda5d fix(cmd/fw): why to fucking log when it is printed by the only caller 2026-02-15 04:56:22 +03:00
zero@thinky
9dc0b6002e refactor(internal/config): error handling 2026-02-15 04:56:22 +03:00
zero@thinky
4953be3ef6 refactor(internal/storage/RequestWriter/WriteReq): wtf is that error handling 2026-02-15 04:56:22 +03:00
zero@thinky
c386a2d6bc refactor(internal/storage/RequestWriter): deduplicate dsn 2026-02-15 04:54:38 +03:00
zero@thinky
dea03a6f70 refactor(*): what the fuck is that naming 2026-02-15 04:54:38 +03:00
zero@thinky
11f755c03c style(internal/storage/BanWriter): rm extra newline 2026-02-15 04:54:38 +03:00
zero@thinky
1c7a1c1778 refactor(internal/storage/BanWriter): deduplicate dsn 2026-02-15 04:54:38 +03:00
zero@thinky
411574cabe refactor(internal/storage): generalization and deduplication 2026-02-15 04:28:34 +03:00
d3m0k1d
820c9410a1 feat: update docs for new commands
All checks were successful
build / build (push) Successful in 2m8s
CD - BanForge Release / release (push) Successful in 3m46s
2026-02-09 22:27:28 +03:00
d3m0k1d
6f261803a7 feat: add to cli commands for open/close ports on firewall
All checks were successful
build / build (push) Successful in 2m2s
2026-02-09 21:51:31 +03:00
d3m0k1d
aacc98668f feat: add logic for PortClose and PortOpen on interfaces
All checks were successful
build / build (push) Successful in 2m4s
2026-02-09 21:31:19 +03:00
d3m0k1d
9519eedf4f feat: add new interface method to firewals
All checks were successful
build / build (push) Successful in 3m9s
2026-02-09 19:50:06 +03:00
d3m0k1d
b8b9b227a9 Fix: daemon chanels
All checks were successful
build / build (push) Successful in 3m9s
CD - BanForge Release / release (push) Successful in 5m9s
2026-01-27 17:10:01 +03:00
d3m0k1d
08d3214f22 Fix: goimport linter fix
All checks were successful
build / build (push) Successful in 3m27s
2026-01-27 17:04:36 +03:00
d3m0k1d
6ebda76738 feat: Add apache support
Some checks failed
build / build (push) Failing after 2m48s
2026-01-27 16:59:32 +03:00
d3m0k1d
b9754f605b fix: Delete sudo calls on exec
All checks were successful
build / build (push) Successful in 3m8s
CD - BanForge Release / release (push) Successful in 5m24s
2026-01-27 16:20:03 +03:00
d3m0k1d
be6b19426b docs: Add installation guide
All checks were successful
build / build (push) Successful in 3m16s
2026-01-26 16:51:40 +03:00
23 changed files with 756 additions and 235 deletions

View File

@@ -32,15 +32,79 @@ If you have any questions or suggestions, create issue on [Github](https://githu
- ufw/iptables/nftables/firewalld
# Installation
Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it. Then create or copy(/build dir) a systemd unit(openrc script) file.
Or clone the repo and use the Makefile.
```
git clone https://gitea.d3m0k1d.ru/d3m0k1d/BanForge.git
cd BanForge
sudo make build-daemon
cd bin
Search for a release on the [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases) releases page and download it.
In release page you can find rpm, deb, apk packages, for amd or arm architecture.
## Installation guide for packages
### Debian/Ubuntu(.deb)
```bash
# Download the latest DEB package
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.deb
# Install
sudo dpkg -i banforge_0.4.0_linux_amd64.deb
# Verify installation
sudo systemctl status banforge
```
### RHEL-based(.rpm)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.rpm
# Install
sudo rpm -i banforge_0.4.0_linux_amd64.rpm
# Or with dnf (CentOS 8+, AlmaLinux)
sudo dnf install banforge_0.4.0_linux_amd64.rpm
# Verify
sudo systemctl status banforge
```
### Alpine(.apk)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.apk
# Install
sudo apk add --allow-untrusted banforge_0.4.0_linux_amd64.apk
# Verify
sudo rc-service banforge status
```
### Arch Linux(.pkg.tar.zst)
```bash
# Download
wget https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases/download/v0.4.0/banforge_0.4.0_linux_amd64.pkg.tar.zst
# Install
sudo pacman -U banforge_0.4.0_linux_amd64.pkg.tar.zst
# Verify
sudo systemctl status banforge
```
This is examples for other versions with different architecture or new versions check release page on [Gitea](https://gitea.d3m0k1d.ru/d3m0k1d/BanForge/releases).
## Installation guide for source code
```bash
# Download
git clone https://github.com/d3m0k1d/BanForge.git
cd BanForge
make build-daemon
cd bin
mv banforge /usr/bin/banforge
cd ..
# Add init script and uses banforge init
cd build
./postinstall.sh
```
# Usage
For first steps use this commands
```bash

View File

@@ -30,6 +30,11 @@ var DaemonCmd = &cobra.Command{
log.Error("Failed to create request writer", "error", err)
os.Exit(1)
}
reqDb_r, err := storage.NewRequestsRd()
if err != nil {
log.Error("Failed to create request reader", "error", err)
os.Exit(1)
}
banDb_r, err := storage.NewBanReader()
if err != nil {
log.Error("Failed to create ban reader", "error", err)
@@ -63,7 +68,7 @@ var DaemonCmd = &cobra.Command{
log.Error("Failed to load rules", "error", err)
os.Exit(1)
}
j := judge.New(banDb_r, banDb_w, b, resultCh, entryCh)
j := judge.New(banDb_r, banDb_w, reqDb_r, b, resultCh, entryCh)
j.LoadRules(r)
go j.UnbanChecker()
go j.Tribunal()
@@ -112,6 +117,11 @@ var DaemonCmd = &cobra.Command{
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), entryCh)
}
if svc.Name == "apache" {
log.Info("Starting apache parser", "service", serviceName)
ap := parser.NewApacheParser()
ap.Parse(p.Events(), entryCh)
}
}(pars, svc.Name)
continue
}
@@ -131,14 +141,18 @@ var DaemonCmd = &cobra.Command{
if svc.Name == "nginx" {
log.Info("Starting nginx parser", "service", serviceName)
ng := parser.NewNginxParser()
ng.Parse(p.Events(), resultCh)
ng.Parse(p.Events(), entryCh)
}
if svc.Name == "ssh" {
log.Info("Starting ssh parser", "service", serviceName)
ssh := parser.NewSshdParser()
ssh.Parse(p.Events(), resultCh)
ssh.Parse(p.Events(), entryCh)
}
if svc.Name == "apache" {
log.Info("Starting apache parser", "service", serviceName)
ap := parser.NewApacheParser()
ap.Parse(p.Events(), entryCh)
}
}(pars, svc.Name)

View File

@@ -13,14 +13,17 @@ import (
var (
ttl_fw string
port int
protocol string
)
var UnbanCmd = &cobra.Command{
Use: "unban",
Short: "Unban IP",
Run: func(cmd *cobra.Command, args []string) {
err := func() error {
if len(args) == 0 {
fmt.Println("IP can't be empty")
os.Exit(1)
return fmt.Errorf("IP can't be empty")
}
if ttl_fw == "" {
ttl_fw = "1y"
@@ -28,39 +31,38 @@ var UnbanCmd = &cobra.Command{
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
fmt.Println(err)
os.Exit(1)
return err
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
return err
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
fmt.Println("IP can't be empty")
os.Exit(1)
return fmt.Errorf("IP can't be empty")
}
if net.ParseIP(ip) == nil {
fmt.Println("Invalid IP")
os.Exit(1)
return fmt.Errorf("invalid IP")
}
if err != nil {
fmt.Println(err)
os.Exit(1)
return err
}
err = b.Unban(ip)
if err != nil {
fmt.Println(err)
os.Exit(1)
return err
}
err = db.RemoveBan(ip)
if err != nil {
return err
}
fmt.Println("IP unblocked successfully!")
return nil
}()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP unblocked successfully!")
},
}
@@ -68,19 +70,64 @@ var BanCmd = &cobra.Command{
Use: "ban",
Short: "Ban IP",
Run: func(cmd *cobra.Command, args []string) {
err := func() error {
if len(args) == 0 {
fmt.Println("IP can't be empty")
os.Exit(1)
return fmt.Errorf("IP can't be empty")
}
if ttl_fw == "" {
ttl_fw = "1y"
}
ip := args[0]
db, err := storage.NewBanWriter()
if err != nil {
return err
}
cfg, err := config.LoadConfig()
if err != nil {
return err
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
return fmt.Errorf("IP can't be empty")
}
if net.ParseIP(ip) == nil {
return fmt.Errorf("invalid IP")
}
if err != nil {
return err
}
err = b.Ban(ip)
if err != nil {
return err
}
err = db.AddBan(ip, ttl_fw, "manual ban")
if err != nil {
return err
}
fmt.Println("IP blocked successfully!")
return nil
}()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
},
}
var PortCmd = &cobra.Command{
Use: "port",
Short: "Ports commands",
}
var PortOpenCmd = &cobra.Command{
Use: "open",
Short: "Open ports on firewall",
Run: func(cmd *cobra.Command, args []string) {
if protocol == "" {
fmt.Println("Protocol can't be empty")
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
@@ -88,32 +135,45 @@ var BanCmd = &cobra.Command{
}
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
if ip == "" {
fmt.Println("IP can't be empty")
os.Exit(1)
}
if net.ParseIP(ip) == nil {
fmt.Println("Invalid IP")
os.Exit(1)
}
err = b.PortOpen(port, protocol)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = b.Ban(ip)
fmt.Println("Port opened successfully!")
},
}
var PortCloseCmd = &cobra.Command{
Use: "close",
Short: "Close ports on firewall",
Run: func(cmd *cobra.Command, args []string) {
if protocol == "" {
fmt.Println("Protocol can't be empty")
os.Exit(1)
}
cfg, err := config.LoadConfig()
if err != nil {
fmt.Println(err)
os.Exit(1)
}
err = db.AddBan(ip, ttl_fw, "manual ban")
fw := cfg.Firewall.Name
b := blocker.GetBlocker(fw, cfg.Firewall.Config)
err = b.PortClose(port, protocol)
if err != nil {
fmt.Println(err)
os.Exit(1)
}
fmt.Println("IP blocked successfully!")
fmt.Println("Port closed successfully!")
},
}
func FwRegister() {
BanCmd.Flags().StringVarP(&ttl_fw, "ttl", "t", "", "ban time")
PortCmd.AddCommand(PortOpenCmd)
PortCmd.AddCommand(PortCloseCmd)
PortOpenCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
PortOpenCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
PortCloseCmd.Flags().IntVarP(&port, "port", "p", 0, "port number")
PortCloseCmd.Flags().StringVarP(&protocol, "protocol", "c", "", "protocol")
}

View File

@@ -61,11 +61,12 @@ var ListCmd = &cobra.Command{
}
for _, rule := range r {
fmt.Printf(
"Name: %s\nService: %s\nPath: %s\nStatus: %s\nMethod: %s\n\n",
"Name: %s\nService: %s\nPath: %s\nStatus: %s\n MaxRetry: %d\nMethod: %s\n\n",
rule.Name,
rule.ServiceName,
rule.Path,
rule.Status,
rule.MaxRetry,
rule.Method,
)
}

View File

@@ -0,0 +1,17 @@
package command
import (
"fmt"
"github.com/spf13/cobra"
)
var version = "0.5.0"
var VersionCmd = &cobra.Command{
Use: "version",
Short: "BanForge version",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("BanForge version:", version)
},
}

View File

@@ -13,7 +13,6 @@ var rootCmd = &cobra.Command{
Use: "banforge",
Short: "IPS log-based written on Golang",
Run: func(cmd *cobra.Command, args []string) {
},
}
@@ -28,6 +27,8 @@ func Execute() {
rootCmd.AddCommand(command.BanCmd)
rootCmd.AddCommand(command.UnbanCmd)
rootCmd.AddCommand(command.BanListCmd)
rootCmd.AddCommand(command.VersionCmd)
rootCmd.AddCommand(command.PortCmd)
command.RuleRegister()
command.FwRegister()
if err := rootCmd.Execute(); err != nil {

View File

@@ -11,6 +11,16 @@ banforge init
**Description**
This command creates the necessary directories and base configuration files
required for the daemon to operate.
### version - Display BanForge version
```shell
banforge version
```
**Description**
This command displays the current version of the BanForge software.
### daemon - Starts the BanForge daemon process
```shell
@@ -32,6 +42,17 @@ banforge unban <ip>
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
Flag -t or -ttl add bantime if not used default ban 1 year
### ports - Open and Close ports on firewall
```shell
banforge open -port <port> -protocol <protocol>
banforge close -port <port> -protocol <protocol>
```
**Description**
These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands.
### list - Lists the IP addresses that are currently blocked
```shell
banforge list

View File

@@ -40,6 +40,7 @@ Example:
service = "nginx"
path = ""
status = "304"
max_retry = 3
method = ""
ban_time = "1m"
```

View File

@@ -1,7 +1,9 @@
package blocker
import (
"fmt"
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
)
@@ -21,14 +23,15 @@ func (f *Firewalld) Ban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
// #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--add-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Add source " + ip + " " + string(output))
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
@@ -42,14 +45,15 @@ func (f *Firewalld) Unban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
// #nosec G204 - ip is validated
cmd := exec.Command("firewall-cmd", "--zone=drop", "--remove-source", ip, "--permanent")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Remove source " + ip + " " + string(output))
output, err = exec.Command("sudo", "firewall-cmd", "--reload").CombinedOutput()
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
@@ -58,6 +62,63 @@ func (f *Firewalld) Unban(ip string) error {
return nil
}
func (f *Firewalld) PortOpen(port int, protocol string) error {
// #nosec G204 - handle is extracted from Firewalld output and validated
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
cmd := exec.Command(
"firewall-cmd",
"--zone=public",
"--add-port="+s+"/"+protocol,
"--permanent",
)
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Add port " + s + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Reload " + string(output))
}
return nil
}
func (f *Firewalld) PortClose(port int, protocol string) error {
// #nosec G204 - handle is extracted from nftables output and validated
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
cmd := exec.Command(
"firewall-cmd",
"--zone=public",
"--remove-port="+s+"/"+protocol,
"--permanent",
)
output, err := cmd.CombinedOutput()
if err != nil {
return err
}
f.logger.Info("Remove port " + s + " " + string(output))
output, err = exec.Command("firewall-cmd", "--reload").CombinedOutput()
if err != nil {
return err
}
f.logger.Info("Reload " + string(output))
}
return nil
}
func (f *Firewalld) Setup(config string) error {
return nil
}

View File

@@ -10,6 +10,8 @@ type BlockerEngine interface {
Ban(ip string) error
Unban(ip string) error
Setup(config string) error
PortOpen(port int, protocol string) error
PortClose(port int, protocol string) error
}
func GetBlocker(fw string, config string) BlockerEngine {

View File

@@ -2,6 +2,7 @@ package blocker
import (
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
)
@@ -27,7 +28,8 @@ func (f *Iptables) Ban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd := exec.Command("iptables", "-A", "INPUT", "-s", ip, "-j", "DROP")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to ban IP",
@@ -45,7 +47,7 @@ func (f *Iptables) Ban(ip string) error {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
cmd = exec.Command("iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
@@ -69,7 +71,8 @@ func (f *Iptables) Unban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd := exec.Command("iptables", "-D", "INPUT", "-s", ip, "-j", "DROP")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to unban IP",
@@ -87,7 +90,7 @@ func (f *Iptables) Unban(ip string) error {
return err
}
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("sudo", "iptables-save", "-f", f.config)
cmd = exec.Command("iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
@@ -102,6 +105,64 @@ func (f *Iptables) Unban(ip string) error {
return nil
}
func (f *Iptables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return nil
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-A", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Add port " + s + " " + string(output))
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
"config_path", f.config,
"error", err.Error(),
"output", string(output))
return err
}
}
return nil
}
func (f *Iptables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
f.logger.Error("invalid protocol")
return nil
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("iptables", "-D", "INPUT", "-p", protocol, "--dport", s, "-j", "ACCEPT")
output, err := cmd.CombinedOutput()
if err != nil {
f.logger.Error(err.Error())
return err
}
f.logger.Info("Add port " + s + " " + string(output))
// #nosec G204 - f.config is validated above via validateConfigPath()
cmd = exec.Command("iptables-save", "-f", f.config)
output, err = cmd.CombinedOutput()
if err != nil {
f.logger.Error("failed to save config",
"config_path", f.config,
"error", err.Error(),
"output", string(output))
return err
}
}
return nil
}
func (f *Iptables) Setup(config string) error {
return nil
}

View File

@@ -3,6 +3,7 @@ package blocker
import (
"fmt"
"os/exec"
"strconv"
"strings"
"github.com/d3m0k1d/BanForge/internal/logger"
@@ -25,8 +26,8 @@ func (n *Nftables) Ban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "nft", "add", "rule", "inet", "banforge", "banned",
// #nosec G204 - ip is validated
cmd := exec.Command("nft", "add", "rule", "inet", "banforge", "banned",
"ip", "saddr", ip, "drop")
output, err := cmd.CombinedOutput()
if err != nil {
@@ -70,7 +71,7 @@ func (n *Nftables) Unban(ip string) error {
return fmt.Errorf("no rule found for IP %s", ip)
}
// #nosec G204 - handle is extracted from nftables output and validated
cmd := exec.Command("sudo", "nft", "delete", "rule", "inet", "banforge", "banned",
cmd := exec.Command("nft", "delete", "rule", "inet", "banforge", "banned",
"handle", handle)
output, err := cmd.CombinedOutput()
if err != nil {
@@ -112,7 +113,8 @@ func (n *Nftables) Setup(config string) error {
}
}
`
cmd := exec.Command("sudo", "tee", config)
// #nosec G204 - config is managed by adminstartor
cmd := exec.Command("tee", config)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)
@@ -134,8 +136,8 @@ func (n *Nftables) Setup(config string) error {
if err = cmd.Wait(); err != nil {
return fmt.Errorf("failed to save config: %w", err)
}
cmd = exec.Command("sudo", "nft", "-f", config)
// #nosec G204 - config is managed by adminstartor
cmd = exec.Command("nft", "-f", config)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to load nftables config: %s", string(output))
@@ -145,7 +147,7 @@ func (n *Nftables) Setup(config string) error {
}
func (n *Nftables) findRuleHandle(ip string) (string, error) {
cmd := exec.Command("sudo", "nft", "-a", "list", "chain", "inet", "banforge", "banned")
cmd := exec.Command("nft", "-a", "list", "chain", "inet", "banforge", "banned")
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("failed to list chain rules: %w", err)
@@ -166,19 +168,94 @@ func (n *Nftables) findRuleHandle(ip string) (string, error) {
return "", nil
}
func (n *Nftables) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
"add",
"rule",
"inet",
"banforge",
"input",
protocol,
"dport",
s,
"accept",
)
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
return err
}
n.logger.Info("Add port " + s + " " + string(output))
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
return err
}
}
return nil
}
func (n *Nftables) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
n.logger.Error("invalid protocol")
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command(
"nft",
"add",
"rule",
"inet",
"banforge",
"input",
protocol,
"dport",
s,
"drop",
)
output, err := cmd.CombinedOutput()
if err != nil {
n.logger.Error(err.Error())
return err
}
n.logger.Info("Add port " + s + " " + string(output))
err = saveNftablesConfig(n.config)
if err != nil {
n.logger.Error("failed to save config",
"config_path", n.config,
"error", err.Error())
return err
}
}
return nil
}
func saveNftablesConfig(configPath string) error {
err := validateConfigPath(configPath)
if err != nil {
return err
}
cmd := exec.Command("sudo", "nft", "list", "ruleset")
cmd := exec.Command("nft", "list", "ruleset")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("failed to get nftables ruleset: %w", err)
}
cmd = exec.Command("sudo", "tee", configPath)
// #nosec G204 - managed by system adminstartor
cmd = exec.Command("tee", configPath)
stdin, err := cmd.StdinPipe()
if err != nil {
return fmt.Errorf("failed to create stdin pipe: %w", err)

View File

@@ -3,6 +3,7 @@ package blocker
import (
"fmt"
"os/exec"
"strconv"
"github.com/d3m0k1d/BanForge/internal/logger"
)
@@ -22,8 +23,8 @@ func (u *Ufw) Ban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "ufw", "--force", "deny", "from", ip)
// #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "deny", "from", ip)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to ban IP",
@@ -41,8 +42,8 @@ func (u *Ufw) Unban(ip string) error {
if err != nil {
return err
}
cmd := exec.Command("sudo", "ufw", "--force", "delete", "deny", "from", ip)
// #nosec G204 - ip is validated
cmd := exec.Command("ufw", "--force", "delete", "deny", "from", ip)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to unban IP",
@@ -56,10 +57,48 @@ func (u *Ufw) Unban(ip string) error {
return nil
}
func (u *Ufw) PortOpen(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol")
return fmt.Errorf("invalid protocol")
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "allow", s+"/"+protocol)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error(err.Error())
return err
}
u.logger.Info("Add port " + s + " " + string(output))
}
return nil
}
func (u *Ufw) PortClose(port int, protocol string) error {
if port >= 0 && port <= 65535 {
if protocol != "tcp" && protocol != "udp" {
u.logger.Error("invalid protocol")
return nil
}
s := strconv.Itoa(port)
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("ufw", "deny", s+"/"+protocol)
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error(err.Error())
return err
}
u.logger.Info("Add port " + s + " " + string(output))
}
return nil
}
func (u *Ufw) Setup(config string) error {
if config != "" {
fmt.Printf("Ufw dont support config file\n")
cmd := exec.Command("sudo", "ufw", "enable")
cmd := exec.Command("ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",
@@ -69,7 +108,7 @@ func (u *Ufw) Setup(config string) error {
}
}
if config == "" {
cmd := exec.Command("sudo", "ufw", "enable")
cmd := exec.Command("ufw", "enable")
output, err := cmd.CombinedOutput()
if err != nil {
u.logger.Error("failed to enable ufw",

View File

@@ -1,6 +1,7 @@
package config
import (
"errors"
"fmt"
"os"
"strconv"
@@ -57,13 +58,9 @@ func NewRule(
return err
}
defer func() {
err = file.Close()
if err != nil {
fmt.Println(err)
}
err = errors.Join(err, file.Close())
}()
cfg := Rules{Rules: r}
err = toml.NewEncoder(file).Encode(cfg)
if err != nil {
return err
@@ -126,24 +123,24 @@ func EditRule(Name string, ServiceName string, Path string, Status string, Metho
}
func ParseDurationWithYears(s string) (time.Duration, error) {
if strings.HasSuffix(s, "y") {
years, err := strconv.Atoi(strings.TrimSuffix(s, "y"))
if ss, ok := strings.CutSuffix(s, "y"); ok {
years, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}
return time.Duration(years) * 365 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "M") {
months, err := strconv.Atoi(strings.TrimSuffix(s, "M"))
if ss, ok := strings.CutSuffix(s, "M"); ok {
months, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}
return time.Duration(months) * 30 * 24 * time.Hour, nil
}
if strings.HasSuffix(s, "d") {
days, err := strconv.Atoi(strings.TrimSuffix(s, "d"))
if ss, ok := strings.CutSuffix(s, "d"); ok {
days, err := strconv.Atoi(ss)
if err != nil {
return 0, err
}

View File

@@ -28,5 +28,6 @@ type Rule struct {
Path string `toml:"path"`
Status string `toml:"status"`
Method string `toml:"method"`
MaxRetry int `toml:"max_retry"`
BanTime string `toml:"ban_time"`
}

View File

@@ -14,6 +14,7 @@ import (
type Judge struct {
db_r *storage.BanReader
db_w *storage.BanWriter
db_rq *storage.RequestReader
logger *logger.Logger
Blocker blocker.BlockerEngine
rulesByService map[string][]config.Rule
@@ -24,6 +25,7 @@ type Judge struct {
func New(
db_r *storage.BanReader,
db_w *storage.BanWriter,
db_rq *storage.RequestReader,
b blocker.BlockerEngine,
resultCh chan *storage.LogEntry,
entryCh chan *storage.LogEntry,
@@ -31,6 +33,7 @@ func New(
return &Judge{
db_w: db_w,
db_r: db_r,
db_rq: db_rq,
logger: logger.New(false),
rulesByService: make(map[string][]config.Rule),
Blocker: b,
@@ -75,31 +78,28 @@ func (j *Judge) Tribunal() {
methodMatch := rule.Method == "" || entry.Method == rule.Method
statusMatch := rule.Status == "" || entry.Status == rule.Status
pathMatch := matchPath(entry.Path, rule.Path)
j.logger.Debug(
"Testing rule",
"rule", rule.Name,
"method_match", methodMatch,
"status_match", statusMatch,
"path_match", pathMatch,
)
if methodMatch && statusMatch && pathMatch {
ruleMatched = true
j.logger.Info("Rule matched", "rule", rule.Name, "ip", entry.IP)
j.resultCh <- entry
banned, err := j.db_r.IsBanned(entry.IP)
if err != nil {
j.logger.Error("Failed to check ban status", "ip", entry.IP, "error", err)
break
}
if banned {
j.logger.Info("IP already banned", "ip", entry.IP)
j.resultCh <- entry
break
}
exceeded, err := j.db_rq.IsMaxRetryExceeded(entry.IP, rule.MaxRetry)
if err != nil {
j.logger.Error("Failed to check retry count", "ip", entry.IP, "error", err)
break
}
if !exceeded {
j.logger.Info("Max retry not exceeded", "ip", entry.IP)
break
}
err = j.db_w.AddBan(entry.IP, rule.BanTime, rule.Name)
if err != nil {
j.logger.Error(
@@ -127,7 +127,6 @@ func (j *Judge) Tribunal() {
"ban_time",
rule.BanTime,
)
j.resultCh <- entry
break
}
}

View File

@@ -0,0 +1,61 @@
package parser
import (
"regexp"
"github.com/d3m0k1d/BanForge/internal/logger"
"github.com/d3m0k1d/BanForge/internal/storage"
)
type ApacheParser struct {
pattern *regexp.Regexp
logger *logger.Logger
}
func NewApacheParser() *ApacheParser {
pattern := regexp.MustCompile(
`^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})\s+-\s+-\s+\[(.*?)\]\s+"(\w+)\s+(.*?)\s+HTTP/[\d.]+"\s+(\d+)\s+(\d+|-)\s+"(.*?)"\s+"(.*?)"`,
)
// Groups:
// 1: IP
// 2: Timestamp
// 3: Method (GET, POST, etc.)
// 4: Path
// 5: Status Code (200, 404, 403...)
// 6: Response Size
// 7: Referer
// 8: User-Agent
return &ApacheParser{
pattern: pattern,
logger: logger.New(false),
}
}
func (p *ApacheParser) Parse(eventCh <-chan Event, resultCh chan<- *storage.LogEntry) {
// Group 1: IP, Group 2: Timestamp, Group 3: Method, Group 4: Path, Group 5: Status
for event := range eventCh {
matches := p.pattern.FindStringSubmatch(event.Data)
if matches == nil {
continue
}
path := matches[4]
status := matches[5]
method := matches[3]
resultCh <- &storage.LogEntry{
Service: "apache",
IP: matches[1],
Path: path,
Status: status,
Method: method,
}
p.logger.Info(
"Parsed apache log entry",
"ip", matches[1],
"path", path,
"status", status,
"method", method,
)
}
}

View File

@@ -24,6 +24,7 @@ type Scanner struct {
}
func NewScannerTail(path string) (*Scanner, error) {
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("tail", "-F", "-n", "10", path)
stdout, err := cmd.StdoutPipe()
if err != nil {
@@ -46,6 +47,7 @@ func NewScannerTail(path string) (*Scanner, error) {
}
func NewScannerJournald(unit string) (*Scanner, error) {
// #nosec G204 - managed by system adminstartor
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "0", "-o", "short", "--no-pager")
stdout, err := cmd.StdoutPipe()
if err != nil {

View File

@@ -21,7 +21,7 @@ type BanWriter struct {
func NewBanWriter() (*BanWriter, error) {
db, err := sql.Open(
"sqlite",
"/var/lib/banforge/bans.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)",
buildSqliteDsn(banDBPath, pragmas),
)
if err != nil {
return nil, err
@@ -175,7 +175,6 @@ func (d *BanReader) IsBanned(ip string) (bool, error) {
}
func (d *BanReader) BanList() error {
var count int
t := table.NewWriter()
t.SetOutputMirror(os.Stdout)

View File

@@ -2,55 +2,60 @@ package storage
import (
"database/sql"
"errors"
"fmt"
"strings"
_ "modernc.org/sqlite"
)
func CreateTables() error {
// Requests DB
db_r, err := sql.Open("sqlite",
"/var/lib/banforge/requests.db?"+
"mode=rwc&"+
"_pragma=journal_mode(WAL)&"+
"_pragma=busy_timeout(30000)&"+
"_pragma=synchronous(NORMAL)")
if err != nil {
return fmt.Errorf("failed to open requests db: %w", err)
}
defer func() {
err = db_r.Close()
if err != nil {
fmt.Println(err)
}
}()
const (
DBDir = "/var/lib/banforge/"
ReqDBPath = DBDir + "requests.db"
banDBPath = DBDir + "bans.db"
)
_, err = db_r.Exec(CreateRequestsTable)
if err != nil {
return fmt.Errorf("failed to create requests table: %w", err)
}
// Bans DB
db_b, err := sql.Open("sqlite",
"/var/lib/banforge/bans.db?"+
"mode=rwc&"+
"_pragma=journal_mode(WAL)&"+
"_pragma=busy_timeout(30000)&"+
"_pragma=synchronous(FULL)")
if err != nil {
return fmt.Errorf("failed to open bans db: %w", err)
}
defer func() {
err = db_b.Close()
if err != nil {
fmt.Println(err)
}
}()
_, err = db_b.Exec(CreateBansTable)
if err != nil {
return fmt.Errorf("failed to create bans table: %w", err)
}
fmt.Println("Tables created successfully!")
return nil
var pragmas = map[string]string{
`journal_mode`: `wal`,
`synchronous`: `normal`,
`busy_timeout`: `30000`,
// also consider these
// `temp_store`: `memory`,
// `cache_size`: `1000000000`,
}
func buildSqliteDsn(path string, pragmas map[string]string) string {
pragmastrs := make([]string, len(pragmas))
i := 0
for k, v := range pragmas {
pragmastrs[i] = (fmt.Sprintf(`pragma=%s(%s)`, k, v))
i++
}
return path + "?" + "mode=rwc&" + strings.Join(pragmastrs, "&")
}
func initDB(dsn, sqlstr string) (err error) {
db, err := sql.Open("sqlite", dsn)
if err != nil {
return fmt.Errorf("failed to open %q: %w", dsn, err)
}
defer func() {
closeErr := db.Close()
if closeErr != nil {
err = errors.Join(err, fmt.Errorf("failed to close %q: %w", dsn, closeErr))
}
}()
_, err = db.Exec(sqlstr)
if err != nil {
return fmt.Errorf("failed to create table: %w", err)
}
return err
}
func CreateTables() (err error) {
// Requests DB
err1 := initDB(buildSqliteDsn(ReqDBPath, pragmas), CreateRequestsTable)
err2 := initDB(buildSqliteDsn(banDBPath, pragmas), CreateBansTable)
return errors.Join(err1, err2)
}

View File

@@ -7,15 +7,15 @@ import (
_ "modernc.org/sqlite"
)
type Request_Writer struct {
type RequestWriter struct {
logger *logger.Logger
db *sql.DB
}
func NewRequestsWr() (*Request_Writer, error) {
func NewRequestsWr() (*RequestWriter, error) {
db, err := sql.Open(
"sqlite",
"/var/lib/banforge/requests.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)",
buildSqliteDsn(ReqDBPath, pragmas),
)
if err != nil {
return nil, err
@@ -23,8 +23,41 @@ func NewRequestsWr() (*Request_Writer, error) {
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &Request_Writer{
return &RequestWriter{
logger: logger.New(false),
db: db,
}, nil
}
type RequestReader struct {
logger *logger.Logger
db *sql.DB
}
func NewRequestsRd() (*RequestReader, error) {
db, err := sql.Open(
"sqlite",
buildSqliteDsn(ReqDBPath, pragmas),
)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &RequestReader{
logger: logger.New(false),
db: db,
}, nil
}
func (r *RequestReader) IsMaxRetryExceeded(ip string, maxRetry int) (bool, error) {
var count int
err := r.db.QueryRow("SELECT COUNT(*) FROM requests WHERE ip = ?", ip).Scan(&count)
if err != nil {
r.logger.Error("error query count: " + err.Error())
return false, err
}
r.logger.Info("Current request count for IP", "ip", ip, "count", count, "maxRetry", maxRetry)
return count >= maxRetry, nil
}

View File

@@ -1,10 +1,13 @@
package storage
import (
"database/sql"
"errors"
"fmt"
"time"
)
func WriteReq(db *Request_Writer, resultCh <-chan *LogEntry) {
func WriteReq(db *RequestWriter, resultCh <-chan *LogEntry) {
db.logger.Info("Starting log writer")
const batchSize = 100
const flushInterval = 1 * time.Second
@@ -14,29 +17,36 @@ func WriteReq(db *Request_Writer, resultCh <-chan *LogEntry) {
defer ticker.Stop()
flush := func() {
defer db.logger.Debug("Flushed batch", "count", len(batch))
err := func() (err error) {
if len(batch) == 0 {
return
return nil
}
tx, err := db.db.Begin()
if err != nil {
db.logger.Error("Failed to begin transaction", "error", err)
return
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer func() {
if rollbackErr := tx.Rollback(); rollbackErr != nil &&
!errors.Is(rollbackErr, sql.ErrTxDone) {
err = errors.Join(
err,
fmt.Errorf("failed to rollback transaction: %w", rollbackErr),
)
}
}()
stmt, err := tx.Prepare(
"INSERT INTO requests (service, ip, path, method, status, created_at) VALUES (?, ?, ?, ?, ?, ?)",
)
if err != nil {
db.logger.Error("Failed to prepare statement", "error", err)
if rollbackErr := tx.Rollback(); rollbackErr != nil {
db.logger.Error("Failed to rollback transaction", "error", rollbackErr)
}
return
err = fmt.Errorf("failed to prepare statement: %w", err)
return err
}
defer func() {
if closeErr := stmt.Close(); closeErr != nil {
db.logger.Error("Failed to close statement", "error", closeErr)
err = errors.Join(err, fmt.Errorf("failed to close statement: %w", closeErr))
}
}()
@@ -50,17 +60,20 @@ func WriteReq(db *Request_Writer, resultCh <-chan *LogEntry) {
time.Now().Format(time.RFC3339),
)
if err != nil {
db.logger.Error("Failed to insert entry", "error", err)
db.logger.Error(fmt.Errorf("failed to insert entry: %w", err).Error())
}
}
if err := tx.Commit(); err != nil {
db.logger.Error("Failed to commit transaction", "error", err)
return
return fmt.Errorf("failed to commit transaction: %w", err)
}
db.logger.Debug("Flushed batch", "count", len(batch))
batch = batch[:0]
return err
}()
if err != nil {
db.logger.Error(err.Error())
}
}
for {
@@ -81,3 +94,13 @@ func WriteReq(db *Request_Writer, resultCh <-chan *LogEntry) {
}
}
}
func (w *RequestWriter) GetRequestCount() (int, error) {
var count int
err := w.db.QueryRow("SELECT COUNT(*) FROM requests").Scan(&count)
return count, err
}
func (w *RequestWriter) Close() error {
return w.db.Close()
}

View File

@@ -277,7 +277,7 @@ func TestWrite_ChannelClosed(t *testing.T) {
}
}
func NewRequestWriterWithDBPath(dbPath string) (*Request_Writer, error) {
func NewRequestWriterWithDBPath(dbPath string) (*RequestWriter, error) {
db, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(NORMAL)")
if err != nil {
return nil, err
@@ -285,13 +285,13 @@ func NewRequestWriterWithDBPath(dbPath string) (*Request_Writer, error) {
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
db.SetConnMaxLifetime(0)
return &Request_Writer{
return &RequestWriter{
logger: logger.New(false),
db: db,
}, nil
}
func (w *Request_Writer) CreateTable() error {
func (w *RequestWriter) CreateTable() error {
_, err := w.db.Exec(CreateRequestsTable)
if err != nil {
return err
@@ -299,21 +299,3 @@ func (w *Request_Writer) CreateTable() error {
w.logger.Info("Created requests table")
return nil
}
func (w *Request_Writer) Close() error {
w.logger.Info("Closing request database connection")
err := w.db.Close()
if err != nil {
return err
}
return nil
}
func (w *Request_Writer) GetRequestCount() (int, error) {
var count int
err := w.db.QueryRow("SELECT COUNT(*) FROM requests").Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}