Add sections header to the validation.js and four more go-files

This commit is contained in:
2026-02-18 01:00:40 +01:00
parent d99fea38a5
commit 45f5907f7c
6 changed files with 268 additions and 259 deletions

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.
@@ -40,6 +40,7 @@ import (
func main() { func main() {
settings := config.GetSettings() settings := config.GetSettings()
// Initialize storage
if err := storage.Init(""); err != nil { if err := storage.Init(""); err != nil {
log.Fatalf("Failed to initialise storage: %v", err) log.Fatalf("Failed to initialise storage: %v", err)
} }
@@ -49,11 +50,12 @@ func main() {
} }
}() }()
// Initialize Fail2ban connectors
if err := fail2ban.GetManager().ReloadFromSettings(settings); err != nil { if err := fail2ban.GetManager().ReloadFromSettings(settings); err != nil {
log.Fatalf("failed to initialise fail2ban connectors: %v", err) log.Fatalf("failed to initialise fail2ban connectors: %v", err)
} }
// OIDC authentication (optional) // Initialize OIDC authentication
oidcConfig, err := config.GetOIDCConfigFromEnv() oidcConfig, err := config.GetOIDCConfigFromEnv()
if err != nil { if err != nil {
log.Fatalf("failed to load OIDC configuration: %v", err) log.Fatalf("failed to load OIDC configuration: %v", err)
@@ -68,18 +70,20 @@ func main() {
log.Println("OIDC authentication enabled") log.Println("OIDC authentication enabled")
} }
// Set Gin mode
if settings.Debug { if settings.Debug {
gin.SetMode(gin.DebugMode) gin.SetMode(gin.DebugMode)
} else { } else {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
} }
// Initialize router
router := gin.Default() router := gin.Default()
serverPort := strconv.Itoa(int(settings.Port)) serverPort := strconv.Itoa(int(settings.Port))
bindAddress, _ := config.GetBindAddressFromEnv() bindAddress, _ := config.GetBindAddressFromEnv()
serverAddr := net.JoinHostPort(bindAddress, serverPort) serverAddr := net.JoinHostPort(bindAddress, serverPort)
// Container vs local: different paths for templates and static assets // Load templates and static assets based on running in container or locally (compiled binary)
_, container := os.LookupEnv("CONTAINER") _, container := os.LookupEnv("CONTAINER")
if container { if container {
router.LoadHTMLGlob("/app/templates/*") router.LoadHTMLGlob("/app/templates/*")
@@ -91,7 +95,7 @@ func main() {
router.Static("/static", "./pkg/web/static") router.Static("/static", "./pkg/web/static")
} }
// WebSocket hub and console log capture // Initialize WebSocket hub and console log capture
wsHub := web.NewHub() wsHub := web.NewHub()
go wsHub.Run() go wsHub.Run()
web.SetupConsoleLogWriter(wsHub) web.SetupConsoleLogWriter(wsHub)
@@ -100,6 +104,7 @@ func main() {
web.SetConsoleLogEnabled(enabled) web.SetConsoleLogEnabled(enabled)
}) })
// Register routes
web.RegisterRoutes(router, wsHub) web.RegisterRoutes(router, wsHub)
isLOTRMode := isLOTRModeActive(settings.AlertCountries) isLOTRMode := isLOTRModeActive(settings.AlertCountries)
printWelcomeBanner(bindAddress, serverPort, isLOTRMode) printWelcomeBanner(bindAddress, serverPort, isLOTRMode)
@@ -127,7 +132,7 @@ func isLOTRModeActive(alertCountries []string) bool {
return false return false
} }
// printWelcomeBanner prints the Tux banner with startup info. // Print welcome banner.
func printWelcomeBanner(bindAddress, appPort string, isLOTRMode bool) { func printWelcomeBanner(bindAddress, appPort string, isLOTRMode bool) {
greeting := getGreeting() greeting := getGreeting()
@@ -173,7 +178,6 @@ Listening on: http://%s:%s
} }
} }
// getGreeting returns a friendly greeting based on the time of day.
func getGreeting() string { func getGreeting() string {
hour := time.Now().Hour() hour := time.Now().Hour()
switch { switch {

View File

@@ -14,6 +14,7 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
// LEGACY, WILL BE REMOVED IN FUTURE VERSIONS.
package fail2ban package fail2ban
import ( import (
@@ -24,13 +25,13 @@ import (
"time" "time"
) )
var ( // =========================================================================
// Typical fail2ban log line: // Types
// 2023-01-20 10:15:30,123 fail2ban.actions [1234]: NOTICE [sshd] Ban 192.168.0.101 // =========================================================================
logRegex = regexp.MustCompile(`^(\S+\s+\S+) fail2ban\.actions.*?\[\d+\]: NOTICE\s+\[(\S+)\]\s+Ban\s+(\S+)`)
)
// BanEvent holds details about a ban var logRegex = regexp.MustCompile(`^(\S+\s+\S+) fail2ban\.actions.*?\[\d+\]: NOTICE\s+\[(\S+)\]\s+Ban\s+(\S+)`)
// This is a single ban event from the fail2ban log. REMOVE THIS TYPE.
type BanEvent struct { type BanEvent struct {
Time time.Time Time time.Time
Jail string Jail string
@@ -38,7 +39,11 @@ type BanEvent struct {
LogLine string LogLine string
} }
// ParseBanLog returns a map[jailName]BanEvents and also the last 5 ban events overall. // =========================================================================
// Log Parsing
// =========================================================================
// ParseBanLog reads the fail2ban log and returns events grouped by jail.
func ParseBanLog(logPath string) (map[string][]BanEvent, error) { func ParseBanLog(logPath string) (map[string][]BanEvent, error) {
file, err := os.Open(logPath) file, err := os.Open(logPath)
if err != nil { if err != nil {
@@ -54,17 +59,12 @@ func ParseBanLog(logPath string) (map[string][]BanEvent, error) {
matches := logRegex.FindStringSubmatch(line) matches := logRegex.FindStringSubmatch(line)
if len(matches) == 4 { if len(matches) == 4 {
// matches[1] -> "2023-01-20 10:15:30,123"
// matches[2] -> jail name, e.g. "sshd"
// matches[3] -> IP, e.g. "192.168.0.101"
timestampStr := matches[1] timestampStr := matches[1]
jail := matches[2] jail := matches[2]
ip := matches[3] ip := matches[3]
// parse "2023-01-20 10:15:30,123" -> time.Time
parsedTime, err := time.Parse("2006-01-02 15:04:05,000", timestampStr) parsedTime, err := time.Parse("2006-01-02 15:04:05,000", timestampStr)
if err != nil { if err != nil {
// If parse fails, skip or set parsedTime=zero
continue continue
} }

View File

@@ -8,7 +8,11 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/config"
) )
// Connector describes a communication backend for a Fail2ban server. // =========================================================================
// Connector Interface
// =========================================================================
// Connector is the communication backend for a Fail2ban server.
type Connector interface { type Connector interface {
ID() string ID() string
Server() config.Fail2banServer Server() config.Fail2banServer
@@ -19,7 +23,7 @@ type Connector interface {
BanIP(ctx context.Context, jail, ip string) error BanIP(ctx context.Context, jail, ip string) error
Reload(ctx context.Context) error Reload(ctx context.Context) error
Restart(ctx context.Context) error Restart(ctx context.Context) error
GetFilterConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error) GetFilterConfig(ctx context.Context, jail string) (string, string, error)
SetFilterConfig(ctx context.Context, jail, content string) error SetFilterConfig(ctx context.Context, jail, content string) error
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
@@ -32,7 +36,7 @@ type Connector interface {
TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (output string, filterPath string, err error) TestFilter(ctx context.Context, filterName string, logLines []string, filterContent string) (output string, filterPath string, err error)
// Jail configuration operations // Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, string, error) // Returns (config, filePath, error) GetJailConfig(ctx context.Context, jail string) (string, string, error)
SetJailConfig(ctx context.Context, jail, content string) error SetJailConfig(ctx context.Context, jail, content string) error
TestLogpath(ctx context.Context, logpath string) ([]string, error) TestLogpath(ctx context.Context, logpath string) ([]string, error)
TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error)
@@ -54,7 +58,11 @@ type Connector interface {
DeleteFilter(ctx context.Context, filterName string) error DeleteFilter(ctx context.Context, filterName string) error
} }
// Manager orchestrates all connectors for configured Fail2ban servers. // =========================================================================
// Manager
// =========================================================================
// Manager holds connectors for all configured Fail2ban servers.
type Manager struct { type Manager struct {
mu sync.RWMutex mu sync.RWMutex
connectors map[string]Connector connectors map[string]Connector
@@ -65,7 +73,6 @@ var (
managerInst *Manager managerInst *Manager
) )
// GetManager returns the singleton connector manager.
func GetManager() *Manager { func GetManager() *Manager {
managerOnce.Do(func() { managerOnce.Do(func() {
managerInst = &Manager{ managerInst = &Manager{
@@ -75,7 +82,6 @@ func GetManager() *Manager {
return managerInst return managerInst
} }
// ReloadFromSettings rebuilds connectors using the provided settings.
func (m *Manager) ReloadFromSettings(settings config.AppSettings) error { func (m *Manager) ReloadFromSettings(settings config.AppSettings) error {
m.mu.Lock() m.mu.Lock()
defer m.mu.Unlock() defer m.mu.Unlock()
@@ -96,7 +102,7 @@ func (m *Manager) ReloadFromSettings(settings config.AppSettings) error {
return nil return nil
} }
// Connector returns the connector for the specified server ID. // Returns the connector for the specified server ID.
func (m *Manager) Connector(serverID string) (Connector, error) { func (m *Manager) Connector(serverID string) (Connector, error) {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -111,7 +117,7 @@ func (m *Manager) Connector(serverID string) (Connector, error) {
return conn, nil return conn, nil
} }
// DefaultConnector returns the default connector as defined in settings. // Returns the default connector as defined in settings.
func (m *Manager) DefaultConnector() (Connector, error) { func (m *Manager) DefaultConnector() (Connector, error) {
server := config.GetDefaultServer() server := config.GetDefaultServer()
if server.ID == "" { if server.ID == "" {
@@ -120,7 +126,7 @@ func (m *Manager) DefaultConnector() (Connector, error) {
return m.Connector(server.ID) return m.Connector(server.ID)
} }
// Connectors returns all connectors. // Returns all connectors.
func (m *Manager) Connectors() []Connector { func (m *Manager) Connectors() []Connector {
m.mu.RLock() m.mu.RLock()
defer m.mu.RUnlock() defer m.mu.RUnlock()
@@ -131,7 +137,11 @@ func (m *Manager) Connectors() []Connector {
return result return result
} }
// UpdateActionFiles updates action files for all active remote connectors (SSH and Agent). // =========================================================================
// Action File Management
// =========================================================================
// Updates action files for all active remote connectors (SSH and Agent).
func (m *Manager) UpdateActionFiles(ctx context.Context) error { func (m *Manager) UpdateActionFiles(ctx context.Context) error {
m.mu.RLock() m.mu.RLock()
connectors := make([]Connector, 0, len(m.connectors)) connectors := make([]Connector, 0, len(m.connectors))
@@ -154,7 +164,7 @@ func (m *Manager) UpdateActionFiles(ctx context.Context) error {
return lastErr return lastErr
} }
// UpdateActionFileForServer updates the action file for a specific server by ID. // Updates the action file for a single server.
func (m *Manager) UpdateActionFileForServer(ctx context.Context, serverID string) error { func (m *Manager) UpdateActionFileForServer(ctx context.Context, serverID string) error {
m.mu.RLock() m.mu.RLock()
conn, ok := m.connectors[serverID] conn, ok := m.connectors[serverID]
@@ -165,7 +175,6 @@ func (m *Manager) UpdateActionFileForServer(ctx context.Context, serverID string
return updateConnectorAction(ctx, conn) return updateConnectorAction(ctx, conn)
} }
// updateConnectorAction updates the action file for a specific connector.
func updateConnectorAction(ctx context.Context, conn Connector) error { func updateConnectorAction(ctx context.Context, conn Connector) error {
switch c := conn.(type) { switch c := conn.(type) {
case *SSHConnector: case *SSHConnector:
@@ -173,15 +182,18 @@ func updateConnectorAction(ctx context.Context, conn Connector) error {
case *AgentConnector: case *AgentConnector:
return c.ensureAction(ctx) return c.ensureAction(ctx)
default: default:
return nil // Local connectors are handled separately return nil
} }
} }
// =========================================================================
// Connector Factory
// =========================================================================
func newConnectorForServer(server config.Fail2banServer) (Connector, error) { func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
switch server.Type { switch server.Type {
case "local": case "local":
// Run migration FIRST before ensuring structure but only when // Run migration before ensuring structure, but only when the experimental JAIL_AUTOMIGRATION=true env var is set.
// the experimental JAIL_AUTOMIGRATION=true env var is set.
if isJailAutoMigrationEnabled() { if isJailAutoMigrationEnabled() {
config.DebugLog("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for local server %s", server.Name) config.DebugLog("JAIL_AUTOMIGRATION=true: running experimental jail.local → jail.d/ migration for local server %s", server.Name)
if err := MigrateJailsFromJailLocal(); err != nil { if err := MigrateJailsFromJailLocal(); err != nil {

View File

@@ -27,13 +27,12 @@ import (
"github.com/swissmakers/fail2ban-ui/internal/config" "github.com/swissmakers/fail2ban-ui/internal/config"
) )
var ( // =========================================================================
// Variable pattern: %(variable_name)s // Pattern Matching
variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`) // =========================================================================
)
var variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`)
// extractVariablesFromString extracts all variable names from a string.
// Returns a list of variable names found in the pattern %(name)s.
func extractVariablesFromString(s string) []string { func extractVariablesFromString(s string) []string {
matches := variablePattern.FindAllStringSubmatch(s, -1) matches := variablePattern.FindAllStringSubmatch(s, -1)
if len(matches) == 0 { if len(matches) == 0 {
@@ -49,8 +48,10 @@ func extractVariablesFromString(s string) []string {
return variables return variables
} }
// searchVariableInFile searches for a variable definition in a single file. // =========================================================================
// Returns the value if found, empty string if not found, and error on file read error. // Variable Lookup
// =========================================================================
func searchVariableInFile(filePath, varName string) (string, error) { func searchVariableInFile(filePath, varName string) (string, error) {
file, err := os.Open(filePath) file, err := os.Open(filePath)
if err != nil { if err != nil {
@@ -162,19 +163,16 @@ func searchVariableInFile(filePath, varName string) (string, error) {
return "", nil return "", nil
} }
// findVariableDefinition searches for a variable definition in all .local files first, // Searches for a variable definition in all .local files first, then .conf files under /etc/fail2ban/ and subdirectories.
// then .conf files under /etc/fail2ban/ and subdirectories.
// Returns the FIRST value found (prioritizing .local over .conf). // Returns the FIRST value found (prioritizing .local over .conf).
func findVariableDefinition(varName string) (string, error) { func findVariableDefinition(varName string) (string, error) {
fail2banPath := "/etc/fail2ban" fail2banPath := "/etc/fail2ban"
config.DebugLog("findVariableDefinition: searching for variable '%s'", varName) config.DebugLog("findVariableDefinition: searching for variable '%s'", varName)
if _, err := os.Stat(fail2banPath); os.IsNotExist(err) { if _, err := os.Stat(fail2banPath); os.IsNotExist(err) {
return "", fmt.Errorf("variable '%s' not found: /etc/fail2ban directory does not exist", varName) return "", fmt.Errorf("variable '%s' not found: /etc/fail2ban directory does not exist", varName)
} }
// First pass: search .local files (higher priority)
var foundValue string var foundValue string
err := filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { err := filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
@@ -187,14 +185,13 @@ func findVariableDefinition(varName string) (string, error) {
value, err := searchVariableInFile(path, varName) value, err := searchVariableInFile(path, varName)
if err != nil { if err != nil {
return nil // Skip files we can't read return nil
} }
if value != "" { if value != "" {
foundValue = value foundValue = value
return filepath.SkipAll // Stop walking when found return filepath.SkipAll
} }
return nil return nil
}) })
@@ -207,7 +204,6 @@ func findVariableDefinition(varName string) (string, error) {
return "", err return "", err
} }
// Second pass: search .conf files (only if not found in .local)
config.DebugLog("findVariableDefinition: variable '%s' not found in .local files, searching .conf files", varName) config.DebugLog("findVariableDefinition: variable '%s' not found in .local files, searching .conf files", varName)
err = filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error { err = filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error {
if err != nil { if err != nil {
@@ -225,9 +221,8 @@ func findVariableDefinition(varName string) (string, error) {
if value != "" { if value != "" {
foundValue = value foundValue = value
return filepath.SkipAll // Stop walking when found return filepath.SkipAll
} }
return nil return nil
}) })
@@ -244,9 +239,10 @@ func findVariableDefinition(varName string) (string, error) {
return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName) return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName)
} }
// resolveVariableRecursive resolves a variable recursively, handling nested variables. // =========================================================================
// visited map tracks visited variables to detect circular references. // Resolution
// This function fully resolves all nested variables until no variables remain. // =========================================================================
func resolveVariableRecursive(varName string, visited map[string]bool) (string, error) { func resolveVariableRecursive(varName string, visited map[string]bool) (string, error) {
if visited[varName] { if visited[varName] {
return "", fmt.Errorf("circular reference detected for variable '%s'", varName) return "", fmt.Errorf("circular reference detected for variable '%s'", varName)
@@ -260,7 +256,6 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string,
return "", err return "", err
} }
// Keep resolving until no more variables are found
resolved := value resolved := value
maxIterations := 10 maxIterations := 10
iteration := 0 iteration := 0
@@ -268,16 +263,12 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string,
for iteration < maxIterations { for iteration < maxIterations {
variables := extractVariablesFromString(resolved) variables := extractVariablesFromString(resolved)
if len(variables) == 0 { if len(variables) == 0 {
// No more variables, fully resolved
config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s'", varName, resolved) config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s'", varName, resolved)
break break
} }
config.DebugLog("resolveVariableRecursive: iteration %d for '%s', found %d variables in '%s': %v", iteration+1, varName, len(variables), resolved, variables) config.DebugLog("resolveVariableRecursive: iteration %d for '%s', found %d variables in '%s': %v", iteration+1, varName, len(variables), resolved, variables)
// Resolve all nested variables
for _, nestedVar := range variables { for _, nestedVar := range variables {
// Check for circular reference
if visited[nestedVar] { if visited[nestedVar] {
return "", fmt.Errorf("circular reference detected: '%s' -> '%s'", varName, nestedVar) return "", fmt.Errorf("circular reference detected: '%s' -> '%s'", varName, nestedVar)
} }
@@ -289,70 +280,50 @@ func resolveVariableRecursive(varName string, visited map[string]bool) (string,
} }
config.DebugLog("resolveVariableRecursive: resolved '%s' to '%s' for '%s'", nestedVar, nestedValue, varName) config.DebugLog("resolveVariableRecursive: resolved '%s' to '%s' for '%s'", nestedVar, nestedValue, varName)
// Replace ALL occurrences of the nested variable
// Pattern: %(varName)s - need to escape parentheses for regex
// The pattern %(varName)s needs to be escaped as %\(varName\)s in regex
pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(nestedVar)) pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(nestedVar))
re := regexp.MustCompile(pattern) re := regexp.MustCompile(pattern)
beforeReplace := resolved beforeReplace := resolved
resolved = re.ReplaceAllString(resolved, nestedValue) resolved = re.ReplaceAllString(resolved, nestedValue)
config.DebugLog("resolveVariableRecursive: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, nestedValue, resolved) config.DebugLog("resolveVariableRecursive: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, nestedValue, resolved)
// Verify the replacement actually happened
if beforeReplace == resolved { if beforeReplace == resolved {
config.DebugLog("resolveVariableRecursive: WARNING - replacement did not change string! Pattern: '%s', Before: '%s', After: '%s'", pattern, beforeReplace, resolved) config.DebugLog("resolveVariableRecursive: WARNING - replacement did not change string! Pattern: '%s', Before: '%s', After: '%s'", pattern, beforeReplace, resolved)
// If replacement didn't work, this is a critical error
return "", fmt.Errorf("failed to replace variable '%s' in '%s': pattern '%s' did not match", nestedVar, beforeReplace, pattern) return "", fmt.Errorf("failed to replace variable '%s' in '%s': pattern '%s' did not match", nestedVar, beforeReplace, pattern)
} }
} }
// After replacing all variables in this iteration, check if we're done
// Verify no variables remain before continuing
remainingVars := extractVariablesFromString(resolved) remainingVars := extractVariablesFromString(resolved)
if len(remainingVars) == 0 { if len(remainingVars) == 0 {
// No more variables, fully resolved
config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s' after replacements", varName, resolved) config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s' after replacements", varName, resolved)
break break
} }
// If we still have variables after replacement, continue to next iteration
// But check if we made progress (resolved should be different from before)
iteration++ iteration++
} }
if iteration >= maxIterations { if iteration >= maxIterations {
return "", fmt.Errorf("maximum resolution iterations reached for variable '%s', possible circular reference. Last resolved value: '%s'", varName, resolved) return "", fmt.Errorf("maximum resolution iterations reached for variable '%s', possible circular reference. Last resolved value: '%s'", varName, resolved)
} }
return resolved, nil return resolved, nil
} }
// ResolveLogpathVariables resolves all variables in a logpath string. // Expands %(var)s patterns in logpath using fail2ban config.
// Returns the fully resolved path. If no variables are present, returns the original path.
// Keeps resolving until no more variables are found (handles nested variables).
func ResolveLogpathVariables(logpath string) (string, error) { func ResolveLogpathVariables(logpath string) (string, error) {
if logpath == "" { if logpath == "" {
return "", nil return "", nil
} }
logpath = strings.TrimSpace(logpath) logpath = strings.TrimSpace(logpath)
// Keep resolving until no more variables are found
resolved := logpath resolved := logpath
maxIterations := 10 // Prevent infinite loops maxIterations := 10
iteration := 0 iteration := 0
for iteration < maxIterations { for iteration < maxIterations {
variables := extractVariablesFromString(resolved) variables := extractVariablesFromString(resolved)
if len(variables) == 0 { if len(variables) == 0 {
// No more variables, we're done
break break
} }
config.DebugLog("ResolveLogpathVariables: iteration %d, found %d variables in '%s'", iteration+1, len(variables), resolved) config.DebugLog("ResolveLogpathVariables: iteration %d, found %d variables in '%s'", iteration+1, len(variables), resolved)
// Resolve all variables found in the current string
visited := make(map[string]bool) visited := make(map[string]bool)
for _, varName := range variables { for _, varName := range variables {
config.DebugLog("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved) config.DebugLog("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved)
@@ -363,9 +334,6 @@ func ResolveLogpathVariables(logpath string) (string, error) {
config.DebugLog("ResolveLogpathVariables: resolved variable '%s' to '%s'", varName, varValue) config.DebugLog("ResolveLogpathVariables: resolved variable '%s' to '%s'", varName, varValue)
// Replace ALL occurrences of the variable in the resolved string
// Pattern: %(varName)s - need to escape parentheses for regex
// The pattern %(varName)s needs to be escaped as %\(varName\)s in regex
pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(varName)) pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(varName))
re := regexp.MustCompile(pattern) re := regexp.MustCompile(pattern)
beforeReplace := resolved beforeReplace := resolved

View File

@@ -1,8 +1,11 @@
// Validation functions for Fail2ban UI // Validation for Fail2ban UI settings and forms.
// =========================================================================
// Field Validators
// =========================================================================
function validateTimeFormat(value, fieldName) { function validateTimeFormat(value, fieldName) {
if (!value || !value.trim()) return { valid: true }; // Empty is OK if (!value || !value.trim()) return { valid: true };
// Support: s (seconds), m (minutes), h (hours), d (days), w (weeks), mo (months), y (years)
const timePattern = /^\d+([smhdwy]|mo)$/i; const timePattern = /^\d+([smhdwy]|mo)$/i;
if (!timePattern.test(value.trim())) { if (!timePattern.test(value.trim())) {
return { return {
@@ -14,7 +17,7 @@ function validateTimeFormat(value, fieldName) {
} }
function validateMaxRetry(value) { function validateMaxRetry(value) {
if (!value || value.trim() === '') return { valid: true }; // Empty is OK if (!value || value.trim() === '') return { valid: true };
const num = parseInt(value, 10); const num = parseInt(value, 10);
if (isNaN(num) || num < 1) { if (isNaN(num) || num < 1) {
return { return {
@@ -26,7 +29,7 @@ function validateMaxRetry(value) {
} }
function validateEmail(value) { function validateEmail(value) {
if (!value || !value.trim()) return { valid: true }; // Empty is OK if (!value || !value.trim()) return { valid: true };
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(value.trim())) { if (!emailPattern.test(value.trim())) {
return { return {
@@ -37,24 +40,18 @@ function validateEmail(value) {
return { valid: true }; return { valid: true };
} }
// Validate IP address (IPv4, IPv6, CIDR, or hostname)
function isValidIP(ip) { function isValidIP(ip) {
if (!ip || !ip.trim()) return false; if (!ip || !ip.trim()) return false;
ip = ip.trim(); ip = ip.trim();
// fail2ban accepts hostnames in addition to IPs
// Allow hostnames (fail2ban supports DNS hostnames)
// Basic hostname validation: alphanumeric, dots, hyphens
const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)*$/; const hostnamePattern = /^[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-\.]*[a-zA-Z0-9])?)*$/;
// IPv4 with optional CIDR // IPv4 with optional CIDR
const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/; const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}(\/\d{1,2})?$/;
// IPv6 with optional CIDR
// IPv6 with optional CIDR (simplified - allows various IPv6 formats)
const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; const ipv6Pattern = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
const ipv6CompressedPattern = /^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/; const ipv6CompressedPattern = /^::([0-9a-fA-F]{0,4}:){0,6}[0-9a-fA-F]{0,4}(\/\d{1,3})?$/;
const ipv6FullPattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/; const ipv6FullPattern = /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}(\/\d{1,3})?$/;
// Check IPv4
if (ipv4Pattern.test(ip)) { if (ipv4Pattern.test(ip)) {
const parts = ip.split('/'); const parts = ip.split('/');
const octets = parts[0].split('.'); const octets = parts[0].split('.');
@@ -68,8 +65,6 @@ function isValidIP(ip) {
} }
return true; return true;
} }
// Check IPv6
if (ipv6Pattern.test(ip) || ipv6CompressedPattern.test(ip) || ipv6FullPattern.test(ip)) { if (ipv6Pattern.test(ip) || ipv6CompressedPattern.test(ip) || ipv6FullPattern.test(ip)) {
if (ip.includes('/')) { if (ip.includes('/')) {
const parts = ip.split('/'); const parts = ip.split('/');
@@ -78,41 +73,40 @@ function isValidIP(ip) {
} }
return true; return true;
} }
// Check hostname
if (hostnamePattern.test(ip)) { if (hostnamePattern.test(ip)) {
return true; return true;
} }
return false; return false;
} }
function validateIgnoreIPs() { function validateIgnoreIPs() {
if (typeof getIgnoreIPsArray !== 'function') { if (typeof getIgnoreIPsArray !== 'function') {
console.error('getIgnoreIPsArray function not found'); console.error('getIgnoreIPsArray function not found');
return { valid: true }; // Skip validation if function not available return { valid: true };
} }
const ignoreIPs = getIgnoreIPsArray(); const ignoreIPs = getIgnoreIPsArray();
const invalidIPs = []; const invalidIPs = [];
for (let i = 0; i < ignoreIPs.length; i++) { for (let i = 0; i < ignoreIPs.length; i++) {
const ip = ignoreIPs[i]; const ip = ignoreIPs[i];
if (!isValidIP(ip)) { if (!isValidIP(ip)) {
invalidIPs.push(ip); invalidIPs.push(ip);
} }
} }
if (invalidIPs.length > 0) { if (invalidIPs.length > 0) {
return { return {
valid: false, valid: false,
message: 'Invalid IP addresses, CIDR notation, or hostnames: ' + invalidIPs.join(', ') message: 'Invalid IP addresses, CIDR notation, or hostnames: ' + invalidIPs.join(', ')
}; };
} }
return { valid: true }; return { valid: true };
} }
// =========================================================================
// Error Display
// =========================================================================
function showFieldError(fieldId, message) { function showFieldError(fieldId, message) {
const errorElement = document.getElementById(fieldId + 'Error'); const errorElement = document.getElementById(fieldId + 'Error');
const inputElement = document.getElementById(fieldId); const inputElement = document.getElementById(fieldId);
@@ -139,10 +133,12 @@ function clearFieldError(fieldId) {
} }
} }
// =========================================================================
// Form Validation
// =========================================================================
function validateAllSettings() { function validateAllSettings() {
let isValid = true; let isValid = true;
// Validate bantime
const banTime = document.getElementById('banTime'); const banTime = document.getElementById('banTime');
if (banTime) { if (banTime) {
const banTimeValidation = validateTimeFormat(banTime.value, 'bantime'); const banTimeValidation = validateTimeFormat(banTime.value, 'bantime');
@@ -153,8 +149,7 @@ function validateAllSettings() {
clearFieldError('banTime'); clearFieldError('banTime');
} }
} }
// Validate findtime
const findTime = document.getElementById('findTime'); const findTime = document.getElementById('findTime');
if (findTime) { if (findTime) {
const findTimeValidation = validateTimeFormat(findTime.value, 'findtime'); const findTimeValidation = validateTimeFormat(findTime.value, 'findtime');
@@ -165,8 +160,7 @@ function validateAllSettings() {
clearFieldError('findTime'); clearFieldError('findTime');
} }
} }
// Validate max retry
const maxRetry = document.getElementById('maxRetry'); const maxRetry = document.getElementById('maxRetry');
if (maxRetry) { if (maxRetry) {
const maxRetryValidation = validateMaxRetry(maxRetry.value); const maxRetryValidation = validateMaxRetry(maxRetry.value);
@@ -177,8 +171,7 @@ function validateAllSettings() {
clearFieldError('maxRetry'); clearFieldError('maxRetry');
} }
} }
// Validate email
const destEmail = document.getElementById('destEmail'); const destEmail = document.getElementById('destEmail');
if (destEmail) { if (destEmail) {
const emailValidation = validateEmail(destEmail.value); const emailValidation = validateEmail(destEmail.value);
@@ -189,11 +182,9 @@ function validateAllSettings() {
clearFieldError('destEmail'); clearFieldError('destEmail');
} }
} }
// Validate IgnoreIPs
const ignoreIPsValidation = validateIgnoreIPs(); const ignoreIPsValidation = validateIgnoreIPs();
if (!ignoreIPsValidation.valid) { if (!ignoreIPsValidation.valid) {
// Show error for ignoreIPs field
const errorContainer = document.getElementById('ignoreIPsError'); const errorContainer = document.getElementById('ignoreIPsError');
if (errorContainer) { if (errorContainer) {
errorContainer.textContent = ignoreIPsValidation.message; errorContainer.textContent = ignoreIPsValidation.message;
@@ -210,11 +201,9 @@ function validateAllSettings() {
errorContainer.textContent = ''; errorContainer.textContent = '';
} }
} }
return isValid; return isValid;
} }
// Setup validation on blur for all fields
function setupFormValidation() { function setupFormValidation() {
const banTimeInput = document.getElementById('banTime'); const banTimeInput = document.getElementById('banTime');
const findTimeInput = document.getElementById('findTime'); const findTimeInput = document.getElementById('findTime');
@@ -231,7 +220,7 @@ function setupFormValidation() {
} }
}); });
} }
if (findTimeInput) { if (findTimeInput) {
findTimeInput.addEventListener('blur', function() { findTimeInput.addEventListener('blur', function() {
const validation = validateTimeFormat(this.value, 'findtime'); const validation = validateTimeFormat(this.value, 'findtime');
@@ -242,7 +231,7 @@ function setupFormValidation() {
} }
}); });
} }
if (maxRetryInput) { if (maxRetryInput) {
maxRetryInput.addEventListener('blur', function() { maxRetryInput.addEventListener('blur', function() {
const validation = validateMaxRetry(this.value); const validation = validateMaxRetry(this.value);
@@ -253,7 +242,7 @@ function setupFormValidation() {
} }
}); });
} }
if (destEmailInput) { if (destEmailInput) {
destEmailInput.addEventListener('blur', function() { destEmailInput.addEventListener('blur', function() {
const validation = validateEmail(this.value); const validation = validateEmail(this.value);
@@ -265,4 +254,3 @@ function setupFormValidation() {
}); });
} }
} }

View File

@@ -1,10 +1,10 @@
/* ============================================ /* LOTR Easter Egg Theme */
LOTR Easter Egg Theme - Middle-earth Styling
============================================ */ /* =========================================================================
Color Variables
========================================================================= */
/* Only apply when body has lotr-mode class */
body.lotr-mode { body.lotr-mode {
/* Enhanced Color Variables - Better Contrast */
--lotr-forest-green: #1a4d2e; --lotr-forest-green: #1a4d2e;
--lotr-dark-green: #0d2818; --lotr-dark-green: #0d2818;
--lotr-deep-green: #051a0f; --lotr-deep-green: #051a0f;
@@ -26,25 +26,25 @@ body.lotr-mode {
--lotr-text-light: #faf8f3; --lotr-text-light: #faf8f3;
} }
/* Base Theme Overrides */ /* =========================================================================
Base Theme
========================================================================= */
body.lotr-mode { body.lotr-mode {
background: url(/static/images/bg-overlay); background: url(/static/images/bg-overlay);
background-size: cover; background-size: cover;
background-position: center; background-position: center;
background-repeat: no-repeat; background-repeat: no-repeat;
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
/*background:
radial-gradient(ellipse at top, rgba(26, 77, 46, 0.3) 0%, transparent 50%),
radial-gradient(ellipse at bottom, rgba(45, 10, 79, 0.2) 0%, transparent 50%),
linear-gradient(135deg, #001f0d 0%, #1f2622 30%, #000000 70%, #36370d 100%);
background-attachment: fixed;
background-size: 100% 100%;*/
color: var(--lotr-text-light); color: var(--lotr-text-light);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
min-height: 100vh; min-height: 100vh;
} }
/* Typography */ /* =========================================================================
Typography
========================================================================= */
body.lotr-mode h1, body.lotr-mode h1,
body.lotr-mode h2, body.lotr-mode h2,
body.lotr-mode h3, body.lotr-mode h3,
@@ -52,7 +52,7 @@ body.lotr-mode .text-2xl,
body.lotr-mode .text-xl { body.lotr-mode .text-xl {
font-family: 'Cinzel', serif; font-family: 'Cinzel', serif;
color: var(--lotr-bright-gold); color: var(--lotr-bright-gold);
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.8), 2px 2px 4px rgba(0, 0, 0, 0.8),
0 0 10px rgba(212, 175, 55, 0.3); 0 0 10px rgba(212, 175, 55, 0.3);
letter-spacing: 0.05em; letter-spacing: 0.05em;
@@ -61,7 +61,7 @@ body.lotr-mode .text-xl {
body.lotr-mode h1 { body.lotr-mode h1 {
font-size: 2.5rem; font-size: 2.5rem;
text-shadow: text-shadow:
3px 3px 6px rgba(0, 0, 0, 0.9), 3px 3px 6px rgba(0, 0, 0, 0.9),
0 0 15px rgba(212, 175, 55, 0.4); 0 0 15px rgba(212, 175, 55, 0.4);
} }
@@ -71,12 +71,15 @@ body.lotr-mode h3 {
font-weight: 600; font-weight: 600;
} }
/* Cards */ /* =========================================================================
Cards and Panels
========================================================================= */
body.lotr-mode .bg-white { body.lotr-mode .bg-white {
background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important;
border: 4px solid var(--lotr-gold); border: 4px solid var(--lotr-gold);
border-radius: 12px; border-radius: 12px;
box-shadow: box-shadow:
0 8px 16px rgba(0, 0, 0, 0.4), 0 8px 16px rgba(0, 0, 0, 0.4),
0 2px 4px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3),
inset 0 1px 0 rgba(255, 255, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.3),
@@ -92,7 +95,7 @@ body.lotr-mode .bg-white::before {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: background:
repeating-linear-gradient( repeating-linear-gradient(
0deg, 0deg,
transparent, transparent,
@@ -113,10 +116,10 @@ body.lotr-mode .bg-white::after {
left: -2px; left: -2px;
right: -2px; right: -2px;
bottom: -2px; bottom: -2px;
background: linear-gradient(45deg, background: linear-gradient(45deg,
var(--lotr-gold) 0%, var(--lotr-gold) 0%,
transparent 25%, transparent 25%,
transparent 75%, transparent 75%,
var(--lotr-gold) 100%); var(--lotr-gold) 100%);
border-radius: 12px; border-radius: 12px;
z-index: -1; z-index: -1;
@@ -127,7 +130,6 @@ body.lotr-mode footer.bg-gray-100 {
background-color: var(--lotr-dark-brown) !important; background-color: var(--lotr-dark-brown) !important;
} }
/* Text Colors with High Contrast */
body.lotr-mode .bg-white .text-gray-800, body.lotr-mode .bg-white .text-gray-800,
body.lotr-mode .bg-white .text-gray-900 { body.lotr-mode .bg-white .text-gray-900 {
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
@@ -143,7 +145,10 @@ body.lotr-mode .bg-white .text-gray-600 {
color: var(--lotr-brown); color: var(--lotr-brown);
} }
/* Buttons - Medieval Shield Style */ /* =========================================================================
Buttons
========================================================================= */
body.lotr-mode button:not(.toast), body.lotr-mode button:not(.toast),
body.lotr-mode .bg-blue-500:not(.toast), body.lotr-mode .bg-blue-500:not(.toast),
body.lotr-mode .bg-blue-600:not(.toast) { body.lotr-mode .bg-blue-600:not(.toast) {
@@ -153,7 +158,7 @@ body.lotr-mode .bg-blue-600:not(.toast) {
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
font-weight: 700; font-weight: 700;
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5); text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
box-shadow: box-shadow:
0 4px 8px rgba(0, 0, 0, 0.4), 0 4px 8px rgba(0, 0, 0, 0.4),
inset 0 2px 4px rgba(255, 255, 255, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.2); inset 0 -2px 4px rgba(0, 0, 0, 0.2);
@@ -171,9 +176,9 @@ body.lotr-mode .bg-blue-600::before {
left: -100%; left: -100%;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(90deg, background: linear-gradient(90deg,
transparent, transparent,
rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.3),
transparent); transparent);
transition: left 0.5s ease; transition: left 0.5s ease;
} }
@@ -188,7 +193,7 @@ body.lotr-mode button:hover,
body.lotr-mode .bg-blue-500:hover, body.lotr-mode .bg-blue-500:hover,
body.lotr-mode .bg-blue-600:hover { body.lotr-mode .bg-blue-600:hover {
background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%) !important; background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%) !important;
box-shadow: box-shadow:
0 6px 12px rgba(0, 0, 0, 0.5), 0 6px 12px rgba(0, 0, 0, 0.5),
inset 0 2px 6px rgba(255, 255, 255, 0.4), inset 0 2px 6px rgba(255, 255, 255, 0.4),
inset 0 -2px 6px rgba(0, 0, 0, 0.3); inset 0 -2px 6px rgba(0, 0, 0, 0.3);
@@ -198,7 +203,7 @@ body.lotr-mode .bg-blue-600:hover {
body.lotr-mode button:active { body.lotr-mode button:active {
transform: translateY(0); transform: translateY(0);
box-shadow: box-shadow:
0 2px 4px rgba(0, 0, 0, 0.4), 0 2px 4px rgba(0, 0, 0, 0.4),
inset 0 2px 4px rgba(0, 0, 0, 0.2); inset 0 2px 4px rgba(0, 0, 0, 0.2);
} }
@@ -206,7 +211,62 @@ body.lotr-mode button:active {
body.lotr-mode nav.bg-blue-600:not(.toast), body.lotr-mode nav.bg-blue-600:hover { body.lotr-mode nav.bg-blue-600:not(.toast), body.lotr-mode nav.bg-blue-600:hover {
background: linear-gradient(135deg, #342e14 0%, #000000 50%, #916f00 100%) !important; background: linear-gradient(135deg, #342e14 0%, #000000 50%, #916f00 100%) !important;
} }
/* Input Fields - Scroll Style */
/* =========================================================================
Navigation
========================================================================= */
body.lotr-mode nav {
background: linear-gradient(135deg,
var(--lotr-dark-brown) 0%,
var(--lotr-brown) 30%,
var(--lotr-dark-green) 70%,
var(--lotr-deep-green) 100%) !important;
border-bottom: 4px solid var(--lotr-gold);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(212, 175, 55, 0.2);
position: relative;
}
body.lotr-mode nav::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent,
var(--lotr-bright-gold) 20%,
var(--lotr-bright-gold) 80%,
transparent);
}
body.lotr-mode nav .text-gray-700,
body.lotr-mode nav .text-gray-800,
body.lotr-mode nav .text-white {
color: var(--lotr-bright-gold) !important;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
}
body.lotr-mode nav a {
color: var(--lotr-bright-gold) !important;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
transition: all 0.3s ease;
}
body.lotr-mode nav a:hover {
background: rgba(212, 175, 55, 0.2) !important;
text-shadow:
1px 1px 2px rgba(0, 0, 0, 0.7),
0 0 10px rgba(212, 175, 55, 0.5);
}
/* =========================================================================
Form Elements
========================================================================= */
body.lotr-mode input:not([type="checkbox"]):not([type="radio"]), body.lotr-mode input:not([type="checkbox"]):not([type="radio"]),
body.lotr-mode select, body.lotr-mode select,
body.lotr-mode textarea { body.lotr-mode textarea {
@@ -229,61 +289,16 @@ body.lotr-mode select:focus,
body.lotr-mode textarea:focus { body.lotr-mode textarea:focus {
background: var(--lotr-parchment) !important; background: var(--lotr-parchment) !important;
border-color: var(--lotr-bright-gold) !important; border-color: var(--lotr-bright-gold) !important;
box-shadow: box-shadow:
0 0 0 4px rgba(212, 175, 55, 0.3), 0 0 0 4px rgba(212, 175, 55, 0.3),
inset 0 2px 4px rgba(0, 0, 0, 0.1) !important; inset 0 2px 4px rgba(0, 0, 0, 0.1) !important;
outline: none; outline: none;
} }
/* Navigation */ /* =========================================================================
body.lotr-mode nav { Loading Overlay
background: linear-gradient(135deg, ========================================================================= */
var(--lotr-dark-brown) 0%,
var(--lotr-brown) 30%,
var(--lotr-dark-green) 70%,
var(--lotr-deep-green) 100%) !important;
border-bottom: 4px solid var(--lotr-gold);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(212, 175, 55, 0.2);
position: relative;
}
body.lotr-mode nav::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg,
transparent,
var(--lotr-bright-gold) 20%,
var(--lotr-bright-gold) 80%,
transparent);
}
body.lotr-mode nav .text-gray-700,
body.lotr-mode nav .text-gray-800,
body.lotr-mode nav .text-white {
color: var(--lotr-bright-gold) !important;
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.7);
}
body.lotr-mode nav a {
color: var(--lotr-bright-gold) !important;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
transition: all 0.3s ease;
}
body.lotr-mode nav a:hover {
background: rgba(212, 175, 55, 0.2) !important;
text-shadow:
1px 1px 2px rgba(0, 0, 0, 0.7),
0 0 10px rgba(212, 175, 55, 0.5);
}
/* Loading Spinner - One Ring Animation */
body.lotr-mode #loading-overlay { body.lotr-mode #loading-overlay {
background: url("/static/images/loading-overlay"); background: url("/static/images/loading-overlay");
background-size: cover; background-size: cover;
@@ -311,7 +326,7 @@ body.lotr-mode #loading-overlay .animate-spin::before {
height: 50px; height: 50px;
border: 4px solid var(--lotr-bright-gold); border: 4px solid var(--lotr-bright-gold);
border-radius: 50%; border-radius: 50%;
box-shadow: box-shadow:
0 0 30px var(--lotr-gold), 0 0 30px var(--lotr-gold),
0 0 60px var(--lotr-gold), 0 0 60px var(--lotr-gold),
inset 0 0 30px var(--lotr-gold); inset 0 0 30px var(--lotr-gold);
@@ -333,7 +348,7 @@ body.lotr-mode #loading-overlay .animate-spin::after {
@keyframes ringGlow { @keyframes ringGlow {
0%, 100% { 0%, 100% {
opacity: 0.7; opacity: 0.7;
box-shadow: box-shadow:
0 0 30px var(--lotr-gold), 0 0 30px var(--lotr-gold),
0 0 60px var(--lotr-gold), 0 0 60px var(--lotr-gold),
inset 0 0 30px var(--lotr-gold); inset 0 0 30px var(--lotr-gold);
@@ -341,7 +356,7 @@ body.lotr-mode #loading-overlay .animate-spin::after {
} }
50% { 50% {
opacity: 1; opacity: 1;
box-shadow: box-shadow:
0 0 50px var(--lotr-gold), 0 0 50px var(--lotr-gold),
0 0 100px var(--lotr-gold), 0 0 100px var(--lotr-gold),
0 0 150px var(--lotr-gold), 0 0 150px var(--lotr-gold),
@@ -361,12 +376,15 @@ body.lotr-mode #loading-overlay .animate-spin::after {
} }
} }
/* Toast Notifications - Scroll Style */ /* =========================================================================
Toast Notifications
========================================================================= */
body.lotr-mode .toast { body.lotr-mode .toast {
background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important;
border: 3px solid var(--lotr-gold); border: 3px solid var(--lotr-gold);
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
box-shadow: box-shadow:
0 8px 16px rgba(0, 0, 0, 0.5), 0 8px 16px rgba(0, 0, 0, 0.5),
inset 0 0 30px rgba(212, 175, 55, 0.15); inset 0 0 30px rgba(212, 175, 55, 0.15);
position: relative; position: relative;
@@ -410,7 +428,10 @@ body.lotr-mode .toast-info {
border-color: var(--lotr-gold); border-color: var(--lotr-gold);
} }
/* Dashboard Cards - Medieval Banners */ /* =========================================================================
Utility Classes
========================================================================= */
body.lotr-mode .bg-blue-50, body.lotr-mode .bg-blue-50,
body.lotr-mode .bg-gray-50 { body.lotr-mode .bg-gray-50 {
background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important; background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important;
@@ -423,14 +444,12 @@ body.lotr-mode .text-blue-600 {
font-weight: 600; font-weight: 600;
} }
/* Text colors in LOTR mode for better visibility */
body.lotr-mode .bg-gray-50 .text-gray-900, body.lotr-mode .bg-gray-50 .text-gray-900,
body.lotr-mode .bg-gray-50 .text-sm, body.lotr-mode .bg-gray-50 .text-sm,
body.lotr-mode .bg-gray-50 .text-sm.font-medium { body.lotr-mode .bg-gray-50 .text-sm.font-medium {
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
} }
/* Open insights button styling */
body.lotr-mode .border-blue-200 { body.lotr-mode .border-blue-200 {
border-color: var(--lotr-gold) !important; border-color: var(--lotr-gold) !important;
} }
@@ -439,7 +458,6 @@ body.lotr-mode .hover\:bg-blue-50:hover {
background-color: rgba(212, 175, 55, 0.1) !important; background-color: rgba(212, 175, 55, 0.1) !important;
} }
/* Logpath results styling */
body.lotr-mode #logpathResults, body.lotr-mode #logpathResults,
body.lotr-mode pre#logpathResults { body.lotr-mode pre#logpathResults {
background: var(--lotr-warm-parchment) !important; background: var(--lotr-warm-parchment) !important;
@@ -455,7 +473,10 @@ body.lotr-mode #logpathResults.text-yellow-600 {
color: var(--lotr-dark-gold) !important; color: var(--lotr-dark-gold) !important;
} }
/* Tables */ /* =========================================================================
Tables
========================================================================= */
body.lotr-mode table { body.lotr-mode table {
border-collapse: separate; border-collapse: separate;
border-spacing: 0; border-spacing: 0;
@@ -493,12 +514,15 @@ body.lotr-mode table tr:nth-child(even):hover td {
background: var(--lotr-warm-parchment); background: var(--lotr-warm-parchment);
} }
/* Modal - Parchment Scroll */ /* =========================================================================
Modals
========================================================================= */
body.lotr-mode .modal-content { body.lotr-mode .modal-content {
background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important; background: linear-gradient(135deg, var(--lotr-parchment) 0%, var(--lotr-warm-parchment) 100%) !important;
border: 5px solid var(--lotr-gold); border: 5px solid var(--lotr-gold);
border-radius: 16px; border-radius: 16px;
box-shadow: box-shadow:
0 12px 48px rgba(0, 0, 0, 0.6), 0 12px 48px rgba(0, 0, 0, 0.6),
inset 0 0 60px rgba(212, 175, 55, 0.15); inset 0 0 60px rgba(212, 175, 55, 0.15);
position: relative; position: relative;
@@ -512,7 +536,7 @@ body.lotr-mode .modal-content::before {
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
background: background:
repeating-linear-gradient( repeating-linear-gradient(
0deg, 0deg,
transparent, transparent,
@@ -541,7 +565,10 @@ body.lotr-mode .modal-content h3::after {
opacity: 0.5; opacity: 0.5;
} }
/* Badges and Labels */ /* =========================================================================
Badges and Labels
========================================================================= */
body.lotr-mode .bg-green-100, body.lotr-mode .bg-green-100,
body.lotr-mode .bg-green-500 { body.lotr-mode .bg-green-500 {
background: linear-gradient(135deg, var(--lotr-forest-green) 0%, var(--lotr-dark-green) 100%) !important; background: linear-gradient(135deg, var(--lotr-forest-green) 0%, var(--lotr-dark-green) 100%) !important;
@@ -568,10 +595,13 @@ body.lotr-mode .bg-yellow-400 {
text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5); text-shadow: 1px 1px 2px rgba(255, 255, 255, 0.5);
} }
/* Restart Banner */ /* =========================================================================
Restart Banner
========================================================================= */
body.lotr-mode #restartBanner { body.lotr-mode #restartBanner {
background: linear-gradient(135deg, background: linear-gradient(135deg,
var(--lotr-bright-gold) 0%, var(--lotr-bright-gold) 0%,
var(--lotr-gold) 30%, var(--lotr-gold) 30%,
var(--lotr-dark-gold) 70%, var(--lotr-dark-gold) 70%,
var(--lotr-gold) 100%) !important; var(--lotr-gold) 100%) !important;
@@ -583,7 +613,10 @@ body.lotr-mode #restartBanner {
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 8px rgba(0, 0, 0, 0.4);
} }
/* Select2 Styling */ /* =========================================================================
Select2 Overrides
========================================================================= */
body.lotr-mode .select2-container--default .select2-selection { body.lotr-mode .select2-container--default .select2-selection {
background: var(--lotr-warm-parchment) !important; background: var(--lotr-warm-parchment) !important;
border: 3px solid var(--lotr-stone-gray) !important; border: 3px solid var(--lotr-stone-gray) !important;
@@ -613,7 +646,6 @@ body.lotr-mode .select2-container--default .select2-selection--multiple .select2
margin-right: 6px; margin-right: 6px;
} }
/* Select2 Dropdown Results Styling */
body.lotr-mode .select2-results__options { body.lotr-mode .select2-results__options {
background: var(--lotr-warm-parchment) !important; background: var(--lotr-warm-parchment) !important;
border: 2px solid var(--lotr-stone-gray) !important; border: 2px solid var(--lotr-stone-gray) !important;
@@ -643,7 +675,10 @@ body.lotr-mode .select2-results__option--highlighted[aria-selected="true"] {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important; background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
} }
/* Ignore IP Tags Styling */ /* =========================================================================
Ignore IPs Tags
========================================================================= */
body.lotr-mode .ignore-ip-tag { body.lotr-mode .ignore-ip-tag {
background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important; background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important;
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
@@ -667,7 +702,10 @@ body.lotr-mode .ignore-ip-tag button:hover {
text-shadow: 0 0 4px rgba(193, 18, 31, 0.5); text-shadow: 0 0 4px rgba(193, 18, 31, 0.5);
} }
/* Scrollbar Styling */ /* =========================================================================
Scrollbar
========================================================================= */
body.lotr-mode ::-webkit-scrollbar { body.lotr-mode ::-webkit-scrollbar {
width: 14px; width: 14px;
height: 14px; height: 14px;
@@ -683,24 +721,27 @@ body.lotr-mode ::-webkit-scrollbar-thumb {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 50%, var(--lotr-dark-gold) 100%); background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 50%, var(--lotr-dark-gold) 100%);
border: 3px solid var(--lotr-brown); border: 3px solid var(--lotr-brown);
border-radius: 8px; border-radius: 8px;
box-shadow: box-shadow:
inset 0 2px 4px rgba(255, 255, 255, 0.2), inset 0 2px 4px rgba(255, 255, 255, 0.2),
inset 0 -2px 4px rgba(0, 0, 0, 0.2); inset 0 -2px 4px rgba(0, 0, 0, 0.2);
} }
body.lotr-mode ::-webkit-scrollbar-thumb:hover { body.lotr-mode ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%); background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-bright-gold) 50%, var(--lotr-gold) 100%);
box-shadow: box-shadow:
inset 0 2px 4px rgba(255, 255, 255, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.3),
inset 0 -2px 4px rgba(0, 0, 0, 0.3), inset 0 -2px 4px rgba(0, 0, 0, 0.3),
0 0 10px rgba(212, 175, 55, 0.5); 0 0 10px rgba(212, 175, 55, 0.5);
} }
/* Decorative Elements */ /* =========================================================================
Decorative Elements
========================================================================= */
body.lotr-mode .lotr-divider { body.lotr-mode .lotr-divider {
height: 4px; height: 4px;
background: linear-gradient(90deg, background: linear-gradient(90deg,
transparent 0%, transparent 0%,
var(--lotr-gold) 15%, var(--lotr-gold) 15%,
var(--lotr-bright-gold) 50%, var(--lotr-bright-gold) 50%,
var(--lotr-gold) 85%, var(--lotr-gold) 85%,
@@ -720,7 +761,7 @@ body.lotr-mode .lotr-divider::after {
color: var(--lotr-bright-gold); color: var(--lotr-bright-gold);
background: #2c1810; background: #2c1810;
padding: 0 15px; padding: 0 15px;
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.5), 2px 2px 4px rgba(0, 0, 0, 0.5),
0 0 10px rgba(212, 175, 55, 0.8); 0 0 10px rgba(212, 175, 55, 0.8);
animation: swordGlow 2s ease-in-out infinite; animation: swordGlow 2s ease-in-out infinite;
@@ -728,12 +769,12 @@ body.lotr-mode .lotr-divider::after {
@keyframes swordGlow { @keyframes swordGlow {
0%, 100% { 0%, 100% {
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.5), 2px 2px 4px rgba(0, 0, 0, 0.5),
0 0 10px rgba(212, 175, 55, 0.8); 0 0 10px rgba(212, 175, 55, 0.8);
} }
50% { 50% {
text-shadow: text-shadow:
2px 2px 4px rgba(0, 0, 0, 0.5), 2px 2px 4px rgba(0, 0, 0, 0.5),
0 0 20px rgba(212, 175, 55, 1), 0 0 20px rgba(212, 175, 55, 1),
0 0 30px rgba(212, 175, 55, 0.8); 0 0 30px rgba(212, 175, 55, 0.8);
@@ -748,9 +789,8 @@ body.lotr-mode .lotr-divider::after {
right: 20%; right: 20%;
} }
/* Glow Effects */
body.lotr-mode .lotr-glow { body.lotr-mode .lotr-glow {
text-shadow: text-shadow:
0 0 15px var(--lotr-gold), 0 0 15px var(--lotr-gold),
0 0 30px var(--lotr-gold), 0 0 30px var(--lotr-gold),
0 0 45px var(--lotr-gold); 0 0 45px var(--lotr-gold);
@@ -759,22 +799,22 @@ body.lotr-mode .lotr-glow {
@keyframes gentleGlow { @keyframes gentleGlow {
0%, 100% { 0%, 100% {
text-shadow: text-shadow:
0 0 15px var(--lotr-gold), 0 0 15px var(--lotr-gold),
0 0 30px var(--lotr-gold), 0 0 30px var(--lotr-gold),
0 0 45px var(--lotr-gold); 0 0 45px var(--lotr-gold);
} }
50% { 50% {
text-shadow: text-shadow:
0 0 25px var(--lotr-bright-gold), 0 0 25px var(--lotr-bright-gold),
0 0 50px var(--lotr-gold), 0 0 50px var(--lotr-gold),
0 0 75px var(--lotr-gold); 0 0 75px var(--lotr-gold);
} }
} }
/* Fire Effect (for email headers) */ /* Fire effect for email headers */
body.lotr-mode .lotr-fire { body.lotr-mode .lotr-fire {
background: linear-gradient(180deg, background: linear-gradient(180deg,
var(--lotr-fire-red) 0%, var(--lotr-fire-red) 0%,
var(--lotr-fire-orange) 30%, var(--lotr-fire-orange) 30%,
var(--lotr-bright-gold) 70%, var(--lotr-bright-gold) 70%,
@@ -803,16 +843,18 @@ body.lotr-mode .lotr-fire {
} }
} }
/* Smooth Theme Transition */
body.lotr-mode, body.lotr-mode,
body.lotr-mode * { body.lotr-mode * {
transition: background-color 0.6s ease, transition: background-color 0.6s ease,
color 0.6s ease, color 0.6s ease,
border-color 0.6s ease, border-color 0.6s ease,
box-shadow 0.6s ease; box-shadow 0.6s ease;
} }
/* Links */ /* =========================================================================
Links and Checkboxes
========================================================================= */
body.lotr-mode a { body.lotr-mode a {
color: var(--lotr-bright-gold) !important; color: var(--lotr-bright-gold) !important;
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7); text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
@@ -821,12 +863,11 @@ body.lotr-mode a {
body.lotr-mode a:hover { body.lotr-mode a:hover {
color: var(--lotr-gold); color: var(--lotr-gold);
text-shadow: text-shadow:
1px 1px 2px rgba(0, 0, 0, 0.7), 1px 1px 2px rgba(0, 0, 0, 0.7),
0 0 10px rgba(212, 175, 55, 0.6); 0 0 10px rgba(212, 175, 55, 0.6);
} }
/* Checkboxes and Radio Buttons */
body.lotr-mode input[type="checkbox"], body.lotr-mode input[type="checkbox"],
body.lotr-mode input[type="radio"] { body.lotr-mode input[type="radio"] {
width: 20px; width: 20px;
@@ -835,13 +876,11 @@ body.lotr-mode input[type="radio"] {
cursor: pointer; cursor: pointer;
} }
/* Toggle Switch Styling for LOTR Mode */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer { body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer {
position: relative; position: relative;
align-items: center; align-items: center;
} }
/* Toggle switch track - default state */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 { body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 {
background: var(--lotr-stone-gray) !important; background: var(--lotr-stone-gray) !important;
border: 2px solid var(--lotr-brown) !important; border: 2px solid var(--lotr-brown) !important;
@@ -849,27 +888,24 @@ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11
position: relative; position: relative;
} }
/* Toggle switch track - checked state (using peer-checked) */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ div { body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ div {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important; background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
border-color: var(--lotr-gold) !important; border-color: var(--lotr-gold) !important;
box-shadow: box-shadow:
0 0 10px rgba(212, 175, 55, 0.5), 0 0 10px rgba(212, 175, 55, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3) !important; inset 0 2px 4px rgba(255, 255, 255, 0.3) !important;
} }
/* Toggle switch focus ring */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:focus ~ div { body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:focus ~ div {
box-shadow: box-shadow:
0 0 0 4px rgba(212, 175, 55, 0.3), 0 0 0 4px rgba(212, 175, 55, 0.3),
inset 0 2px 4px rgba(0, 0, 0, 0.3) !important; inset 0 2px 4px rgba(0, 0, 0, 0.3) !important;
} }
/* Toggle switch thumb (the circle) - properly centered */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > span.absolute { body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > span.absolute {
background: var(--lotr-parchment) !important; background: var(--lotr-parchment) !important;
border: 2px solid var(--lotr-brown) !important; border: 2px solid var(--lotr-brown) !important;
box-shadow: box-shadow:
0 2px 4px rgba(0, 0, 0, 0.3), 0 2px 4px rgba(0, 0, 0, 0.3),
inset 0 1px 2px rgba(255, 255, 255, 0.5) !important; inset 0 1px 2px rgba(255, 255, 255, 0.5) !important;
top: 50% !important; top: 50% !important;
@@ -881,7 +917,6 @@ body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer
transform: translateX(1.25rem) translateY(-50%) !important; transform: translateX(1.25rem) translateY(-50%) !important;
} }
/* Labels */
body.lotr-mode label { body.lotr-mode label {
color: var(--lotr-text-dark); color: var(--lotr-text-dark);
font-weight: 600; font-weight: 600;
@@ -891,23 +926,25 @@ body.lotr-mode .bg-white label {
color: var(--lotr-text-dark) !important; color: var(--lotr-text-dark) !important;
} }
/* Main Content Area */
body.lotr-mode main { body.lotr-mode main {
background: transparent; background: transparent;
} }
/* Mobile Responsive */ /* =========================================================================
Responsive
========================================================================= */
@media (max-width: 768px) { @media (max-width: 768px) {
body.lotr-mode .bg-white { body.lotr-mode .bg-white {
border-width: 3px; border-width: 3px;
} }
body.lotr-mode h1, body.lotr-mode h1,
body.lotr-mode h2, body.lotr-mode h2,
body.lotr-mode h3 { body.lotr-mode h3 {
font-size: 1.8em; font-size: 1.8em;
} }
body.lotr-mode .lotr-divider::before, body.lotr-mode .lotr-divider::before,
body.lotr-mode .lotr-divider::after { body.lotr-mode .lotr-divider::after {
font-size: 20px; font-size: 20px;