Restructure an adding basic sections 2/2

This commit is contained in:
2026-02-19 19:24:18 +01:00
parent 2169b9862f
commit 9fe7276073
11 changed files with 1125 additions and 1995 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -16,14 +16,22 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
// =========================================================================
// Types
// =========================================================================
// Connector for a remote Fail2ban-Agent via HTTP API.
type AgentConnector struct {
server config.Fail2banServer
base *url.URL
client *http.Client
}
// NewAgentConnector constructs a new AgentConnector.
// =========================================================================
// Constructor
// =========================================================================
// Create a new AgentConnector for the given server config.
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
if server.AgentURL == "" {
return nil, fmt.Errorf("agentUrl is required for agent connector")
@@ -52,6 +60,10 @@ func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
return conn, nil
}
// =========================================================================
// Connector Functions
// =========================================================================
func (ac *AgentConnector) ID() string {
return ac.server.ID
}
@@ -114,8 +126,6 @@ func (ac *AgentConnector) Restart(ctx context.Context) error {
return ac.post(ctx, "/v1/actions/restart", nil, nil)
}
// RestartWithMode restarts the remote agent-managed Fail2ban service and
// always reports mode "restart". Any error is propagated to the caller.
func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
if err := ac.Restart(ctx); err != nil {
return "restart", err
@@ -123,6 +133,10 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", nil
}
// =========================================================================
// Filter Operations
// =========================================================================
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct {
Config string `json:"config"`
@@ -131,10 +145,8 @@ func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (str
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
return "", "", err
}
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
}
return resp.Config, filePath, nil
@@ -184,6 +196,10 @@ func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
return result, nil
}
// =========================================================================
// HTTP Helpers
// =========================================================================
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
@@ -280,7 +296,10 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
return json.Unmarshal(data, out)
}
// GetAllJails implements Connector.
// =========================================================================
// Jail Operations
// =========================================================================
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
var resp struct {
Jails []JailInfo `json:"jails"`
@@ -291,12 +310,10 @@ func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
return resp.Jails, nil
}
// UpdateJailEnabledStates implements Connector.
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
}
// GetFilters implements Connector.
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
var resp struct {
Filters []string `json:"filters"`
@@ -307,7 +324,6 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
return resp.Filters, nil
}
// TestFilter implements Connector.
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
payload := map[string]any{
"filterName": filterName,
@@ -323,16 +339,13 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
return "", "", err
}
// If agent doesn't return filterPath, construct it (agent should handle .local priority)
filterPath := resp.FilterPath
if filterPath == "" {
// Default to .conf path (agent should handle .local priority on its side)
filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
}
return resp.Output, filterPath, nil
}
// GetJailConfig implements Connector.
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct {
Config string `json:"config"`
@@ -341,35 +354,33 @@ func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (strin
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
return "", "", err
}
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath
if filePath == "" {
// Default to .local path (agent should handle .local priority on its side)
filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
}
return resp.Config, filePath, nil
}
// SetJailConfig implements Connector.
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
payload := map[string]string{"config": content}
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil)
}
// TestLogpath implements Connector.
// =========================================================================
// Logpath Operations
// =========================================================================
func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
payload := map[string]string{"logpath": logpath}
var resp struct {
Files []string `json:"files"`
}
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil {
return []string{}, nil // Return empty on error
return []string{}, nil
}
return resp.Files, nil
}
// TestLogpathWithResolution implements Connector.
// Agent server should handle variable resolution.
func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
originalPath = strings.TrimSpace(logpath)
if originalPath == "" {
@@ -386,7 +397,7 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
// Try new endpoint first, fallback to old endpoint
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil {
// Fallback: use old endpoint and assume no resolution
// Fallback; use old endpoint if new endpoint fails and assume no resolution
files, err2 := ac.TestLogpath(ctx, originalPath)
if err2 != nil {
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2)
@@ -408,14 +419,14 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil
}
// UpdateDefaultSettings implements Connector.
// =========================================================================
// Settings and Structure
// =========================================================================
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
// Since the managed jail.local is fully owned by Fail2ban-UI, a complete
// rewrite from current settings is always correct and self-healing.
return ac.EnsureJailLocalStructure(ctx)
}
// CheckJailLocalIntegrity implements Connector.
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
var result struct {
Exists bool `json:"exists"`
@@ -431,10 +442,8 @@ func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
return result.Exists, result.HasUIAction, nil
}
// EnsureJailLocalStructure implements Connector.
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
// Safety: if jail.local exists but is not managed by Fail2ban-UI,
// it belongs to the user; never overwrite it.
// If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not overwrite it.
if exists, hasUI, err := ac.CheckJailLocalIntegrity(ctx); err == nil && exists && !hasUI {
config.DebugLog("jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite", ac.server.Name)
return nil
@@ -443,7 +452,10 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil)
}
// CreateJail implements Connector.
// =========================================================================
// Filter and Jail Management
// =========================================================================
func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
payload := map[string]interface{}{
"name": jailName,
@@ -452,12 +464,10 @@ func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content stri
return ac.post(ctx, "/v1/jails", payload, nil)
}
// DeleteJail implements Connector.
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil)
}
// CreateFilter implements Connector.
func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
payload := map[string]interface{}{
"name": filterName,
@@ -466,7 +476,6 @@ func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content
return ac.post(ctx, "/v1/filters", payload, nil)
}
// DeleteFilter implements Connector.
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil)
}

View File

