diff --git a/.gitignore b/.gitignore index 8cdf339..34ffe4e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,5 @@ go.work.sum # Project specific fail2ban-ui-settings.json -_dev \ No newline at end of file +_dev +fail2ban-ui.db* \ No newline at end of file diff --git a/cmd/server/main.go b/cmd/server/main.go index 655c812..fbded03 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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) diff --git a/go.mod b/go.mod index f46960a..ae19709 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index e055ceb..31b9cf1 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/config/settings.go b/internal/config/settings.go index add7c87..e937159 100644 --- a/internal/config/settings.go +++ b/internal/config/settings.go @@ -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"` @@ -59,50 +71,358 @@ type AppSettings struct { // init paths to key-files const ( - settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started - defaultJailFile = "/etc/fail2ban/jail.conf" - 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" + settingsFile = "fail2ban-ui-settings.json" // this file is created, relatively to where the app was started + defaultJailFile = "/etc/fail2ban/jail.conf" + 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 '' \ + --arg jail '' \ + --arg hostname '' \ + --arg failures '' \ + --arg whois "$(whois || echo 'missing whois program')" \ + --arg logs "$(tac | grep -wF )" \ + '{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 ` + // in-memory copy of settings var ( currentSettings AppSettings settingsLock sync.RWMutex ) -func init() { - // Attempt to load existing file; if it doesn't exist, create with defaults. - if err := loadSettings(); err != nil { - 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.") +var ( + errSettingsNotFound = errors.New("settings not found") + backgroundCtx = context.Background() +) - // save defaults to file - if err := saveSettings(); err != nil { - fmt.Println("Failed to save default settings:", err) +// 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() { + 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 with defaults.") + } + + 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,40 +511,126 @@ 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 := ¤tSettings.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 + 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 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) - } - fmt.Println("Successfully created jail.local from jail.conf.") - file, err = os.Open(jailFile) - if err != nil { - return err - } - } else { - return err // Other error + 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) + } + file, err = os.Open(jailFile) + } + if err != nil { + return err } defer file.Close() @@ -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 '' \ - --arg jail '' \ - --arg hostname '' \ - --arg failures '' \ - --arg whois "$(whois || echo 'missing whois program')" \ - --arg logs "$(tac | grep -wF )" \ - '{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 ` - - // 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 ` 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 + } + url = fmt.Sprintf("http://127.0.0.1:%d", port) } - 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 + 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 } - // Write again the Fail2ban-UI action file (in the future not used anymore) - return writeFail2banAction() + 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 := ¤tSettings.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 } diff --git a/internal/fail2ban/client.go b/internal/fail2ban/client.go index 6ed4b07..50c39cd 100644 --- a/internal/fail2ban/client.go +++ b/internal/fail2ban/client.go @@ -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 unbanip " 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()) } diff --git a/internal/fail2ban/connector_agent.go b/internal/fail2ban/connector_agent.go new file mode 100644 index 0000000..9c2050e --- /dev/null +++ b/internal/fail2ban/connector_agent.go @@ -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) +} diff --git a/internal/fail2ban/connector_local.go b/internal/fail2ban/connector_local.go new file mode 100644 index 0000000..9f4b724 --- /dev/null +++ b/internal/fail2ban/connector_local.go @@ -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 +} diff --git a/internal/fail2ban/connector_ssh.go b/internal/fail2ban/connector_ssh.go new file mode 100644 index 0000000..c119ad8 --- /dev/null +++ b/internal/fail2ban/connector_ssh.go @@ -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 +} diff --git a/internal/fail2ban/filter_management.go b/internal/fail2ban/filter_management.go index 45eda8b..b03750b 100644 --- a/internal/fail2ban/filter_management.go +++ b/internal/fail2ban/filter_management.go @@ -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/.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) diff --git a/internal/fail2ban/manager.go b/internal/fail2ban/manager.go new file mode 100644 index 0000000..7de95ef --- /dev/null +++ b/internal/fail2ban/manager.go @@ -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) + } +} diff --git a/internal/locales/de.json b/internal/locales/de.json index 66b23f5..abbec26 100644 --- a/internal/locales/de.json +++ b/internal/locales/de.json @@ -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." } \ No newline at end of file diff --git a/internal/locales/de_ch.json b/internal/locales/de_ch.json index 090c4fd..07c0b36 100644 --- a/internal/locales/de_ch.json +++ b/internal/locales/de_ch.json @@ -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." } \ No newline at end of file diff --git a/internal/locales/en.json b/internal/locales/en.json index c3a202c..30eaa27 100644 --- a/internal/locales/en.json +++ b/internal/locales/en.json @@ -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." } \ No newline at end of file diff --git a/internal/locales/es.json b/internal/locales/es.json index 0945863..3814692 100644 --- a/internal/locales/es.json +++ b/internal/locales/es.json @@ -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." } diff --git a/internal/locales/fr.json b/internal/locales/fr.json index 8f37b50..a480b93 100644 --- a/internal/locales/fr.json +++ b/internal/locales/fr.json @@ -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." } diff --git a/internal/locales/it.json b/internal/locales/it.json index 28505a2..ad7e7f2 100644 --- a/internal/locales/it.json +++ b/internal/locales/it.json @@ -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." } diff --git a/internal/storage/storage.go b/internal/storage/storage.go new file mode 100644 index 0000000..018b269 --- /dev/null +++ b/internal/storage/storage.go @@ -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) +} diff --git a/pkg/web/handlers.go b/pkg/web/handlers.go index 286f615..6dae5b7 100644 --- a/pkg/web/handlers.go +++ b/pkg/web/handlers.go @@ -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 { diff --git a/pkg/web/routes.go b/pkg/web/routes.go index 04d65dc..4180caa 100644 --- a/pkg/web/routes.go +++ b/pkg/web/routes.go @@ -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) } } diff --git a/pkg/web/templates/index.html b/pkg/web/templates/index.html index 834ca3e..5eac8bf 100644 --- a/pkg/web/templates/index.html +++ b/pkg/web/templates/index.html @@ -199,21 +199,33 @@
-
-

