Refactor jail.local and jail.d management

This commit is contained in:
2025-12-03 20:43:44 +01:00
parent c7174ed0c6
commit cd7a814cda
9 changed files with 987 additions and 125 deletions

View File

@@ -4,39 +4,38 @@ services:
fail2ban-ui:
# Use pre-built image from registry
image: registry.swissmakers.ch/infra/fail2ban-ui:latest
# Or build from source (uncomment to use):
# build:
# context: .
# dockerfile: Dockerfile
container_name: fail2ban-ui
network_mode: host
restart: unless-stopped
environment:
# Custom port (optional, defaults to 8080)
# Change this to use a different port for the web interface
- PORT=8080
volumes:
# Required: Configuration and database storage
# Stores SQLite database, application settings, and SSH keys
- /opt/podman-fail2ban-ui:/config:Z
# Required: Fail2Ban configuration directory
# Needed for managing local Fail2Ban instance
- /etc/fail2ban:/etc/fail2ban:Z
# Required: Fail2Ban socket directory
# Needed for local Fail2Ban control socket access
- /var/run/fail2ban:/var/run/fail2ban
# Optional: System logs (read-only)
# Useful for filter testing and log analysis (or if planned to integrate fal2ban directly in this container)
- /var/log:/var/log:ro
# Optional: GeoIP databases (read-only)
# Enables geographic IP analysis features (GeoIP must be installed and configured on the host)
- /usr/share/GeoIP:/usr/share/GeoIP:ro

View File

@@ -291,3 +291,32 @@ func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, log
}
return resp.Output, nil
}
// GetJailConfig implements Connector.
func (ac *AgentConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
var resp struct {
Config string `json:"config"`
}
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), &resp); err != nil {
return "", err
}
return resp.Config, nil
}
// SetJailConfig implements Connector.
func (ac *AgentConnector) SetJailConfig(ctx context.Context, jail, content string) error {
payload := map[string]string{"config": content}
return ac.put(ctx, fmt.Sprintf("/v1/jails/%s/config", url.PathEscape(jail)), payload, nil)
}
// TestLogpath implements Connector.
func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
payload := map[string]string{"logpath": logpath}
var resp struct {
Files []string `json:"files"`
}
if err := ac.post(ctx, "/v1/jails/test-logpath", payload, &resp); err != nil {
return []string{}, nil // Return empty on error
}
return resp.Files, nil
}

View File

