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

1
.gitignore vendored
View File

@@ -27,3 +27,4 @@ go.work.sum
# Project specific
fail2ban-ui-settings.json
_dev
fail2ban-ui.db*

View File

@@ -25,6 +25,8 @@ import (
"github.com/gin-gonic/gin"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/storage"
"github.com/swissmakers/fail2ban-ui/pkg/web"
)
@@ -32,6 +34,19 @@ func main() {
// Get application settings from the config package.
settings := config.GetSettings()
if err := storage.Init(""); err != nil {
log.Fatalf("Failed to initialise storage: %v", err)
}
defer func() {
if err := storage.Close(); err != nil {
log.Printf("warning: failed to close storage: %v", err)
}
}()
if err := fail2ban.GetManager().ReloadFromSettings(settings); err != nil {
log.Fatalf("failed to initialise fail2ban connectors: %v", err)
}
// Set Gin mode based on the debug flag in settings.
if settings.Debug {
gin.SetMode(gin.DebugMode)

12
go.mod
View File

@@ -6,24 +6,30 @@ require (
github.com/gin-gonic/gin v1.10.0
github.com/go-playground/validator/v10 v10.26.0
github.com/oschwald/maxminddb-golang v1.13.1
modernc.org/sqlite v1.33.1
)
require (
github.com/bytedance/sonic v1.12.8 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.13.0 // indirect
@@ -33,4 +39,10 @@ require (
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
modernc.org/strutil v1.2.0 // indirect
modernc.org/token v1.1.0 // indirect
)

44
go.sum
View File

@@ -9,6 +9,8 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
@@ -28,6 +30,12 @@ github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PU
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
@@ -43,12 +51,16 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -69,13 +81,19 @@ golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
@@ -85,4 +103,30 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.33.1 h1:trb6Z3YYoeM9eDL1O8do81kP+0ejv+YzgyFo+Gwy0nM=
modernc.org/sqlite v1.33.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -18,14 +18,23 @@ package config
import (
"bufio"
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/swissmakers/fail2ban-ui/internal/storage"
)
// SMTPSettings holds the SMTP server configuration for sending alert emails
@@ -46,6 +55,9 @@ type AppSettings struct {
RestartNeeded bool `json:"restartNeeded"`
AlertCountries []string `json:"alertCountries"`
SMTP SMTPSettings `json:"smtp"`
CallbackURL string `json:"callbackUrl"`
Servers []Fail2banServer `json:"servers"`
// Fail2Ban [DEFAULT] section values from jail.local
BantimeIncrement bool `json:"bantimeIncrement"`
@@ -64,45 +76,353 @@ const (
jailFile = "/etc/fail2ban/jail.local" // Path to jail.local (to override conf-values from jail.conf)
jailDFile = "/etc/fail2ban/jail.d/ui-custom-action.conf"
actionFile = "/etc/fail2ban/action.d/ui-custom-action.conf"
actionCallbackPlaceholder = "__CALLBACK_URL__"
)
const fail2banActionTemplate = `[INCLUDES]
before = sendmail-common.conf
mail-whois-common.conf
helpers-common.conf
[Definition]
# Bypass ban/unban for restored tickets
norestored = 1
# Option: actionban
# This executes a cURL request to notify our API when an IP is banned.
actionban = /usr/bin/curl -X POST __CALLBACK_URL__/api/ban \
-H "Content-Type: application/json" \
-d "$(jq -n --arg ip '<ip>' \
--arg jail '<name>' \
--arg hostname '<fq-hostname>' \
--arg failures '<failures>' \
--arg whois "$(whois <ip> || echo 'missing whois program')" \
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
[Init]
# Default name of the chain
name = default
# Path to log files containing relevant lines for the abuser IP
logpath = /dev/null
# Number of log lines to include in the email
grepmax = 200
grepopts = -m <grepmax>`
// in-memory copy of settings
var (
currentSettings AppSettings
settingsLock sync.RWMutex
)
var (
errSettingsNotFound = errors.New("settings not found")
backgroundCtx = context.Background()
)
// Fail2banServer represents a Fail2ban instance the UI can manage.
type Fail2banServer struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // local, ssh, agent
Host string `json:"host,omitempty"`
Port int `json:"port,omitempty"`
SocketPath string `json:"socketPath,omitempty"`
LogPath string `json:"logPath,omitempty"`
SSHUser string `json:"sshUser,omitempty"`
SSHKeyPath string `json:"sshKeyPath,omitempty"`
AgentURL string `json:"agentUrl,omitempty"`
AgentSecret string `json:"agentSecret,omitempty"`
Hostname string `json:"hostname,omitempty"`
Tags []string `json:"tags,omitempty"`
IsDefault bool `json:"isDefault"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
enabledSet bool
}
func (s *Fail2banServer) UnmarshalJSON(data []byte) error {
type Alias Fail2banServer
aux := &struct {
Enabled *bool `json:"enabled"`
*Alias
}{
Alias: (*Alias)(s),
}
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
if aux.Enabled != nil {
s.Enabled = *aux.Enabled
s.enabledSet = true
} else {
s.enabledSet = false
}
return nil
}
func init() {
// Attempt to load existing file; if it doesn't exist, create with defaults.
if err := loadSettings(); err != nil {
if err := storage.Init(""); err != nil {
panic(fmt.Sprintf("failed to initialise storage: %v", err))
}
if err := loadSettingsFromStorage(); err != nil {
if !errors.Is(err, errSettingsNotFound) {
fmt.Println("Error loading settings from storage:", err)
}
if err := migrateLegacySettings(); err != nil {
if !errors.Is(err, os.ErrNotExist) {
fmt.Println("Error migrating legacy settings:", err)
}
fmt.Println("App settings not found, initializing from jail.local (if exist)")
if err := initializeFromJailFile(); err != nil {
fmt.Println("Error reading jail.local:", err)
}
setDefaults()
fmt.Println("Initialized successfully.")
fmt.Println("Initialized with defaults.")
}
// save defaults to file
if err := saveSettings(); err != nil {
fmt.Println("Failed to save default settings:", err)
if err := persistAll(); err != nil {
fmt.Println("Failed to persist settings:", err)
}
} else {
if err := persistAll(); err != nil {
fmt.Println("Failed to persist settings:", err)
}
}
if err := initializeFail2banAction(); err != nil {
fmt.Println("Error initializing Fail2ban action:", err)
}
func loadSettingsFromStorage() error {
appRec, found, err := storage.GetAppSettings(backgroundCtx)
if err != nil {
return err
}
serverRecs, err := storage.ListServers(backgroundCtx)
if err != nil {
return err
}
if !found {
return errSettingsNotFound
}
settingsLock.Lock()
defer settingsLock.Unlock()
applyAppSettingsRecordLocked(appRec)
applyServerRecordsLocked(serverRecs)
setDefaultsLocked()
return nil
}
func migrateLegacySettings() error {
data, err := os.ReadFile(settingsFile)
if err != nil {
return err
}
var legacy AppSettings
if err := json.Unmarshal(data, &legacy); err != nil {
return err
}
settingsLock.Lock()
currentSettings = legacy
settingsLock.Unlock()
return nil
}
func persistAll() error {
settingsLock.Lock()
defer settingsLock.Unlock()
setDefaultsLocked()
return persistAllLocked()
}
func persistAllLocked() error {
if err := persistAppSettingsLocked(); err != nil {
return err
}
return persistServersLocked()
}
func persistAppSettingsLocked() error {
rec, err := toAppSettingsRecordLocked()
if err != nil {
return err
}
return storage.SaveAppSettings(backgroundCtx, rec)
}
func persistServersLocked() error {
records, err := toServerRecordsLocked()
if err != nil {
return err
}
return storage.ReplaceServers(backgroundCtx, records)
}
func applyAppSettingsRecordLocked(rec storage.AppSettingsRecord) {
currentSettings.Language = rec.Language
currentSettings.Port = rec.Port
currentSettings.Debug = rec.Debug
currentSettings.CallbackURL = rec.CallbackURL
currentSettings.RestartNeeded = rec.RestartNeeded
currentSettings.BantimeIncrement = rec.BantimeIncrement
currentSettings.IgnoreIP = rec.IgnoreIP
currentSettings.Bantime = rec.Bantime
currentSettings.Findtime = rec.Findtime
currentSettings.Maxretry = rec.MaxRetry
currentSettings.Destemail = rec.DestEmail
currentSettings.SMTP = SMTPSettings{
Host: rec.SMTPHost,
Port: rec.SMTPPort,
Username: rec.SMTPUsername,
Password: rec.SMTPPassword,
From: rec.SMTPFrom,
UseTLS: rec.SMTPUseTLS,
}
if rec.AlertCountriesJSON != "" {
var countries []string
if err := json.Unmarshal([]byte(rec.AlertCountriesJSON), &countries); err == nil {
currentSettings.AlertCountries = countries
}
}
}
func applyServerRecordsLocked(records []storage.ServerRecord) {
servers := make([]Fail2banServer, 0, len(records))
for _, rec := range records {
var tags []string
if rec.TagsJSON != "" {
_ = json.Unmarshal([]byte(rec.TagsJSON), &tags)
}
server := Fail2banServer{
ID: rec.ID,
Name: rec.Name,
Type: rec.Type,
Host: rec.Host,
Port: rec.Port,
SocketPath: rec.SocketPath,
LogPath: rec.LogPath,
SSHUser: rec.SSHUser,
SSHKeyPath: rec.SSHKeyPath,
AgentURL: rec.AgentURL,
AgentSecret: rec.AgentSecret,
Hostname: rec.Hostname,
Tags: tags,
IsDefault: rec.IsDefault,
Enabled: rec.Enabled,
CreatedAt: rec.CreatedAt,
UpdatedAt: rec.UpdatedAt,
enabledSet: true,
}
servers = append(servers, server)
}
currentSettings.Servers = servers
}
func toAppSettingsRecordLocked() (storage.AppSettingsRecord, error) {
countries := currentSettings.AlertCountries
if countries == nil {
countries = []string{}
}
countryBytes, err := json.Marshal(countries)
if err != nil {
return storage.AppSettingsRecord{}, err
}
return storage.AppSettingsRecord{
Language: currentSettings.Language,
Port: currentSettings.Port,
Debug: currentSettings.Debug,
CallbackURL: currentSettings.CallbackURL,
RestartNeeded: currentSettings.RestartNeeded,
AlertCountriesJSON: string(countryBytes),
SMTPHost: currentSettings.SMTP.Host,
SMTPPort: currentSettings.SMTP.Port,
SMTPUsername: currentSettings.SMTP.Username,
SMTPPassword: currentSettings.SMTP.Password,
SMTPFrom: currentSettings.SMTP.From,
SMTPUseTLS: currentSettings.SMTP.UseTLS,
BantimeIncrement: currentSettings.BantimeIncrement,
IgnoreIP: currentSettings.IgnoreIP,
Bantime: currentSettings.Bantime,
Findtime: currentSettings.Findtime,
MaxRetry: currentSettings.Maxretry,
DestEmail: currentSettings.Destemail,
}, nil
}
func toServerRecordsLocked() ([]storage.ServerRecord, error) {
records := make([]storage.ServerRecord, 0, len(currentSettings.Servers))
for _, srv := range currentSettings.Servers {
tags := srv.Tags
if tags == nil {
tags = []string{}
}
tagBytes, err := json.Marshal(tags)
if err != nil {
return nil, err
}
createdAt := srv.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
updatedAt := srv.UpdatedAt
if updatedAt.IsZero() {
updatedAt = createdAt
}
records = append(records, storage.ServerRecord{
ID: srv.ID,
Name: srv.Name,
Type: srv.Type,
Host: srv.Host,
Port: srv.Port,
SocketPath: srv.SocketPath,
LogPath: srv.LogPath,
SSHUser: srv.SSHUser,
SSHKeyPath: srv.SSHKeyPath,
AgentURL: srv.AgentURL,
AgentSecret: srv.AgentSecret,
Hostname: srv.Hostname,
TagsJSON: string(tagBytes),
IsDefault: srv.IsDefault,
Enabled: srv.Enabled,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
})
}
return records, nil
}
// setDefaults populates default values in currentSettings
func setDefaults() {
settingsLock.Lock()
defer settingsLock.Unlock()
setDefaultsLocked()
}
func setDefaultsLocked() {
if currentSettings.Language == "" {
currentSettings.Language = "en"
}
if currentSettings.Port == 0 {
currentSettings.Port = 8080
}
if currentSettings.CallbackURL == "" {
currentSettings.CallbackURL = fmt.Sprintf("http://127.0.0.1:%d", currentSettings.Port)
}
if currentSettings.AlertCountries == nil {
currentSettings.AlertCountries = []string{"ALL"}
}
@@ -139,6 +459,8 @@ func setDefaults() {
if currentSettings.IgnoreIP == "" {
currentSettings.IgnoreIP = "127.0.0.1/8 ::1"
}
normalizeServersLocked()
}
// initializeFromJailFile reads Fail2ban jail.local and merges its settings into currentSettings.
@@ -189,41 +511,127 @@ func initializeFromJailFile() error {
return nil
}
// initializeFail2banAction writes a custom action configuration for Fail2ban to use AlertCountries.
func initializeFail2banAction() error {
func normalizeServersLocked() {
now := time.Now().UTC()
if len(currentSettings.Servers) == 0 {
hostname, _ := os.Hostname()
currentSettings.Servers = []Fail2banServer{{
ID: "local",
Name: "Local Fail2ban",
Type: "local",
SocketPath: "/var/run/fail2ban/fail2ban.sock",
LogPath: "/var/log/fail2ban.log",
Hostname: hostname,
IsDefault: false,
Enabled: false,
CreatedAt: now,
UpdatedAt: now,
enabledSet: true,
}}
return
}
hasDefault := false
for idx := range currentSettings.Servers {
server := &currentSettings.Servers[idx]
if server.ID == "" {
server.ID = generateServerID()
}
if server.Name == "" {
server.Name = "Fail2ban Server " + server.ID
}
if server.Type == "" {
server.Type = "local"
}
if server.CreatedAt.IsZero() {
server.CreatedAt = now
}
if server.UpdatedAt.IsZero() {
server.UpdatedAt = now
}
if server.Type == "local" && server.SocketPath == "" {
server.SocketPath = "/var/run/fail2ban/fail2ban.sock"
}
if server.Type == "local" && server.LogPath == "" {
server.LogPath = "/var/log/fail2ban.log"
}
if !server.enabledSet {
if server.Type == "local" {
server.Enabled = false
} else {
server.Enabled = true
}
}
server.enabledSet = true
if server.IsDefault && !server.Enabled {
server.IsDefault = false
}
if server.IsDefault && server.Enabled {
hasDefault = true
}
}
if !hasDefault {
for idx := range currentSettings.Servers {
if currentSettings.Servers[idx].Enabled {
currentSettings.Servers[idx].IsDefault = true
hasDefault = true
break
}
}
}
sort.SliceStable(currentSettings.Servers, func(i, j int) bool {
return currentSettings.Servers[i].CreatedAt.Before(currentSettings.Servers[j].CreatedAt)
})
}
func generateServerID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
return fmt.Sprintf("srv-%d", time.Now().UnixNano())
}
return "srv-" + hex.EncodeToString(b[:])
}
// ensureFail2banActionFiles writes the local action files if Fail2ban is present.
func ensureFail2banActionFiles(callbackURL string) error {
DebugLog("----------------------------")
DebugLog("Running initial initializeFail2banAction()") // entry point
// Ensure the jail.local is configured correctly
DebugLog("ensureFail2banActionFiles called (settings.go)")
if _, err := os.Stat(filepath.Dir(jailFile)); os.IsNotExist(err) {
return nil
}
if err := setupGeoCustomAction(); err != nil {
fmt.Println("Error setup GeoCustomAction in jail.local:", err)
return err
}
// Ensure the jail.d config file is set up
if err := ensureJailDConfig(); err != nil {
fmt.Println("Error setting up jail.d configuration:", err)
return err
}
// Write the fail2ban action file
return writeFail2banAction()
return writeFail2banAction(callbackURL)
}
// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI
func setupGeoCustomAction() error {
DebugLog("Running initial setupGeoCustomAction()") // entry point
file, err := os.Open(jailFile)
if err != nil {
// Fallback: Copy default file if jail.local is not found
if os.IsNotExist(err) {
if err := copyFile(defaultJailFile, jailFile); err != nil {
return fmt.Errorf("failed to copy default jail.conf to jail.local: %w", err)
if err := os.MkdirAll(filepath.Dir(jailFile), 0o755); err != nil {
return fmt.Errorf("failed to ensure jail.local directory: %w", err)
}
file, err := os.Open(jailFile)
if os.IsNotExist(err) {
if _, statErr := os.Stat(defaultJailFile); os.IsNotExist(statErr) {
return nil
}
if copyErr := copyFile(defaultJailFile, jailFile); copyErr != nil {
return fmt.Errorf("failed to copy default jail.conf to jail.local: %w", copyErr)
}
fmt.Println("Successfully created jail.local from jail.conf.")
file, err = os.Open(jailFile)
}
if err != nil {
return err
}
} else {
return err // Other error
}
}
defer file.Close()
var lines []string
@@ -295,6 +703,10 @@ func ensureJailDConfig() error {
return nil
}
if err := os.MkdirAll(filepath.Dir(jailDFile), 0o755); err != nil {
return fmt.Errorf("failed to ensure jail.d directory: %v", err)
}
// Define the content for the custom jail.d configuration
jailDConfig := `[DEFAULT]
# Custom Fail2Ban action using geo-filter for email alerts
@@ -313,47 +725,14 @@ action_mwlg = %(action_)s
}
// writeFail2banAction creates or updates the action file with the AlertCountries.
func writeFail2banAction() error {
func writeFail2banAction(callbackURL string) error {
DebugLog("Running initial writeFail2banAction()") // entry point
DebugLog("----------------------------")
// Define the Fail2Ban action file content
actionConfig := `[INCLUDES]
if err := os.MkdirAll(filepath.Dir(actionFile), 0o755); err != nil {
return fmt.Errorf("failed to ensure action.d directory: %w", err)
}
before = sendmail-common.conf
mail-whois-common.conf
helpers-common.conf
[Definition]
# Bypass ban/unban for restored tickets
norestored = 1
# Option: actionban
# This executes a cURL request to notify our API when an IP is banned.
actionban = /usr/bin/curl -X POST http://127.0.0.1:8080/api/ban \
-H "Content-Type: application/json" \
-d "$(jq -n --arg ip '<ip>' \
--arg jail '<name>' \
--arg hostname '<fq-hostname>' \
--arg failures '<failures>' \
--arg whois "$(whois <ip> || echo 'missing whois program')" \
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
[Init]
# Default name of the chain
name = default
# Path to log files containing relevant lines for the abuser IP
logpath = /dev/null
# Number of log lines to include in the email
grepmax = 200
grepopts = -m <grepmax>`
// Write the action file
actionConfig := BuildFail2banActionConfig(callbackURL)
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
if err != nil {
return fmt.Errorf("failed to write action file: %w", err)
@@ -363,46 +742,253 @@ grepopts = -m <grepmax>`
return nil
}
// loadSettings reads fail2ban-ui-settings.json into currentSettings.
func loadSettings() error {
DebugLog("----------------------------")
DebugLog("loadSettings called (settings.go)") // entry point
data, err := os.ReadFile(settingsFile)
if os.IsNotExist(err) {
return err // triggers setDefaults + save
func cloneServer(src Fail2banServer) Fail2banServer {
dst := src
if src.Tags != nil {
dst.Tags = append([]string{}, src.Tags...)
}
if err != nil {
return err
}
var s AppSettings
if err := json.Unmarshal(data, &s); err != nil {
return err
}
settingsLock.Lock()
defer settingsLock.Unlock()
currentSettings = s
return nil
dst.enabledSet = src.enabledSet
return dst
}
// saveSettings writes currentSettings to JSON
func saveSettings() error {
DebugLog("----------------------------")
DebugLog("saveSettings called (settings.go)") // entry point
func BuildFail2banActionConfig(callbackURL string) string {
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
if trimmed == "" {
trimmed = "http://127.0.0.1:8080"
}
return strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
}
b, err := json.MarshalIndent(currentSettings, "", " ")
if err != nil {
DebugLog("Error marshalling settings: %v", err) // Debug
return err
func getCallbackURLLocked() string {
url := strings.TrimSpace(currentSettings.CallbackURL)
if url == "" {
port := currentSettings.Port
if port == 0 {
port = 8080
}
DebugLog("Settings marshaled, writing to file...") // Log marshaling success
err = os.WriteFile(settingsFile, b, 0644)
if err != nil {
DebugLog("Error writing to file: %v", err) // Debug
url = fmt.Sprintf("http://127.0.0.1:%d", port)
}
// Write again the Fail2ban-UI action file (in the future not used anymore)
return writeFail2banAction()
return strings.TrimRight(url, "/")
}
// GetCallbackURL returns the callback URL used by Fail2ban agents.
func GetCallbackURL() string {
settingsLock.RLock()
defer settingsLock.RUnlock()
return getCallbackURLLocked()
}
// EnsureLocalFail2banAction ensures the local Fail2ban action files exist when the local connector is enabled.
func EnsureLocalFail2banAction(server Fail2banServer) error {
if !server.Enabled {
return nil
}
settingsLock.RLock()
callbackURL := getCallbackURLLocked()
settingsLock.RUnlock()
return ensureFail2banActionFiles(callbackURL)
}
func serverByIDLocked(id string) (Fail2banServer, bool) {
for _, srv := range currentSettings.Servers {
if srv.ID == id {
return cloneServer(srv), true
}
}
return Fail2banServer{}, false
}
// ListServers returns a copy of the configured Fail2ban servers.
func ListServers() []Fail2banServer {
settingsLock.RLock()
defer settingsLock.RUnlock()
out := make([]Fail2banServer, len(currentSettings.Servers))
for idx, srv := range currentSettings.Servers {
out[idx] = cloneServer(srv)
}
return out
}
// GetServerByID returns the server matching the supplied ID.
func GetServerByID(id string) (Fail2banServer, bool) {
settingsLock.RLock()
defer settingsLock.RUnlock()
srv, ok := serverByIDLocked(id)
if !ok {
return Fail2banServer{}, false
}
return cloneServer(srv), true
}
// GetServerByHostname returns the first server matching the hostname.
func GetServerByHostname(hostname string) (Fail2banServer, bool) {
settingsLock.RLock()
defer settingsLock.RUnlock()
for _, srv := range currentSettings.Servers {
if strings.EqualFold(srv.Hostname, hostname) {
return cloneServer(srv), true
}
}
return Fail2banServer{}, false
}
// GetDefaultServer returns the default server.
func GetDefaultServer() Fail2banServer {
settingsLock.RLock()
defer settingsLock.RUnlock()
for _, srv := range currentSettings.Servers {
if srv.IsDefault && srv.Enabled {
return cloneServer(srv)
}
}
for _, srv := range currentSettings.Servers {
if srv.Enabled {
return cloneServer(srv)
}
}
return Fail2banServer{}
}
// UpsertServer adds or updates a Fail2ban server and persists the settings.
func UpsertServer(input Fail2banServer) (Fail2banServer, error) {
settingsLock.Lock()
defer settingsLock.Unlock()
now := time.Now().UTC()
input.Type = strings.ToLower(strings.TrimSpace(input.Type))
if input.ID == "" {
input.ID = generateServerID()
input.CreatedAt = now
}
if input.CreatedAt.IsZero() {
input.CreatedAt = now
}
input.UpdatedAt = now
if input.Type == "" {
input.Type = "local"
}
if !input.enabledSet {
if input.Type == "local" {
input.Enabled = false
} else {
input.Enabled = true
}
input.enabledSet = true
}
if input.Type == "local" && input.SocketPath == "" {
input.SocketPath = "/var/run/fail2ban/fail2ban.sock"
}
if input.Type == "local" && input.LogPath == "" {
input.LogPath = "/var/log/fail2ban.log"
}
if input.Name == "" {
input.Name = "Fail2ban Server " + input.ID
}
replaced := false
for idx, srv := range currentSettings.Servers {
if srv.ID == input.ID {
if !input.enabledSet {
input.Enabled = srv.Enabled
input.enabledSet = true
}
if !input.Enabled {
input.IsDefault = false
}
if input.IsDefault {
clearDefaultLocked()
}
// preserve created timestamp if incoming zero
if input.CreatedAt.IsZero() {
input.CreatedAt = srv.CreatedAt
}
currentSettings.Servers[idx] = input
replaced = true
break
}
}
if !replaced {
if input.IsDefault {
clearDefaultLocked()
}
if len(currentSettings.Servers) == 0 && input.Enabled {
input.IsDefault = true
}
currentSettings.Servers = append(currentSettings.Servers, input)
}
normalizeServersLocked()
if err := persistServersLocked(); err != nil {
return Fail2banServer{}, err
}
srv, _ := serverByIDLocked(input.ID)
return cloneServer(srv), nil
}
func clearDefaultLocked() {
for idx := range currentSettings.Servers {
currentSettings.Servers[idx].IsDefault = false
}
}
// DeleteServer removes a server by ID.
func DeleteServer(id string) error {
settingsLock.Lock()
defer settingsLock.Unlock()
if len(currentSettings.Servers) == 0 {
return fmt.Errorf("no servers configured")
}
index := -1
for i, srv := range currentSettings.Servers {
if srv.ID == id {
index = i
break
}
}
if index == -1 {
return fmt.Errorf("server %s not found", id)
}
currentSettings.Servers = append(currentSettings.Servers[:index], currentSettings.Servers[index+1:]...)
normalizeServersLocked()
return persistServersLocked()
}
// SetDefaultServer marks the specified server as default.
func SetDefaultServer(id string) (Fail2banServer, error) {
settingsLock.Lock()
defer settingsLock.Unlock()
found := false
for idx := range currentSettings.Servers {
srv := &currentSettings.Servers[idx]
if srv.ID == id {
found = true
srv.IsDefault = true
if !srv.Enabled {
srv.Enabled = true
srv.enabledSet = true
}
srv.UpdatedAt = time.Now().UTC()
} else {
srv.IsDefault = false
}
}
if !found {
return Fail2banServer{}, fmt.Errorf("server %s not found", id)
}
normalizeServersLocked()
if err := persistServersLocked(); err != nil {
return Fail2banServer{}, err
}
srv, _ := serverByIDLocked(id)
return cloneServer(srv), nil
}
// GetSettings returns a copy of the current settings
@@ -418,7 +1004,7 @@ func MarkRestartNeeded() error {
defer settingsLock.Unlock()
currentSettings.RestartNeeded = true
return saveSettings()
return persistAppSettingsLocked()
}
// MarkRestartDone sets restartNeeded = false and saves JSON
@@ -427,7 +1013,7 @@ func MarkRestartDone() error {
defer settingsLock.Unlock()
currentSettings.RestartNeeded = false
return saveSettings()
return persistAppSettingsLocked()
}
// UpdateSettings merges new settings with old and sets restartNeeded if needed
@@ -452,14 +1038,20 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
new.RestartNeeded = new.RestartNeeded || old.RestartNeeded
}
new.CallbackURL = strings.TrimSpace(new.CallbackURL)
if len(new.Servers) == 0 && len(currentSettings.Servers) > 0 {
new.Servers = make([]Fail2banServer, len(currentSettings.Servers))
for i, srv := range currentSettings.Servers {
new.Servers[i] = cloneServer(srv)
}
}
currentSettings = new
setDefaultsLocked()
DebugLog("New settings applied: %v", currentSettings) // Log settings applied
// persist to file
if err := saveSettings(); err != nil {
fmt.Println("Error saving settings:", err) // Log save error
if err := persistAllLocked(); err != nil {
fmt.Println("Error saving settings:", err)
return currentSettings, err
}
fmt.Println("Settings saved to file successfully") // Log save success
return currentSettings, nil
}

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)
}
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Service neu starten",
"dashboard.title": "Dashboard",
"dashboard.overview": "Aktive Jails und Blocks Übersicht",
"dashboard.overview_hint": "Verwende die Suche, um gesperrte IPs zu filtern, und klicke auf ein Jail, um dessen Konfiguration zu bearbeiten.",
"dashboard.search_label": "Suche gesperrte IPs",
"dashboard.search_placeholder": "Geben Sie eine IP-Adresse zum Suchen ein",
"dashboard.external_ip": "Deine ext. IP:",
"dashboard.manage_servers": "Server verwalten",
"dashboard.no_servers_title": "Keine Fail2ban-Server konfiguriert",
"dashboard.no_servers_body": "Füge einen Server hinzu, um Fail2ban-Instanzen zu überwachen und zu steuern.",
"dashboard.loading_summary": "Zusammenfassung wird geladen…",
"dashboard.no_enabled_servers_title": "Keine aktiven Verbindungen",
"dashboard.no_enabled_servers_body": "Aktiviere den lokalen Connector oder registriere einen entfernten Fail2ban-Server, um Live-Daten zu sehen.",
"dashboard.errors.summary_failed": "Zusammenfassung konnte nicht vom Server geladen werden.",
"dashboard.cards.active_jails": "Aktive Jails",
"dashboard.cards.total_banned": "Gesamt gesperrte IPs",
"dashboard.cards.new_last_hour": "Neue in der letzten Stunde",
"dashboard.cards.total_logged": "Gespeicherte Sperr-Ereignisse",
"dashboard.table.jail_name": "Jail-Name",
"dashboard.table.total_banned": "Insgesamt gesperrt",
"dashboard.table.new_last_hour": "Neu in letzter Stunde",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "Keine aktuellen Sperrvorgänge gefunden.",
"dashboard.no_banned_ips": "Keine gesperrten IPs",
"dashboard.unban": "Entsperren",
"logs.overview.title": "Interne Log-Übersicht",
"logs.overview.subtitle": "Von Fail2ban-UI gespeicherte Ereignisse über alle Connectoren.",
"logs.overview.refresh": "Daten aktualisieren",
"logs.overview.total_events": "Gespeicherte Ereignisse gesamt",
"logs.overview.per_server": "Ereignisse pro Server",
"logs.overview.recent_events_title": "Letzte gespeicherte Ereignisse",
"logs.overview.recent_empty": "Für den ausgewählten Server wurden keine gespeicherten Ereignisse gefunden.",
"logs.overview.empty": "Es wurden noch keine Sperr-Ereignisse protokolliert.",
"logs.table.server": "Server",
"logs.table.count": "Anzahl",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Zeit",
"logs.table.country": "Land",
"filter_debug.title": "Filter-Debug",
"filter_debug.select_filter": "Wählen Sie einen Filter",
"filter_debug.log_lines": "Logzeilen",
@@ -34,6 +61,8 @@
"settings.language": "Sprache",
"settings.enable_debug": "Debug-Protokoll aktivieren",
"settings.alert": "Alarm-Einstellungen",
"settings.callback_url": "Fail2ban Callback-URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Ziel-E-Mail (Alarmempfänger)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alarm-Länder",
@@ -64,8 +93,65 @@
"modal.filter_config": "Filter-Konfiguration:",
"modal.cancel": "Abbrechen",
"modal.save": "Speichern",
"modal.close": "Schließen",
"loading": "Lade...",
"dashboard.manage_jails": "Jails verwalten",
"modal.manage_jails_title": "Jails verwalten"
"modal.manage_jails_title": "Jails verwalten",
"servers.selector.label": "Aktiver Server",
"servers.selector.empty": "Keine Server konfiguriert",
"servers.selector.none": "Kein Server konfiguriert. Bitte füge einen Fail2ban-Server hinzu.",
"servers.modal.title": "Fail2ban-Server verwalten",
"servers.modal.description": "Registriere entfernte Fail2ban-Instanzen und wähle, wie das UI sich verbindet.",
"servers.modal.list_title": "Registrierte Server",
"servers.modal.list_empty": "Keine Server konfiguriert. Füge rechts deinen ersten Fail2ban-Server hinzu.",
"servers.modal.form_title": "Server hinzufügen oder bearbeiten",
"servers.form.name": "Anzeigename",
"servers.form.name_placeholder": "Mein Fail2ban-Server",
"servers.form.type": "Verbindungstyp",
"servers.type.local": "Lokal (gleicher Host)",
"servers.type.ssh": "SSH",
"servers.type.agent": "API-Agent",
"servers.form.host": "Hostname / IP",
"servers.form.host_placeholder": "fail2ban.beispiel.de",
"servers.form.port": "Port",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Fail2ban-Socket-Pfad",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Fail2ban-Logpfad",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Server-Hostname",
"servers.form.hostname_placeholder": "optional",
"servers.form.ssh_user": "SSH-Benutzer",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "Pfad zum SSH-Schlüssel",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "Agent-URL",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Agent-Secret",
"servers.form.agent_secret_placeholder": "gemeinsames Geheimnis",
"servers.form.tags": "Tags",
"servers.form.tags_placeholder": "kommagetrennte Tags",
"servers.form.set_default": "Als Standardserver setzen",
"servers.form.enabled": "Connector aktivieren",
"servers.form.submit": "Server speichern",
"servers.form.reset": "Zurücksetzen",
"servers.form.success": "Server erfolgreich gespeichert.",
"servers.badge.default": "Standard",
"servers.badge.enabled": "Aktiv",
"servers.badge.disabled": "Deaktiviert",
"servers.actions.edit": "Bearbeiten",
"servers.actions.set_default": "Als Standard setzen",
"servers.actions.enable": "Aktivieren",
"servers.actions.disable": "Deaktivieren",
"servers.actions.test": "Verbindung testen",
"servers.actions.test_success": "Verbindung erfolgreich",
"servers.actions.test_failure": "Verbindung fehlgeschlagen",
"servers.actions.delete": "Löschen",
"servers.actions.delete_confirm": "Diesen Servereintrag löschen?",
"servers.form.select_key": "Privaten Schlüssel auswählen",
"servers.form.select_key_placeholder": "Manuelle Eingabe",
"servers.form.no_keys": "Keine SSH-Schlüssel gefunden; Pfad manuell eingeben",
"filter_debug.not_available": "Filter-Debug ist nur für lokale Connectoren verfügbar.",
"filter_debug.local_missing": "Das lokale Fail2ban-Filterverzeichnis wurde auf diesem Host nicht gefunden."
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Service neu starte",
"dashboard.title": "Dashboard",
"dashboard.overview": "Übersicht vo de aktive Jails und Blocks",
"dashboard.overview_hint": "Bruch d Suechi zum g'sperrti IPs filtere und klick uf es Jail, zum d Konfiguration z'bearbeite.",
"dashboard.search_label": "Suech nach g'sperrte IPs",
"dashboard.search_placeholder": "Gib d'IP adrässe i, wo du suechsch",
"dashboard.external_ip": "Dini ext. IP:",
"dashboard.manage_servers": "Server verwalte",
"dashboard.no_servers_title": "Kei Fail2ban-Server konfiguriert",
"dashboard.no_servers_body": "Füeg en Server dezue zum Fail2ban-Instanze überwache und steuere.",
"dashboard.loading_summary": "Lad Zämmefassig…",
"dashboard.no_enabled_servers_title": "Kei aktivi Verbindige",
"dashboard.no_enabled_servers_body": "Aktivier dr lokale Connector oder registrier ä entfernten Fail2ban-Server für Live-Datä.",
"dashboard.errors.summary_failed": "Zämmefassig het nid chönne glade wärde.",
"dashboard.cards.active_jails": "Aktivi Jails",
"dashboard.cards.total_banned": "Total g'sperrti IPs",
"dashboard.cards.new_last_hour": "Neu i dr letschte Stund",
"dashboard.cards.total_logged": "Gspeichereti Sperr-Ereigniss",
"dashboard.table.jail_name": "Jail-Name",
"dashboard.table.total_banned": "Insgsamt g'sperrt",
"dashboard.table.new_last_hour": "Neu in dr letschte Stund",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "Kei aktuelli Sperrvorgäng gfunde.",
"dashboard.no_banned_ips": "Kei g'sperrti IPs",
"dashboard.unban": "Entsperre",
"logs.overview.title": "Interni Log-Übersicht",
"logs.overview.subtitle": "Vo Fail2ban-UI gspeichereti Ereigniss über alli Connectorä.",
"logs.overview.refresh": "Date aktualisiere",
"logs.overview.total_events": "Total gspeichereti Ereigniss",
"logs.overview.per_server": "Ereigniss pro Server",
"logs.overview.recent_events_title": "Letschti gspeichereti Ereigniss",
"logs.overview.recent_empty": "Kei gspeichereti Ereigniss für dä gwählte Server gfunde.",
"logs.overview.empty": "No kei Sperr-Ereigniss protokolliert.",
"logs.table.server": "Server",
"logs.table.count": "Aazahl",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Zyt",
"logs.table.country": "Land",
"filter_debug.title": "Filter Debug",
"filter_debug.select_filter": "Wähl en Filter us",
"filter_debug.log_lines": "Log-Zile",
@@ -34,6 +61,8 @@
"settings.language": "Sprach",
"settings.enable_debug": "Debug-Modus aktivierä",
"settings.alert": "Alarm-Istellige",
"settings.callback_url": "Fail2ban Callback-URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Ziil-Email (Alarmempfänger)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alarm-Länder",
@@ -64,8 +93,65 @@
"modal.filter_config": "Filter-Konfiguration:",
"modal.cancel": "Abbräche",
"modal.save": "Speicherä",
"modal.close": "Zue",
"loading": "Lade...",
"dashboard.manage_jails": "Jails ala oder absteue",
"modal.manage_jails_title": "Jails ala oder absteue"
"modal.manage_jails_title": "Jails ala oder absteue",
"servers.selector.label": "Aktiver Server",
"servers.selector.empty": "Kei Server konfiguriert",
"servers.selector.none": "Kei Server konfiguriert. Bitte füeg ä Fail2ban-Server dezue.",
"servers.modal.title": "Fail2ban-Server verwalte",
"servers.modal.description": "Registrier entfernti Fail2ban-Instanze und wähl, wie s UI sich verbindet.",
"servers.modal.list_title": "Registrierti Server",
"servers.modal.list_empty": "Kei Server konfiguriert. Füeg rechts din erschte Fail2ban-Server dezue.",
"servers.modal.form_title": "Server hinzuefüege oder bearbeite",
"servers.form.name": "Aazeigname",
"servers.form.name_placeholder": "Mi Fail2ban-Server",
"servers.form.type": "Verbindigstyp",
"servers.type.local": "Lokal (gliiche Host)",
"servers.type.ssh": "SSH",
"servers.type.agent": "API-Agent",
"servers.form.host": "Hostname / IP",
"servers.form.host_placeholder": "fail2ban.beispiel.ch",
"servers.form.port": "Port",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Fail2ban-Socket-Pfad",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Fail2ban-Logpfad",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Server-Hostname",
"servers.form.hostname_placeholder": "optional",
"servers.form.ssh_user": "SSH-Benutzer",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "Pfad zum SSH-Schlüssel",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "Agent-URL",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Agent-Secret",
"servers.form.agent_secret_placeholder": "teilts Geheimnis",
"servers.form.tags": "Tags",
"servers.form.tags_placeholder": "Komma-trennte Tags",
"servers.form.set_default": "Als Standard-Server setze",
"servers.form.enabled": "Connector aktivierä",
"servers.form.submit": "Server speichere",
"servers.form.reset": "Zruggsetze",
"servers.form.success": "Server erfolgriich gspeicheret.",
"servers.badge.default": "Standard",
"servers.badge.enabled": "Aktiv",
"servers.badge.disabled": "Deaktiviert",
"servers.actions.edit": "Bearbeite",
"servers.actions.set_default": "Als Standard setze",
"servers.actions.enable": "Aktivierä",
"servers.actions.disable": "Deaktivierä",
"servers.actions.test": "Verbindig teste",
"servers.actions.test_success": "Verbindig erfolgriich",
"servers.actions.test_failure": "Verbindig nöd möglich",
"servers.actions.delete": "Lösche",
"servers.actions.delete_confirm": "Dä Servereintrag lösche?",
"servers.form.select_key": "Priväte Schlissel ufwähle",
"servers.form.select_key_placeholder": "Manuäll igäh",
"servers.form.no_keys": "Kei SSH-Schlüssel gfunde; Pfad selber igäh",
"filter_debug.not_available": "Filter-Debug git's nur für lokal Connectorä.",
"filter_debug.local_missing": "S lokale Fail2ban-Filterverzeichnis isch uf däm Host nid gfunde worde."
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Restart Service",
"dashboard.title": "Dashboard",
"dashboard.overview": "Overview active Jails and Blocks",
"dashboard.overview_hint": "Use the search to filter banned IPs and click a jail to edit its configuration.",
"dashboard.search_label": "Search Banned IPs",
"dashboard.search_placeholder": "Enter IP address to search",
"dashboard.external_ip": "Your ext. IP:",
"dashboard.manage_servers": "Manage Servers",
"dashboard.no_servers_title": "No Fail2ban servers configured",
"dashboard.no_servers_body": "Add a server to start monitoring and controlling Fail2ban instances.",
"dashboard.loading_summary": "Loading summary data…",
"dashboard.errors.summary_failed": "Failed to load summary from server.",
"dashboard.no_enabled_servers_title": "No active connectors",
"dashboard.no_enabled_servers_body": "Enable the local connector or register a remote Fail2ban server to see live data.",
"dashboard.cards.active_jails": "Active Jails",
"dashboard.cards.total_banned": "Total Banned IPs",
"dashboard.cards.new_last_hour": "New Last Hour",
"dashboard.cards.total_logged": "Stored Ban Events",
"dashboard.table.jail_name": "Jail Name",
"dashboard.table.total_banned": "Total Banned",
"dashboard.table.new_last_hour": "New Last Hour",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "No recent bans found.",
"dashboard.no_banned_ips": "No banned IPs",
"dashboard.unban": "Unban",
"logs.overview.title": "Internal Log Overview",
"logs.overview.subtitle": "Events stored by Fail2ban-UI across all connectors.",
"logs.overview.refresh": "Refresh data",
"logs.overview.total_events": "Total stored events",
"logs.overview.per_server": "Events per server",
"logs.overview.recent_events_title": "Recent stored events",
"logs.overview.recent_empty": "No stored events found for the selected server.",
"logs.overview.empty": "No ban events recorded yet.",
"logs.table.server": "Server",
"logs.table.count": "Count",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Time",
"logs.table.country": "Country",
"filter_debug.title": "Filter Debug",
"filter_debug.select_filter": "Select a Filter",
"filter_debug.log_lines": "Log Lines",
@@ -34,6 +61,8 @@
"settings.language": "Language",
"settings.enable_debug": "Enable Debug Log",
"settings.alert": "Alert Settings",
"settings.callback_url": "Fail2ban Callback URL",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Destination Email (Alerts Receiver)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Alert Countries",
@@ -64,8 +93,65 @@
"modal.filter_config": "Filter Config:",
"modal.cancel": "Cancel",
"modal.save": "Save",
"modal.close": "Close",
"loading": "Loading...",
"dashboard.manage_jails": "Manage Jails",
"modal.manage_jails_title": "Manage Jails"
"modal.manage_jails_title": "Manage Jails",
"servers.selector.label": "Active Server",
"servers.selector.empty": "No servers configured",
"servers.selector.none": "No server configured. Please add a Fail2ban server.",
"servers.modal.title": "Manage Fail2ban Servers",
"servers.modal.description": "Register remote Fail2ban instances and choose how the UI connects to them.",
"servers.modal.list_title": "Registered Servers",
"servers.modal.list_empty": "No servers configured yet. Add your first Fail2ban server using the form on the right.",
"servers.modal.form_title": "Add or Update Server",
"servers.form.name": "Display Name",
"servers.form.name_placeholder": "My Fail2ban server",
"servers.form.type": "Connection Type",
"servers.type.local": "Local (same host)",
"servers.type.ssh": "SSH",
"servers.type.agent": "API Agent",
"servers.form.host": "Hostname / IP",
"servers.form.host_placeholder": "fail2ban.example.com",
"servers.form.port": "Port",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Fail2ban Socket Path",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Fail2ban Log Path",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Server Hostname",
"servers.form.hostname_placeholder": "optional",
"servers.form.ssh_user": "SSH User",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "SSH Private Key Path",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "Agent URL",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Agent Secret",
"servers.form.agent_secret_placeholder": "shared secret token",
"servers.form.tags": "Tags",
"servers.form.tags_placeholder": "comma,separated,tags",
"servers.form.set_default": "Set as default server",
"servers.form.enabled": "Enable connector",
"servers.form.submit": "Save Server",
"servers.form.reset": "Reset",
"servers.form.success": "Server saved successfully.",
"servers.badge.default": "Default",
"servers.badge.enabled": "Enabled",
"servers.badge.disabled": "Disabled",
"servers.actions.edit": "Edit",
"servers.actions.set_default": "Set default",
"servers.actions.enable": "Enable",
"servers.actions.disable": "Disable",
"servers.actions.test": "Test connection",
"servers.actions.test_success": "Connection successful",
"servers.actions.test_failure": "Connection failed",
"servers.actions.delete": "Delete",
"servers.actions.delete_confirm": "Delete this server entry?",
"servers.form.select_key": "Select Private Key",
"servers.form.select_key_placeholder": "Manual entry",
"servers.form.no_keys": "No SSH keys found; enter path manually",
"filter_debug.not_available": "Filter debug is only available for local connectors.",
"filter_debug.local_missing": "The local Fail2ban filter directory was not found on this host."
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Reiniciar servicio",
"dashboard.title": "Panel de control",
"dashboard.overview": "Resumen de Jails y Bloqueos activos",
"dashboard.overview_hint": "Usa la búsqueda para filtrar IPs bloqueadas y haz clic en un jail para editar su configuración.",
"dashboard.search_label": "Buscar IP bloqueadas",
"dashboard.search_placeholder": "Introduce la dirección IP a buscar",
"dashboard.external_ip": "Tu IP ext.:",
"dashboard.manage_servers": "Administrar servidores",
"dashboard.no_servers_title": "No hay servidores Fail2ban configurados",
"dashboard.no_servers_body": "Añade un servidor para empezar a supervisar y controlar instancias de Fail2ban.",
"dashboard.loading_summary": "Cargando resumen…",
"dashboard.no_enabled_servers_title": "Sin conectores activos",
"dashboard.no_enabled_servers_body": "Activa el conector local o registra un servidor Fail2ban remoto para ver datos en vivo.",
"dashboard.errors.summary_failed": "No se pudo cargar el resumen desde el servidor.",
"dashboard.cards.active_jails": "Jails activos",
"dashboard.cards.total_banned": "IPs bloqueadas totales",
"dashboard.cards.new_last_hour": "Nuevas en la última hora",
"dashboard.cards.total_logged": "Eventos de bloqueo almacenados",
"dashboard.table.jail_name": "Nombre del Jail",
"dashboard.table.total_banned": "Total bloqueadas",
"dashboard.table.new_last_hour": "Nuevas en la última hora",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "No se encontraron bloqueos recientes.",
"dashboard.no_banned_ips": "No hay IP bloqueadas",
"dashboard.unban": "Desbloquear",
"logs.overview.title": "Resumen interno de registros",
"logs.overview.subtitle": "Eventos almacenados por Fail2ban-UI a través de todos los conectores.",
"logs.overview.refresh": "Actualizar datos",
"logs.overview.total_events": "Eventos almacenados totales",
"logs.overview.per_server": "Eventos por servidor",
"logs.overview.recent_events_title": "Eventos almacenados recientes",
"logs.overview.recent_empty": "No se encontraron eventos almacenados para el servidor seleccionado.",
"logs.overview.empty": "Aún no se han registrado eventos de bloqueo.",
"logs.table.server": "Servidor",
"logs.table.count": "Cantidad",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Hora",
"logs.table.country": "País",
"filter_debug.title": "Depuración de filtros",
"filter_debug.select_filter": "Selecciona un filtro",
"filter_debug.log_lines": "Líneas de log",
@@ -34,6 +61,8 @@
"settings.language": "Idioma",
"settings.enable_debug": "Habilitar el modo de depuración",
"settings.alert": "Configuración de alertas",
"settings.callback_url": "URL de retorno de Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Correo electrónico de destino (receptor de alertas)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Países para alerta",
@@ -64,7 +93,64 @@
"modal.filter_config": "Configuración del filtro:",
"modal.cancel": "Cancelar",
"modal.save": "Guardar",
"modal.close": "Cerrar",
"loading": "Cargando...",
"dashboard.manage_jails": "Administrar jails",
"modal.manage_jails_title": "Administrar jails"
"modal.manage_jails_title": "Administrar jails",
"servers.selector.label": "Servidor activo",
"servers.selector.empty": "No hay servidores configurados",
"servers.selector.none": "No hay servidor configurado. Añade un servidor Fail2ban.",
"servers.modal.title": "Administrar servidores Fail2ban",
"servers.modal.description": "Registra instancias remotas de Fail2ban y elige cómo se conecta la interfaz.",
"servers.modal.list_title": "Servidores registrados",
"servers.modal.list_empty": "No hay servidores configurados. Añade tu primer servidor Fail2ban usando el formulario.",
"servers.modal.form_title": "Añadir o actualizar servidor",
"servers.form.name": "Nombre para mostrar",
"servers.form.name_placeholder": "Mi servidor Fail2ban",
"servers.form.type": "Tipo de conexión",
"servers.type.local": "Local (mismo host)",
"servers.type.ssh": "SSH",
"servers.type.agent": "Agente API",
"servers.form.host": "Nombre de host / IP",
"servers.form.host_placeholder": "fail2ban.ejemplo.com",
"servers.form.port": "Puerto",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Ruta del socket de Fail2ban",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Ruta del log de Fail2ban",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Nombre de host del servidor",
"servers.form.hostname_placeholder": "opcional",
"servers.form.ssh_user": "Usuario SSH",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "Ruta de la clave SSH",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "URL del agente",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Secreto del agente",
"servers.form.agent_secret_placeholder": "token compartido",
"servers.form.tags": "Etiquetas",
"servers.form.tags_placeholder": "etiquetas separadas por comas",
"servers.form.set_default": "Establecer como servidor predeterminado",
"servers.form.enabled": "Habilitar conector",
"servers.form.submit": "Guardar servidor",
"servers.form.reset": "Restablecer",
"servers.form.success": "Servidor guardado correctamente.",
"servers.badge.default": "Predeterminado",
"servers.badge.enabled": "Habilitado",
"servers.badge.disabled": "Deshabilitado",
"servers.actions.edit": "Editar",
"servers.actions.set_default": "Establecer predeterminado",
"servers.actions.enable": "Habilitar",
"servers.actions.disable": "Deshabilitar",
"servers.actions.test": "Probar conexión",
"servers.actions.test_success": "Conexión exitosa",
"servers.actions.test_failure": "Conexión fallida",
"servers.actions.delete": "Eliminar",
"servers.actions.delete_confirm": "¿Eliminar este servidor?",
"servers.form.select_key": "Seleccionar clave privada",
"servers.form.select_key_placeholder": "Entrada manual",
"servers.form.no_keys": "No se encontraron claves SSH; introduzca la ruta manualmente",
"filter_debug.not_available": "La depuración de filtros solo está disponible para conectores locales.",
"filter_debug.local_missing": "No se encontró el directorio de filtros local de Fail2ban en este host."
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Redémarrer le service",
"dashboard.title": "Tableau de bord",
"dashboard.overview": "Vue d'ensemble des jails et blocages actifs",
"dashboard.overview_hint": "Utilisez la recherche pour filtrer les IP bloquées et cliquez sur un jail pour modifier sa configuration.",
"dashboard.search_label": "Rechercher des IP bloquées",
"dashboard.search_placeholder": "Entrez l'adresse IP à rechercher",
"dashboard.external_ip": "Votre IP ext. :",
"dashboard.manage_servers": "Gérer les serveurs",
"dashboard.no_servers_title": "Aucun serveur Fail2ban configuré",
"dashboard.no_servers_body": "Ajoutez un serveur pour commencer à superviser et contrôler les instances Fail2ban.",
"dashboard.loading_summary": "Chargement du résumé…",
"dashboard.no_enabled_servers_title": "Aucun connecteur actif",
"dashboard.no_enabled_servers_body": "Activez le connecteur local ou enregistrez un serveur Fail2ban distant pour voir les données en direct.",
"dashboard.errors.summary_failed": "Impossible de charger le résumé depuis le serveur.",
"dashboard.cards.active_jails": "Jails actifs",
"dashboard.cards.total_banned": "Total d'IPs bloquées",
"dashboard.cards.new_last_hour": "Nouvelles dans la dernière heure",
"dashboard.cards.total_logged": "Événements de blocage enregistrés",
"dashboard.table.jail_name": "Nom du Jail",
"dashboard.table.total_banned": "Total bloqués",
"dashboard.table.new_last_hour": "Nouveaux dans la dernière heure",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "Aucun blocage récent trouvé.",
"dashboard.no_banned_ips": "Aucune IP bloquée",
"dashboard.unban": "Débloquer",
"logs.overview.title": "Vue d'ensemble interne des journaux",
"logs.overview.subtitle": "Événements enregistrés par Fail2ban-UI sur l'ensemble des connecteurs.",
"logs.overview.refresh": "Actualiser les données",
"logs.overview.total_events": "Total d'événements enregistrés",
"logs.overview.per_server": "Événements par serveur",
"logs.overview.recent_events_title": "Événements enregistrés récents",
"logs.overview.recent_empty": "Aucun événement enregistré trouvé pour le serveur sélectionné.",
"logs.overview.empty": "Aucun événement de blocage n'a encore été enregistré.",
"logs.table.server": "Serveur",
"logs.table.count": "Nombre",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Heure",
"logs.table.country": "Pays",
"filter_debug.title": "Débogage des filtres",
"filter_debug.select_filter": "Sélectionnez un filtre",
"filter_debug.log_lines": "Lignes de log",
@@ -34,6 +61,8 @@
"settings.language": "Langue",
"settings.enable_debug": "Activer le mode débogage",
"settings.alert": "Paramètres d'alerte",
"settings.callback_url": "URL de rappel Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Email de destination (récepteur des alertes)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Pays d'alerte",
@@ -64,7 +93,64 @@
"modal.filter_config": "Configuration du filtre:",
"modal.cancel": "Annuler",
"modal.save": "Enregistrer",
"modal.close": "Fermer",
"loading": "Chargement...",
"dashboard.manage_jails": "Gérer les jails",
"modal.manage_jails_title": "Gérer les jails"
"modal.manage_jails_title": "Gérer les jails",
"servers.selector.label": "Serveur actif",
"servers.selector.empty": "Aucun serveur configuré",
"servers.selector.none": "Aucun serveur configuré. Veuillez ajouter un serveur Fail2ban.",
"servers.modal.title": "Gérer les serveurs Fail2ban",
"servers.modal.description": "Enregistrez des instances Fail2ban distantes et choisissez comment l'interface s'y connecte.",
"servers.modal.list_title": "Serveurs enregistrés",
"servers.modal.list_empty": "Aucun serveur configuré. Ajoutez votre premier serveur Fail2ban via le formulaire.",
"servers.modal.form_title": "Ajouter ou mettre à jour un serveur",
"servers.form.name": "Nom à afficher",
"servers.form.name_placeholder": "Mon serveur Fail2ban",
"servers.form.type": "Type de connexion",
"servers.type.local": "Local (même hôte)",
"servers.type.ssh": "SSH",
"servers.type.agent": "Agent API",
"servers.form.host": "Nom d'hôte / IP",
"servers.form.host_placeholder": "fail2ban.exemple.com",
"servers.form.port": "Port",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Chemin du socket Fail2ban",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Chemin du log Fail2ban",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Nom d'hôte du serveur",
"servers.form.hostname_placeholder": "optionnel",
"servers.form.ssh_user": "Utilisateur SSH",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "Chemin de la clé SSH",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "URL de l'agent",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Secret de l'agent",
"servers.form.agent_secret_placeholder": "jeton partagé",
"servers.form.tags": "Étiquettes",
"servers.form.tags_placeholder": "étiquettes séparées par des virgules",
"servers.form.set_default": "Définir comme serveur par défaut",
"servers.form.enabled": "Activer le connecteur",
"servers.form.submit": "Enregistrer le serveur",
"servers.form.reset": "Réinitialiser",
"servers.form.success": "Serveur enregistré avec succès.",
"servers.badge.default": "Par défaut",
"servers.badge.enabled": "Activé",
"servers.badge.disabled": "Désactivé",
"servers.actions.edit": "Modifier",
"servers.actions.set_default": "Définir par défaut",
"servers.actions.enable": "Activer",
"servers.actions.disable": "Désactiver",
"servers.actions.test": "Tester la connexion",
"servers.actions.test_success": "Connexion réussie",
"servers.actions.test_failure": "Échec de la connexion",
"servers.actions.delete": "Supprimer",
"servers.actions.delete_confirm": "Supprimer ce serveur ?",
"servers.form.select_key": "Sélectionner la clé privée",
"servers.form.select_key_placeholder": "Saisie manuelle",
"servers.form.no_keys": "Aucune clé SSH trouvée ; saisissez le chemin manuellement",
"filter_debug.not_available": "Le débogage des filtres n'est disponible que pour les connecteurs locaux.",
"filter_debug.local_missing": "Le répertoire de filtres Fail2ban local est introuvable sur cet hôte."
}

View File

@@ -7,8 +7,21 @@
"restart_banner.button": "Riavvia il servizio",
"dashboard.title": "Cruscotto",
"dashboard.overview": "Panoramica dei jail e dei blocchi attivi",
"dashboard.overview_hint": "Usa la ricerca per filtrare le IP bloccate e fai clic su un jail per modificarne la configurazione.",
"dashboard.search_label": "Cerca IP bloccate",
"dashboard.search_placeholder": "Inserisci l'indirizzo IP da cercare",
"dashboard.external_ip": "La tua IP ext.:",
"dashboard.manage_servers": "Gestisci server",
"dashboard.no_servers_title": "Nessun server Fail2ban configurato",
"dashboard.no_servers_body": "Aggiungi un server per iniziare a monitorare e controllare le istanze Fail2ban.",
"dashboard.loading_summary": "Caricamento del riepilogo…",
"dashboard.no_enabled_servers_title": "Nessun connettore attivo",
"dashboard.no_enabled_servers_body": "Abilita il connettore locale o registra un server Fail2ban remoto per visualizzare dati in tempo reale.",
"dashboard.errors.summary_failed": "Impossibile caricare il riepilogo dal server.",
"dashboard.cards.active_jails": "Jail attivi",
"dashboard.cards.total_banned": "IP bloccate totali",
"dashboard.cards.new_last_hour": "Nuove nell'ultima ora",
"dashboard.cards.total_logged": "Eventi di blocco memorizzati",
"dashboard.table.jail_name": "Nome del Jail",
"dashboard.table.total_banned": "Totale bloccate",
"dashboard.table.new_last_hour": "Nuove nell'ultima ora",
@@ -22,6 +35,20 @@
"dashboard.no_recent_bans": "Nessun blocco recente trovato.",
"dashboard.no_banned_ips": "Nessuna IP bloccata",
"dashboard.unban": "Sblocca",
"logs.overview.title": "Panoramica interna dei log",
"logs.overview.subtitle": "Eventi memorizzati da Fail2ban-UI su tutti i connettori.",
"logs.overview.refresh": "Aggiorna dati",
"logs.overview.total_events": "Eventi memorizzati totali",
"logs.overview.per_server": "Eventi per server",
"logs.overview.recent_events_title": "Eventi memorizzati recenti",
"logs.overview.recent_empty": "Nessun evento memorizzato trovato per il server selezionato.",
"logs.overview.empty": "Nessun evento di blocco è stato ancora registrato.",
"logs.table.server": "Server",
"logs.table.count": "Conteggio",
"logs.table.jail": "Jail",
"logs.table.ip": "IP",
"logs.table.time": "Ora",
"logs.table.country": "Paese",
"filter_debug.title": "Debug Filtro",
"filter_debug.select_filter": "Seleziona un filtro",
"filter_debug.log_lines": "Righe di log",
@@ -34,6 +61,8 @@
"settings.language": "Lingua",
"settings.enable_debug": "Abilita debug",
"settings.alert": "Impostazioni di allarme",
"settings.callback_url": "URL di callback Fail2ban",
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
"settings.destination_email": "Email di destinazione (ricevente allarmi)",
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
"settings.alert_countries": "Paesi per allarme",
@@ -64,7 +93,64 @@
"modal.filter_config": "Configurazione del filtro:",
"modal.cancel": "Annulla",
"modal.save": "Salva",
"modal.close": "Chiudi",
"loading": "Caricamento...",
"dashboard.manage_jails": "Gestire i jails",
"modal.manage_jails_title": "Gestire i jails"
"modal.manage_jails_title": "Gestire i jails",
"servers.selector.label": "Server attivo",
"servers.selector.empty": "Nessun server configurato",
"servers.selector.none": "Nessun server configurato. Aggiungi un server Fail2ban.",
"servers.modal.title": "Gestisci i server Fail2ban",
"servers.modal.description": "Registra istanze Fail2ban remote e scegli come l'interfaccia si connette.",
"servers.modal.list_title": "Server registrati",
"servers.modal.list_empty": "Nessun server configurato. Aggiungi il tuo primo server Fail2ban tramite il modulo.",
"servers.modal.form_title": "Aggiungi o aggiorna un server",
"servers.form.name": "Nome visualizzato",
"servers.form.name_placeholder": "Il mio server Fail2ban",
"servers.form.type": "Tipo di connessione",
"servers.type.local": "Locale (stesso host)",
"servers.type.ssh": "SSH",
"servers.type.agent": "Agente API",
"servers.form.host": "Nome host / IP",
"servers.form.host_placeholder": "fail2ban.esempio.com",
"servers.form.port": "Porta",
"servers.form.port_placeholder": "22",
"servers.form.socket_path": "Percorso del socket Fail2ban",
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
"servers.form.log_path": "Percorso del log Fail2ban",
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
"servers.form.hostname": "Nome host del server",
"servers.form.hostname_placeholder": "opzionale",
"servers.form.ssh_user": "Utente SSH",
"servers.form.ssh_user_placeholder": "root",
"servers.form.ssh_key": "Percorso della chiave SSH",
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
"servers.form.agent_url": "URL dell'agente",
"servers.form.agent_url_placeholder": "https://host:9443",
"servers.form.agent_secret": "Segreto dell'agente",
"servers.form.agent_secret_placeholder": "token condiviso",
"servers.form.tags": "Tag",
"servers.form.tags_placeholder": "tag separati da virgole",
"servers.form.set_default": "Imposta come server predefinito",
"servers.form.enabled": "Abilita connettore",
"servers.form.submit": "Salva server",
"servers.form.reset": "Reimposta",
"servers.form.success": "Server salvato correttamente.",
"servers.badge.default": "Predefinito",
"servers.badge.enabled": "Abilitato",
"servers.badge.disabled": "Disabilitato",
"servers.actions.edit": "Modifica",
"servers.actions.set_default": "Imposta predefinito",
"servers.actions.enable": "Abilita",
"servers.actions.disable": "Disabilita",
"servers.actions.test": "Verifica connessione",
"servers.actions.test_success": "Connessione riuscita",
"servers.actions.test_failure": "Connessione fallita",
"servers.actions.delete": "Elimina",
"servers.actions.delete_confirm": "Eliminare questo server?",
"servers.form.select_key": "Seleziona chiave privata",
"servers.form.select_key_placeholder": "Inserimento manuale",
"servers.form.no_keys": "Nessuna chiave SSH trovata; inserire il percorso manualmente",
"filter_debug.not_available": "Il debug dei filtri è disponibile solo per i connettori locali.",
"filter_debug.local_missing": "La directory dei filtri Fail2ban locale non è stata trovata su questo host."
}

607
internal/storage/storage.go Normal file
View File

@@ -0,0 +1,607 @@
package storage
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"time"
_ "modernc.org/sqlite"
)
var (
db *sql.DB
initOnce sync.Once
initErr error
defaultPath = "fail2ban-ui.db"
)
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
func intToBool(i int) bool {
return i != 0
}
func stringFromNull(ns sql.NullString) string {
if ns.Valid {
return ns.String
}
return ""
}
func intFromNull(ni sql.NullInt64) int {
if ni.Valid {
return int(ni.Int64)
}
return 0
}
type AppSettingsRecord struct {
Language string
Port int
Debug bool
CallbackURL string
RestartNeeded bool
AlertCountriesJSON string
SMTPHost string
SMTPPort int
SMTPUsername string
SMTPPassword string
SMTPFrom string
SMTPUseTLS bool
BantimeIncrement bool
IgnoreIP string
Bantime string
Findtime string
MaxRetry int
DestEmail string
}
type ServerRecord struct {
ID string
Name string
Type string
Host string
Port int
SocketPath string
LogPath string
SSHUser string
SSHKeyPath string
AgentURL string
AgentSecret string
Hostname string
TagsJSON string
IsDefault bool
Enabled bool
CreatedAt time.Time
UpdatedAt time.Time
}
// BanEventRecord represents a single ban event stored in the internal database.
type BanEventRecord struct {
ID int64 `json:"id"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
Jail string `json:"jail"`
IP string `json:"ip"`
Country string `json:"country"`
Hostname string `json:"hostname"`
Failures string `json:"failures"`
Whois string `json:"whois"`
Logs string `json:"logs"`
OccurredAt time.Time `json:"occurredAt"`
CreatedAt time.Time `json:"createdAt"`
}
// Init initializes the internal storage. Safe to call multiple times.
func Init(dbPath string) error {
initOnce.Do(func() {
if dbPath == "" {
dbPath = defaultPath
}
if err := ensureDirectory(dbPath); err != nil {
initErr = err
return
}
var err error
db, err = sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout=5000", dbPath))
if err != nil {
initErr = err
return
}
if err = db.Ping(); err != nil {
initErr = err
return
}
initErr = ensureSchema(context.Background())
})
return initErr
}
// Close closes the underlying database if it has been initialised.
func Close() error {
if db == nil {
return nil
}
return db.Close()
}
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
if db == nil {
return AppSettingsRecord{}, false, errors.New("storage not initialised")
}
row := db.QueryRowContext(ctx, `
SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
FROM app_settings
WHERE id = 1`)
var (
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail sql.NullString
port, smtpPort, maxretry sql.NullInt64
debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64
)
err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail)
if errors.Is(err, sql.ErrNoRows) {
return AppSettingsRecord{}, false, nil
}
if err != nil {
return AppSettingsRecord{}, false, err
}
rec := AppSettingsRecord{
Language: stringFromNull(lang),
Port: intFromNull(port),
Debug: intToBool(intFromNull(debug)),
CallbackURL: stringFromNull(callback),
RestartNeeded: intToBool(intFromNull(restartNeeded)),
AlertCountriesJSON: stringFromNull(alerts),
SMTPHost: stringFromNull(smtpHost),
SMTPPort: intFromNull(smtpPort),
SMTPUsername: stringFromNull(smtpUser),
SMTPPassword: stringFromNull(smtpPass),
SMTPFrom: stringFromNull(smtpFrom),
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
IgnoreIP: stringFromNull(ignoreIP),
Bantime: stringFromNull(bantime),
Findtime: stringFromNull(findtime),
MaxRetry: intFromNull(maxretry),
DestEmail: stringFromNull(destemail),
}
return rec, true, nil
}
func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
if db == nil {
return errors.New("storage not initialised")
}
_, err := db.ExecContext(ctx, `
INSERT INTO app_settings (
id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
) VALUES (
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
) ON CONFLICT(id) DO UPDATE SET
language = excluded.language,
port = excluded.port,
debug = excluded.debug,
callback_url = excluded.callback_url,
restart_needed = excluded.restart_needed,
alert_countries = excluded.alert_countries,
smtp_host = excluded.smtp_host,
smtp_port = excluded.smtp_port,
smtp_username = excluded.smtp_username,
smtp_password = excluded.smtp_password,
smtp_from = excluded.smtp_from,
smtp_use_tls = excluded.smtp_use_tls,
bantime_increment = excluded.bantime_increment,
ignore_ip = excluded.ignore_ip,
bantime = excluded.bantime,
findtime = excluded.findtime,
maxretry = excluded.maxretry,
destemail = excluded.destemail
`, rec.Language,
rec.Port,
boolToInt(rec.Debug),
rec.CallbackURL,
boolToInt(rec.RestartNeeded),
rec.AlertCountriesJSON,
rec.SMTPHost,
rec.SMTPPort,
rec.SMTPUsername,
rec.SMTPPassword,
rec.SMTPFrom,
boolToInt(rec.SMTPUseTLS),
boolToInt(rec.BantimeIncrement),
rec.IgnoreIP,
rec.Bantime,
rec.Findtime,
rec.MaxRetry,
rec.DestEmail,
)
return err
}
func ListServers(ctx context.Context) ([]ServerRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
rows, err := db.QueryContext(ctx, `
SELECT id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
FROM servers
ORDER BY created_at`)
if err != nil {
return nil, err
}
defer rows.Close()
var records []ServerRecord
for rows.Next() {
var rec ServerRecord
var host, socket, logPath, sshUser, sshKey, agentURL, agentSecret, hostname, tags sql.NullString
var name, serverType sql.NullString
var created, updated sql.NullString
var port sql.NullInt64
var isDefault, enabled sql.NullInt64
if err := rows.Scan(
&rec.ID,
&name,
&serverType,
&host,
&port,
&socket,
&logPath,
&sshUser,
&sshKey,
&agentURL,
&agentSecret,
&hostname,
&tags,
&isDefault,
&enabled,
&created,
&updated,
); err != nil {
return nil, err
}
rec.Name = stringFromNull(name)
rec.Type = stringFromNull(serverType)
rec.Host = stringFromNull(host)
rec.Port = intFromNull(port)
rec.SocketPath = stringFromNull(socket)
rec.LogPath = stringFromNull(logPath)
rec.SSHUser = stringFromNull(sshUser)
rec.SSHKeyPath = stringFromNull(sshKey)
rec.AgentURL = stringFromNull(agentURL)
rec.AgentSecret = stringFromNull(agentSecret)
rec.Hostname = stringFromNull(hostname)
rec.TagsJSON = stringFromNull(tags)
rec.IsDefault = intToBool(intFromNull(isDefault))
rec.Enabled = intToBool(intFromNull(enabled))
if created.Valid {
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
rec.CreatedAt = t
}
}
if updated.Valid {
if t, err := time.Parse(time.RFC3339Nano, updated.String); err == nil {
rec.UpdatedAt = t
}
}
records = append(records, rec)
}
return records, rows.Err()
}
func ReplaceServers(ctx context.Context, servers []ServerRecord) error {
if db == nil {
return errors.New("storage not initialised")
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
if _, err = tx.ExecContext(ctx, `DELETE FROM servers`); err != nil {
return err
}
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO servers (
id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)`)
if err != nil {
return err
}
defer stmt.Close()
for _, srv := range servers {
createdAt := srv.CreatedAt
if createdAt.IsZero() {
createdAt = time.Now().UTC()
}
updatedAt := srv.UpdatedAt
if updatedAt.IsZero() {
updatedAt = createdAt
}
if _, err = stmt.ExecContext(ctx,
srv.ID,
srv.Name,
srv.Type,
srv.Host,
srv.Port,
srv.SocketPath,
srv.LogPath,
srv.SSHUser,
srv.SSHKeyPath,
srv.AgentURL,
srv.AgentSecret,
srv.Hostname,
srv.TagsJSON,
boolToInt(srv.IsDefault),
boolToInt(srv.Enabled),
createdAt.Format(time.RFC3339Nano),
updatedAt.Format(time.RFC3339Nano),
); err != nil {
return err
}
}
err = tx.Commit()
return err
}
func DeleteServer(ctx context.Context, id string) error {
if db == nil {
return errors.New("storage not initialised")
}
_, err := db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
return err
}
// RecordBanEvent stores a ban event in the database.
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
if db == nil {
return errors.New("storage not initialised")
}
if record.ServerID == "" {
return errors.New("server id is required")
}
now := time.Now().UTC()
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
if record.OccurredAt.IsZero() {
record.OccurredAt = now
}
const query = `
INSERT INTO ban_events (
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := db.ExecContext(
ctx,
query,
record.ServerID,
record.ServerName,
record.Jail,
record.IP,
record.Country,
record.Hostname,
record.Failures,
record.Whois,
record.Logs,
record.OccurredAt.UTC(),
record.CreatedAt.UTC(),
)
return err
}
// ListBanEvents returns ban events ordered by creation date descending.
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
if limit <= 0 || limit > 500 {
limit = 100
}
baseQuery := `
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
FROM ban_events
WHERE 1=1`
args := []any{}
if serverID != "" {
baseQuery += " AND server_id = ?"
args = append(args, serverID)
}
if !since.IsZero() {
baseQuery += " AND occurred_at >= ?"
args = append(args, since.UTC())
}
baseQuery += " ORDER BY occurred_at DESC LIMIT ?"
args = append(args, limit)
rows, err := db.QueryContext(ctx, baseQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var results []BanEventRecord
for rows.Next() {
var rec BanEventRecord
if err := rows.Scan(
&rec.ID,
&rec.ServerID,
&rec.ServerName,
&rec.Jail,
&rec.IP,
&rec.Country,
&rec.Hostname,
&rec.Failures,
&rec.Whois,
&rec.Logs,
&rec.OccurredAt,
&rec.CreatedAt,
); err != nil {
return nil, err
}
results = append(results, rec)
}
return results, rows.Err()
}
// CountBanEventsByServer returns simple aggregation per server.
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
if db == nil {
return nil, errors.New("storage not initialised")
}
query := `
SELECT server_id, COUNT(*)
FROM ban_events
WHERE 1=1`
args := []any{}
if !since.IsZero() {
query += " AND occurred_at >= ?"
args = append(args, since.UTC())
}
query += " GROUP BY server_id"
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]int64)
for rows.Next() {
var serverID string
var count int64
if err := rows.Scan(&serverID, &count); err != nil {
return nil, err
}
result[serverID] = count
}
return result, rows.Err()
}
func ensureSchema(ctx context.Context) error {
if db == nil {
return errors.New("storage not initialised")
}
const createTable = `
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
language TEXT,
port INTEGER,
debug INTEGER,
callback_url TEXT,
restart_needed INTEGER,
alert_countries TEXT,
smtp_host TEXT,
smtp_port INTEGER,
smtp_username TEXT,
smtp_password TEXT,
smtp_from TEXT,
smtp_use_tls INTEGER,
bantime_increment INTEGER,
ignore_ip TEXT,
bantime TEXT,
findtime TEXT,
maxretry INTEGER,
destemail TEXT
);
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
name TEXT,
type TEXT,
host TEXT,
port INTEGER,
socket_path TEXT,
log_path TEXT,
ssh_user TEXT,
ssh_key_path TEXT,
agent_url TEXT,
agent_secret TEXT,
hostname TEXT,
tags TEXT,
is_default INTEGER,
enabled INTEGER,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS ban_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id TEXT NOT NULL,
server_name TEXT NOT NULL,
jail TEXT NOT NULL,
ip TEXT NOT NULL,
country TEXT,
hostname TEXT,
failures TEXT,
whois TEXT,
logs TEXT,
occurred_at DATETIME NOT NULL,
created_at DATETIME NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
`
_, err := db.ExecContext(ctx, createTable)
return err
}
func ensureDirectory(path string) error {
if path == ":memory:" {
return nil
}
dir := filepath.Dir(path)
if dir == "." || dir == "" {
return nil
}
return os.MkdirAll(dir, 0o755)
}

View File

@@ -18,6 +18,7 @@ package web
import (
"bytes"
"context"
"crypto/tls"
"errors"
"fmt"
@@ -27,6 +28,8 @@ import (
"net/http"
"net/smtp"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -35,6 +38,7 @@ import (
"github.com/oschwald/maxminddb-golang"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/storage"
)
// SummaryResponse is what we return from /api/summary
@@ -43,34 +47,66 @@ type SummaryResponse struct {
LastBans []fail2ban.BanEvent `json:"lastBans"`
}
func resolveConnector(c *gin.Context) (fail2ban.Connector, error) {
serverID := c.Query("serverId")
if serverID == "" {
serverID = c.GetHeader("X-F2B-Server")
}
manager := fail2ban.GetManager()
if serverID != "" {
return manager.Connector(serverID)
}
return manager.DefaultConnector()
}
func resolveServerForNotification(serverID, hostname string) (config.Fail2banServer, error) {
if serverID != "" {
if srv, ok := config.GetServerByID(serverID); ok {
if !srv.Enabled {
return config.Fail2banServer{}, fmt.Errorf("server %s is disabled", serverID)
}
return srv, nil
}
return config.Fail2banServer{}, fmt.Errorf("serverId %s not found", serverID)
}
if hostname != "" {
if srv, ok := config.GetServerByHostname(hostname); ok {
if !srv.Enabled {
return config.Fail2banServer{}, fmt.Errorf("server for hostname %s is disabled", hostname)
}
return srv, nil
}
}
srv := config.GetDefaultServer()
if srv.ID == "" {
return config.Fail2banServer{}, fmt.Errorf("no default fail2ban server configured")
}
if !srv.Enabled {
return config.Fail2banServer{}, fmt.Errorf("default fail2ban server is disabled")
}
return srv, nil
}
// SummaryHandler returns a JSON summary of all jails, including
// number of banned IPs, how many are new in the last hour, etc.
// and the last 5 overall ban events from the log.
func SummaryHandler(c *gin.Context) {
const logPath = "/var/log/fail2ban.log"
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
jailInfos, err := fail2ban.BuildJailInfos(logPath)
jailInfos, err := conn.GetJailInfos(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Parse the log to find last 5 ban events
eventsByJail, err := fail2ban.ParseBanLog(logPath)
lastBans := make([]fail2ban.BanEvent, 0)
if err == nil {
// If we can parse logs successfully, let's gather all events
var all []fail2ban.BanEvent
for _, evs := range eventsByJail {
all = append(all, evs...)
}
// Sort by descending time
sortByTimeDesc(all)
if len(all) > 5 {
lastBans = all[:5]
} else {
lastBans = all
}
lastBans, err := conn.FetchBanEvents(c.Request.Context(), 5)
if err != nil {
log.Printf("warning: failed to fetch ban events for summary: %v", err)
lastBans = []fail2ban.BanEvent{}
}
resp := SummaryResponse{
@@ -87,11 +123,14 @@ func UnbanIPHandler(c *gin.Context) {
jail := c.Param("jail")
ip := c.Param("ip")
err := fail2ban.UnbanIP(jail, ip)
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := conn.UnbanIP(c.Request.Context(), jail, ip); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
fmt.Println(ip + " from jail " + jail + " unbanned successfully.")
@@ -103,6 +142,7 @@ func UnbanIPHandler(c *gin.Context) {
// BanNotificationHandler processes incoming ban notifications from Fail2Ban.
func BanNotificationHandler(c *gin.Context) {
var request struct {
ServerID string `json:"serverId"`
IP string `json:"ip" binding:"required"`
Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"`
@@ -148,8 +188,14 @@ func BanNotificationHandler(c *gin.Context) {
log.Printf("✅ Parsed Ban Request - IP: %s, Jail: %s, Hostname: %s, Failures: %s",
request.IP, request.Jail, request.Hostname, request.Failures)
server, err := resolveServerForNotification(request.ServerID, request.Hostname)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Handle the Fail2Ban notification
if err := HandleBanNotification(request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil {
if err := HandleBanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, request.Failures, request.Whois, request.Logs); err != nil {
log.Printf("❌ Failed to process ban notification: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process ban notification: " + err.Error()})
return
@@ -159,8 +205,214 @@ func BanNotificationHandler(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"})
}
// HandleBanNotification processes Fail2Ban notifications, checks geo-location, and sends alerts.
func HandleBanNotification(ip, jail, hostname, failures, whois, logs string) error {
// ListBanEventsHandler returns stored ban events from the internal database.
func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId")
limit := 100
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
var since time.Time
if sinceStr := c.Query("since"); sinceStr != "" {
if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil {
since = parsed
}
}
events, err := storage.ListBanEvents(c.Request.Context(), serverID, limit, since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"events": events})
}
// BanStatisticsHandler returns aggregated ban counts per server.
func BanStatisticsHandler(c *gin.Context) {
var since time.Time
if sinceStr := c.Query("since"); sinceStr != "" {
if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil {
since = parsed
}
}
stats, err := storage.CountBanEventsByServer(c.Request.Context(), since)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"counts": stats})
}
// ListServersHandler returns configured Fail2ban servers.
func ListServersHandler(c *gin.Context) {
servers := config.ListServers()
c.JSON(http.StatusOK, gin.H{"servers": servers})
}
// UpsertServerHandler creates or updates a Fail2ban server configuration.
func UpsertServerHandler(c *gin.Context) {
var req config.Fail2banServer
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
return
}
switch strings.ToLower(req.Type) {
case "", "local":
req.Type = "local"
case "ssh":
if req.Host == "" || req.SSHUser == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ssh servers require host and sshUser"})
return
}
case "agent":
if req.AgentURL == "" || req.AgentSecret == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "agent servers require agentUrl and agentSecret"})
return
}
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported server type"})
return
}
server, err := config.UpsertServer(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"server": server})
}
// DeleteServerHandler removes a server configuration.
func DeleteServerHandler(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
return
}
if err := config.DeleteServer(id); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "server deleted"})
}
// SetDefaultServerHandler marks a server as default.
func SetDefaultServerHandler(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
return
}
server, err := config.SetDefaultServer(id)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"server": server})
}
// ListSSHKeysHandler returns SSH keys available on the UI host.
func ListSSHKeysHandler(c *gin.Context) {
home, err := os.UserHomeDir()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
dir := filepath.Join(home, ".ssh")
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
c.JSON(http.StatusOK, gin.H{"keys": []string{}, "messageKey": "servers.form.no_keys"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
var keys []string
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pem") || strings.HasSuffix(name, ".key") {
keys = append(keys, filepath.Join(dir, name))
}
}
if len(keys) == 0 {
c.JSON(http.StatusOK, gin.H{"keys": []string{}, "messageKey": "servers.form.no_keys"})
return
}
c.JSON(http.StatusOK, gin.H{"keys": keys})
}
// TestServerHandler verifies connectivity to a configured Fail2ban server.
func TestServerHandler(c *gin.Context) {
id := c.Param("id")
if id == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
return
}
server, ok := config.GetServerByID(id)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
defer cancel()
var (
conn fail2ban.Connector
err error
)
switch server.Type {
case "local":
conn = fail2ban.NewLocalConnector(server)
case "ssh":
conn, err = fail2ban.NewSSHConnector(server)
case "agent":
conn, err = fail2ban.NewAgentConnector(server)
default:
err = fmt.Errorf("unsupported server type %s", server.Type)
}
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "messageKey": "servers.actions.test_failure"})
return
}
if _, err := conn.GetJailInfos(ctx); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "messageKey": "servers.actions.test_failure"})
return
}
c.JSON(http.StatusOK, gin.H{"messageKey": "servers.actions.test_success"})
}
// HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts.
func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error {
// Load settings to get alert countries
settings := config.GetSettings()
@@ -168,12 +420,33 @@ func HandleBanNotification(ip, jail, hostname, failures, whois, logs string) err
country, err := lookupCountry(ip)
if err != nil {
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
return err
country = ""
}
event := storage.BanEventRecord{
ServerID: server.ID,
ServerName: server.Name,
Jail: jail,
IP: ip,
Country: country,
Hostname: hostname,
Failures: failures,
Whois: whois,
Logs: logs,
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
log.Printf("⚠️ Failed to record ban event: %v", err)
}
// Check if country is in alert list
displayCountry := country
if displayCountry == "" {
displayCountry = "UNKNOWN"
}
if !shouldAlertForCountry(country, settings.AlertCountries) {
log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, country, settings.AlertCountries)
log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
return nil
}
@@ -183,7 +456,7 @@ func HandleBanNotification(ip, jail, hostname, failures, whois, logs string) err
return err
}
log.Printf("✅ Email alert sent for banned IP %s (%s)", ip, country)
log.Printf("✅ Email alert sent for banned IP %s (%s)", ip, displayCountry)
return nil
}
@@ -374,6 +647,11 @@ func UpdateSettingsHandler(c *gin.Context) {
}
config.DebugLog("Settings updated successfully (handlers.go)")
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload fail2ban connectors: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated",
"restartNeeded": newSettings.RestartNeeded,
@@ -385,7 +663,26 @@ func UpdateSettingsHandler(c *gin.Context) {
func ListFiltersHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("ListFiltersHandler called (handlers.go)") // entry point
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
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"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read filter directory: " + statErr.Error()})
return
}
files, err := os.ReadDir(dir)
if err != nil {

View File

@@ -44,6 +44,14 @@ func RegisterRoutes(r *gin.Engine) {
api.POST("/settings", UpdateSettingsHandler)
api.POST("/settings/test-email", TestEmailHandler)
// Fail2ban servers management
api.GET("/servers", ListServersHandler)
api.POST("/servers", UpsertServerHandler)
api.DELETE("/servers/:id", DeleteServerHandler)
api.POST("/servers/:id/default", SetDefaultServerHandler)
api.GET("/ssh/keys", ListSSHKeysHandler)
api.POST("/servers/:id/test", TestServerHandler)
// Filter debugger endpoints
api.GET("/filters", ListFiltersHandler)
api.POST("/filters/test", TestFilterHandler)
@@ -56,5 +64,9 @@ func RegisterRoutes(r *gin.Engine) {
// Handle Fail2Ban notifications
api.POST("/ban", BanNotificationHandler)
// Internal database overview
api.GET("/events/bans", ListBanEventsHandler)
api.GET("/events/bans/stats", BanStatisticsHandler)
}
}

File diff suppressed because it is too large Load Diff