Fix the manage jails functions over ssh, also improve the speed or remote connections

This commit is contained in:
2025-11-12 16:25:16 +01:00
parent 9c3713bb41
commit 3b118cb616
6 changed files with 445 additions and 78 deletions

View File

@@ -249,3 +249,45 @@ func (ac *AgentConnector) do(req *http.Request, out any) error {
}
return json.Unmarshal(data, out)
}
// GetAllJails implements Connector.
func (ac *AgentConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
var resp struct {
Jails []JailInfo `json:"jails"`
}
if err := ac.get(ctx, "/v1/jails/all", &resp); err != nil {
return nil, err
}
return resp.Jails, nil
}
// UpdateJailEnabledStates implements Connector.
func (ac *AgentConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return ac.post(ctx, "/v1/jails/update-enabled", updates, nil)
}
// GetFilters implements Connector.
func (ac *AgentConnector) GetFilters(ctx context.Context) ([]string, error) {
var resp struct {
Filters []string `json:"filters"`
}
if err := ac.get(ctx, "/v1/filters", &resp); err != nil {
return nil, err
}
return resp.Filters, nil
}
// TestFilter implements Connector.
func (ac *AgentConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
payload := map[string]any{
"filterName": filterName,
"logLines": logLines,
}
var resp struct {
Matches []string `json:"matches"`
}
if err := ac.post(ctx, "/v1/filters/test", payload, &resp); err != nil {
return nil, err
}
return resp.Matches, nil
}

View File

