mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
372 lines
11 KiB
Go
372 lines
11 KiB
Go
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:") {
|
||
// Use SplitN to only split on the first colon, preserving IPv6 addresses
|
||
parts := strings.SplitN(line, ":", 2)
|
||
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:") {
|
||
// Use SplitN to only split on the first colon
|
||
parts := strings.SplitN(line, ":", 2)
|
||
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
|
||
}
|