mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-19 06:53:14 +02:00
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:
@@ -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())
|
||||
}
|
||||
|
||||
251
internal/fail2ban/connector_agent.go
Normal file
251
internal/fail2ban/connector_agent.go
Normal 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)
|
||||
}
|
||||
218
internal/fail2ban/connector_local.go
Normal file
218
internal/fail2ban/connector_local.go
Normal 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
|
||||
}
|
||||
256
internal/fail2ban/connector_ssh.go
Normal file
256
internal/fail2ban/connector_ssh.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
117
internal/fail2ban/manager.go
Normal file
117
internal/fail2ban/manager.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user