@@ -8,6 +8,7 @@ import (
"os/exec"
"sort"
"strings"
"sync"
"time"
"github.com/swissmakers/fail2ban-ui/internal/config"
@@ -51,37 +52,61 @@ func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error)
}
oneHourAgo := time.Now().Add(-1 * time.Hour)
var results []JailInfo
for _, jail := range jails {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
// Use parallel execution for better performance
type jailResult struct {
jail JailInfo
err error
}
results := make(chan jailResult, len(jails))
var wg sync.WaitGroup
bannedIPs, err := lc.GetBannedIPs(ctx, jail)
if err != nil {
continue
}
newInLastHour := 0
if events, ok := banHistory[jail]; ok {
for _, e := range events {
if e.Time.After(oneHourAgo) {
newInLastHour++
for _, jail := range jails {
wg.Add(1)
go func(j string) {
defer wg.Done()
bannedIPs, err := lc.GetBannedIPs(ctx, j)
if err != nil {
results <- jailResult{err: err}
return
}
newInLastHour := 0
if events, ok := banHistory[j]; ok {
for _, e := range events {
if e.Time.After(oneHourAgo) {
newInLastHour++
}
}
}
}
results = append(results, JailInfo{
JailName: jail,
TotalBanned: len(bannedIPs),
NewInLastHour: newInLastHour,
BannedIPs: bannedIPs,
Enabled: true,
})
results <- jailResult{
jail: JailInfo{
JailName: j,
TotalBanned: len(bannedIPs),
NewInLastHour: newInLastHour,
BannedIPs: bannedIPs,
Enabled: true,
},
}
}(jail)
}
return results, nil
go func() {
wg.Wait()
close(results)
}()
var finalResults []JailInfo
for result := range results {
if result.err != nil {
continue
}
finalResults = append(finalResults, result.jail)
}
sort.SliceStable(finalResults, func(i, j int) bool {
return finalResults[i].JailName < finalResults[j].JailName
})
return finalResults, nil
}
// GetBannedIPs implements Connector.
@@ -207,6 +232,26 @@ func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
return append(base, args...)
}
// GetAllJails implements Connector.
func (lc *LocalConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
return GetAllJails()
}
// UpdateJailEnabledStates implements Connector.
func (lc *LocalConnector) UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error {
return UpdateJailEnabledStates(updates)
}
// GetFilters implements Connector.
func (lc *LocalConnector) GetFilters(ctx context.Context) ([]string, error) {
return GetFiltersLocal()
}
// TestFilter implements Connector.
func (lc *LocalConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
return TestFilterLocal(filterName, logLines)
}
func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {

View File

@@ -1,6 +1,7 @@
package fail2ban
import (
"bufio"
"context"
"encoding/base64"
"fmt"
@@ -8,11 +9,12 @@ import (
"sort"
"strconv"
"strings"
"sync"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
const sshEnsureActionScript = `sudo python3 - <<'PY'
const sshEnsureActionScript = `python3 - <<'PY'
import base64
import pathlib
@@ -90,24 +92,46 @@ func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
return nil, err
}
var infos []JailInfo
// Use parallel execution for better performance
type jailResult struct {
jail JailInfo
err error
}
results := make(chan jailResult, len(jails))
var wg sync.WaitGroup
for _, jail := range jails {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
ips, err := sc.GetBannedIPs(ctx, jail)
if err != nil {
wg.Add(1)
go func(j string) {
defer wg.Done()
ips, err := sc.GetBannedIPs(ctx, j)
if err != nil {
results <- jailResult{err: err}
return
}
results <- jailResult{
jail: JailInfo{
JailName: j,
TotalBanned: len(ips),
NewInLastHour: 0,
BannedIPs: ips,
Enabled: true,
},
}
}(jail)
}
go func() {
wg.Wait()
close(results)
}()
var infos []JailInfo
for result := range results {
if result.err != nil {
continue
}
infos = append(infos, JailInfo{
JailName: jail,
TotalBanned: len(ips),
NewInLastHour: 0,
BannedIPs: ips,
Enabled: true,
})
infos = append(infos, result.jail)
}
sort.SliceStable(infos, func(i, j int) bool {
@@ -177,7 +201,10 @@ func (sc *SSHConnector) ensureAction(ctx context.Context) error {
actionConfig := config.BuildFail2banActionConfig(callbackURL)
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
// Base64 encode the entire script to avoid shell escaping issues
scriptB64 := base64.StdEncoding.EncodeToString([]byte(script))
cmd := fmt.Sprintf("echo %s | base64 -d | sudo bash", scriptB64)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
@@ -254,3 +281,176 @@ func (sc *SSHConnector) buildSSHArgs(command []string) []string {
args = append(args, command...)
return args
}
// GetAllJails implements Connector.
func (sc *SSHConnector) GetAllJails(ctx context.Context) ([]JailInfo, error) {
// Read jail.local and jail.d files remotely
var allJails []JailInfo
// Parse jail.local
jailLocalContent, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", "/etc/fail2ban/jail.local"})
if err == nil {
jails := parseJailConfigContent(jailLocalContent)
allJails = append(allJails, jails...)
}
// Parse jail.d directory
jailDList, err := sc.runRemoteCommand(ctx, []string{"sudo", "bash", "-c", "for f in /etc/fail2ban/jail.d/*.conf; do [ -f \"$f\" ] && echo \"$f\"; done"})
if err == nil && jailDList != "" {
for _, file := range strings.Split(jailDList, "\n") {
file = strings.TrimSpace(file)
if file == "" {
continue
}
content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", file})
if err == nil {
jails := parseJailConfigContent(content)
allJails = append(allJails, jails...)
}
}
}
return allJails, nil
}
// 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{"sudo", "cat", "/etc/fail2ban/jail.local"})
if err != nil {
return fmt.Errorf("failed to read jail.local: %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)
} else {
outputLines = append(outputLines, line)
}
} else {
outputLines = append(outputLines, line)
}
}
// Write back
newContent := strings.Join(outputLines, "\n")
cmd := fmt.Sprintf("cat <<'EOF' | sudo tee /etc/fail2ban/jail.local >/dev/null\n%s\nEOF", newContent)
_, err = sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
// GetFilters implements Connector.
func (sc *SSHConnector) GetFilters(ctx context.Context) ([]string, error) {
list, err := sc.runRemoteCommand(ctx, []string{"sudo", "bash", "-c", "for f in /etc/fail2ban/filter.d/*.conf; do [ -f \"$f\" ] && basename \"$f\" .conf; done"})
if err != nil {
return nil, fmt.Errorf("failed to list filters: %w", err)
}
var filters []string
for _, line := range strings.Split(list, "\n") {
line = strings.TrimSpace(line)
if line != "" {
filters = append(filters, line)
}
}
return filters, nil
}
// TestFilter implements Connector.
func (sc *SSHConnector) TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error) {
if len(logLines) == 0 {
return []string{}, nil
}
// Read filter config remotely
filterPath := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", filterName)
content, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", filterPath})
if err != nil {
return nil, fmt.Errorf("filter %s not found: %w", filterName, err)
}
// Extract failregex
var failregex string
scanner := bufio.NewScanner(strings.NewReader(content))
inFailregex := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[Definition]") {
inFailregex = true
continue
}
if inFailregex && strings.HasPrefix(line, "failregex") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
failregex = strings.TrimSpace(parts[1])
}
break
}
if inFailregex && strings.HasPrefix(line, "[") {
break
}
}
if failregex == "" {
return nil, fmt.Errorf("no failregex found in filter %s", filterName)
}
// Test each log line remotely
var matches []string
for _, logLine := range logLines {
if logLine == "" {
continue
}
// Escape the log line and regex for shell
escapedLine := strconv.Quote(logLine)
escapedRegex := strconv.Quote(failregex)
cmd := fmt.Sprintf("echo %s | sudo fail2ban-regex - %s", escapedLine, escapedRegex)
out, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
if err == nil && strings.Contains(out, "Success") {
matches = append(matches, logLine)
}
}
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
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
if currentJail != "" && currentJail != "DEFAULT" {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
})
}
currentJail = strings.Trim(line, "[]")
enabled = true
} else if strings.HasPrefix(strings.ToLower(line), "enabled") {
parts := strings.Split(line, "=")
if len(parts) == 2 {
value := strings.TrimSpace(parts[1])
enabled = strings.EqualFold(value, "true")
}
}
}
if currentJail != "" && currentJail != "DEFAULT" {
jails = append(jails, JailInfo{
JailName: currentJail,
Enabled: enabled,
})
}
return jails
}

View File

@@ -17,10 +17,13 @@
package fail2ban
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// GetFilterConfig returns the filter configuration using the default connector.
@@ -59,3 +62,73 @@ func SetFilterConfigLocal(jail, newContent string) error {
}
return nil
}
// GetFiltersLocal returns a list of filter names from /etc/fail2ban/filter.d
func GetFiltersLocal() ([]string, error) {
dir := "/etc/fail2ban/filter.d"
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("failed to read filter directory: %w", err)
}
var filters []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".conf") {
name := strings.TrimSuffix(entry.Name(), ".conf")
filters = append(filters, name)
}
}
return filters, nil
}
// TestFilterLocal tests a filter against log lines using fail2ban-regex
func TestFilterLocal(filterName string, logLines []string) ([]string, error) {
if len(logLines) == 0 {
return []string{}, nil
}
filterPath := filepath.Join("/etc/fail2ban/filter.d", filterName+".conf")
if _, err := os.Stat(filterPath); err != nil {
return nil, fmt.Errorf("filter %s not found: %w", filterName, err)
}
// Read the filter config to extract the failregex
content, err := os.ReadFile(filterPath)
if err != nil {
return nil, fmt.Errorf("failed to read filter config: %w", err)
}
// Extract failregex from the config
var failregex string
scanner := bufio.NewScanner(strings.NewReader(string(content)))
inFailregex := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if strings.HasPrefix(line, "[Definition]") {
inFailregex = true
continue
}
if inFailregex && strings.HasPrefix(line, "failregex") {
parts := strings.SplitN(line, "=", 2)
if len(parts) == 2 {
failregex = strings.TrimSpace(parts[1])
}
break
}
if inFailregex && strings.HasPrefix(line, "[") {
break
}
}
if failregex == "" {
return nil, fmt.Errorf("no failregex found in filter %s", filterName)
}
// Use fail2ban-regex to test
var matches []string
for _, logLine := range logLines {
if logLine == "" {
continue
}
cmd := exec.Command("fail2ban-regex", logLine, failregex)
out, err := cmd.CombinedOutput()
if err == nil && strings.Contains(string(out), "Success") {
matches = append(matches, logLine)
}
}
return matches, nil
}

View File

@@ -21,6 +21,14 @@ type Connector interface {
GetFilterConfig(ctx context.Context, jail string) (string, error)
SetFilterConfig(ctx context.Context, jail, content string) error
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
// Jail management
GetAllJails(ctx context.Context) ([]JailInfo, error)
UpdateJailEnabledStates(ctx context.Context, updates map[string]bool) error
// Filter operations
GetFilters(ctx context.Context) ([]string, error)
TestFilter(ctx context.Context, filterName string, logLines []string) ([]string, error)
}
// Manager orchestrates all connectors for configured Fail2ban servers.

View File

@@ -578,9 +578,12 @@ func SetJailFilterConfigHandler(c *gin.Context) {
func ManageJailsHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("ManageJailsHandler called (handlers.go)") // entry point
// Get all jails from jail.local and jail.d directories.
// This helper should parse both files and return []fail2ban.JailInfo.
jails, err := fail2ban.GetAllJails()
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
jails, err := conn.GetAllJails(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jails: " + err.Error()})
return
@@ -594,21 +597,21 @@ func ManageJailsHandler(c *gin.Context) {
func UpdateJailManagementHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var updates map[string]bool
if err := c.ShouldBindJSON(&updates); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// Update jail configuration file(s) with the new enabled states.
if err := fail2ban.UpdateJailEnabledStates(updates); err != nil {
if err := conn.UpdateJailEnabledStates(c.Request.Context(), updates); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()})
return
}
// Restart the Fail2ban service.
//if err := fail2ban.RestartFail2ban(); err != nil {
// c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to reload fail2ban: " + err.Error()})
// return
//}
if err := config.MarkRestartNeeded(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -669,43 +672,35 @@ func ListFiltersHandler(c *gin.Context) {
return
}
server := conn.Server()
if server.Type != "local" {
c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.not_available"})
return
}
dir := "/etc/fail2ban/filter.d"
if _, statErr := os.Stat(dir); statErr != nil {
if os.IsNotExist(statErr) {
c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.local_missing"})
if server.Type == "local" {
// For local, check if directory exists first
dir := "/etc/fail2ban/filter.d"
if _, statErr := os.Stat(dir); statErr != nil {
if os.IsNotExist(statErr) {
c.JSON(http.StatusOK, gin.H{"filters": []string{}, "messageKey": "filter_debug.local_missing"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read filter directory: " + statErr.Error()})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read filter directory: " + statErr.Error()})
return
}
files, err := os.ReadDir(dir)
filters, err := conn.GetFilters(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to read filter directory: " + err.Error(),
})
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list filters: " + err.Error()})
return
}
var filters []string
for _, f := range files {
if !f.IsDir() && strings.HasSuffix(f.Name(), ".conf") {
name := strings.TrimSuffix(f.Name(), ".conf")
filters = append(filters, name)
}
}
c.JSON(http.StatusOK, gin.H{"filters": filters})
}
func TestFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("TestFilterHandler called (handlers.go)") // entry point
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req struct {
FilterName string `json:"filterName"`
LogLines []string `json:"logLines"`
@@ -715,8 +710,12 @@ func TestFilterHandler(c *gin.Context) {
return
}
// For now, just pretend nothing matches
c.JSON(http.StatusOK, gin.H{"matches": []string{}})
matches, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"matches": matches})
}
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON