From f5128e1b51aad83dcbe822fbd486f75900cb7255 Mon Sep 17 00:00:00 2001 From: Michael Reber Date: Tue, 30 Dec 2025 10:46:44 +0100 Subject: [PATCH] Fix ssh connector and rewrite the get jail function, to get all in only one ssh-connection, instead of one for every jail (speed-up), add missing translations --- internal/fail2ban/connector_ssh.go | 305 ++++++++++++++++++++++------- internal/locales/de.json | 23 ++- internal/locales/de_ch.json | 23 ++- internal/locales/en.json | 23 ++- internal/locales/es.json | 23 ++- internal/locales/fr.json | 23 ++- internal/locales/it.json | 23 ++- internal/storage/storage.go | 31 +++ pkg/web/static/fail2ban-ui.css | 13 +- pkg/web/static/js/servers.js | 2 +- pkg/web/templates/index.html | 11 +- 11 files changed, 414 insertions(+), 86 deletions(-) diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go index 28c3efb..ef1740e 100644 --- a/internal/fail2ban/connector_ssh.go +++ b/internal/fail2ban/connector_ssh.go @@ -69,7 +69,10 @@ PY` // SSHConnector connects to a remote Fail2ban instance over SSH. type SSHConnector struct { - server config.Fail2banServer + server config.Fail2banServer + fail2banPath string // Cache the fail2ban path + pathCached bool // Track if path is cached + pathMutex sync.RWMutex } // NewSSHConnector creates a new SSH connector. @@ -444,37 +447,43 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string { } // listRemoteFiles lists files in a remote directory matching a pattern. -// Uses Python to list files, which works better with FACL permissions than find/ls. +// Uses find command which works reliably with FACL permissions. func (sc *SSHConnector) listRemoteFiles(ctx context.Context, directory, pattern string) ([]string, error) { - // Use Python to list files - works better with FACL permissions - script := fmt.Sprintf(`python3 -c " -import os -import sys -directory = %q -pattern = %q -try: - if os.path.isdir(directory): - files = os.listdir(directory) - for f in files: - if f.endswith(pattern) and not f.startswith('.'): - full_path = os.path.join(directory, f) - if os.path.isfile(full_path): - print(full_path) -except Exception as e: - sys.stderr.write(f'Error listing files: {e}\\n') - sys.exit(1) -"`, directory, pattern) + // Use find command with absolute path - it will handle non-existent directories gracefully + // Find files ending with pattern, exclude hidden files, and ensure they're regular files + // Redirect stderr to /dev/null to suppress "No such file or directory" errors + // Pass the entire command as a single string to SSH (SSH executes through a shell by default) + cmd := fmt.Sprintf(`find "%s" -maxdepth 1 -type f -name "*%s" ! -name ".*" 2>/dev/null | sort`, directory, pattern) - out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", script}) + out, err := sc.runRemoteCommand(ctx, []string{cmd}) if err != nil { - return nil, fmt.Errorf("failed to list files in %s: %w", directory, err) + // If find fails (e.g., directory doesn't exist or permission denied), return empty list (not an error) + config.DebugLog("Find command failed for %s on server %s: %v, returning empty list", directory, sc.server.Name, err) + return []string{}, nil } + // If find succeeds but directory doesn't exist, it will return empty output + // This is fine - we'll just return an empty list + var files []string for _, line := range strings.Split(out, "\n") { line = strings.TrimSpace(line) - if line != "" { - files = append(files, line) + // Skip empty lines, current directory marker, and relative paths + if line == "" || line == "." || strings.HasPrefix(line, "./") { + continue + } + // Only process files that match our pattern (end with .local or .conf) + // and are actually in the target directory + if strings.HasSuffix(line, pattern) { + // If it's already an absolute path starting with our directory, use it directly + if strings.HasPrefix(line, directory) { + files = append(files, line) + } else if !strings.HasPrefix(line, "/") { + // Relative path, join with directory + fullPath := filepath.Join(directory, line) + files = append(files, fullPath) + } + // Skip any other absolute paths that don't start with our directory } } @@ -501,7 +510,7 @@ func (sc *SSHConnector) writeRemoteFile(ctx context.Context, filePath, content s REMOTEEOF `, filePath, escaped) - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + _, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return fmt.Errorf("failed to write remote file %s: %w", filePath, err) } @@ -526,7 +535,7 @@ func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, nam fi `, localPath, confPath, confPath, localPath, localPath) - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + _, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return fmt.Errorf("failed to ensure remote .local file %s: %w", localPath, err) } @@ -535,22 +544,46 @@ func (sc *SSHConnector) ensureRemoteLocalFile(ctx context.Context, basePath, nam // getFail2banPath detects the fail2ban configuration path on the remote system. // Returns /config/fail2ban for linuxserver images, or /etc/fail2ban for standard installations. +// Uses caching to avoid repeated SSH calls. func (sc *SSHConnector) getFail2banPath(ctx context.Context) string { - // Check if /config/fail2ban exists (linuxserver image) - checkScript := `if [ -d "/config/fail2ban" ]; then echo "/config/fail2ban"; elif [ -d "/etc/fail2ban" ]; then echo "/etc/fail2ban"; else echo "/etc/fail2ban"; fi` - out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript}) + // Try to read from cache first + sc.pathMutex.RLock() + if sc.pathCached { + path := sc.fail2banPath + sc.pathMutex.RUnlock() + return path + } + sc.pathMutex.RUnlock() + + // Acquire write lock to update cache + sc.pathMutex.Lock() + defer sc.pathMutex.Unlock() + + // Double-check after acquiring write lock (another goroutine might have cached it) + if sc.pathCached { + return sc.fail2banPath + } + + // Actually fetch the path + checkCmd := `test -d "/config/fail2ban" && echo "/config/fail2ban" || (test -d "/etc/fail2ban" && echo "/etc/fail2ban" || echo "/etc/fail2ban")` + out, err := sc.runRemoteCommand(ctx, []string{checkCmd}) if err == nil { path := strings.TrimSpace(out) if path != "" { + sc.fail2banPath = path + sc.pathCached = true return path } } // Default to /etc/fail2ban - return "/etc/fail2ban" + sc.fail2banPath = "/etc/fail2ban" + sc.pathCached = true + return sc.fail2banPath } // GetAllJails implements Connector. // Discovers all jails from filesystem (mirrors local connector behavior). +// Optimized to read all files in a single SSH command instead of individual reads. func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") @@ -559,23 +592,167 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { processedFiles := make(map[string]bool) // Track base names to avoid duplicates processedJails := make(map[string]bool) // Track jail names to avoid duplicates + // Use a Python script to read all files in a single SSH command + // This is much more efficient than reading each file individually + readAllScript := fmt.Sprintf(`python3 << 'PYEOF' +import os +import sys +import json + +jail_d_path = %q +files_data = {} + +# Read all .local files first +local_files = [] +if os.path.isdir(jail_d_path): + for filename in os.listdir(jail_d_path): + if filename.endswith('.local') and not filename.startswith('.'): + local_files.append(os.path.join(jail_d_path, filename)) + +# Process .local files +for filepath in sorted(local_files): + try: + filename = os.path.basename(filepath) + basename = filename[:-6] # Remove .local + if basename and basename not in files_data: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + files_data[basename] = {'path': filepath, 'content': content, 'type': 'local'} + except Exception as e: + sys.stderr.write(f"Error reading {filepath}: {e}\n") + +# Read all .conf files that don't have corresponding .local files +conf_files = [] +if os.path.isdir(jail_d_path): + for filename in os.listdir(jail_d_path): + if filename.endswith('.conf') and not filename.startswith('.'): + basename = filename[:-5] # Remove .conf + if basename not in files_data: + conf_files.append(os.path.join(jail_d_path, filename)) + +# Process .conf files +for filepath in sorted(conf_files): + try: + filename = os.path.basename(filepath) + basename = filename[:-5] # Remove .conf + if basename: + with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: + content = f.read() + files_data[basename] = {'path': filepath, 'content': content, 'type': 'conf'} + except Exception as e: + sys.stderr.write(f"Error reading {filepath}: {e}\n") + +# Output files with a delimiter: FILE_START:path:type\ncontent\nFILE_END\n +for basename, data in sorted(files_data.items()): + print(f"FILE_START:{data['path']}:{data['type']}") + print(data['content'], end='') + print("FILE_END") +PYEOF`, jailDPath) + + output, err := sc.runRemoteCommand(ctx, []string{readAllScript}) + if err != nil { + // Fallback to individual file reads if the script fails + config.DebugLog("Failed to read all jail files at once on server %s, falling back to individual reads: %v", sc.server.Name, err) + return sc.getAllJailsFallback(ctx, jailDPath) + } + + // Parse the output: files are separated by FILE_START:path:type\ncontent\nFILE_END\n + var currentFile string + var currentContent strings.Builder + var currentType string + inFile := false + + lines := strings.Split(output, "\n") + for _, line := range lines { + if strings.HasPrefix(line, "FILE_START:") { + // Save previous file if any + if inFile && currentFile != "" { + content := currentContent.String() + jails := parseJailConfigContent(content) + for _, jail := range jails { + if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + allJails = append(allJails, jail) + processedJails[jail.JailName] = true + } + } + } + // Parse new file header: FILE_START:path:type + parts := strings.SplitN(line, ":", 3) + if len(parts) == 3 { + currentFile = parts[1] + currentType = parts[2] + currentContent.Reset() + inFile = true + filename := filepath.Base(currentFile) + var baseName string + if currentType == "local" { + baseName = strings.TrimSuffix(filename, ".local") + } else { + baseName = strings.TrimSuffix(filename, ".conf") + } + if baseName != "" { + processedFiles[baseName] = true + } + } + } else if line == "FILE_END" { + // End of file, process it + if inFile && currentFile != "" { + content := currentContent.String() + jails := parseJailConfigContent(content) + for _, jail := range jails { + if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + allJails = append(allJails, jail) + processedJails[jail.JailName] = true + } + } + } + inFile = false + currentFile = "" + currentContent.Reset() + } else if inFile { + // Content line + if currentContent.Len() > 0 { + currentContent.WriteString("\n") + } + currentContent.WriteString(line) + } + } + + // Handle last file if output doesn't end with FILE_END + if inFile && currentFile != "" { + content := currentContent.String() + jails := parseJailConfigContent(content) + for _, jail := range jails { + if jail.JailName != "" && jail.JailName != "DEFAULT" && !processedJails[jail.JailName] { + allJails = append(allJails, jail) + processedJails[jail.JailName] = true + } + } + } + + return allJails, nil +} + +// getAllJailsFallback is the fallback method that reads files individually. +// Used when the optimized batch read fails. +func (sc *SSHConnector) getAllJailsFallback(ctx context.Context, jailDPath string) ([]JailInfo, error) { + var allJails []JailInfo + processedFiles := make(map[string]bool) + processedJails := make(map[string]bool) + // List all .local files first localFiles, err := sc.listRemoteFiles(ctx, jailDPath, ".local") if err != nil { config.DebugLog("Failed to list .local files in jail.d on server %s: %v", sc.server.Name, err) - // Continue with .conf files } else { - // Process .local files for _, filePath := range localFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".local") if baseName == "" || processedFiles[baseName] { continue } - processedFiles[baseName] = true - // Read and parse the file content, err := sc.readRemoteFile(ctx, filePath) if err != nil { config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) @@ -597,17 +774,14 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) { if err != nil { config.DebugLog("Failed to list .conf files in jail.d on server %s: %v", sc.server.Name, err) } else { - // Process .conf files for _, filePath := range confFiles { filename := filepath.Base(filePath) baseName := strings.TrimSuffix(filename, ".conf") if baseName == "" || processedFiles[baseName] { continue } - processedFiles[baseName] = true - // Read and parse the file content, err := sc.readRemoteFile(ctx, filePath) if err != nil { config.DebugLog("Failed to read jail file %s on server %s: %v", filePath, sc.server.Name, err) @@ -632,12 +806,6 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map fail2banPath := sc.getFail2banPath(ctx) jailDPath := filepath.Join(fail2banPath, "jail.d") - // Ensure jail.d directory exists - _, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", jailDPath}) - if err != nil { - return fmt.Errorf("failed to create jail.d directory: %w", err) - } - // Update each jail in its own .local file for jailName, enabled := range updates { // Validate jail name - skip empty or invalid names @@ -650,8 +818,9 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map localPath := filepath.Join(jailDPath, jailName+".local") confPath := filepath.Join(jailDPath, jailName+".conf") - // Ensure .local file exists (copy from .conf if needed) - ensureScript := fmt.Sprintf(` + // Combined script: ensure .local file exists AND read it in one SSH call + // This reduces SSH round-trips from 2 to 1 per jail + combinedScript := fmt.Sprintf(` if [ ! -f "%s" ]; then if [ -f "%s" ]; then cp "%s" "%s" @@ -659,16 +828,12 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map echo "[%s]" > "%s" fi fi - `, localPath, confPath, confPath, localPath, jailName, localPath) + cat "%s" + `, localPath, confPath, confPath, localPath, jailName, localPath, localPath) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", ensureScript}); err != nil { - return fmt.Errorf("failed to ensure .local file for jail %s: %w", jailName, err) - } - - // Read existing .local file - content, err := sc.runRemoteCommand(ctx, []string{"cat", localPath}) + content, err := sc.runRemoteCommand(ctx, []string{combinedScript}) if err != nil { - return fmt.Errorf("failed to read jail .local file %s: %w", localPath, err) + return fmt.Errorf("failed to ensure and read .local file for jail %s: %w", jailName, err) } // Update enabled state in existing file @@ -717,7 +882,7 @@ func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map // Write updated content to .local file newContent := strings.Join(outputLines, "\n") cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", localPath, newContent) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil { + if _, err := sc.runRemoteCommand(ctx, []string{cmd}); err != nil { return fmt.Errorf("failed to write jail .local file %s: %w", localPath, err) } } @@ -810,9 +975,11 @@ func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLi filterName = strings.ReplaceAll(filterName, "/", "") filterName = strings.ReplaceAll(filterName, "..", "") + // Get the fail2ban path dynamically + fail2banPath := sc.getFail2banPath(ctx) // Try .local first, then fallback to .conf - localPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.local", filterName) - confPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName) + localPath := filepath.Join(fail2banPath, "filter.d", filterName+".local") + confPath := filepath.Join(fail2banPath, "filter.d", filterName+".conf") const heredocMarker = "F2B_FILTER_TEST_LOG" logContent := strings.Join(cleaned, "\n") @@ -839,7 +1006,7 @@ cat <<'%[3]s' > "$TMPFILE" fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true `, localPath, confPath, heredocMarker, logContent) - out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return "", "", err } @@ -965,7 +1132,7 @@ fi `, logpath) } - out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script}) + out, err := sc.runRemoteCommand(ctx, []string{script}) if err != nil { return []string{}, nil // Return empty on error } @@ -1099,7 +1266,7 @@ PYEOF `, originalPath) // Run resolution script - resolveOut, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", resolveScript}) + resolveOut, err := sc.runRemoteCommand(ctx, []string{resolveScript}) if err != nil { return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err) } @@ -1139,7 +1306,7 @@ func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings conf if existingContent != "" { // Use sed to remove lines starting with # (but preserve empty lines) removeCommentsCmd := fmt.Sprintf("sed '/^[[:space:]]*#/d' %s", jailLocalPath) - uncommentedContent, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", removeCommentsCmd}) + uncommentedContent, err := sc.runRemoteCommand(ctx, []string{removeCommentsCmd}) if err == nil { existingContent = uncommentedContent } @@ -1182,7 +1349,7 @@ func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings conf defaultLines = append(defaultLines, "") newContent := strings.Join(defaultLines, "\n") cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailLocalPath, newContent) - _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}) + _, err = sc.runRemoteCommand(ctx, []string{cmd}) return err } @@ -1285,7 +1452,7 @@ with open(jail_file, 'w') as f: f.writelines(output_lines) PY`, escapeForShell(jailLocalPath), escapeForShell(ignoreIPStr), escapeForShell(banactionVal), escapeForShell(banactionAllportsVal), settings.BantimeIncrement, settings.DefaultJailEnable, escapeForShell(settings.Bantime), escapeForShell(settings.Findtime), settings.Maxretry, escapeForShell(settings.Destemail)) - _, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", updateScript}) + _, err = sc.runRemoteCommand(ctx, []string{updateScript}) return err } @@ -1370,7 +1537,7 @@ action = %(action_mwlg)s JAILLOCAL `, jailLocalPath, escaped) - _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}) + _, err := sc.runRemoteCommand(ctx, []string{writeScript}) return err } @@ -1381,7 +1548,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err // Check if jail.local exists checkScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailLocalPath) - out, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkScript}) + out, err := sc.runRemoteCommand(ctx, []string{checkScript}) if err != nil || strings.TrimSpace(out) != "exists" { config.DebugLog("No jails to migrate from jail.local on server %s (file does not exist)", sc.server.Name) return nil // Nothing to migrate @@ -1408,14 +1575,14 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err // Create backup backupPath := jailLocalPath + ".backup." + fmt.Sprintf("%d", time.Now().Unix()) backupScript := fmt.Sprintf("cp %s %s", jailLocalPath, backupPath) - if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", backupScript}); err != nil { + if _, err := sc.runRemoteCommand(ctx, []string{backupScript}); err != nil { return fmt.Errorf("failed to create backup on server %s: %w", sc.server.Name, err) } config.DebugLog("Created backup of jail.local at %s on server %s", backupPath, sc.server.Name) // Ensure jail.d directory exists ensureDirScript := fmt.Sprintf("mkdir -p %s", jailDPath) - if _, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", ensureDirScript}); err != nil { + if _, err := sc.runRemoteCommand(ctx, []string{ensureDirScript}); err != nil { return fmt.Errorf("failed to create jail.d directory on server %s: %w", sc.server.Name, err) } @@ -1430,7 +1597,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err // Check if .local file already exists checkFileScript := fmt.Sprintf("test -f %s && echo 'exists' || echo 'notfound'", jailFilePath) - fileOut, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", checkFileScript}) + fileOut, err := sc.runRemoteCommand(ctx, []string{checkFileScript}) if err == nil && strings.TrimSpace(fileOut) == "exists" { config.DebugLog("Skipping migration for jail %s on server %s: .local file already exists", jailName, sc.server.Name) continue @@ -1443,7 +1610,7 @@ func (sc *SSHConnector) MigrateJailsFromJailLocalRemote(ctx context.Context) err %s JAILEOF '`, jailFilePath, escapedContent) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeScript}); err != nil { + if _, err := sc.runRemoteCommand(ctx, []string{writeScript}); err != nil { return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err) } config.DebugLog("Migrated jail %s to %s on server %s", jailName, jailFilePath, sc.server.Name) @@ -1459,7 +1626,7 @@ JAILEOF %s LOCALEOF '`, jailLocalPath, escapedDefault) - if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", writeLocalScript}); err != nil { + if _, err := sc.runRemoteCommand(ctx, []string{writeLocalScript}); err != nil { return fmt.Errorf("failed to rewrite jail.local: %w", err) } config.DebugLog("Migration completed on server %s: moved %d jails to jail.d/", sc.server.Name, migratedCount) diff --git a/internal/locales/de.json b/internal/locales/de.json index c9c3791..9fd58a7 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Alarm-Länder", "settings.alert_countries_description": "Wählen Sie die Länder aus, für die E-Mail-Alarme ausgelöst werden sollen, wenn eine Sperrung erfolgt.", + "settings.email_alerts": "E-Mail-Benachrichtigungseinstellungen", "settings.email_alerts_for_bans": "E-Mail-Benachrichtigungen für Sperrungen aktivieren", "settings.email_alerts_for_unbans": "E-Mail-Benachrichtigungen für Entsperrungen aktivieren", "settings.smtp": "SMTP-Konfiguration", @@ -198,6 +199,23 @@ "settings.save": "Speichern", "modal.filter_config": "Filter / Jail-Konfiguration:", "modal.filter_config_edit": "Filter / Jail bearbeiten", + "modal.filter_config_label": "Filter-Konfiguration", + "modal.filter_config_hint": "Wenn leer gelassen, wird eine leere Filterdatei erstellt.", + "modal.filter_name": "Filter-Name", + "modal.filter_name_hint": "Nur alphanumerische Zeichen, Bindestriche und Unterstriche sind erlaubt.", + "modal.jail_config": "Jail-Konfiguration", + "modal.jail_config_hint": "Die Jail-Konfiguration wird automatisch ausgefüllt, wenn Sie einen Filter auswählen.", + "modal.jail_config_label": "Jail-Konfiguration", + "modal.jail_filter": "Filter (optional)", + "modal.jail_filter_hint": "Die Auswahl eines Filters füllt die Jail-Konfiguration automatisch aus.", + "modal.jail_name": "Jail-Name", + "modal.jail_name_hint": "Nur alphanumerische Zeichen, Bindestriche und Unterstriche sind erlaubt.", + "modal.test_logpath": "Logpfad testen", + "modal.create": "Erstellen", + "modal.create_filter": "Neuen Filter erstellen", + "modal.create_filter_title": "Neuen Filter erstellen", + "modal.create_jail": "Neues Jail erstellen", + "modal.create_jail_title": "Neues Jail erstellen", "modal.cancel": "Abbrechen", "modal.save": "Speichern", "modal.close": "Schließen", @@ -229,9 +247,10 @@ "servers.form.hostname": "Server-Hostname", "servers.form.hostname_placeholder": "optional", "servers.form.ssh_user": "SSH-Benutzer", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "Pfad zum SSH-Schlüssel", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Platzieren Sie Ihren SSH-Private-Key im Verzeichnis /config/.ssh/ (gemountetes Config-Volume). Die Schlüsseldatei muss die Berechtigungen 600 haben (chmod 600). Beispiel: /config/.ssh/id_rsa", "servers.form.agent_url": "Agent-URL", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Agent-Secret", diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 356671b..994d325 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Alarm-Länder", "settings.alert_countries_description": "Wähl d'Länder us, für weli du per Email ä Alarm becho wetsch, wenn e Sperrig erfolgt.", + "settings.email_alerts": "Email-Benachrichtigungsiistellige", "settings.email_alerts_for_bans": "Email-Benachrichtigunge für Sperrige aktiviere", "settings.email_alerts_for_unbans": "Email-Benachrichtigunge für Entsperrige aktiviere", "settings.smtp": "SMTP-Konfiguration", @@ -198,6 +199,23 @@ "settings.save": "Speicherä", "modal.filter_config": "Filter / Jail-Konfiguration:", "modal.filter_config_edit": "Filter / Jail bearbeite", + "modal.filter_config_label": "Filter-Konfiguration", + "modal.filter_config_hint": "Wenn leer glah, wird e leeri Filterdatei erstellt.", + "modal.filter_name": "Filter-Name", + "modal.filter_name_hint": "Nur alphanumerischi Zeiche, Bindestrich und Unterstriche si erlaubt.", + "modal.jail_config": "Jail-Konfiguration", + "modal.jail_config_hint": "D Jail-Konfiguration wird automatisch usgfüllt, wenn Sie ä Filter uswähle.", + "modal.jail_config_label": "Jail-Konfiguration", + "modal.jail_filter": "Filter (optional)", + "modal.jail_filter_hint": "D Uswahl von ne Filter füllt d Jail-Konfiguration automatisch us.", + "modal.jail_name": "Jail-Name", + "modal.jail_name_hint": "Nur alphanumerischi Zeiche, Bindestrich und Unterstriche si erlaubt.", + "modal.test_logpath": "Logpfad teste", + "modal.create": "Ersteue", + "modal.create_filter": "Neue Filter ersteue", + "modal.create_filter_title": "Neue Filter ersteue", + "modal.create_jail": "Neus Jail ersteue", + "modal.create_jail_title": "Neus Jail ersteue", "modal.cancel": "Abbräche", "modal.save": "Speicherä", "modal.close": "Wider Schliesse", @@ -229,9 +247,10 @@ "servers.form.hostname": "Server-Hostname", "servers.form.hostname_placeholder": "optional", "servers.form.ssh_user": "SSH-Benutzer", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "Pfad zum SSH-Schlüssel", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Platzieren Sie Ihren SSH-Private-Key im Verzeichnis /config/.ssh/ (gemountetes Config-Volume). Die Schlüsseldatei muss die Berechtigungen 600 haben (chmod 600). Beispiel: /config/.ssh/id_rsa", "servers.form.agent_url": "Agent-URL", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Agent-Secret", diff --git a/internal/locales/en.json b/internal/locales/en.json index 20c9186..b08fe6a 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Alert Countries", "settings.alert_countries_description": "Choose the countries for which you want to receive email alerts when a block is triggered.", + "settings.email_alerts": "Email Alert Preferences", "settings.email_alerts_for_bans": "Enable email alerts for bans", "settings.email_alerts_for_unbans": "Enable email alerts for unbans", "settings.smtp": "SMTP Configuration", @@ -198,6 +199,23 @@ "settings.save": "Save", "modal.filter_config": "Filter / Jail Configuration:", "modal.filter_config_edit": "Edit Filter / Jail", + "modal.filter_config_label": "Filter Configuration", + "modal.filter_config_hint": "If left empty, an empty filter file will be created.", + "modal.filter_name": "Filter Name", + "modal.filter_name_hint": "Only alphanumeric characters, dashes, and underscores are allowed.", + "modal.jail_config": "Jail Configuration", + "modal.jail_config_hint": "Jail configuration will be auto-populated when you select a filter.", + "modal.jail_config_label": "Jail Configuration", + "modal.jail_filter": "Filter (optional)", + "modal.jail_filter_hint": "Selecting a filter will auto-populate the jail configuration.", + "modal.jail_name": "Jail Name", + "modal.jail_name_hint": "Only alphanumeric characters, dashes, and underscores are allowed.", + "modal.test_logpath": "Test Logpath", + "modal.create": "Create", + "modal.create_filter": "Create New Filter", + "modal.create_filter_title": "Create New Filter", + "modal.create_jail": "Create New Jail", + "modal.create_jail_title": "Create New Jail", "modal.cancel": "Cancel", "modal.save": "Save", "modal.close": "Close", @@ -229,9 +247,10 @@ "servers.form.hostname": "Server Hostname", "servers.form.hostname_placeholder": "optional", "servers.form.ssh_user": "SSH User", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "SSH Private Key Path", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Place your SSH private key in the /config/.ssh/ directory (mounted config volume). The key file must have permissions 600 (chmod 600). Example: /config/.ssh/id_rsa", "servers.form.agent_url": "Agent URL", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Agent Secret", diff --git a/internal/locales/es.json b/internal/locales/es.json index 87cee13..5661cf8 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Países para alerta", "settings.alert_countries_description": "Elige los países para los que deseas recibir alertas por correo electrónico cuando se produzca un bloqueo.", + "settings.email_alerts": "Preferencias de alertas por email", "settings.email_alerts_for_bans": "Activar alertas por email para bloqueos", "settings.email_alerts_for_unbans": "Activar alertas por email para desbloqueos", "settings.smtp": "Configuración SMTP", @@ -198,6 +199,23 @@ "settings.save": "Guardar", "modal.filter_config": "Configuración del filtro / Jail:", "modal.filter_config_edit": "Editar filtro / Jail", + "modal.filter_config_label": "Configuración del filtro", + "modal.filter_config_hint": "Si se deja vacío, se creará un archivo de filtro vacío.", + "modal.filter_name": "Nombre del filtro", + "modal.filter_name_hint": "Solo se permiten caracteres alfanuméricos, guiones y guiones bajos.", + "modal.jail_config": "Configuración del jail", + "modal.jail_config_hint": "La configuración del jail se completará automáticamente cuando seleccione un filtro.", + "modal.jail_config_label": "Configuración del jail", + "modal.jail_filter": "Filtro (opcional)", + "modal.jail_filter_hint": "La selección de un filtro completará automáticamente la configuración del jail.", + "modal.jail_name": "Nombre del jail", + "modal.jail_name_hint": "Solo se permiten caracteres alfanuméricos, guiones y guiones bajos.", + "modal.test_logpath": "Probar ruta de registro", + "modal.create": "Crear", + "modal.create_filter": "Crear nuevo filtro", + "modal.create_filter_title": "Crear nuevo filtro", + "modal.create_jail": "Crear nuevo jail", + "modal.create_jail_title": "Crear nuevo jail", "modal.cancel": "Cancelar", "modal.save": "Guardar", "modal.close": "Cerrar", @@ -229,9 +247,10 @@ "servers.form.hostname": "Nombre de host del servidor", "servers.form.hostname_placeholder": "opcional", "servers.form.ssh_user": "Usuario SSH", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "Ruta de la clave SSH", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Coloque su clave privada SSH en el directorio /config/.ssh/ (volumen de configuración montado). El archivo de clave debe tener permisos 600 (chmod 600). Ejemplo: /config/.ssh/id_rsa", "servers.form.agent_url": "URL del agente", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Secreto del agente", diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 1eb36d1..243230d 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Pays d'alerte", "settings.alert_countries_description": "Choisissez les pays pour lesquels vous souhaitez recevoir des alertes par email lors d'un blocage.", + "settings.email_alerts": "Préférences d'alertes email", "settings.email_alerts_for_bans": "Activer les alertes email pour les bannissements", "settings.email_alerts_for_unbans": "Activer les alertes email pour les débannissements", "settings.smtp": "Configuration SMTP", @@ -198,6 +199,23 @@ "settings.save": "Enregistrer", "modal.filter_config": "Configuration du filtre / Jail:", "modal.filter_config_edit": "Modifier le filtre / Jail", + "modal.filter_config_label": "Configuration du filtre", + "modal.filter_config_hint": "Si laissé vide, un fichier de filtre vide sera créé.", + "modal.filter_name": "Nom du filtre", + "modal.filter_name_hint": "Seuls les caractères alphanumériques, les tirets et les underscores sont autorisés.", + "modal.jail_config": "Configuration du jail", + "modal.jail_config_hint": "La configuration du jail sera automatiquement remplie lorsque vous sélectionnez un filtre.", + "modal.jail_config_label": "Configuration du jail", + "modal.jail_filter": "Filtre (optionnel)", + "modal.jail_filter_hint": "La sélection d'un filtre remplira automatiquement la configuration du jail.", + "modal.jail_name": "Nom du jail", + "modal.jail_name_hint": "Seuls les caractères alphanumériques, les tirets et les underscores sont autorisés.", + "modal.test_logpath": "Tester le chemin de journal", + "modal.create": "Créer", + "modal.create_filter": "Créer un nouveau filtre", + "modal.create_filter_title": "Créer un nouveau filtre", + "modal.create_jail": "Créer un nouveau jail", + "modal.create_jail_title": "Créer un nouveau jail", "modal.cancel": "Annuler", "modal.save": "Enregistrer", "modal.close": "Fermer", @@ -229,9 +247,10 @@ "servers.form.hostname": "Nom d'hôte du serveur", "servers.form.hostname_placeholder": "optionnel", "servers.form.ssh_user": "Utilisateur SSH", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "Chemin de la clé SSH", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Placez votre clé privée SSH dans le répertoire /config/.ssh/ (volume de configuration monté). Le fichier de clé doit avoir les permissions 600 (chmod 600). Exemple : /config/.ssh/id_rsa", "servers.form.agent_url": "URL de l'agent", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Secret de l'agent", diff --git a/internal/locales/it.json b/internal/locales/it.json index a6e281a..9826af1 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -112,6 +112,7 @@ "settings.destination_email_placeholder": "alerts@swissmakers.ch", "settings.alert_countries": "Paesi per allarme", "settings.alert_countries_description": "Seleziona i paesi per i quali desideri ricevere allarmi via email quando si verifica un blocco.", + "settings.email_alerts": "Preferenze allarmi email", "settings.email_alerts_for_bans": "Abilita allarmi email per i ban", "settings.email_alerts_for_unbans": "Abilita allarmi email per gli unban", "settings.smtp": "Configurazione SMTP", @@ -198,6 +199,23 @@ "settings.save": "Salva", "modal.filter_config": "Configurazione del filtro / Jail:", "modal.filter_config_edit": "Modifica filtro / Jail", + "modal.filter_config_label": "Configurazione del filtro", + "modal.filter_config_hint": "Se lasciato vuoto, verrà creato un file di filtro vuoto.", + "modal.filter_name": "Nome del filtro", + "modal.filter_name_hint": "Sono consentiti solo caratteri alfanumerici, trattini e underscore.", + "modal.jail_config": "Configurazione del jail", + "modal.jail_config_hint": "La configurazione del jail verrà compilata automaticamente quando si seleziona un filtro.", + "modal.jail_config_label": "Configurazione del jail", + "modal.jail_filter": "Filtro (opzionale)", + "modal.jail_filter_hint": "La selezione di un filtro compila automaticamente la configurazione del jail.", + "modal.jail_name": "Nome del jail", + "modal.jail_name_hint": "Sono consentiti solo caratteri alfanumerici, trattini e underscore.", + "modal.test_logpath": "Testa il percorso del log", + "modal.create": "Crea", + "modal.create_filter": "Crea nuovo filtro", + "modal.create_filter_title": "Crea nuovo filtro", + "modal.create_jail": "Crea nuovo jail", + "modal.create_jail_title": "Crea nuovo jail", "modal.cancel": "Annulla", "modal.save": "Salva", "modal.close": "Chiudi", @@ -229,9 +247,10 @@ "servers.form.hostname": "Nome host del server", "servers.form.hostname_placeholder": "opzionale", "servers.form.ssh_user": "Utente SSH", - "servers.form.ssh_user_placeholder": "root", + "servers.form.ssh_user_placeholder": "sa_fail2ban", "servers.form.ssh_key": "Percorso della chiave SSH", - "servers.form.ssh_key_placeholder": "~/.ssh/id_rsa", + "servers.form.ssh_key_placeholder": "/config/.ssh/id_rsa", + "servers.form.ssh_key_help": "Posiziona la tua chiave privata SSH nella directory /config/.ssh/ (volume di configurazione montato). Il file della chiave deve avere i permessi 600 (chmod 600). Esempio: /config/.ssh/id_rsa", "servers.form.agent_url": "URL dell'agente", "servers.form.agent_url_placeholder": "https://host:9443", "servers.form.agent_secret": "Segreto dell'agente", diff --git a/internal/storage/storage.go b/internal/storage/storage.go index e8d1f81..0aba7eb 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -153,6 +153,12 @@ func Init(dbPath string) error { return } + // Ensure .ssh directory exists (for SSH key storage) + if err := ensureSSHDirectory(); err != nil { + // Log but don't fail - .ssh directory creation is not critical + log.Printf("Warning: failed to ensure .ssh directory: %v", err) + } + var err error db, err = sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout=5000", dbPath)) if err != nil { @@ -943,6 +949,31 @@ func ensureDirectory(path string) error { return os.MkdirAll(dir, 0o755) } +// ensureSSHDirectory ensures the .ssh directory exists for SSH key storage. +// In containers, this is /config/.ssh, on the host it's ~/.ssh +func ensureSSHDirectory() error { + var sshDir string + // Check if running inside a container + if _, container := os.LookupEnv("CONTAINER"); container { + // In container, use /config/.ssh + sshDir = "/config/.ssh" + } else { + // On host, use ~/.ssh + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("failed to get user home directory: %w", err) + } + sshDir = filepath.Join(home, ".ssh") + } + + // Create directory with proper permissions (0700 for .ssh) + if err := os.MkdirAll(sshDir, 0o700); err != nil { + return fmt.Errorf("failed to create .ssh directory at %s: %w", sshDir, err) + } + + return nil +} + // UpsertPermanentBlock records or updates a permanent block entry. func UpsertPermanentBlock(ctx context.Context, rec PermanentBlockRecord) error { if db == nil { diff --git a/pkg/web/static/fail2ban-ui.css b/pkg/web/static/fail2ban-ui.css index b7bc902..ae33264 100644 --- a/pkg/web/static/fail2ban-ui.css +++ b/pkg/web/static/fail2ban-ui.css @@ -221,7 +221,7 @@ mark { } #statusDot.bg-red-500 { - box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7); + box-shadow: 0 0 0 0 rgb(163 44 44); animation: pulseRed 2s infinite; } @@ -419,4 +419,15 @@ button.bg-red-500, button.bg-red-600 { button.bg-red-500:hover, button.bg-red-600:hover { background-color: rgb(220 38 38); +} + +/* Green color classes */ +.bg-green-100 { + --tw-bg-opacity: 1; + background-color: rgb(220 252 231 / var(--tw-bg-opacity, 1)); +} + +.text-green-800 { + --tw-text-opacity: 1; + color: rgb(22 101 52 / var(--tw-text-opacity, 1)); } \ No newline at end of file diff --git a/pkg/web/static/js/servers.js b/pkg/web/static/js/servers.js index c75a72c..5baa0ab 100644 --- a/pkg/web/static/js/servers.js +++ b/pkg/web/static/js/servers.js @@ -206,7 +206,7 @@ function resetServerForm() { document.getElementById('serverName').value = ''; document.getElementById('serverType').value = 'local'; document.getElementById('serverHost').value = ''; - document.getElementById('serverPort').value = ''; + document.getElementById('serverPort').value = '22'; document.getElementById('serverSocket').value = '/var/run/fail2ban/fail2ban.sock'; document.getElementById('serverLogPath').value = '/var/log/fail2ban.log'; document.getElementById('serverHostname').value = ''; diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 2bb574a..0e29661 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -1079,7 +1079,7 @@
- +
@@ -1095,11 +1095,16 @@
- +
- + +

+ Place your SSH private key in the /config/.ssh/ directory (mounted config volume). + The key file must have permissions 600 (chmod 600). + Example: /config/.ssh/id_rsa +