Merge pull request #10 from swissmakers/dev

Refactor WebUI to TailwindCSS, Improve Logging & Debugging, and Various Fixes
This commit is contained in:
Swissmakers GmbH
2025-07-22 19:29:18 +02:00
committed by GitHub
15 changed files with 2226 additions and 622 deletions

View File

@@ -1,7 +1,7 @@
# =========================================
# STAGE 1: Build Fail2Ban UI Binary
# =========================================
FROM golang:1.22.9 AS builder
FROM golang:1.23 AS builder
WORKDIR /app
@@ -41,6 +41,7 @@ WORKDIR /config
COPY --from=builder /app/fail2ban-ui /app/fail2ban-ui
RUN chown fail2ban:0 /app/fail2ban-ui && chmod +x /app/fail2ban-ui
COPY --from=builder /app/pkg/web/templates /app/templates
COPY --from=builder /app/internal/locales /app/locales
# Set environment variables
ENV CONTAINER=true

View File

@@ -1,3 +1,19 @@
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
//
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
// You may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.gnu.org/licenses/gpl-3.0.en.html
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package main
import (
@@ -32,12 +48,14 @@ func main() {
if container {
// In container, templates are assumed to be in /app/templates
router.LoadHTMLGlob("/app/templates/*")
router.Static("/locales", "/app/locales")
} else {
// When running locally, load templates from pkg/web/templates
router.LoadHTMLGlob("pkg/web/templates/*")
router.Static("/locales", "./internal/locales")
}
// Register all application routes, including the static file serving route for locales.
// Register all application routes, including the static files and templates.
web.RegisterRoutes(router)
printWelcomeBanner(serverPort)

8
go.mod
View File

@@ -4,6 +4,7 @@ go 1.23
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
)
@@ -15,7 +16,6 @@ require (
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/go-playground/validator/v10 v10.24.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
@@ -27,10 +27,10 @@ require (
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
golang.org/x/crypto v0.32.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

16
go.sum
View File

@@ -21,8 +21,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
@@ -67,15 +67,15 @@ github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65E
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
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/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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/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=

View File

@@ -191,6 +191,8 @@ func initializeFromJailFile() error {
// initializeFail2banAction writes a custom action configuration for Fail2ban to use AlertCountries.
func initializeFail2banAction() error {
DebugLog("----------------------------")
DebugLog("Running initial initializeFail2banAction()") // entry point
// Ensure the jail.local is configured correctly
if err := setupGeoCustomAction(); err != nil {
fmt.Println("Error setup GeoCustomAction in jail.local:", err)
@@ -205,6 +207,7 @@ func initializeFail2banAction() error {
// setupGeoCustomAction checks and replaces the default action in jail.local with our from fail2ban-UI
func setupGeoCustomAction() error {
DebugLog("Running initial setupGeoCustomAction()") // entry point
file, err := os.Open(jailFile)
if err != nil {
// Fallback: Copy default file if jail.local is not found
@@ -284,6 +287,7 @@ func copyFile(src, dst string) error {
// ensureJailDConfig checks if the jail.d file exists and creates it if necessary
func ensureJailDConfig() error {
DebugLog("Running initial ensureJailDConfig()") // entry point
// Check if the file already exists
if _, err := os.Stat(jailDFile); err == nil {
// File already exists, do nothing
@@ -310,6 +314,8 @@ action_mwlg = %(action_)s
// writeFail2banAction creates or updates the action file with the AlertCountries.
func writeFail2banAction() error {
DebugLog("Running initial writeFail2banAction()") // entry point
DebugLog("----------------------------")
// Define the Fail2Ban action file content
actionConfig := `[INCLUDES]
@@ -344,8 +350,8 @@ name = default
logpath = /dev/null
# Number of log lines to include in the email
# grepmax = 1000
# grepopts = -m <grepmax>`
grepmax = 200
grepopts = -m <grepmax>`
// Write the action file
err := os.WriteFile(actionFile, []byte(actionConfig), 0644)
@@ -395,7 +401,7 @@ func saveSettings() error {
if err != nil {
DebugLog("Error writing to file: %v", err) // Debug
}
// Update the Fail2ban action file
// Write again the Fail2ban-UI action file (in the future not used anymore)
return writeFail2banAction()
}
@@ -439,8 +445,6 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
old.Bantime != new.Bantime ||
old.Findtime != new.Findtime ||
//old.Maxretry != new.Maxretry ||
old.Destemail != new.Destemail ||
//old.Sender != new.Sender {
old.Maxretry != new.Maxretry {
new.RestartNeeded = true
} else {
@@ -448,11 +452,6 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
new.RestartNeeded = new.RestartNeeded || old.RestartNeeded
}
// Countries change? Currently also requires a reload
if !equalStringSlices(old.AlertCountries, new.AlertCountries) {
new.RestartNeeded = true
}
currentSettings = new
DebugLog("New settings applied: %v", currentSettings) // Log settings applied
@@ -464,19 +463,3 @@ func UpdateSettings(new AppSettings) (AppSettings, error) {
fmt.Println("Settings saved to file successfully") // Log save success
return currentSettings, nil
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
m := make(map[string]bool)
for _, x := range a {
m[x] = false
}
for _, x := range b {
if _, ok := m[x]; !ok {
return false
}
}
return true
}

View File

@@ -19,6 +19,7 @@ package fail2ban
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
@@ -152,6 +153,11 @@ func ReloadFail2ban() error {
// RestartFail2ban restarts the Fail2ban service.
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)
if err != nil {

View File

@@ -3,7 +3,7 @@
"nav.dashboard": "Dashboard",
"nav.filter_debug": "Filter-Debug",
"nav.settings": "Einstellungen",
"restart_banner.message": "Fail2ban Konfiguration geändert! Um Änderungen zu übernehmen bitte: ",
"restart_banner.message": "Fail2ban Konfiguration geändert. Um Änderungen zu übernehmen bitte ",
"restart_banner.button": "Service neu starten",
"dashboard.title": "Dashboard",
"dashboard.overview": "Aktive Jails und Blocks Übersicht",

View File

@@ -3,7 +3,7 @@
"nav.dashboard": "Dashboard",
"nav.filter_debug": "Filter Debug",
"nav.settings": "Settings",
"restart_banner.message": "Fail2ban configuration changed! To apply the changes, please: ",
"restart_banner.message": "Fail2ban configuration changed. To apply the changes, please ",
"restart_banner.button": "Restart Service",
"dashboard.title": "Dashboard",
"dashboard.overview": "Overview active Jails and Blocks",

View File

@@ -3,7 +3,7 @@
"nav.dashboard": "Panel de control",
"nav.filter_debug": "Depuración de filtros",
"nav.settings": "Configuración",
"restart_banner.message": "¡Configuración de Fail2ban modificada! Para aplicar los cambios, por favor: ",
"restart_banner.message": "¡Configuración de Fail2ban modificada. Para aplicar los cambios, por favor ",
"restart_banner.button": "Reiniciar servicio",
"dashboard.title": "Panel de control",
"dashboard.overview": "Resumen de Jails y Bloqueos activos",

View File

@@ -3,7 +3,7 @@
"nav.dashboard": "Tableau de bord",
"nav.filter_debug": "Débogage des filtres",
"nav.settings": "Paramètres",
"restart_banner.message": "Configuration Fail2ban modifiée ! Pour appliquer les changements, veuillez: ",
"restart_banner.message": "Configuration Fail2ban modifiée. Pour appliquer les changements, veuillez ",
"restart_banner.button": "Redémarrer le service",
"dashboard.title": "Tableau de bord",
"dashboard.overview": "Vue d'ensemble des jails et blocages actifs",

View File

@@ -3,7 +3,7 @@
"nav.dashboard": "Cruscotto",
"nav.filter_debug": "Debug Filtro",
"nav.settings": "Impostazioni",
"restart_banner.message": "Configurazione di Fail2ban modificata! Per applicare le modifiche, per favore: ",
"restart_banner.message": "Configurazione di Fail2ban modificata. Per applicare le modifiche, per favore ",
"restart_banner.button": "Riavvia il servizio",
"dashboard.title": "Cruscotto",
"dashboard.overview": "Panoramica dei jail e dei blocchi attivi",

View File

@@ -31,6 +31,7 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/oschwald/maxminddb-golang"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
@@ -112,14 +113,33 @@ func BanNotificationHandler(c *gin.Context) {
// **DEBUGGING: Log Raw JSON Body**
body, _ := io.ReadAll(c.Request.Body)
log.Printf("----------------------------------------------------")
log.Printf("Request Content-Length: %d", c.Request.ContentLength)
log.Printf("Request Headers: %v", c.Request.Header)
log.Printf("Request Headers: %v", c.Request.Body)
log.Printf("----------------------------------------------------")
config.DebugLog("📩 Incoming Ban Notification: %s\n", string(body))
// Rebind body so Gin can parse it again (important!)
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
log.Printf("Request Content-Length: %d", c.Request.ContentLength)
log.Printf("Request Headers: %v", c.Request.Header)
log.Printf("Request Headers: %v", c.Request.Body)
// Parse JSON request body
if err := c.ShouldBindJSON(&request); err != nil {
log.Printf("❌ Invalid request: %v\n", err)
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
log.Printf("❌ Validierungsfehler: Feld '%s' verletzt Regel '%s'", fe.Field(), fe.ActualTag())
}
} else {
log.Printf("❌ JSON-Parsing Fehler: %v", err)
}
log.Printf("Raw JSON: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
@@ -439,13 +459,22 @@ func RestartFail2banHandler(c *gin.Context) {
// return
// }
// Then restart
if err := fail2ban.RestartFail2ban(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
// Attempt to restart the fail2ban service.
restartErr := fail2ban.RestartFail2ban()
if restartErr != nil {
// Check if running inside a container.
if _, container := os.LookupEnv("CONTAINER"); container {
// In a container, the restart command may fail (since fail2ban runs on the host).
// Log the error and continue, so we can mark the restart as done.
log.Printf("Warning: restart failed inside container (expected behavior): %v", restartErr)
} else {
// On the host, a restart error is not acceptable.
c.JSON(http.StatusInternalServerError, gin.H{"error": restartErr.Error()})
return
}
}
// We set restart done in config
// Only call MarkRestartDone if we either successfully restarted the service or we are in a container.
if err := config.MarkRestartDone(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -475,7 +504,8 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error {
smtpAddr := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort))
// **Choose Connection Type**
if smtpPort == 465 {
switch smtpPort {
case 465:
// SMTPS (Implicit TLS) - Not supported at the moment.
tlsConfig := &tls.Config{ServerName: smtpHost}
conn, err := tls.Dial("tcp", smtpAddr, tlsConfig)
@@ -496,7 +526,7 @@ func sendEmail(to, subject, body string, settings config.AppSettings) error {
return sendSMTPMessage(client, settings.SMTP.From, to, msg)
} else if smtpPort == 587 {
case 587:
// STARTTLS (Explicit TLS)
conn, err := net.Dial("tcp", smtpAddr)
if err != nil {

View File

@@ -22,9 +22,6 @@ import (
// RegisterRoutes sets up the routes for the Fail2ban UI.
func RegisterRoutes(r *gin.Engine) {
// Serve static files for locales from the "internal/locales" directory.
// (This makes the translation files available under the /locales/ URL.)
r.Static("/locales", "./internal/locales")
// Render the dashboard
r.GET("/", IndexHandler)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff