Files
fail2ban-ui/internal/fail2ban/connector_local.go

370 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package fail2ban
import (
"context"
"errors"
"fmt"
"os/exec"
"sort"
"strings"
"sync"
"time"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI.
type LocalConnector struct {
server config.Fail2banServer
}
// NewLocalConnector creates a new LocalConnector instance.
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
return &LocalConnector{server: server}
}
// ID implements Connector.
func (lc *LocalConnector) ID() string {
return lc.server.ID
}
// Server implements Connector.
func (lc *LocalConnector) Server() config.Fail2banServer {
return lc.server
}
// GetJailInfos implements Connector.
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
jails, err := lc.getJails(ctx)
if err != nil {
return nil, err
}
logPath := lc.server.LogPath
if logPath == "" {
logPath = "/var/log/fail2ban.log"
}
banHistory, err := ParseBanLog(logPath)
if err != nil {
banHistory = make(map[string][]BanEvent)
}
oneHourAgo := time.Now().Add(-1 * time.Hour)
// 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 {
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 <- jailResult{
jail: JailInfo{
JailName: j,
TotalBanned: len(bannedIPs),
NewInLastHour: newInLastHour,
BannedIPs: bannedIPs,
Enabled: true,
},
}
}(jail)
}
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.
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
args := []string{"status", jail}
out, err := lc.runFail2banClient(ctx, args...)
if err != nil {
return nil, fmt.Errorf("fail2ban-client status %s failed: %w", jail, err)
}
var bannedIPs []string
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "IP list:") {
parts := strings.Split(line, ":")
if len(parts) > 1 {
ips := strings.Fields(strings.TrimSpace(parts[1]))
bannedIPs = append(bannedIPs, ips...)
}
break
}
}
return bannedIPs, nil
}
// UnbanIP implements Connector.
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
args := []string{"set", jail, "unbanip", ip}
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
return fmt.Errorf("error unbanning IP %s from jail %s: %w", ip, jail, err)
}
return nil
}
// Reload implements Connector.
func (lc *LocalConnector) Reload(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "reload")
if err != nil {
// Include the output in the error message for better debugging
return fmt.Errorf("fail2ban reload error: %w (output: %s)", err, strings.TrimSpace(out))
}
// Check if output indicates success (fail2ban-client returns "OK" on success)
outputTrimmed := strings.TrimSpace(out)
if outputTrimmed != "OK" && outputTrimmed != "" {
config.DebugLog("fail2ban reload output: %s", out)
// Check for jail errors in output even when command succeeds
// Look for patterns like "Errors in jail 'jailname'. Skipping..."
if strings.Contains(out, "Errors in jail") || strings.Contains(out, "Unable to read the filter") {
// Return an error that includes the output so handler can parse it
return fmt.Errorf("fail2ban reload completed but with errors (output: %s)", strings.TrimSpace(out))
}
}
return nil
}
// RestartWithMode restarts (or reloads) the local Fail2ban instance and returns
// a mode string describing what happened:
// - "restart": systemd service was restarted and health check passed
// - "reload": configuration was reloaded via fail2ban-client and pong check passed
func (lc *LocalConnector) RestartWithMode(ctx context.Context) (string, error) {
// 1) Try systemd restart if systemctl is available.
if _, err := exec.LookPath("systemctl"); err == nil {
cmd := "systemctl restart fail2ban"
out, err := executeShellCommand(ctx, cmd)
if err != nil {
return "restart", fmt.Errorf("failed to restart fail2ban via systemd: %w - output: %s",
err, strings.TrimSpace(out))
}
if err := lc.checkFail2banHealthy(ctx); err != nil {
return "restart", fmt.Errorf("fail2ban health check after systemd restart failed: %w", err)
}
return "restart", nil
}
// 2) Fallback: no systemctl in PATH (container image without systemd, or
// non-systemd environment). Use fail2ban-client reload + ping.
if err := lc.Reload(ctx); err != nil {
return "reload", fmt.Errorf("failed to reload fail2ban via fail2ban-client (systemctl not available): %w", err)
}
if err := lc.checkFail2banHealthy(ctx); err != nil {
return "reload", fmt.Errorf("fail2ban health check after reload failed: %w", err)
}
return "reload", nil
}
// Restart implements Connector.
func (lc *LocalConnector) Restart(ctx context.Context) error {
_, err := lc.RestartWithMode(ctx)
return err
}
// GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, string, error) {
return GetFilterConfigLocal(jail)
}
// SetFilterConfig implements Connector.
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
return SetFilterConfigLocal(jail, content)
}
// FetchBanEvents implements Connector.
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
logPath := lc.server.LogPath
if logPath == "" {
logPath = "/var/log/fail2ban.log"
}
eventsByJail, err := ParseBanLog(logPath)
if err != nil {
return nil, err
}
var all []BanEvent
for _, evs := range eventsByJail {
all = append(all, evs...)
}
sort.SliceStable(all, func(i, j int) bool {
return all[i].Time.After(all[j].Time)
})
if limit > 0 && len(all) > limit {
all = all[:limit]
}
return all, nil
}
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
out, err := lc.runFail2banClient(ctx, "status")
if err != nil {
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
}
var jails []string
lines := strings.Split(out, "\n")
for _, line := range lines {
if strings.Contains(line, "Jail list:") {
parts := strings.Split(line, ":")
if len(parts) > 1 {
raw := strings.TrimSpace(parts[1])
jails = strings.Split(raw, ",")
for i := range jails {
jails[i] = strings.TrimSpace(jails[i])
}
}
}
}
return jails, nil
}
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
cmdArgs := lc.buildFail2banArgs(args...)
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
out, err := cmd.CombinedOutput()
return string(out), err
}
func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
if lc.server.SocketPath == "" {
return args
}
base := []string{"-s", lc.server.SocketPath}
return append(base, args...)
}
// checkFail2banHealthy runs a quick `fail2ban-client ping` via the existing
// runFail2banClient helper and expects a successful pong reply.
func (lc *LocalConnector) checkFail2banHealthy(ctx context.Context) error {
out, err := lc.runFail2banClient(ctx, "ping")
trimmed := strings.TrimSpace(out)
if err != nil {
return fmt.Errorf("fail2ban ping error: %w (output: %s)", err, trimmed)
}
// Typical output is e.g. "Server replied: pong" accept anything that
// contains "pong" case-insensitively.
if !strings.Contains(strings.ToLower(trimmed), "pong") {
return fmt.Errorf("unexpected fail2ban ping output: %s", trimmed)
}
return nil
}
// 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, string, error) {
return TestFilterLocal(filterName, logLines)
}
// GetJailConfig implements Connector.
func (lc *LocalConnector) GetJailConfig(ctx context.Context, jail string) (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)
}
// 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.
func (lc *LocalConnector) UpdateDefaultSettings(ctx context.Context, settings config.AppSettings) error {
return UpdateDefaultSettingsLocal(settings)
}
// EnsureJailLocalStructure implements Connector.
func (lc *LocalConnector) EnsureJailLocalStructure(ctx context.Context) error {
// Note: Migration is handled in newConnectorForServer() before
// config.EnsureLocalFail2banAction() is called, so migration has already
// run by the time this method is called.
return config.EnsureJailLocalStructure()
}
// CreateJail implements Connector.
func (lc *LocalConnector) CreateJail(ctx context.Context, jailName, content string) error {
return CreateJail(jailName, content)
}
// DeleteJail implements Connector.
func (lc *LocalConnector) DeleteJail(ctx context.Context, jailName string) error {
return DeleteJail(jailName)
}
// CreateFilter implements Connector.
func (lc *LocalConnector) CreateFilter(ctx context.Context, filterName, content string) error {
return CreateFilter(filterName, content)
}
// DeleteFilter implements Connector.
func (lc *LocalConnector) DeleteFilter(ctx context.Context, filterName string) error {
return DeleteFilter(filterName)
}
func executeShellCommand(ctx context.Context, command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {
return "", errors.New("no command provided")
}
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
out, err := cmd.CombinedOutput()
return string(out), err
}