Refactor the whole backend to support remote-fail2ban machines over ssh or over a agent-api(needs to be build)

This commit is contained in:
2025-11-12 15:52:34 +01:00
parent 995de09ce4
commit 9c3713bb41
21 changed files with 4263 additions and 440 deletions

View File

@@ -16,14 +16,7 @@
package fail2ban
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
)
import "context"
type JailInfo struct {
JailName string `json:"jailName"`
@@ -33,146 +26,65 @@ type JailInfo struct {
Enabled bool `json:"enabled"`
}
// Get active jails using "fail2ban-client status".
// GetJails returns the jail names for the default server.
func GetJails() ([]string, error) {
cmd := exec.Command("fail2ban-client", "status")
out, err := cmd.CombinedOutput()
conn, err := GetManager().DefaultConnector()
if err != nil {
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %v", err)
return nil, err
}
var jails []string
lines := strings.Split(string(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
}
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
func GetBannedIPs(jail string) ([]string, error) {
cmd := exec.Command("fail2ban-client", "status", jail)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("fail2ban-client status %s failed: %v", jail, err)
}
var bannedIPs []string
lines := strings.Split(string(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 unbans an IP from the given jail.
func UnbanIP(jail, ip string) error {
// We assume "fail2ban-client set <jail> unbanip <ip>" works.
cmd := exec.Command("fail2ban-client", "set", jail, "unbanip", ip)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("error unbanning IP %s from jail %s: %v\nOutput: %s", ip, jail, err, out)
}
return nil
}
// BuildJailInfos returns extended info for each jail:
// - total banned count
// - new banned in the last hour
// - list of currently banned IPs
func BuildJailInfos(logPath string) ([]JailInfo, error) {
jails, err := GetJails()
infos, err := conn.GetJailInfos(context.Background())
if err != nil {
return nil, err
}
// Parse the log once, so we can determine "newInLastHour" per jail
// for performance reasons. We'll gather all ban timestamps by jail.
banHistory, err := ParseBanLog(logPath)
if err != nil {
// If fail2ban.log can't be read, we can still show partial info.
banHistory = make(map[string][]BanEvent)
names := make([]string, 0, len(infos))
for _, info := range infos {
names = append(names, info.JailName)
}
oneHourAgo := time.Now().Add(-1 * time.Hour)
var results []JailInfo
for _, jail := range jails {
bannedIPs, err := GetBannedIPs(jail)
if err != nil {
// Just skip or handle error per jail
continue
}
// Count how many bans occurred in the last hour for this jail
newInLastHour := 0
if events, ok := banHistory[jail]; ok {
for _, e := range events {
if e.Time.After(oneHourAgo) {
newInLastHour++
}
}
}
jinfo := JailInfo{
JailName: jail,
TotalBanned: len(bannedIPs),
NewInLastHour: newInLastHour,
BannedIPs: bannedIPs,
}
results = append(results, jinfo)
}
return results, nil
return names, nil
}
// ReloadFail2ban runs "fail2ban-client reload"
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
func GetBannedIPs(jail string) ([]string, error) {
conn, err := GetManager().DefaultConnector()
if err != nil {
return nil, err
}
return conn.GetBannedIPs(context.Background(), jail)
}
// UnbanIP unbans an IP from the given jail.
func UnbanIP(jail, ip string) error {
conn, err := GetManager().DefaultConnector()
if err != nil {
return err
}
return conn.UnbanIP(context.Background(), jail, ip)
}
// BuildJailInfos returns extended info for each jail on the default server.
func BuildJailInfos(_ string) ([]JailInfo, error) {
conn, err := GetManager().DefaultConnector()
if err != nil {
return nil, err
}
return conn.GetJailInfos(context.Background())
}
// ReloadFail2ban triggers a reload on the default server.
func ReloadFail2ban() error {
cmd := exec.Command("fail2ban-client", "reload")
out, err := cmd.CombinedOutput()
conn, err := GetManager().DefaultConnector()
if err != nil {
return fmt.Errorf("fail2ban reload error: %v\noutput: %s", err, out)
return err
}
return nil
return conn.Reload(context.Background())
}
// RestartFail2ban restarts the Fail2ban service.
// RestartFail2ban restarts the Fail2ban service using the default connector.
func RestartFail2ban() error {
// Check if running inside a container.
if _, container := os.LookupEnv("CONTAINER"); container {
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
}
cmd := "systemctl restart fail2ban"
out, err := execCommand(cmd)
conn, err := GetManager().DefaultConnector()
if err != nil {
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
return err
}
return nil
}
// execCommand is a helper function to execute shell commands.
func execCommand(command string) (string, error) {
parts := strings.Fields(command)
if len(parts) == 0 {
return "", errors.New("no command provided")
}
cmd := exec.Command(parts[0], parts[1:]...)
out, err := cmd.CombinedOutput()
return string(out), err
return conn.Restart(context.Background())
}

View File

@@ -0,0 +1,251 @@
package fail2ban
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
type AgentConnector struct {
server config.Fail2banServer
base *url.URL
client *http.Client
}
// NewAgentConnector constructs a new AgentConnector.
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
if server.AgentURL == "" {
return nil, fmt.Errorf("agentUrl is required for agent connector")
}
if server.AgentSecret == "" {
return nil, fmt.Errorf("agentSecret is required for agent connector")
}
parsed, err := url.Parse(server.AgentURL)
if err != nil {
return nil, fmt.Errorf("invalid agentUrl: %w", err)
}
if parsed.Scheme == "" {
parsed.Scheme = "https"
}
client := &http.Client{
Timeout: 15 * time.Second,
}
conn := &AgentConnector{
server: server,
base: parsed,
client: client,
}
if err := conn.ensureAction(context.Background()); err != nil {
fmt.Printf("warning: failed to ensure agent action for %s: %v\n", server.Name, err)
}
return conn, nil
}
func (ac *AgentConnector) ID() string {
return ac.server.ID
}
func (ac *AgentConnector) Server() config.Fail2banServer {
return ac.server
}
func (ac *AgentConnector) ensureAction(ctx context.Context) error {
payload := map[string]any{
"name": "ui-custom-action",
"config": config.BuildFail2banActionConfig(config.GetCallbackURL()),
"callbackUrl": config.GetCallbackURL(),
"setDefault": true,
}
return ac.put(ctx, "/v1/actions/ui-custom", payload, nil)
}
func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
var resp struct {
Jails []JailInfo `json:"jails"`
}
if err := ac.get(ctx, "/v1/jails", &resp); err != nil {
return nil, err
}
return resp.Jails, nil
}
func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
var resp struct {
Jail string `json:"jail"`
BannedIPs []string `json:"bannedIPs"`
TotalBanned int `json:"totalBanned"`
}
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s", url.PathEscape(jail)), &resp); err != nil {
return nil, err
}
if len(resp.BannedIPs) > 0 {
return resp.BannedIPs, nil
}
return []string{}, nil
}
func (ac *AgentConnector) UnbanIP(ctx context.Context, jail, ip string) error {
payload := map[string]string{"ip": ip}
return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/unban", url.PathEscape(jail)), payload, nil)
}
func (ac *AgentConnector) Reload(ctx context.Context) error {
return ac.post(ctx, "/v1/actions/reload", nil, nil)
}
func (ac *AgentConnector) Restart(ctx context.Context) error {
return ac.post(ctx, "/v1/actions/restart", nil, nil)
}
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
var resp struct {
Config string `json:"config"`
}
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
return "", err
}
return resp.Config, nil
}
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
payload := map[string]string{"config": content}
return ac.put(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), payload, nil)
}
func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
query := url.Values{}
if limit > 0 {
query.Set("limit", strconv.Itoa(limit))
}
var resp struct {
Events []struct {
IP string `json:"ip"`
Jail string `json:"jail"`
Hostname string `json:"hostname"`
Failures string `json:"failures"`
Whois string `json:"whois"`
Logs string `json:"logs"`
Timestamp string `json:"timestamp"`
} `json:"events"`
}
endpoint := "/v1/events"
if encoded := query.Encode(); encoded != "" {
endpoint += "?" + encoded
}
if err := ac.get(ctx, endpoint, &resp); err != nil {
return nil, err
}
result := make([]BanEvent, 0, len(resp.Events))
for _, evt := range resp.Events {
ts, err := time.Parse(time.RFC3339, evt.Timestamp)
if err != nil {
ts = time.Now()
}
result = append(result, BanEvent{
Time: ts,
Jail: evt.Jail,
IP: evt.IP,
LogLine: fmt.Sprintf("%s %s", evt.Hostname, evt.Failures),
})
}
return result, nil
}
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
return ac.do(req, out)
}
func (ac *AgentConnector) post(ctx context.Context, endpoint string, payload any, out any) error {
req, err := ac.newRequest(ctx, http.MethodPost, endpoint, payload)
if err != nil {
return err
}
return ac.do(req, out)
}
func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any, out any) error {
req, err := ac.newRequest(ctx, http.MethodPut, endpoint, payload)
if err != nil {
return err
}
return ac.do(req, out)
}
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
u := *ac.base
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
if err != nil {
return nil, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("X-F2B-Token", ac.server.AgentSecret)
return req, nil
}
func (ac *AgentConnector) do(req *http.Request, out any) error {
settingsSnapshot := config.GetSettings()
if settingsSnapshot.Debug {
config.DebugLog("Agent request [%s]: %s %s", ac.server.Name, req.Method, req.URL.String())
}
resp, err := ac.client.Do(req)
if err != nil {
if settingsSnapshot.Debug {
config.DebugLog("Agent request error [%s]: %v", ac.server.Name, err)
}
return fmt.Errorf("agent request failed: %w", err)
}
defer resp.Body.Close()
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
if err != nil {
return err
}
trimmed := strings.TrimSpace(string(data))
if settingsSnapshot.Debug {
config.DebugLog("Agent response [%s]: %s | %s", ac.server.Name, resp.Status, trimmed)
}
if resp.StatusCode >= 400 {
return fmt.Errorf("agent request failed: %s (%s)", resp.Status, trimmed)
}
if out == nil {
return nil
}
if len(trimmed) == 0 {
return nil
}
return json.Unmarshal(data, out)
}

View File

@@ -0,0 +1,218 @@
package fail2ban
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"sort"
"strings"
"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)
var results []JailInfo
for _, jail := range jails {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
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++
}
}
}
results = append(results, JailInfo{
JailName: jail,
TotalBanned: len(bannedIPs),
NewInLastHour: newInLastHour,
BannedIPs: bannedIPs,
Enabled: true,
})
}
return results, 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 {
if _, err := lc.runFail2banClient(ctx, "reload"); err != nil {
return fmt.Errorf("fail2ban reload error: %w", err)
}
return nil
}
// Restart implements Connector.
func (lc *LocalConnector) Restart(ctx context.Context) error {
if _, container := os.LookupEnv("CONTAINER"); container {
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
}
cmd := "systemctl restart fail2ban"
out, err := executeShellCommand(ctx, cmd)
if err != nil {
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
}
return nil
}
// GetFilterConfig implements Connector.
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail 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...)
}
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
}

View File

@@ -0,0 +1,256 @@
package fail2ban
import (
"context"
"encoding/base64"
"fmt"
"os/exec"
"sort"
"strconv"
"strings"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
const sshEnsureActionScript = `sudo python3 - <<'PY'
import base64
import pathlib
action_dir = pathlib.Path("/etc/fail2ban/action.d")
action_dir.mkdir(parents=True, exist_ok=True)
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
if not jail_file.exists():
jail_file.write_text("[DEFAULT]\n")
lines = jail_file.read_text().splitlines()
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
if not already:
new_lines = []
inserted = False
for line in lines:
stripped = line.strip()
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
if not stripped.startswith("#"):
new_lines.append("# " + line)
else:
new_lines.append(line)
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.append("action = %(action_mwlg)s")
inserted = True
continue
new_lines.append(line)
if not inserted:
insert_at = None
for idx, value in enumerate(new_lines):
if value.strip().startswith("[DEFAULT]"):
insert_at = idx + 1
break
if insert_at is None:
new_lines.append("[DEFAULT]")
insert_at = len(new_lines)
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
jail_file.write_text("\n".join(new_lines) + "\n")
PY`
// SSHConnector connects to a remote Fail2ban instance over SSH.
type SSHConnector struct {
server config.Fail2banServer
}
// NewSSHConnector creates a new SSH connector.
func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
if server.Host == "" {
return nil, fmt.Errorf("host is required for ssh connector")
}
if server.SSHUser == "" {
return nil, fmt.Errorf("sshUser is required for ssh connector")
}
conn := &SSHConnector{server: server}
if err := conn.ensureAction(context.Background()); err != nil {
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err)
}
return conn, nil
}
func (sc *SSHConnector) ID() string {
return sc.server.ID
}
func (sc *SSHConnector) Server() config.Fail2banServer {
return sc.server
}
func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
jails, err := sc.getJails(ctx)
if err != nil {
return nil, err
}
var infos []JailInfo
for _, jail := range jails {
select {
case <-ctx.Done():
return nil, ctx.Err()
default:
}
ips, err := sc.GetBannedIPs(ctx, jail)
if err != nil {
continue
}
infos = append(infos, JailInfo{
JailName: jail,
TotalBanned: len(ips),
NewInLastHour: 0,
BannedIPs: ips,
Enabled: true,
})
}
sort.SliceStable(infos, func(i, j int) bool {
return infos[i].JailName < infos[j].JailName
})
return infos, nil
}
func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
out, err := sc.runFail2banCommand(ctx, "status", jail)
if err != nil {
return nil, 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
}
func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error {
_, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip)
return err
}
func (sc *SSHConnector) Reload(ctx context.Context) error {
_, err := sc.runFail2banCommand(ctx, "reload")
return err
}
func (sc *SSHConnector) Restart(ctx context.Context) error {
_, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"})
return err
}
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
out, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", path})
if err != nil {
return "", fmt.Errorf("failed to read remote filter config: %w", err)
}
return out, nil
}
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
cmd := fmt.Sprintf("cat <<'EOF' | sudo tee %s >/dev/null\n%s\nEOF", path, content)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
return err
}
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
// Not available over SSH without copying logs; return empty slice.
return []BanEvent{}, nil
}
func (sc *SSHConnector) ensureAction(ctx context.Context) error {
callbackURL := config.GetCallbackURL()
actionConfig := config.BuildFail2banActionConfig(callbackURL)
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
return err
}
func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) {
out, err := sc.runFail2banCommand(ctx, "status")
if err != nil {
return nil, 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 (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) {
fail2banArgs := sc.buildFail2banArgs(args...)
cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...)
return sc.runRemoteCommand(ctx, cmdArgs)
}
func (sc *SSHConnector) buildFail2banArgs(args ...string) []string {
if sc.server.SocketPath == "" {
return args
}
base := []string{"-s", sc.server.SocketPath}
return append(base, args...)
}
func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) {
args := sc.buildSSHArgs(command)
cmd := exec.CommandContext(ctx, "ssh", args...)
settingSnapshot := config.GetSettings()
if settingSnapshot.Debug {
config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " "))
}
out, err := cmd.CombinedOutput()
output := strings.TrimSpace(string(out))
if err != nil {
if settingSnapshot.Debug {
config.DebugLog("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output)
}
return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output)
}
if settingSnapshot.Debug {
config.DebugLog("SSH command output [%s]: %s", sc.server.Name, output)
}
return output, nil
}
func (sc *SSHConnector) buildSSHArgs(command []string) []string {
args := []string{"-o", "BatchMode=yes"}
if sc.server.SSHKeyPath != "" {
args = append(args, "-i", sc.server.SSHKeyPath)
}
if sc.server.Port > 0 {
args = append(args, "-p", strconv.Itoa(sc.server.Port))
}
target := sc.server.Host
if sc.server.SSHUser != "" {
target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target)
}
args = append(args, target)
args = append(args, command...)
return args
}

View File

@@ -17,15 +17,32 @@
package fail2ban
import (
"context"
"fmt"
"os"
"path/filepath"
)
// GetFilterConfig returns the config content for a given jail filter.
// Example: we assume each jail config is at /etc/fail2ban/filter.d/<jailname>.conf
// Adapt this to your environment.
// GetFilterConfig returns the filter configuration using the default connector.
func GetFilterConfig(jail string) (string, error) {
conn, err := GetManager().DefaultConnector()
if err != nil {
return "", err
}
return conn.GetFilterConfig(context.Background(), jail)
}
// SetFilterConfig writes the filter configuration using the default connector.
func SetFilterConfig(jail, newContent string) error {
conn, err := GetManager().DefaultConnector()
if err != nil {
return err
}
return conn.SetFilterConfig(context.Background(), jail, newContent)
}
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
func GetFilterConfigLocal(jail string) (string, error) {
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
content, err := os.ReadFile(configPath)
if err != nil {
@@ -34,8 +51,8 @@ func GetFilterConfig(jail string) (string, error) {
return string(content), nil
}
// SetFilterConfig overwrites the config file for a given jail with new content.
func SetFilterConfig(jail, newContent string) error {
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
func SetFilterConfigLocal(jail, newContent string) error {
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil {
return fmt.Errorf("failed to write config for jail %s: %v", jail, err)

View File

@@ -0,0 +1,117 @@
package fail2ban
import (
"context"
"fmt"
"sync"
"github.com/swissmakers/fail2ban-ui/internal/config"
)
// Connector describes a communication backend for a Fail2ban server.
type Connector interface {
ID() string
Server() config.Fail2banServer
GetJailInfos(ctx context.Context) ([]JailInfo, error)
GetBannedIPs(ctx context.Context, jail string) ([]string, error)
UnbanIP(ctx context.Context, jail, ip string) error
Reload(ctx context.Context) error
Restart(ctx context.Context) error
GetFilterConfig(ctx context.Context, jail string) (string, error)
SetFilterConfig(ctx context.Context, jail, content string) error
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
}
// Manager orchestrates all connectors for configured Fail2ban servers.
type Manager struct {
mu sync.RWMutex
connectors map[string]Connector
}
var (
managerOnce sync.Once
managerInst *Manager
)
// GetManager returns the singleton connector manager.
func GetManager() *Manager {
managerOnce.Do(func() {
managerInst = &Manager{
connectors: make(map[string]Connector),
}
})
return managerInst
}
// ReloadFromSettings rebuilds connectors using the provided settings.
func (m *Manager) ReloadFromSettings(settings config.AppSettings) error {
m.mu.Lock()
defer m.mu.Unlock()
connectors := make(map[string]Connector)
for _, srv := range settings.Servers {
if !srv.Enabled {
continue
}
conn, err := newConnectorForServer(srv)
if err != nil {
return fmt.Errorf("failed to initialise connector for %s (%s): %w", srv.Name, srv.ID, err)
}
connectors[srv.ID] = conn
}
m.connectors = connectors
return nil
}
// Connector returns the connector for the specified server ID.
func (m *Manager) Connector(serverID string) (Connector, error) {
m.mu.RLock()
defer m.mu.RUnlock()
if serverID == "" {
return nil, fmt.Errorf("server id must be provided")
}
conn, ok := m.connectors[serverID]
if !ok {
return nil, fmt.Errorf("connector for server %s not found or not enabled", serverID)
}
return conn, nil
}
// DefaultConnector returns the default connector as defined in settings.
func (m *Manager) DefaultConnector() (Connector, error) {
server := config.GetDefaultServer()
if server.ID == "" {
return nil, fmt.Errorf("no active fail2ban server configured")
}
return m.Connector(server.ID)
}
// Connectors returns all connectors.
func (m *Manager) Connectors() []Connector {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]Connector, 0, len(m.connectors))
for _, conn := range m.connectors {
result = append(result, conn)
}
return result
}
func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
switch server.Type {
case "local":
if err := config.EnsureLocalFail2banAction(server); err != nil {
fmt.Printf("warning: failed to ensure local fail2ban action: %v\n", err)
}
return NewLocalConnector(server), nil
case "ssh":
return NewSSHConnector(server)
case "agent":
return NewAgentConnector(server)
default:
return nil, fmt.Errorf("unsupported server type %s", server.Type)
}
}