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" "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 { type AgentConnector struct {
server config.Fail2banServer server config.Fail2banServer
base *url.URL base *url.URL
client *http.Client 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) { func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
if server.AgentURL == "" { if server.AgentURL == "" {
return nil, fmt.Errorf("agentUrl is required for agent connector") return nil, fmt.Errorf("agentUrl is required for agent connector")
@@ -52,6 +60,10 @@ func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
return conn, nil return conn, nil
} }
// =========================================================================
// Connector Functions
// =========================================================================
func (ac *AgentConnector) ID() string { func (ac *AgentConnector) ID() string {
return ac.server.ID 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) 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) { func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
if err := ac.Restart(ctx); err != nil { if err := ac.Restart(ctx); err != nil {
return "restart", err return "restart", err
@@ -123,6 +133,10 @@ func (ac *AgentConnector) RestartWithMode(ctx context.Context) (string, error) {
return "restart", nil return "restart", nil
} }
// =========================================================================
// Filter Operations
// =========================================================================
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) { func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct { var resp struct {
Config string `json:"config"` 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 { if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
return "", "", err return "", "", err
} }
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath filePath := resp.FilePath
if 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) filePath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", jail)
} }
return resp.Config, filePath, nil return resp.Config, filePath, nil
@@ -184,6 +196,10 @@ func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
return result, nil return result, nil
} }
// =========================================================================
// HTTP Helpers
// =========================================================================
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error { func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil) req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -280,7 +296,10 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
return json.Unmarshal(data, out) return json.Unmarshal(data, out)
} }
// GetAllJails implements Connector. // =========================================================================
// Jail Operations
// =========================================================================
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
var resp struct { var resp struct {
Jails []JailInfo `json:"jails"` Jails []JailInfo `json:"jails"`
@@ -291,12 +310,10 @@ func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
return resp.Jails, nil return resp.Jails, nil
} }
// UpdateJailEnabledStates implements Connector.
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil) return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
} }
// GetFilters implements Connector.
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) { func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
var resp struct { var resp struct {
Filters []string `json:"filters"` Filters []string `json:"filters"`
@@ -307,7 +324,6 @@ func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
return resp.Filters, nil return resp.Filters, nil
} }
// TestFilter implements Connector.
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
payload := map[string]any{ payload := map[string]any{
"filterName": filterName, "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 { if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
return "", "", err return "", "", err
} }
// If agent doesn't return filterPath, construct it (agent should handle .local priority)
filterPath := resp.FilterPath filterPath := resp.FilterPath
if 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) filterPath = fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
} }
return resp.Output, filterPath, nil return resp.Output, filterPath, nil
} }
// GetJailConfig implements Connector.
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
var resp struct { var resp struct {
Config string `json:"config"` 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 { if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
return "", "", err return "", "", err
} }
// If agent doesn't return filePath, construct it (agent should handle .local priority)
filePath := resp.FilePath filePath := resp.FilePath
if 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) filePath = fmt.Sprintf("/etc/fail2ban/jail.d/%s.local", jail)
} }
return resp.Config, filePath, nil return resp.Config, filePath, nil
} }
// SetJailConfig implements Connector.
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error { func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
payload := map[string]string{"config": content} payload := map[string]string{"config": content}
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil) 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) { func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
payload := map[string]string{"logpath": logpath} payload := map[string]string{"logpath": logpath}
var resp struct { var resp struct {
Files []string `json:"files"` Files []string `json:"files"`
} }
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil { 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 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) { func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
originalPath = strings.TrimSpace(logpath) originalPath = strings.TrimSpace(logpath)
if originalPath == "" { if originalPath == "" {
@@ -386,7 +397,7 @@ func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath
// Try new endpoint first, fallback to old endpoint // Try new endpoint first, fallback to old endpoint
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil { 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) files, err2 := ac.TestLogpath(ctx, originalPath)
if err2 != nil { if err2 != nil {
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2) 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 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 { 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) return ac.EnsureJailLocalStructure(ctx)
} }
// CheckJailLocalIntegrity implements Connector.
func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
var result struct { var result struct {
Exists bool `json:"exists"` Exists bool `json:"exists"`
@@ -431,10 +442,8 @@ func (ac *AgentConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bo
return result.Exists, result.HasUIAction, nil return result.Exists, result.HasUIAction, nil
} }
// EnsureJailLocalStructure implements Connector.
func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error { func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
// Safety: if jail.local exists but is not managed by Fail2ban-UI, // If jail.local exists but is not managed by Fail2ban-UI, it belongs to the user, we do not overwrite it.
// it belongs to the user; never overwrite it.
if exists, hasUI, err := ac.CheckJailLocalIntegrity(ctx); err == nil && exists && !hasUI { 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) config.DebugLog("jail.local on agent server %s exists but is not managed by Fail2ban-UI -- skipping overwrite", ac.server.Name)
return nil return nil
@@ -443,7 +452,10 @@ func (ac *AgentConnector) EnsureJailLocalStructure(ctx context.Context) error {
return ac.post(ctx, "/v1/jails/ensure-structure", nil, nil) 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 { func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content string) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
"name": jailName, "name": jailName,
@@ -452,12 +464,10 @@ func (ac *AgentConnector) CreateJail(ctx context.Context, jailName, content stri
return ac.post(ctx, "/v1/jails", payload, nil) return ac.post(ctx, "/v1/jails", payload, nil)
} }
// DeleteJail implements Connector.
func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error { func (ac *AgentConnector) DeleteJail(ctx context.Context, jailName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/jails/%s", jailName), nil) 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 { func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content string) error {
payload := map[string]interface{}{ payload := map[string]interface{}{
"name": filterName, "name": filterName,
@@ -466,7 +476,6 @@ func (ac *AgentConnector) CreateFilter(ctx context.Context, filterName, content
return ac.post(ctx, "/v1/filters", payload, nil) return ac.post(ctx, "/v1/filters", payload, nil)
} }
// DeleteFilter implements Connector.
func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error { func (ac *AgentConnector) DeleteFilter(ctx context.Context, filterName string) error {
return ac.delete(ctx, fmt.Sprintf("/v1/filters/%s", filterName), nil) 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" "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 { type LocalConnector struct {
server config.Fail2banServer 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 { func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
return &LocalConnector{server: server} return &LocalConnector{server: server}
} }
// ID implements Connector.
func (lc *LocalConnector) ID() string { func (lc *LocalConnector) ID() string {
return lc.server.ID return lc.server.ID
} }
// Server implements Connector.
func (lc *LocalConnector) Server() config.Fail2banServer { func (lc *LocalConnector) Server() config.Fail2banServer {
return lc.server return lc.server
} }
// GetJailInfos implements Connector. // Get jail information.
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) { func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
jails, err := lc.getJails(ctx) jails, err := lc.getJails(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
logPath := lc.server.LogPath // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
logPath := lc.server.LogPath
if logPath == "" { if logPath == "" {
logPath = "/var/log/fail2ban.log" logPath = "/var/log/fail2ban.log"
} }
banHistory, err := ParseBanLog(logPath) // LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
banHistory, err := ParseBanLog(logPath)
if err != nil { if err != nil {
banHistory = make(map[string][]BanEvent) banHistory = make(map[string][]BanEvent)
} }
oneHourAgo := time.Now().Add(-1 * time.Hour) oneHourAgo := time.Now().Add(-1 * time.Hour)
// Use parallel execution for better performance
type jailResult struct { type jailResult struct {
jail JailInfo jail JailInfo
err error err error
@@ -89,12 +86,10 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
} }
}(jail) }(jail)
} }
go func() { go func() {
wg.Wait() wg.Wait()
close(results) close(results)
}() }()
var finalResults []JailInfo var finalResults []JailInfo
for result := range results { for result := range results {
if result.err != nil { if result.err != nil {
@@ -102,14 +97,13 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
} }
finalResults = append(finalResults, result.jail) finalResults = append(finalResults, result.jail)
} }
sort.SliceStable(finalResults, func(i, j int) bool { sort.SliceStable(finalResults, func(i, j int) bool {
return finalResults[i].JailName < finalResults[j].JailName return finalResults[i].JailName < finalResults[j].JailName
}) })
return finalResults, nil return finalResults, nil
} }
// GetBannedIPs implements Connector. // Get banned IPs for a given jail.
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) { func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
args := []string{"status", jail} args := []string{"status", jail}
out, err := lc.runFail2banClient(ctx, args...) 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") lines := strings.Split(out, "\n")
for _, line := range lines { for _, line := range lines {
if strings.Contains(line, "IP list:") { if strings.Contains(line, "IP list:") {
// Use SplitN to only split on the first colon, preserving IPv6 addresses
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 { if len(parts) > 1 {
ips := strings.Fields(strings.TrimSpace(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 return bannedIPs, nil
} }
// UnbanIP implements Connector. // Unban an IP from a given jail.
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error { func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
args := []string{"set", jail, "unbanip", ip} args := []string{"set", jail, "unbanip", ip}
if _, err := lc.runFail2banClient(ctx, args...); err != nil { 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 return nil
} }
// BanIP implements Connector. // Ban an IP in a given jail.
func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error { func (lc *LocalConnector) BanIP(ctx context.Context, jail, ip string) error {
args := []string{"set", jail, "banip", ip} args := []string{"set", jail, "banip", ip}
if _, err := lc.runFail2banClient(ctx, args...); err != nil { 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 return nil
} }
// Reload implements Connector. // Reload the Fail2ban service.
func (lc *LocalConnector) Reload(ctx context.Context) error { func (lc *LocalConnector) Reload(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "reload") out, err := lc.runFail2banClient(ctx, "reload")
if err != nil { 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)) return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out))
} }
// Check if fail2ban-client returns "OK"
// Check if output indicates success (fail2ban-client returns "OK" on success)
outputTrimmed := strings.TrimSpace(out) outputTrimmed := strings.TrimSpace(out)
if outputTrimmed != "OK" && outputTrimmed != "" { if outputTrimmed != "OK" && outputTrimmed != "" {
config.DebugLog("fail2ban reload output: %s", out) 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") { 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 fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out))
} }
} }
return nil return nil
} }
// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns // Restart or reload the local Fail2ban instance; returns "restart" or "reload".
// 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
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) { func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
// 1) Try systemd restart if systemctl is available.
if _, err := exec.LookPath("systemctl"); err == nil { if _, err := exec.LookPath("systemctl"); err == nil {
cmd := "systemctl restart fail2ban" cmd := "systemctl restart fail2ban"
out, err := executeShellCommand(ctx, cmd) out, err := executeShellCommand(ctx, cmd)
@@ -191,9 +174,6 @@ func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
} }
return "restart", nil 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 { if err := lc.Reload(ctx); err != nil {
return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err) 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 return "reload", nil
} }
// Restart implements Connector.
func (lc *LocalConnector) Restart(ctx context.Context) error { func (lc *LocalConnector) Restart(ctx context.Context) error {
_, err := lc.RestartWithMode(ctx) _, err := lc.RestartWithMode(ctx)
return err return err
} }
// GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) { func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
return GetFilterConfigLocal(jail) return GetFilterConfigLocal(jail)
} }
// SetFilterConfig implements Connector.
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error { func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
return SetFilterConfigLocal(jail, content) return SetFilterConfigLocal(jail, content)
} }
// FetchBanEvents implements Connector. // REMOVE THIS FUNCTION
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) { func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
logPath := lc.server.LogPath logPath := lc.server.LogPath
if logPath == "" { if logPath == "" {
@@ -242,17 +219,16 @@ func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanE
return all, nil return all, nil
} }
// Get all jails.
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) { func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
out, err := lc.runFail2banClient(ctx, "status") out, err := lc.runFail2banClient(ctx, "status")
if err != nil { if err != nil {
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err) return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
} }
var jails []string var jails []string
lines := strings.Split(out, "\n") lines := strings.Split(out, "\n")
for _, line := range lines { for _, line := range lines {
if strings.Contains(line, "Jail list:") { if strings.Contains(line, "Jail list:") {
// Use SplitN to only split on the first colon
parts := strings.SplitN(line, ":", 2) parts := strings.SplitN(line, ":", 2)
if len(parts) > 1 { if len(parts) > 1 {
raw := strings.TrimSpace(parts[1]) raw := strings.TrimSpace(parts[1])
@@ -266,6 +242,10 @@ func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
return jails, nil return jails, nil
} }
// =========================================================================
// CLI Helpers
// =========================================================================
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) { func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
cmdArgs := lc.buildFail2banArgs(args...) cmdArgs := lc.buildFail2banArgs(args...)
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...) cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
@@ -281,99 +261,84 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
return append(base, args...) 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 { func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "ping") out, err := lc.runFail2banClient(ctx, "ping")
trimmed := strings.TrimSpace(out) trimmed := strings.TrimSpace(out)
if err != nil { if err != nil {
return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed) 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") { if !strings.Contains(strings.ToLower(trimmed), "pong") {
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed) return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
} }
return nil return nil
} }
// GetAllJails implements Connector. // =========================================================================
// Delegated Operations
// =========================================================================
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
return GetAllJails() return GetAllJails()
} }
// UpdateJailEnabledStates implements Connector.
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error { func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return UpdateJailEnabledStates(updates) return UpdateJailEnabledStates(updates)
} }
// GetFilters implements Connector.
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) { func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
return GetFiltersLocal() return GetFiltersLocal()
} }
// TestFilter implements Connector.
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) { func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (string, string, error) {
return TestFilterLocal(filterName, logLines, filterContent) return TestFilterLocal(filterName, logLines, filterContent)
} }
// GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) { func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, string, error) {
return GetJailConfig(jail) return GetJailConfig(jail)
} }
// SetJailConfig implements Connector.
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error { func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
return SetJailConfig(jail, content) return SetJailConfig(jail, content)
} }
// TestLogpath implements Connector.
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) { func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
return TestLogpath(logpath) return TestLogpath(logpath)
} }
// TestLogpathWithResolution implements Connector.
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) { func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
return TestLogpathWithResolution(logpath) return TestLogpathWithResolution(logpath)
} }
// UpdateDefaultSettings implements Connector.
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error { func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
return UpdateDefaultSettingsLocal(settings) return UpdateDefaultSettingsLocal(settings)
} }
// EnsureJailLocalStructure implements Connector.
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error { func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
return config.EnsureJailLocalStructure() return config.EnsureJailLocalStructure()
} }
// CreateJail implements Connector.
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error { func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
return CreateJail(jailName, content) return CreateJail(jailName, content)
} }
// DeleteJail implements Connector.
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error { func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
return DeleteJail(jailName) return DeleteJail(jailName)
} }
// CreateFilter implements Connector.
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error { func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
return CreateFilter(filterName, content) return CreateFilter(filterName, content)
} }
// DeleteFilter implements Connector.
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error { func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
return DeleteFilter(filterName) return DeleteFilter(filterName)
} }
// CheckJailLocalIntegrity implements Connector.
func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) { func (lc *LocalConnector) CheckJailLocalIntegrity(ctx context.Context) (bool, bool, error) {
const jailLocalPath = "/etc/fail2ban/jail.local" const jailLocalPath = "/etc/fail2ban/jail.local"
content, err := os.ReadFile(jailLocalPath) content, err := os.ReadFile(jailLocalPath)
if err != nil { if err != nil {
if os.IsNotExist(err) { 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) 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 return true, hasUIAction, nil
} }
// =========================================================================
// Shell Execution
// =========================================================================
func executeShellCommand(ctx context.Context, command string) (string, error) { func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command) parts := strings.Fields(command)
if len(parts) == 0 { 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. // 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) // Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License. // You may not use this file except in compliance with the License.
@@ -29,8 +29,7 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/config"
) )
// GetFilterConfig returns the filter configuration using the default connector. // Returns the filter configuration using the default connector.
// Returns (config, filePath, error)
func GetFilterConfig(jail string) (string, string, error) { func GetFilterConfig(jail string) (string, string, error) {
conn, err := GetManager().DefaultConnector() conn, err := GetManager().DefaultConnector()
if err != nil { if err != nil {
@@ -39,7 +38,7 @@ func GetFilterConfig(jail string) (string, string, error) {
return conn.GetFilterConfig(context.Background(), jail) 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 { func SetFilterConfig(jail, newContent string) error {
conn, err := GetManager().DefaultConnector() conn, err := GetManager().DefaultConnector()
if err != nil { if err != nil {
@@ -48,10 +47,7 @@ func SetFilterConfig(jail, newContent string) error {
return conn.SetFilterConfig(context.Background(), jail, newContent) 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 { func ensureFilterLocalFile(filterName string) error {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName) filterName = strings.TrimSpace(filterName)
if filterName == "" { if filterName == "" {
return fmt.Errorf("filter name cannot be empty") return fmt.Errorf("filter name cannot be empty")
@@ -61,13 +57,11 @@ func ensureFilterLocalFile(filterName string) error {
localPath := filepath.Join(filterDPath, filterName+".local") localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf") confPath := filepath.Join(filterDPath, filterName+".conf")
// Check if .local already exists
if _, err := os.Stat(localPath); err == nil { if _, err := os.Stat(localPath); err == nil {
config.DebugLog("Filter .local file already exists: %s", localPath) config.DebugLog("Filter .local file already exists: %s", localPath)
return nil return nil
} }
// Try to copy from .conf if it exists
if _, err := os.Stat(confPath); err == nil { if _, err := os.Stat(confPath); err == nil {
config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath) config.DebugLog("Copying filter config from .conf to .local: %s -> %s", confPath, localPath)
content, err := os.ReadFile(confPath) content, err := os.ReadFile(confPath)
@@ -81,7 +75,6 @@ func ensureFilterLocalFile(filterName string) error {
return nil return nil
} }
// Neither exists, create empty .local file
config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName) config.DebugLog("Neither .local nor .conf exists for filter %s, creating empty .local file", filterName)
if err := os.WriteFile(localPath, []byte(""), 0644); err != nil { if err := os.WriteFile(localPath, []byte(""), 0644); err != nil {
return fmt.Errorf("failed to create empty filter .local file %s: %w", localPath, err) 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 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 { func RemoveComments(content string) string {
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
var result []string var result []string
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
// Skip lines that start with # (comments)
if !strings.HasPrefix(trimmed, "#") { if !strings.HasPrefix(trimmed, "#") {
result = append(result, line) result = append(result, line)
} }
} }
// Remove leading empty lines
for len(result) > 0 && strings.TrimSpace(result[0]) == "" { for len(result) > 0 && strings.TrimSpace(result[0]) == "" {
result = result[1:] result = result[1:]
} }
// Remove trailing empty lines
for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" { for len(result) > 0 && strings.TrimSpace(result[len(result)-1]) == "" {
result = result[:len(result)-1] result = result[:len(result)-1]
} }
@@ -117,10 +104,8 @@ func RemoveComments(content string) string {
return strings.Join(result, "\n") return strings.Join(result, "\n")
} }
// readFilterConfigWithFallback reads filter config from .local first, then falls back to .conf. // Reads filter config from .local first, then falls back to .conf.
// Returns (content, filePath, error)
func readFilterConfigWithFallback(filterName string) (string, string, error) { func readFilterConfigWithFallback(filterName string) (string, string, error) {
// Validate filter name - must not be empty
filterName = strings.TrimSpace(filterName) filterName = strings.TrimSpace(filterName)
if filterName == "" { if filterName == "" {
return "", "", fmt.Errorf("filter name cannot be empty") 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") localPath := filepath.Join(filterDPath, filterName+".local")
confPath := filepath.Join(filterDPath, filterName+".conf") confPath := filepath.Join(filterDPath, filterName+".conf")
// Try .local first
if content, err := os.ReadFile(localPath); err == nil { if content, err := os.ReadFile(localPath); err == nil {
config.DebugLog("Reading filter config from .local: %s", localPath) config.DebugLog("Reading filter config from .local: %s", localPath)
return string(content), localPath, nil return string(content), localPath, nil
} }
// Fallback to .conf
if content, err := os.ReadFile(confPath); err == nil { if content, err := os.ReadFile(confPath); err == nil {
config.DebugLog("Reading filter config from .conf: %s", confPath) config.DebugLog("Reading filter config from .conf: %s", confPath)
return string(content), confPath, nil 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) 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) { func GetFilterConfigLocal(jail string) (string, string, error) {
return readFilterConfigWithFallback(jail) 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 { func SetFilterConfigLocal(jail, newContent string) error {
// Ensure .local file exists (copy from .conf if needed)
if err := ensureFilterLocalFile(jail); err != nil { if err := ensureFilterLocalFile(jail); err != nil {
return err return err
} }
localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local") localPath := filepath.Join("/etc/fail2ban/filter.d", jail+".local")
if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil { if err := os.WriteFile(localPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write filter .local file for %s: %w", jail, err) 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 return nil
} }
// ValidateFilterName validates a filter name format. // Validates a filter name format.
// Returns an error if the name is invalid (empty, contains invalid characters, or is reserved).
func ValidateFilterName(name string) error { func ValidateFilterName(name string) error {
name = strings.TrimSpace(name) name = strings.TrimSpace(name)
if name == "" { if name == "" {
return fmt.Errorf("filter name cannot be empty") return fmt.Errorf("filter name cannot be empty")
} }
// Check for invalid characters (only alphanumeric, dash, underscore allowed)
invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`) invalidChars := regexp.MustCompile(`[^a-zA-Z0-9_-]`)
if invalidChars.MatchString(name) { if invalidChars.MatchString(name) {
return fmt.Errorf("filter name '%s' contains invalid characters. Only alphanumeric characters, dashes, and underscores are allowed", 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 return nil
} }
// ListFilterFiles lists all filter files in the specified directory. // Lists all filter files in the specified directory.
// Returns full paths to .local and .conf files.
func ListFilterFiles(directory string) ([]string, error) { func ListFilterFiles(directory string) ([]string, error) {
var files []string var files []string
@@ -200,14 +171,10 @@ func ListFilterFiles(directory string) ([]string, error) {
if entry.IsDir() { if entry.IsDir() {
continue continue
} }
name := entry.Name() name := entry.Name()
// Skip hidden files and invalid names
if strings.HasPrefix(name, ".") { if strings.HasPrefix(name, ".") {
continue continue
} }
// Only include .local and .conf files
if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") { if strings.HasSuffix(name, ".local") || strings.HasSuffix(name, ".conf") {
fullPath := filepath.Join(directory, name) fullPath := filepath.Join(directory, name)
files = append(files, fullPath) files = append(files, fullPath)
@@ -217,28 +184,22 @@ func ListFilterFiles(directory string) ([]string, error) {
return files, nil return files, nil
} }
// DiscoverFiltersFromFiles discovers all filters from the filesystem. // Returns all filters from the filesystem.
// Reads from /etc/fail2ban/filter.d/ directory, preferring .local files over .conf files.
// Returns unique filter names.
func DiscoverFiltersFromFiles() ([]string, error) { func DiscoverFiltersFromFiles() ([]string, error) {
filterDPath := "/etc/fail2ban/filter.d" filterDPath := "/etc/fail2ban/filter.d"
// Check if directory exists
if _, err := os.Stat(filterDPath); os.IsNotExist(err) { if _, err := os.Stat(filterDPath); os.IsNotExist(err) {
// Directory doesn't exist, return empty list
return []string{}, nil return []string{}, nil
} }
// List all filter files
files, err := ListFilterFiles(filterDPath) files, err := ListFilterFiles(filterDPath)
if err != nil { if err != nil {
return nil, err return nil, err
} }
filterMap := make(map[string]bool) // Track unique filter names filterMap := make(map[string]bool)
processedFiles := make(map[string]bool) // Track base names to avoid duplicates processedFiles := make(map[string]bool)
// First pass: collect all .local files (these take precedence)
for _, filePath := range files { for _, filePath := range files {
if !strings.HasSuffix(filePath, ".local") { if !strings.HasSuffix(filePath, ".local") {
continue continue
@@ -250,7 +211,6 @@ func DiscoverFiltersFromFiles() ([]string, error) {
continue continue
} }
// Skip if we've already processed this base name
if processedFiles[baseName] { if processedFiles[baseName] {
continue continue
} }
@@ -259,28 +219,22 @@ func DiscoverFiltersFromFiles() ([]string, error) {
filterMap[baseName] = true filterMap[baseName] = true
} }
// Second pass: collect .conf files that don't have corresponding .local files
for _, filePath := range files { for _, filePath := range files {
if !strings.HasSuffix(filePath, ".conf") { if !strings.HasSuffix(filePath, ".conf") {
continue continue
} }
filename := filepath.Base(filePath) filename := filepath.Base(filePath)
baseName := strings.TrimSuffix(filename, ".conf") baseName := strings.TrimSuffix(filename, ".conf")
if baseName == "" { if baseName == "" {
continue continue
} }
// Skip if we've already processed a .local file with the same base name
if processedFiles[baseName] { if processedFiles[baseName] {
continue continue
} }
processedFiles[baseName] = true processedFiles[baseName] = true
filterMap[baseName] = true filterMap[baseName] = true
} }
// Convert map to sorted slice
var filters []string var filters []string
for name := range filterMap { for name := range filterMap {
filters = append(filters, name) filters = append(filters, name)
@@ -290,32 +244,24 @@ func DiscoverFiltersFromFiles() ([]string, error) {
return filters, nil return filters, nil
} }
// CreateFilter creates a new filter in filter.d/{name}.local. // Creates a new filter.
// If the filter already exists, it will be overwritten.
func CreateFilter(filterName, content string) error { func CreateFilter(filterName, content string) error {
if err := ValidateFilterName(filterName); err != nil { if err := ValidateFilterName(filterName); err != nil {
return err return err
} }
filterDPath := "/etc/fail2ban/filter.d" filterDPath := "/etc/fail2ban/filter.d"
localPath := filepath.Join(filterDPath, filterName+".local") localPath := filepath.Join(filterDPath, filterName+".local")
// Ensure directory exists
if err := os.MkdirAll(filterDPath, 0755); err != nil { if err := os.MkdirAll(filterDPath, 0755); err != nil {
return fmt.Errorf("failed to create filter.d directory: %w", err) return fmt.Errorf("failed to create filter.d directory: %w", err)
} }
// Write the file
if err := os.WriteFile(localPath, []byte(content), 0644); err != nil { if err := os.WriteFile(localPath, []byte(content), 0644); err != nil {
return fmt.Errorf("failed to create filter file %s: %w", localPath, err) return fmt.Errorf("failed to create filter file %s: %w", localPath, err)
} }
config.DebugLog("Created filter file: %s", localPath) config.DebugLog("Created filter file: %s", localPath)
return nil return nil
} }
// DeleteFilter deletes a filter's .local and .conf files from filter.d/ if they exist. // 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.
func DeleteFilter(filterName string) error { func DeleteFilter(filterName string) error {
if err := ValidateFilterName(filterName); err != nil { if err := ValidateFilterName(filterName); err != nil {
return err return err
@@ -328,7 +274,6 @@ func DeleteFilter(filterName string) error {
var deletedFiles []string var deletedFiles []string
var lastErr error var lastErr error
// Delete .local file if it exists
if _, err := os.Stat(localPath); err == nil { if _, err := os.Stat(localPath); err == nil {
if err := os.Remove(localPath); err != nil { if err := os.Remove(localPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", localPath, err) 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) config.DebugLog("Deleted filter file: %s", localPath)
} }
} }
// Delete .conf file if it exists
if _, err := os.Stat(confPath); err == nil { if _, err := os.Stat(confPath); err == nil {
if err := os.Remove(confPath); err != nil { if err := os.Remove(confPath); err != nil {
lastErr = fmt.Errorf("failed to delete filter file %s: %w", confPath, err) 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) 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 { if len(deletedFiles) == 0 && lastErr == nil {
return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath) return fmt.Errorf("filter file %s or %s does not exist", localPath, confPath)
} }
// Return the last error if any occurred
if lastErr != nil { if lastErr != nil {
return lastErr return lastErr
} }
return nil 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) { func GetFiltersLocal() ([]string, error) {
return DiscoverFiltersFromFiles() return DiscoverFiltersFromFiles()
} }
@@ -380,7 +315,7 @@ func normalizeLogLines(logLines []string) []string {
return cleaned 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 { func extractVariablesFromContent(content string) map[string]bool {
variables := make(map[string]bool) variables := make(map[string]bool)
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
@@ -388,20 +323,15 @@ func extractVariablesFromContent(content string) map[string]bool {
for _, line := range lines { for _, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
// Check for [DEFAULT] section
if strings.HasPrefix(trimmed, "[DEFAULT]") { if strings.HasPrefix(trimmed, "[DEFAULT]") {
inDefaultSection = true inDefaultSection = true
continue continue
} }
// Check for end of [DEFAULT] section (next section starts) // Check for end of [DEFAULT] section (next section starts)
if inDefaultSection && strings.HasPrefix(trimmed, "[") { if inDefaultSection && strings.HasPrefix(trimmed, "[") {
inDefaultSection = false inDefaultSection = false
continue continue
} }
// Extract variable name from [DEFAULT] section
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
parts := strings.SplitN(trimmed, "=", 2) parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
@@ -412,11 +342,9 @@ func extractVariablesFromContent(content string) map[string]bool {
} }
} }
} }
return variables return variables
} }
// removeDuplicateVariables removes variable definitions from included content that already exist in main filter
func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string { func removeDuplicateVariables(includedContent string, mainVariables map[string]bool) string {
lines := strings.Split(includedContent, "\n") lines := strings.Split(includedContent, "\n")
var result strings.Builder var result strings.Builder
@@ -427,7 +355,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
originalLine := line originalLine := line
// Check for [DEFAULT] section
if strings.HasPrefix(trimmed, "[DEFAULT]") { if strings.HasPrefix(trimmed, "[DEFAULT]") {
inDefaultSection = true inDefaultSection = true
result.WriteString(originalLine) result.WriteString(originalLine)
@@ -443,13 +370,11 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
continue continue
} }
// In [DEFAULT] section, check if variable already exists in main filter
if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") { if inDefaultSection && !strings.HasPrefix(trimmed, "#") && strings.Contains(trimmed, "=") {
parts := strings.SplitN(trimmed, "=", 2) parts := strings.SplitN(trimmed, "=", 2)
if len(parts) == 2 { if len(parts) == 2 {
varName := strings.TrimSpace(parts[0]) varName := strings.TrimSpace(parts[0])
if mainVariables[varName] { if mainVariables[varName] {
// Skip this line - variable will be defined in main filter (takes precedence)
removedCount++ removedCount++
config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName) config.DebugLog("Removing variable '%s' from included file (will be overridden by main filter)", varName)
continue continue
@@ -468,11 +393,6 @@ func removeDuplicateVariables(includedContent string, mainVariables map[string]b
return result.String() 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) { func resolveFilterIncludes(filterContent string, filterDPath string, currentFilterName string) (string, error) {
lines := strings.Split(filterContent, "\n") lines := strings.Split(filterContent, "\n")
var beforeFiles []string var beforeFiles []string
@@ -484,18 +404,15 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
for i, line := range lines { for i, line := range lines {
trimmed := strings.TrimSpace(line) trimmed := strings.TrimSpace(line)
// Check for [INCLUDES] section
if strings.HasPrefix(trimmed, "[INCLUDES]") { if strings.HasPrefix(trimmed, "[INCLUDES]") {
inIncludesSection = true inIncludesSection = true
continue continue
} }
// Check for end of [INCLUDES] section (next section starts)
if inIncludesSection && strings.HasPrefix(trimmed, "[") { if inIncludesSection && strings.HasPrefix(trimmed, "[") {
inIncludesSection = false inIncludesSection = false
} }
// Parse before and after directives
if inIncludesSection { if inIncludesSection {
if strings.HasPrefix(strings.ToLower(trimmed), "before") { if strings.HasPrefix(strings.ToLower(trimmed), "before") {
parts := strings.SplitN(trimmed, "=", 2) 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 !inIncludesSection {
if i > 0 { if i > 0 {
mainContent.WriteString("\n") mainContent.WriteString("\n")
@@ -532,12 +448,9 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
mainContentStr := mainContent.String() mainContentStr := mainContent.String()
mainVariables := extractVariablesFromContent(mainContentStr) mainVariables := extractVariablesFromContent(mainContentStr)
// Build combined content: before files + main filter + after files
var combined strings.Builder var combined strings.Builder
// Load and append before files, removing duplicates that exist in main filter
for _, fileName := range beforeFiles { for _, fileName := range beforeFiles {
// Remove any existing extension to get base name
baseName := fileName baseName := fileName
if strings.HasSuffix(baseName, ".local") { if strings.HasSuffix(baseName, ".local") {
baseName = strings.TrimSuffix(baseName, ".local") baseName = strings.TrimSuffix(baseName, ".local")
@@ -545,13 +458,11 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
baseName = strings.TrimSuffix(baseName, ".conf") baseName = strings.TrimSuffix(baseName, ".conf")
} }
// Skip if this is the same filter (avoid self-inclusion)
if baseName == currentFilterName { if baseName == currentFilterName {
config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName) config.DebugLog("Skipping self-inclusion of filter '%s' in before files", baseName)
continue continue
} }
// Always try .local first, then .conf (matching fail2ban's behavior)
localPath := filepath.Join(filterDPath, baseName+".local") localPath := filepath.Join(filterDPath, baseName+".local")
confPath := filepath.Join(filterDPath, baseName+".conf") confPath := filepath.Join(filterDPath, baseName+".conf")
@@ -559,7 +470,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
var err error var err error
var filePath string var filePath string
// Try .local first
if content, err = os.ReadFile(localPath); err == nil { if content, err = os.ReadFile(localPath); err == nil {
filePath = localPath filePath = localPath
config.DebugLog("Loading included filter file from .local: %s", filePath) 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) config.DebugLog("Loading included filter file from .conf: %s", filePath)
} else { } else {
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) 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) 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) cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
combined.WriteString(cleanedContent) combined.WriteString(cleanedContent)
if !strings.HasSuffix(cleanedContent, "\n") { if !strings.HasSuffix(cleanedContent, "\n") {
@@ -581,15 +491,12 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
combined.WriteString("\n") combined.WriteString("\n")
} }
// Append main filter content (unchanged - this is what the user is editing)
combined.WriteString(mainContentStr) combined.WriteString(mainContentStr)
if !strings.HasSuffix(mainContentStr, "\n") { if !strings.HasSuffix(mainContentStr, "\n") {
combined.WriteString("\n") combined.WriteString("\n")
} }
// Load and append after files, also removing duplicates that exist in main filter
for _, fileName := range afterFiles { for _, fileName := range afterFiles {
// Remove any existing extension to get base name
baseName := fileName baseName := fileName
if strings.HasSuffix(baseName, ".local") { if strings.HasSuffix(baseName, ".local") {
baseName = strings.TrimSuffix(baseName, ".local") baseName = strings.TrimSuffix(baseName, ".local")
@@ -597,11 +504,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
baseName = strings.TrimSuffix(baseName, ".conf") 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") localPath := filepath.Join(filterDPath, baseName+".local")
confPath := filepath.Join(filterDPath, baseName+".conf") confPath := filepath.Join(filterDPath, baseName+".conf")
@@ -609,7 +511,6 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
var err error var err error
var filePath string var filePath string
// Try .local first
if content, err = os.ReadFile(localPath); err == nil { if content, err = os.ReadFile(localPath); err == nil {
filePath = localPath filePath = localPath
config.DebugLog("Loading included filter file from .local: %s", filePath) 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) config.DebugLog("Loading included filter file from .conf: %s", filePath)
} else { } else {
config.DebugLog("Warning: could not load included filter file '%s' or '%s': %v", localPath, confPath, err) 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) contentStr := string(content)
// Remove variables from included file that are defined in main filter (main filter takes precedence)
cleanedContent := removeDuplicateVariables(contentStr, mainVariables) cleanedContent := removeDuplicateVariables(contentStr, mainVariables)
combined.WriteString("\n") combined.WriteString("\n")
combined.WriteString(cleanedContent) combined.WriteString(cleanedContent)
@@ -634,10 +533,10 @@ func resolveFilterIncludes(filterContent string, filterDPath string, currentFilt
return combined.String(), nil 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 // Filter Testing
// 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
func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) { func TestFilterLocal(filterName string, logLines []string, filterContent string) (string, string, error) {
cleaned := normalizeLogLines(logLines) cleaned := normalizeLogLines(logLines)
if len(cleaned) == 0 { if len(cleaned) == 0 {
@@ -657,7 +556,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
defer os.Remove(tempFilterFile.Name()) defer os.Remove(tempFilterFile.Name())
defer tempFilterFile.Close() defer tempFilterFile.Close()
// Resolve filter includes to get complete filter content with all dependencies
filterDPath := "/etc/fail2ban/filter.d" filterDPath := "/etc/fail2ban/filter.d"
contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName) contentToWrite, err := resolveFilterIncludes(filterContent, filterDPath, filterName)
if err != nil { if err != nil {
@@ -665,7 +563,6 @@ func TestFilterLocal(filterName string, logLines []string, filterContent string)
contentToWrite = filterContent contentToWrite = filterContent
} }
// Ensure it ends with a newline for proper parsing
if !strings.HasSuffix(contentToWrite, "\n") { if !strings.HasSuffix(contentToWrite, "\n") {
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) return "", "", fmt.Errorf("failed to write temporary filter file: %w", err)
} }
// Ensure the file is synced to disk
if err := tempFilterFile.Sync(); err != nil { if err := tempFilterFile.Sync(); err != nil {
return "", "", fmt.Errorf("failed to sync temporary filter file: %w", err) 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() 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) config.DebugLog("TestFilterLocal: using custom filter content from temporary file: %s (size: %d bytes, includes resolved: %v)", filterPath, len(contentToWrite), err == nil)
} else { } else {
// Try .local first, then fallback to .conf
localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local") localPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".local")
confPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf") 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 os.Remove(tmpFile.Name())
defer tmpFile.Close() defer tmpFile.Close()
// Write all log lines to the temp file
for _, logLine := range cleaned { for _, logLine := range cleaned {
if _, err := tmpFile.WriteString(logLine + "\n"); err != nil { if _, err := tmpFile.WriteString(logLine + "\n"); err != nil {
return "", filterPath, fmt.Errorf("failed to write to temporary log file: %w", err) 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() 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) cmd := exec.Command("fail2ban-regex", tmpFile.Name(), filterPath)
out, _ := cmd.CombinedOutput() out, _ := cmd.CombinedOutput()
output := string(out) 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 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 { if cfg.SkipTLSVerify {
httpClient.Transport = &http.Transport{ 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 { if cfg.SkipTLSVerify {
httpClient.Transport = &http.Transport{ 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" _ "modernc.org/sqlite"
) )
// =========================================================================
// Database Connection
// =========================================================================
var ( var (
db *sql.DB db *sql.DB
initOnce sync.Once initOnce sync.Once
@@ -22,6 +26,10 @@ var (
defaultPath = "fail2ban-ui.db" defaultPath = "fail2ban-ui.db"
) )
// =========================================================================
// Conversion Helpers
// =========================================================================
func boolToInt(b bool) int { func boolToInt(b bool) int {
if b { if b {
return 1 return 1
@@ -47,22 +55,21 @@ func intFromNull(ni sql.NullInt64) int {
return 0 return 0
} }
// =========================================================================
// Types
// =========================================================================
type AppSettingsRecord struct { type AppSettingsRecord struct {
// Basic app settings Language string
Language string Port int
Port int Debug bool
Debug bool RestartNeeded bool
RestartNeeded bool CallbackURL string
// Callback settings CallbackSecret string
CallbackURL string AlertCountriesJSON string
CallbackSecret string EmailAlertsForBans bool
// Alert settings EmailAlertsForUnbans bool
AlertCountriesJSON string ConsoleOutput bool
EmailAlertsForBans bool
EmailAlertsForUnbans bool
// Console output settings
ConsoleOutput bool
// SMTP settings
SMTPHost string SMTPHost string
SMTPPort int SMTPPort int
SMTPUsername string SMTPUsername string
@@ -71,23 +78,21 @@ type AppSettingsRecord struct {
SMTPUseTLS bool SMTPUseTLS bool
SMTPInsecureSkipVerify bool SMTPInsecureSkipVerify bool
SMTPAuthMethod string SMTPAuthMethod string
// Fail2Ban DEFAULT settings BantimeIncrement bool
BantimeIncrement bool DefaultJailEnable bool
DefaultJailEnable bool IgnoreIP string
IgnoreIP string // Stored as space-separated string, converted to array in AppSettings Bantime string
Bantime string Findtime string
Findtime string MaxRetry int
MaxRetry int DestEmail string
DestEmail string Banaction string
Banaction string BanactionAllports string
BanactionAllports string Chain string
Chain string BantimeRndtime string
BantimeRndtime string AdvancedActionsJSON string
// Advanced features GeoIPProvider string
AdvancedActionsJSON string GeoIPDatabasePath string
GeoIPProvider string MaxLogLines int
GeoIPDatabasePath string
MaxLogLines int
} }
type ServerRecord struct { type ServerRecord struct {
@@ -111,7 +116,6 @@ type ServerRecord struct {
UpdatedAt time.Time UpdatedAt time.Time
} }
// BanEventRecord represents a single ban or unban event stored in the internal database.
type BanEventRecord struct { type BanEventRecord struct {
ID int64 `json:"id"` ID int64 `json:"id"`
ServerID string `json:"serverId"` ServerID string `json:"serverId"`
@@ -128,7 +132,6 @@ type BanEventRecord struct {
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
} }
// RecurringIPStat represents aggregation info for repeatedly banned IPs.
type RecurringIPStat struct { type RecurringIPStat struct {
IP string `json:"ip"` IP string `json:"ip"`
Country string `json:"country"` Country string `json:"country"`
@@ -148,7 +151,7 @@ type PermanentBlockRecord struct {
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
} }
// Init initializes the internal storage. Safe to call multiple times. // Initialize the database.
func Init(dbPath string) error { func Init(dbPath string) error {
initOnce.Do(func() { initOnce.Do(func() {
if dbPath == "" { if dbPath == "" {
@@ -159,9 +162,7 @@ func Init(dbPath string) error {
return return
} }
// Ensure .ssh directory exists (for SSH key storage)
if err := ensureSSHDirectory(); err != nil { 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) log.Printf("Warning: failed to ensure .ssh directory: %v", err)
} }
@@ -182,7 +183,7 @@ func Init(dbPath string) error {
return initErr return initErr
} }
// Close closes the underlying database if it has been initialised. // Close the database.
func Close() error { func Close() error {
if db == nil { if db == nil {
return nil return nil
@@ -190,6 +191,7 @@ func Close() error {
return db.Close() return db.Close()
} }
// Get the app settings.
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) { func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
if db == nil { if db == nil {
return AppSettingsRecord{}, false, errors.New("storage not initialised") return AppSettingsRecord{}, false, errors.New("storage not initialised")
@@ -215,19 +217,15 @@ WHERE id = 1`)
} }
rec := AppSettingsRecord{ rec := AppSettingsRecord{
// Basic app settings Language: stringFromNull(lang),
Language: stringFromNull(lang), Port: intFromNull(port),
Port: intFromNull(port), Debug: intToBool(intFromNull(debug)),
Debug: intToBool(intFromNull(debug)), RestartNeeded: intToBool(intFromNull(restartNeeded)),
RestartNeeded: intToBool(intFromNull(restartNeeded)), CallbackURL: stringFromNull(callback),
// Callback settings CallbackSecret: stringFromNull(callbackSecret),
CallbackURL: stringFromNull(callback), AlertCountriesJSON: stringFromNull(alerts),
CallbackSecret: stringFromNull(callbackSecret), EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
// Alert settings EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
AlertCountriesJSON: stringFromNull(alerts),
EmailAlertsForBans: intToBool(intFromNull(emailAlertsForBans)),
EmailAlertsForUnbans: intToBool(intFromNull(emailAlertsForUnbans)),
// SMTP settings
SMTPHost: stringFromNull(smtpHost), SMTPHost: stringFromNull(smtpHost),
SMTPPort: intFromNull(smtpPort), SMTPPort: intFromNull(smtpPort),
SMTPUsername: stringFromNull(smtpUser), SMTPUsername: stringFromNull(smtpUser),
@@ -236,25 +234,22 @@ WHERE id = 1`)
SMTPUseTLS: intToBool(intFromNull(smtpTLS)), SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)), SMTPInsecureSkipVerify: intToBool(intFromNull(smtpInsecureSkipVerify)),
SMTPAuthMethod: stringFromNull(smtpAuthMethod), SMTPAuthMethod: stringFromNull(smtpAuthMethod),
// Fail2Ban DEFAULT settings BantimeIncrement: intToBool(intFromNull(bantimeInc)),
BantimeIncrement: intToBool(intFromNull(bantimeInc)), DefaultJailEnable: intToBool(intFromNull(defaultJailEn)),
DefaultJailEnable: intToBool(intFromNull(defaultJailEn)), IgnoreIP: stringFromNull(ignoreIP),
IgnoreIP: stringFromNull(ignoreIP), Bantime: stringFromNull(bantime),
Bantime: stringFromNull(bantime), Findtime: stringFromNull(findtime),
Findtime: stringFromNull(findtime), MaxRetry: intFromNull(maxretry),
MaxRetry: intFromNull(maxretry), DestEmail: stringFromNull(destemail),
DestEmail: stringFromNull(destemail), Banaction: stringFromNull(banaction),
Banaction: stringFromNull(banaction), BanactionAllports: stringFromNull(banactionAllports),
BanactionAllports: stringFromNull(banactionAllports), Chain: stringFromNull(chain),
Chain: stringFromNull(chain), BantimeRndtime: stringFromNull(bantimeRndtime),
BantimeRndtime: stringFromNull(bantimeRndtime), AdvancedActionsJSON: stringFromNull(advancedActions),
// Advanced features GeoIPProvider: stringFromNull(geoipProvider),
AdvancedActionsJSON: stringFromNull(advancedActions), GeoIPDatabasePath: stringFromNull(geoipDatabasePath),
GeoIPProvider: stringFromNull(geoipProvider), MaxLogLines: intFromNull(maxLogLines),
GeoIPDatabasePath: stringFromNull(geoipDatabasePath), ConsoleOutput: intToBool(intFromNull(consoleOutput)),
MaxLogLines: intFromNull(maxLogLines),
// Console output settings
ConsoleOutput: intToBool(intFromNull(consoleOutput)),
} }
return rec, true, nil return rec, true, nil
@@ -339,6 +334,10 @@ INSERT INTO app_settings (
return err return err
} }
// =========================================================================
// Servers
// =========================================================================
func ListServers(ctx context.Context) ([]ServerRecord, error) { func ListServers(ctx context.Context) ([]ServerRecord, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -493,7 +492,11 @@ func DeleteServer(ctx context.Context, id string) error {
return err 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 { func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
if db == nil { if db == nil {
return errors.New("storage not initialised") return errors.New("storage not initialised")
@@ -509,8 +512,7 @@ func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
if record.OccurredAt.IsZero() { if record.OccurredAt.IsZero() {
record.OccurredAt = now record.OccurredAt = now
} }
// If the event type is not set, we set it to "ban" by default.
// Default to 'ban' if event type is not set
eventType := record.EventType eventType := record.EventType
if eventType == "" { if eventType == "" {
eventType = "ban" eventType = "ban"
@@ -544,7 +546,7 @@ INSERT INTO ban_events (
return nil 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) { func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -599,7 +601,6 @@ WHERE 1=1`
); err != nil { ); err != nil {
return nil, err return nil, err
} }
// Default to 'ban' if event_type is NULL (for backward compatibility)
if eventType.Valid { if eventType.Valid {
rec.EventType = eventType.String rec.EventType = eventType.String
} else { } else {
@@ -618,7 +619,7 @@ const (
MaxBanEventsOffset = 1000 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. // search is applied as LIKE %search% on ip, jail, server_name, hostname, country.
// limit is capped at MaxBanEventsLimit; offset is capped at MaxBanEventsOffset. // 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) { 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() 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) { func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Time, search, country string) (int64, error) {
if db == nil { if db == nil {
return 0, errors.New("storage not initialised") return 0, errors.New("storage not initialised")
@@ -744,7 +745,7 @@ func CountBanEventsFiltered(ctx context.Context, serverID string, since time.Tim
return total, nil 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) { func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -782,7 +783,7 @@ WHERE 1=1`
return result, rows.Err() 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) { func CountBanEvents(ctx context.Context, since time.Time, serverID string) (int64, error) {
if db == nil { if db == nil {
return 0, errors.New("storage not initialised") return 0, errors.New("storage not initialised")
@@ -811,7 +812,7 @@ WHERE 1=1`
return total, nil 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) { func CountBanEventsByIP(ctx context.Context, ip, serverID string) (int64, error) {
if db == nil { if db == nil {
return 0, errors.New("storage not initialised") 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 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) { func CountBanEventsByCountry(ctx context.Context, since time.Time, serverID string) (map[string]int64, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -881,7 +882,11 @@ WHERE 1=1`
return result, rows.Err() 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) { func ListRecurringIPStats(ctx context.Context, since time.Time, minCount, limit int, serverID string) ([]RecurringIPStat, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -927,20 +932,14 @@ LIMIT ?`
var results []RecurringIPStat var results []RecurringIPStat
for rows.Next() { for rows.Next() {
var stat RecurringIPStat var stat RecurringIPStat
// First, scan as string to see what format SQLite returns
// Then parse it properly
var lastSeenStr sql.NullString var lastSeenStr sql.NullString
if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil { if err := rows.Scan(&stat.IP, &stat.Country, &stat.Count, &lastSeenStr); err != nil {
return nil, fmt.Errorf("failed to scan row: %w", err) return nil, fmt.Errorf("failed to scan row: %w", err)
} }
if lastSeenStr.Valid && lastSeenStr.String != "" { 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{ 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.RFC3339Nano,
time.RFC3339, time.RFC3339,
"2006-01-02 15:04:05.999999999+00:00", "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.999999999",
"2006-01-02T15:04:05", "2006-01-02T15:04:05",
} }
parsed := time.Time{} // zero time parsed := time.Time{}
for _, format := range formats { for _, format := range formats {
if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil { if t, parseErr := time.Parse(format, lastSeenStr.String); parseErr == nil {
parsed = t.UTC() parsed = t.UTC()
break break
} }
} }
// If still zero, log the actual string for debugging
if parsed.IsZero() { 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) 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 stat.LastSeen = parsed
} else { } else {
// Log when lastSeen is NULL or empty
log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP) log.Printf("WARNING: lastSeen is NULL or empty for IP %s", stat.IP)
} }
results = append(results, stat) results = append(results, stat)
@@ -974,6 +971,10 @@ LIMIT ?`
return results, rows.Err() return results, rows.Err()
} }
// =========================================================================
// Schema Management
// =========================================================================
func ensureSchema(ctx context.Context) error { func ensureSchema(ctx context.Context) error {
if db == nil { if db == nil {
return errors.New("storage not initialised") 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 { if _, err := db.ExecContext(ctx, createTable); err != nil {
return err 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 := 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") { if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err 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 := 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") { if err != nil && !strings.Contains(strings.ToLower(err.Error()), "duplicate column name") {
return err return err
@@ -1119,8 +1106,6 @@ CREATE INDEX IF NOT EXISTS idx_perm_blocks_status ON permanent_blocks(status);
return err return err
} }
} }
_ = strings.Contains // Keep strings import for migration example above
return nil return nil
} }
@@ -1135,16 +1120,12 @@ func ensureDirectory(path string) error {
return os.MkdirAll(dir, 0o755) return os.MkdirAll(dir, 0o755)
} }
// ensureSSHDirectory ensures the .ssh directory exists for SSH key storage. // Ensures .ssh exists for SSH key storage (/config/.ssh in container, ~/.ssh on host).
// In containers, this is /config/.ssh, on the host it's ~/.ssh
func ensureSSHDirectory() error { func ensureSSHDirectory() error {
var sshDir string var sshDir string
// Check if running inside a container
if _, container := os.LookupEnv("CONTAINER"); container { if _, container := os.LookupEnv("CONTAINER"); container {
// In container, use /config/.ssh
sshDir = "/config/.ssh" sshDir = "/config/.ssh"
} else { } else {
// On host, use ~/.ssh
home, err := os.UserHomeDir() home, err := os.UserHomeDir()
if err != nil { if err != nil {
return fmt.Errorf("failed to get user home directory: %w", err) return fmt.Errorf("failed to get user home directory: %w", err)
@@ -1152,7 +1133,6 @@ func ensureSSHDirectory() error {
sshDir = filepath.Join(home, ".ssh") sshDir = filepath.Join(home, ".ssh")
} }
// Create directory with proper permissions (0700 for .ssh)
if err := os.MkdirAll(sshDir, 0o700); err != nil { if err := os.MkdirAll(sshDir, 0o700); err != nil {
return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err) return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err)
} }
@@ -1160,7 +1140,11 @@ func ensureSSHDirectory() error {
return nil 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 { func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error {
if db == nil { if db == nil {
return errors.New("storage not initialised") return errors.New("storage not initialised")
@@ -1200,7 +1184,7 @@ ON CONFLICT(ip, integration) DO UPDATE SET
return err return err
} }
// GetPermanentBlock retrieves a permanent block entry. // Returns a permanent block entry.
func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) { func GetPermanentBlock(ctx context.Context, ip, integration string) (PermanentBlockRecord, bool, error) {
if db == nil { if db == nil {
return PermanentBlockRecord{}, false, errors.New("storage not initialised") return PermanentBlockRecord{}, false, errors.New("storage not initialised")
@@ -1235,7 +1219,7 @@ WHERE ip = ? AND integration = ?`, ip, integration)
return rec, true, nil return rec, true, nil
} }
// ListPermanentBlocks returns recent permanent block entries. // Returns recent permanent block entries.
func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) { func ListPermanentBlocks(ctx context.Context, limit int) ([]PermanentBlockRecord, error) {
if db == nil { if db == nil {
return nil, errors.New("storage not initialised") return nil, errors.New("storage not initialised")
@@ -1276,7 +1260,7 @@ LIMIT ?`, limit)
return records, rows.Err() 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) { func IsPermanentBlockActive(ctx context.Context, ip, integration string) (bool, error) {
rec, found, err := GetPermanentBlock(ctx, ip, integration) rec, found, err := GetPermanentBlock(ctx, ip, integration)
if err != nil || !found { 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. 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) Licensed under the GNU General Public License, Version 3 (GPL-3.0)
You may not use this file except in compliance with the License. You may not use this file except in compliance with the License.
@@ -22,21 +22,14 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<title data-i18n="page.title">Fail2ban UI Dashboard</title> <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}}" /> <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-core.min.js?v={{.version}}"></script>
<script src="/static/vendor/prism/prism-autoloader.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}}"> <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}}"> <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}}" /> <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}}"> <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> <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}}"> <link rel="stylesheet" href="/static/vendor/fonts/google-fonts.css?v={{.version}}">
</head> </head>
@@ -80,7 +73,6 @@
<div id="clockDisplay" class="ml-4 text-sm font-mono"> <div id="clockDisplay" class="ml-4 text-sm font-mono">
<span id="clockTime">--:--:--</span> <span id="clockTime">--:--:--</span>
</div> </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 id="userInfoContainer" class="hidden ml-4 flex items-center gap-3 border-l border-blue-500 pl-4">
<div class="relative"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg> </svg>
</button> </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 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="px-4 py-2 border-b border-gray-200">
<div class="text-sm font-medium text-gray-900" id="userMenuDisplayName"></div> <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('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('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> <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 id="mobileUserInfoContainer" class="hidden border-t border-blue-500 mt-2 pt-2">
<div class="px-3 py-2"> <div class="px-3 py-2">
<div class="text-sm font-medium" id="mobileUserDisplayName"></div> <div class="text-sm font-medium" id="mobileUserDisplayName"></div>
@@ -126,15 +116,17 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Mobile menu END -->
</nav> </nav>
<!-- ************************ Navigation END *************************** --> <!-- ************************ 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 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"> <div class="max-w-md w-full">
<!-- Login Card -->
<div class="bg-white rounded-lg shadow-lg p-8 border border-gray-200"> <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="text-center mb-8">
<div class="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-blue-600 mb-4"> <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"> <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> <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> <p class="text-sm text-gray-600" data-i18n="auth.login_description">Please authenticate to access the management interface</p>
</div> </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 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">
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -158,8 +148,6 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Login Button -->
<div class="mb-6"> <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"> <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"> <svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -167,8 +155,6 @@
</svg> </svg>
<span data-i18n="auth.login_button">Sign in with OIDC</span> <span data-i18n="auth.login_button">Sign in with OIDC</span>
</button> </button>
<!-- Loading State -->
<div id="loginLoading" class="hidden text-center py-4"> <div id="loginLoading" class="hidden text-center py-4">
<div class="inline-flex items-center"> <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> <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> </div>
</div> </div>
<!-- Footer Info -->
<div class="pt-6 border-t border-gray-200"> <div class="pt-6 border-t border-gray-200">
<p class="text-xs text-center text-gray-500"> <p class="text-xs text-center text-gray-500">
Secure authentication via OpenID Connect Secure authentication via OpenID Connect
@@ -186,12 +170,14 @@
</div> </div>
</div> </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"> <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 id="dashboardSection">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6"> <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between mb-6">
<div> <div>
@@ -220,15 +206,14 @@
</div> </div>
</div> </div>
</div> </div>
<div id="dashboard"></div>
<div id="dashboard"></div> <!-- Dynamic content from the API -->
</div> </div>
<!-- ********************** Dashboard Page END ************************* --> <!-- ********************** Dashboard Section END ********************** -->
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<!-- Filter-Debug Page START --> <!-- Filter-Debug Section START -->
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<div id="filterSection" class="hidden"> <div id="filterSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="filter_debug.title">Filter Debug</h2> <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> </div>
</div> </div>
<!-- Textarea for filter content (readonly by default, editable with Edit button) -->
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center justify-between mb-2"> <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> <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" <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> placeholder="Filter content will appear here when a filter is selected..." readonly></textarea>
</div> </div>
<!-- Textarea for log lines to test -->
<div class="mb-4"> <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> <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" <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 id="testResults" class="hidden bg-gray-900 rounded-lg shadow p-6 text-white font-mono text-sm"></div>
</div> </div>
<!-- ********************* Filter-Debug Page END *********************** --> <!-- ********************* Filter-Debug Page END *********************** -->
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<!-- Settings Page START --> <!-- Settings Page START -->
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<div id="settingsSection" class="hidden"> <div id="settingsSection" class="hidden">
<h2 class="text-2xl font-bold text-gray-800 mb-6" data-i18n="settings.title">Settings</h2> <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"> <form onsubmit="saveSettings(event)" class="space-y-6">
<!-- General Settings Group --> <!-- ========================= General Settings ========================= -->
<div class="bg-white rounded-lg shadow p-6"> <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> <h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.general">General Settings</h3>
<!-- Language Selection -->
<div class="mb-4"> <div class="mb-4">
<label for="languageSelect" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.language">Language</label> <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"> <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> <option value="de_ch">Schwiizerdütsch</option>
</select> </select>
</div> </div>
<!-- Fail2Ban UI Port (server) -->
<div class="mb-4"> <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> <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" <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>
<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> <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>
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center justify-between mb-2"> <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> <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" /> 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> <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> </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 gap-4 border border-gray-200 rounded-lg p-2 overflow-x-auto bg-gray-50">
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="debugMode" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out"> <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> <label for="consoleOutput" class="ml-2 block text-sm text-gray-700" data-i18n="settings.enable_console">Enable Console Output</label>
</div> </div>
</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 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 justify-between bg-gray-800 px-4 py-2 border-b border-gray-700">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
@@ -361,7 +333,7 @@
</div> </div>
</div> </div>
<!-- Advanced Actions --> <!-- ========================= Advanced Actions ========================= -->
<div class="bg-white rounded-lg shadow p-6"> <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 class="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div> <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> <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> </div>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<div class="flex items-center"> <div class="flex items-center">
<input type="checkbox" id="advancedActionsEnabled" class="h-4 w-4 text-blue-600 border-gray-300 rounded"> <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> <label for="advancedActionsEnabled" class="ml-2 text-sm text-gray-700" data-i18n="settings.advanced.enable">Enable automatic permanent blocking</label>
</div> </div>
<div> <div>
<label for="advancedThreshold" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.threshold">Threshold before permanent block</label> <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"> <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> <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>
<div> <div>
<label for="advancedIntegrationSelect" class="block text-sm font-medium text-gray-700" data-i18n="settings.advanced.integration">Integration</label> <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"> <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>
</div> </div>
<div id="advancedPfSenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50"> <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> <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"> <div class="mb-3 text-sm">
@@ -454,7 +422,6 @@
</div> </div>
</div> </div>
</div> </div>
<div id="advancedOPNsenseFields" class="hidden border border-gray-200 rounded-lg p-4 overflow-x-auto bg-gray-50"> <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> <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"> <div class="mb-3 text-sm">
@@ -486,7 +453,6 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mt-6"> <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> <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"> <div id="permanentBlockLog" class="overflow-x-auto border border-gray-200 rounded-md">
@@ -495,11 +461,9 @@
</div> </div>
</div> </div>
<!-- Alert Settings Group --> <!-- ========================= Alert Settings =========================== -->
<div class="bg-white rounded-lg shadow p-6"> <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> <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"> <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> <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"> <div class="space-y-2">
@@ -513,15 +477,12 @@
</label> </label>
</div> </div>
</div> </div>
<div class="mb-4" id="emailFieldsContainer"> <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> <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" <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" /> data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p> <p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</div> </div>
<!-- GeoIP Provider -->
<div class="mb-4"> <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> <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)"> <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> </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> <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> </div>
<!-- GeoIP Database Path (shown only for MaxMind) -->
<div id="geoipDatabasePathContainer" class="mb-4"> <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> <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"> <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> <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> </div>
<!-- Max Log Lines -->
<div class="mb-4"> <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> <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"> <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> <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>
<div class="mb-4"> <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> <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"> <p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">
@@ -751,7 +707,7 @@
</div> </div>
</div> </div>
<!-- SMTP Configuration Group --> <!-- ========================= SMTP Configuration ======================= -->
<div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer"> <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> <h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
<div class="mb-4"> <div class="mb-4">
@@ -805,12 +761,10 @@
</div> </div>
</div> </div>
<!-- Fail2Ban Configuration Group --> <!-- ========================= Fail2Ban Defaults ======================== -->
<div class="bg-white rounded-lg shadow p-6"> <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> <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> <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="mb-4">
<div class="flex items-center mb-2"> <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" /> <input type="checkbox" id="bantimeIncrement" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
@@ -818,8 +772,6 @@
</div> </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> <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> </div>
<!-- Default Enabled -->
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center mb-2"> <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" /> <input type="checkbox" id="defaultJailEnable" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out" />
@@ -827,8 +779,6 @@
</div> </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> <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> </div>
<!-- Bantime -->
<div class="mb-4"> <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> <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> <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" /> data-i18n-placeholder="settings.default_bantime_placeholder" placeholder="e.g., 48h" />
<p class="text-xs text-red-600 mt-1 hidden" id="banTimeError"></p> <p class="text-xs text-red-600 mt-1 hidden" id="banTimeError"></p>
</div> </div>
<!-- Bantime Rndtime (optional, for bantime increment formula) -->
<div class="mb-4"> <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> <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> <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" <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" /> data-i18n-placeholder="settings.bantime_rndtime_placeholder" placeholder="e.g., 2048" />
</div> </div>
<!-- Banaction -->
<div class="mb-4"> <div class="mb-4">
<label for="banaction" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.banaction">Banaction</label> <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> <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> <option value="apf">apf</option>
</select> </select>
</div> </div>
<!-- Banaction Allports -->
<div class="mb-4"> <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> <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> <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> <option value="apf">apf</option>
</select> </select>
</div> </div>
<!-- Default Chain -->
<div class="mb-4"> <div class="mb-4">
<div class="flex items-center gap-2 mb-2"> <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> <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> <option value="FORWARD" data-i18n="settings.chain_forward">FORWARD</option>
</select> </select>
</div> </div>
<!-- Findtime -->
<div class="mb-4"> <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> <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> <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" /> data-i18n-placeholder="settings.default_findtime_placeholder" placeholder="e.g., 30m" />
<p class="text-xs text-red-600 mt-1 hidden" id="findTimeError"></p> <p class="text-xs text-red-600 mt-1 hidden" id="findTimeError"></p>
</div> </div>
<!-- Max Retry -->
<div class="mb-4"> <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> <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> <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" /> 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> <p class="text-xs text-red-600 mt-1 hidden" id="maxRetryError"></p>
</div> </div>
<!-- Ignore IPs -->
<div class="mb-4"> <div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.ignore_ips">Ignore IPs</label> <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> <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 id="ignoreIPsError" class="hidden text-red-600 text-sm mt-1"></div>
</div> </div>
</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> <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> </form>
</div> </div>
<!-- *********************** Settings Page END ************************* --> <!-- *********************** Settings Page END ************************* -->
</main> </main>
<!-- Footer --> <!-- ******************************************************************* -->
<!-- Footer -->
<!-- ******************************************************************* -->
<footer id="footer" class="hidden bg-gray-100 py-4"> <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"> <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"> <p class="mb-0">
@@ -988,11 +924,12 @@
<!-- ******************************************************************* --> <!-- ******************************************************************* -->
<!-- Modal Templates START --> <!-- 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 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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8" style="max-width: 90vw; max-height: calc(100vh - 2rem); display: flex; flex-direction: column;"> <div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8" style="max-width: 90vw; max-height: calc(100vh - 2rem); display: flex; flex-direction: column;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" style="flex: 1; overflow-y: auto; min-height: 0;"> <div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" style="flex: 1; overflow-y: auto; min-height: 0;">
<div class="sm:flex sm:items-start"> <div class="sm:flex sm:items-start">
@@ -1008,7 +945,6 @@
</button> </button>
</div> </div>
<div class="mt-4 space-y-4"> <div class="mt-4 space-y-4">
<!-- Filter Configuration -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <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> <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> onfocus="preventExtensionInterference(this);"></textarea>
</div> </div>
</div> </div>
<!-- Divider -->
<div class="border-t border-gray-300"></div> <div class="border-t border-gray-300"></div>
<!-- Jail Configuration -->
<div> <div>
<div class="flex items-center justify-between mb-2"> <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> <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> onfocus="preventExtensionInterference(this);"></textarea>
</div> </div>
</div> </div>
<!-- Test Logpath Button (only shown if logpath is set) -->
<div id="testLogpathSection" class="hidden"> <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"> <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> <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>
</div> </div>
<!-- Manage Jails Modal --> <!-- ========================= Manage Jails Modal ======================== -->
<div id="manageJailsModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <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> <span data-i18n="modal.create_jail">Create New Jail</span>
</button> </button>
</div> </div>
<!-- Dynamically filled list of jails with toggle switches -->
<div id="jailsList" class="divide-y divide-gray-200"></div> <div id="jailsList" class="divide-y divide-gray-200"></div>
</div> </div>
</div> </div>
@@ -1135,7 +1064,7 @@
</div> </div>
</div> </div>
<!-- Create Jail Modal --> <!-- ========================= Create Jail Modal ========================= -->
<div id="createJailModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1182,7 +1111,7 @@
</div> </div>
</div> </div>
<!-- Create Filter Modal --> <!-- ========================= Create Filter Modal ======================= -->
<div id="createFilterModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1222,7 +1151,7 @@
</div> </div>
</div> </div>
<!-- Server Manager Modal --> <!-- ========================= Server Manager Modal ====================== -->
<div id="serverManagerModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1345,7 +1274,7 @@
</div> </div>
</div> </div>
<!-- Whois Modal --> <!-- ========================= Whois Modal =============================== -->
<div id="whoisModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1377,7 +1306,7 @@
</div> </div>
</div> </div>
<!-- Advanced Actions Test Modal --> <!-- ========================= Advanced Actions Test Modal =============== -->
<div id="advancedTestModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1411,7 +1340,7 @@
</div> </div>
</div> </div>
<!-- Logs Modal --> <!-- ========================= Logs Modal ================================ -->
<div id="logsModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1446,7 +1375,7 @@
</div> </div>
</div> </div>
<!-- Ban Insights Modal --> <!-- ========================= Ban Insights Modal ======================== -->
<div id="banInsightsModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1464,13 +1393,8 @@
</button> </button>
</div> </div>
<p class="text-sm text-gray-600 mb-4" data-i18n="logs.modal.insights_description">Country distribution and recurring offenders.</p> <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> <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"> <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="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
@@ -1481,8 +1405,6 @@
</div> </div>
<div id="countryStatsContainer" class="space-y-4 max-h-96 overflow-y-auto"></div> <div id="countryStatsContainer" class="space-y-4 max-h-96 overflow-y-auto"></div>
</div> </div>
<!-- Recurring IPs -->
<div class="border border-gray-200 rounded-lg p-4 bg-gray-50"> <div class="border border-gray-200 rounded-lg p-4 bg-gray-50">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
@@ -1504,7 +1426,7 @@
</div> </div>
</div> </div>
<!-- Chain Help Modal --> <!-- ========================= Chain Help Modal ========================== -->
<div id="chainHelpModal" class="hidden fixed inset-0 z-50 overflow-y-auto"> <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="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> <div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
@@ -1547,12 +1469,11 @@
<!-- ********************** Modal Templates END ************************ --> <!-- ********************** Modal Templates END ************************ -->
<!-- jQuery (used by Select2) --> <!-- ******************************************************************* -->
<!-- Script Includes -->
<!-- ******************************************************************* -->
<script src="/static/vendor/jquery/jquery-3.6.0.min.js?v={{.version}}"></script> <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> <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/globals.js?v={{.version}}"></script>
<script src="/static/js/core.js?v={{.version}}"></script> <script src="/static/js/core.js?v={{.version}}"></script>
<script src="/static/js/api.js?v={{.version}}"></script> <script src="/static/js/api.js?v={{.version}}"></script>