diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index 546ff7e..49ae4bb 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -17,10 +17,9 @@ package fail2ban import ( + "errors" "fmt" - "os" "os/exec" - "path/filepath" "strings" "time" ) @@ -30,9 +29,10 @@ type JailInfo struct { TotalBanned int `json:"totalBanned"` NewInLastHour int `json:"newInLastHour"` BannedIPs []string `json:"bannedIPs"` + Enabled bool `json:"enabled"` } -// GetJails returns all configured jails using "fail2ban-client status". +// Get active jails using "fail2ban-client status". func GetJails() ([]string, error) { cmd := exec.Command("fail2ban-client", "status") out, err := cmd.CombinedOutput() @@ -140,33 +140,33 @@ func BuildJailInfos(logPath string) ([]JailInfo, error) { return results, nil } -// GetJailConfig returns the config content for a given jail. -// 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 := 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 := 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 fmt.Errorf("fail2ban reload error: %v\noutput: %s", err, out) } return nil } + +// RestartFail2ban restarts the Fail2ban service. +func RestartFail2ban() error { + cmd := "systemctl restart fail2ban" + out, err := execCommand(cmd) + if err != nil { + return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out) + } + return nil +} + +// execCommand is a helper function to execute shell commands. +func execCommand(command string) (string, error) { + parts := strings.Fields(command) + if len(parts) == 0 { + return "", errors.New("no command provided") + } + cmd := exec.Command(parts[0], parts[1:]...) + out, err := cmd.CombinedOutput() + return string(out), err +} diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go new file mode 100644 index 0000000..45eda8b --- /dev/null +++ b/internal/fail2ban/filter_management.go @@ -0,0 +1,44 @@ +// 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" + "os" + "path/filepath" +) + +// GetFilterConfig returns the config content for a given jail filter. +// Example: we assume each jail config is at /etc/fail2ban/filter.d/.conf +// Adapt this to your environment. +func GetFilterConfig(jail string) (string, error) { + 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 +} + +// SetFilterConfig overwrites the config file for a given jail with new content. +func SetFilterConfig(jail, newContent string) error { + 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 +} diff --git a/internal/fail2ban/jail_management.go b/internal/fail2ban/jail_management.go new file mode 100644 index 0000000..9f40cf8 --- /dev/null +++ b/internal/fail2ban/jail_management.go @@ -0,0 +1,150 @@ +package fail2ban + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/swissmakers/fail2ban-ui/internal/config" +) + +// GetAllJails reads jails from both /etc/fail2ban/jail.local and /etc/fail2ban/jail.d directory. +func GetAllJails() ([]JailInfo, error) { + var jails []JailInfo + + // Parse jails from jail.local + localPath := "/etc/fail2ban/jail.local" + localJails, err := parseJailConfigFile(localPath) + if err != nil { + return nil, fmt.Errorf("failed to parse %s: %w", localPath, err) + } + config.DebugLog("############################") + config.DebugLog(fmt.Sprintf("%+v", localJails)) + config.DebugLog("############################") + + jails = append(jails, localJails...) + + // Parse jails from jail.d directory, if it exists + jailDPath := "/etc/fail2ban/jail.d" + files, err := os.ReadDir(jailDPath) + if err == nil { + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { + fullPath := filepath.Join(jailDPath, f.Name()) + dJails, err := parseJailConfigFile(fullPath) + if err == nil { + jails = append(jails, dJails...) + } + } + } + } + return jails, nil +} + +// parseJailConfigFile parses a jail configuration file and returns a slice of JailInfo. +// It assumes each jail section is defined by [JailName] and that an "enabled" line may exist. +func parseJailConfigFile(path string) ([]JailInfo, error) { + var jails []JailInfo + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentJail string + + // default value is true if "enabled" is missing; we set it for each section. + enabled := true + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + // When a new section starts, save the previous jail if exists. + if currentJail != "" && currentJail != "DEFAULT" { + jails = append(jails, JailInfo{ + JailName: currentJail, + Enabled: enabled, + }) + } + // Start a new jail section. + currentJail = strings.Trim(line, "[]") + // Reset to default for the new section. + enabled = true + } else if strings.HasPrefix(strings.ToLower(line), "enabled") { + // Expect format: enabled = true/false + parts := strings.Split(line, "=") + if len(parts) == 2 { + value := strings.TrimSpace(parts[1]) + enabled = strings.EqualFold(value, "true") + } + } + } + // Add the final jail if one exists. + if currentJail != "" && currentJail != "DEFAULT" { + jails = append(jails, JailInfo{ + JailName: currentJail, + Enabled: enabled, + }) + } + return jails, scanner.Err() +} + +// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map. +// It updates /etc/fail2ban/jail.local and attempts to update any jail.d files as well. +func UpdateJailEnabledStates(updates map[string]bool) error { + // Update jail.local file + localPath := "/etc/fail2ban/jail.local" + if err := updateJailConfigFile(localPath, updates); err != nil { + return fmt.Errorf("failed to update %s: %w", localPath, err) + } + // Update jail.d files (if any) + jailDPath := "/etc/fail2ban/jail.d" + files, err := os.ReadDir(jailDPath) + if err == nil { + for _, f := range files { + if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" { + fullPath := filepath.Join(jailDPath, f.Name()) + // Ignore error here, as jail.d files might not need to be updated. + _ = updateJailConfigFile(fullPath, updates) + } + } + } + return nil +} + +// updateJailConfigFile updates a single jail configuration file with the new enabled states. +func updateJailConfigFile(path string, updates map[string]bool) error { + input, err := os.ReadFile(path) + if err != nil { + return err + } + lines := strings.Split(string(input), "\n") + var outputLines []string + var currentJail string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") { + currentJail = strings.Trim(trimmed, "[]") + outputLines = append(outputLines, line) + } else if strings.HasPrefix(trimmed, "enabled") { + if val, ok := updates[currentJail]; ok { + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", val)) + // Remove the update from map to mark it as processed. + delete(updates, currentJail) + } else { + outputLines = append(outputLines, line) + } + } else { + outputLines = append(outputLines, line) + } + } + // For any jails in updates that did not have an "enabled" line, append it. + for jail, val := range updates { + outputLines = append(outputLines, fmt.Sprintf("[%s]", jail)) + outputLines = append(outputLines, fmt.Sprintf("enabled = %t", val)) + } + newContent := strings.Join(outputLines, "\n") + return os.WriteFile(path, []byte(newContent), 0644) +} diff --git a/internal/locales/de.json b/internal/locales/de.json index 46c0254..781ff0a 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -64,6 +64,8 @@ "modal.filter_config": "Filter-Konfiguration:", "modal.cancel": "Abbrechen", "modal.save": "Speichern", - "loading": "Lade..." + "loading": "Lade...", + "dashboard.manage_jails": "Jails verwalten", + "modal.manage_jails_title": "Jails verwalten" } \ No newline at end of file diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 3ff6061..4443197 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -64,6 +64,8 @@ "modal.filter_config": "Filter-Konfiguration:", "modal.cancel": "Abbräche", "modal.save": "Speicherä", - "loading": "Lade..." + "loading": "Lade...", + "dashboard.manage_jails": "Jails ala oder absteue", + "modal.manage_jails_title": "Jails ala oder absteue" } \ No newline at end of file diff --git a/internal/locales/en.json b/internal/locales/en.json index 9008812..abb3941 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -64,6 +64,8 @@ "modal.filter_config": "Filter Config:", "modal.cancel": "Cancel", "modal.save": "Save", - "loading": "Loading..." + "loading": "Loading...", + "dashboard.manage_jails": "Manage Jails", + "modal.manage_jails_title": "Manage Jails" } \ No newline at end of file diff --git a/internal/locales/es.json b/internal/locales/es.json index fef403b..8e3ba08 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -64,5 +64,7 @@ "modal.filter_config": "Configuración del filtro:", "modal.cancel": "Cancelar", "modal.save": "Guardar", - "loading": "Cargando..." + "loading": "Cargando...", + "dashboard.manage_jails": "Administrar jails", + "modal.manage_jails_title": "Administrar jails" } diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 9d175e3..e4045c7 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -64,5 +64,7 @@ "modal.filter_config": "Configuration du filtre:", "modal.cancel": "Annuler", "modal.save": "Enregistrer", - "loading": "Chargement..." + "loading": "Chargement...", + "dashboard.manage_jails": "Gérer les jails", + "modal.manage_jails_title": "Gérer les jails" } diff --git a/internal/locales/it.json b/internal/locales/it.json index ca1f1ca..a55cabb 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -64,5 +64,7 @@ "modal.filter_config": "Configurazione del filtro:", "modal.cancel": "Annulla", "modal.save": "Salva", - "loading": "Caricamento..." + "loading": "Caricamento...", + "dashboard.manage_jails": "Gestire i jails", + "modal.manage_jails_title": "Gestire i jails" } diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 3ee7017..b941feb 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -233,7 +233,7 @@ func GetJailFilterConfigHandler(c *gin.Context) { config.DebugLog("----------------------------") config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point jail := c.Param("jail") - cfg, err := fail2ban.GetJailConfig(jail) + cfg, err := fail2ban.GetFilterConfig(jail) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return @@ -260,7 +260,7 @@ func SetJailFilterConfigHandler(c *gin.Context) { } // Write the filter config file to /etc/fail2ban/filter.d/.conf - if err := fail2ban.SetJailConfig(jail, req.Config); err != nil { + if err := fail2ban.SetFilterConfig(jail, req.Config); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } @@ -280,6 +280,49 @@ func SetJailFilterConfigHandler(c *gin.Context) { // }) } +// ManageJailsHandler returns a list of all jails (from jail.local and jail.d) +// including their enabled status. +func ManageJailsHandler(c *gin.Context) { + config.DebugLog("----------------------------") + config.DebugLog("ManageJailsHandler called (handlers.go)") // entry point + // Get all jails from jail.local and jail.d directories. + // This helper should parse both files and return []fail2ban.JailInfo. + jails, err := fail2ban.GetAllJails() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jails: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"jails": jails}) +} + +// UpdateJailManagementHandler updates the enabled state for each jail. +// Expected JSON format: { "JailName1": true, "JailName2": false, ... } +// After updating, the Fail2ban service is restarted. +func UpdateJailManagementHandler(c *gin.Context) { + config.DebugLog("----------------------------") + config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point + var updates map[string]bool + if err := c.ShouldBindJSON(&updates); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()}) + return + } + // Update jail configuration file(s) with the new enabled states. + if err := fail2ban.UpdateJailEnabledStates(updates); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()}) + return + } + // Restart the Fail2ban service. + //if err := fail2ban.RestartFail2ban(); err != nil { + // c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload fail2ban: " + err.Error()}) + // return + //} + if err := config.MarkReloadNeeded(); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated successfully"}) +} + // GetSettingsHandler returns the entire AppSettings struct as JSON func GetSettingsHandler(c *gin.Context) { config.DebugLog("----------------------------") @@ -429,7 +472,7 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error { smtpHost := settings.SMTP.Host smtpPort := settings.SMTP.Port auth := LoginAuth(settings.SMTP.Username, settings.SMTP.Password) - smtpAddr := fmt.Sprintf("%s:%d", smtpHost, smtpPort) + smtpAddr := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort)) // **Choose Connection Type** if smtpPort == 465 { diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 357a201..0765c29 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -34,10 +34,14 @@ func RegisterRoutes(r *gin.Engine) { api.GET("/summary", SummaryHandler) api.POST("/jails/:jail/unban/:ip", UnbanIPHandler) - // Config endpoints + // Routes for jail-filter management (TODO: rename API-call) api.GET("/jails/:jail/config", GetJailFilterConfigHandler) api.POST("/jails/:jail/config", SetJailFilterConfigHandler) + // Routes for jail management + api.GET("/jails/manage", ManageJailsHandler) + api.POST("/jails/manage", UpdateJailManagementHandler) + // Settings endpoints api.GET("/settings", GetSettingsHandler) api.POST("/settings", UpdateSettingsHandler) @@ -46,6 +50,7 @@ func RegisterRoutes(r *gin.Engine) { // Filter debugger endpoints api.GET("/filters", ListFiltersHandler) api.POST("/filters/test", TestFilterHandler) + // TODO: create or generate new filters // api.POST("/filters/generate", GenerateFilterHandler) diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 459f2ff..31f018f 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -109,7 +109,7 @@

Dashboard

- +
@@ -844,10 +844,10 @@ } // Function: openManageJailsModal - // Fetches the list of jails (from /api/summary) and builds a list with toggle switches. + // Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches. function openManageJailsModal() { showLoading(true); - fetch('/api/summary') + fetch('/api/jails/manage') .then(function(res) { return res.json(); }) .then(function(data) { if (!data.jails || data.jails.length === 0) { @@ -857,8 +857,7 @@ } var html = '
'; data.jails.forEach(function(jail) { - // If "enabled" is missing, assume true. - var isEnabled = (jail.enabled === undefined || jail.enabled === true); + var isEnabled = (jail.enabled === true); html += '
'; html += '' + jail.jailName + ''; html += '
'; @@ -901,8 +900,8 @@ if (data.error) { alert("Error saving jail settings: " + data.error); } else { - alert("Jail settings updated successfully."); - fetchSummary(); // Optionally we refresh the dashboard. + // A restart of fail2ban is needed, to enable or disable jails - a reload is not enough + document.getElementById('reloadBanner').style.display = 'block'; } }) .catch(function(err) {