Dashboard

-
-
- Your ext. IP: Loading… +
+
+

Dashboard

+

+
+
+
+
+ Your ext. IP: + Loading… +
+
+ +
-
- -
+ +
@@ -224,6 +236,8 @@ + + + @@ -665,15 +796,44 @@ showLoading(true); var currentJailForConfig = null; + var serversCache = []; + var currentServerId = null; + var currentServer = null; + var latestSummary = null; + var latestSummaryError = null; + var latestBanStats = {}; + var latestBanEvents = []; + var translations = {}; + var sshKeysCache = null; + + function t(key, fallback) { + if (translations && Object.prototype.hasOwnProperty.call(translations, key) && translations[key]) { + return translations[key]; + } + return fallback !== undefined ? fallback : key; + } + window.addEventListener('DOMContentLoaded', function() { + showLoading(true); displayExternalIP(); - checkRestartNeeded(); - fetchSummary().then(function() { - showLoading(false); - initializeTooltips(); // Initialize tooltips after fetching and rendering - initializeSearch(); - getTranslationsSettingsOnPageload(); - }); + Promise.all([ + loadServers(), + getTranslationsSettingsOnPageload() + ]) + .then(function() { + checkRestartNeeded(); + return refreshData({ silent: true }); + }) + .catch(function(err) { + console.error('Initialization error:', err); + latestSummaryError = err ? err.toString() : 'failed to initialize'; + renderDashboard(); + }) + .finally(function() { + initializeTooltips(); // Initialize tooltips after fetching and rendering + initializeSearch(); + showLoading(false); + }); }); // ******************************************************************* @@ -788,6 +948,149 @@ menu.classList.toggle('hidden'); } + function escapeHtml(value) { + if (value === undefined || value === null) return ''; + return String(value).replace(/[&<>"']/g, function(match) { + switch (match) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + default: return '''; + } + }); + } + + function withServerParam(url) { + if (!currentServerId) { + return url; + } + return url + (url.indexOf('?') === -1 ? '?' : '&') + 'serverId=' + encodeURIComponent(currentServerId); + } + + function serverHeaders(headers) { + headers = headers || {}; + if (currentServerId) { + headers['X-F2B-Server'] = currentServerId; + } + return headers; + } + + function loadServers() { + return fetch('/api/servers') + .then(function(res) { return res.json(); }) + .then(function(data) { + serversCache = data.servers || []; + var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + if (!enabledServers.length) { + currentServerId = null; + currentServer = null; + } else { + var desired = currentServerId; + var selected = desired ? enabledServers.find(function(s) { return s.id === desired; }) : null; + if (!selected) { + var def = enabledServers.find(function(s) { return s.isDefault; }); + selected = def || enabledServers[0]; + } + currentServer = selected; + currentServerId = selected ? selected.id : null; + } + renderServerSelector(); + renderServerSubtitle(); + }) + .catch(function(err) { + console.error('Error loading servers:', err); + serversCache = []; + currentServerId = null; + currentServer = null; + renderServerSelector(); + renderServerSubtitle(); + }); + } + + function renderServerSelector() { + var container = document.getElementById('serverSelectorContainer'); + if (!container) return; + var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + if (!serversCache.length) { + container.innerHTML = '
No servers configured
'; + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + return; + } + if (!enabledServers.length) { + container.innerHTML = '
No servers configured
'; + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + return; + } + + var options = enabledServers.map(function(server) { + var label = escapeHtml(server.name || server.id); + var type = server.type ? (' (' + server.type.toUpperCase() + ')') : ''; + return ''; + }).join(''); + + container.innerHTML = '' + + '
' + + ' ' + + ' ' + + '
'; + + var select = document.getElementById('serverSelect'); + if (select) { + select.value = currentServerId || ''; + select.addEventListener('change', function(e) { + setCurrentServer(e.target.value); + }); + } + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + } + + function renderServerSubtitle() { + var subtitle = document.getElementById('currentServerSubtitle'); + if (!subtitle) return; + if (!currentServer) { + subtitle.textContent = t('servers.selector.none', 'No server configured. Please add a Fail2ban server.'); + subtitle.classList.add('text-red-500'); + return; + } + subtitle.classList.remove('text-red-500'); + var parts = []; + parts.push(currentServer.name || currentServer.id); + parts.push(currentServer.type ? currentServer.type.toUpperCase() : 'LOCAL'); + if (currentServer.host) { + var host = currentServer.host; + if (currentServer.port) { + host += ':' + currentServer.port; + } + parts.push(host); + } else if (currentServer.hostname) { + parts.push(currentServer.hostname); + } + subtitle.textContent = parts.join(' • '); + } + + function setCurrentServer(serverId) { + if (!serverId) { + currentServerId = null; + currentServer = null; + } else { + var next = serversCache.find(function(s) { return s.id === serverId && s.enabled; }); + currentServer = next || null; + currentServerId = currentServer ? currentServer.id : null; + } + renderServerSelector(); + renderServerSubtitle(); + refreshData(); + } + // Close modal function closeModal(modalId) { document.getElementById(modalId).classList.add('hidden'); @@ -803,138 +1106,257 @@ //* Fetch data and render dashboard functions * //******************************************************************* - // Fetch summary (jails, stats, last bans) - function fetchSummary() { - return fetch('/api/summary') - .then(function(res) { return res.json(); }) - .then(function(data) { - if (data.error) { - document.getElementById('dashboard').innerHTML = - '
' + data.error + '
'; - return; - } - renderDashboard(data); + function refreshData(options) { + options = options || {}; + var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + if (!serversCache.length || !enabledServers.length || !currentServerId) { + latestSummary = null; + latestSummaryError = null; + latestBanStats = {}; + latestBanEvents = []; + renderDashboard(); + return Promise.resolve(); + } + + if (!options.silent) { + showLoading(true); + } + + return Promise.all([ + fetchSummaryData(), + fetchBanStatisticsData(), + fetchBanEventsData() + ]) + .then(function() { + renderDashboard(); }) .catch(function(err) { - document.getElementById('dashboard').innerHTML = - '
Error: ' + err + '
'; + console.error('Error refreshing data:', err); + latestSummaryError = err ? err.toString() : 'Unknown error'; + renderDashboard(); + }) + .finally(function() { + if (!options.silent) { + showLoading(false); + } }); } - // Render the main dashboard - function renderDashboard(data) { + function fetchSummaryData() { + return fetch(withServerParam('/api/summary')) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data && !data.error) { + latestSummary = data; + latestSummaryError = null; + } else { + latestSummary = null; + latestSummaryError = data && data.error ? data.error : t('dashboard.errors.summary_failed', 'Failed to load summary from server.'); + } + }) + .catch(function(err) { + latestSummary = null; + latestSummaryError = err ? err.toString() : 'Unknown error'; + }); + } - var html = ` -
-
-

Active Jails

-

${data.jails.length}

-
-
-

Total Banned IPs

-

- ${data.jails.reduce((sum, j) => sum + j.totalBanned, 0)} -

-
-
-

New Last Hour

-

- ${data.jails.reduce((sum, j) => sum + j.newInLastHour, 0)} -

-
-
-

Recent Bans

-

${data.lastBans ? data.lastBans.length : 0}

-
-
- `; + function fetchBanStatisticsData() { + return fetch(withServerParam('/api/events/bans/stats')) + .then(function(res) { return res.json(); }) + .then(function(data) { + latestBanStats = data && data.counts ? data.counts : {}; + }) + .catch(function(err) { + console.error('Error fetching ban statistics:', err); + latestBanStats = latestBanStats || {}; + }); + } - // Add a search bar - html += ` -
-

- Overview active Jails and Blocks -

-
- - -
- `; + function fetchBanEventsData() { + return fetch(withServerParam('/api/events/bans?limit=25')) + .then(function(res) { return res.json(); }) + .then(function(data) { + latestBanEvents = data && data.events ? data.events : []; + }) + .catch(function(err) { + console.error('Error fetching ban events:', err); + latestBanEvents = latestBanEvents || []; + }); + } - // Jails table - if (!data.jails || data.jails.length === 0) { - html += '

No jails found.

'; + function formatDateTime(value) { + if (!value) return ''; + var date = new Date(value); + if (isNaN(date.getTime())) { + return value; + } + return date.toLocaleString(); + } + + function totalStoredBans() { + if (!latestBanStats) return 0; + return Object.keys(latestBanStats).reduce(function(sum, key) { + return sum + (latestBanStats[key] || 0); + }, 0); + } + + function renderDashboard() { + var container = document.getElementById('dashboard'); + if (!container) return; + + var enabledServers = serversCache.filter(function(s) { return s.enabled; }); + if (!serversCache.length) { + container.innerHTML = '' + + ''; + if (typeof updateTranslations === 'function') updateTranslations(); + return; + } + if (!enabledServers.length) { + container.innerHTML = '' + + ''; + if (typeof updateTranslations === 'function') updateTranslations(); + return; + } + + var summary = latestSummary; + var html = ''; + + if (latestSummaryError) { + html += '' + + '
' + + escapeHtml(latestSummaryError) + + '
'; + } + + if (!summary) { + html += '' + + '
' + + '

Loading summary data…

' + + '
'; + container.innerHTML = html; + if (typeof updateTranslations === 'function') updateTranslations(); + return; + } + + var totalBanned = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.totalBanned || 0); }, 0) : 0; + var newLastHour = summary.jails ? summary.jails.reduce(function(sum, j) { return sum + (j.newInLastHour || 0); }, 0) : 0; + var totalStored = totalStoredBans(); + + html += '' + + '
' + + '
' + + '

Active Jails

' + + '

' + (summary.jails ? summary.jails.length : 0) + '

' + + '
' + + '
' + + '

Total Banned IPs

' + + '

' + totalBanned + '

' + + '
' + + '
' + + '

New Last Hour

' + + '

' + newLastHour + '

' + + '
' + + '
' + + '

Stored Ban Events

' + + '

' + totalStored + '

' + + '
' + + '
'; + + html += '' + + '
' + + '
' + + '
' + + '

Overview active Jails and Blocks

' + + '

Use the search to filter banned IPs and click a jail to edit its configuration.

' + + '
' + + '
' + + ' ' + + ' ' + + '
' + + '
'; + + if (!summary.jails || summary.jails.length === 0) { + html += '

No jails found.

'; } else { html += '' - + '
' - + '' - + '' - + '' - + '' - + '' - + '' - + '' - + '' - + ' ' - + ' '; + + '
' + + '
Jail NameBanned IPs
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; - data.jails.forEach(function(jail) { - var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs); + summary.jails.forEach(function(jail) { + var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs || []); html += '' + '' + ' ' - + ' ' - + ' ' + + ' ' + + ' ' + ' ' + ''; }); - html += '
JailBanned IPs
' - + ' ' - + jail.jailName + + ' ' + + escapeHtml(jail.jailName) + ' ' + ' ' + bannedHTML + '
'; - html += '
'; + html += ' '; + html += '
'; } - // Last 5 bans - html += '
'; - html += '

Last 5 Ban Events

'; - if (!data.lastBans || data.lastBans.length === 0) { - html += '

No recent bans found.

'; - } else { - html += '' - + '
' - + '' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' ' - + ' '; - - data.lastBans.forEach(function(e) { + if (summary.lastBans) { + html += '
'; + html += '

Last 5 Ban Events

'; + if (!summary.lastBans.length) { + html += '

No recent bans found.

'; + } else { html += '' - + '
' - + ' ' - + ' ' - + ' ' - + ' ' - + ''; - }); - - html += '
TimeLog Line
' + e.Time + '' + e.LogLine + '
'; + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; + summary.lastBans.forEach(function(event) { + html += '' + + '' + + ' ' + + ' ' + + ' ' + + ' ' + + ''; + }); + html += '
TimeDetails
' + escapeHtml(event.Time || '') + '' + escapeHtml(event.LogLine || '') + '
'; + } + html += '
'; } - html += '
'; - document.getElementById('dashboard').innerHTML = html; + html += '
'; // close overview card + + html += renderLogOverview(); + + container.innerHTML = html; const extIpEl = document.getElementById('external-ip'); if (extIpEl) { - extIpEl.addEventListener('click', () => { + extIpEl.addEventListener('click', function() { const ip = extIpEl.textContent.trim(); const searchInput = document.getElementById('ipSearch'); if (searchInput) { @@ -945,6 +1367,493 @@ } }); } + + filterIPs(); + initializeSearch(); + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + } + + function renderLogOverview() { + var html = '' + + '
' + + '
' + + '
' + + '

Internal Log Overview

' + + '

Events stored by Fail2ban-UI across all connectors.

' + + '
' + + ' ' + + '
'; + + var statsKeys = Object.keys(latestBanStats || {}); + if (statsKeys.length === 0) { + html += '

No ban events recorded yet.

'; + } else { + html += '' + + '
' + + '
' + + '

Total stored events

' + + '

' + totalStoredBans() + '

' + + '
' + + '
' + + '

Events per server

' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; + statsKeys.forEach(function(serverId) { + var count = latestBanStats[serverId] || 0; + var server = serversCache.find(function(s) { return s.id === serverId; }); + html += '' + + ' ' + + ' ' + + ' ' + + ' '; + }); + html += '
ServerCount
' + escapeHtml(server ? server.name : serverId) + '' + count + '
'; + } + + html += '

Recent stored events

'; + + if (!latestBanEvents.length) { + html += '

No stored events found for the selected server.

'; + } else { + html += '' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; + latestBanEvents.forEach(function(event) { + html += '' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' '; + }); + html += '
TimeServerIP
' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '' + escapeHtml(event.serverName || event.serverId || '') + '' + escapeHtml(event.ip || '') + '
'; + } + + html += '
'; + return html; + } + + //******************************************************************* + //* Server management helper functions * + //******************************************************************* + + function openServerManager(serverId) { + showLoading(true); + loadServers() + .then(function() { + if (serverId) { + editServer(serverId); + } else { + resetServerForm(); + } + renderServerManagerList(); + openModal('serverManagerModal'); + }) + .finally(function() { + showLoading(false); + }); + } + + function renderServerManagerList() { + var list = document.getElementById('serverManagerList'); + var emptyState = document.getElementById('serverManagerListEmpty'); + if (!list || !emptyState) return; + + if (!serversCache.length) { + list.innerHTML = ''; + emptyState.classList.remove('hidden'); + if (typeof updateTranslations === 'function') updateTranslations(); + return; + } + + emptyState.classList.add('hidden'); + + var html = serversCache.map(function(server) { + var statusBadge = server.enabled + ? 'Enabled' + : 'Disabled'; + var defaultBadge = server.isDefault + ? 'Default' + : ''; + var descriptor = []; + if (server.type) { + descriptor.push(server.type.toUpperCase()); + } + if (server.host) { + var endpoint = server.host; + if (server.port) { + endpoint += ':' + server.port; + } + descriptor.push(endpoint); + } else if (server.hostname) { + descriptor.push(server.hostname); + } + var meta = descriptor.join(' • '); + var tags = (server.tags || []).length + ? '
' + escapeHtml(server.tags.join(', ')) + '
' + : ''; + return '' + + '
' + + '
' + + '
' + + '

' + escapeHtml(server.name || server.id) + defaultBadge + statusBadge + '

' + + '

' + escapeHtml(meta || server.id) + '

' + + tags + + '
' + + '
' + + ' ' + + (server.isDefault ? '' : '') + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + '
'; + }).join(''); + + list.innerHTML = html; + if (typeof updateTranslations === 'function') updateTranslations(); + } + + function resetServerForm() { + document.getElementById('serverId').value = ''; + document.getElementById('serverName').value = ''; + document.getElementById('serverType').value = 'local'; + document.getElementById('serverHost').value = ''; + document.getElementById('serverPort').value = ''; + document.getElementById('serverSocket').value = '/var/run/fail2ban/fail2ban.sock'; + document.getElementById('serverLogPath').value = '/var/log/fail2ban.log'; + document.getElementById('serverHostname').value = ''; + document.getElementById('serverSSHUser').value = ''; + document.getElementById('serverSSHKey').value = ''; + document.getElementById('serverAgentUrl').value = ''; + document.getElementById('serverAgentSecret').value = ''; + document.getElementById('serverTags').value = ''; + document.getElementById('serverDefault').checked = false; + document.getElementById('serverEnabled').checked = false; + populateSSHKeySelect(sshKeysCache || [], ''); + onServerTypeChange('local'); + } + + function editServer(serverId) { + var server = serversCache.find(function(s) { return s.id === serverId; }); + if (!server) return; + document.getElementById('serverId').value = server.id || ''; + document.getElementById('serverName').value = server.name || ''; + document.getElementById('serverType').value = server.type || 'local'; + document.getElementById('serverHost').value = server.host || ''; + document.getElementById('serverPort').value = server.port || ''; + document.getElementById('serverSocket').value = server.socketPath || '/var/run/fail2ban/fail2ban.sock'; + document.getElementById('serverLogPath').value = server.logPath || '/var/log/fail2ban.log'; + document.getElementById('serverHostname').value = server.hostname || ''; + document.getElementById('serverSSHUser').value = server.sshUser || ''; + document.getElementById('serverSSHKey').value = server.sshKeyPath || ''; + document.getElementById('serverAgentUrl').value = server.agentUrl || ''; + document.getElementById('serverAgentSecret').value = server.agentSecret || ''; + document.getElementById('serverTags').value = (server.tags || []).join(','); + document.getElementById('serverDefault').checked = !!server.isDefault; + document.getElementById('serverEnabled').checked = !!server.enabled; + onServerTypeChange(server.type || 'local'); + if ((server.type || 'local') === 'ssh') { + loadSSHKeys().then(function(keys) { + populateSSHKeySelect(keys, server.sshKeyPath || ''); + }); + } + } + + function onServerTypeChange(type) { + document.querySelectorAll('[data-server-fields]').forEach(function(el) { + var values = (el.getAttribute('data-server-fields') || '').split(/\s+/); + if (values.indexOf(type) !== -1) { + el.classList.remove('hidden'); + } else { + el.classList.add('hidden'); + } + }); + var enabledToggle = document.getElementById('serverEnabled'); + if (!enabledToggle) return; + var isEditing = !!document.getElementById('serverId').value; + if (isEditing) { + return; + } + if (type === 'local') { + enabledToggle.checked = false; + } else { + enabledToggle.checked = true; + } + if (type === 'ssh') { + loadSSHKeys().then(function(keys) { + if (!isEditing) { + populateSSHKeySelect(keys, ''); + } + }); + } else { + populateSSHKeySelect([], ''); + } + } + + function submitServerForm(event) { + event.preventDefault(); + showLoading(true); + + var payload = { + id: document.getElementById('serverId').value || undefined, + name: document.getElementById('serverName').value.trim(), + type: document.getElementById('serverType').value, + host: document.getElementById('serverHost').value.trim(), + port: document.getElementById('serverPort').value ? parseInt(document.getElementById('serverPort').value, 10) : undefined, + socketPath: document.getElementById('serverSocket').value.trim(), + logPath: document.getElementById('serverLogPath').value.trim(), + hostname: document.getElementById('serverHostname').value.trim(), + sshUser: document.getElementById('serverSSHUser').value.trim(), + sshKeyPath: document.getElementById('serverSSHKey').value.trim(), + agentUrl: document.getElementById('serverAgentUrl').value.trim(), + agentSecret: document.getElementById('serverAgentSecret').value.trim(), + tags: document.getElementById('serverTags').value + ? document.getElementById('serverTags').value.split(',').map(function(tag) { return tag.trim(); }).filter(Boolean) + : [], + enabled: document.getElementById('serverEnabled').checked + }; + if (!payload.socketPath) delete payload.socketPath; + if (!payload.logPath) delete payload.logPath; + if (!payload.hostname) delete payload.hostname; + if (!payload.agentUrl) delete payload.agentUrl; + if (!payload.agentSecret) delete payload.agentSecret; + if (!payload.sshUser) delete payload.sshUser; + if (!payload.sshKeyPath) delete payload.sshKeyPath; + if (document.getElementById('serverDefault').checked) { + payload.isDefault = true; + } + + if (payload.type !== 'local' && payload.type !== 'ssh') { + delete payload.socketPath; + } + if (payload.type !== 'local') { + delete payload.logPath; + } + if (payload.type !== 'ssh') { + delete payload.sshUser; + delete payload.sshKeyPath; + } + if (payload.type !== 'agent') { + delete payload.agentUrl; + delete payload.agentSecret; + } + + fetch('/api/servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.error) { + alert('Error saving server: ' + data.error); + return; + } + alert(t('servers.form.success', 'Server saved successfully.')); + var saved = data.server || {}; + currentServerId = saved.id || currentServerId; + return loadServers().then(function() { + renderServerManagerList(); + renderServerSelector(); + renderServerSubtitle(); + if (currentServerId) { + currentServer = serversCache.find(function(s) { return s.id === currentServerId; }) || currentServer; + } + return refreshData({ silent: true }); + }); + }) + .catch(function(err) { + alert('Error saving server: ' + err); + }) + .finally(function() { + showLoading(false); + }); + } + + function populateSSHKeySelect(keys, selected) { + var select = document.getElementById('serverSSHKeySelect'); + if (!select) return; + var options = ''; + var selectedInList = false; + if (keys && keys.length) { + keys.forEach(function(key) { + var safe = escapeHtml(key); + if (selected && key === selected) { + selectedInList = true; + } + options += ''; + }); + } else { + options += ''; + } + if (selected && !selectedInList) { + var safeSelected = escapeHtml(selected); + options += ''; + } + select.innerHTML = options; + if (selected) { + select.value = selected; + } else { + select.value = ''; + } + if (typeof updateTranslations === 'function') { + updateTranslations(); + } + } + + function loadSSHKeys() { + if (sshKeysCache !== null) { + populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value); + return Promise.resolve(sshKeysCache); + } + return fetch('/api/ssh/keys') + .then(function(res) { return res.json(); }) + .then(function(data) { + sshKeysCache = data.keys || []; + populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value); + return sshKeysCache; + }) + .catch(function(err) { + console.error('Error loading SSH keys:', err); + sshKeysCache = []; + populateSSHKeySelect(sshKeysCache, document.getElementById('serverSSHKey').value); + return sshKeysCache; + }); + } + + function setServerEnabled(serverId, enabled) { + var server = serversCache.find(function(s) { return s.id === serverId; }); + if (!server) { + return; + } + var payload = Object.assign({}, server, { enabled: enabled }); + showLoading(true); + fetch('/api/servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.error) { + alert('Error saving server: ' + data.error); + return; + } + if (!enabled && currentServerId === serverId) { + currentServerId = null; + currentServer = null; + } + return loadServers().then(function() { + renderServerManagerList(); + renderServerSelector(); + renderServerSubtitle(); + return refreshData({ silent: true }); + }); + }) + .catch(function(err) { + alert('Error saving server: ' + err); + }) + .finally(function() { + showLoading(false); + }); + } + + function testServerConnection(serverId) { + if (!serverId) return; + showLoading(true); + fetch('/api/servers/' + encodeURIComponent(serverId) + '/test', { + method: 'POST' + }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.error) { + alert(t(data.messageKey || 'servers.actions.test_failure', data.error)); + return; + } + alert(t(data.messageKey || 'servers.actions.test_success', data.message || 'Connection successful')); + }) + .catch(function(err) { + alert(t('servers.actions.test_failure', 'Connection failed') + ': ' + err); + }) + .finally(function() { + showLoading(false); + }); + } + + function deleteServer(serverId) { + if (!confirm(t('servers.actions.delete_confirm', 'Delete this server entry?'))) return; + showLoading(true); + fetch('/api/servers/' + encodeURIComponent(serverId), { method: 'DELETE' }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.error) { + alert('Error deleting server: ' + data.error); + return; + } + if (currentServerId === serverId) { + currentServerId = null; + currentServer = null; + } + return loadServers().then(function() { + renderServerManagerList(); + renderServerSelector(); + renderServerSubtitle(); + return refreshData({ silent: true }); + }); + }) + .catch(function(err) { + alert('Error deleting server: ' + err); + }) + .finally(function() { + showLoading(false); + }); + } + + function makeDefaultServer(serverId) { + showLoading(true); + fetch('/api/servers/' + encodeURIComponent(serverId) + '/default', { method: 'POST' }) + .then(function(res) { return res.json(); }) + .then(function(data) { + if (data.error) { + alert('Error setting default server: ' + data.error); + return; + } + currentServerId = data.server ? data.server.id : serverId; + return loadServers().then(function() { + renderServerManagerList(); + renderServerSelector(); + renderServerSubtitle(); + return refreshData({ silent: true }); + }); + }) + .catch(function(err) { + alert('Error setting default server: ' + err); + }) + .finally(function() { + showLoading(false); + }); } // Render banned IPs with "Unban" button @@ -1018,7 +1927,11 @@ return; } showLoading(true); - fetch('/api/jails/' + jail + '/unban/' + ip, { method: 'POST' }) + var url = '/api/jails/' + encodeURIComponent(jail) + '/unban/' + encodeURIComponent(ip); + fetch(withServerParam(url), { + method: 'POST', + headers: serverHeaders() + }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { @@ -1026,7 +1939,7 @@ } else { alert(data.message || "IP unbanned successfully"); } - return fetchSummary(); + return refreshData({ silent: true }); }) .catch(function(err) { alert("Error: " + err); @@ -1048,7 +1961,10 @@ document.getElementById('modalJailName').textContent = jailName; showLoading(true); - fetch('/api/jails/' + jailName + '/config') + var url = '/api/jails/' + encodeURIComponent(jailName) + '/config'; + fetch(withServerParam(url), { + headers: serverHeaders() + }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { @@ -1071,9 +1987,10 @@ showLoading(true); var newConfig = document.getElementById('jailConfigTextarea').value; - fetch('/api/jails/' + currentJailForConfig + '/config', { + var url = '/api/jails/' + encodeURIComponent(currentJailForConfig) + '/config'; + fetch(withServerParam(url), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: serverHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify({ config: newConfig }), }) .then(function(res) { return res.json(); }) @@ -1097,8 +2014,14 @@ // Function: openManageJailsModal // Fetches the full-list of all jails (from /jails/manage) and builds a list with toggle switches. function openManageJailsModal() { + if (!currentServerId) { + alert(t('servers.selector.none', 'Please add and select a Fail2ban server first.')); + return; + } showLoading(true); - fetch('/api/jails/manage') + fetch(withServerParam('/api/jails/manage'), { + headers: serverHeaders() + }) .then(res => res.json()) .then(data => { if (!data.jails?.length) { @@ -1157,9 +2080,9 @@ }); // Send updated states to the API endpoint /api/jails/manage. - fetch('/api/jails/manage', { + fetch(withServerParam('/api/jails/manage'), { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: serverHeaders({ 'Content-Type': 'application/json' }), body: JSON.stringify(updatedJails), }) .then(function(res) { return res.json(); }) @@ -1192,6 +2115,7 @@ document.getElementById('languageSelect').value = data.language || 'en'; document.getElementById('uiPort').value = data.port || 8080, document.getElementById('debugMode').checked = data.debug || false; + document.getElementById('callbackURL').value = data.callbackUrl || ''; document.getElementById('destEmail').value = data.destemail || ''; @@ -1256,6 +2180,7 @@ port: parseInt(document.getElementById('uiPort').value, 10) || 8080, debug: document.getElementById('debugMode').checked, destemail: document.getElementById('destEmail').value.trim(), + callbackUrl: document.getElementById('callbackURL').value.trim(), alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"], bantimeIncrement: document.getElementById('bantimeIncrement').checked, bantime: document.getElementById('banTime').value.trim(), @@ -1293,7 +2218,9 @@ function loadFilters() { showLoading(true); - fetch('/api/filters') + fetch(withServerParam('/api/filters'), { + headers: serverHeaders() + }) .then(res => res.json()) .then(data => { if (data.error) { @@ -1301,6 +2228,16 @@ return; } const select = document.getElementById('filterSelect'); + const notice = document.getElementById('filterNotice'); + if (notice) { + if (data.messageKey) { + notice.classList.remove('hidden'); + notice.textContent = t(data.messageKey, data.message || ''); + } else { + notice.classList.add('hidden'); + notice.textContent = ''; + } + } select.innerHTML = ''; if (!data.filters || data.filters.length === 0) { const opt = document.createElement('option'); @@ -1390,6 +2327,17 @@ // When showing the filter section function showFilterSection() { + if (!currentServerId) { + var notice = document.getElementById('filterNotice'); + if (notice) { + notice.classList.remove('hidden'); + notice.textContent = t('filter_debug.not_available', 'Filter debug is only available when a Fail2ban server is selected.'); + } + document.getElementById('filterSelect').innerHTML = ''; + document.getElementById('logLinesTextarea').value = ''; + document.getElementById('testResults').innerHTML = ''; + return; + } loadFilters(); document.getElementById('testResults').innerHTML = ''; document.getElementById('logLinesTextarea').value = ''; @@ -1402,14 +2350,17 @@ function restartFail2ban() { if (!confirm("Keep in mind that while fail2ban is restarting, logs are not being parsed and no IP addresses are blocked. Restart fail2ban now? This will take some time.")) return; showLoading(true); - fetch('/api/fail2ban/restart', { method: 'POST' }) + fetch(withServerParam('/api/fail2ban/restart'), { + method: 'POST', + headers: serverHeaders() + }) .then(function(res) { return res.json(); }) .then(function(data) { if (data.error) { alert("Error: " + data.error); } else { document.getElementById('restartBanner').style.display = 'none'; - return fetchSummary(); + return refreshData({ silent: true }); } }) .catch(function(err) { @@ -1447,13 +2398,20 @@ } } }); + + var sshKeySelect = document.getElementById('serverSSHKeySelect'); + if (sshKeySelect) { + sshKeySelect.addEventListener('change', function(e) { + if (e.target.value) { + document.getElementById('serverSSHKey').value = e.target.value; + } + }); + } }); //******************************************************************* //* Translation Related Functions : * //******************************************************************* - var translations = {}; - // Loads translation JSON file for given language (e.g., en, de, etc.) function loadTranslations(lang) { $.getJSON('/locales/' + lang + '.json') @@ -1484,19 +2442,17 @@ } function getTranslationsSettingsOnPageload() { - // Fetch settings to get the current language preference - fetch('/api/settings') - .then(function(res) { return res.json(); }) - .then(function(data) { - var lang = data.language || 'en'; // Use the language from settings or default to "en" - $('#languageSelect').val(lang); // Update the language dropdown accordingly - loadTranslations(lang); // Load the appropriate translation file - }) - .catch(function(err) { - console.error('Error loading initial settings:', err); - // In case of an error, fallback to English - loadTranslations('en'); - }); + return fetch('/api/settings') + .then(function(res) { return res.json(); }) + .then(function(data) { + var lang = data.language || 'en'; + $('#languageSelect').val(lang); + loadTranslations(lang); + }) + .catch(function(err) { + console.error('Error loading initial settings:', err); + loadTranslations('en'); + }); }