diff --git a/cmd/banforge/command/rule.go b/cmd/banforge/command/rule.go index 5037f98..0fda84b 100644 --- a/cmd/banforge/command/rule.go +++ b/cmd/banforge/command/rule.go @@ -3,19 +3,22 @@ package command import ( "fmt" "os" + "path/filepath" "github.com/d3m0k1d/BanForge/internal/config" + "github.com/jedib0t/go-pretty/v6/table" "github.com/spf13/cobra" ) var ( - name string - service string - path string - status string - method string - ttl string - max_retry int + name string + service string + path string + status string + method string + ttl string + maxRetry int + editName string ) var RuleCmd = &cobra.Command{ @@ -25,24 +28,25 @@ var RuleCmd = &cobra.Command{ var AddCmd = &cobra.Command{ Use: "add", - Short: "CLI interface for add new rule to file /etc/banforge/rules.toml", + Short: "Add a new rule to /etc/banforge/rules.d/", + Long: "Creates a new rule file in /etc/banforge/rules.d/.toml", Run: func(cmd *cobra.Command, args []string) { if name == "" { - fmt.Printf("Rule name can't be empty\n") + fmt.Println("Rule name can't be empty (use -n flag)") os.Exit(1) } if service == "" { - fmt.Printf("Service name can't be empty\n") + fmt.Println("Service name can't be empty (use -s flag)") os.Exit(1) } if path == "" && status == "" && method == "" { - fmt.Printf("At least 1 rule field must be filled in.") + fmt.Println("At least one rule field must be filled: path, status, or method") os.Exit(1) } if ttl == "" { ttl = "1y" } - err := config.NewRule(name, service, path, status, method, ttl, max_retry) + err := config.NewRule(name, service, path, status, method, ttl, maxRetry) if err != nil { fmt.Println(err) os.Exit(1) @@ -51,37 +55,103 @@ var AddCmd = &cobra.Command{ }, } -var ListCmd = &cobra.Command{ - Use: "list", - Short: "List rules", +var EditCmd = &cobra.Command{ + Use: "edit", + Short: "Edit an existing rule", + Long: "Edit rule fields by name. Only specified fields will be updated.", Run: func(cmd *cobra.Command, args []string) { - r, err := config.LoadRuleConfig() + if editName == "" { + fmt.Println("Rule name is required (use -n flag)") + os.Exit(1) + } + if service == "" && path == "" && status == "" && method == "" { + fmt.Println("At least one field must be specified to edit: -s, -p, -c, or -m") + os.Exit(1) + } + err := config.EditRule(editName, service, path, status, method) if err != nil { fmt.Println(err) os.Exit(1) } - for _, rule := range r { - fmt.Printf( - "Name: %s\nService: %s\nPath: %s\nStatus: %s\n MaxRetry: %d\nMethod: %s\n\n", + fmt.Println("Rule updated successfully!") + }, +} + +var RemoveCmd = &cobra.Command{ + Use: "remove ", + Short: "Remove a rule by name", + Args: cobra.ExactArgs(1), + Run: func(cmd *cobra.Command, args []string) { + ruleName := args[0] + fileName := config.SanitizeRuleFilename(ruleName) + ".toml" + filePath := filepath.Join("/etc/banforge/rules.d", fileName) + + if _, err := os.Stat(filePath); os.IsNotExist(err) { + fmt.Printf("Rule '%s' not found\n", ruleName) + os.Exit(1) + } + + if err := os.Remove(filePath); err != nil { + fmt.Printf("Failed to remove rule: %v\n", err) + os.Exit(1) + } + fmt.Printf("Rule '%s' removed successfully\n", ruleName) + }, +} + +var ListCmd = &cobra.Command{ + Use: "list", + Short: "List all rules", + Run: func(cmd *cobra.Command, args []string) { + rules, err := config.LoadRuleConfig() + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + if len(rules) == 0 { + fmt.Println("No rules found") + return + } + + t := table.NewWriter() + t.SetOutputMirror(os.Stdout) + t.AppendHeader(table.Row{ + "Name", "Service", "Path", "Status", "Method", "MaxRetry", "BanTime", + }) + + for _, rule := range rules { + t.AppendRow(table.Row{ rule.Name, rule.ServiceName, rule.Path, rule.Status, - rule.MaxRetry, rule.Method, - ) + rule.MaxRetry, + rule.BanTime, + }) } + t.Render() }, } func RuleRegister() { RuleCmd.AddCommand(AddCmd) + RuleCmd.AddCommand(EditCmd) + RuleCmd.AddCommand(RemoveCmd) RuleCmd.AddCommand(ListCmd) + AddCmd.Flags().StringVarP(&name, "name", "n", "", "rule name (required)") - AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name") + AddCmd.Flags().StringVarP(&service, "service", "s", "", "service name (required)") AddCmd.Flags().StringVarP(&path, "path", "p", "", "request path") AddCmd.Flags().StringVarP(&status, "status", "c", "", "status code") - AddCmd.Flags().StringVarP(&method, "method", "m", "", "method") - AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time") - AddCmd.Flags().IntVarP(&max_retry, "max_retry", "r", 0, "max retry") + AddCmd.Flags().StringVarP(&method, "method", "m", "", "HTTP method") + AddCmd.Flags().StringVarP(&ttl, "ttl", "t", "", "ban time (e.g., 1h, 1d, 1y)") + AddCmd.Flags().IntVarP(&maxRetry, "max_retry", "r", 0, "max retry before ban") + + EditCmd.Flags().StringVarP(&editName, "name", "n", "", "rule name to edit (required)") + EditCmd.Flags().StringVarP(&service, "service", "s", "", "new service name") + EditCmd.Flags().StringVarP(&path, "path", "p", "", "new path") + EditCmd.Flags().StringVarP(&status, "status", "c", "", "new status code") + EditCmd.Flags().StringVarP(&method, "method", "m", "", "new HTTP method") } diff --git a/docs/cli.md b/docs/cli.md index d6a0901..323730f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,16 +1,24 @@ # CLI commands BanForge -BanForge provides a command-line interface (CLI) to manage IP blocking, + +BanForge provides a command-line interface (CLI) to manage IP blocking, configure detection rules, and control the daemon process. + ## Commands -### init - create a deps file + +### init - Create configuration files ```shell banforge init ``` -**Description** -This command creates the necessary directories and base configuration files -required for the daemon to operate. +**Description** +This command creates the necessary directories and base configuration files +required for the daemon to operate: +- `/etc/banforge/config.toml` — main configuration +- `/etc/banforge/rules.toml` — default rules file +- `/etc/banforge/rules.d/` — directory for individual rule files + +--- ### version - Display BanForge version @@ -18,67 +26,201 @@ required for the daemon to operate. banforge version ``` -**Description** +**Description** This command displays the current version of the BanForge software. +--- + ### daemon - Starts the BanForge daemon process ```shell banforge daemon ``` -**Description** -This command starts the BanForge daemon process in the background. -The daemon continuously monitors incoming requests, detects anomalies, +**Description** +This command starts the BanForge daemon process in the background. +The daemon continuously monitors incoming requests, detects anomalies, and applies firewall rules in real-time. +--- + ### firewall - Manages firewall rules + ```shell banforge ban banforge unban ``` -**Description** +**Description** These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands. -Flag -t or -ttl add bantime if not used default ban 1 year +| Flag | Description | +| ----------- | ------------------------------ | +| `-t`, `-ttl` | Ban duration (default: 1 year) | -### ports - Open and Close ports on firewall +**Examples:** +```bash +# Ban IP for 1 hour +banforge ban 192.168.1.100 -t 1h + +# Unban IP +banforge unban 192.168.1.100 +``` + +--- + +### ports - Open and close ports on firewall ```shell banforge open -port -protocol banforge close -port -protocol ``` -**Description** +**Description** These commands provide an abstraction over your firewall. If you want to simplify the interface to your firewall, you can use these commands. -### list - Lists the IP addresses that are currently blocked +| Flag | Required | Description | +| ------------- | -------- | ------------------------ | +| `-port` | + | Port number (e.g., 80) | +| `-protocol` | + | Protocol (tcp/udp) | + +**Examples:** +```bash +# Open port 80 for TCP +banforge open -port 80 -protocol tcp + +# Close port 443 +banforge close -port 443 -protocol tcp +``` + +--- + +### list - List blocked IP addresses + ```shell banforge list ``` -**Description** -This command output table of IP addresses that are currently blocked +**Description** +This command outputs a table of IP addresses that are currently blocked. -### rule - Manages detection rules +--- + +### rule - Manage detection rules + +Rules are stored in `/etc/banforge/rules.d/` as individual `.toml` files. + +#### Add a new rule ```shell -banforge rule add -n rule.name -c 403 -banforge rule list +banforge rule add -n -s [options] ``` -**Description** -These command help you to create and manage detection rules in CLI interface. +**Flags:** -| Flag | Required | -| ----------- | -------- | -| -n -name | + | -| -s -service | + | -| -p -path | - | -| -m -method | - | -| -c -status | - | -| -t -ttl | -(if not used default ban 1 year) | -| -r -max_retry | - | +| Flag | Required | Description | +| ------------------- | -------- | ---------------------------------------- | +| `-n`, `--name` | + | Rule name (used as filename) | +| `-s`, `--service` | + | Service name (nginx, apache, ssh, etc.) | +| `-p`, `--path` | - | Request path to match | +| `-m`, `--method` | - | HTTP method (GET, POST, etc.) | +| `-c`, `--status` | - | HTTP status code (403, 404, etc.) | +| `-t`, `--ttl` | - | Ban duration (default: 1y) | +| `-r`, `--max_retry` | - | Max retries before ban (default: 0) | -You must specify at least 1 of the optional flags to create a rule. +**Note:** At least one of `-p`, `-m`, or `-c` must be specified. + +**Examples:** +```bash +# Ban on 403 status +banforge rule add -n "Forbidden" -s nginx -c 403 -t 30m + +# Ban on path pattern +banforge rule add -n "Admin Access" -s nginx -p "/admin/*" -t 2h -r 3 + +# SSH brute force protection +banforge rule add -n "SSH Bruteforce" -s ssh -c "Failed" -t 1h -r 5 +``` + +--- + +#### List all rules + +```shell +banforge rule list +``` + +**Description** +Displays all configured rules in a table format. + +**Example output:** +``` ++------------------+---------+--------+--------+--------+----------+---------+ +| NAME | SERVICE | PATH | STATUS | METHOD | MAXRETRY | BANTIME | ++------------------+---------+--------+--------+--------+----------+---------+ +| SSH Bruteforce | ssh | | Failed | | 5 | 1h | +| Nginx 404 | nginx | | 404 | | 3 | 30m | +| Admin Panel | nginx | /admin | | | 2 | 2h | ++------------------+---------+--------+--------+--------+----------+---------+ +``` + +--- + +#### Edit an existing rule + +```shell +banforge rule edit -n [options] +``` + +**Description** +Edit fields of an existing rule. Only specified fields will be updated. + +| Flag | Required | Description | +| ------------------- | -------- | ------------------------------- | +| `-n`, `--name` | + | Rule name to edit | +| `-s`, `--service` | - | New service name | +| `-p`, `--path` | - | New path | +| `-m`, `--method` | - | New method | +| `-c`, `--status` | - | New status code | + +**Examples:** +```bash +# Update ban time for existing rule +banforge rule edit -n "SSH Bruteforce" -t 2h + +# Change status code +banforge rule edit -n "Forbidden" -c 403 +``` + +--- + +#### Remove a rule + +```shell +banforge rule remove +``` + +**Description** +Permanently delete a rule by name. + +**Example:** +```bash +banforge rule remove "Old Rule" +``` + +--- + +## Ban time format + +Use the following suffixes for ban duration: + +| Suffix | Duration | +| ------ | -------- | +| `s` | Seconds | +| `m` | Minutes | +| `h` | Hours | +| `d` | Days | +| `M` | Months (30 days) | +| `y` | Years (365 days) | + +**Examples:** `30s`, `5m`, `2h`, `1d`, `1M`, `1y` diff --git a/internal/config/appconf.go b/internal/config/appconf.go index bd6094b..26436d0 100644 --- a/internal/config/appconf.go +++ b/internal/config/appconf.go @@ -1,15 +1,14 @@ package config import ( - "errors" "fmt" "os" + "path/filepath" "strconv" "strings" "time" "github.com/BurntSushi/toml" - "github.com/d3m0k1d/BanForge/internal/logger" "github.com/d3m0k1d/BanForge/internal/metrics" ) @@ -30,116 +29,151 @@ func LoadMetricsConfig() (*Metrics, error) { } func LoadRuleConfig() ([]Rule, error) { - log := logger.New(false) + const rulesDir = "/etc/banforge/rules.d" + var cfg Rules - _, err := toml.DecodeFile("/etc/banforge/rules.toml", &cfg) + + files, err := os.ReadDir(rulesDir) if err != nil { - log.Error(fmt.Sprintf("failed to decode config: %v", err)) - return nil, err + return nil, fmt.Errorf("failed to read rules directory: %w", err) + } + + for _, file := range files { + if file.IsDir() || !strings.HasSuffix(file.Name(), ".toml") { + continue + } + + filePath := filepath.Join(rulesDir, file.Name()) + var fileCfg Rules + + if _, err := toml.DecodeFile(filePath, &fileCfg); err != nil { + return nil, fmt.Errorf("failed to parse rule file %s: %w", filePath, err) + } + + cfg.Rules = append(cfg.Rules, fileCfg.Rules...) } - log.Info(fmt.Sprintf("loaded %d rules", len(cfg.Rules))) return cfg.Rules, nil } func NewRule( - Name string, - ServiceName string, - Path string, - Status string, - Method string, + name string, + serviceName string, + path string, + status string, + method string, ttl string, - max_retry int, + maxRetry int, ) error { - r, err := LoadRuleConfig() - if err != nil { - r = []Rule{} + if name == "" { + return fmt.Errorf("rule name can't be empty") } - if Name == "" { - fmt.Printf("Rule name can't be empty\n") - return nil + + rule := Rule{ + Name: name, + ServiceName: serviceName, + Path: path, + Status: status, + Method: method, + BanTime: ttl, + MaxRetry: maxRetry, } - r = append( - r, - Rule{ - Name: Name, - ServiceName: ServiceName, - Path: Path, - Status: Status, - Method: Method, - BanTime: ttl, - MaxRetry: max_retry, - }, - ) - file, err := os.Create("/etc/banforge/rules.toml") + + filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml") + + if _, err := os.Stat(filePath); err == nil { + return fmt.Errorf("rule with name '%s' already exists", name) + } + + cfg := Rules{Rules: []Rule{rule}} + + // #nosec G304 - validate by sanitizeRuleFilename + file, err := os.Create(filePath) if err != nil { - return err + return fmt.Errorf("failed to create rule file: %w", err) } defer func() { - err = errors.Join(err, file.Close()) + if closeErr := file.Close(); closeErr != nil { + fmt.Printf("warning: failed to close rule file: %v\n", closeErr) + } }() - cfg := Rules{Rules: r} - err = toml.NewEncoder(file).Encode(cfg) - if err != nil { - return err + + if err := toml.NewEncoder(file).Encode(cfg); err != nil { + return fmt.Errorf("failed to encode rule: %w", err) } + return nil } -func EditRule(Name string, ServiceName string, Path string, Status string, Method string) error { - if Name == "" { - return fmt.Errorf("Rule name can't be empty") +func EditRule(name string, serviceName string, path string, status string, method string) error { + if name == "" { + return fmt.Errorf("rule name can't be empty") } - r, err := LoadRuleConfig() + rules, err := LoadRuleConfig() if err != nil { - return fmt.Errorf("rules is empty, please use 'banforge add rule' or create rules.toml") + return fmt.Errorf("failed to load rules: %w", err) } found := false - for i, rule := range r { - if rule.Name == Name { + var updatedRule *Rule + for i, rule := range rules { + if rule.Name == name { found = true + updatedRule = &rules[i] - if ServiceName != "" { - r[i].ServiceName = ServiceName + if serviceName != "" { + updatedRule.ServiceName = serviceName } - if Path != "" { - r[i].Path = Path + if path != "" { + updatedRule.Path = path } - if Status != "" { - r[i].Status = Status + if status != "" { + updatedRule.Status = status } - if Method != "" { - r[i].Method = Method + if method != "" { + updatedRule.Method = method } break } } if !found { - return fmt.Errorf("rule '%s' not found", Name) + return fmt.Errorf("rule '%s' not found", name) } - file, err := os.Create("/etc/banforge/rules.toml") + filePath := filepath.Join("/etc/banforge/rules.d", SanitizeRuleFilename(name)+".toml") + cfg := Rules{Rules: []Rule{*updatedRule}} + + // #nosec G304 - validate by sanitizeRuleFilename + file, err := os.Create(filePath) if err != nil { - return err + return fmt.Errorf("failed to update rule file: %w", err) } defer func() { - err = file.Close() - if err != nil { - fmt.Println(err) + if closeErr := file.Close(); closeErr != nil { + fmt.Printf("warning: failed to close rule file: %v\n", closeErr) } }() - cfg := Rules{Rules: r} if err := toml.NewEncoder(file).Encode(cfg); err != nil { - return fmt.Errorf("failed to encode config: %w", err) + return fmt.Errorf("failed to encode updated rule: %w", err) } return nil } +func SanitizeRuleFilename(name string) string { + result := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || r == '-' || r == '_' { + return r + } + return '_' + }, name) + return strings.ToLower(result) +} + func ParseDurationWithYears(s string) (time.Duration, error) { if ss, ok := strings.CutSuffix(s, "y"); ok { years, err := strconv.Atoi(ss)