@@ -252,6 +252,21 @@ func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, log
return TestFilterLocal(filterName, logLines)
}
// GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
return GetJailConfig(jail)
}
// SetJailConfig implements Connector.
func (lc *LocalConnector) SetJailConfig(ctx context.Context, jail, content string) error {
return SetJailConfig(jail, content)
}
// TestLogpath implements Connector.
func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
return TestLogpath(logpath)
}
func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {

View File

@@ -318,18 +318,24 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
// GetAllJails implements Connector.
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// Read jail.local and jail.d files remotely
// Read jail.local (DEFAULT only) and jail.d files remotely
var allJails []JailInfo
// Parse jail.local
// Parse jail.local (only DEFAULT section, skip other jails)
jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"})
if err == nil {
// Filter to only include DEFAULT section jails (though DEFAULT itself isn't returned as a jail)
jails := parseJailConfigContent(jailLocalContent)
allJails = append(allJails, jails...)
// Filter out DEFAULT section - we only want actual jails
for _, jail := range jails {
if jail.JailName != "DEFAULT" {
allJails = append(allJails, jail)
}
}
}
// Parse jail.d directory
jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f"
jailDCmd := "find /etc/fail2ban/jail.d -maxdepth 1 -name '*.conf' -type f 2>/dev/null"
jailDList, err := sc.runRemoteCommand(ctx, []string{"sh", "-c", jailDCmd})
if err == nil && jailDList != "" {
for _, file := range strings.Split(jailDList, "\n") {
@@ -350,38 +356,79 @@ func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// UpdateJailEnabledStates implements Connector.
func (sc *SSHConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
// Read current jail.local
content, err := sc.runRemoteCommand(ctx, []string{"cat", "/etc/fail2ban/jail.local"})
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"})
if err != nil {
return fmt.Errorf("failed to read jail.local: %w", err)
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Update enabled states
lines := strings.Split(content, "\n")
var outputLines []string
var currentJail string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentJail = strings.Trim(trimmed, "[]")
outputLines = append(outputLines, line)
} else if strings.HasPrefix(trimmed, "enabled") {
if val, ok := updates[currentJail]; ok {
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", val))
delete(updates, currentJail)
// Update each jail in its own file
for jailName, enabled := range updates {
jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jailName)
// Read existing file if it exists
content, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath})
if err != nil {
// File doesn't exist, create new one
newContent := fmt.Sprintf("[%s]\nenabled = %t\n", jailName, enabled)
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailPath, err)
}
continue
}
// Update enabled state in existing file
lines := strings.Split(content, "\n")
var outputLines []string
var foundEnabled bool
var currentJail string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentJail = strings.Trim(trimmed, "[]")
outputLines = append(outputLines, line)
} else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") {
if currentJail == jailName {
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled))
foundEnabled = true
} else {
outputLines = append(outputLines, line)
}
} else {
outputLines = append(outputLines, line)
}
} else {
outputLines = append(outputLines, line)
}
// If enabled line not found, add it after the jail section header
if !foundEnabled {
var newLines []string
for i, line := range outputLines {
newLines = append(newLines, line)
if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) {
newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled))
if i+1 < len(outputLines) {
newLines = append(newLines, outputLines[i+1:]...)
}
break
}
}
if len(newLines) > len(outputLines) {
outputLines = newLines
} else {
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled))
}
}
// Write updated content
newContent := strings.Join(outputLines, "\n")
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, newContent)
if _, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd}); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailPath, err)
}
}
// Write back
newContent := strings.Join(outputLines, "\n")
cmd := fmt.Sprintf("cat <<'EOF' | tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
return nil
}
// GetFilters implements Connector.
@@ -473,16 +520,94 @@ fail2ban-regex "$TMPFILE" "$FILTER_PATH" || true
return out, nil
}
// GetJailConfig implements Connector.
func (sc *SSHConnector) GetJailConfig(ctx context.Context, jail string) (string, error) {
jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"cat", jailPath})
if err != nil {
// If file doesn't exist, return empty jail section
return fmt.Sprintf("[%s]\n", jail), nil
}
return out, nil
}
// SetJailConfig implements Connector.
func (sc *SSHConnector) SetJailConfig(ctx context.Context, jail, content string) error {
jailPath := fmt.Sprintf("/etc/fail2ban/jail.d/%s.conf", jail)
// Ensure jail.d directory exists
_, err := sc.runRemoteCommand(ctx, []string{"mkdir", "-p", "/etc/fail2ban/jail.d"})
if err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
cmd := fmt.Sprintf("cat <<'EOF' | tee %s >/dev/null\n%s\nEOF", jailPath, content)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
// TestLogpath implements Connector.
func (sc *SSHConnector) TestLogpath(ctx context.Context, logpath string) ([]string, error) {
if logpath == "" {
return []string{}, nil
}
logpath = strings.TrimSpace(logpath)
hasWildcard := strings.ContainsAny(logpath, "*?[")
var script string
if hasWildcard {
// Use find with glob pattern
script = fmt.Sprintf(`
set -e
LOGPATH=%q
# Use find for glob patterns
find $(dirname "$LOGPATH") -maxdepth 1 -path "$LOGPATH" -type f 2>/dev/null | sort
`, logpath)
} else {
// Check if it's a directory or file
script = fmt.Sprintf(`
set -e
LOGPATH=%q
if [ -d "$LOGPATH" ]; then
find "$LOGPATH" -maxdepth 1 -type f 2>/dev/null | sort
elif [ -f "$LOGPATH" ]; then
echo "$LOGPATH"
fi
`, logpath)
}
out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
if err != nil {
return []string{}, nil // Return empty on error
}
var matches []string
for _, line := range strings.Split(out, "\n") {
line = strings.TrimSpace(line)
if line != "" {
matches = append(matches, line)
}
}
return matches, nil
}
// parseJailConfigContent parses jail configuration content and returns JailInfo slice.
func parseJailConfigContent(content string) []JailInfo {
var jails []JailInfo
scanner := bufio.NewScanner(strings.NewReader(content))
var currentJail string
enabled := true
// Sections that should be ignored (not jails)
ignoredSections := map[string]bool{
"DEFAULT": true,
"INCLUDES": true,
}
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if currentJail != "" && currentJail != "DEFAULT" {
if currentJail != "" && !ignoredSections[currentJail] {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
@@ -498,7 +623,7 @@ func parseJailConfigContent(content string) []JailInfo {
}
}
}
if currentJail != "" && currentJail != "DEFAULT" {
if currentJail != "" && !ignoredSections[currentJail] {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,

View File

@@ -6,36 +6,48 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// GetAllJails reads jails from both /etc/fail2ban/jail.local and /etc/fail2ban/jail.d directory.
var (
migrationOnce sync.Once
)
// GetAllJails reads jails from /etc/fail2ban/jail.local (DEFAULT only) and /etc/fail2ban/jail.d directory.
// Automatically migrates legacy jails from jail.local to jail.d on first call.
func GetAllJails() ([]JailInfo, error) {
// Run migration once if needed
migrationOnce.Do(func() {
if err := MigrateJailsToJailD(); err != nil {
config.DebugLog("Migration warning: %v", err)
}
})
var jails []JailInfo
// Parse jails from jail.local
// Parse only DEFAULT section from jail.local (skip other jails)
localPath := "/etc/fail2ban/jail.local"
localJails, err := parseJailConfigFile(localPath)
if err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", localPath, err)
if _, err := os.Stat(localPath); err == nil {
defaultJails, err := parseJailConfigFileOnlyDefault(localPath)
if err == nil {
jails = append(jails, defaultJails...)
}
}
config.DebugLog("############################")
config.DebugLog(fmt.Sprintf("%+v", localJails))
config.DebugLog("############################")
jails = append(jails, localJails...)
// Parse jails from jail.d directory, if it exists
// Parse jails from jail.d directory
jailDPath := "/etc/fail2ban/jail.d"
files, err := os.ReadDir(jailDPath)
if err == nil {
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
jails = append(jails, dJails...)
if _, err := os.Stat(jailDPath); err == nil {
files, err := os.ReadDir(jailDPath)
if err == nil {
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
fullPath := filepath.Join(jailDPath, f.Name())
dJails, err := parseJailConfigFile(fullPath)
if err == nil {
jails = append(jails, dJails...)
}
}
}
}
@@ -56,13 +68,19 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
scanner := bufio.NewScanner(file)
var currentJail string
// Sections that should be ignored (not jails)
ignoredSections := map[string]bool{
"DEFAULT": true,
"INCLUDES": true,
}
// default value is true if "enabled" is missing; we set it for each section.
enabled := true
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
// When a new section starts, save the previous jail if exists.
if currentJail != "" && currentJail != "DEFAULT" {
if currentJail != "" && !ignoredSections[currentJail] {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
@@ -82,7 +100,7 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
}
}
// Add the final jail if one exists.
if currentJail != "" && currentJail != "DEFAULT" {
if currentJail != "" && !ignoredSections[currentJail] {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
@@ -92,24 +110,83 @@ func parseJailConfigFile(path string) ([]JailInfo, error) {
}
// UpdateJailEnabledStates updates the enabled state for each jail based on the provided updates map.
// It updates /etc/fail2ban/jail.local and attempts to update any jail.d files as well.
// Updates only the corresponding file in /etc/fail2ban/jail.d/ for each jail.
func UpdateJailEnabledStates(updates map[string]bool) error {
// Update jail.local file
localPath := "/etc/fail2ban/jail.local"
if err := updateJailConfigFile(localPath, updates); err != nil {
return fmt.Errorf("failed to update %s: %w", localPath, err)
}
// Update jail.d files (if any)
jailDPath := "/etc/fail2ban/jail.d"
files, err := os.ReadDir(jailDPath)
if err == nil {
for _, f := range files {
if !f.IsDir() && filepath.Ext(f.Name()) == ".conf" {
fullPath := filepath.Join(jailDPath, f.Name())
// Ignore error here, as jail.d files might not need to be updated.
_ = updateJailConfigFile(fullPath, updates)
// Ensure jail.d directory exists
if err := os.MkdirAll(jailDPath, 0755); err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Update each jail in its own file
for jailName, enabled := range updates {
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
// Read existing file if it exists
content, err := os.ReadFile(jailFilePath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read jail file %s: %w", jailFilePath, err)
}
var lines []string
if len(content) > 0 {
lines = strings.Split(string(content), "\n")
} else {
// Create new file with jail section
lines = []string{fmt.Sprintf("[%s]", jailName)}
}
// Update or add enabled line
var outputLines []string
var foundEnabled bool
var currentJail string
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentJail = strings.Trim(trimmed, "[]")
outputLines = append(outputLines, line)
} else if strings.HasPrefix(strings.ToLower(trimmed), "enabled") {
if currentJail == jailName {
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled))
foundEnabled = true
} else {
outputLines = append(outputLines, line)
}
} else {
outputLines = append(outputLines, line)
}
}
// If enabled line not found, add it after the jail section header
if !foundEnabled {
var newLines []string
for i, line := range outputLines {
newLines = append(newLines, line)
if strings.TrimSpace(line) == fmt.Sprintf("[%s]", jailName) {
// Insert enabled line after the section header
newLines = append(newLines, fmt.Sprintf("enabled = %t", enabled))
// Add remaining lines
if i+1 < len(outputLines) {
newLines = append(newLines, outputLines[i+1:]...)
}
break
}
}
if len(newLines) > len(outputLines) {
outputLines = newLines
} else {
// Fallback: append at the end
outputLines = append(outputLines, fmt.Sprintf("enabled = %t", enabled))
}
}
// Write updated content
newContent := strings.Join(outputLines, "\n")
if err := os.WriteFile(jailFilePath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
}
}
return nil
}
@@ -148,3 +225,380 @@ func updateJailConfigFile(path string, updates map[string]bool) error {
newContent := strings.Join(outputLines, "\n")
return os.WriteFile(path, []byte(newContent), 0644)
}
// MigrateJailsToJailD migrates all non-DEFAULT jails from jail.local to individual files in jail.d/.
// Creates a backup of jail.local before migration. If a jail already exists in jail.d, jail.local takes precedence.
func MigrateJailsToJailD() error {
localPath := "/etc/fail2ban/jail.local"
jailDPath := "/etc/fail2ban/jail.d"
// Check if jail.local exists
if _, err := os.Stat(localPath); os.IsNotExist(err) {
return nil // Nothing to migrate
}
// Read jail.local content
content, err := os.ReadFile(localPath)
if err != nil {
return fmt.Errorf("failed to read jail.local: %w", err)
}
// Parse content to extract sections
sections, defaultContent, err := parseJailSections(string(content))
if err != nil {
return fmt.Errorf("failed to parse jail.local: %w", err)
}
// If no non-DEFAULT jails found, nothing to migrate
if len(sections) == 0 {
return nil
}
// Create backup of jail.local
backupPath := localPath + ".backup." + fmt.Sprintf("%d", os.Getpid())
if err := os.WriteFile(backupPath, content, 0644); err != nil {
return fmt.Errorf("failed to create backup: %w", err)
}
config.DebugLog("Created backup of jail.local at %s", backupPath)
// Ensure jail.d directory exists
if err := os.MkdirAll(jailDPath, 0755); err != nil {
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
// Write each jail to its own file in jail.d/
for jailName, jailContent := range sections {
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
// Check if file already exists
if _, err := os.Stat(jailFilePath); err == nil {
// File exists - jail.local takes precedence, so overwrite
config.DebugLog("Overwriting existing jail file %s with content from jail.local", jailFilePath)
}
// Write jail content to file
if err := os.WriteFile(jailFilePath, []byte(jailContent), 0644); err != nil {
return fmt.Errorf("failed to write jail file %s: %w", jailFilePath, err)
}
}
// Rewrite jail.local with only DEFAULT section
newLocalContent := defaultContent
if !strings.HasSuffix(newLocalContent, "\n") {
newLocalContent += "\n"
}
if err := os.WriteFile(localPath, []byte(newLocalContent), 0644); err != nil {
return fmt.Errorf("failed to rewrite jail.local: %w", err)
}
config.DebugLog("Migration completed: moved %d jails to jail.d/", len(sections))
return nil
}
// parseJailSections parses jail.local content and returns:
// - map of jail name to jail content (excluding DEFAULT and INCLUDES)
// - DEFAULT section content
func parseJailSections(content string) (map[string]string, string, error) {
sections := make(map[string]string)
var defaultContent strings.Builder
// Sections that should be ignored (not jails)
ignoredSections := map[string]bool{
"DEFAULT": true,
"INCLUDES": true,
}
scanner := bufio.NewScanner(strings.NewReader(content))
var currentSection string
var currentContent strings.Builder
inDefault := false
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
// Save previous section
if currentSection != "" {
sectionContent := strings.TrimSpace(currentContent.String())
if inDefault {
defaultContent.WriteString(sectionContent)
if !strings.HasSuffix(sectionContent, "\n") {
defaultContent.WriteString("\n")
}
} else if !ignoredSections[currentSection] {
// Only save if it's not an ignored section
sections[currentSection] = sectionContent
}
}
// Start new section
currentSection = strings.Trim(trimmed, "[]")
currentContent.Reset()
currentContent.WriteString(line)
currentContent.WriteString("\n")
inDefault = (currentSection == "DEFAULT")
} else {
currentContent.WriteString(line)
currentContent.WriteString("\n")
}
}
// Save final section
if currentSection != "" {
sectionContent := strings.TrimSpace(currentContent.String())
if inDefault {
defaultContent.WriteString(sectionContent)
} else if !ignoredSections[currentSection] {
// Only save if it's not an ignored section
sections[currentSection] = sectionContent
}
}
return sections, defaultContent.String(), scanner.Err()
}
// parseJailConfigFileOnlyDefault parses only the DEFAULT section from a jail config file.
func parseJailConfigFileOnlyDefault(path string) ([]JailInfo, error) {
var jails []JailInfo
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
// We scan through the file but don't return any jails from DEFAULT section
// This function exists to validate the file can be read
for scanner.Scan() {
// Just scan through - we don't need to parse anything for DEFAULT
}
// We don't return DEFAULT as a jail, but we could if needed
// For now, we only return jails from DEFAULT if they're explicitly listed (unlikely)
return jails, scanner.Err()
}
// GetJailConfig reads the full jail configuration from /etc/fail2ban/jail.d/{jailName}.conf
func GetJailConfig(jailName string) (string, error) {
config.DebugLog("GetJailConfig called for jail: %s", jailName)
jailDPath := "/etc/fail2ban/jail.d"
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
config.DebugLog("Reading jail config from: %s", jailFilePath)
content, err := os.ReadFile(jailFilePath)
if err != nil {
if os.IsNotExist(err) {
config.DebugLog("Jail config file does not exist, returning empty section")
// Return empty jail section if file doesn't exist
return fmt.Sprintf("[%s]\n", jailName), nil
}
config.DebugLog("Failed to read jail config file: %v", err)
return "", fmt.Errorf("failed to read jail config for %s: %w", jailName, err)
}
config.DebugLog("Jail config read successfully, length: %d", len(content))
return string(content), nil
}
// SetJailConfig writes the full jail configuration to /etc/fail2ban/jail.d/{jailName}.conf
func SetJailConfig(jailName, content string) error {
config.DebugLog("SetJailConfig called for jail: %s, content length: %d", jailName, len(content))
jailDPath := "/etc/fail2ban/jail.d"
// Ensure jail.d directory exists
if err := os.MkdirAll(jailDPath, 0755); err != nil {
config.DebugLog("Failed to create jail.d directory: %v", err)
return fmt.Errorf("failed to create jail.d directory: %w", err)
}
config.DebugLog("jail.d directory ensured")
// Validate and fix the jail section header
// The content might start with comments, so we need to find the section header
trimmed := strings.TrimSpace(content)
if trimmed == "" {
config.DebugLog("Content is empty, creating minimal jail config")
content = fmt.Sprintf("[%s]\n", jailName)
} else {
expectedSection := fmt.Sprintf("[%s]", jailName)
lines := strings.Split(content, "\n")
sectionFound := false
sectionIndex := -1
var sectionIndices []int
// Find all section headers in the content
for i, line := range lines {
trimmedLine := strings.TrimSpace(line)
if strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]") {
sectionIndices = append(sectionIndices, i)
if trimmedLine == expectedSection {
if !sectionFound {
sectionIndex = i
sectionFound = true
config.DebugLog("Correct section header found at line %d", i)
} else {
config.DebugLog("Duplicate correct section header found at line %d, will remove", i)
}
} else {
config.DebugLog("Incorrect section header found at line %d: %s (expected %s)", i, trimmedLine, expectedSection)
if sectionIndex == -1 {
sectionIndex = i
}
}
}
}
// Remove duplicate section headers (keep only the first correct one)
if len(sectionIndices) > 1 {
config.DebugLog("Found %d section headers, removing duplicates", len(sectionIndices))
var newLines []string
keptFirst := false
for i, line := range lines {
trimmedLine := strings.TrimSpace(line)
isSectionHeader := strings.HasPrefix(trimmedLine, "[") && strings.HasSuffix(trimmedLine, "]")
if isSectionHeader {
if !keptFirst && trimmedLine == expectedSection {
// Keep the first correct section header
newLines = append(newLines, expectedSection)
keptFirst = true
config.DebugLog("Keeping section header at line %d", i)
} else {
// Skip duplicate or incorrect section headers
config.DebugLog("Removing duplicate/incorrect section header at line %d: %s", i, trimmedLine)
continue
}
} else {
newLines = append(newLines, line)
}
}
lines = newLines
}
if !sectionFound {
if sectionIndex >= 0 {
// Replace incorrect section header
config.DebugLog("Replacing incorrect section header at line %d", sectionIndex)
lines[sectionIndex] = expectedSection
} else {
// No section header found, prepend it
config.DebugLog("No section header found, prepending %s", expectedSection)
lines = append([]string{expectedSection}, lines...)
}
content = strings.Join(lines, "\n")
} else {
// Section header is correct, but we may have removed duplicates
content = strings.Join(lines, "\n")
}
}
jailFilePath := filepath.Join(jailDPath, jailName+".conf")
config.DebugLog("Writing jail config to: %s", jailFilePath)
if err := os.WriteFile(jailFilePath, []byte(content), 0644); err != nil {
config.DebugLog("Failed to write jail config: %v", err)
return fmt.Errorf("failed to write jail config for %s: %w", jailName, err)
}
config.DebugLog("Jail config written successfully")
return nil
}
// TestLogpath tests a logpath pattern and returns matching files.
// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths.
func TestLogpath(logpath string) ([]string, error) {
if logpath == "" {
return []string{}, nil
}
// Trim whitespace
logpath = strings.TrimSpace(logpath)
// Check if it's a glob pattern (contains *, ?, or [)
hasWildcard := strings.ContainsAny(logpath, "*?[")
var matches []string
if hasWildcard {
// Use filepath.Glob for pattern matching
matched, err := filepath.Glob(logpath)
if err != nil {
return nil, fmt.Errorf("invalid glob pattern: %w", err)
}
matches = matched
} else {
// Check if it's a directory
info, err := os.Stat(logpath)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil // Path doesn't exist, return empty
}
return nil, fmt.Errorf("failed to stat path: %w", err)
}
if info.IsDir() {
// List files in directory
entries, err := os.ReadDir(logpath)
if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
fullPath := filepath.Join(logpath, entry.Name())
matches = append(matches, fullPath)
}
}
} else {
// It's a file, return it
matches = []string{logpath}
}
}
return matches, nil
}
// parseJailSection extracts the logpath from a jail configuration content.
func parseJailSection(content string, jailName string) (string, error) {
// This function can be used to extract specific settings from jail config
// For now, we'll use it to find logpath
scanner := bufio.NewScanner(strings.NewReader(content))
var inTargetJail bool
var jailContent strings.Builder
for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "[") && strings.HasSuffix(trimmed, "]") {
currentJail := strings.Trim(trimmed, "[]")
if currentJail == jailName {
inTargetJail = true
jailContent.Reset()
jailContent.WriteString(line)
jailContent.WriteString("\n")
} else {
inTargetJail = false
}
} else if inTargetJail {
jailContent.WriteString(line)
jailContent.WriteString("\n")
}
}
return jailContent.String(), scanner.Err()
}
// ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content.
func ExtractLogpathFromJailConfig(jailContent string) string {
scanner := bufio.NewScanner(strings.NewReader(jailContent))
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(strings.ToLower(line), "logpath") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
return strings.TrimSpace(parts[1])
}
}
}
return ""
}

