diff --git a/internal/config/settings.go b/internal/config/settings.go index ca3fddc..088280e 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -1,10 +1,14 @@ package config import ( + "bufio" "encoding/json" "fmt" "log" "os" + "regexp" + "strconv" + "strings" "sync" ) @@ -26,8 +30,13 @@ type AppSettings struct { Sender string `json:"sender"` } -// path to the JSON file (relative to where the app is started) -const settingsFile = "fail2ban-ui-settings.json" +// init paths to key-files +const ( + settingsFile = "fail2ban-ui-settings.json" // this is relative to where the app was started + jailFile = "/etc/fail2ban/jail.local" // Path to jail.local (to override conf-values from jail.conf) + jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf" + actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf" +) // in-memory copy of settings var ( @@ -38,10 +47,10 @@ var ( func init() { // Attempt to load existing file; if it doesn't exist, create with defaults. if err := loadSettings(); err != nil { - fmt.Println("Error loading settings:", err) - fmt.Println("Creating a new settings file with defaults...") - - // set defaults + fmt.Println("App settings not found, initializing new from jail.local (if exist):", err) + if err := initializeFromJailFile(); err != nil { + fmt.Println("Error reading jail.local:", err) + } setDefaults() // save defaults to file @@ -49,6 +58,9 @@ func init() { fmt.Println("Failed to save default settings:", err) } } + if err := initializeFail2banAction(); err != nil { + fmt.Println("Error initializing Fail2ban action:", err) + } } // setDefaults populates default values in currentSettings @@ -56,23 +68,247 @@ func setDefaults() { settingsLock.Lock() defer settingsLock.Unlock() - currentSettings = AppSettings{ - Language: "en", - Debug: false, - ReloadNeeded: false, - AlertCountries: []string{"all"}, - - BantimeIncrement: true, - IgnoreIP: "127.0.0.1/8 ::1 172.16.10.1/24", - Bantime: "48h", - Findtime: "30m", - Maxretry: 3, - Destemail: "admin@swissmakers.ch", - Sender: "noreply@swissmakers.ch", + if currentSettings.Language == "" { + currentSettings.Language = "en" + } + if currentSettings.AlertCountries == nil { + currentSettings.AlertCountries = []string{"all"} + } + if currentSettings.Bantime == "" { + currentSettings.Bantime = "48h" + } + if currentSettings.Findtime == "" { + currentSettings.Findtime = "30m" + } + if currentSettings.Maxretry == 0 { + currentSettings.Maxretry = 3 + } + if currentSettings.Destemail == "" { + currentSettings.Destemail = "alerts@swissmakers.ch" + } + if currentSettings.Sender == "" { + currentSettings.Sender = "noreply@swissmakers.ch" + } + if currentSettings.IgnoreIP == "" { + currentSettings.IgnoreIP = "127.0.0.1/8 ::1" } } -// loadSettings reads the file (if exists) into currentSettings +// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings. +func initializeFromJailFile() error { + file, err := os.Open(jailFile) + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + re := regexp.MustCompile(`^\s*(?P[a-zA-Z0-9_]+)\s*=\s*(?P.+)$`) + + settings := map[string]string{} + for scanner.Scan() { + line := scanner.Text() + if matches := re.FindStringSubmatch(line); matches != nil { + key := strings.ToLower(matches[1]) + value := matches[2] + settings[key] = value + } + } + + settingsLock.Lock() + defer settingsLock.Unlock() + + if val, ok := settings["bantime"]; ok { + currentSettings.Bantime = val + } + if val, ok := settings["findtime"]; ok { + currentSettings.Findtime = val + } + if val, ok := settings["maxretry"]; ok { + if maxRetry, err := strconv.Atoi(val); err == nil { + currentSettings.Maxretry = maxRetry + } + } + if val, ok := settings["ignoreip"]; ok { + currentSettings.IgnoreIP = val + } + if val, ok := settings["destemail"]; ok { + currentSettings.Destemail = val + } + if val, ok := settings["sender"]; ok { + currentSettings.Sender = val + } + + return nil +} + +// initializeFail2banAction writes a custom action configuration for Fail2ban to use AlertCountries. +func initializeFail2banAction() error { + // Ensure the jail.local is configured correctly + if err := setupGeoCustomAction(); err != nil { + fmt.Println("Error setup GeoCustomAction in jail.local:", err) + } + // Ensure the jail.d config file is set up + if err := ensureJailDConfig(); err != nil { + fmt.Println("Error setting up jail.d configuration:", err) + } + // Write the fail2ban action file + return writeFail2banAction(currentSettings.AlertCountries) +} + +// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI +func setupGeoCustomAction() error { + file, err := os.Open(jailFile) + if err != nil { + return err // File not found or inaccessible + } + defer file.Close() + + var lines []string + actionPattern := regexp.MustCompile(`^\s*action\s*=\s*%(.*?)\s*$`) + alreadyModified := false + actionFound := false + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + + // Check if we already modified the file (prevent duplicate modifications) + if strings.Contains(line, "# Custom Fail2Ban action applied") { + alreadyModified = true + } + + // Look for an existing action definition + if actionPattern.MatchString(line) && !alreadyModified { + actionFound = true + + // Comment out the existing action line + lines = append(lines, "# "+line) + + // Add our replacement action with a comment marker + lines = append(lines, "# Custom Fail2Ban action applied by fail2ban-ui") + lines = append(lines, "action = %(action_mwlg)s") + continue + } + + // Store the original line + lines = append(lines, line) + } + + // If no action was found, no need to modify the file + if !actionFound || alreadyModified { + return nil + } + + // Write back the modified lines + output := strings.Join(lines, "\n") + return os.WriteFile(jailFile, []byte(output), 0644) +} + +// ensureJailDConfig checks if the jail.d file exists and creates it if necessary +func ensureJailDConfig() error { + // Check if the file already exists + if _, err := os.Stat(jailDFile); err == nil { + // File already exists, do nothing + fmt.Println("Custom jail.d configuration already exists.") + return nil + } + + // Define the content for the custom jail.d configuration + jailDConfig := `[DEFAULT] +# Custom Fail2Ban action using geo-filter for email alerts + +action_mwlg = %(action_)s + ui-custom-action[sender="%(sender)s", dest="%(destemail)s", logpath="%(logpath)s", chain="%(chain)s"] +` + // Write the new configuration file + err := os.WriteFile(jailDFile, []byte(jailDConfig), 0644) + if err != nil { + return fmt.Errorf("failed to write jail.d config: %v", err) + } + + fmt.Println("Created custom jail.d configuration at:", jailDFile) + return nil +} + +// writeFail2banAction creates or updates the action file with the AlertCountries. +func writeFail2banAction(alertCountries []string) error { + // If "all" is included in AlertCountries, allow all countries + if len(alertCountries) == 1 && strings.ToLower(alertCountries[0]) == "all" { + alertCountries = []string{"CH DE IT FR UK US"} // Match everything + } + + // Convert country list into properly formatted Python set syntax + //countries := strings.Join(alertCountries, "','") + //countriesFormatted := fmt.Sprintf("'%s'", countries) + + // Convert country list into properly formatted Bash syntax + countries := strings.Join(alertCountries, "' '") + countriesFormatted := fmt.Sprintf("' %s '", countries) + + //actionConfig := `[Definition] + //actionstart = + //actionban = python3 -c ' + //import sys + //from geoip import geolite2 + //country = geolite2.lookup(sys.argv[1]).country + //if country in {{ALERT_COUNTRIES}}: + // sys.exit(0) # Send alert + //sys.exit(1) # Do not send alert' + + // Define the Fail2Ban action file content + actionConfig := fmt.Sprintf(`[INCLUDES] + +before = sendmail-common.conf + mail-whois-common.conf + helpers-common.conf + +[Definition] + +# bypass ban/unban for restored tickets +norestored = 1 + +# Option: actionban +# Notes.: command executed when banning an IP. Take care that the +# command is executed with Fail2Ban user rights. + +actionban = bash -c ' + COUNTRY="" + if [[ " %s " =~ " $COUNTRY " ]]; then + ( printf %%%%b "Subject: [Fail2Ban] : banned from \n" + printf "Date: `+"`LC_ALL=C date +\"%%%%a, %%%%d %%%%h %%%%Y %%%%T %%%%z\"`"+`\n" + printf "From: <>\n" + printf "To: \n\n" + printf "Hi,\n" + printf "The IP has just been banned by Fail2Ban after attempts against .\n\n" + printf "Here is more information about :\n" + printf "%%%%(_whois_command)s\n" + printf "\nLines containing failures of (max )\n" + printf "%%%%(_grep_logs)s\n" + printf "\n\nRegards,\nFail2Ban\n" + ) | + fi' + +[Init] + +# Default name of the chain +# +name = default + +# Path to the log files which contain relevant lines for the abuser IP +# +logpath = /dev/null + +# Number of log lines to include in the email +# +#grepmax = 1000 +#grepopts = -m +`, countriesFormatted) + + return os.WriteFile(actionFile, []byte(actionConfig), 0644) +} + +// loadSettings reads fail2ban-ui-settings.json into currentSettings. func loadSettings() error { fmt.Println("----------------------------") fmt.Println("loadSettings called (settings.go)") // entry point @@ -113,7 +349,8 @@ func saveSettings() error { } else { log.Println("Settings saved successfully!") // Debug } - return nil + // Update the Fail2ban action file + return writeFail2banAction(currentSettings.AlertCountries) } // GetSettings returns a copy of the current settings