diff --git a/.gitignore b/.gitignore index 6f72f89..f8b9f34 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ go.work.sum # env file .env + +# Project specific +fail2ban-ui-settings.json \ No newline at end of file diff --git a/README.md b/README.md index 5890f6a..328cd55 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Fail2ban UI -A **Go**-powered, **single-page** web interface for [Fail2ban](https://www.fail2ban.org/). +A Swissmade, management interface for [Fail2ban](https://www.fail2ban.org/). It provides a modern dashboard to currently: - View all Fail2ban jails and banned IPs @@ -8,6 +8,7 @@ It provides a modern dashboard to currently: - Edit and save jail/filter configs - Reload Fail2ban when needed - See recent ban events +- More to come... Built by [Swissmakers GmbH](https://swissmakers.ch). diff --git a/cmd/server/main.go b/cmd/server/main.go index a1bce65..7d470e4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -16,7 +16,7 @@ func main() { // Register our routes (IndexHandler, /api/summary, /api/jails/:jail/unban/:ip) web.RegisterRoutes(r) - log.Println("Starting Fail2ban UI on :8080. Run with 'sudo' if fail2ban-client requires it.") + log.Println("Starting Fail2ban-UI server on :8080.") if err := r.Run(":8080"); err != nil { log.Fatalf("Server crashed: %v", err) } diff --git a/internal/config/settings.go b/internal/config/settings.go new file mode 100644 index 0000000..30bf18c --- /dev/null +++ b/internal/config/settings.go @@ -0,0 +1,420 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "bufio" + "encoding/json" + "fmt" + "log" + "os" + "regexp" + "strconv" + "strings" + "sync" +) + +// AppSettings holds both the UI settings (like language) and +// relevant Fail2ban jail/local config options. +type AppSettings struct { + Language string `json:"language"` + Debug bool `json:"debug"` + ReloadNeeded bool `json:"reloadNeeded"` + AlertCountries []string `json:"alertCountries"` + + // These mirror some Fail2ban [DEFAULT] section parameters from jail.local + BantimeIncrement bool `json:"bantimeIncrement"` + IgnoreIP string `json:"ignoreip"` + Bantime string `json:"bantime"` + Findtime string `json:"findtime"` + Maxretry int `json:"maxretry"` + Destemail string `json:"destemail"` + Sender string `json:"sender"` +} + +// 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 ( + currentSettings AppSettings + settingsLock sync.RWMutex +) + +func init() { + // Attempt to load existing file; if it doesn't exist, create with defaults. + if err := loadSettings(); err != nil { + 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 + if err := saveSettings(); err != nil { + 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 +func setDefaults() { + settingsLock.Lock() + defer settingsLock.Unlock() + + 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" + } +} + +// 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 { + + // Join the alertCountries into a comma-separated string + countriesFormatted := strings.Join(alertCountries, ",") + + // 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 +# This executes the Python script with and the list of allowed countries. +# If the country matches the allowed list, it sends the email. +actionban = /etc/fail2ban/action.d/geoip_notify.py "%s" 'name=;ip=;fq-hostname=;sendername=;sender=;dest=;failures=;_whois_command=%%(_whois_command)s;_grep_logs=%%(_grep_logs)s;grepmax=;mailcmd=' + +[Init] + +# Default name of the chain +name = default + +# Path to log files containing relevant lines for the abuser IP +logpath = /dev/null + +# Number of log lines to include in the email +# grepmax = 1000 +# grepopts = -m +`, countriesFormatted) + + // Write the action file + err := os.WriteFile(actionFile, []byte(actionConfig), 0644) + if err != nil { + return fmt.Errorf("failed to write action file: %w", err) + } + + fmt.Printf("Action file successfully written to %s\n", actionFile) + return nil +} + +// loadSettings reads fail2ban-ui-settings.json into currentSettings. +func loadSettings() error { + fmt.Println("----------------------------") + fmt.Println("loadSettings called (settings.go)") // entry point + data, err := os.ReadFile(settingsFile) + if os.IsNotExist(err) { + return err // triggers setDefaults + save + } + if err != nil { + return err + } + + var s AppSettings + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + settingsLock.Lock() + defer settingsLock.Unlock() + currentSettings = s + return nil +} + +// saveSettings writes currentSettings to JSON +func saveSettings() error { + fmt.Println("----------------------------") + fmt.Println("saveSettings called (settings.go)") // entry point + + b, err := json.MarshalIndent(currentSettings, "", " ") + if err != nil { + fmt.Println("Error marshalling settings:", err) // Debug + return err + } + fmt.Println("Settings marshaled, writing to file...") // Log marshaling success + //return os.WriteFile(settingsFile, b, 0644) + err = os.WriteFile(settingsFile, b, 0644) + if err != nil { + log.Println("Error writing to file:", err) // Debug + } else { + log.Println("Settings saved successfully!") // Debug + } + // Update the Fail2ban action file + return writeFail2banAction(currentSettings.AlertCountries) +} + +// GetSettings returns a copy of the current settings +func GetSettings() AppSettings { + settingsLock.RLock() + defer settingsLock.RUnlock() + return currentSettings +} + +// MarkReloadNeeded sets reloadNeeded = true and saves JSON +func MarkReloadNeeded() error { + settingsLock.Lock() + defer settingsLock.Unlock() + + currentSettings.ReloadNeeded = true + return saveSettings() +} + +// MarkReloadDone sets reloadNeeded = false and saves JSON +func MarkReloadDone() error { + settingsLock.Lock() + defer settingsLock.Unlock() + + currentSettings.ReloadNeeded = false + return saveSettings() +} + +// UpdateSettings merges new settings with old and sets reloadNeeded if needed +func UpdateSettings(new AppSettings) (AppSettings, error) { + settingsLock.Lock() + defer settingsLock.Unlock() + + fmt.Println("Locked settings for update") // Log lock acquisition + + old := currentSettings + + // If certain fields change, we mark reload needed + if old.BantimeIncrement != new.BantimeIncrement || + old.IgnoreIP != new.IgnoreIP || + old.Bantime != new.Bantime || + old.Findtime != new.Findtime || + old.Maxretry != new.Maxretry || + old.Destemail != new.Destemail || + old.Sender != new.Sender { + new.ReloadNeeded = true + } else { + // preserve previous ReloadNeeded if it was already true + new.ReloadNeeded = new.ReloadNeeded || old.ReloadNeeded + } + + // Countries change? Currently also requires a reload + if !equalStringSlices(old.AlertCountries, new.AlertCountries) { + new.ReloadNeeded = true + } + + currentSettings = new + fmt.Println("New settings applied:", currentSettings) // Log settings applied + + // persist to file + if err := saveSettings(); err != nil { + fmt.Println("Error saving settings:", err) // Log save error + return currentSettings, err + } + fmt.Println("Settings saved to file successfully") // Log save success + return currentSettings, nil +} + +func equalStringSlices(a, b []string) bool { + if len(a) != len(b) { + return false + } + m := make(map[string]bool) + for _, x := range a { + m[x] = false + } + for _, x := range b { + if _, ok := m[x]; !ok { + return false + } + } + return true +} diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index ef6cd18..654aa8b 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -1,19 +1,35 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package fail2ban import ( "fmt" - "io/ioutil" - "os/exec" - "path/filepath" + "os" + "os/exec" + "path/filepath" "strings" "time" ) type JailInfo struct { - JailName string `json:"jailName"` - TotalBanned int `json:"totalBanned"` - NewInLastHour int `json:"newInLastHour"` - BannedIPs []string `json:"bannedIPs"` + JailName string `json:"jailName"` + TotalBanned int `json:"totalBanned"` + NewInLastHour int `json:"newInLastHour"` + BannedIPs []string `json:"bannedIPs"` } // GetJails returns all configured jails using "fail2ban-client status". @@ -21,7 +37,7 @@ func GetJails() ([]string, error) { cmd := exec.Command("fail2ban-client", "status") out, err := cmd.CombinedOutput() if err != nil { - return nil, fmt.Errorf("could not run 'fail2ban-client status': %v", err) + return nil, fmt.Errorf("could not get jail information. is fail2ban running? error: %v", err) } var jails []string @@ -128,29 +144,29 @@ func BuildJailInfos(logPath string) ([]JailInfo, error) { // Example: we assume each jail config is at /etc/fail2ban/filter.d/.conf // Adapt this to your environment. func GetJailConfig(jail string) (string, error) { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - content, err := ioutil.ReadFile(configPath) - if err != nil { - return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err) - } - return string(content), nil + configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") + content, err := os.ReadFile(configPath) + if err != nil { + return "", fmt.Errorf("failed to read config for jail %s: %v", jail, err) + } + return string(content), nil } // SetJailConfig overwrites the config file for a given jail with new content. func SetJailConfig(jail, newContent string) error { - configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") - if err := ioutil.WriteFile(configPath, []byte(newContent), 0644); err != nil { - return fmt.Errorf("failed to write config for jail %s: %v", jail, err) - } - return nil + configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf") + if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil { + return fmt.Errorf("failed to write config for jail %s: %v", jail, err) + } + return nil } // ReloadFail2ban runs "fail2ban-client reload" func ReloadFail2ban() error { - cmd := exec.Command("fail2ban-client", "reload") - out, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out) - } - return nil -} \ No newline at end of file + cmd := exec.Command("fail2ban-client", "reload") + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("fail2ban reload error: %v\nOutput: %s", err, out) + } + return nil +} diff --git a/internal/fail2ban/logparse.go b/internal/fail2ban/logparse.go index caeed1d..e77913b 100644 --- a/internal/fail2ban/logparse.go +++ b/internal/fail2ban/logparse.go @@ -1,3 +1,19 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package fail2ban import ( @@ -5,7 +21,6 @@ import ( "fmt" "os" "regexp" - //"strings" "time" ) @@ -69,31 +84,3 @@ func ParseBanLog(logPath string) (map[string][]BanEvent, error) { } return eventsByJail, nil } - -// GetLastFiveBans crawls the parse results to find the last 5 ban events overall. -func GetLastFiveBans(eventsByJail map[string][]BanEvent) []BanEvent { - var allEvents []BanEvent - for _, events := range eventsByJail { - allEvents = append(allEvents, events...) - } - - // Sort by time descending - // (We want the latest 5 ban events) - sortByTimeDesc(allEvents) - - if len(allEvents) > 5 { - return allEvents[:5] - } - return allEvents -} - -// A simple in-file sorting utility -func sortByTimeDesc(events []BanEvent) { - for i := 0; i < len(events); i++ { - for j := i + 1; j < len(events); j++ { - if events[j].Time.After(events[i].Time) { - events[i], events[j] = events[j], events[i] - } - } - } -} diff --git a/internal/geoip_notify.py b/internal/geoip_notify.py new file mode 100644 index 0000000..d9cf4d5 --- /dev/null +++ b/internal/geoip_notify.py @@ -0,0 +1,132 @@ +#!/usr/bin/python3.9 +""" +Fail2ban UI - A Swiss made, management interface for Fail2ban. + +Copyright (C) 2025 Swissmakers GmbH + +Licensed under the GNU General Public License, Version 3 (GPL-3.0) +You may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.gnu.org/licenses/gpl-3.0.en.html + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +""" + +# This file is for testing purposes only. +# (Must be copied to "/etc/fail2ban/action.d/geoip_notify.py") +#python3.9 -c "import maxminddb; print('maxminddb is installed successfully')" + +import sys +import subprocess + +# Manually set Python path where maxminddb is installed +sys.path.append("/usr/local/lib64/python3.9/site-packages/") + +try: + import maxminddb +except ImportError: + print("Error: maxminddb module not found, even after modifying PYTHONPATH.") + sys.exit(1) + + +# Path to MaxMind GeoIP2 database +GEOIP_DB_PATH = "/usr/share/GeoIP/GeoLite2-Country.mmdb" + +def get_country(ip): + """ + Perform a GeoIP lookup to get the country code from an IP address. + Returns the country code (e.g., "CH") or None if lookup fails. + """ + try: + with maxminddb.open_database(GEOIP_DB_PATH) as reader: + geo_data = reader.get(ip) + if geo_data and "country" in geo_data and "iso_code" in geo_data["country"]: + return geo_data["country"]["iso_code"] + except Exception as e: + print(f"GeoIP lookup failed: {e}", file=sys.stderr) + return None + +def parse_placeholders(placeholder_str): + """ + Parses Fail2Ban placeholders passed as a string in "key=value" format. + Returns a dictionary. + """ + placeholders = {} + for item in placeholder_str.split(";"): + key_value = item.split("=", 1) + if len(key_value) == 2: + key, value = key_value + placeholders[key.strip()] = value.strip() + return placeholders + +def send_email(placeholders): + """ + Generates and sends the email alert using sendmail. + """ + email_content = f"""Subject: [Fail2Ban] {placeholders['name']}: banned {placeholders['ip']} from {placeholders['fq-hostname']} +Date: $(LC_ALL=C date +"%a, %d %h %Y %T %z") +From: {placeholders['sendername']} <{placeholders['sender']}> +To: {placeholders['dest']} + +Hi, + +The IP {placeholders['ip']} has just been banned by Fail2Ban after {placeholders['failures']} attempts against {placeholders['name']}. + +Here is more information about {placeholders['ip']}: +{subprocess.getoutput(placeholders['_whois_command'])} + +Lines containing failures of {placeholders['ip']} (max {placeholders['grepmax']}): +{subprocess.getoutput(placeholders['_grep_logs'])} + +Regards, +Fail2Ban""" + + try: + subprocess.run( + ["/usr/sbin/sendmail", "-f", placeholders["sender"], placeholders["dest"]], + input=email_content, + text=True, + check=True + ) + print("Email sent successfully.") + except subprocess.CalledProcessError as e: + print(f"Failed to send email: {e}", file=sys.stderr) + +def main(ip, allowed_countries, placeholder_str): + """ + Main function to check the IP's country and send an email if it matches the allowed list. + """ + allowed_countries = allowed_countries.split(",") + placeholders = parse_placeholders(placeholder_str) + + # Perform GeoIP lookup + country = get_country(ip) + if not country: + print(f"Could not determine country for IP {ip}", file=sys.stderr) + sys.exit(1) + + print(f"IP {ip} belongs to country: {country}") + + # If the country is in the allowed list or "ALL" is selected, send the email + if "ALL" in allowed_countries or country in allowed_countries: + print(f"IP {ip} is in the alert countries list. Sending email...") + send_email(placeholders) + else: + print(f"IP {ip} is NOT in the alert countries list. No email sent.") + sys.exit(0) # Exit normally without error + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: geoip_notify.py ", file=sys.stderr) + sys.exit(1) + + ip = sys.argv[1] + allowed_countries = sys.argv[2] + placeholders = sys.argv[3] + + main(ip, allowed_countries, placeholders) diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 1f76a1e..c4eb0fb 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -1,17 +1,37 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package web import ( + "fmt" "net/http" + "os" + "strings" "time" "github.com/gin-gonic/gin" + "github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/fail2ban" ) // SummaryResponse is what we return from /api/summary type SummaryResponse struct { - Jails []fail2ban.JailInfo `json:"jails"` - LastBans []fail2ban.BanEvent `json:"lastBans"` + Jails []fail2ban.JailInfo `json:"jails"` + LastBans []fail2ban.BanEvent `json:"lastBans"` } // SummaryHandler returns a JSON summary of all jails, including @@ -53,6 +73,8 @@ func SummaryHandler(c *gin.Context) { // UnbanIPHandler unbans a given IP in a specific jail. func UnbanIPHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("UnbanIPHandler called (handlers.go)") // entry point jail := c.Param("jail") ip := c.Param("ip") @@ -63,6 +85,7 @@ func UnbanIPHandler(c *gin.Context) { }) return } + fmt.Println(ip + " from jail " + jail + " unbanned successfully (handlers.go)") c.JSON(http.StatusOK, gin.H{ "message": "IP unbanned successfully", }) @@ -78,53 +101,191 @@ func sortByTimeDesc(events []fail2ban.BanEvent) { } } -// IndexHandler serves the main HTML page +// IndexHandler serves the HTML page func IndexHandler(c *gin.Context) { c.HTML(http.StatusOK, "index.html", gin.H{ "timestamp": time.Now().Format(time.RFC1123), }) } -// GetJailConfigHandler returns the raw config for a given jail -func GetJailConfigHandler(c *gin.Context) { - jail := c.Param("jail") - cfg, err := fail2ban.GetJailConfig(jail) - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{ - "jail": jail, - "config": cfg, - }) +// GetJailFilterConfigHandler returns the raw filter config for a given jail +func GetJailFilterConfigHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("GetJailFilterConfigHandler called (handlers.go)") // entry point + jail := c.Param("jail") + cfg, err := fail2ban.GetJailConfig(jail) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{ + "jail": jail, + "config": cfg, + }) } -// SetJailConfigHandler overwrites the jail config with new content -func SetJailConfigHandler(c *gin.Context) { - jail := c.Param("jail") +// SetJailFilterConfigHandler overwrites the current filter config with new content +func SetJailFilterConfigHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("SetJailFilterConfigHandler called (handlers.go)") // entry point + jail := c.Param("jail") - var req struct { - Config string `json:"config"` - } - if err := c.ShouldBindJSON(&req); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) - return - } + // Parse JSON body (containing the new filter content) + var req struct { + Config string `json:"config"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"}) + return + } - if err := fail2ban.SetJailConfig(jail, req.Config); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } + // Write the filter config file to /etc/fail2ban/filter.d/.conf + if err := fail2ban.SetJailConfig(jail, req.Config); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } - c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) + // Mark reload needed in our UI settings + // if err := config.MarkReloadNeeded(); err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // return + // } + + c.JSON(http.StatusOK, gin.H{"message": "jail config updated"}) + + // Return a simple JSON response without forcing a blocking alert + // c.JSON(http.StatusOK, gin.H{ + // "message": "Filter updated, reload needed", + // "reloadNeeded": true, + // }) +} + +// GetSettingsHandler returns the entire AppSettings struct as JSON +func GetSettingsHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("GetSettingsHandler called (handlers.go)") // entry point + s := config.GetSettings() + c.JSON(http.StatusOK, s) +} + +// UpdateSettingsHandler updates the AppSettings from a JSON body +func UpdateSettingsHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("UpdateSettingsHandler called (handlers.go)") // entry point + var req config.AppSettings + if err := c.ShouldBindJSON(&req); err != nil { + fmt.Println("JSON binding error:", err) // Debug + c.JSON(http.StatusBadRequest, gin.H{ + "error": "invalid JSON", + "details": err.Error(), + }) + return + } + fmt.Println("JSON binding successful, updating settings (handlers.go)") + + newSettings, err := config.UpdateSettings(req) + if err != nil { + fmt.Println("Error updating settings:", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + fmt.Println("Settings updated successfully (handlers.go)") + + c.JSON(http.StatusOK, gin.H{ + "message": "Settings updated", + "reloadNeeded": newSettings.ReloadNeeded, + }) +} + +// ListFiltersHandler returns a JSON array of filter names +// found as *.conf in /etc/fail2ban/filter.d +func ListFiltersHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("ListFiltersHandler called (handlers.go)") // entry point + dir := "/etc/fail2ban/filter.d" + + files, err := os.ReadDir(dir) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{ + "error": "Failed to read filter directory: " + err.Error(), + }) + return + } + + var filters []string + for _, f := range files { + if !f.IsDir() && strings.HasSuffix(f.Name(), ".conf") { + name := strings.TrimSuffix(f.Name(), ".conf") + filters = append(filters, name) + } + } + + c.JSON(http.StatusOK, gin.H{"filters": filters}) +} + +func TestFilterHandler(c *gin.Context) { + fmt.Println("----------------------------") + fmt.Println("TestFilterHandler called (handlers.go)") // entry point + var req struct { + FilterName string `json:"filterName"` + LogLines []string `json:"logLines"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"}) + return + } + + // For now, just pretend nothing matches + c.JSON(http.StatusOK, gin.H{"matches": []string{}}) +} + +// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON +func ApplyFail2banSettings(jailLocalPath string) error { + fmt.Println("----------------------------") + fmt.Println("ApplyFail2banSettings called (handlers.go)") // entry point + s := config.GetSettings() + + // open /etc/fail2ban/jail.local, parse or do a simplistic approach: + // TODO: -> maybe we store [DEFAULT] block in memory, replace lines + // or do a line-based approach. Example is simplistic: + + newLines := []string{ + "[DEFAULT]", + fmt.Sprintf("bantime.increment = %t", s.BantimeIncrement), + fmt.Sprintf("ignoreip = %s", s.IgnoreIP), + fmt.Sprintf("bantime = %s", s.Bantime), + fmt.Sprintf("findtime = %s", s.Findtime), + fmt.Sprintf("maxretry = %d", s.Maxretry), + fmt.Sprintf("destemail = %s", s.Destemail), + fmt.Sprintf("sender = %s", s.Sender), + "", + } + content := strings.Join(newLines, "\n") + + return os.WriteFile(jailLocalPath, []byte(content), 0644) } // ReloadFail2banHandler reloads the Fail2ban service func ReloadFail2banHandler(c *gin.Context) { - err := fail2ban.ReloadFail2ban() - if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) - return - } - c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"}) -} \ No newline at end of file + fmt.Println("----------------------------") + fmt.Println("ApplyFail2banSettings called (handlers.go)") // entry point + + // First we write our new settings to /etc/fail2ban/jail.local + // if err := fail2ban.ApplyFail2banSettings("/etc/fail2ban/jail.local"); err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + // return + // } + + // Then reload + if err := fail2ban.ReloadFail2ban(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // We set reload done in config + if err := config.MarkReloadDone(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"}) +} diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 2d85923..674d693 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -1,3 +1,19 @@ +// Fail2ban UI - A Swiss made, management interface for Fail2ban. +// +// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch) +// +// Licensed under the GNU General Public License, Version 3 (GPL-3.0) +// You may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.gnu.org/licenses/gpl-3.0.en.html +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + package web import ( @@ -9,16 +25,26 @@ func RegisterRoutes(r *gin.Engine) { // Render the dashboard r.GET("/", IndexHandler) - api := r.Group("/api") - { - api.GET("/summary", SummaryHandler) - api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) + api := r.Group("/api") + { + api.GET("/summary", SummaryHandler) + api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - // New config endpoints - api.GET("/jails/:jail/config", GetJailConfigHandler) - api.POST("/jails/:jail/config", SetJailConfigHandler) + // config endpoints + api.GET("/jails/:jail/config", GetJailFilterConfigHandler) + api.POST("/jails/:jail/config", SetJailFilterConfigHandler) - // Reload endpoint - api.POST("/fail2ban/reload", ReloadFail2banHandler) - } + // settings + api.GET("/settings", GetSettingsHandler) + api.POST("/settings", UpdateSettingsHandler) + + // filter debugger + api.GET("/filters", ListFiltersHandler) + api.POST("/filters/test", TestFilterHandler) + // TODO create or generate new filters + // api.POST("/filters/generate", GenerateFilterHandler) + + // Reload endpoint + api.POST("/fail2ban/reload", ReloadFail2banHandler) + } } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 94195f7..63ea496 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1,7 +1,26 @@ + + + Fail2ban UI Dashboard + - + + + +
@@ -48,11 +90,122 @@
-
+ + + + + +

Dashboard

+ + + + + + +

@@ -64,6 +217,9 @@

+ + +
@@ -95,6 +251,7 @@
+