@@ -14,46 +14,43 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI.
// Connector for a local Fail2ban instance via fail2ban-client CLI.
type LocalConnector struct {
server config.Fail2banServer
}
// NewLocalConnector creates a new LocalConnector instance.
// =========================================================================
// Constructor
// =========================================================================
// Create a new LocalConnector for the given server config.
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
return &LocalConnector{server: server}
}
// ID implements Connector.
func (lc *LocalConnector) ID() string {
return lc.server.ID
}
// Server implements Connector.
func (lc *LocalConnector) Server() config.Fail2banServer {
return lc.server
}
// GetJailInfos implements Connector.
// Get jail information.
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
jails, err := lc.getJails(ctx)
if err != nil {
return nil, err
}
logPath := lc.server.LogPath
logPath := lc.server.LogPath // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
if logPath == "" {
logPath = "/var/log/fail2ban.log"
}
banHistory, err := ParseBanLog(logPath)
banHistory, err := ParseBanLog(logPath) // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
if err != nil {
banHistory = make(map[string][]BanEvent)
}
oneHourAgo := time.Now().Add(-1 * time.Hour)
// Use parallel execution for better performance
type jailResult struct {
jail JailInfo
err error
@@ -89,12 +86,10 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
}
}(jail)
}
go func() {
wg.Wait()
close(results)
}()
var finalResults []JailInfo
for result := range results {
if result.err != nil {
@@ -102,14 +97,13 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
}
finalResults = append(finalResults, result.jail)
}
sort.SliceStable(finalResults, func(i, j int) bool {
return finalResults[i].JailName < finalResults[j].JailName
})
return finalResults, nil
}
// GetBannedIPs implements Connector.
// Get banned IPs for a given jail.
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
args := []string{"status", jail}
out, err := lc.runFail2banClient(ctx, args...)
@@ -120,7 +114,6 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "IP list:") {
// Use SplitN to only split on the first colon, preserving IPv6 addresses
parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 {
ips := strings.Fields(strings.TrimSpace(parts[1]))
@@ -132,7 +125,7 @@ func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]stri
return bannedIPs, nil
}
// UnbanIP implements Connector.
// Unban an IP from a given jail.
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
args := []string{"set", jail, "unbanip", ip}
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
@@ -141,7 +134,7 @@ func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
return nil
}
// BanIP implements Connector.
// Ban an IP in a given jail.
func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
args := []string{"set", jail, "banip", ip}
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
@@ -150,35 +143,25 @@ func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
return nil
}
// Reload implements Connector.
// Reload the Fail2ban service.
func (lc *LocalConnector) Reload(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "reload")
if err != nil {
// Include the output in the error message for better debugging
return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out))
}
// Check if output indicates success (fail2ban-client returns "OK" on success)
// Check if fail2ban-client returns "OK"
outputTrimmed := strings.TrimSpace(out)
if outputTrimmed != "OK" && outputTrimmed != "" {
config.DebugLog("fail2ban reload output: %s", out)
// Check for jail errors in output even when command succeeds
// Look for patterns like "Errors in jail 'jailname'. Skipping..."
if strings.Contains(out, "Errors in jail") || strings.Contains(out, "Unable to read the filter") {
// Return an error that includes the output so handler can parse it
return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out))
}
}
return nil
}
// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns
// a mode string describing what happened:
// - "restart": systemd service was restarted and health check passed
// - "reload": configuration was reloaded via fail2ban-client and pong check passed
// Restart or reload the local Fail2ban instance; returns "restart" or "reload".
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
// 1) Try systemd restart if systemctl is available.
if _, err := exec.LookPath("systemctl"); err == nil {
cmd := "systemctl restart fail2ban"
out, err := executeShellCommand(ctx, cmd)
@@ -191,9 +174,6 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
}
return "restart", nil
}
// 2) Fallback: no systemctl in PATH (container image without systemd, or
// non-systemd environment). Use fail2ban-client reload + ping.
if err := lc.Reload(ctx); err != nil {
return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err)
}
@@ -203,23 +183,20 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
return "reload", nil
}
// Restart implements Connector.
func (lc *LocalConnector) Restart(ctx context.Context) error {
_, err := lc.RestartWithMode(ctx)
return err
}
// GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
return GetFilterConfigLocal(jail)
}
// SetFilterConfig implements Connector.
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
return SetFilterConfigLocal(jail, content)
}
// FetchBanEvents implements Connector.
// REMOVE THIS FUNCTION
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
logPath := lc.server.LogPath
if logPath == "" {
@@ -242,17 +219,16 @@ func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
return all, nil
}
// Get all jails.
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
out, err := lc.runFail2banClient(ctx, "status")
if err != nil {
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
}
var jails []string
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "Jail list:") {
// Use SplitN to only split on the first colon
parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 {
raw := strings.TrimSpace(parts[1])
@@ -266,6 +242,10 @@ func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
return jails, nil
}
// =========================================================================
// CLI Helpers
// =========================================================================
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
cmdArgs := lc.buildFail2banArgs(args...)
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
@@ -281,99 +261,84 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
return append(base, args...)
}
// checkFail2banHealthy runs a quick `fail2ban-client ping` via the existing
// runFail2banClient helper and expects a successful pong reply.
func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "ping")
trimmed := strings.TrimSpace(out)
if err != nil {
return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed)
}
// Typical output is e.g. "Server replied: pong" accept anything that
// contains "pong" case-insensitively.
if !strings.Contains(strings.ToLower(trimmed), "pong") {
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
}
return nil
}
// GetAllJails implements Connector.
// =========================================================================
// Delegated Operations
// =========================================================================
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
return GetAllJails()
}
// UpdateJailEnabledStates implements Connector.
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return UpdateJailEnabledStates(updates)
}
// GetFilters implements Connector.
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
return GetFiltersLocal()
}
// TestFilter implements Connector.
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
return TestFilterLocal(filterName, logLines, filterContent)
}
// GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
return GetJailConfig(jail)
}
// SetJailConfig implements Connector.
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
return SetJailConfig(jail, content)
}
// TestLogpath implements Connector.
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
return TestLogpath(logpath)
}
// TestLogpathWithResolution implements Connector.
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
return TestLogpathWithResolution(logpath)
}
// UpdateDefaultSettings implements Connector.
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
return UpdateDefaultSettingsLocal(settings)
}
// EnsureJailLocalStructure implements Connector.
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
return config.EnsureJailLocalStructure()
}
// CreateJail implements Connector.
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
return CreateJail(jailName, content)
}
// DeleteJail implements Connector.
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
return DeleteJail(jailName)
}
// CreateFilter implements Connector.
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
return CreateFilter(filterName, content)
}
// DeleteFilter implements Connector.
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
return DeleteFilter(filterName)
}
// CheckJailLocalIntegrity implements Connector.
func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
const jailLocalPath = "/etc/fail2ban/jail.local"
content, err := os.ReadFile(jailLocalPath)
if err != nil {
if os.IsNotExist(err) {
return false, false, nil // file does not exist; OK, will be created
return false, false, nil
}
return false, false, fmt.Errorf("failed to read jail.local: %w", err)
}
@@ -381,6 +346,10 @@ func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
return true, hasUIAction, nil
}
// =========================================================================
// Shell Execution
// =========================================================================
func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
// Copyright (C) 2026 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.
@@ -29,8 +29,7 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// GetFilterConfig returns the filter configuration using the default connector.
// Returns (config, filePath, error)
// Returns the filter configuration using the default connector.
func GetFilterConfig(jail string) (string, string, error) {
conn, err := GetManager().DefaultConnector()
if err != nil {
@@ -39,7 +38,7 @@ func GetFilterConfig(jail string) (string, string, error) {
return conn.GetFilterConfig(context.Background(), jail)
}
// SetFilterConfig writes the filter configuration using the default connector.
// Writes the filter configuration using the default connector.
func SetFilterConfig(jail, newContent string) error {
conn, err := GetManager().DefaultConnector()
if err != nil {
@@ -48,10 +47,7 @@ func SetFilterConfig(jail, newContent string) error {
return conn.SetFilterConfig(context.Background(), jail, newContent)
}
// ensureFilterLocalFile ensures that a .local file exists for the given filter.
// If .local doesn't exist, it copies from .conf if available, or creates an empty file.
func ensureFilterLocalFile(filterName string) error {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return fmt.Errorf("filter name cannot be empty")
@@ -61,13 +57,11 @@ func ensureFilterLocalFile(filterName string) error {
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf")
// Check if .local already exists
if _, err := os.Stat(localPath); err == nil {
config.DebugLog("Filter .local file already exists: %s", localPath)
return nil
}
// Try to copy from .conf if it exists
if _, err := os.Stat(confPath); err == nil {
config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath)
content, err := os.ReadFile(confPath)
@@ -81,7 +75,6 @@ func ensureFilterLocalFile(filterName string) error {
return nil
}
// Neither exists, create empty .local file
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err)
@@ -90,26 +83,20 @@ func ensureFilterLocalFile(filterName string) error {
return nil
}
// RemoveComments removes all lines that start with # (comments) from filter content
// and trims leading/trailing empty newlines
// This is exported for use in handlers that need to display filter content without comments
func RemoveComments(content string) string {
lines := strings.Split(content, "\n")
var result []string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Skip lines that start with # (comments)
if !strings.HasPrefix(trimmed, "#") {
result = append(result, line)
}
}
// Remove leading empty lines
for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
result = result[1:]
}
// Remove trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1]
}
@@ -117,10 +104,8 @@ func RemoveComments(content string) string {
return strings.Join(result, "\n")
}
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf.
// Returns (content, filePath, error)
// Reads filter config from .local first, then falls back to .conf.
func readFilterConfigWithFallback(filterName string) (string, string, error) {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName)
if filterName == "" {
return "", "", fmt.Errorf("filter name cannot be empty")
@@ -130,37 +115,26 @@ func readFilterConfigWithFallback(filterName string) (string, string, error) {
localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf")
// Try .local first
if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading filter config from .local: %s", localPath)
return string(content), localPath, nil
}
// Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading filter config from .conf: %s", confPath)
return string(content), confPath, nil
}
// Neither exists, return error with .local path (will be created on save)
return "", localPath, fmt.Errorf("filter config not found: neither %s nor %s exists", localPath, confPath)
}
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
// Prefers .local over .conf files.
// Returns (content, filePath, error)
func GetFilterConfigLocal(jail string) (string, string, error) {
return readFilterConfigWithFallback(jail)
}
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
// Always writes to .local file, ensuring it exists first by copying from .conf if needed.
func SetFilterConfigLocal(jail, newContent string) error {
// Ensure .local file exists (copy from .conf if needed)
if err := ensureFilterLocalFile(jail); err != nil {
return err
}
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err)
@@ -169,15 +143,13 @@ func SetFilterConfigLocal(jail, newContent string) error {
return nil
}
// ValidateFilterName validates a filter name format.
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
// Validates a filter name format.
func ValidateFilterName(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("filter name cannot be empty")
}
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
if invalidChars.MatchString(name) {
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", name)
@@ -186,8 +158,7 @@ func ValidateFilterName(name string) error {
return nil
}
// ListFilterFiles lists all filter files in the specified directory.
// Returns full paths to .local and .conf files.
// Lists all filter files in the specified directory.
func ListFilterFiles(directory string) ([]string, error) {
var files []string
@@ -200,14 +171,10 @@ func ListFilterFiles(directory string) ([]string, error) {
if entry.IsDir() {
continue
}
name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") {
continue
}
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name)
files = append(files, fullPath)
@@ -217,28 +184,22 @@ func ListFilterFiles(directory string) ([]string, error) {
return files, nil
}
// DiscoverFiltersFromFiles discovers all filters from the filesystem.
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
// Returns unique filter names.
// Returns all filters from the filesystem.
func DiscoverFiltersFromFiles() ([]string, error) {
filterDPath := "/etc/fail2ban/filter.d"
// Check if directory exists
if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
// Directory doesn't exist, return empty list
return []string{}, nil
}
// List all filter files
files, err := ListFilterFiles(filterDPath)
if err != nil {
return nil, err
}
filterMap := make(map[string]bool) // Track unique filter names
processedFiles := make(map[string]bool) // Track base names to avoid duplicates
filterMap := make(map[string]bool)
processedFiles := make(map[string]bool)
// First pass: collect all .local files (these take precedence)
for _, filePath := range files {
if !strings.HasSuffix(filePath, ".local") {
continue
@@ -250,7 +211,6 @@ func DiscoverFiltersFromFiles() ([]string, error) {
continue
}
// Skip if we've already processed this base name
if processedFiles[baseName] {
continue
}
@@ -259,28 +219,22 @@ func DiscoverFiltersFromFiles() ([]string, error) {
filterMap[baseName] = true
}
// Second pass: collect .conf files that don't have corresponding .local files
for _, filePath := range files {
if !strings.HasSuffix(filePath, ".conf") {
continue
}
filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" {
continue
}
// Skip if we've already processed a .local file with the same base name
if processedFiles[baseName] {
continue
}
processedFiles[baseName] = true
filterMap[baseName] = true
}
// Convert map to sorted slice
var filters []string
for name := range filterMap {
filters = append(filters, name)
@@ -290,32 +244,24 @@ func DiscoverFiltersFromFiles() ([]string, error) {
return filters, nil
}
// CreateFilter creates a new filter in filter.d/{name}.local.
// If the filter already exists, it will be overwritten.
// Creates a new filter.
func CreateFilter(filterName, content string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
}
filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local")
// Ensure directory exists
if err := os.MkdirAll(filterDPath, 0755); err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err)
}
// Write the file
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
}
config.DebugLog("Created filter file: %s", localPath)
return nil
}
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist.
// Both files are deleted to ensure complete removal of the filter configuration.
// Deletes a filter's .local and .conf files from filter.d/ if they exist.
func DeleteFilter(filterName string) error {
if err := ValidateFilterName(filterName); err != nil {
return err
@@ -328,7 +274,6 @@ func DeleteFilter(filterName string) error {
var deletedFiles []string
var lastErr error
// Delete .local file if it exists
if _, err := os.Stat(localPath); err == nil {
if err := os.Remove(localPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err)
@@ -337,8 +282,6 @@ func DeleteFilter(filterName string) error {
config.DebugLog("Deleted filter file: %s", localPath)
}
}
// Delete .conf file if it exists
if _, err := os.Stat(confPath); err == nil {
if err := os.Remove(confPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err)
@@ -347,23 +290,15 @@ func DeleteFilter(filterName string) error {
config.DebugLog("Deleted filter file: %s", confPath)
}
}
// If no files were deleted and no error occurred, it means neither file existed
if len(deletedFiles) == 0 && lastErr == nil {
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
}
// Return the last error if any occurred
if lastErr != nil {
return lastErr
}
return nil
}
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d
// Returns unique filter names from both .conf and .local files (prefers .local if both exist)
// This is the canonical implementation - now uses DiscoverFiltersFromFiles()
func GetFiltersLocal() ([]string, error) {
return DiscoverFiltersFromFiles()
}
@@ -380,7 +315,7 @@ func normalizeLogLines(logLines []string) []string {
return cleaned
}
// extractVariablesFromContent extracts variable names from [DEFAULT] section of filter content
// Extracts variable names from [DEFAULT] section of filter content.
func extractVariablesFromContent(content string) map[string]bool {
variables := make(map[string]bool)
lines := strings.Split(content, "\n")
@@ -388,20 +323,15 @@ func extractVariablesFromContent(content string) map[string]bool {
for _, line := range lines {
trimmed := strings.TrimSpace(line)
// Check for [DEFAULT] section
if strings.HasPrefix(trimmed, "[DEFAULT]") {
inDefaultSection = true
continue
}
// Check for end of [DEFAULT] section (next section starts)
if inDefaultSection && strings.HasPrefix(trimmed, "[") {
inDefaultSection = false
continue
}
// Extract variable name from [DEFAULT] section
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
@@ -412,11 +342,9 @@ func extractVariablesFromContent(content string) map[string]bool {
}
}
}
return variables
}
// removeDuplicateVariables removes variable definitions from included content that already exist in main filter
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
lines := strings.Split(includedContent, "\n")
var result strings.Builder
@@ -427,7 +355,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
trimmed := strings.TrimSpace(line)
originalLine := line
// Check for [DEFAULT] section
if strings.HasPrefix(trimmed, "[DEFAULT]") {
inDefaultSection = true
result.WriteString(originalLine)
@@ -443,13 +370,11 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
continue
}
// In [DEFAULT] section, check if variable already exists in main filter
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 {
varName := strings.TrimSpace(parts[0])
if mainVariables[varName] {
// Skip this line - variable will be defined in main filter (takes precedence)
removedCount++
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
continue
@@ -468,11 +393,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
return result.String()
}
// resolveFilterIncludes parses the filter content to find [INCLUDES] section
// and loads the included files, combining them with the main filter content.
// Returns: combined content with before files + main filter + after files
// Duplicate variables in main filter are removed if they exist in included files
// currentFilterName: name of the current filter being tested (to avoid self-inclusion)
func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) {
lines := strings.Split(filterContent, "\n")
var beforeFiles []string
@@ -484,18 +404,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Check for [INCLUDES] section
if strings.HasPrefix(trimmed, "[INCLUDES]") {
inIncludesSection = true
continue
}
// Check for end of [INCLUDES] section (next section starts)
if inIncludesSection && strings.HasPrefix(trimmed, "[") {
inIncludesSection = false
}
// Parse before and after directives
if inIncludesSection {
if strings.HasPrefix(strings.ToLower(trimmed), "before") {
parts := strings.SplitN(trimmed, "=", 2)
@@ -519,7 +436,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
}
}
// Collect main content (everything except [INCLUDES] section)
if !inIncludesSection {
if i > 0 {
mainContent.WriteString("\n")
@@ -532,12 +448,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
mainContentStr := mainContent.String()
mainVariables := extractVariablesFromContent(mainContentStr)
// Build combined content: before files + main filter + after files
var combined strings.Builder
// Load and append before files, removing duplicates that exist in main filter
for _, fileName := range beforeFiles {
// Remove any existing extension to get base name
baseName := fileName
if strings.HasSuffix(baseName, ".local") {
baseName = strings.TrimSuffix(baseName, ".local")
@@ -545,13 +458,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
baseName = strings.TrimSuffix(baseName, ".conf")
}
// Skip if this is the same filter (avoid self-inclusion)
if baseName == currentFilterName {
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
continue
}
// Always try .local first, then .conf (matching fail2ban's behavior)
localPath := filepath.Join(filterDPath, baseName+".local")
confPath := filepath.Join(filterDPath, baseName+".conf")
@@ -559,7 +470,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
var err error
var filePath string
// Try .local first
if content, err = os.ReadFile(localPath); err == nil {
filePath = localPath
config.DebugLog("Loading included filter file from .local: %s", filePath)
@@ -568,11 +478,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
config.DebugLog("Loading included filter file from .conf: %s", filePath)
} else {
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
continue // Skip if neither file exists
continue
}
contentStr := string(content)
// Remove variables from included file that are defined in main filter (main filter takes precedence)
// Remove variables from included file that are defined in main filter.
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
combined.WriteString(cleanedContent)
if !strings.HasSuffix(cleanedContent, "\n") {
@@ -581,15 +491,12 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
combined.WriteString("\n")
}
// Append main filter content (unchanged - this is what the user is editing)
combined.WriteString(mainContentStr)
if !strings.HasSuffix(mainContentStr, "\n") {
combined.WriteString("\n")
}
// Load and append after files, also removing duplicates that exist in main filter
for _, fileName := range afterFiles {
// Remove any existing extension to get base name
baseName := fileName
if strings.HasSuffix(baseName, ".local") {
baseName = strings.TrimSuffix(baseName, ".local")
@@ -597,11 +504,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
baseName = strings.TrimSuffix(baseName, ".conf")
}
// Note: Self-inclusion in "after" directive is intentional in fail2ban
// (e.g., after = apache-common.local is standard pattern for .local files)
// So we always load it, even if it's the same filter name
// Always try .local first, then .conf (matching fail2ban's behavior)
localPath := filepath.Join(filterDPath, baseName+".local")
confPath := filepath.Join(filterDPath, baseName+".conf")
@@ -609,7 +511,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
var err error
var filePath string
// Try .local first
if content, err = os.ReadFile(localPath); err == nil {
filePath = localPath
config.DebugLog("Loading included filter file from .local: %s", filePath)
@@ -618,11 +519,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
config.DebugLog("Loading included filter file from .conf: %s", filePath)
} else {
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err)
continue // Skip if neither file exists
continue
}
contentStr := string(content)
// Remove variables from included file that are defined in main filter (main filter takes precedence)
cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
combined.WriteString("\n")
combined.WriteString(cleanedContent)
@@ -634,10 +533,10 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
return combined.String(), nil
}
// TestFilterLocal tests a filter against log lines using fail2ban-regex
// Returns the full output of fail2ban-regex command and the filter path used
// Uses .local file if it exists, otherwise falls back to .conf file
// If filterContent is provided, it creates a temporary filter file and uses that instead
// =========================================================================
// Filter Testing
// =========================================================================
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 {
@@ -657,7 +556,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
defer os.Remove(tempFilterFile.Name())
defer tempFilterFile.Close()
// Resolve filter includes to get complete filter content with all dependencies
filterDPath := "/etc/fail2ban/filter.d"
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
if err != nil {
@@ -665,7 +563,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
contentToWrite = filterContent
}
// Ensure it ends with a newline for proper parsing
if !strings.HasSuffix(contentToWrite, "\n") {
contentToWrite += "\n"
}
@@ -674,7 +571,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
return "", "", fmt.Errorf("failed to write temporary filter file: %w", err)
}
// Ensure the file is synced to disk
if err := tempFilterFile.Sync(); err != nil {
return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err)
}
@@ -683,7 +579,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
filterPath = tempFilterFile.Name()
config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil)
} else {
// Try .local first, then fallback to .conf
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
@@ -706,7 +601,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
defer os.Remove(tmpFile.Name())
defer tmpFile.Close()
// Write all log lines to the temp file
for _, logLine := range cleaned {
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
return "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err)
@@ -714,13 +608,9 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
}
tmpFile.Close()
// Run fail2ban-regex with the log file and filter config
// Format: fail2ban-regex /path/to/logfile /etc/fail2ban/filter.d/filter-name.conf
cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath)
out, _ := cmd.CombinedOutput()
output := string(out)
// Return the full output regardless of exit code (fail2ban-regex may exit non-zero for no matches)
// The output contains useful information even when there are no matches
return output, filterPath, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -83,7 +83,7 @@ func (o *opnsenseIntegration) callAPI(req Request, action, ip string) error {
}
if cfg.SkipTLSVerify {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}

View File

@@ -93,7 +93,7 @@ func (p *pfSenseIntegration) modifyAliasIP(req Request, ip, description string,
}
if cfg.SkipTLSVerify {
httpClient.Transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402 - user controlled
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
}

View File

@@ -15,6 +15,10 @@ import (
_ "modernc.org/sqlite"
)
// =========================================================================
// Database Connection
// =========================================================================
var (
db *sql.DB
initOnce sync.Once
@@ -22,6 +26,10 @@ var (
defaultPath = "fail2ban-ui.db"
)
// =========================================================================
// Conversion Helpers
// =========================================================================
func boolToInt(b bool) int {
if b {
return 1
@@ -47,22 +55,21 @@ func intFromNull(ni sql.NullInt64) int {
return 0
}
// =========================================================================
// Types
// =========================================================================
type AppSettingsRecord struct {
// Basic app settings
Language string
Port int
Debug bool
RestartNeeded bool
// Callback settings
CallbackURL string
CallbackSecret string
// Alert settings
AlertCountriesJSON string
EmailAlertsForBans bool
EmailAlertsForUnbans bool
// Console output settings
ConsoleOutput bool
// SMTP settings
Language string
Port int
Debug bool
RestartNeeded bool
CallbackURL string
CallbackSecret string
AlertCountriesJSON string
EmailAlertsForBans bool
EmailAlertsForUnbans bool
ConsoleOutput bool
SMTPHost string
SMTPPort int
SMTPUsername string
@@ -71,23 +78,21 @@ type AppSettingsRecord struct {
SMTPUseTLS bool
SMTPInsecureSkipVerify bool
SMTPAuthMethod string
// Fail2Ban DEFAULT settings
BantimeIncrement bool
DefaultJailEnable bool
IgnoreIP string // Stored as space-separated string, converted to array in AppSettings
Bantime string
Findtime string
MaxRetry int
DestEmail string
Banaction string
BanactionAllports string
Chain string
BantimeRndtime string
// Advanced features
AdvancedActionsJSON string
GeoIPProvider string
GeoIPDatabasePath string
MaxLogLines int
BantimeIncrement bool
DefaultJailEnable bool
IgnoreIP string
Bantime string
Findtime string
MaxRetry int
DestEmail string
Banaction string
BanactionAllports string
Chain string
BantimeRndtime string
AdvancedActionsJSON string
GeoIPProvider string
GeoIPDatabasePath string
MaxLogLines int
}
type ServerRecord struct {
@@ -111,7 +116,6 @@ type ServerRecord struct {
UpdatedAt time.Time
}
// BanEventRecord represents a single ban or unban event stored in the internal database.
type BanEventRecord struct {
ID int64 `json:"id"`
ServerID string `json:"serverId"`
@@ -128,7 +132,6 @@ type BanEventRecord struct {
CreatedAt time.Time `json:"createdAt"`
}
// RecurringIPStat represents aggregation info for repeatedly banned IPs.
type RecurringIPStat struct {
IP string `json:"ip"`
Country string `json:"country"`
@@ -148,7 +151,7 @@ type PermanentBlockRecord struct {
UpdatedAt time.Time `json:"updatedAt"`
}
// Init initializes the internal storage. Safe to call multiple times.
// Initialize the database.
func Init(dbPath string) error {
initOnce.Do(func() {
if dbPath == "" {
@@ -159,9 +162,7 @@ func Init(dbPath string) error {
return
}
// Ensure .ssh directory exists (for SSH key storage)
if err := ensureSSHDirectory(); err != nil {
// Log but don't fail - .ssh directory creation is not critical
log.Printf("Warning: failed to ensure .ssh directory: %v", err)
}
@@ -182,7 +183,7 @@ func Init(dbPath string) error {
return initErr
}
// Close closes the underlying database if it has been initialised.
// Close the database.
func Close() error {
if db == nil {
return nil
@@ -190,6 +191,7 @@ func Close() error {
return db.Close()
}
// Get the app settings.
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
if db == nil {
return AppSettingsRecord{}, false, errors.New("storage not initialised")
@@ -215,19 +217,15 @@ WHERE id = 1`)
}
rec := AppSettingsRecord{
// Basic app settings
Language: stringFromNull(lang),
Port: intFromNull(port),
Debug: intToBool(intFromNull(debug)),
RestartNeeded: intToBool(intFromNull(restartNeeded)),
// Callback settings
CallbackURL: stringFromNull(callback),
CallbackSecret: stringFromNull(callbackSecret),
// Alert settings
AlertCountriesJSON: stringFromNull(alerts),
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
// SMTP settings
Language: stringFromNull(lang),
Port: intFromNull(port),
Debug: intToBool(intFromNull(debug)),
RestartNeeded: intToBool(intFromNull(restartNeeded)),
CallbackURL: stringFromNull(callback),
CallbackSecret: stringFromNull(callbackSecret),
AlertCountriesJSON: stringFromNull(alerts),
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
SMTPHost: stringFromNull(smtpHost),
SMTPPort: intFromNull(smtpPort),
SMTPUsername: stringFromNull(smtpUser),
@@ -236,25 +234,22 @@ WHERE id = 1`)
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)),
SMTPAuthMethod: stringFromNull(smtpAuthMethod),
// Fail2Ban DEFAULT settings
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
IgnoreIP: stringFromNull(ignoreIP),
Bantime: stringFromNull(bantime),
Findtime: stringFromNull(findtime),
MaxRetry: intFromNull(maxretry),
DestEmail: stringFromNull(destemail),
Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports),
Chain: stringFromNull(chain),
BantimeRndtime: stringFromNull(bantimeRndtime),
// Advanced features
AdvancedActionsJSON: stringFromNull(advancedActions),
GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines),
// Console output settings
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
IgnoreIP: stringFromNull(ignoreIP),
Bantime: stringFromNull(bantime),
Findtime: stringFromNull(findtime),
MaxRetry: intFromNull(maxretry),
DestEmail: stringFromNull(destemail),
Banaction: stringFromNull(banaction),
BanactionAllports: stringFromNull(banactionAllports),
Chain: stringFromNull(chain),
BantimeRndtime: stringFromNull(bantimeRndtime),
AdvancedActionsJSON: stringFromNull(advancedActions),
GeoIPProvider: stringFromNull(geoipProvider),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
MaxLogLines: intFromNull(maxLogLines),
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
}
return rec, true, nil
@@ -339,6 +334,10 @@ INSERT INTO app_settings (
return err
}
// =========================================================================
// Servers
// =========================================================================
func ListServers(ctx context.Context) ([]ServerRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -493,7 +492,11 @@ func DeleteServer(ctx context.Context, id string) error {
return err
}
// RecordBanEvent stores a ban event in the database.
// =========================================================================
// Ban Events Records
// =========================================================================
// Stores a ban/unban event into the database.
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
if db == nil {
return errors.New("storage not initialised")
@@ -509,8 +512,7 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
if record.OccurredAt.IsZero() {
record.OccurredAt = now
}
// Default to 'ban' if event type is not set
// If the event type is not set, we set it to "ban" by default.
eventType := record.EventType
if eventType == "" {
eventType = "ban"
@@ -544,7 +546,7 @@ INSERT INTO ban_events (
return nil
}
// ListBanEvents returns ban events ordered by creation date descending.
// Returns ban events ordered by creation date descending.
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -599,7 +601,6 @@ WHERE 1=1`
); err != nil {
return nil, err
}
// Default to 'ban' if event_type is NULL (for backward compatibility)
if eventType.Valid {
rec.EventType = eventType.String
} else {
@@ -618,7 +619,7 @@ const (
MaxBanEventsOffset = 1000
)
// ListBanEventsFiltered returns ban events with optional search and country filter, ordered by occurred_at DESC.
// Returns ban events with optional search and country filter, ordered by occurred_at DESC.
// search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset.
func ListBanEventsFiltered(ctx context.Context, serverID string, limit, offset int, since time.Time, search, country string) ([]BanEventRecord, error) {
@@ -703,7 +704,7 @@ WHERE 1=1`
return results, rows.Err()
}
// CountBanEventsFiltered returns the total count of ban events matching the same filters as ListBanEventsFiltered.
// Returns the total count of ban events matching the same filters as ListBanEventsFiltered.
func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
@@ -744,7 +745,7 @@ func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Tim
return total, nil
}
// CountBanEventsByServer returns simple aggregation per server.
// Returns simple aggregation per server.
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -782,7 +783,7 @@ WHERE 1=1`
return result, rows.Err()
}
// CountBanEvents returns total number of ban events optionally filtered by time and server.
// Returns total number of ban events optionally filtered by time and server.
func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
@@ -811,7 +812,7 @@ WHERE 1=1`
return total, nil
}
// CountBanEventsByIP returns total number of ban events for a specific IP and optional server.
// Returns total number of ban events for a specific IP and optional server.
func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) {
if db == nil {
return 0, errors.New("storage not initialised")
@@ -838,7 +839,7 @@ WHERE ip = ? AND (event_type = 'ban' OR event_type IS NULL)`
return total, nil
}
// CountBanEventsByCountry returns aggregation per country code, optionally filtered by server.
// Returns aggregation per country code, optionally filtered by server.
func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -881,7 +882,11 @@ WHERE 1=1`
return result, rows.Err()
}
// ListRecurringIPStats returns IPs that have been banned at least minCount times, optionally filtered by server.
// =========================================================================
// Recurring IP Statistics
// =========================================================================
// Returns IPs that have been banned at least minCount times, optionally filtered by server.
func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -927,20 +932,14 @@ LIMIT ?`
var results []RecurringIPStat
for rows.Next() {
var stat RecurringIPStat
// First, scan as string to see what format SQLite returns
// Then parse it properly
var lastSeenStr sql.NullString
if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err)
}
if lastSeenStr.Valid && lastSeenStr.String != "" {
// Try to parse the datetime string
// SQLite stores DATETIME as TEXT, format depends on how it was inserted
// The modernc.org/sqlite driver returns MAX(occurred_at) in format:
// "2006-01-02 15:04:05.999999999 -0700 MST" (e.g., "2025-11-22 12:17:24.697430041 +0000 UTC")
formats := []string{
"2006-01-02 15:04:05.999999999 -0700 MST", // Format returned by MAX() in SQLite
"2006-01-02 15:04:05.999999999 -0700 MST",
time.RFC3339Nano,
time.RFC3339,
"2006-01-02 15:04:05.999999999+00:00",
@@ -952,20 +951,18 @@ LIMIT ?`
"2006-01-02T15:04:05.999999999",
"2006-01-02T15:04:05",
}
parsed := time.Time{} // zero time
parsed := time.Time{}
for _, format := range formats {
if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil {
parsed = t.UTC()
break
}
}
// If still zero, log the actual string for debugging
if parsed.IsZero() {
log.Printf("ERROR: Could not parse lastSeen datetime '%s' (length: %d) for IP %s. All format attempts failed.", lastSeenStr.String, len(lastSeenStr.String), stat.IP)
}
stat.LastSeen = parsed
} else {
// Log when lastSeen is NULL or empty
log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP)
}
results = append(results, stat)
@@ -974,6 +971,10 @@ LIMIT ?`
return results, rows.Err()
}
// =========================================================================
// Schema Management
// =========================================================================
func ensureSchema(ctx context.Context) error {
if db == nil {
return errors.New("storage not initialised")
@@ -1080,25 +1081,11 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
if _, err := db.ExecContext(ctx, createTable); err != nil {
return err
}
// NOTE: Database migrations for feature releases
// For this version, we start with a fresh schema. Future feature releases
// that require database schema changes should add migration logic here.
// Example migration pattern:
// if _, err := db.ExecContext(ctx, `ALTER TABLE table_name ADD COLUMN new_column TYPE DEFAULT value`); err != nil {
// if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
// return err
// }
// }
// Migration: Add console_output column if it doesn't exist
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN console_output INTEGER DEFAULT 0`); err != nil {
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err
}
}
// Migration: Add new SMTP columns if they don't exist
if _, err := db.ExecContext(ctx, `ALTER TABLE app_settings ADD COLUMN smtp_insecure_skip_verify INTEGER DEFAULT 0`); err != nil {
if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err
@@ -1119,8 +1106,6 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
return err
}
}
_ = strings.Contains // Keep strings import for migration example above
return nil
}
@@ -1135,16 +1120,12 @@ func ensureDirectory(path string) error {
return os.MkdirAll(dir, 0o755)
}
// ensureSSHDirectory ensures the .ssh directory exists for SSH key storage.
// In containers, this is /config/.ssh, on the host it's ~/.ssh
// Ensures .ssh exists for SSH key storage (/config/.ssh in container, ~/.ssh on host).
func ensureSSHDirectory() error {
var sshDir string
// Check if running inside a container
if _, container := os.LookupEnv("CONTAINER"); container {
// In container, use /config/.ssh
sshDir = "/config/.ssh"
} else {
// On host, use ~/.ssh
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err)
@@ -1152,7 +1133,6 @@ func ensureSSHDirectory() error {
sshDir = filepath.Join(home, ".ssh")
}
// Create directory with proper permissions (0700 for .ssh)
if err := os.MkdirAll(sshDir, 0o700); err != nil {
return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err)
}
@@ -1160,7 +1140,11 @@ func ensureSSHDirectory() error {
return nil
}
// UpsertPermanentBlock records or updates a permanent block entry.
// =========================================================================
// Permanent Blocks Records
// =========================================================================
// Stores or updates a permanent block entry.
func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error {
if db == nil {
return errors.New("storage not initialised")
@@ -1200,7 +1184,7 @@ ON CONFLICT(ip, integration) DO UPDATE SET
return err
}
// GetPermanentBlock retrieves a permanent block entry.
// Returns a permanent block entry.
func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) {
if db == nil {
return PermanentBlockRecord{}, false, errors.New("storage not initialised")
@@ -1235,7 +1219,7 @@ WHERE ip = ? AND integration = ?`, ip, integration)
return rec, true, nil
}
// ListPermanentBlocks returns recent permanent block entries.
// Returns recent permanent block entries.
func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
@@ -1276,7 +1260,7 @@ LIMIT ?`, limit)
return records, rows.Err()
}
// IsPermanentBlockActive returns true when IP is currently blocked by integration.
// Returns true when IP is currently blocked by integration.
func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) {
rec, found, err := GetPermanentBlock(ctx, ip, integration)
if err != nil || !found {

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
<!--
Fail2ban UI - A Swiss made, management interface for Fail2ban.
Copyright (C) 2025 Swissmakers GmbH
Copyright (C) 2026 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.
@@ -22,21 +22,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title data-i18n="page.title">Fail2ban UI Dashboard</title>
<!-- Prism.js for syntax highlighting -->
<link rel="stylesheet" href="/static/vendor/prism/prism-tomorrow.min.css?v={{.version}}" />
<script src="/static/vendor/prism/prism-core.min.js?v={{.version}}"></script>
<script src="/static/vendor/prism/prism-autoloader.min.js?v={{.version}}"></script>
<!-- Tailwind CSS -->
<link rel="stylesheet" href="/static/tailwind.css?v={{.version}}">
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="/static/vendor/fontawesome/all.min.css?v={{.version}}">
<!-- Select2 CSS -->
<link rel="stylesheet" href="/static/vendor/select2/select2.min.css?v={{.version}}" />
<!-- Fail2ban UI CSS -->
<link rel="stylesheet" href="/static/fail2ban-ui.css?v={{.version}}">
<!-- LOTR Theme CSS (loaded conditionally) -->
<link rel="stylesheet" href="/static/lotr.css?v={{.version}}" id="lotr-css" disabled>
<!-- Google Fonts for LOTR theme -->
<link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
</head>
@@ -80,7 +73,6 @@
<div id="clockDisplay" class="ml-4 text-sm font-mono">
<span id="clockTime">--:--:--</span>
</div>
<!-- User info and logout (shown when authenticated) -->
<div id="userInfoContainer" class="hidden ml-4 flex items-center gap-3 border-l border-blue-500 pl-4">
<div class="relative">
<button id="userMenuButton" onclick="toggleUserMenu()" class="flex items-center gap-2 px-3 py-2 rounded text-sm font-medium hover:bg-blue-700 transition-colors focus:outline-none">
@@ -89,7 +81,6 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<!-- User dropdown menu -->
<div id="userMenuDropdown" class="hidden absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg py-1 z-50 border border-gray-200">
<div class="px-4 py-2 border-b border-gray-200">
<div class="text-sm font-medium text-gray-900" id="userMenuDisplayName"></div>
@@ -116,7 +107,6 @@
<a href="#" onclick="showSection('dashboardSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
<a href="#" onclick="showSection('filterSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
<a href="#" onclick="showSection('settingsSection')" class="block px-3 py-2 rounded-md text-base font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
<!-- User info and logout in mobile menu (shown when authenticated) -->
<div id="mobileUserInfoContainer" class="hidden border-t border-blue-500 mt-2 pt-2">
<div class="px-3 py-2">
<div class="text-sm font-medium" id="mobileUserDisplayName"></div>
@@ -126,15 +116,17 @@
</div>
</div>
</div>
<!-- Mobile menu END -->
</nav>
<!-- ************************ Navigation END *************************** -->
<!-- Login Page (hidden by default, shown only when OIDC enabled and not authenticated) -->
<!-- ******************************************************************* -->
<!-- Login Page START -->
<!-- ******************************************************************* -->
<div id="loginPage" class="hidden min-h-screen flex items-center justify-center bg-gray-100 py-12 px-4 sm:px-6 lg:px-8">
<div class="max-w-md w-full">
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200">
<!-- Logo and Title -->
<div class="text-center mb-8">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-600 mb-4">
<svg class="h-10 w-10 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -144,8 +136,6 @@
<h2 class="text-3xl font-bold text-gray-900 mb-2" data-i18n="auth.login_title">Sign in to Fail2ban UI</h2>
<p class="text-sm text-gray-600" data-i18n="auth.login_description">Please authenticate to access the management interface</p>
</div>
<!-- Error Message -->
<div id="loginError" class="hidden bg-red-50 border-l-4 border-red-400 text-red-700 px-4 py-3 rounded mb-6">
<div class="flex">
<div class="flex-shrink-0">
@@ -158,8 +148,6 @@
</div>
</div>
</div>
<!-- Login Button -->
<div class="mb-6">
<button type="button" onclick="handleLogin()" class="w-full flex justify-center items-center py-3 px-4 border border-transparent text-base font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 transition-colors">
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -167,8 +155,6 @@
</svg>
<span data-i18n="auth.login_button">Sign in with OIDC</span>
</button>
<!-- Loading State -->
<div id="loginLoading" class="hidden text-center py-4">
<div class="inline-flex items-center">
<div class="h-5 w-5 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mr-3"></div>
@@ -176,8 +162,6 @@
</div>
</div>
</div>
<!-- Footer Info -->
<div class="pt-6 border-t border-gray-200">
<p class="text-xs text-center text-gray-500">
Secure authentication via OpenID Connect
@@ -186,12 +170,14 @@
</div>
</div>
</div>
<!-- ********************** Login Page END ******************************* -->
<!-- Main Content -->
<main id="mainContent" class="hidden max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<!-- ******************************************************************* -->
<!-- Dashboard Page START -->
<!-- ******************************************************************* -->
<!-- ******************************************************************* -->
<!-- Dashboard Section START -->
<!-- ******************************************************************* -->
<div id="dashboardSection">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
<div>
@@ -220,15 +206,14 @@
</div>
</div>
</div>
<div id="dashboard"></div> <!-- Dynamic content from the API -->
<div id="dashboard"></div>
</div>
<!-- ********************** Dashboard Page END ************************* -->
<!-- ********************** Dashboard Section END ********************** -->
<!-- ******************************************************************* -->
<!-- Filter-Debug Page START -->
<!-- ******************************************************************* -->
<!-- ******************************************************************* -->
<!-- Filter-Debug Section START -->
<!-- ******************************************************************* -->
<div id="filterSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="filter_debug.title">Filter Debug</h2>
@@ -246,8 +231,6 @@
</div>
</div>
</div>
<!-- Textarea for filter content (readonly by default, editable with Edit button) -->
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label for="filterContentTextarea" class="block text-sm font-medium text-gray-700" data-i18n="filter_debug.filter_content">Filter Content</label>
@@ -258,8 +241,6 @@
<textarea id="filterContentTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40 font-mono text-sm bg-gray-50"
placeholder="Filter content will appear here when a filter is selected..." readonly></textarea>
</div>
<!-- Textarea for log lines to test -->
<div class="mb-4">
<label for="logLinesTextarea" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="filter_debug.log_lines">Log Lines</label>
<textarea id="logLinesTextarea" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 h-40"
@@ -271,21 +252,19 @@
<div id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
</div>
<!-- ********************* Filter-Debug Page END *********************** -->
<!-- ********************* Filter-Debug Page END *********************** -->
<!-- ******************************************************************* -->
<!-- Settings Page START -->
<!-- ******************************************************************* -->
<!-- ******************************************************************* -->
<!-- Settings Page START -->
<!-- ******************************************************************* -->
<div id="settingsSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="settings.title">Settings</h2>
<form onsubmit="saveSettings(event)" class="space-y-6">
<!-- General Settings Group -->
<!-- ========================= General Settings ========================= -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.general">General Settings</h3>
<!-- Language Selection -->
<div class="mb-4">
<label for="languageSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.language">Language</label>
<select id="languageSelect" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@@ -297,8 +276,6 @@
<option value="de_ch">Schwiizerdütsch</option>
</select>
</div>
<!-- Fail2Ban UI Port (server) -->
<div class="mb-4">
<label for="uiPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.server_port">Server Port</label>
<input type="number" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="uiPort"
@@ -319,7 +296,6 @@
</p>
<p class="text-xs text-gray-500 mt-1" id="callbackUrlDefaultHint" data-i18n="settings.callback_url_hint">This URL is used by all Fail2Ban instances to send ban alerts back to Fail2Ban UI. For local deployments, use the same port as Fail2Ban UI (e.g., http://127.0.0.1:8080). For reverse proxy setups, use your TLS-encrypted endpoint (e.g., https://fail2ban.example.com).</p>
</div>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<label for="callbackSecret" class="block text-sm font-medium text-gray-700" data-i18n="settings.callback_secret">Fail2ban Callback URL Secret</label>
@@ -329,8 +305,6 @@
data-i18n-placeholder="settings.callback_secret_placeholder" placeholder="Auto-generated 42-character secret" />
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.callback_secret.description">This secret is automatically generated and used to authenticate ban notification requests. It is included in the fail2ban action configuration.</p>
</div>
<!-- Debug Log Output -->
<div class="flex items-center gap-4 border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
<div class="flex items-center">
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
@@ -341,8 +315,6 @@
<label for="consoleOutput" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_console">Enable Console Output</label>
</div>
</div>
<!-- Console Output Window -->
<div id="consoleOutputContainer" class="hidden mt-4 border border-gray-700 rounded-lg bg-gray-900 shadow-lg overflow-hidden">
<div class="flex items-center justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
<div class="flex items-center gap-2">
@@ -361,7 +333,7 @@
</div>
</div>
<!-- Advanced Actions -->
<!-- ========================= Advanced Actions ========================= -->
<div class="bg-white rounded-lg shadow p-6">
<div class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
@@ -373,19 +345,16 @@
<button type="button" class="px-3 py-2 text-sm rounded border border-blue-600 text-blue-600 hover:bg-blue-50" onclick="openAdvancedTestModal()" data-i18n="settings.advanced.test_button">Manually Block / Test</button>
</div>
</div>
<div class="mt-4 space-y-4">
<div class="flex items-center">
<input type="checkbox" id="advancedActionsEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded">
<label for="advancedActionsEnabled" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.enable">Enable automatic permanent blocking</label>
</div>
<div>
<label for="advancedThreshold" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.threshold">Threshold before permanent block</label>
<input type="number" id="advancedThreshold" min="1" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="5">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.advanced.threshold_hint">If an IP is banned at least this many times it will be forwarded to the selected firewall integration.</p>
</div>
<div>
<label for="advancedIntegrationSelect" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.integration">Integration</label>
<select id="advancedIntegrationSelect" class="mt-1 w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
@@ -426,7 +395,6 @@
</div>
</div>
</div>
<div id="advancedPfSenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.pfsense.note">Requires the pfSense REST API package. Enter the API key and alias to manage.</p>
<div class="mb-3 text-sm">
@@ -454,7 +422,6 @@
</div>
</div>
</div>
<div id="advancedOPNsenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50">
<p class="text-sm text-gray-500 mb-3" data-i18n="settings.advanced.opnsense.note">Enter the OPNsense API credentials and alias to manage.</p>
<div class="mb-3 text-sm">
@@ -486,7 +453,6 @@
</div>
</div>
</div>
<div class="mt-6">
<h4 class="text-md font-semibold text-gray-800 mb-2" data-i18n="settings.advanced.log_title">Permanent Block Log</h4>
<div id="permanentBlockLog" class="overflow-x-auto border border-gray-200 rounded-md">
@@ -495,11 +461,9 @@
</div>
</div>
<!-- Alert Settings Group -->
<!-- ========================= Alert Settings =========================== -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
<!-- Email Alert Preferences -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.email_alerts">Email Alert Preferences</label>
<div class="space-y-2">
@@ -513,15 +477,12 @@
</label>
</div>
</div>
<div class="mb-4" id="emailFieldsContainer">
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="destEmail"
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</div>
<!-- GeoIP Provider -->
<div class="mb-4">
<label for="geoipProvider" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_provider">GeoIP Provider</label>
<select id="geoipProvider" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="onGeoIPProviderChange(this.value)">
@@ -530,21 +491,16 @@
</select>
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_provider.description">Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.</p>
</div>
<!-- GeoIP Database Path (shown only for MaxMind) -->
<div id="geoipDatabasePathContainer" class="mb-4">
<label for="geoipDatabasePath" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_database_path">GeoIP Database Path</label>
<input type="text" id="geoipDatabasePath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="/usr/share/GeoIP/GeoLite2-Country.mmdb">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_database_path.description">Path to the MaxMind GeoLite2-Country database file.</p>
</div>
<!-- Max Log Lines -->
<div class="mb-4">
<label for="maxLogLines" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.max_log_lines">Maximum Log Lines</label>
<input type="number" id="maxLogLines" min="1" max="500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="50">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.max_log_lines.description">Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.</p>
</div>
<div class="mb-4">
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">
@@ -751,7 +707,7 @@
</div>
</div>
<!-- SMTP Configuration Group -->
<!-- ========================= SMTP Configuration ======================= -->
<div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
<div class="mb-4">
@@ -805,12 +761,10 @@
</div>
</div>
<!-- Fail2Ban Configuration Group -->
<!-- ========================= Fail2Ban Defaults ======================== -->
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.fail2ban">Global Default Fail2Ban Configurations</h3>
<p class="text-sm text-gray-600 mb-4" data-i18n="settings.fail2ban.description">These settings will be applied to all enabled Fail2Ban servers and stored in their jail.local [DEFAULT] section.</p>
<!-- Bantime Increment -->
<div class="mb-4">
<div class="flex items-center mb-2">
<input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
@@ -818,8 +772,6 @@
</div>
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.enable_bantime_increment.description">If set to true, the bantime will be calculated using the formula: bantime = findtime * (number of failures / maxretry) * (1 + bantime.rndtime).</p>
</div>
<!-- Default Enabled -->
<div class="mb-4">
<div class="flex items-center mb-2">
<input type="checkbox" id="defaultJailEnable" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
@@ -827,8 +779,6 @@
</div>
<p class="text-xs text-gray-500 ml-9" data-i18n="settings.default_jail_enable.description">If enabled, all jails will be enabled by default. When disabled, jails must be explicitly enabled.</p>
</div>
<!-- Bantime -->
<div class="mb-4">
<label for="banTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_bantime">Default Bantime</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_bantime.description">The number of seconds that a host is banned. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
@@ -836,16 +786,12 @@
data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
<p class="text-xs text-red-600 mt-1 hidden" id="banTimeError"></p>
</div>
<!-- Bantime Rndtime (optional, for bantime increment formula) -->
<div class="mb-4">
<label for="bantimeRndtime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.bantime_rndtime">Bantime Rndtime</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.bantime_rndtime.description">Optional. Max random seconds added in bantime increment formula (e.g. 2048). Leave empty to use Fail2ban default.</p>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="bantimeRndtime"
data-i18n-placeholder="settings.bantime_rndtime_placeholder" placeholder="e.g., 2048" />
</div>
<!-- Banaction -->
<div class="mb-4">
<label for="banaction" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction">Banaction</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction.description">Default banning action (e.g. nftables-multiport, nftables-allports, firewallcmd-rich-rules, etc). It is used to define action_* variables.</p>
@@ -881,8 +827,6 @@
<option value="apf">apf</option>
</select>
</div>
<!-- Banaction Allports -->
<div class="mb-4">
<label for="banactionAllports" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction_allports">Banaction Allports</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.banaction_allports.description">Banning action for all ports (e.g. iptables-allports, firewallcmd-allports, etc). Used when a jail needs to ban all ports instead of specific ones.</p>
@@ -918,8 +862,6 @@
<option value="apf">apf</option>
</select>
</div>
<!-- Default Chain -->
<div class="mb-4">
<div class="flex items-center gap-2 mb-2">
<label for="defaultChain" class="block text-sm font-medium text-gray-700" data-i18n="settings.default_chain">Default Chain</label>
@@ -932,8 +874,6 @@
<option value="FORWARD" data-i18n="settings.chain_forward">FORWARD</option>
</select>
</div>
<!-- Findtime -->
<div class="mb-4">
<label for="findTime" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_findtime">Default Findtime</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_findtime.description">A host is banned if it has generated 'maxretry' failures during the last 'findtime' seconds. Time format: 1h = 1 hour, 1d = 1 day, 1w = 1 week, 1m = 1 month, 1y = 1 year.</p>
@@ -941,8 +881,6 @@
data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
<p class="text-xs text-red-600 mt-1 hidden" id="findTimeError"></p>
</div>
<!-- Max Retry -->
<div class="mb-4">
<label for="maxRetry" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.default_max_retry">Default Max Retry</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.default_max_retry.description">Number of failures before a host gets banned.</p>
@@ -950,8 +888,6 @@
data-i18n-placeholder="settings.default_max_retry_placeholder" placeholder="Enter maximum retries" min="1" />
<p class="text-xs text-red-600 mt-1 hidden" id="maxRetryError"></p>
</div>
<!-- Ignore IPs -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label>
<p class="text-xs text-gray-500 mb-2" data-i18n="settings.ignore_ips.description">Space separated list of IP addresses, CIDR masks or DNS hosts. Fail2ban will not ban a host which matches an address in this list.</p>
@@ -962,16 +898,16 @@
<div id="ignoreIPsError" class="hidden text-red-600 text-sm mt-1"></div>
</div>
</div>
</div>
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition-colors" data-i18n="settings.save">Save</button>
</form>
</div>
<!-- *********************** Settings Page END ************************* -->
<!-- *********************** Settings Page END ************************* -->
</main>
<!-- Footer -->
<!-- ******************************************************************* -->
<!-- Footer -->
<!-- ******************************************************************* -->
<footer id="footer" class="hidden bg-gray-100 py-4">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center text-gray-600 text-sm">
<p class="mb-0">
@@ -988,7 +924,8 @@
<!-- ******************************************************************* -->
<!-- Modal Templates START -->
<!-- ******************************************************************* -->
<!-- Jail Config Modal -->
<!-- ========================= Jail Config Modal ========================= -->
<div id="jailConfigModal" class="hidden fixed inset-0 z-50 overflow-y-auto" style="z-index: 60;">
<div class="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1008,7 +945,6 @@
</button>
</div>
<div class="mt-4 space-y-4">
<!-- Filter Configuration -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.filter_config_label">Filter Configuration</label>
@@ -1037,11 +973,7 @@
onfocus="preventExtensionInterference(this);"></textarea>
</div>
</div>
<!-- Divider -->
<div class="border-t border-gray-300"></div>
<!-- Jail Configuration -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="block text-sm font-medium text-gray-700" data-i18n="modal.jail_config_label">Jail Configuration</label>
@@ -1070,8 +1002,6 @@
onfocus="preventExtensionInterference(this);"></textarea>
</div>
</div>
<!-- Test Logpath Button (only shown if logpath is set) -->
<div id="testLogpathSection" class="hidden">
<div id="localServerLogpathHint" class="mb-2 p-2 bg-blue-50 border border-blue-200 rounded-md text-xs text-blue-800 hidden">
<strong data-i18n="modal.local_server_logpath_note"> Note:</strong> <span data-i18n="modal.local_server_logpath_text_prefix">For a local fail2ban server (e.g. installed on container host system or in a container on same host), log files must also be mounted to the fail2ban-ui container (e.g.,</span> <code class="font-mono">-v /var/log:/var/log:ro</code> <span data-i18n="modal.local_server_logpath_text_suffix">) this is required so that the fail2ban-ui can verify logpath variables or paths when updating jails.</span>
@@ -1094,7 +1024,7 @@
</div>
</div>
<!-- Manage Jails Modal -->
<!-- ========================= Manage Jails Modal ======================== -->
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1122,7 +1052,6 @@
<span data-i18n="modal.create_jail">Create New Jail</span>
</button>
</div>
<!-- Dynamically filled list of jails with toggle switches -->
<div id="jailsList" class="divide-y divide-gray-200"></div>
</div>
</div>
@@ -1135,7 +1064,7 @@
</div>
</div>
<!-- Create Jail Modal -->
<!-- ========================= Create Jail Modal ========================= -->
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1182,7 +1111,7 @@
</div>
</div>
<!-- Create Filter Modal -->
<!-- ========================= Create Filter Modal ======================= -->
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1222,7 +1151,7 @@
</div>
</div>
<!-- Server Manager Modal -->
<!-- ========================= Server Manager Modal ====================== -->
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1345,7 +1274,7 @@
</div>
</div>
<!-- Whois Modal -->
<!-- ========================= Whois Modal =============================== -->
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1377,7 +1306,7 @@
</div>
</div>
<!-- Advanced Actions Test Modal -->
<!-- ========================= Advanced Actions Test Modal =============== -->
<div id="advancedTestModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1411,7 +1340,7 @@
</div>
</div>
<!-- Logs Modal -->
<!-- ========================= Logs Modal ================================ -->
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1446,7 +1375,7 @@
</div>
</div>
<!-- Ban Insights Modal -->
<!-- ========================= Ban Insights Modal ======================== -->
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1464,13 +1393,8 @@
</button>
</div>
<p class="text-sm text-gray-600 mb-4" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p>
<!-- Summary Cards -->
<div id="insightsSummary" class="grid gap-4 sm:grid-cols-3 mb-6"></div>
<!-- Main Content Grid -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- Country Statistics -->
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-4">
<div>
@@ -1481,8 +1405,6 @@
</div>
<div id="countryStatsContainer" class="space-y-4 max-h-96 overflow-y-auto"></div>
</div>
<!-- Recurring IPs -->
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-4">
<div>
@@ -1504,7 +1426,7 @@
</div>
</div>
<!-- Chain Help Modal -->
<!-- ========================= Chain Help Modal ========================== -->
<div id="chainHelpModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
<div class="relative flex min-h-full w-full items-center justify-center p-4 sm:p-6">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1547,12 +1469,11 @@
<!-- ********************** Modal Templates END ************************ -->
<!-- jQuery (used by Select2) -->
<!-- ******************************************************************* -->
<!-- Script Includes -->
<!-- ******************************************************************* -->
<script src="/static/vendor/jquery/jquery-3.6.0.min.js?v={{.version}}"></script>
<!-- Select2 JS -->
<script src="/static/vendor/select2/select2.min.js?v={{.version}}"></script>
<!-- Fail2ban UI JavaScript Modules -->
<script src="/static/js/globals.js?v={{.version}}"></script>
<script src="/static/js/core.js?v={{.version}}"></script>
<script src="/static/js/api.js?v={{.version}}"></script>