mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Reimplement Logpath Tester with fail2ban variable resolution and real-path joining
This commit is contained in:
@@ -321,6 +321,46 @@ func (ac *AgentConnector) TestLogpath(ctx context.Context, logpath string) ([]st
|
|||||||
return resp.Files, nil
|
return resp.Files, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogpathWithResolution implements Connector.
|
||||||
|
// Agent server should handle variable resolution.
|
||||||
|
func (ac *AgentConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
|
originalPath = strings.TrimSpace(logpath)
|
||||||
|
if originalPath == "" {
|
||||||
|
return originalPath, "", []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := map[string]string{"logpath": originalPath}
|
||||||
|
var resp struct {
|
||||||
|
OriginalLogpath string `json:"original_logpath"`
|
||||||
|
ResolvedLogpath string `json:"resolved_logpath"`
|
||||||
|
Files []string `json:"files"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try new endpoint first, fallback to old endpoint
|
||||||
|
if err := ac.post(ctx, "/v1/jails/test-logpath-with-resolution", payload, &resp); err != nil {
|
||||||
|
// Fallback: use old endpoint and assume no resolution
|
||||||
|
files, err2 := ac.TestLogpath(ctx, originalPath)
|
||||||
|
if err2 != nil {
|
||||||
|
return originalPath, "", nil, fmt.Errorf("failed to test logpath: %w", err2)
|
||||||
|
}
|
||||||
|
return originalPath, originalPath, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.Error != "" {
|
||||||
|
return originalPath, "", nil, fmt.Errorf("agent error: %s", resp.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.ResolvedLogpath == "" {
|
||||||
|
resp.ResolvedLogpath = resp.OriginalLogpath
|
||||||
|
}
|
||||||
|
if resp.OriginalLogpath == "" {
|
||||||
|
resp.OriginalLogpath = originalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.OriginalLogpath, resp.ResolvedLogpath, resp.Files, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDefaultSettings implements Connector.
|
// UpdateDefaultSettings implements Connector.
|
||||||
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
func (ac *AgentConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||||
// Convert IgnoreIPs array to space-separated string
|
// Convert IgnoreIPs array to space-separated string
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||||
|
|
||||||
// Use parallel execution for better performance
|
// Use parallel execution for better performance
|
||||||
type jailResult struct {
|
type jailResult struct {
|
||||||
jail JailInfo
|
jail JailInfo
|
||||||
@@ -273,6 +273,11 @@ func (lc *LocalConnector) TestLogpath(ctx context.Context, logpath string) ([]st
|
|||||||
return TestLogpath(logpath)
|
return TestLogpath(logpath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogpathWithResolution implements Connector.
|
||||||
|
func (lc *LocalConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
|
return TestLogpathWithResolution(logpath)
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDefaultSettings implements Connector.
|
// UpdateDefaultSettings implements Connector.
|
||||||
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||||
return UpdateDefaultSettingsLocal(settings)
|
return UpdateDefaultSettingsLocal(settings)
|
||||||
|
|||||||
@@ -730,6 +730,150 @@ fi
|
|||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogpathWithResolution implements Connector.
|
||||||
|
// Resolves variables on remote system, then tests the resolved path.
|
||||||
|
func (sc *SSHConnector) TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
|
originalPath = strings.TrimSpace(logpath)
|
||||||
|
if originalPath == "" {
|
||||||
|
return originalPath, "", []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Python script to resolve variables on remote system
|
||||||
|
resolveScript := fmt.Sprintf(`python3 - <<'PYEOF'
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import glob
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
def extract_variables(s):
|
||||||
|
"""Extract all variable names from a string."""
|
||||||
|
pattern = r'%%\(([^)]+)\)s'
|
||||||
|
return re.findall(pattern, s)
|
||||||
|
|
||||||
|
def find_variable_definition(var_name, fail2ban_path="/etc/fail2ban"):
|
||||||
|
"""Search for variable definition in all .conf files."""
|
||||||
|
var_name_lower = var_name.lower()
|
||||||
|
|
||||||
|
for conf_file in Path(fail2ban_path).rglob("*.conf"):
|
||||||
|
try:
|
||||||
|
with open(conf_file, 'r') as f:
|
||||||
|
current_var = None
|
||||||
|
current_value = []
|
||||||
|
in_multiline = False
|
||||||
|
|
||||||
|
for line in f:
|
||||||
|
original_line = line
|
||||||
|
line = line.strip()
|
||||||
|
|
||||||
|
if not in_multiline:
|
||||||
|
if '=' in line and not line.startswith('#'):
|
||||||
|
parts = line.split('=', 1)
|
||||||
|
key = parts[0].strip()
|
||||||
|
value = parts[1].strip()
|
||||||
|
|
||||||
|
if key.lower() == var_name_lower:
|
||||||
|
current_var = key
|
||||||
|
current_value = [value]
|
||||||
|
in_multiline = True
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
# Check if continuation or new variable/section
|
||||||
|
if line.startswith('[') or (not line.startswith(' ') and '=' in line and not line.startswith('\t')):
|
||||||
|
# End of multi-line
|
||||||
|
return ' '.join(current_value)
|
||||||
|
else:
|
||||||
|
# Continuation
|
||||||
|
current_value.append(line)
|
||||||
|
|
||||||
|
if in_multiline and current_var:
|
||||||
|
return ' '.join(current_value)
|
||||||
|
except:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def resolve_variable_recursive(var_name, visited=None):
|
||||||
|
"""Resolve variable recursively."""
|
||||||
|
if visited is None:
|
||||||
|
visited = set()
|
||||||
|
|
||||||
|
if var_name in visited:
|
||||||
|
raise ValueError(f"Circular reference detected for variable '{var_name}'")
|
||||||
|
|
||||||
|
visited.add(var_name)
|
||||||
|
|
||||||
|
try:
|
||||||
|
value = find_variable_definition(var_name)
|
||||||
|
if value is None:
|
||||||
|
raise ValueError(f"Variable '{var_name}' not found")
|
||||||
|
|
||||||
|
# Check for nested variables
|
||||||
|
nested_vars = extract_variables(value)
|
||||||
|
if not nested_vars:
|
||||||
|
return value
|
||||||
|
|
||||||
|
# Resolve nested variables
|
||||||
|
resolved = value
|
||||||
|
for nested_var in nested_vars:
|
||||||
|
nested_value = resolve_variable_recursive(nested_var, visited.copy())
|
||||||
|
pattern = f'%%({re.escape(nested_var)})s'
|
||||||
|
resolved = re.sub(pattern, nested_value, resolved)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
finally:
|
||||||
|
visited.discard(var_name)
|
||||||
|
|
||||||
|
def resolve_logpath(logpath):
|
||||||
|
"""Resolve all variables in logpath."""
|
||||||
|
variables = extract_variables(logpath)
|
||||||
|
if not variables:
|
||||||
|
return logpath
|
||||||
|
|
||||||
|
resolved = logpath
|
||||||
|
for var_name in variables:
|
||||||
|
var_value = resolve_variable_recursive(var_name)
|
||||||
|
pattern = f'%%({re.escape(var_name)})s'
|
||||||
|
resolved = re.sub(pattern, var_value, resolved)
|
||||||
|
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
# Main
|
||||||
|
logpath = %q
|
||||||
|
try:
|
||||||
|
resolved = resolve_logpath(logpath)
|
||||||
|
print(f"RESOLVED:{resolved}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR:{str(e)}")
|
||||||
|
exit(1)
|
||||||
|
PYEOF
|
||||||
|
`, originalPath)
|
||||||
|
|
||||||
|
// Run resolution script
|
||||||
|
resolveOut, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", resolveScript})
|
||||||
|
if err != nil {
|
||||||
|
return originalPath, "", nil, fmt.Errorf("failed to resolve variables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveOut = strings.TrimSpace(resolveOut)
|
||||||
|
if strings.HasPrefix(resolveOut, "ERROR:") {
|
||||||
|
return originalPath, "", nil, fmt.Errorf(strings.TrimPrefix(resolveOut, "ERROR:"))
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(resolveOut, "RESOLVED:") {
|
||||||
|
resolvedPath = strings.TrimPrefix(resolveOut, "RESOLVED:")
|
||||||
|
} else {
|
||||||
|
// Fallback: use original if resolution failed
|
||||||
|
resolvedPath = originalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the resolved path
|
||||||
|
files, err = sc.TestLogpath(ctx, resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPath, resolvedPath, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateDefaultSettings implements Connector.
|
// UpdateDefaultSettings implements Connector.
|
||||||
func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
func (sc *SSHConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
|
||||||
jailLocalPath := "/etc/fail2ban/jail.local"
|
jailLocalPath := "/etc/fail2ban/jail.local"
|
||||||
|
|||||||
@@ -623,6 +623,7 @@ func SetJailConfig(jailName, content string) error {
|
|||||||
|
|
||||||
// TestLogpath tests a logpath pattern and returns matching files.
|
// TestLogpath tests a logpath pattern and returns matching files.
|
||||||
// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths.
|
// Supports wildcards/glob patterns (e.g., /var/log/*.log) and directory paths.
|
||||||
|
// This function tests the path as-is without variable resolution.
|
||||||
func TestLogpath(logpath string) ([]string, error) {
|
func TestLogpath(logpath string) ([]string, error) {
|
||||||
if logpath == "" {
|
if logpath == "" {
|
||||||
return []string{}, nil
|
return []string{}, nil
|
||||||
@@ -674,6 +675,34 @@ func TestLogpath(logpath string) ([]string, error) {
|
|||||||
return matches, nil
|
return matches, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestLogpathWithResolution resolves variables in logpath and tests the resolved path.
|
||||||
|
// Returns the original path, resolved path, matching files, and any error.
|
||||||
|
func TestLogpathWithResolution(logpath string) (originalPath, resolvedPath string, files []string, err error) {
|
||||||
|
originalPath = strings.TrimSpace(logpath)
|
||||||
|
if originalPath == "" {
|
||||||
|
return originalPath, "", []string{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve variables
|
||||||
|
resolvedPath, err = ResolveLogpathVariables(originalPath)
|
||||||
|
if err != nil {
|
||||||
|
return originalPath, "", nil, fmt.Errorf("failed to resolve logpath variables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If resolution didn't change the path, resolvedPath will be the same
|
||||||
|
if resolvedPath == "" {
|
||||||
|
resolvedPath = originalPath
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test the resolved path
|
||||||
|
files, err = TestLogpath(resolvedPath)
|
||||||
|
if err != nil {
|
||||||
|
return originalPath, resolvedPath, nil, fmt.Errorf("failed to test logpath: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalPath, resolvedPath, files, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content.
|
// ExtractLogpathFromJailConfig extracts the logpath value from jail configuration content.
|
||||||
func ExtractLogpathFromJailConfig(jailContent string) string {
|
func ExtractLogpathFromJailConfig(jailContent string) string {
|
||||||
scanner := bufio.NewScanner(strings.NewReader(jailContent))
|
scanner := bufio.NewScanner(strings.NewReader(jailContent))
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ type Connector interface {
|
|||||||
GetJailConfig(ctx context.Context, jail string) (string, error)
|
GetJailConfig(ctx context.Context, jail string) (string, error)
|
||||||
SetJailConfig(ctx context.Context, jail, content string) error
|
SetJailConfig(ctx context.Context, jail, content string) error
|
||||||
TestLogpath(ctx context.Context, logpath string) ([]string, error)
|
TestLogpath(ctx context.Context, logpath string) ([]string, error)
|
||||||
|
TestLogpathWithResolution(ctx context.Context, logpath string) (originalPath, resolvedPath string, files []string, err error)
|
||||||
|
|
||||||
// Default settings operations
|
// Default settings operations
|
||||||
UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error
|
UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error
|
||||||
|
|||||||
385
internal/fail2ban/variable_resolver.go
Normal file
385
internal/fail2ban/variable_resolver.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||||||
|
//
|
||||||
|
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
|
||||||
|
//
|
||||||
|
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||||||
|
// You may not use this file except in compliance with the License.
|
||||||
|
// You may obtain a copy of the License at
|
||||||
|
//
|
||||||
|
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||||||
|
//
|
||||||
|
// Unless required by applicable law or agreed to in writing, software
|
||||||
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
// See the License for the specific language governing permissions and
|
||||||
|
// limitations under the License.
|
||||||
|
|
||||||
|
package fail2ban
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Variable pattern: %(variable_name)s
|
||||||
|
variablePattern = regexp.MustCompile(`%\(([^)]+)\)s`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// extractVariablesFromString extracts all variable names from a string.
|
||||||
|
// Returns a list of variable names found in the pattern %(name)s.
|
||||||
|
func extractVariablesFromString(s string) []string {
|
||||||
|
matches := variablePattern.FindAllStringSubmatch(s, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var variables []string
|
||||||
|
for _, match := range matches {
|
||||||
|
if len(match) > 1 {
|
||||||
|
variables = append(variables, match[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return variables
|
||||||
|
}
|
||||||
|
|
||||||
|
// searchVariableInFile searches for a variable definition in a single file.
|
||||||
|
// Returns the value if found, empty string if not found, and error on file read error.
|
||||||
|
func searchVariableInFile(filePath, varName string) (string, error) {
|
||||||
|
file, err := os.Open(filePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
var currentVar string
|
||||||
|
var currentValue strings.Builder
|
||||||
|
var inMultiLine bool
|
||||||
|
var pendingLine string
|
||||||
|
var pendingLineOriginal string
|
||||||
|
|
||||||
|
for {
|
||||||
|
var originalLine string
|
||||||
|
var line string
|
||||||
|
|
||||||
|
if pendingLine != "" {
|
||||||
|
originalLine = pendingLineOriginal
|
||||||
|
line = pendingLine
|
||||||
|
pendingLine = ""
|
||||||
|
pendingLineOriginal = ""
|
||||||
|
} else {
|
||||||
|
if !scanner.Scan() {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
originalLine = scanner.Text()
|
||||||
|
line = strings.TrimSpace(originalLine)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inMultiLine && (strings.HasPrefix(line, "#") || line == "") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if !inMultiLine {
|
||||||
|
parts := strings.SplitN(line, "=", 2)
|
||||||
|
if len(parts) == 2 {
|
||||||
|
key := strings.TrimSpace(parts[0])
|
||||||
|
value := strings.TrimSpace(parts[1])
|
||||||
|
|
||||||
|
if strings.EqualFold(key, varName) {
|
||||||
|
config.DebugLog("findVariableDefinition: found variable '%s' = '%s' in file %s", key, value, filePath)
|
||||||
|
currentVar = key
|
||||||
|
currentValue.WriteString(value)
|
||||||
|
|
||||||
|
if scanner.Scan() {
|
||||||
|
nextLineOriginal := scanner.Text()
|
||||||
|
nextLineTrimmed := strings.TrimSpace(nextLineOriginal)
|
||||||
|
|
||||||
|
isContinuation := nextLineTrimmed != "" &&
|
||||||
|
!strings.HasPrefix(nextLineTrimmed, "#") &&
|
||||||
|
!strings.HasPrefix(nextLineTrimmed, "[") &&
|
||||||
|
(strings.HasPrefix(nextLineOriginal, " ") || strings.HasPrefix(nextLineOriginal, "\t") ||
|
||||||
|
(!strings.Contains(nextLineTrimmed, "=")))
|
||||||
|
|
||||||
|
if isContinuation {
|
||||||
|
inMultiLine = true
|
||||||
|
pendingLine = nextLineTrimmed
|
||||||
|
pendingLineOriginal = nextLineOriginal
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
trimmedLine := strings.TrimSpace(originalLine)
|
||||||
|
|
||||||
|
if strings.HasPrefix(trimmedLine, "[") {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(trimmedLine, "=") && !strings.HasPrefix(originalLine, " ") && !strings.HasPrefix(originalLine, "\t") {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if currentValue.Len() > 0 {
|
||||||
|
currentValue.WriteString(" ")
|
||||||
|
}
|
||||||
|
currentValue.WriteString(trimmedLine)
|
||||||
|
|
||||||
|
if scanner.Scan() {
|
||||||
|
nextLineOriginal := scanner.Text()
|
||||||
|
nextLineTrimmed := strings.TrimSpace(nextLineOriginal)
|
||||||
|
|
||||||
|
if nextLineTrimmed == "" ||
|
||||||
|
strings.HasPrefix(nextLineTrimmed, "#") ||
|
||||||
|
strings.HasPrefix(nextLineTrimmed, "[") ||
|
||||||
|
(strings.Contains(nextLineTrimmed, "=") && !strings.HasPrefix(nextLineOriginal, " ") && !strings.HasPrefix(nextLineOriginal, "\t")) {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
pendingLine = nextLineTrimmed
|
||||||
|
pendingLineOriginal = nextLineOriginal
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if inMultiLine && currentVar != "" {
|
||||||
|
return strings.TrimSpace(currentValue.String()), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findVariableDefinition searches for a variable definition in all .local files first,
|
||||||
|
// then .conf files under /etc/fail2ban/ and subdirectories.
|
||||||
|
// Returns the FIRST value found (prioritizing .local over .conf).
|
||||||
|
func findVariableDefinition(varName string) (string, error) {
|
||||||
|
fail2banPath := "/etc/fail2ban"
|
||||||
|
|
||||||
|
config.DebugLog("findVariableDefinition: searching for variable '%s'", varName)
|
||||||
|
|
||||||
|
if _, err := os.Stat(fail2banPath); os.IsNotExist(err) {
|
||||||
|
return "", fmt.Errorf("variable '%s' not found: /etc/fail2ban directory does not exist", varName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First pass: search .local files (higher priority)
|
||||||
|
var foundValue string
|
||||||
|
err := filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".local") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := searchVariableInFile(path, varName)
|
||||||
|
if err != nil {
|
||||||
|
return nil // Skip files we can't read
|
||||||
|
}
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
foundValue = value
|
||||||
|
return filepath.SkipAll // Stop walking when found
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if foundValue != "" {
|
||||||
|
config.DebugLog("findVariableDefinition: returning value '%s' for variable '%s' (from .local file)", foundValue, varName)
|
||||||
|
return foundValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && err != filepath.SkipAll {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: search .conf files (only if not found in .local)
|
||||||
|
config.DebugLog("findVariableDefinition: variable '%s' not found in .local files, searching .conf files", varName)
|
||||||
|
err = filepath.Walk(fail2banPath, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() || !strings.HasSuffix(strings.ToLower(path), ".conf") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := searchVariableInFile(path, varName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if value != "" {
|
||||||
|
foundValue = value
|
||||||
|
return filepath.SkipAll // Stop walking when found
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if foundValue != "" {
|
||||||
|
config.DebugLog("findVariableDefinition: returning value '%s' for variable '%s' (from .conf file)", foundValue, varName)
|
||||||
|
return foundValue, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil && err != filepath.SkipAll {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("findVariableDefinition: variable '%s' not found", varName)
|
||||||
|
return "", fmt.Errorf("variable '%s' not found in Fail2Ban configuration files", varName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveVariableRecursive resolves a variable recursively, handling nested variables.
|
||||||
|
// visited map tracks visited variables to detect circular references.
|
||||||
|
// This function fully resolves all nested variables until no variables remain.
|
||||||
|
func resolveVariableRecursive(varName string, visited map[string]bool) (string, error) {
|
||||||
|
if visited[varName] {
|
||||||
|
return "", fmt.Errorf("circular reference detected for variable '%s'", varName)
|
||||||
|
}
|
||||||
|
|
||||||
|
visited[varName] = true
|
||||||
|
defer delete(visited, varName)
|
||||||
|
|
||||||
|
value, err := findVariableDefinition(varName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep resolving until no more variables are found
|
||||||
|
resolved := value
|
||||||
|
maxIterations := 10
|
||||||
|
iteration := 0
|
||||||
|
|
||||||
|
for iteration < maxIterations {
|
||||||
|
variables := extractVariablesFromString(resolved)
|
||||||
|
if len(variables) == 0 {
|
||||||
|
// No more variables, fully resolved
|
||||||
|
config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s'", varName, resolved)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("resolveVariableRecursive: iteration %d for '%s', found %d variables in '%s': %v", iteration+1, varName, len(variables), resolved, variables)
|
||||||
|
|
||||||
|
// Resolve all nested variables
|
||||||
|
for _, nestedVar := range variables {
|
||||||
|
// Check for circular reference
|
||||||
|
if visited[nestedVar] {
|
||||||
|
return "", fmt.Errorf("circular reference detected: '%s' -> '%s'", varName, nestedVar)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("resolveVariableRecursive: resolving nested variable '%s' for '%s'", nestedVar, varName)
|
||||||
|
nestedValue, err := resolveVariableRecursive(nestedVar, visited)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to resolve variable '%s' in '%s': %w", nestedVar, varName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("resolveVariableRecursive: resolved '%s' to '%s' for '%s'", nestedVar, nestedValue, varName)
|
||||||
|
|
||||||
|
// Replace ALL occurrences of the nested variable
|
||||||
|
// Pattern: %(varName)s - need to escape parentheses for regex
|
||||||
|
// The pattern %(varName)s needs to be escaped as %\(varName\)s in regex
|
||||||
|
pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(nestedVar))
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
beforeReplace := resolved
|
||||||
|
resolved = re.ReplaceAllString(resolved, nestedValue)
|
||||||
|
config.DebugLog("resolveVariableRecursive: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, nestedValue, resolved)
|
||||||
|
|
||||||
|
// Verify the replacement actually happened
|
||||||
|
if beforeReplace == resolved {
|
||||||
|
config.DebugLog("resolveVariableRecursive: WARNING - replacement did not change string! Pattern: '%s', Before: '%s', After: '%s'", pattern, beforeReplace, resolved)
|
||||||
|
// If replacement didn't work, this is a critical error
|
||||||
|
return "", fmt.Errorf("failed to replace variable '%s' in '%s': pattern '%s' did not match", nestedVar, beforeReplace, pattern)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// After replacing all variables in this iteration, check if we're done
|
||||||
|
// Verify no variables remain before continuing
|
||||||
|
remainingVars := extractVariablesFromString(resolved)
|
||||||
|
if len(remainingVars) == 0 {
|
||||||
|
// No more variables, fully resolved
|
||||||
|
config.DebugLog("resolveVariableRecursive: '%s' fully resolved to '%s' after replacements", varName, resolved)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we still have variables after replacement, continue to next iteration
|
||||||
|
// But check if we made progress (resolved should be different from before)
|
||||||
|
iteration++
|
||||||
|
}
|
||||||
|
|
||||||
|
if iteration >= maxIterations {
|
||||||
|
return "", fmt.Errorf("maximum resolution iterations reached for variable '%s', possible circular reference. Last resolved value: '%s'", varName, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolveLogpathVariables resolves all variables in a logpath string.
|
||||||
|
// Returns the fully resolved path. If no variables are present, returns the original path.
|
||||||
|
// Keeps resolving until no more variables are found (handles nested variables).
|
||||||
|
func ResolveLogpathVariables(logpath string) (string, error) {
|
||||||
|
if logpath == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logpath = strings.TrimSpace(logpath)
|
||||||
|
|
||||||
|
// Keep resolving until no more variables are found
|
||||||
|
resolved := logpath
|
||||||
|
maxIterations := 10 // Prevent infinite loops
|
||||||
|
iteration := 0
|
||||||
|
|
||||||
|
for iteration < maxIterations {
|
||||||
|
variables := extractVariablesFromString(resolved)
|
||||||
|
if len(variables) == 0 {
|
||||||
|
// No more variables, we're done
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("ResolveLogpathVariables: iteration %d, found %d variables in '%s'", iteration+1, len(variables), resolved)
|
||||||
|
|
||||||
|
// Resolve all variables found in the current string
|
||||||
|
visited := make(map[string]bool)
|
||||||
|
for _, varName := range variables {
|
||||||
|
config.DebugLog("ResolveLogpathVariables: resolving variable '%s' from string '%s'", varName, resolved)
|
||||||
|
varValue, err := resolveVariableRecursive(varName, visited)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to resolve variable '%s': %w", varName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("ResolveLogpathVariables: resolved variable '%s' to '%s'", varName, varValue)
|
||||||
|
|
||||||
|
// Replace ALL occurrences of the variable in the resolved string
|
||||||
|
// Pattern: %(varName)s - need to escape parentheses for regex
|
||||||
|
// The pattern %(varName)s needs to be escaped as %\(varName\)s in regex
|
||||||
|
pattern := fmt.Sprintf("%%\\(%s\\)s", regexp.QuoteMeta(varName))
|
||||||
|
re := regexp.MustCompile(pattern)
|
||||||
|
beforeReplace := resolved
|
||||||
|
resolved = re.ReplaceAllString(resolved, varValue)
|
||||||
|
config.DebugLog("ResolveLogpathVariables: replaced pattern '%s' in '%s' with '%s', result: '%s'", pattern, beforeReplace, varValue, resolved)
|
||||||
|
}
|
||||||
|
|
||||||
|
iteration++
|
||||||
|
}
|
||||||
|
|
||||||
|
if iteration >= maxIterations {
|
||||||
|
return "", fmt.Errorf("maximum resolution iterations reached, possible circular reference in logpath '%s'", logpath)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.DebugLog("Resolved logpath: '%s' -> '%s'", logpath, resolved)
|
||||||
|
return resolved, nil
|
||||||
|
}
|
||||||
@@ -860,6 +860,7 @@ func equalStringSlices(a, b []string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TestLogpathHandler tests a logpath and returns matching files
|
// TestLogpathHandler tests a logpath and returns matching files
|
||||||
|
// Resolves Fail2Ban variables before testing
|
||||||
func TestLogpathHandler(c *gin.Context) {
|
func TestLogpathHandler(c *gin.Context) {
|
||||||
config.DebugLog("----------------------------")
|
config.DebugLog("----------------------------")
|
||||||
config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point
|
config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point
|
||||||
@@ -878,22 +879,28 @@ func TestLogpathHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract logpath from jail config
|
// Extract logpath from jail config
|
||||||
logpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg)
|
originalLogpath := fail2ban.ExtractLogpathFromJailConfig(jailCfg)
|
||||||
if logpath == "" {
|
if originalLogpath == "" {
|
||||||
c.JSON(http.StatusOK, gin.H{"files": []string{}, "message": "No logpath configured for this jail"})
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"original_logpath": "",
|
||||||
|
"resolved_logpath": "",
|
||||||
|
"files": []string{},
|
||||||
|
"message": "No logpath configured for this jail",
|
||||||
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test the logpath
|
// Test the logpath with variable resolution
|
||||||
files, err := conn.TestLogpath(c.Request.Context(), logpath)
|
originalPath, resolvedPath, files, err := conn.TestLogpathWithResolution(c.Request.Context(), originalLogpath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"logpath": logpath,
|
"original_logpath": originalPath,
|
||||||
"files": files,
|
"resolved_logpath": resolvedPath,
|
||||||
|
"files": files,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,23 +152,54 @@ function testLogpath() {
|
|||||||
if (data.error) {
|
if (data.error) {
|
||||||
resultsDiv.textContent = 'Error: ' + data.error;
|
resultsDiv.textContent = 'Error: ' + data.error;
|
||||||
resultsDiv.classList.add('text-red-600');
|
resultsDiv.classList.add('text-red-600');
|
||||||
|
// Auto-scroll to results
|
||||||
|
setTimeout(function() {
|
||||||
|
resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var originalLogpath = data.original_logpath || '';
|
||||||
|
var resolvedLogpath = data.resolved_logpath || '';
|
||||||
var files = data.files || [];
|
var files = data.files || [];
|
||||||
|
|
||||||
|
// Build output message
|
||||||
|
var output = '';
|
||||||
|
|
||||||
|
// Show resolved logpath if different from original
|
||||||
|
if (resolvedLogpath && resolvedLogpath !== originalLogpath) {
|
||||||
|
output += 'Resolved logpath: ' + resolvedLogpath + '\n\n';
|
||||||
|
} else if (resolvedLogpath) {
|
||||||
|
output += 'Logpath: ' + resolvedLogpath + '\n\n';
|
||||||
|
} else if (originalLogpath) {
|
||||||
|
output += 'Logpath: ' + originalLogpath + '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show files found
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
resultsDiv.textContent = 'No files found for logpath: ' + (data.logpath || 'N/A');
|
output += 'No files found matching the logpath pattern.';
|
||||||
resultsDiv.classList.remove('text-red-600');
|
resultsDiv.classList.remove('text-red-600');
|
||||||
resultsDiv.classList.add('text-yellow-600');
|
resultsDiv.classList.add('text-yellow-600');
|
||||||
} else {
|
} else {
|
||||||
resultsDiv.textContent = 'Found ' + files.length + ' file(s):\n' + files.join('\n');
|
output += 'Found ' + files.length + ' file(s):\n' + files.join('\n');
|
||||||
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
|
resultsDiv.classList.remove('text-red-600', 'text-yellow-600');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resultsDiv.textContent = output;
|
||||||
|
|
||||||
|
// Auto-scroll to results
|
||||||
|
setTimeout(function() {
|
||||||
|
resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
showLoading(false);
|
showLoading(false);
|
||||||
resultsDiv.textContent = 'Error: ' + err;
|
resultsDiv.textContent = 'Error: ' + err;
|
||||||
resultsDiv.classList.add('text-red-600');
|
resultsDiv.classList.add('text-red-600');
|
||||||
|
// Auto-scroll to results
|
||||||
|
setTimeout(function() {
|
||||||
|
resultsDiv.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user