mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Refactor the whole backend to support remote-fail2ban machines over ssh or over a agent-api(needs to be build)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -27,3 +27,4 @@ go.work.sum
|
||||
# Project specific
|
||||
fail2ban-ui-settings.json
|
||||
_dev
|
||||
fail2ban-ui.db*
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
|
||||
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
||||
"github.com/swissmakers/fail2ban-ui/pkg/web"
|
||||
)
|
||||
|
||||
@@ -32,6 +34,19 @@ func main() {
|
||||
// Get application settings from the config package.
|
||||
settings := config.GetSettings()
|
||||
|
||||
if err := storage.Init(""); err != nil {
|
||||
log.Fatalf("Failed to initialise storage: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := storage.Close(); err != nil {
|
||||
log.Printf("warning: failed to close storage: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
if err := fail2ban.GetManager().ReloadFromSettings(settings); err != nil {
|
||||
log.Fatalf("failed to initialise fail2ban connectors: %v", err)
|
||||
}
|
||||
|
||||
// Set Gin mode based on the debug flag in settings.
|
||||
if settings.Debug {
|
||||
gin.SetMode(gin.DebugMode)
|
||||
|
||||
12
go.mod
12
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
|
||||
)
|
||||
|
||||
44
go.sum
44
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=
|
||||
|
||||
@@ -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 '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
--arg hostname '<fq-hostname>' \
|
||||
--arg failures '<failures>' \
|
||||
--arg whois "$(whois <ip> || echo 'missing whois program')" \
|
||||
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
|
||||
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
|
||||
|
||||
[Init]
|
||||
|
||||
# Default name of the chain
|
||||
name = default
|
||||
|
||||
# Path to log files containing relevant lines for the abuser IP
|
||||
logpath = /dev/null
|
||||
|
||||
# Number of log lines to include in the email
|
||||
grepmax = 200
|
||||
grepopts = -m <grepmax>`
|
||||
|
||||
// in-memory copy of settings
|
||||
var (
|
||||
currentSettings AppSettings
|
||||
settingsLock sync.RWMutex
|
||||
)
|
||||
|
||||
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 '<ip>' \
|
||||
--arg jail '<name>' \
|
||||
--arg hostname '<fq-hostname>' \
|
||||
--arg failures '<failures>' \
|
||||
--arg whois "$(whois <ip> || echo 'missing whois program')" \
|
||||
--arg logs "$(tac <logpath> | grep <grepopts> -wF <ip>)" \
|
||||
'{ip: $ip, jail: $jail, hostname: $hostname, failures: $failures, whois: $whois, logs: $logs}')"
|
||||
|
||||
[Init]
|
||||
|
||||
# Default name of the chain
|
||||
name = default
|
||||
|
||||
# Path to log files containing relevant lines for the abuser IP
|
||||
logpath = /dev/null
|
||||
|
||||
# Number of log lines to include in the email
|
||||
grepmax = 200
|
||||
grepopts = -m <grepmax>`
|
||||
|
||||
// Write the action file
|
||||
actionConfig := BuildFail2banActionConfig(callbackURL)
|
||||
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write action file: %w", err)
|
||||
@@ -363,46 +742,253 @@ grepopts = -m <grepmax>`
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadSettings reads fail2ban-ui-settings.json into currentSettings.
|
||||
func loadSettings() error {
|
||||
DebugLog("----------------------------")
|
||||
DebugLog("loadSettings called (settings.go)") // entry point
|
||||
data, err := os.ReadFile(settingsFile)
|
||||
if os.IsNotExist(err) {
|
||||
return err // triggers setDefaults + save
|
||||
func cloneServer(src Fail2banServer) Fail2banServer {
|
||||
dst := src
|
||||
if src.Tags != nil {
|
||||
dst.Tags = append([]string{}, src.Tags...)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var s AppSettings
|
||||
if err := json.Unmarshal(data, &s); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
settingsLock.Lock()
|
||||
defer settingsLock.Unlock()
|
||||
currentSettings = s
|
||||
return nil
|
||||
dst.enabledSet = src.enabledSet
|
||||
return dst
|
||||
}
|
||||
|
||||
// saveSettings writes currentSettings to JSON
|
||||
func saveSettings() error {
|
||||
DebugLog("----------------------------")
|
||||
DebugLog("saveSettings called (settings.go)") // entry point
|
||||
func BuildFail2banActionConfig(callbackURL string) string {
|
||||
trimmed := strings.TrimRight(strings.TrimSpace(callbackURL), "/")
|
||||
if trimmed == "" {
|
||||
trimmed = "http://127.0.0.1:8080"
|
||||
}
|
||||
return strings.ReplaceAll(fail2banActionTemplate, actionCallbackPlaceholder, trimmed)
|
||||
}
|
||||
|
||||
b, err := json.MarshalIndent(currentSettings, "", " ")
|
||||
if err != nil {
|
||||
DebugLog("Error marshalling settings: %v", err) // Debug
|
||||
return err
|
||||
func getCallbackURLLocked() string {
|
||||
url := strings.TrimSpace(currentSettings.CallbackURL)
|
||||
if url == "" {
|
||||
port := currentSettings.Port
|
||||
if port == 0 {
|
||||
port = 8080
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -16,14 +16,7 @@
|
||||
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
import "context"
|
||||
|
||||
type JailInfo struct {
|
||||
JailName string `json:"jailName"`
|
||||
@@ -33,146 +26,65 @@ type JailInfo struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
// Get active jails using "fail2ban-client status".
|
||||
// GetJails returns the jail names for the default server.
|
||||
func GetJails() ([]string, error) {
|
||||
cmd := exec.Command("fail2ban-client", "status")
|
||||
out, err := cmd.CombinedOutput()
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var jails []string
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
|
||||
func GetBannedIPs(jail string) ([]string, error) {
|
||||
cmd := exec.Command("fail2ban-client", "status", jail)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail2ban-client status %s failed: %v", jail, err)
|
||||
}
|
||||
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(string(out), "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
// UnbanIP unbans an IP from the given jail.
|
||||
func UnbanIP(jail, ip string) error {
|
||||
// We assume "fail2ban-client set <jail> unbanip <ip>" works.
|
||||
cmd := exec.Command("fail2ban-client", "set", jail, "unbanip", ip)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error unbanning IP %s from jail %s: %v\nOutput: %s", ip, jail, err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildJailInfos returns extended info for each jail:
|
||||
// - total banned count
|
||||
// - new banned in the last hour
|
||||
// - list of currently banned IPs
|
||||
func BuildJailInfos(logPath string) ([]JailInfo, error) {
|
||||
jails, err := GetJails()
|
||||
infos, err := conn.GetJailInfos(context.Background())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse the log once, so we can determine "newInLastHour" per jail
|
||||
// for performance reasons. We'll gather all ban timestamps by jail.
|
||||
banHistory, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
// If fail2ban.log can't be read, we can still show partial info.
|
||||
banHistory = make(map[string][]BanEvent)
|
||||
names := make([]string, 0, len(infos))
|
||||
for _, info := range infos {
|
||||
names = append(names, info.JailName)
|
||||
}
|
||||
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
|
||||
var results []JailInfo
|
||||
for _, jail := range jails {
|
||||
bannedIPs, err := GetBannedIPs(jail)
|
||||
if err != nil {
|
||||
// Just skip or handle error per jail
|
||||
continue
|
||||
}
|
||||
|
||||
// Count how many bans occurred in the last hour for this jail
|
||||
newInLastHour := 0
|
||||
if events, ok := banHistory[jail]; ok {
|
||||
for _, e := range events {
|
||||
if e.Time.After(oneHourAgo) {
|
||||
newInLastHour++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jinfo := JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(bannedIPs),
|
||||
NewInLastHour: newInLastHour,
|
||||
BannedIPs: bannedIPs,
|
||||
}
|
||||
results = append(results, jinfo)
|
||||
}
|
||||
return results, nil
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// ReloadFail2ban runs "fail2ban-client reload"
|
||||
// GetBannedIPs returns a slice of currently banned IPs for a specific jail.
|
||||
func GetBannedIPs(jail string) ([]string, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.GetBannedIPs(context.Background(), jail)
|
||||
}
|
||||
|
||||
// UnbanIP unbans an IP from the given jail.
|
||||
func UnbanIP(jail, ip string) error {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.UnbanIP(context.Background(), jail, ip)
|
||||
}
|
||||
|
||||
// BuildJailInfos returns extended info for each jail on the default server.
|
||||
func BuildJailInfos(_ string) ([]JailInfo, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return conn.GetJailInfos(context.Background())
|
||||
}
|
||||
|
||||
// ReloadFail2ban triggers a reload on the default server.
|
||||
func ReloadFail2ban() error {
|
||||
cmd := exec.Command("fail2ban-client", "reload")
|
||||
out, err := cmd.CombinedOutput()
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return fmt.Errorf("fail2ban reload error: %v\noutput: %s", err, out)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return conn.Reload(context.Background())
|
||||
}
|
||||
|
||||
// RestartFail2ban restarts the Fail2ban service.
|
||||
// RestartFail2ban restarts the Fail2ban service using the default connector.
|
||||
func RestartFail2ban() error {
|
||||
|
||||
// Check if running inside a container.
|
||||
if _, container := os.LookupEnv("CONTAINER"); container {
|
||||
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
|
||||
}
|
||||
cmd := "systemctl restart fail2ban"
|
||||
out, err := execCommand(cmd)
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// execCommand is a helper function to execute shell commands.
|
||||
func execCommand(command string) (string, error) {
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
return "", errors.New("no command provided")
|
||||
}
|
||||
cmd := exec.Command(parts[0], parts[1:]...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
return conn.Restart(context.Background())
|
||||
}
|
||||
|
||||
251
internal/fail2ban/connector_agent.go
Normal file
251
internal/fail2ban/connector_agent.go
Normal file
@@ -0,0 +1,251 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// AgentConnector connects to a remote fail2ban-agent via HTTP API.
|
||||
type AgentConnector struct {
|
||||
server config.Fail2banServer
|
||||
base *url.URL
|
||||
client *http.Client
|
||||
}
|
||||
|
||||
// NewAgentConnector constructs a new AgentConnector.
|
||||
func NewAgentConnector(server config.Fail2banServer) (Connector, error) {
|
||||
if server.AgentURL == "" {
|
||||
return nil, fmt.Errorf("agentUrl is required for agent connector")
|
||||
}
|
||||
if server.AgentSecret == "" {
|
||||
return nil, fmt.Errorf("agentSecret is required for agent connector")
|
||||
}
|
||||
parsed, err := url.Parse(server.AgentURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid agentUrl: %w", err)
|
||||
}
|
||||
if parsed.Scheme == "" {
|
||||
parsed.Scheme = "https"
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
conn := &AgentConnector{
|
||||
server: server,
|
||||
base: parsed,
|
||||
client: client,
|
||||
}
|
||||
if err := conn.ensureAction(context.Background()); err != nil {
|
||||
fmt.Printf("warning: failed to ensure agent action for %s: %v\n", server.Name, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) ID() string {
|
||||
return ac.server.ID
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Server() config.Fail2banServer {
|
||||
return ac.server
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) ensureAction(ctx context.Context) error {
|
||||
payload := map[string]any{
|
||||
"name": "ui-custom-action",
|
||||
"config": config.BuildFail2banActionConfig(config.GetCallbackURL()),
|
||||
"callbackUrl": config.GetCallbackURL(),
|
||||
"setDefault": true,
|
||||
}
|
||||
return ac.put(ctx, "/v1/actions/ui-custom", payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
var resp struct {
|
||||
Jails []JailInfo `json:"jails"`
|
||||
}
|
||||
if err := ac.get(ctx, "/v1/jails", &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp.Jails, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
var resp struct {
|
||||
Jail string `json:"jail"`
|
||||
BannedIPs []string `json:"bannedIPs"`
|
||||
TotalBanned int `json:"totalBanned"`
|
||||
}
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/jails/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp.BannedIPs) > 0 {
|
||||
return resp.BannedIPs, nil
|
||||
}
|
||||
return []string{}, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
payload := map[string]string{"ip": ip}
|
||||
return ac.post(ctx, fmt.Sprintf("/v1/jails/%s/unban", url.PathEscape(jail)), payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Reload(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/actions/reload", nil, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) Restart(ctx context.Context) error {
|
||||
return ac.post(ctx, "/v1/actions/restart", nil, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
var resp struct {
|
||||
Config string `json:"config"`
|
||||
}
|
||||
if err := ac.get(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return resp.Config, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
payload := map[string]string{"config": content}
|
||||
return ac.put(ctx, fmt.Sprintf("/v1/filters/%s", url.PathEscape(jail)), payload, nil)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
query := url.Values{}
|
||||
if limit > 0 {
|
||||
query.Set("limit", strconv.Itoa(limit))
|
||||
}
|
||||
var resp struct {
|
||||
Events []struct {
|
||||
IP string `json:"ip"`
|
||||
Jail string `json:"jail"`
|
||||
Hostname string `json:"hostname"`
|
||||
Failures string `json:"failures"`
|
||||
Whois string `json:"whois"`
|
||||
Logs string `json:"logs"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
} `json:"events"`
|
||||
}
|
||||
endpoint := "/v1/events"
|
||||
if encoded := query.Encode(); encoded != "" {
|
||||
endpoint += "?" + encoded
|
||||
}
|
||||
if err := ac.get(ctx, endpoint, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make([]BanEvent, 0, len(resp.Events))
|
||||
for _, evt := range resp.Events {
|
||||
ts, err := time.Parse(time.RFC3339, evt.Timestamp)
|
||||
if err != nil {
|
||||
ts = time.Now()
|
||||
}
|
||||
result = append(result, BanEvent{
|
||||
Time: ts,
|
||||
Jail: evt.Jail,
|
||||
IP: evt.IP,
|
||||
LogLine: fmt.Sprintf("%s %s", evt.Hostname, evt.Failures),
|
||||
})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) get(ctx context.Context, endpoint string, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodGet, endpoint, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) post(ctx context.Context, endpoint string, payload any, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodPost, endpoint, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) put(ctx context.Context, endpoint string, payload any, out any) error {
|
||||
req, err := ac.newRequest(ctx, http.MethodPut, endpoint, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ac.do(req, out)
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) newRequest(ctx context.Context, method, endpoint string, payload any) (*http.Request, error) {
|
||||
u := *ac.base
|
||||
u.Path = path.Join(ac.base.Path, strings.TrimPrefix(endpoint, "/"))
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(data)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, method, u.String(), body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if payload != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("X-F2B-Token", ac.server.AgentSecret)
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (ac *AgentConnector) do(req *http.Request, out any) error {
|
||||
settingsSnapshot := config.GetSettings()
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent request [%s]: %s %s", ac.server.Name, req.Method, req.URL.String())
|
||||
}
|
||||
|
||||
resp, err := ac.client.Do(req)
|
||||
if err != nil {
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent request error [%s]: %v", ac.server.Name, err)
|
||||
}
|
||||
return fmt.Errorf("agent request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
trimmed := strings.TrimSpace(string(data))
|
||||
|
||||
if settingsSnapshot.Debug {
|
||||
config.DebugLog("Agent response [%s]: %s | %s", ac.server.Name, resp.Status, trimmed)
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
return fmt.Errorf("agent request failed: %s (%s)", resp.Status, trimmed)
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(trimmed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
218
internal/fail2ban/connector_local.go
Normal file
218
internal/fail2ban/connector_local.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// LocalConnector interacts with a local fail2ban instance via fail2ban-client CLI.
|
||||
type LocalConnector struct {
|
||||
server config.Fail2banServer
|
||||
}
|
||||
|
||||
// NewLocalConnector creates a new LocalConnector instance.
|
||||
func NewLocalConnector(server config.Fail2banServer) *LocalConnector {
|
||||
return &LocalConnector{server: server}
|
||||
}
|
||||
|
||||
// ID implements Connector.
|
||||
func (lc *LocalConnector) ID() string {
|
||||
return lc.server.ID
|
||||
}
|
||||
|
||||
// Server implements Connector.
|
||||
func (lc *LocalConnector) Server() config.Fail2banServer {
|
||||
return lc.server
|
||||
}
|
||||
|
||||
// GetJailInfos implements Connector.
|
||||
func (lc *LocalConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
jails, err := lc.getJails(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logPath := lc.server.LogPath
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
|
||||
banHistory, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
banHistory = make(map[string][]BanEvent)
|
||||
}
|
||||
|
||||
oneHourAgo := time.Now().Add(-1 * time.Hour)
|
||||
var results []JailInfo
|
||||
for _, jail := range jails {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
bannedIPs, err := lc.GetBannedIPs(ctx, jail)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
newInLastHour := 0
|
||||
if events, ok := banHistory[jail]; ok {
|
||||
for _, e := range events {
|
||||
if e.Time.After(oneHourAgo) {
|
||||
newInLastHour++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(bannedIPs),
|
||||
NewInLastHour: newInLastHour,
|
||||
BannedIPs: bannedIPs,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetBannedIPs implements Connector.
|
||||
func (lc *LocalConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
args := []string{"status", jail}
|
||||
out, err := lc.runFail2banClient(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fail2ban-client status %s failed: %w", jail, err)
|
||||
}
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
// UnbanIP implements Connector.
|
||||
func (lc *LocalConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
args := []string{"set", jail, "unbanip", ip}
|
||||
if _, err := lc.runFail2banClient(ctx, args...); err != nil {
|
||||
return fmt.Errorf("error unbanning IP %s from jail %s: %w", ip, jail, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload implements Connector.
|
||||
func (lc *LocalConnector) Reload(ctx context.Context) error {
|
||||
if _, err := lc.runFail2banClient(ctx, "reload"); err != nil {
|
||||
return fmt.Errorf("fail2ban reload error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Restart implements Connector.
|
||||
func (lc *LocalConnector) Restart(ctx context.Context) error {
|
||||
if _, container := os.LookupEnv("CONTAINER"); container {
|
||||
return fmt.Errorf("restart not supported inside container; please restart fail2ban on the host")
|
||||
}
|
||||
cmd := "systemctl restart fail2ban"
|
||||
out, err := executeShellCommand(ctx, cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to restart fail2ban: %w - output: %s", err, out)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
return GetFilterConfigLocal(jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig implements Connector.
|
||||
func (lc *LocalConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
return SetFilterConfigLocal(jail, content)
|
||||
}
|
||||
|
||||
// FetchBanEvents implements Connector.
|
||||
func (lc *LocalConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
logPath := lc.server.LogPath
|
||||
if logPath == "" {
|
||||
logPath = "/var/log/fail2ban.log"
|
||||
}
|
||||
eventsByJail, err := ParseBanLog(logPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var all []BanEvent
|
||||
for _, evs := range eventsByJail {
|
||||
all = append(all, evs...)
|
||||
}
|
||||
sort.SliceStable(all, func(i, j int) bool {
|
||||
return all[i].Time.After(all[j].Time)
|
||||
})
|
||||
if limit > 0 && len(all) > limit {
|
||||
all = all[:limit]
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
out, err := lc.runFail2banClient(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error: unable to retrieve jail information. is your fail2ban service running? details: %w", err)
|
||||
}
|
||||
|
||||
var jails []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) runFail2banClient(ctx context.Context, args ...string) (string, error) {
|
||||
cmdArgs := lc.buildFail2banArgs(args...)
|
||||
cmd := exec.CommandContext(ctx, "fail2ban-client", cmdArgs...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func (lc *LocalConnector) buildFail2banArgs(args ...string) []string {
|
||||
if lc.server.SocketPath == "" {
|
||||
return args
|
||||
}
|
||||
base := []string{"-s", lc.server.SocketPath}
|
||||
return append(base, args...)
|
||||
}
|
||||
|
||||
func executeShellCommand(ctx context.Context, command string) (string, error) {
|
||||
parts := strings.Fields(command)
|
||||
if len(parts) == 0 {
|
||||
return "", errors.New("no command provided")
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, parts[0], parts[1:]...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
256
internal/fail2ban/connector_ssh.go
Normal file
256
internal/fail2ban/connector_ssh.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
const sshEnsureActionScript = `sudo python3 - <<'PY'
|
||||
import base64
|
||||
import pathlib
|
||||
|
||||
action_dir = pathlib.Path("/etc/fail2ban/action.d")
|
||||
action_dir.mkdir(parents=True, exist_ok=True)
|
||||
action_cfg = base64.b64decode("__PAYLOAD__").decode("utf-8")
|
||||
(action_dir / "ui-custom-action.conf").write_text(action_cfg)
|
||||
|
||||
jail_file = pathlib.Path("/etc/fail2ban/jail.local")
|
||||
if not jail_file.exists():
|
||||
jail_file.write_text("[DEFAULT]\n")
|
||||
|
||||
lines = jail_file.read_text().splitlines()
|
||||
already = any("Custom Fail2Ban action applied by fail2ban-ui" in line for line in lines)
|
||||
if not already:
|
||||
new_lines = []
|
||||
inserted = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("action") and "ui-custom-action" not in stripped and not inserted:
|
||||
if not stripped.startswith("#"):
|
||||
new_lines.append("# " + line)
|
||||
else:
|
||||
new_lines.append(line)
|
||||
new_lines.append("# Custom Fail2Ban action applied by fail2ban-ui")
|
||||
new_lines.append("action = %(action_mwlg)s")
|
||||
inserted = True
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if not inserted:
|
||||
insert_at = None
|
||||
for idx, value in enumerate(new_lines):
|
||||
if value.strip().startswith("[DEFAULT]"):
|
||||
insert_at = idx + 1
|
||||
break
|
||||
if insert_at is None:
|
||||
new_lines.append("[DEFAULT]")
|
||||
insert_at = len(new_lines)
|
||||
new_lines.insert(insert_at, "# Custom Fail2Ban action applied by fail2ban-ui")
|
||||
new_lines.insert(insert_at + 1, "action = %(action_mwlg)s")
|
||||
jail_file.write_text("\n".join(new_lines) + "\n")
|
||||
PY`
|
||||
|
||||
// SSHConnector connects to a remote Fail2ban instance over SSH.
|
||||
type SSHConnector struct {
|
||||
server config.Fail2banServer
|
||||
}
|
||||
|
||||
// NewSSHConnector creates a new SSH connector.
|
||||
func NewSSHConnector(server config.Fail2banServer) (Connector, error) {
|
||||
if server.Host == "" {
|
||||
return nil, fmt.Errorf("host is required for ssh connector")
|
||||
}
|
||||
if server.SSHUser == "" {
|
||||
return nil, fmt.Errorf("sshUser is required for ssh connector")
|
||||
}
|
||||
conn := &SSHConnector{server: server}
|
||||
if err := conn.ensureAction(context.Background()); err != nil {
|
||||
fmt.Printf("warning: failed to ensure remote fail2ban action for %s: %v\n", server.Name, err)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) ID() string {
|
||||
return sc.server.ID
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Server() config.Fail2banServer {
|
||||
return sc.server
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetJailInfos(ctx context.Context) ([]JailInfo, error) {
|
||||
jails, err := sc.getJails(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var infos []JailInfo
|
||||
for _, jail := range jails {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
ips, err := sc.GetBannedIPs(ctx, jail)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
infos = append(infos, JailInfo{
|
||||
JailName: jail,
|
||||
TotalBanned: len(ips),
|
||||
NewInLastHour: 0,
|
||||
BannedIPs: ips,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(infos, func(i, j int) bool {
|
||||
return infos[i].JailName < infos[j].JailName
|
||||
})
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetBannedIPs(ctx context.Context, jail string) ([]string, error) {
|
||||
out, err := sc.runFail2banCommand(ctx, "status", jail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var bannedIPs []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "IP list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
ips := strings.Fields(strings.TrimSpace(parts[1]))
|
||||
bannedIPs = append(bannedIPs, ips...)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
return bannedIPs, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) UnbanIP(ctx context.Context, jail, ip string) error {
|
||||
_, err := sc.runFail2banCommand(ctx, "set", jail, "unbanip", ip)
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Reload(ctx context.Context) error {
|
||||
_, err := sc.runFail2banCommand(ctx, "reload")
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) Restart(ctx context.Context) error {
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"sudo", "systemctl", "restart", "fail2ban"})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) GetFilterConfig(ctx context.Context, jail string) (string, error) {
|
||||
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
|
||||
out, err := sc.runRemoteCommand(ctx, []string{"sudo", "cat", path})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read remote filter config: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) SetFilterConfig(ctx context.Context, jail, content string) error {
|
||||
path := fmt.Sprintf("/etc/fail2ban/filter.d/%s.conf", jail)
|
||||
cmd := fmt.Sprintf("cat <<'EOF' | sudo tee %s >/dev/null\n%s\nEOF", path, content)
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", cmd})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error) {
|
||||
// Not available over SSH without copying logs; return empty slice.
|
||||
return []BanEvent{}, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) ensureAction(ctx context.Context) error {
|
||||
callbackURL := config.GetCallbackURL()
|
||||
actionConfig := config.BuildFail2banActionConfig(callbackURL)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte(actionConfig))
|
||||
script := strings.ReplaceAll(sshEnsureActionScript, "__PAYLOAD__", payload)
|
||||
_, err := sc.runRemoteCommand(ctx, []string{"bash", "-lc", script})
|
||||
return err
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) getJails(ctx context.Context) ([]string, error) {
|
||||
out, err := sc.runFail2banCommand(ctx, "status")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var jails []string
|
||||
lines := strings.Split(out, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.Contains(line, "Jail list:") {
|
||||
parts := strings.Split(line, ":")
|
||||
if len(parts) > 1 {
|
||||
raw := strings.TrimSpace(parts[1])
|
||||
jails = strings.Split(raw, ",")
|
||||
for i := range jails {
|
||||
jails[i] = strings.TrimSpace(jails[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return jails, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) runFail2banCommand(ctx context.Context, args ...string) (string, error) {
|
||||
fail2banArgs := sc.buildFail2banArgs(args...)
|
||||
cmdArgs := append([]string{"sudo", "fail2ban-client"}, fail2banArgs...)
|
||||
return sc.runRemoteCommand(ctx, cmdArgs)
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) buildFail2banArgs(args ...string) []string {
|
||||
if sc.server.SocketPath == "" {
|
||||
return args
|
||||
}
|
||||
base := []string{"-s", sc.server.SocketPath}
|
||||
return append(base, args...)
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) runRemoteCommand(ctx context.Context, command []string) (string, error) {
|
||||
args := sc.buildSSHArgs(command)
|
||||
cmd := exec.CommandContext(ctx, "ssh", args...)
|
||||
settingSnapshot := config.GetSettings()
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command [%s]: ssh %s", sc.server.Name, strings.Join(args, " "))
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
output := strings.TrimSpace(string(out))
|
||||
if err != nil {
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command error [%s]: %v | output: %s", sc.server.Name, err, output)
|
||||
}
|
||||
return output, fmt.Errorf("ssh command failed: %w (output: %s)", err, output)
|
||||
}
|
||||
if settingSnapshot.Debug {
|
||||
config.DebugLog("SSH command output [%s]: %s", sc.server.Name, output)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (sc *SSHConnector) buildSSHArgs(command []string) []string {
|
||||
args := []string{"-o", "BatchMode=yes"}
|
||||
if sc.server.SSHKeyPath != "" {
|
||||
args = append(args, "-i", sc.server.SSHKeyPath)
|
||||
}
|
||||
if sc.server.Port > 0 {
|
||||
args = append(args, "-p", strconv.Itoa(sc.server.Port))
|
||||
}
|
||||
target := sc.server.Host
|
||||
if sc.server.SSHUser != "" {
|
||||
target = fmt.Sprintf("%s@%s", sc.server.SSHUser, target)
|
||||
}
|
||||
args = append(args, target)
|
||||
args = append(args, command...)
|
||||
return args
|
||||
}
|
||||
@@ -17,15 +17,32 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// GetFilterConfig returns the config content for a given jail filter.
|
||||
// Example: we assume each jail config is at /etc/fail2ban/filter.d/<jailname>.conf
|
||||
// Adapt this to your environment.
|
||||
// GetFilterConfig returns the filter configuration using the default connector.
|
||||
func GetFilterConfig(jail string) (string, error) {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return conn.GetFilterConfig(context.Background(), jail)
|
||||
}
|
||||
|
||||
// SetFilterConfig writes the filter configuration using the default connector.
|
||||
func SetFilterConfig(jail, newContent string) error {
|
||||
conn, err := GetManager().DefaultConnector()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return conn.SetFilterConfig(context.Background(), jail, newContent)
|
||||
}
|
||||
|
||||
// GetFilterConfigLocal reads a filter configuration from the local filesystem.
|
||||
func GetFilterConfigLocal(jail string) (string, error) {
|
||||
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
||||
content, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
@@ -34,8 +51,8 @@ func GetFilterConfig(jail string) (string, error) {
|
||||
return string(content), nil
|
||||
}
|
||||
|
||||
// SetFilterConfig overwrites the config file for a given jail with new content.
|
||||
func SetFilterConfig(jail, newContent string) error {
|
||||
// SetFilterConfigLocal writes the filter configuration to the local filesystem.
|
||||
func SetFilterConfigLocal(jail, newContent string) error {
|
||||
configPath := filepath.Join("/etc/fail2ban/filter.d", jail+".conf")
|
||||
if err := os.WriteFile(configPath, []byte(newContent), 0644); err != nil {
|
||||
return fmt.Errorf("failed to write config for jail %s: %v", jail, err)
|
||||
|
||||
117
internal/fail2ban/manager.go
Normal file
117
internal/fail2ban/manager.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package fail2ban
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||||
)
|
||||
|
||||
// Connector describes a communication backend for a Fail2ban server.
|
||||
type Connector interface {
|
||||
ID() string
|
||||
Server() config.Fail2banServer
|
||||
|
||||
GetJailInfos(ctx context.Context) ([]JailInfo, error)
|
||||
GetBannedIPs(ctx context.Context, jail string) ([]string, error)
|
||||
UnbanIP(ctx context.Context, jail, ip string) error
|
||||
Reload(ctx context.Context) error
|
||||
Restart(ctx context.Context) error
|
||||
GetFilterConfig(ctx context.Context, jail string) (string, error)
|
||||
SetFilterConfig(ctx context.Context, jail, content string) error
|
||||
FetchBanEvents(ctx context.Context, limit int) ([]BanEvent, error)
|
||||
}
|
||||
|
||||
// Manager orchestrates all connectors for configured Fail2ban servers.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
connectors map[string]Connector
|
||||
}
|
||||
|
||||
var (
|
||||
managerOnce sync.Once
|
||||
managerInst *Manager
|
||||
)
|
||||
|
||||
// GetManager returns the singleton connector manager.
|
||||
func GetManager() *Manager {
|
||||
managerOnce.Do(func() {
|
||||
managerInst = &Manager{
|
||||
connectors: make(map[string]Connector),
|
||||
}
|
||||
})
|
||||
return managerInst
|
||||
}
|
||||
|
||||
// ReloadFromSettings rebuilds connectors using the provided settings.
|
||||
func (m *Manager) ReloadFromSettings(settings config.AppSettings) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
connectors := make(map[string]Connector)
|
||||
for _, srv := range settings.Servers {
|
||||
if !srv.Enabled {
|
||||
continue
|
||||
}
|
||||
conn, err := newConnectorForServer(srv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialise connector for %s (%s): %w", srv.Name, srv.ID, err)
|
||||
}
|
||||
connectors[srv.ID] = conn
|
||||
}
|
||||
|
||||
m.connectors = connectors
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connector returns the connector for the specified server ID.
|
||||
func (m *Manager) Connector(serverID string) (Connector, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if serverID == "" {
|
||||
return nil, fmt.Errorf("server id must be provided")
|
||||
}
|
||||
conn, ok := m.connectors[serverID]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("connector for server %s not found or not enabled", serverID)
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// DefaultConnector returns the default connector as defined in settings.
|
||||
func (m *Manager) DefaultConnector() (Connector, error) {
|
||||
server := config.GetDefaultServer()
|
||||
if server.ID == "" {
|
||||
return nil, fmt.Errorf("no active fail2ban server configured")
|
||||
}
|
||||
return m.Connector(server.ID)
|
||||
}
|
||||
|
||||
// Connectors returns all connectors.
|
||||
func (m *Manager) Connectors() []Connector {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
result := make([]Connector, 0, len(m.connectors))
|
||||
for _, conn := range m.connectors {
|
||||
result = append(result, conn)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func newConnectorForServer(server config.Fail2banServer) (Connector, error) {
|
||||
switch server.Type {
|
||||
case "local":
|
||||
if err := config.EnsureLocalFail2banAction(server); err != nil {
|
||||
fmt.Printf("warning: failed to ensure local fail2ban action: %v\n", err)
|
||||
}
|
||||
return NewLocalConnector(server), nil
|
||||
case "ssh":
|
||||
return NewSSHConnector(server)
|
||||
case "agent":
|
||||
return NewAgentConnector(server)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported server type %s", server.Type)
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
"restart_banner.button": "Riavvia il servizio",
|
||||
"dashboard.title": "Cruscotto",
|
||||
"dashboard.overview": "Panoramica dei jail e dei blocchi attivi",
|
||||
"dashboard.overview_hint": "Usa la ricerca per filtrare le IP bloccate e fai clic su un jail per modificarne la configurazione.",
|
||||
"dashboard.search_label": "Cerca IP bloccate",
|
||||
"dashboard.search_placeholder": "Inserisci l'indirizzo IP da cercare",
|
||||
"dashboard.external_ip": "La tua IP ext.:",
|
||||
"dashboard.manage_servers": "Gestisci server",
|
||||
"dashboard.no_servers_title": "Nessun server Fail2ban configurato",
|
||||
"dashboard.no_servers_body": "Aggiungi un server per iniziare a monitorare e controllare le istanze Fail2ban.",
|
||||
"dashboard.loading_summary": "Caricamento del riepilogo…",
|
||||
"dashboard.no_enabled_servers_title": "Nessun connettore attivo",
|
||||
"dashboard.no_enabled_servers_body": "Abilita il connettore locale o registra un server Fail2ban remoto per visualizzare dati in tempo reale.",
|
||||
"dashboard.errors.summary_failed": "Impossibile caricare il riepilogo dal server.",
|
||||
"dashboard.cards.active_jails": "Jail attivi",
|
||||
"dashboard.cards.total_banned": "IP bloccate totali",
|
||||
"dashboard.cards.new_last_hour": "Nuove nell'ultima ora",
|
||||
"dashboard.cards.total_logged": "Eventi di blocco memorizzati",
|
||||
"dashboard.table.jail_name": "Nome del Jail",
|
||||
"dashboard.table.total_banned": "Totale bloccate",
|
||||
"dashboard.table.new_last_hour": "Nuove nell'ultima ora",
|
||||
@@ -22,6 +35,20 @@
|
||||
"dashboard.no_recent_bans": "Nessun blocco recente trovato.",
|
||||
"dashboard.no_banned_ips": "Nessuna IP bloccata",
|
||||
"dashboard.unban": "Sblocca",
|
||||
"logs.overview.title": "Panoramica interna dei log",
|
||||
"logs.overview.subtitle": "Eventi memorizzati da Fail2ban-UI su tutti i connettori.",
|
||||
"logs.overview.refresh": "Aggiorna dati",
|
||||
"logs.overview.total_events": "Eventi memorizzati totali",
|
||||
"logs.overview.per_server": "Eventi per server",
|
||||
"logs.overview.recent_events_title": "Eventi memorizzati recenti",
|
||||
"logs.overview.recent_empty": "Nessun evento memorizzato trovato per il server selezionato.",
|
||||
"logs.overview.empty": "Nessun evento di blocco è stato ancora registrato.",
|
||||
"logs.table.server": "Server",
|
||||
"logs.table.count": "Conteggio",
|
||||
"logs.table.jail": "Jail",
|
||||
"logs.table.ip": "IP",
|
||||
"logs.table.time": "Ora",
|
||||
"logs.table.country": "Paese",
|
||||
"filter_debug.title": "Debug Filtro",
|
||||
"filter_debug.select_filter": "Seleziona un filtro",
|
||||
"filter_debug.log_lines": "Righe di log",
|
||||
@@ -34,6 +61,8 @@
|
||||
"settings.language": "Lingua",
|
||||
"settings.enable_debug": "Abilita debug",
|
||||
"settings.alert": "Impostazioni di allarme",
|
||||
"settings.callback_url": "URL di callback Fail2ban",
|
||||
"settings.callback_url_placeholder": "http://127.0.0.1:8080",
|
||||
"settings.destination_email": "Email di destinazione (ricevente allarmi)",
|
||||
"settings.destination_email_placeholder": "alerts@swissmakers.ch",
|
||||
"settings.alert_countries": "Paesi per allarme",
|
||||
@@ -64,7 +93,64 @@
|
||||
"modal.filter_config": "Configurazione del filtro:",
|
||||
"modal.cancel": "Annulla",
|
||||
"modal.save": "Salva",
|
||||
"modal.close": "Chiudi",
|
||||
"loading": "Caricamento...",
|
||||
"dashboard.manage_jails": "Gestire i jails",
|
||||
"modal.manage_jails_title": "Gestire i jails"
|
||||
"modal.manage_jails_title": "Gestire i jails",
|
||||
"servers.selector.label": "Server attivo",
|
||||
"servers.selector.empty": "Nessun server configurato",
|
||||
"servers.selector.none": "Nessun server configurato. Aggiungi un server Fail2ban.",
|
||||
"servers.modal.title": "Gestisci i server Fail2ban",
|
||||
"servers.modal.description": "Registra istanze Fail2ban remote e scegli come l'interfaccia si connette.",
|
||||
"servers.modal.list_title": "Server registrati",
|
||||
"servers.modal.list_empty": "Nessun server configurato. Aggiungi il tuo primo server Fail2ban tramite il modulo.",
|
||||
"servers.modal.form_title": "Aggiungi o aggiorna un server",
|
||||
"servers.form.name": "Nome visualizzato",
|
||||
"servers.form.name_placeholder": "Il mio server Fail2ban",
|
||||
"servers.form.type": "Tipo di connessione",
|
||||
"servers.type.local": "Locale (stesso host)",
|
||||
"servers.type.ssh": "SSH",
|
||||
"servers.type.agent": "Agente API",
|
||||
"servers.form.host": "Nome host / IP",
|
||||
"servers.form.host_placeholder": "fail2ban.esempio.com",
|
||||
"servers.form.port": "Porta",
|
||||
"servers.form.port_placeholder": "22",
|
||||
"servers.form.socket_path": "Percorso del socket Fail2ban",
|
||||
"servers.form.socket_path_placeholder": "/var/run/fail2ban/fail2ban.sock",
|
||||
"servers.form.log_path": "Percorso del log Fail2ban",
|
||||
"servers.form.log_path_placeholder": "/var/log/fail2ban.log",
|
||||
"servers.form.hostname": "Nome host del server",
|
||||
"servers.form.hostname_placeholder": "opzionale",
|
||||
"servers.form.ssh_user": "Utente SSH",
|
||||
"servers.form.ssh_user_placeholder": "root",
|
||||
"servers.form.ssh_key": "Percorso della chiave SSH",
|
||||
"servers.form.ssh_key_placeholder": "~/.ssh/id_rsa",
|
||||
"servers.form.agent_url": "URL dell'agente",
|
||||
"servers.form.agent_url_placeholder": "https://host:9443",
|
||||
"servers.form.agent_secret": "Segreto dell'agente",
|
||||
"servers.form.agent_secret_placeholder": "token condiviso",
|
||||
"servers.form.tags": "Tag",
|
||||
"servers.form.tags_placeholder": "tag separati da virgole",
|
||||
"servers.form.set_default": "Imposta come server predefinito",
|
||||
"servers.form.enabled": "Abilita connettore",
|
||||
"servers.form.submit": "Salva server",
|
||||
"servers.form.reset": "Reimposta",
|
||||
"servers.form.success": "Server salvato correttamente.",
|
||||
"servers.badge.default": "Predefinito",
|
||||
"servers.badge.enabled": "Abilitato",
|
||||
"servers.badge.disabled": "Disabilitato",
|
||||
"servers.actions.edit": "Modifica",
|
||||
"servers.actions.set_default": "Imposta predefinito",
|
||||
"servers.actions.enable": "Abilita",
|
||||
"servers.actions.disable": "Disabilita",
|
||||
"servers.actions.test": "Verifica connessione",
|
||||
"servers.actions.test_success": "Connessione riuscita",
|
||||
"servers.actions.test_failure": "Connessione fallita",
|
||||
"servers.actions.delete": "Elimina",
|
||||
"servers.actions.delete_confirm": "Eliminare questo server?",
|
||||
"servers.form.select_key": "Seleziona chiave privata",
|
||||
"servers.form.select_key_placeholder": "Inserimento manuale",
|
||||
"servers.form.no_keys": "Nessuna chiave SSH trovata; inserire il percorso manualmente",
|
||||
"filter_debug.not_available": "Il debug dei filtri è disponibile solo per i connettori locali.",
|
||||
"filter_debug.local_missing": "La directory dei filtri Fail2ban locale non è stata trovata su questo host."
|
||||
}
|
||||
|
||||
607
internal/storage/storage.go
Normal file
607
internal/storage/storage.go
Normal file
@@ -0,0 +1,607 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
var (
|
||||
db *sql.DB
|
||||
initOnce sync.Once
|
||||
initErr error
|
||||
defaultPath = "fail2ban-ui.db"
|
||||
)
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func intToBool(i int) bool {
|
||||
return i != 0
|
||||
}
|
||||
|
||||
func stringFromNull(ns sql.NullString) string {
|
||||
if ns.Valid {
|
||||
return ns.String
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func intFromNull(ni sql.NullInt64) int {
|
||||
if ni.Valid {
|
||||
return int(ni.Int64)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
type AppSettingsRecord struct {
|
||||
Language string
|
||||
Port int
|
||||
Debug bool
|
||||
CallbackURL string
|
||||
RestartNeeded bool
|
||||
AlertCountriesJSON string
|
||||
SMTPHost string
|
||||
SMTPPort int
|
||||
SMTPUsername string
|
||||
SMTPPassword string
|
||||
SMTPFrom string
|
||||
SMTPUseTLS bool
|
||||
BantimeIncrement bool
|
||||
IgnoreIP string
|
||||
Bantime string
|
||||
Findtime string
|
||||
MaxRetry int
|
||||
DestEmail string
|
||||
}
|
||||
|
||||
type ServerRecord struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Host string
|
||||
Port int
|
||||
SocketPath string
|
||||
LogPath string
|
||||
SSHUser string
|
||||
SSHKeyPath string
|
||||
AgentURL string
|
||||
AgentSecret string
|
||||
Hostname string
|
||||
TagsJSON string
|
||||
IsDefault bool
|
||||
Enabled bool
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// BanEventRecord represents a single ban event stored in the internal database.
|
||||
type BanEventRecord struct {
|
||||
ID int64 `json:"id"`
|
||||
ServerID string `json:"serverId"`
|
||||
ServerName string `json:"serverName"`
|
||||
Jail string `json:"jail"`
|
||||
IP string `json:"ip"`
|
||||
Country string `json:"country"`
|
||||
Hostname string `json:"hostname"`
|
||||
Failures string `json:"failures"`
|
||||
Whois string `json:"whois"`
|
||||
Logs string `json:"logs"`
|
||||
OccurredAt time.Time `json:"occurredAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// Init initializes the internal storage. Safe to call multiple times.
|
||||
func Init(dbPath string) error {
|
||||
initOnce.Do(func() {
|
||||
if dbPath == "" {
|
||||
dbPath = defaultPath
|
||||
}
|
||||
if err := ensureDirectory(dbPath); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = sql.Open("sqlite", fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout=5000", dbPath))
|
||||
if err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
if err = db.Ping(); err != nil {
|
||||
initErr = err
|
||||
return
|
||||
}
|
||||
|
||||
initErr = ensureSchema(context.Background())
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
// Close closes the underlying database if it has been initialised.
|
||||
func Close() error {
|
||||
if db == nil {
|
||||
return nil
|
||||
}
|
||||
return db.Close()
|
||||
}
|
||||
|
||||
func GetAppSettings(ctx context.Context) (AppSettingsRecord, bool, error) {
|
||||
if db == nil {
|
||||
return AppSettingsRecord{}, false, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
row := db.QueryRowContext(ctx, `
|
||||
SELECT language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
|
||||
FROM app_settings
|
||||
WHERE id = 1`)
|
||||
|
||||
var (
|
||||
lang, callback, alerts, smtpHost, smtpUser, smtpPass, smtpFrom, ignoreIP, bantime, findtime, destemail sql.NullString
|
||||
port, smtpPort, maxretry sql.NullInt64
|
||||
debug, restartNeeded, smtpTLS, bantimeInc sql.NullInt64
|
||||
)
|
||||
|
||||
err := row.Scan(&lang, &port, &debug, &callback, &restartNeeded, &alerts, &smtpHost, &smtpPort, &smtpUser, &smtpPass, &smtpFrom, &smtpTLS, &bantimeInc, &ignoreIP, &bantime, &findtime, &maxretry, &destemail)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return AppSettingsRecord{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return AppSettingsRecord{}, false, err
|
||||
}
|
||||
|
||||
rec := AppSettingsRecord{
|
||||
Language: stringFromNull(lang),
|
||||
Port: intFromNull(port),
|
||||
Debug: intToBool(intFromNull(debug)),
|
||||
CallbackURL: stringFromNull(callback),
|
||||
RestartNeeded: intToBool(intFromNull(restartNeeded)),
|
||||
AlertCountriesJSON: stringFromNull(alerts),
|
||||
SMTPHost: stringFromNull(smtpHost),
|
||||
SMTPPort: intFromNull(smtpPort),
|
||||
SMTPUsername: stringFromNull(smtpUser),
|
||||
SMTPPassword: stringFromNull(smtpPass),
|
||||
SMTPFrom: stringFromNull(smtpFrom),
|
||||
SMTPUseTLS: intToBool(intFromNull(smtpTLS)),
|
||||
BantimeIncrement: intToBool(intFromNull(bantimeInc)),
|
||||
IgnoreIP: stringFromNull(ignoreIP),
|
||||
Bantime: stringFromNull(bantime),
|
||||
Findtime: stringFromNull(findtime),
|
||||
MaxRetry: intFromNull(maxretry),
|
||||
DestEmail: stringFromNull(destemail),
|
||||
}
|
||||
|
||||
return rec, true, nil
|
||||
}
|
||||
|
||||
func SaveAppSettings(ctx context.Context, rec AppSettingsRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `
|
||||
INSERT INTO app_settings (
|
||||
id, language, port, debug, callback_url, restart_needed, alert_countries, smtp_host, smtp_port, smtp_username, smtp_password, smtp_from, smtp_use_tls, bantime_increment, ignore_ip, bantime, findtime, maxretry, destemail
|
||||
) VALUES (
|
||||
1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
) ON CONFLICT(id) DO UPDATE SET
|
||||
language = excluded.language,
|
||||
port = excluded.port,
|
||||
debug = excluded.debug,
|
||||
callback_url = excluded.callback_url,
|
||||
restart_needed = excluded.restart_needed,
|
||||
alert_countries = excluded.alert_countries,
|
||||
smtp_host = excluded.smtp_host,
|
||||
smtp_port = excluded.smtp_port,
|
||||
smtp_username = excluded.smtp_username,
|
||||
smtp_password = excluded.smtp_password,
|
||||
smtp_from = excluded.smtp_from,
|
||||
smtp_use_tls = excluded.smtp_use_tls,
|
||||
bantime_increment = excluded.bantime_increment,
|
||||
ignore_ip = excluded.ignore_ip,
|
||||
bantime = excluded.bantime,
|
||||
findtime = excluded.findtime,
|
||||
maxretry = excluded.maxretry,
|
||||
destemail = excluded.destemail
|
||||
`, rec.Language,
|
||||
rec.Port,
|
||||
boolToInt(rec.Debug),
|
||||
rec.CallbackURL,
|
||||
boolToInt(rec.RestartNeeded),
|
||||
rec.AlertCountriesJSON,
|
||||
rec.SMTPHost,
|
||||
rec.SMTPPort,
|
||||
rec.SMTPUsername,
|
||||
rec.SMTPPassword,
|
||||
rec.SMTPFrom,
|
||||
boolToInt(rec.SMTPUseTLS),
|
||||
boolToInt(rec.BantimeIncrement),
|
||||
rec.IgnoreIP,
|
||||
rec.Bantime,
|
||||
rec.Findtime,
|
||||
rec.MaxRetry,
|
||||
rec.DestEmail,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func ListServers(ctx context.Context) ([]ServerRecord, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
|
||||
FROM servers
|
||||
ORDER BY created_at`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []ServerRecord
|
||||
for rows.Next() {
|
||||
var rec ServerRecord
|
||||
var host, socket, logPath, sshUser, sshKey, agentURL, agentSecret, hostname, tags sql.NullString
|
||||
var name, serverType sql.NullString
|
||||
var created, updated sql.NullString
|
||||
var port sql.NullInt64
|
||||
var isDefault, enabled sql.NullInt64
|
||||
|
||||
if err := rows.Scan(
|
||||
&rec.ID,
|
||||
&name,
|
||||
&serverType,
|
||||
&host,
|
||||
&port,
|
||||
&socket,
|
||||
&logPath,
|
||||
&sshUser,
|
||||
&sshKey,
|
||||
&agentURL,
|
||||
&agentSecret,
|
||||
&hostname,
|
||||
&tags,
|
||||
&isDefault,
|
||||
&enabled,
|
||||
&created,
|
||||
&updated,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rec.Name = stringFromNull(name)
|
||||
rec.Type = stringFromNull(serverType)
|
||||
rec.Host = stringFromNull(host)
|
||||
rec.Port = intFromNull(port)
|
||||
rec.SocketPath = stringFromNull(socket)
|
||||
rec.LogPath = stringFromNull(logPath)
|
||||
rec.SSHUser = stringFromNull(sshUser)
|
||||
rec.SSHKeyPath = stringFromNull(sshKey)
|
||||
rec.AgentURL = stringFromNull(agentURL)
|
||||
rec.AgentSecret = stringFromNull(agentSecret)
|
||||
rec.Hostname = stringFromNull(hostname)
|
||||
rec.TagsJSON = stringFromNull(tags)
|
||||
rec.IsDefault = intToBool(intFromNull(isDefault))
|
||||
rec.Enabled = intToBool(intFromNull(enabled))
|
||||
|
||||
if created.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, created.String); err == nil {
|
||||
rec.CreatedAt = t
|
||||
}
|
||||
}
|
||||
if updated.Valid {
|
||||
if t, err := time.Parse(time.RFC3339Nano, updated.String); err == nil {
|
||||
rec.UpdatedAt = t
|
||||
}
|
||||
}
|
||||
|
||||
records = append(records, rec)
|
||||
}
|
||||
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
func ReplaceServers(ctx context.Context, servers []ServerRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err = tx.ExecContext(ctx, `DELETE FROM servers`); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stmt, err := tx.PrepareContext(ctx, `
|
||||
INSERT INTO servers (
|
||||
id, name, type, host, port, socket_path, log_path, ssh_user, ssh_key_path, agent_url, agent_secret, hostname, tags, is_default, enabled, created_at, updated_at
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stmt.Close()
|
||||
|
||||
for _, srv := range servers {
|
||||
createdAt := srv.CreatedAt
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Now().UTC()
|
||||
}
|
||||
updatedAt := srv.UpdatedAt
|
||||
if updatedAt.IsZero() {
|
||||
updatedAt = createdAt
|
||||
}
|
||||
if _, err = stmt.ExecContext(ctx,
|
||||
srv.ID,
|
||||
srv.Name,
|
||||
srv.Type,
|
||||
srv.Host,
|
||||
srv.Port,
|
||||
srv.SocketPath,
|
||||
srv.LogPath,
|
||||
srv.SSHUser,
|
||||
srv.SSHKeyPath,
|
||||
srv.AgentURL,
|
||||
srv.AgentSecret,
|
||||
srv.Hostname,
|
||||
srv.TagsJSON,
|
||||
boolToInt(srv.IsDefault),
|
||||
boolToInt(srv.Enabled),
|
||||
createdAt.Format(time.RFC3339Nano),
|
||||
updatedAt.Format(time.RFC3339Nano),
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.Commit()
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteServer(ctx context.Context, id string) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
_, err := db.ExecContext(ctx, `DELETE FROM servers WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// RecordBanEvent stores a ban event in the database.
|
||||
func RecordBanEvent(ctx context.Context, record BanEventRecord) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
if record.ServerID == "" {
|
||||
return errors.New("server id is required")
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
if record.OccurredAt.IsZero() {
|
||||
record.OccurredAt = now
|
||||
}
|
||||
|
||||
const query = `
|
||||
INSERT INTO ban_events (
|
||||
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
|
||||
_, err := db.ExecContext(
|
||||
ctx,
|
||||
query,
|
||||
record.ServerID,
|
||||
record.ServerName,
|
||||
record.Jail,
|
||||
record.IP,
|
||||
record.Country,
|
||||
record.Hostname,
|
||||
record.Failures,
|
||||
record.Whois,
|
||||
record.Logs,
|
||||
record.OccurredAt.UTC(),
|
||||
record.CreatedAt.UTC(),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ListBanEvents returns ban events ordered by creation date descending.
|
||||
func ListBanEvents(ctx context.Context, serverID string, limit int, since time.Time) ([]BanEventRecord, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
}
|
||||
|
||||
baseQuery := `
|
||||
SELECT id, server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
||||
FROM ban_events
|
||||
WHERE 1=1`
|
||||
|
||||
args := []any{}
|
||||
if serverID != "" {
|
||||
baseQuery += " AND server_id = ?"
|
||||
args = append(args, serverID)
|
||||
}
|
||||
if !since.IsZero() {
|
||||
baseQuery += " AND occurred_at >= ?"
|
||||
args = append(args, since.UTC())
|
||||
}
|
||||
|
||||
baseQuery += " ORDER BY occurred_at DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := db.QueryContext(ctx, baseQuery, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var results []BanEventRecord
|
||||
for rows.Next() {
|
||||
var rec BanEventRecord
|
||||
if err := rows.Scan(
|
||||
&rec.ID,
|
||||
&rec.ServerID,
|
||||
&rec.ServerName,
|
||||
&rec.Jail,
|
||||
&rec.IP,
|
||||
&rec.Country,
|
||||
&rec.Hostname,
|
||||
&rec.Failures,
|
||||
&rec.Whois,
|
||||
&rec.Logs,
|
||||
&rec.OccurredAt,
|
||||
&rec.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, rec)
|
||||
}
|
||||
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
// CountBanEventsByServer returns simple aggregation per server.
|
||||
func CountBanEventsByServer(ctx context.Context, since time.Time) (map[string]int64, error) {
|
||||
if db == nil {
|
||||
return nil, errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT server_id, COUNT(*)
|
||||
FROM ban_events
|
||||
WHERE 1=1`
|
||||
args := []any{}
|
||||
|
||||
if !since.IsZero() {
|
||||
query += " AND occurred_at >= ?"
|
||||
args = append(args, since.UTC())
|
||||
}
|
||||
|
||||
query += " GROUP BY server_id"
|
||||
|
||||
rows, err := db.QueryContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int64)
|
||||
for rows.Next() {
|
||||
var serverID string
|
||||
var count int64
|
||||
if err := rows.Scan(&serverID, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[serverID] = count
|
||||
}
|
||||
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func ensureSchema(ctx context.Context) error {
|
||||
if db == nil {
|
||||
return errors.New("storage not initialised")
|
||||
}
|
||||
|
||||
const createTable = `
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
language TEXT,
|
||||
port INTEGER,
|
||||
debug INTEGER,
|
||||
callback_url TEXT,
|
||||
restart_needed INTEGER,
|
||||
alert_countries TEXT,
|
||||
smtp_host TEXT,
|
||||
smtp_port INTEGER,
|
||||
smtp_username TEXT,
|
||||
smtp_password TEXT,
|
||||
smtp_from TEXT,
|
||||
smtp_use_tls INTEGER,
|
||||
bantime_increment INTEGER,
|
||||
ignore_ip TEXT,
|
||||
bantime TEXT,
|
||||
findtime TEXT,
|
||||
maxretry INTEGER,
|
||||
destemail TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS servers (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
type TEXT,
|
||||
host TEXT,
|
||||
port INTEGER,
|
||||
socket_path TEXT,
|
||||
log_path TEXT,
|
||||
ssh_user TEXT,
|
||||
ssh_key_path TEXT,
|
||||
agent_url TEXT,
|
||||
agent_secret TEXT,
|
||||
hostname TEXT,
|
||||
tags TEXT,
|
||||
is_default INTEGER,
|
||||
enabled INTEGER,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS ban_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
server_id TEXT NOT NULL,
|
||||
server_name TEXT NOT NULL,
|
||||
jail TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
country TEXT,
|
||||
hostname TEXT,
|
||||
failures TEXT,
|
||||
whois TEXT,
|
||||
logs TEXT,
|
||||
occurred_at DATETIME NOT NULL,
|
||||
created_at DATETIME NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_ban_events_server_id ON ban_events(server_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_ban_events_occurred_at ON ban_events(occurred_at);
|
||||
`
|
||||
|
||||
_, err := db.ExecContext(ctx, createTable)
|
||||
return err
|
||||
}
|
||||
|
||||
func ensureDirectory(path string) error {
|
||||
if path == ":memory:" {
|
||||
return nil
|
||||
}
|
||||
dir := filepath.Dir(path)
|
||||
if dir == "." || dir == "" {
|
||||
return nil
|
||||
}
|
||||
return os.MkdirAll(dir, 0o755)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -44,6 +44,14 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
api.POST("/settings", UpdateSettingsHandler)
|
||||
api.POST("/settings/test-email", TestEmailHandler)
|
||||
|
||||
// Fail2ban servers management
|
||||
api.GET("/servers", ListServersHandler)
|
||||
api.POST("/servers", UpsertServerHandler)
|
||||
api.DELETE("/servers/:id", DeleteServerHandler)
|
||||
api.POST("/servers/:id/default", SetDefaultServerHandler)
|
||||
api.GET("/ssh/keys", ListSSHKeysHandler)
|
||||
api.POST("/servers/:id/test", TestServerHandler)
|
||||
|
||||
// Filter debugger endpoints
|
||||
api.GET("/filters", ListFiltersHandler)
|
||||
api.POST("/filters/test", TestFilterHandler)
|
||||
@@ -56,5 +64,9 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
|
||||
// Handle Fail2Ban notifications
|
||||
api.POST("/ban", BanNotificationHandler)
|
||||
|
||||
// Internal database overview
|
||||
api.GET("/events/bans", ListBanEventsHandler)
|
||||
api.GET("/events/bans/stats", BanStatisticsHandler)
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user