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

This commit is contained in:
2025-12-30 10:46:44 +01:00
parent 84a97eaa96
commit f5128e1b51
11 changed files with 414 additions and 86 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
}
@@ -420,3 +420,14 @@ 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));
}

View File

@@ -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 = '';

View File

@@ -1079,7 +1079,7 @@
</div>
<div data-server-fields="ssh agent">
<label for="serverPort" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.port">Port</label>
<input type="number" id="serverPort" min="1" max="65535" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.port_placeholder" placeholder="22">
<input type="number" id="serverPort" min="1" max="65535" value="22" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.port_placeholder" placeholder="22">
</div>
<div data-server-fields="local ssh">
<label for="serverSocket" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.socket_path">Fail2ban Socket Path</label>
@@ -1095,11 +1095,16 @@
</div>
<div data-server-fields="ssh">
<label for="serverSSHUser" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.ssh_user">SSH User</label>
<input type="text" id="serverSSHUser" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_user_placeholder" placeholder="root">
<input type="text" id="serverSSHUser" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_user_placeholder" placeholder="sa_fail2ban">
</div>
<div data-server-fields="ssh">
<label for="serverSSHKey" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.ssh_key">SSH Private Key Path</label>
<input type="text" id="serverSSHKey" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_key_placeholder" placeholder="~/.ssh/id_rsa">
<input type="text" id="serverSSHKey" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" data-i18n-placeholder="servers.form.ssh_key_placeholder" placeholder="/config/.ssh/id_rsa">
<p class="mt-1 text-sm text-gray-500" data-i18n="servers.form.ssh_key_help">
Place your SSH private key in the <code class="px-1 py-0.5 bg-gray-100 rounded text-xs">/config/.ssh/</code> directory (mounted config volume).
The key file must have permissions <code class="px-1 py-0.5 bg-gray-100 rounded text-xs">600</code> (chmod 600).
Example: <code class="px-1 py-0.5 bg-gray-100 rounded text-xs">/config/.ssh/id_rsa</code>
</p>
</div>
<div data-server-fields="ssh">
<label for="serverSSHKeySelect" class="block text-sm font-medium text-gray-700 mb-1" data-i18n="servers.form.select_key">Select Private Key</label>