View File

@@ -29,6 +29,11 @@ type Connector interface {
// Filter operations
GetFilters(ctx context.Context) ([]string, error)
TestFilter(ctx context.Context, filterName string, logLines []string) (string, error)
// Jail configuration operations
GetJailConfig(ctx context.Context, jail string) (string, error)
SetJailConfig(ctx context.Context, jail, content string) error
TestLogpath(ctx context.Context, logpath string) ([]string, error)
}
// Manager orchestrates all connectors for configured Fail2ban servers.

View File

@@ -664,58 +664,172 @@ func IndexHandler(c *gin.Context) {
})
}
// GetJailFilterConfigHandler returns the raw filter config for a given jail
// GetJailFilterConfigHandler returns both the filter config and jail config for a given jail
func GetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail")
config.DebugLog("Jail name: %s", jail)
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Failed to resolve connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg, err := conn.GetFilterConfig(c.Request.Context(), jail)
config.DebugLog("Connector resolved: %s", conn.Server().Name)
config.DebugLog("Loading filter config for jail: %s", jail)
filterCfg, err := conn.GetFilterConfig(c.Request.Context(), jail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
config.DebugLog("Failed to load filter config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load filter config: " + err.Error()})
return
}
config.DebugLog("Filter config loaded, length: %d", len(filterCfg))
config.DebugLog("Loading jail config for jail: %s", jail)
jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil {
config.DebugLog("Failed to load jail config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
return
}
config.DebugLog("Jail config loaded, length: %d", len(jailCfg))
c.JSON(http.StatusOK, gin.H{
"jail": jail,
"config": cfg,
"filter": filterCfg,
"jailConfig": jailCfg,
})
}
// SetJailFilterConfigHandler overwrites the current filter config with new content
// SetJailFilterConfigHandler overwrites both the filter config and jail config with new content
func SetJailFilterConfigHandler(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
config.DebugLog("PANIC in SetJailFilterConfigHandler: %v", r)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Internal server error: %v", r)})
}
}()
config.DebugLog("----------------------------")
config.DebugLog("SetJailFilterConfigHandler called (handlers.go)") // entry point
jail := c.Param("jail")
config.DebugLog("Jail name: %s", jail)
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Failed to resolve connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config.DebugLog("Connector resolved: %s (type: %s)", conn.Server().Name, conn.Server().Type)
// Parse JSON body (containing both filter and jail content)
var req struct {
Filter string `json:"filter"`
Jail string `json:"jail"`
}
if err := c.ShouldBindJSON(&req); err != nil {
config.DebugLog("Failed to parse JSON body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body: " + err.Error()})
return
}
config.DebugLog("Request parsed - Filter length: %d, Jail length: %d", len(req.Filter), len(req.Jail))
if len(req.Filter) > 0 {
config.DebugLog("Filter preview (first 100 chars): %s", req.Filter[:min(100, len(req.Filter))])
}
if len(req.Jail) > 0 {
config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))])
}
// Save filter config
if req.Filter != "" {
config.DebugLog("Saving filter config for jail: %s", jail)
if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Filter); err != nil {
config.DebugLog("Failed to save filter config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()})
return
}
config.DebugLog("Filter config saved successfully")
} else {
config.DebugLog("No filter config provided, skipping")
}
// Save jail config
if req.Jail != "" {
config.DebugLog("Saving jail config for jail: %s", jail)
if err := conn.SetJailConfig(c.Request.Context(), jail, req.Jail); err != nil {
config.DebugLog("Failed to save jail config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save jail config: " + err.Error()})
return
}
config.DebugLog("Jail config saved successfully")
} else {
config.DebugLog("No jail config provided, skipping")
}
// Reload fail2ban
config.DebugLog("Reloading fail2ban")
if err := conn.Reload(c.Request.Context()); err != nil {
config.DebugLog("Failed to reload fail2ban: %v", err)
// Still return success but warn about reload failure
// The config was saved successfully, user can manually reload
c.JSON(http.StatusOK, gin.H{
"message": "Config saved successfully, but fail2ban reload failed",
"warning": "Please check the fail2ban configuration and reload manually: " + err.Error(),
})
return
}
config.DebugLog("Fail2ban reloaded successfully")
c.JSON(http.StatusOK, gin.H{"message": "Filter and jail config updated and fail2ban reloaded"})
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// TestLogpathHandler tests a logpath and returns matching files
func TestLogpathHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point
jail := c.Param("jail")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Parse JSON body (containing the new filter content)
var req struct {
Config string `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
// Get jail config to extract logpath
jailCfg, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
return
}
if err := conn.SetFilterConfig(c.Request.Context(), jail, req.Config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// Extract logpath from jail config
logpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg)
if logpath == "" {
c.JSON(http.StatusOK, gin.H{"files": []string{}, "message": "No logpath configured for this jail"})
return
}
if err := conn.Reload(c.Request.Context()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "filter saved but reload failed: " + err.Error()})
// Test the logpath
files, err := conn.TestLogpath(c.Request.Context(), logpath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Filter updated and fail2ban reloaded"})
c.JSON(http.StatusOK, gin.H{
"logpath": logpath,
"files": files,
})
}
// ManageJailsHandler returns a list of all jails (from jail.local and jail.d)

View File

@@ -34,6 +34,7 @@ func RegisterRoutes(r *gin.Engine) {
// Routes for jail-filter management (TODO: rename API-call)
api.GET("/jails/:jail/config", GetJailFilterConfigHandler)
api.POST("/jails/:jail/config", SetJailFilterConfigHandler)
api.POST("/jails/:jail/logpath/test", TestLogpathHandler)
// Routes for jail management
api.GET("/jails/manage", ManageJailsHandler)

View File

@@ -627,39 +627,80 @@
<div class="relative flex min-h-full w-full items-center justify-center p-2 sm:p-4">
<div class="fixed inset-0 bg-gray-500 opacity-75" aria-hidden="true"></div>
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8 max-h-screen overflow-y-auto" style="max-width: 90vw; max-height: calc(100vh - 2rem);">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div class="relative z-10 w-full rounded-lg bg-white text-left shadow-xl transition-all my-4 sm:my-8" style="max-width: 90vw; max-height: calc(100vh - 2rem); display: flex; flex-direction: column;">
<div class="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4" style="flex: 1; overflow-y: auto; min-height: 0;">
<div class="sm:flex sm:items-start">
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left w-full">
<h3 class="text-lg leading-6 font-medium text-gray-900">
<span data-i18n="modal.filter_config">Filter Config:</span> <span id="modalJailName"></span>
</h3>
<div class="mt-4">
<textarea id="jailConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
data-extension-ignore="true"
data-icloud-keychain-ignore="true"
data-safari-autofill="false"
role="textbox"
aria-label="Filter configuration editor"
name="filter-config-editor"
inputmode="text"
style="caret-color: #ffffff; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
wrap="off"
onfocus="preventExtensionInterference(this);"></textarea>
<div class="mt-4 space-y-4">
<!-- Filter Configuration -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.filter_config_label">Filter Configuration</label>
<textarea id="filterConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 h-96 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
data-extension-ignore="true"
data-icloud-keychain-ignore="true"
data-safari-autofill="false"
role="textbox"
aria-label="Filter configuration editor"
name="filter-config-editor"
inputmode="text"
style="caret-color: #ffffff; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
wrap="off"
onfocus="preventExtensionInterference(this);"></textarea>
</div>
<!-- Divider -->
<div class="border-t border-gray-300"></div>
<!-- Jail Configuration -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="modal.jail_config_label">Jail Configuration</label>
<textarea id="jailConfigTextarea"
class="w-full border border-gray-700 rounded-md px-4 py-3 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm bg-gray-900 text-white resize-none overflow-auto"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
data-form-type="other"
data-extension-ignore="true"
data-icloud-keychain-ignore="true"
data-safari-autofill="false"
role="textbox"
aria-label="Jail configuration editor"
name="jail-config-editor"
inputmode="text"
style="height: 300px; caret-color: #ffffff; line-height: 1.5; tab-size: 2; width: 100%; min-width: 100%; max-width: 100%; box-sizing: border-box; -webkit-appearance: none; appearance: none;"
wrap="off"
onfocus="preventExtensionInterference(this);"></textarea>
</div>
<!-- Test Logpath Button (only shown if logpath is set) -->
<div id="testLogpathSection" class="hidden">
<button type="button"
class="inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-green-600 text-sm font-medium text-white hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
onclick="testLogpath()"
data-i18n="modal.test_logpath">Test Logpath</button>
<div id="logpathResults" class="mt-2 p-3 bg-gray-100 rounded-md text-sm font-mono max-h-32 overflow-y-auto hidden"></div>
</div>
</div>
</div>
</div>
</div>
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
<div class="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse" style="flex-shrink: 0;">
<button type="button" class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-blue-600 text-base font-medium text-white hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:ml-3 sm:w-auto sm:text-sm" onclick="saveJailConfig()" data-i18n="modal.save">Save</button>
<button type="button" class="mt-3 w-full inline-flex justify-center rounded-md border border-gray-300 shadow-sm px-4 py-2 bg-white text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm" onclick="closeModal('jailConfigModal')" data-i18n="modal.cancel">Cancel</button>
</div>
@@ -2641,14 +2682,21 @@
function openJailConfigModal(jailName) {
currentJailForConfig = jailName;
var textArea = document.getElementById('jailConfigTextarea');
textArea.value = '';
var filterTextArea = document.getElementById('filterConfigTextarea');
var jailTextArea = document.getElementById('jailConfigTextarea');
filterTextArea.value = '';
jailTextArea.value = '';
// Prevent browser extensions from interfering
preventExtensionInterference(textArea);
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
document.getElementById('modalJailName').textContent = jailName;
// Hide test logpath section initially
document.getElementById('testLogpathSection').classList.add('hidden');
document.getElementById('logpathResults').classList.add('hidden');
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(jailName) + '/config';
fetch(withServerParam(url), {
@@ -2660,13 +2708,23 @@
showToast("Error loading config: " + data.error, 'error');
return;
}
textArea.value = data.config;
filterTextArea.value = data.filter || '';
jailTextArea.value = data.jailConfig || '';
// Check if logpath is set in jail config and show test button
updateLogpathButtonVisibility();
// Add listener to update button visibility when jail config changes
jailTextArea.addEventListener('input', updateLogpathButtonVisibility);
// Prevent extension interference before opening modal
preventExtensionInterference(textArea);
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
openModal('jailConfigModal');
// Call again after a short delay to ensure it's set after modal is visible
setTimeout(function() {
preventExtensionInterference(textArea);
preventExtensionInterference(filterTextArea);
preventExtensionInterference(jailTextArea);
}, 100);
})
.catch(function(err) {
@@ -2677,35 +2735,97 @@
});
}
function updateLogpathButtonVisibility() {
var jailTextArea = document.getElementById('jailConfigTextarea');
var jailConfig = jailTextArea ? jailTextArea.value : '';
var hasLogpath = /logpath\s*=/i.test(jailConfig);
var testSection = document.getElementById('testLogpathSection');
if (hasLogpath && testSection) {
testSection.classList.remove('hidden');
} else if (testSection) {
testSection.classList.add('hidden');
document.getElementById('logpathResults').classList.add('hidden');
}
}
function saveJailConfig() {
if (!currentJailForConfig) return;
showLoading(true);
var newConfig = document.getElementById('jailConfigTextarea').value;
var filterConfig = document.getElementById('filterConfigTextarea').value;
var jailConfig = document.getElementById('jailConfigTextarea').value;
var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/config';
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ config: newConfig }),
body: JSON.stringify({ filter: filterConfig, jail: jailConfig }),
})
.then(function(res) { return res.json(); })
.then(function(res) {
if (!res.ok) {
return res.json().then(function(data) {
throw new Error(data.error || 'Server returned ' + res.status);
});
}
return res.json();
})
.then(function(data) {
if (data.error) {
showToast("Error saving config: " + data.error, 'error');
return;
}
closeModal('jailConfigModal');
showToast(t('filter_debug.save_success', 'Filter saved and reloaded'), 'success');
showToast(t('filter_debug.save_success', 'Filter and jail config saved and reloaded'), 'success');
return refreshData({ silent: true });
})
.catch(function(err) {
showToast("Error: " + err, 'error');
console.error("Error saving config:", err);
showToast("Error saving config: " + err.message, 'error');
})
.finally(function() {
showLoading(false);
});
}
function testLogpath() {
if (!currentJailForConfig) return;
var resultsDiv = document.getElementById('logpathResults');
resultsDiv.textContent = 'Testing logpath...';
resultsDiv.classList.remove('hidden');
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
showLoading(true);
var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/logpath/test';
fetch(withServerParam(url), {
method: 'POST',
headers: serverHeaders({ 'Content-Type': 'application/json' }),
})
.then(function(res) { return res.json(); })
.then(function(data) {
showLoading(false);
if (data.error) {
resultsDiv.textContent = 'Error: ' + data.error;
resultsDiv.classList.add('text-red-600');
return;
}
var files = data.files || [];
if (files.length === 0) {
resultsDiv.textContent = 'No files found for logpath: ' + (data.logpath || 'N/A');
resultsDiv.classList.remove('text-red-600');
resultsDiv.classList.add('text-yellow-600');
} else {
resultsDiv.textContent = 'Found ' + files.length + ' file(s):\n' + files.join('\n');
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
}
})
.catch(function(err) {
showLoading(false);
resultsDiv.textContent = 'Error: ' + err;
resultsDiv.classList.add('text-red-600');
});
}
// Function: openWhoisModal
// Opens the whois modal with data from the event at the given index
function openWhoisModal(eventIndex) {