Implement basic jail management (turn off and on) and fix some old stuff

This commit is contained in:
2025-02-26 16:55:21 +01:00
parent e19c24de08
commit 25238bf83c
12 changed files with 295 additions and 42 deletions

View File

@@ -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/<jail>.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
}

View File

@@ -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/<jailname>.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
}

View File

@@ -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)
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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"
}

View File

@@ -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/<jail>.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 {

View File

@@ -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)

View File

@@ -109,7 +109,7 @@
<div id="dashboardSection" class="container my-4">
<div class="d-flex align-items-center" style="position: relative;">
<h1 class="mb-4 flex-grow-1" data-i18n="dashboard.title">Dashboard</h1>
<button class="btn btn-outline-secondary" style="position: absolute; right: 0; top: 0;" onclick="openManageJailsModal()" data-i18n="dashboard.manage_jails" title="This feature is currently being implemented." disabled>Manage Jails</button>
<button class="btn btn-outline-secondary" style="position: absolute; right: 0; top: 0;" onclick="openManageJailsModal()" data-i18n="dashboard.manage_jails">Manage Jails</button>
</div>
<div id="dashboard"></div>
</div>
@@ -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 = '<div class="list-group">';
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 += '<div class="list-group-item d-flex justify-content-between align-items-center">';
html += '<span>' + jail.jailName + '</span>';
html += '<div class="form-check form-switch">';
@@ -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) {