Files
fail2ban-ui/pkg/web/handlers.go

3459 lines
119 KiB
Go
Raw Normal View History

// Fail2ban UI - A Swiss made, management interface for Fail2ban.
//
// Copyright (C) 2026 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.
2025-01-25 16:21:14 +01:00
package web
import (
"bytes"
"context"
"crypto/rand"
"crypto/subtle"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"html"
"io"
"log"
"net"
2025-01-25 16:21:14 +01:00
"net/http"
"net/smtp"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
2025-01-25 21:43:50 +01:00
"strings"
"sync"
2025-01-25 16:21:14 +01:00
"time"
"github.com/gin-gonic/gin"
"github.com/go-playground/validator/v10"
"github.com/oschwald/maxminddb-golang"
"github.com/swissmakers/fail2ban-ui/internal/auth"
2025-01-25 21:43:50 +01:00
"github.com/swissmakers/fail2ban-ui/internal/config"
2025-01-25 16:21:14 +01:00
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
"github.com/swissmakers/fail2ban-ui/internal/integrations"
"github.com/swissmakers/fail2ban-ui/internal/storage"
"github.com/swissmakers/fail2ban-ui/internal/version"
2025-01-25 16:21:14 +01:00
)
// =========================================================================
// Types and Variables
// =========================================================================
var wsHub *Hub
// SetWebSocketHub sets the global WebSocket hub instance
func SetWebSocketHub(hub *Hub) {
wsHub = hub
}
2025-01-25 16:21:14 +01:00
type SummaryResponse struct {
Jails []fail2ban.JailInfo `json:"jails"`
JailLocalWarning bool `json:"jailLocalWarning,omitempty"`
2025-01-25 16:21:14 +01:00
}
type emailDetail struct {
Label string
Value string
}
type githubReleaseResponse struct {
TagName string `json:"tag_name"`
}
var (
httpQuotedStatusPattern = regexp.MustCompile(`"[^"]*"\s+(\d{3})\b`)
httpPlainStatusPattern = regexp.MustCompile(`\s(\d{3})\s+(?:\d+|-)`)
suspiciousLogIndicators = []string{
"select ",
"union ",
"/etc/passwd",
"/xmlrpc.php",
"/wp-admin",
"/cgi-bin",
"cmd=",
"wget",
"curl ",
"nslookup",
"content-length: 0",
"${",
}
localeCache = make(map[string]map[string]string)
localeCacheLock sync.RWMutex
)
// =========================================================================
// Request Helpers
// =========================================================================
// Resolves the Fail2ban connector for the current request.
// Uses the "serverId" query param, "X-F2B-Server" header, or the default server.
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()
}
// Resolves a server by ID, hostname, or falls back to default.
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
}
// =========================================================================
// Dashboard
// =========================================================================
// Returns a JSON summary of all jails for the selected server.
2025-01-25 16:21:14 +01:00
func SummaryHandler(c *gin.Context) {
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
2025-01-25 16:21:14 +01:00
jailInfos, err := conn.GetJailInfos(c.Request.Context())
2025-01-25 16:21:14 +01:00
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := SummaryResponse{
Jails: jailInfos,
2025-01-25 16:21:14 +01:00
}
// Checks the jail.local integrity on every summary request to warn the user if not managed by Fail2ban-UI.
if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil {
if exists && !hasUI {
resp.JailLocalWarning = true
} else if !exists {
// File was removed (user finished migration) initialize a fresh managed file
if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to initialize jail.local on summary request: %v", err)
} else {
config.DebugLog("Initialized fresh jail.local for server %s (file was missing)", conn.Server().Name)
}
}
}
2025-01-25 16:21:14 +01:00
c.JSON(http.StatusOK, resp)
}
// =========================================================================
// Ban / Unban Actions
// =========================================================================
// Bans a given IP in a specific jail.
func BanIPHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("BanIPHandler called (handlers.go)")
2025-01-25 16:21:14 +01:00
jail := c.Param("jail")
ip := c.Param("ip")
conn, err := resolveConnector(c)
2025-01-25 16:21:14 +01:00
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := conn.BanIP(c.Request.Context(), jail, ip); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
2025-01-25 16:21:14 +01:00
return
}
fmt.Println(ip + " in jail " + jail + " banned successfully.")
2025-01-25 16:21:14 +01:00
c.JSON(http.StatusOK, gin.H{
"message": "IP banned successfully",
2025-01-25 16:21:14 +01:00
})
}
// Unbans a given IP from a specific jail.
func UnbanIPHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("UnbanIPHandler called (handlers.go)")
jail := c.Param("jail")
ip := c.Param("ip")
conn, err := resolveConnector(c)
if err != nil {
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.")
c.JSON(http.StatusOK, gin.H{
"message": "IP unbanned successfully",
})
}
// Processes incoming ban callbacks from Fail2Ban action scripts.
func BanNotificationHandler(c *gin.Context) {
settings := config.GetSettings()
providedSecret := c.GetHeader("X-Callback-Secret")
expectedSecret := settings.CallbackSecret
if expectedSecret == "" {
log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"})
return
}
if providedSecret == "" {
log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"})
return
}
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 {
log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"})
return
}
var request struct {
ServerID string `json:"serverId"`
IP string `json:"ip" binding:"required"`
Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"`
Failures string `json:"failures"`
Whois string `json:"whois"`
Logs string `json:"logs"`
}
// Logs the raw JSON body of the request
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))
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)
if err := c.ShouldBindJSON(&request); err != nil {
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
}
} else {
log.Printf("❌ JSON parsing error -> Action will not be recorded! Details: %v", err)
}
log.Printf("Raw JSON: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
// Logs the parsed request
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
}
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
}
c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"})
}
// Processes incoming unban callbacks from Fail2Ban action scripts.
func UnbanNotificationHandler(c *gin.Context) {
settings := config.GetSettings()
providedSecret := c.GetHeader("X-Callback-Secret")
expectedSecret := settings.CallbackSecret
if expectedSecret == "" {
log.Printf("⚠️ Callback secret not configured, rejecting request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Callback secret not configured"})
return
}
if providedSecret == "" {
log.Printf("⚠️ Missing X-Callback-Secret header in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Missing X-Callback-Secret header"})
return
}
if subtle.ConstantTimeCompare([]byte(providedSecret), []byte(expectedSecret)) != 1 {
log.Printf("⚠️ Invalid callback secret in request from %s", c.ClientIP())
c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid callback secret"})
return
}
var request struct {
ServerID string `json:"serverId"`
IP string `json:"ip" binding:"required"`
Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"`
}
body, _ := io.ReadAll(c.Request.Body)
config.DebugLog("📩 Incoming unban notification: %s\n", string(body))
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
if err := c.ShouldBindJSON(&request); err != nil {
var verr validator.ValidationErrors
if errors.As(err, &verr) {
for _, fe := range verr {
log.Printf("❌ Validation error: Field '%s' violated rule '%s'", fe.Field(), fe.ActualTag())
}
} else {
log.Printf("❌ JSON parsing error -> Action will not be recorded! Details: %v", err)
}
log.Printf("Raw JSON: %s", string(body))
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
return
}
log.Printf("✅ Parsed unban request - IP: %s, Jail: %s, Hostname: %s",
request.IP, request.Jail, request.Hostname)
server, err := resolveServerForNotification(request.ServerID, request.Hostname)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := HandleUnbanNotification(c.Request.Context(), server, request.IP, request.Jail, request.Hostname, "", ""); err != nil {
log.Printf("❌ Failed to process unban notification: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to process unban notification: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"})
}
// =========================================================================
// Ban Events Records
// =========================================================================
// Returns paginated, filterable ban/unban events.
func ListBanEventsHandler(c *gin.Context) {
serverID := c.Query("serverId")
limit := storage.MaxBanEventsLimit
if limitStr := c.DefaultQuery("limit", strconv.Itoa(storage.MaxBanEventsLimit)); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
if parsed <= storage.MaxBanEventsLimit {
limit = parsed
}
}
}
offset := 0
if offsetStr := c.DefaultQuery("offset", "0"); offsetStr != "" {
if parsed, err := strconv.Atoi(offsetStr); err == nil && parsed >= 0 {
if parsed <= storage.MaxBanEventsOffset {
offset = parsed
}
}
}
var since time.Time
if sinceStr := c.Query("since"); sinceStr != "" {
if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil {
since = parsed
}
}
search := strings.TrimSpace(c.Query("search"))
country := strings.TrimSpace(c.Query("country"))
ctx := c.Request.Context()
events, err := storage.ListBanEventsFiltered(ctx, serverID, limit, offset, since, search, country)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
resp := gin.H{"events": events, "hasMore": len(events) == limit}
if offset == 0 {
total, errCount := storage.CountBanEventsFiltered(ctx, serverID, since, search, country)
if errCount == nil {
resp["total"] = total
}
}
c.JSON(http.StatusOK, resp)
}
// Returns aggregated ban event 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})
}
// Returns aggregate stats for countries and recurring IPs for ban events.
func BanInsightsHandler(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
}
}
serverID := c.Query("serverId")
minCount := 3
if minCountStr := c.DefaultQuery("minCount", "3"); minCountStr != "" {
if parsed, err := strconv.Atoi(minCountStr); err == nil && parsed > 0 {
minCount = parsed
}
}
limit := 50
if limitStr := c.DefaultQuery("limit", "50"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
ctx := c.Request.Context()
countriesMap, err := storage.CountBanEventsByCountry(ctx, since, serverID)
if err != nil {
settings := config.GetSettings()
errorMsg := err.Error()
if settings.Debug {
config.DebugLog("BanInsightsHandler: CountBanEventsByCountry error: %v", err)
errorMsg = fmt.Sprintf("CountBanEventsByCountry failed: %v", err)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errorMsg})
return
}
recurring, err := storage.ListRecurringIPStats(ctx, since, minCount, limit, serverID)
if err != nil {
settings := config.GetSettings()
errorMsg := err.Error()
if settings.Debug {
config.DebugLog("BanInsightsHandler: ListRecurringIPStats error: %v", err)
errorMsg = fmt.Sprintf("ListRecurringIPStats failed: %v", err)
}
c.JSON(http.StatusInternalServerError, gin.H{"error": errorMsg})
return
}
totalOverall, err := storage.CountBanEvents(ctx, time.Time{}, serverID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
now := time.Now().UTC()
totalToday, err := storage.CountBanEvents(ctx, now.Add(-24*time.Hour), serverID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
totalWeek, err := storage.CountBanEvents(ctx, now.Add(-7*24*time.Hour), serverID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
type countryStat struct {
Country string `json:"country"`
Count int64 `json:"count"`
}
countries := make([]countryStat, 0, len(countriesMap))
for country, count := range countriesMap {
countries = append(countries, countryStat{
Country: country,
Count: count,
})
}
sort.Slice(countries, func(i, j int) bool {
if countries[i].Count == countries[j].Count {
return countries[i].Country < countries[j].Country
}
return countries[i].Count > countries[j].Count
})
c.JSON(http.StatusOK, gin.H{
"countries": countries,
"recurring": recurring,
"totals": gin.H{
"overall": totalOverall,
"today": totalToday,
"week": totalWeek,
},
})
}
// =========================================================================
// Fail2ban Servers Management
// =========================================================================
// Returns all configured Fail2ban servers.
func ListServersHandler(c *gin.Context) {
servers := config.ListServers()
c.JSON(http.StatusOK, gin.H{"servers": servers})
}
// 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
}
// Check if server exists and was previously disabled
oldServer, wasEnabled := config.GetServerByID(req.ID)
wasDisabled := !wasEnabled || !oldServer.Enabled
server, err := config.UpsertServer(req)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Check if server was just enabled (transition from disabled to enabled)
justEnabled := wasDisabled && server.Enabled
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if justEnabled && (server.Type == "ssh" || server.Type == "agent") {
if err := fail2ban.GetManager().UpdateActionFileForServer(c.Request.Context(), server.ID); err != nil {
config.DebugLog("Warning: failed to update action file for server %s: %v", server.Name, err)
}
}
// Ensures the jail.local structure is properly initialized for newly enabled/added servers
var jailLocalWarning bool
if justEnabled || !wasEnabled {
conn, err := fail2ban.GetManager().Connector(server.ID)
if err == nil {
// EnsureJailLocalStructure respects user-owned files:
// - file missing --> creates it
// - file is ours --> updates it
// - file is user's own --> leave it alone
if err := conn.EnsureJailLocalStructure(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to ensure jail.local structure for server %s: %v", server.Name, err)
} else {
config.DebugLog("Successfully ensured jail.local structure for server %s", server.Name)
}
// Checks the integrity AFTER ensuring structure so fresh servers don't trigger a false-positive warning.
if exists, hasUI, chkErr := conn.CheckJailLocalIntegrity(c.Request.Context()); chkErr == nil && exists && !hasUI {
jailLocalWarning = true
log.Printf("⚠️ Server %s: jail.local is not managed by Fail2ban-UI. Please migrate your jail.local manually (see documentation).", server.Name)
}
// Tries to restart Fail2ban and performs a basic health check after the server was enabled
if justEnabled {
if err := conn.Restart(c.Request.Context()); err != nil {
msg := fmt.Sprintf("failed to restart fail2ban for server %s: %v", server.Name, err)
config.DebugLog("Warning: %s", msg)
c.JSON(http.StatusInternalServerError, gin.H{
"error": msg,
"server": server,
})
return
} else {
if _, err := conn.GetJailInfos(c.Request.Context()); err != nil {
config.DebugLog("Warning: fail2ban appears unhealthy on server %s after restart: %v", server.Name, err)
} else {
config.DebugLog("Fail2ban service appears healthy on server %s after restart", server.Name)
}
}
}
}
}
resp := gin.H{"server": server}
if jailLocalWarning {
resp["jailLocalWarning"] = true
}
c.JSON(http.StatusOK, resp)
}
// Removes a server configuration by ID.
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"})
}
// Marks a server as the 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})
}
// Returns available SSH private keys from the host or container.
func ListSSHKeysHandler(c *gin.Context) {
var dir string
if _, container := os.LookupEnv("CONTAINER"); container {
// In container, we look for SSH keys in the /config/.ssh directory
dir = "/config/.ssh"
} else {
// On host, we look for SSH keys in the user's home directory
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, ".pub")) ||
strings.HasSuffix(name, ".pem") ||
(strings.HasSuffix(name, ".key") && !strings.HasSuffix(name, ".pub")) {
keyPath := filepath.Join(dir, name)
keys = append(keys, keyPath)
}
}
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})
}
// Verifies connectivity to a configured Fail2ban server by ID.
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
}
// Checks the jail.local integrity: if it exists but is not managed by Fail2ban-UI, we warn the user.
// If the file was removed (e.g. after finished migration or just deleted), we initialize a fresh managed file.
resp := gin.H{"messageKey": "servers.actions.test_success"}
if exists, hasUI, err := conn.CheckJailLocalIntegrity(ctx); err == nil {
if exists && !hasUI {
resp["jailLocalWarning"] = true
} else if !exists {
if err := conn.EnsureJailLocalStructure(ctx); err != nil {
config.DebugLog("Warning: failed to initialize jail.local on test request: %v", err)
} else {
config.DebugLog("Initialized fresh jail.local for server %s (file was missing)", conn.Server().Name)
}
}
}
c.JSON(http.StatusOK, resp)
}
// =========================================================================
// Notification Processing (Internal)
// =========================================================================
// Records a ban event, broadcasts it via WebSocket,
// evaluates advanced actions, and sends an email alert if enabled.
func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error {
// Loads the settings to get alert countries and GeoIP provider from the database
settings := config.GetSettings()
var whoisData string
var err error
if whois == "" {
log.Printf("Performing whois lookup for IP %s", ip)
whoisData, err = lookupWhois(ip)
if err != nil {
log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err)
whoisData = ""
}
} else {
log.Printf("Using provided whois data for IP %s", ip)
whoisData = whois
}
// Filters the logs for the email alert to show relevant lines
filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines)
// Looks up the country for the given IP using the configured GeoIP provider
country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
if err != nil {
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
if whoisData != "" {
country = extractCountryFromWhois(whoisData)
if country != "" {
log.Printf("Extracted country %s from whois data for IP %s", country, ip)
}
}
if country == "" {
country = ""
}
}
event := storage.BanEventRecord{
ServerID: server.ID,
ServerName: server.Name,
Jail: jail,
IP: ip,
Country: country,
Hostname: hostname,
Failures: failures,
Whois: whoisData,
Logs: filteredLogs,
EventType: "ban",
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
log.Printf("⚠️ Failed to record ban event: %v", err)
}
// Broadcasts the ban event to WebSocket clients
if wsHub != nil {
wsHub.BroadcastBanEvent(event)
}
evaluateAdvancedActions(ctx, settings, server, ip)
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, displayCountry, settings.AlertCountries)
return nil
}
if !settings.EmailAlertsForBans {
log.Printf("❌ Email alerts for bans are disabled. No alert sent for IP %s", ip)
return nil
}
if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil {
log.Printf("❌ Failed to send ban alert email: %v", err)
}
return nil
}
// Records an unban event, broadcasts it via WebSocket, and sends an email alert if enabled.
func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error {
// Loads the settings to get alert countries and GeoIP provider from the database
settings := config.GetSettings()
var whoisData string
var err error
if whois == "" {
log.Printf("Performing whois lookup for IP %s", ip)
whoisData, err = lookupWhois(ip)
if err != nil {
log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err)
whoisData = ""
}
} else {
log.Printf("Using provided whois data for IP %s", ip)
whoisData = whois
}
if country == "" {
country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
if err != nil {
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
if whoisData != "" {
country = extractCountryFromWhois(whoisData)
if country != "" {
log.Printf("Extracted country %s from whois data for IP %s", country, ip)
}
}
if country == "" {
country = ""
}
}
}
event := storage.BanEventRecord{
ServerID: server.ID,
ServerName: server.Name,
Jail: jail,
IP: ip,
Country: country,
Hostname: hostname,
Failures: "",
Whois: whoisData,
Logs: "",
EventType: "unban",
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
log.Printf("⚠️ Failed to record unban event: %v", err)
}
// Broadcasts the unban event to WebSocket clients
if wsHub != nil {
wsHub.BroadcastUnbanEvent(event)
}
if !settings.EmailAlertsForUnbans {
log.Printf("🔕 Email alerts for unbans are disabled. No alert sent for IP %s", ip)
return nil
}
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, displayCountry, settings.AlertCountries)
return nil
}
// Sends an unban email notification (if enabled)
if err := sendUnbanAlert(ip, jail, hostname, whoisData, country, settings); err != nil {
log.Printf("❌ Failed to send unban alert email: %v", err)
}
return nil
}
// =========================================================================
// GeoIP and Helpers
// =========================================================================
// Resolves the ISO country code for an IP using the configured GeoIP provider.
func lookupCountry(ip, provider, dbPath string) (string, error) {
switch provider {
case "builtin":
return lookupCountryBuiltin(ip)
case "maxmind", "":
if dbPath == "" {
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
}
return lookupCountryMaxMind(ip, dbPath)
default:
// Unknown GeoIP provider, falls back to MaxMind
log.Printf("Unknown GeoIP provider '%s', falling back to MaxMind", provider)
if dbPath == "" {
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
}
return lookupCountryMaxMind(ip, dbPath)
}
}
// Looks up the country ISO code using MaxMind GeoLite2 database.
func lookupCountryMaxMind(ip, dbPath string) (string, error) {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return "", fmt.Errorf("invalid IP address: %s", ip)
}
db, err := maxminddb.Open(dbPath)
if err != nil {
return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err)
}
defer db.Close()
var record struct {
Country struct {
ISOCode string `maxminddb:"iso_code"`
} `maxminddb:"country"`
}
if err := db.Lookup(parsedIP, &record); err != nil {
return "", fmt.Errorf("GeoIP lookup error: %w", err)
}
return record.Country.ISOCode, nil
}
// Looks up the country ISO code using ip-api.com free API.
func lookupCountryBuiltin(ip string) (string, error) {
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return "", fmt.Errorf("invalid IP address: %s", ip)
}
// Uses ip-api.com free API (no account needed, rate limited to 45 requests/minute)
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=countryCode", ip)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to query ip-api.com: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
}
var result struct {
CountryCode string `json:"countryCode"`
Status string `json:"status"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.Status == "fail" {
return "", fmt.Errorf("ip-api.com error: %s", result.Message)
}
return result.CountryCode, nil
}
// Filters relevant logs for the email alert to show relevant lines.
func filterRelevantLogs(logs, ip string, maxLines int) string {
if logs == "" {
return ""
}
if maxLines <= 0 {
maxLines = 50
}
lines := strings.Split(logs, "\n")
if len(lines) <= maxLines {
return logs
}
// Priority patterns to identify relevant log lines
priorityPatterns := []string{
"denied", "deny", "forbidden", "unauthorized", "failed", "failure",
"error", "403", "404", "401", "500", "502", "503",
"invalid", "rejected", "blocked", "ban",
}
type scoredLine struct {
line string
score int
index int
}
scored := make([]scoredLine, len(lines))
for i, line := range lines {
lineLower := strings.ToLower(line)
score := 0
if strings.Contains(line, ip) {
score += 10
}
for _, pattern := range priorityPatterns {
if strings.Contains(lineLower, pattern) {
score += 5
}
}
score += (len(lines) - i) / 10
scored[i] = scoredLine{
line: line,
score: score,
index: i,
}
}
for i := 0; i < len(scored)-1; i++ {
for j := i + 1; j < len(scored); j++ {
if scored[i].score < scored[j].score {
scored[i], scored[j] = scored[j], scored[i]
}
}
}
selected := scored[:maxLines]
for i := 0; i < len(selected)-1; i++ {
for j := i + 1; j < len(selected); j++ {
if selected[i].index > selected[j].index {
selected[i], selected[j] = selected[j], selected[i]
}
}
}
result := make([]string, len(selected))
for i, s := range selected {
result[i] = s.line
}
filtered := []string{}
lastLine := ""
for _, line := range result {
if line != lastLine {
filtered = append(filtered, line)
lastLine = line
}
}
return strings.Join(filtered, "\n")
}
// Checks if an IP's country is in the allowed alert list.
func shouldAlertForCountry(country string, alertCountries []string) bool {
if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") {
return true
}
for _, c := range alertCountries {
if strings.EqualFold(country, c) {
return true
}
}
return false
}
// =========================================================================
// Page Rendering
// =========================================================================
// Renders the main SPA page with template variables.
func renderIndexPage(c *gin.Context) {
disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1"
// Checks if OIDC is enabled and skip login page setting
oidcEnabled := auth.IsEnabled()
skipLoginPage := false
if oidcEnabled {
oidcConfig := auth.GetConfig()
if oidcConfig != nil {
skipLoginPage = oidcConfig.SkipLoginPage
}
}
// Checks is a user wants to disable the github versioning check
updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false"
2025-01-25 16:21:14 +01:00
c.HTML(http.StatusOK, "index.html", gin.H{
"timestamp": time.Now().Format(time.RFC1123),
"version": time.Now().Unix(),
"appVersion": version.Version,
"updateCheckEnabled": updateCheckEnabled,
"disableExternalIP": disableExternalIP,
"oidcEnabled": oidcEnabled,
"skipLoginPage": skipLoginPage,
2025-01-25 16:21:14 +01:00
})
}
// =========================================================================
// Version
// =========================================================================
// Returns the app version and checks GitHub for updates.
func GetVersionHandler(c *gin.Context) {
updateCheckEnabled := os.Getenv("UPDATE_CHECK") != "false"
out := gin.H{
"version": version.Version,
"update_check_enabled": updateCheckEnabled,
}
if !updateCheckEnabled {
c.JSON(http.StatusOK, out)
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.github.com/repos/swissmakers/fail2ban-ui/releases/latest", nil)
if err != nil {
c.JSON(http.StatusOK, out)
return
}
req.Header.Set("Accept", "application/vnd.github.v3+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
c.JSON(http.StatusOK, out)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusOK, out)
return
}
var gh githubReleaseResponse
if err := json.NewDecoder(resp.Body).Decode(&gh); err != nil {
c.JSON(http.StatusOK, out)
return
}
latest := strings.TrimPrefix(strings.TrimSpace(gh.TagName), "v")
out["latest_version"] = latest
out["update_available"] = versionLess(version.Version, latest)
c.JSON(http.StatusOK, out)
}
// Checks if a version is less than another version.
func versionLess(a, b string) bool {
parse := func(s string) []int {
s = strings.TrimPrefix(strings.TrimSpace(s), "v")
parts := strings.Split(s, ".")
out := make([]int, 0, len(parts))
for _, p := range parts {
n, _ := strconv.Atoi(p)
out = append(out, n)
}
return out
}
pa, pb := parse(a), parse(b)
for i := 0; i < len(pa) || i < len(pb); i++ {
va, vb := 0, 0
if i < len(pa) {
va = pa[i]
}
if i < len(pb) {
vb = pb[i]
}
if va < vb {
return true
}
if va > vb {
return false
}
}
return false
}
// =========================================================================
// Jail Config
// =========================================================================
// Returns the filter and jail config for a given jail.
2025-01-25 21:43:50 +01:00
func GetJailFilterConfigHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("GetJailFilterConfigHandler called (handlers.go)")
2025-01-25 21:43:50 +01:00
jail := c.Param("jail")
config.DebugLog("Jail name: %s", jail)
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Failed to resolve connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config.DebugLog("Connector resolved: %s", conn.Server().Name)
var filterCfg string
var filterFilePath string
var jailCfg string
var jailFilePath string
var filterErr error
// Always load jail config first to determine which filter to load
config.DebugLog("Loading jail config for jail: %s", jail)
var jailErr error
jailCfg, jailFilePath, jailErr = conn.GetJailConfig(c.Request.Context(), jail)
if jailErr != nil {
config.DebugLog("Failed to load jail config: %v", jailErr)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + jailErr.Error()})
return
}
config.DebugLog("Jail config loaded, length: %d, file: %s", len(jailCfg), jailFilePath)
// Extracts the filter name from the jail config, or uses the jail name as fallback
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
if filterName == "" {
// No filter directive found, uses the jail name as filter name
filterName = jail
config.DebugLog("No filter directive found in jail config, using jail name as filter name: %s", filterName)
} else {
config.DebugLog("Found filter directive in jail config: %s", filterName)
}
// Loads the filter config using the filter name determined from the jail config
config.DebugLog("Loading filter config for filter: %s", filterName)
filterCfg, filterFilePath, filterErr = conn.GetFilterConfig(c.Request.Context(), filterName)
if filterErr != nil {
config.DebugLog("Failed to load filter config for %s: %v", filterName, filterErr)
config.DebugLog("Continuing without filter config (filter may not exist yet)")
filterCfg = ""
filterFilePath = ""
} else {
config.DebugLog("Filter config loaded, length: %d, file: %s", len(filterCfg), filterFilePath)
2025-01-25 21:43:50 +01:00
}
2025-01-25 21:43:50 +01:00
c.JSON(http.StatusOK, gin.H{
"jail": jail,
"filter": filterCfg,
"filterFilePath": filterFilePath,
"jailConfig": jailCfg,
"jailFilePath": jailFilePath,
2025-01-25 21:43:50 +01:00
})
2025-01-25 16:21:14 +01:00
}
// Saves updated filter/jail config and reloads Fail2ban.
2025-01-25 21:43:50 +01:00
func SetJailFilterConfigHandler(c *gin.Context) {
defer func() {
if r := recover(); r != nil {
config.DebugLog("PANIC in SetJailFilterConfigHandler: %v", r)
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Internal server error: %v", r)})
}
}()
config.DebugLog("----------------------------")
config.DebugLog("SetJailFilterConfigHandler called (handlers.go)")
2025-01-25 21:43:50 +01:00
jail := c.Param("jail")
config.DebugLog("Jail name: %s", jail)
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Failed to resolve connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config.DebugLog("Connector resolved: %s (type: %s)", conn.Server().Name, conn.Server().Type)
2025-01-25 16:21:14 +01:00
2025-01-25 21:43:50 +01:00
var req struct {
Filter string `json:"filter"`
Jail string `json:"jail"`
2025-01-25 21:43:50 +01:00
}
if err := c.ShouldBindJSON(&req); err != nil {
config.DebugLog("Failed to parse JSON body: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body: " + err.Error()})
2025-01-25 21:43:50 +01:00
return
}
config.DebugLog("Request parsed - Filter length: %d, Jail length: %d", len(req.Filter), len(req.Jail))
if len(req.Filter) > 0 {
config.DebugLog("Filter preview (first 100 chars): %s", req.Filter[:min(100, len(req.Filter))])
}
if len(req.Jail) > 0 {
config.DebugLog("Jail preview (first 100 chars): %s", req.Jail[:min(100, len(req.Jail))])
}
2025-01-25 16:21:14 +01:00
if req.Filter != "" {
originalJailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil {
config.DebugLog("Failed to load original jail config to determine filter name: %v", err)
originalJailCfg = req.Jail
}
// Extracts the original filter name (the one that was loaded when the modal opened)
originalFilterName := fail2ban.ExtractFilterFromJailConfig(originalJailCfg)
if originalFilterName == "" {
// No filter directive found in original config, uses the jail name as filter name
originalFilterName = jail
config.DebugLog("No filter directive found in original jail config, using jail name as filter name: %s", originalFilterName)
} else {
config.DebugLog("Found original filter directive in jail config: %s", originalFilterName)
}
// Extracts the new filter name from the updated jail config
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
if newFilterName == "" {
newFilterName = jail
}
// If the filter name changed, saves to the original filter name
// This prevents overwriting a different filter with the old filter's content
if originalFilterName != newFilterName {
config.DebugLog("Filter name changed from %s to %s, saving filter to original name: %s", originalFilterName, newFilterName, originalFilterName)
} else {
config.DebugLog("Filter name unchanged: %s", originalFilterName)
}
config.DebugLog("Saving filter config for filter: %s", originalFilterName)
if err := conn.SetFilterConfig(c.Request.Context(), originalFilterName, req.Filter); err != nil {
config.DebugLog("Failed to save filter config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save filter config: " + err.Error()})
return
}
config.DebugLog("Filter config saved successfully to filter: %s", originalFilterName)
} else {
config.DebugLog("No filter config provided, skipping")
}
if req.Jail != "" {
config.DebugLog("Saving jail config for jail: %s", jail)
if err := conn.SetJailConfig(c.Request.Context(), jail, req.Jail); err != nil {
config.DebugLog("Failed to save jail config: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save jail config: " + err.Error()})
return
}
config.DebugLog("Jail config saved successfully")
} else {
config.DebugLog("No jail config provided, skipping")
2025-01-25 21:43:50 +01:00
}
// Reloads Fail2ban
config.DebugLog("Reloading fail2ban")
if err := conn.Reload(c.Request.Context()); err != nil {
log.Printf("⚠️ Config saved but fail2ban reload failed: %v", err)
// If reload fails, we automatically disable the jail so Fail2ban won't crash on next restart (invalid filter/jail config)
disableUpdate := map[string]bool{jail: false}
if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil {
log.Printf("⚠️ Failed to auto-disable jail %s after reload failure: %v", jail, disableErr)
c.JSON(http.StatusOK, gin.H{
"message": "Config saved successfully, but fail2ban reload failed",
"warning": err.Error(),
})
return
}
if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil {
log.Printf("⚠️ Failed to reload fail2ban after auto-disabling jail %s: %v", jail, reloadErr2)
}
c.JSON(http.StatusOK, gin.H{
"message": "Config saved successfully, but fail2ban reload failed",
"warning": err.Error(),
"jailAutoDisabled": true,
"jailName": jail,
})
return
}
config.DebugLog("Fail2ban reloaded successfully")
c.JSON(http.StatusOK, gin.H{"message": "Filter and jail config updated and fail2ban reloaded"})
}
func equalStringSlices(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// Validates that a jail's log path resolves to real files.
func TestLogpathHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("TestLogpathHandler called (handlers.go)")
jail := c.Param("jail")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var originalLogpath string
// Checks if a logpath is provided in the request body
var reqBody struct {
Logpath string `json:"logpath"`
}
if err := c.ShouldBindJSON(&reqBody); err == nil && reqBody.Logpath != "" {
// Uses the logpath from the request body (from textarea)
originalLogpath = strings.TrimSpace(reqBody.Logpath)
config.DebugLog("Using logpath from request body: %s", originalLogpath)
} else {
// Falls back to reading from the saved jail config
jailCfg, _, err := conn.GetJailConfig(c.Request.Context(), jail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jail config: " + err.Error()})
return
}
// Extracts the logpath from the jail config
originalLogpath = fail2ban.ExtractLogpathFromJailConfig(jailCfg)
if originalLogpath == "" {
c.JSON(http.StatusOK, gin.H{
"original_logpath": "",
"resolved_logpath": "",
"files": []string{},
"message": "No logpath configured for this jail",
})
return
}
config.DebugLog("Using logpath from saved jail config: %s", originalLogpath)
}
if originalLogpath == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "No logpath provided"})
return
}
// Gets the server type to determine the test strategy
server := conn.Server()
isLocalServer := server.Type == "local"
// Splits the logpath by newlines and spaces (Fail2ban supports multiple logpaths separated by spaces or newlines)
// First splits by newlines, then splits each line by spaces
var logpaths []string
for _, line := range strings.Split(originalLogpath, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
paths := strings.Fields(line)
logpaths = append(logpaths, paths...)
}
var allResults []map[string]interface{}
for _, logpathLine := range logpaths {
logpathLine = strings.TrimSpace(logpathLine)
if logpathLine == "" {
continue
}
if isLocalServer {
resolvedPath, err := fail2ban.ResolveLogpathVariables(logpathLine)
if err != nil {
allResults = append(allResults, map[string]interface{}{
"logpath": logpathLine,
"resolved_path": "",
"found": false,
"files": []string{},
"error": err.Error(),
})
continue
}
if resolvedPath == "" {
resolvedPath = logpathLine
}
files, localErr := fail2ban.TestLogpath(resolvedPath)
allResults = append(allResults, map[string]interface{}{
"logpath": logpathLine,
"resolved_path": resolvedPath,
"found": len(files) > 0,
"files": files,
"error": func() string {
if localErr != nil {
return localErr.Error()
}
return ""
}(),
})
} else {
_, resolvedPath, filesOnRemote, err := conn.TestLogpathWithResolution(c.Request.Context(), logpathLine)
if err != nil {
allResults = append(allResults, map[string]interface{}{
"logpath": logpathLine,
"resolved_path": resolvedPath,
"found": false,
"files": []string{},
"error": err.Error(),
})
continue
}
allResults = append(allResults, map[string]interface{}{
"logpath": logpathLine,
"resolved_path": resolvedPath,
"found": len(filesOnRemote) > 0,
"files": filesOnRemote,
"error": "",
})
}
}
c.JSON(http.StatusOK, gin.H{
"original_logpath": originalLogpath,
"is_local_server": isLocalServer,
"results": allResults,
})
2025-01-25 21:43:50 +01:00
}
2025-01-25 16:21:14 +01:00
// =========================================================================
// Jail Management
// =========================================================================
// Returns all jails (enabled and disabled) for the manage-jails modal.
func ManageJailsHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("ManageJailsHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
jails, err := conn.GetAllJails(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to load jails: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"jails": jails})
}
// =========================================================================
// Advanced Actions
// =========================================================================
// Returns the permanent block log entries.
func ListPermanentBlocksHandler(c *gin.Context) {
limit := 100
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" {
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
limit = parsed
}
}
records, err := storage.ListPermanentBlocks(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"blocks": records})
}
// Allows manual block/unblock against the configured integration.
func AdvancedActionsTestHandler(c *gin.Context) {
var req struct {
Action string `json:"action"`
IP string `json:"ip"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if req.IP == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "ip is required"})
return
}
action := strings.ToLower(req.Action)
if action == "" {
action = "block"
}
if action != "block" && action != "unblock" {
c.JSON(http.StatusBadRequest, gin.H{"error": "action must be block or unblock"})
return
}
settings := config.GetSettings()
if settings.AdvancedActions.Integration == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "no integration configured. Please configure an integration (MikroTik, pfSense, or OPNsense) in Advanced Actions settings first"})
return
}
integration, ok := integrations.Get(settings.AdvancedActions.Integration)
if !ok {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration %s not found or not registered", settings.AdvancedActions.Integration)})
return
}
if err := integration.Validate(settings.AdvancedActions); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration configuration is invalid: %v", err)})
return
}
server := config.Fail2banServer{}
// Checks if the IP is already blocked before attempting the action (for block action only)
2025-11-30 13:26:09 +01:00
skipLoggingIfAlreadyBlocked := false
if action == "block" && settings.AdvancedActions.Integration != "" {
active, checkErr := storage.IsPermanentBlockActive(c.Request.Context(), req.IP, settings.AdvancedActions.Integration)
if checkErr == nil && active {
skipLoggingIfAlreadyBlocked = true
}
}
err := runAdvancedIntegrationAction(
c.Request.Context(),
action,
req.IP,
settings,
server,
map[string]any{"manual": true},
2025-11-30 13:26:09 +01:00
skipLoggingIfAlreadyBlocked,
)
if err != nil {
2025-11-30 13:26:09 +01:00
if skipLoggingIfAlreadyBlocked {
errMsg := strings.ToLower(err.Error())
if strings.Contains(errMsg, "already have such entry") ||
strings.Contains(errMsg, "already exists") ||
strings.Contains(errMsg, "duplicate") {
// IP is already blocked, returns info message with original error
2025-11-30 13:26:09 +01:00
c.JSON(http.StatusOK, gin.H{"message": err.Error(), "info": true})
return
}
}
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Action %s completed for %s", action, req.IP)})
}
// Returns a sorted slice of jail names from the map.
func getJailNames(jails map[string]bool) []string {
names := make([]string, 0, len(jails))
for name := range jails {
names = append(names, name)
}
sort.Strings(names)
return names
}
func contains(slice []string, item string) bool {
for _, s := range slice {
if s == item {
return true
}
}
return false
}
// Extracts problematic jail names from Fail2ban reload output.
func parseJailErrorsFromReloadOutput(output string) []string {
var problematicJails []string
lines := strings.Split(output, "\n")
for _, line := range lines {
if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") {
re := regexp.MustCompile(`Errors in jail '([^']+)'`)
matches := re.FindStringSubmatch(line)
if len(matches) > 1 {
problematicJails = append(problematicJails, matches[1])
}
}
// Also checks for filter errors that might indicate jail problems
_ = strings.Contains(line, "Unable to read the filter")
}
seen := make(map[string]bool)
uniqueJails := []string{}
for _, jail := range problematicJails {
if !seen[jail] {
seen[jail] = true
uniqueJails = append(uniqueJails, jail)
}
}
return uniqueJails
}
// Enables/disables jails and reloads Fail2ban.
func UpdateJailManagementHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("UpdateJailManagementHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
config.DebugLog("Error resolving connector: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var updates map[string]bool
if err := c.ShouldBindJSON(&updates); err != nil {
config.DebugLog("Error parsing JSON: %v", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
config.DebugLog("Received jail updates: %+v", updates)
if len(updates) == 0 {
config.DebugLog("Warning: No jail updates provided")
c.JSON(http.StatusBadRequest, gin.H{"error": "No jail updates provided"})
return
}
// Tracks which jails were enabled (for error recovery)
enabledJails := make(map[string]bool)
for jailName, enabled := range updates {
if enabled {
enabledJails[jailName] = true
}
}
if err := conn.UpdateJailEnabledStates(c.Request.Context(), updates); err != nil {
config.DebugLog("Error updating jail enabled states: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to update jail settings: " + err.Error()})
return
}
config.DebugLog("Successfully updated jail enabled states")
// Reloads fail2ban to apply the changes
reloadErr := conn.Reload(c.Request.Context())
// Checks for errors in reload output
var problematicJails []string
var detailedErrorOutput string
if reloadErr != nil {
errMsg := reloadErr.Error()
config.DebugLog("Error: failed to reload fail2ban after updating jail settings: %v", reloadErr)
// Extracts the output from the error message
if strings.Contains(errMsg, "(output:") {
outputStart := strings.Index(errMsg, "(output:") + 8
outputEnd := strings.LastIndex(errMsg, ")")
if outputEnd > outputStart {
detailedErrorOutput = errMsg[outputStart:outputEnd]
problematicJails = parseJailErrorsFromReloadOutput(detailedErrorOutput)
}
} else if strings.Contains(errMsg, "output:") {
outputStart := strings.Index(errMsg, "output:") + 7
if outputStart < len(errMsg) {
detailedErrorOutput = strings.TrimSpace(errMsg[outputStart:])
problematicJails = parseJailErrorsFromReloadOutput(detailedErrorOutput)
}
}
// If problematic jails are found, disables them // TODO: @matthias we need to further enhance this
if len(problematicJails) > 0 {
config.DebugLog("Found %d problematic jail(s) in reload output: %v", len(problematicJails), problematicJails)
disableUpdate := make(map[string]bool)
for _, jailName := range problematicJails {
disableUpdate[jailName] = false
}
for jailName := range enabledJails {
if contains(problematicJails, jailName) {
disableUpdate[jailName] = false
}
}
if len(disableUpdate) > 0 {
if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil {
config.DebugLog("Error disabling problematic jails: %v", disableErr)
} else {
// Reload again after disabling
if reloadErr2 := conn.Reload(c.Request.Context()); reloadErr2 != nil {
config.DebugLog("Error: failed to reload fail2ban after disabling problematic jails: %v", reloadErr2)
}
}
}
for _, jailName := range problematicJails {
enabledJails[jailName] = true
}
}
if detailedErrorOutput != "" {
errMsg = strings.TrimSpace(detailedErrorOutput)
}
if len(enabledJails) > 0 {
config.DebugLog("Reload failed after enabling %d jail(s), auto-disabling all enabled jails: %v", len(enabledJails), enabledJails)
disableUpdate := make(map[string]bool)
for jailName := range enabledJails {
disableUpdate[jailName] = false
}
if disableErr := conn.UpdateJailEnabledStates(c.Request.Context(), disableUpdate); disableErr != nil {
config.DebugLog("Error disabling jails after reload failure: %v", disableErr)
c.JSON(http.StatusOK, gin.H{
"error": fmt.Sprintf("Failed to reload fail2ban: %s. Additionally, failed to auto-disable enabled jails: %v", errMsg, disableErr),
"autoDisabled": false,
"enabledJails": getJailNames(enabledJails),
})
return
}
// Reloads again after disabling
if reloadErr = conn.Reload(c.Request.Context()); reloadErr != nil {
config.DebugLog("Error: failed to reload fail2ban after disabling jails: %v", reloadErr)
c.JSON(http.StatusOK, gin.H{
"error": fmt.Sprintf("Failed to reload fail2ban after disabling jails: %v", reloadErr),
"autoDisabled": true,
"enabledJails": getJailNames(enabledJails),
})
return
}
config.DebugLog("Successfully disabled %d jail(s) and reloaded fail2ban", len(enabledJails))
jailNamesList := getJailNames(enabledJails)
if len(jailNamesList) == 1 {
c.JSON(http.StatusOK, gin.H{
"error": fmt.Sprintf("Jail '%s' was enabled but caused a reload error: %s. It has been automatically disabled.", jailNamesList[0], errMsg),
"autoDisabled": true,
"enabledJails": jailNamesList,
"message": fmt.Sprintf("Jail '%s' was automatically disabled due to configuration error", jailNamesList[0]),
})
} else {
c.JSON(http.StatusOK, gin.H{
"error": fmt.Sprintf("Jails %v were enabled but caused a reload error: %s. They have been automatically disabled.", jailNamesList, errMsg),
"autoDisabled": true,
"enabledJails": jailNamesList,
"message": fmt.Sprintf("%d jail(s) were automatically disabled due to configuration error", len(jailNamesList)),
})
}
return
}
c.JSON(http.StatusOK, gin.H{
"error": fmt.Sprintf("Failed to reload fail2ban: %s", errMsg),
})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Jail settings updated and fail2ban reloaded successfully"})
}
// Creates a new jail with the given name and optional config.
func CreateJailHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("CreateJailHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req struct {
JailName string `json:"jailName" binding:"required"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
if err := fail2ban.ValidateJailName(req.JailName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Content == "" {
req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName)
}
if err := conn.CreateJail(c.Request.Context(), req.JailName, req.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create jail: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' created successfully", req.JailName)})
}
// Removes a jail and its config file.
func DeleteJailHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("DeleteJailHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
jailName := c.Param("jail")
if jailName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Jail name is required"})
return
}
if err := fail2ban.ValidateJailName(jailName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := conn.DeleteJail(c.Request.Context(), jailName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete jail: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Jail '%s' deleted successfully", jailName)})
}
// =========================================================================
// App Settings
// =========================================================================
// Returns the current AppSettings as JSON.
2025-01-25 21:43:50 +01:00
func GetSettingsHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("GetSettingsHandler called (handlers.go)")
2025-01-25 21:43:50 +01:00
s := config.GetSettings()
2025-12-01 23:25:54 +01:00
envPort, envPortSet := config.GetPortFromEnv()
response := make(map[string]interface{})
responseBytes, _ := json.Marshal(s)
json.Unmarshal(responseBytes, &response)
response["portFromEnv"] = envPort
response["portEnvSet"] = envPortSet
2025-12-01 23:25:54 +01:00
if envPortSet {
response["port"] = envPort
}
2025-12-01 23:25:54 +01:00
envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv()
response["callbackUrlEnvSet"] = envCallbackURLSet
response["callbackUrlFromEnv"] = envCallbackURL
if envCallbackURLSet {
response["callbackUrl"] = envCallbackURL
}
c.JSON(http.StatusOK, response)
2025-01-25 21:43:50 +01:00
}
// Saves new settings, pushes defaults to servers, and reloads.
2025-01-25 21:43:50 +01:00
func UpdateSettingsHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("UpdateSettingsHandler called (handlers.go)")
var req config.AppSettings
if err := c.ShouldBindJSON(&req); err != nil {
fmt.Println("JSON binding error:", err)
c.JSON(http.StatusBadRequest, gin.H{
"error": "invalid JSON",
"details": err.Error(),
})
return
}
config.DebugLog("JSON binding successful, updating settings (handlers.go)")
2025-01-25 21:43:50 +01:00
// Ignores port changes from request if the PORT environment variable is set
envPort, envPortSet := config.GetPortFromEnv()
if envPortSet {
req.Port = envPort
}
// Ignores callback URL changes from request if the CALLBACK_URL environment variable is set
envCallbackURL, envCallbackURLSet := config.GetCallbackURLFromEnv()
if envCallbackURLSet {
req.CallbackURL = envCallbackURL
}
oldSettings := config.GetSettings()
newSettings, err := config.UpdateSettings(req)
if err != nil {
fmt.Println("Error updating settings:", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
config.DebugLog("Settings updated successfully (handlers.go)")
2025-01-25 21:43:50 +01:00
// Checks if the callback URL changed; if so, updates the action files for all active remote servers
callbackURLChanged := oldSettings.CallbackURL != newSettings.CallbackURL
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reload fail2ban connectors: " + err.Error()})
return
}
if callbackURLChanged {
config.DebugLog("Callback URL changed, updating action files and reloading fail2ban on all servers")
// Updates the action files for remote servers (SSH and Agent)
if err := fail2ban.GetManager().UpdateActionFiles(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to update some remote action files: %v", err)
}
// Reloads all remote servers after updating the action files
connectors := fail2ban.GetManager().Connectors()
for _, conn := range connectors {
server := conn.Server()
// Only reloads remote servers (SSH and Agent), local will be handled separately
if (server.Type == "ssh" || server.Type == "agent") && server.Enabled {
config.DebugLog("Reloading fail2ban on %s after callback URL change", server.Name)
if err := conn.Reload(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to reload fail2ban on %s after updating action file: %v", server.Name, err)
} else {
config.DebugLog("Successfully reloaded fail2ban on %s", server.Name)
}
}
}
// Also updates the local action file if the callback URL changed
settings := config.GetSettings()
for _, server := range settings.Servers {
if server.Type == "local" && server.Enabled {
if err := config.EnsureLocalFail2banAction(server); err != nil {
config.DebugLog("Warning: failed to update local action file: %v", err)
} else {
// Reloads local fail2ban after updating the action file
if conn, err := fail2ban.GetManager().Connector(server.ID); err == nil {
config.DebugLog("Reloading local fail2ban after callback URL change")
if reloadErr := conn.Reload(c.Request.Context()); reloadErr != nil {
config.DebugLog("Warning: failed to reload local fail2ban after updating action file: %v", reloadErr)
} else {
config.DebugLog("Successfully reloaded local fail2ban")
}
}
}
}
}
}
ignoreIPsChanged := !equalStringSlices(oldSettings.IgnoreIPs, newSettings.IgnoreIPs)
defaultSettingsChanged := oldSettings.BantimeIncrement != newSettings.BantimeIncrement ||
2025-12-15 18:57:50 +01:00
oldSettings.DefaultJailEnable != newSettings.DefaultJailEnable ||
ignoreIPsChanged ||
oldSettings.Bantime != newSettings.Bantime ||
oldSettings.BantimeRndtime != newSettings.BantimeRndtime ||
oldSettings.Findtime != newSettings.Findtime ||
oldSettings.Maxretry != newSettings.Maxretry ||
oldSettings.Banaction != newSettings.Banaction ||
oldSettings.BanactionAllports != newSettings.BanactionAllports ||
oldSettings.Chain != newSettings.Chain
if defaultSettingsChanged {
config.DebugLog("Fail2Ban DEFAULT settings changed, pushing to all enabled servers")
connectors := fail2ban.GetManager().Connectors()
var errors []string
for _, conn := range connectors {
server := conn.Server()
config.DebugLog("Updating DEFAULT settings on server: %s (type: %s)", server.Name, server.Type)
if err := conn.UpdateDefaultSettings(c.Request.Context(), newSettings); err != nil {
errorMsg := fmt.Sprintf("Failed to update DEFAULT settings on %s: %v", server.Name, err)
log.Printf("⚠️ %s", errorMsg)
errors = append(errors, errorMsg)
} else {
config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name)
if err := conn.Reload(c.Request.Context()); err != nil {
config.DebugLog("Warning: failed to reload fail2ban on %s after updating DEFAULT settings: %v", server.Name, err)
errors = append(errors, fmt.Sprintf("Settings updated on %s, but reload failed: %v", server.Name, err))
} else {
config.DebugLog("Successfully reloaded fail2ban on %s", server.Name)
}
}
}
if len(errors) > 0 {
config.DebugLog("Some servers failed to update DEFAULT settings: %v", errors)
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated",
"restartNeeded": false,
"warnings": errors,
})
return
}
// Settings were updated and reloaded successfully
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated and fail2ban reloaded",
"restartNeeded": false,
})
return
}
2025-01-25 21:43:50 +01:00
c.JSON(http.StatusOK, gin.H{
"message": "Settings updated",
"restartNeeded": newSettings.RestartNeeded,
2025-01-25 21:43:50 +01:00
})
}
// =========================================================================
// Filters
// =========================================================================
// Returns all available filter names for the selected server.
2025-01-25 21:43:50 +01:00
func ListFiltersHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("ListFiltersHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
server := conn.Server()
if server.Type == "local" {
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
}
}
2025-01-25 21:43:50 +01:00
filters, err := conn.GetFilters(c.Request.Context())
2025-01-25 21:43:50 +01:00
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list filters: " + err.Error()})
2025-01-25 21:43:50 +01:00
return
}
c.JSON(http.StatusOK, gin.H{"filters": filters})
}
// Returns the content of a specific filter file.
func GetFilterContentHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("GetFilterContentHandler called (handlers.go)")
filterName := c.Param("filter")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
content, filePath, err := conn.GetFilterConfig(c.Request.Context(), filterName)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get filter content: " + err.Error()})
return
}
content = fail2ban.RemoveComments(content)
c.JSON(http.StatusOK, gin.H{
"content": content,
"filterPath": filePath,
})
}
// Runs fail2ban-regex against provided log lines and filter content.
2025-01-25 21:43:50 +01:00
func TestFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("TestFilterHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
2025-01-25 21:43:50 +01:00
var req struct {
FilterName string `json:"filterName"`
LogLines []string `json:"logLines"`
FilterContent string `json:"filterContent"`
2025-01-25 21:43:50 +01:00
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON"})
return
}
output, filterPath, err := conn.TestFilter(c.Request.Context(), req.FilterName, req.LogLines, req.FilterContent)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"output": output,
"filterPath": filterPath,
})
2025-01-25 16:21:14 +01:00
}
// Creates a new filter definition file.
func CreateFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("CreateFilterHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
var req struct {
FilterName string `json:"filterName" binding:"required"`
Content string `json:"content"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON: " + err.Error()})
return
}
// Validate filter name
if err := fail2ban.ValidateFilterName(req.FilterName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if req.Content == "" {
req.Content = fmt.Sprintf("# Filter: %s\n", req.FilterName)
}
// Create the filter
if err := conn.CreateFilter(c.Request.Context(), req.FilterName, req.Content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' created successfully", req.FilterName)})
}
// Removes a filter definition file.
func DeleteFilterHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("DeleteFilterHandler called (handlers.go)")
conn, err := resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
filterName := c.Param("filter")
if filterName == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Filter name is required"})
return
}
if err := fail2ban.ValidateFilterName(filterName); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := conn.DeleteFilter(c.Request.Context(), filterName); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to delete filter: " + err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("Filter '%s' deleted successfully", filterName)})
}
// =========================================================================
// Restart
// =========================================================================
// Restarts (or reloads) the Fail2ban service on the selected server.
func RestartFail2banHandler(c *gin.Context) {
config.DebugLog("----------------------------")
config.DebugLog("RestartFail2banHandler called (handlers.go)")
serverID := c.Query("serverId")
var conn fail2ban.Connector
var err error
if serverID != "" {
manager := fail2ban.GetManager()
conn, err = manager.Connector(serverID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Server not found: " + err.Error()})
return
}
} else {
// Uses the default connector from the context
conn, err = resolveConnector(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
server := conn.Server()
// Attempts to restart the fail2ban service via the connector.
mode, err := fail2ban.RestartFail2ban(server.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
2025-01-25 21:43:50 +01:00
}
// Only calls MarkRestartDone if the service was successfully restarted
//if err := config.MarkRestartDone(server.ID); err != nil {
// c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
// return
//}
msg := "Fail2ban service restarted successfully"
if mode == "reload" {
msg = "Fail2ban configuration reloaded successfully (no systemd service restart)"
}
c.JSON(http.StatusOK, gin.H{
"message": msg,
"mode": mode,
"server": server,
})
2025-01-25 21:43:50 +01:00
}
// =========================================================================
// Email Alerts and SMTP
// =========================================================================
// loadLocale returns cached translations for the given language, loading from disk if needed.
func loadLocale(lang string) (map[string]string, error) {
localeCacheLock.RLock()
if cached, ok := localeCache[lang]; ok {
localeCacheLock.RUnlock()
return cached, nil
}
localeCacheLock.RUnlock()
// Determines the locale file path
var localePath string
_, container := os.LookupEnv("CONTAINER")
if container {
localePath = fmt.Sprintf("/app/locales/%s.json", lang)
} else {
localePath = fmt.Sprintf("./internal/locales/%s.json", lang)
}
data, err := os.ReadFile(localePath)
if err != nil {
// Falls back to English if the locale file is not found
if lang != "en" {
return loadLocale("en")
}
return nil, fmt.Errorf("failed to read locale file: %w", err)
}
var translations map[string]string
if err := json.Unmarshal(data, &translations); err != nil {
return nil, fmt.Errorf("failed to parse locale file: %w", err)
}
localeCacheLock.Lock()
localeCache[lang] = translations
localeCacheLock.Unlock()
return translations, nil
}
// Resolves a translation key, falling back to English.
func getEmailTranslation(lang, key string) string {
translations, err := loadLocale(lang)
if err != nil {
if lang != "en" {
translations, err = loadLocale("en")
if err != nil {
return key
}
} else {
return key
}
}
if translation, ok := translations[key]; ok {
return translation
}
if lang != "en" {
enTranslations, err := loadLocale("en")
if err == nil {
if enTranslation, ok := enTranslations[key]; ok {
return enTranslation
}
}
}
return key
}
// Reads the email template style from environment variable (default: "modern").
func getEmailStyle() string {
style := os.Getenv("emailStyle")
if style == "classic" {
return "classic"
}
return "modern"
}
// Connects to the SMTP server and delivers a single HTML message.
func sendEmail(to, subject, body string, settings config.AppSettings) error {
// Skips sending if the destination email is still the default placeholder
if strings.EqualFold(strings.TrimSpace(to), "alerts@example.com") {
log.Printf("⚠️ sendEmail skipped: destination email is still the default placeholder (alerts@example.com). Please update the 'Destination Email' in Settings → Alert Settings.")
return nil
}
if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" || settings.SMTP.From == "" {
err := errors.New("SMTP settings are incomplete. Please configure all required fields")
log.Printf("❌ sendEmail validation failed: %v (Host: %q, Username: %q, From: %q)", err, settings.SMTP.Host, settings.SMTP.Username, settings.SMTP.From)
return err
}
if settings.SMTP.Port <= 0 || settings.SMTP.Port > 65535 {
err := errors.New("SMTP port must be between 1 and 65535")
log.Printf("❌ sendEmail validation failed: %v (Port: %d)", err, settings.SMTP.Port)
return err
}
message := fmt.Sprintf("From: %s\nTo: %s\nSubject: %s\n"+
"MIME-Version: 1.0\nContent-Type: text/html; charset=\"UTF-8\"\n\n%s",
settings.SMTP.From, to, subject, body)
msg := []byte(message)
smtpHost := settings.SMTP.Host
smtpPort := settings.SMTP.Port
smtpAddr := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort))
tlsConfig := &tls.Config{
ServerName: smtpHost,
InsecureSkipVerify: settings.SMTP.InsecureSkipVerify,
}
authMethod := settings.SMTP.AuthMethod
if authMethod == "" {
authMethod = "auto"
}
auth, err := getSMTPAuth(settings.SMTP.Username, settings.SMTP.Password, authMethod, smtpHost)
if err != nil {
log.Printf("❌ sendEmail: failed to create SMTP auth (method: %q): %v", authMethod, err)
return fmt.Errorf("failed to create SMTP auth: %w", err)
}
log.Printf("📧 sendEmail: Using SMTP auth method: %q, host: %s, port: %d, useTLS: %v, insecureSkipVerify: %v", authMethod, smtpHost, smtpPort, settings.SMTP.UseTLS, settings.SMTP.InsecureSkipVerify)
// Determines the connection type based on the port and UseTLS setting
// Port 465 typically uses implicit TLS (SMTPS)
// Port 587 typically uses STARTTLS
useImplicitTLS := (smtpPort == 465) || (settings.SMTP.UseTLS && smtpPort != 587 && smtpPort != 25)
useSTARTTLS := settings.SMTP.UseTLS && (smtpPort == 587 || (smtpPort != 465 && smtpPort != 25))
var client *smtp.Client
if useImplicitTLS {
conn, err := tls.Dial("tcp", smtpAddr, tlsConfig)
if err != nil {
return fmt.Errorf("failed to connect via TLS: %w", err)
}
defer conn.Close()
client, err = smtp.NewClient(conn, smtpHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
} else {
conn, err := net.DialTimeout("tcp", smtpAddr, 30*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to SMTP server: %w", err)
}
defer conn.Close()
client, err = smtp.NewClient(conn, smtpHost)
if err != nil {
return fmt.Errorf("failed to create SMTP client: %w", err)
}
if useSTARTTLS {
if err := client.StartTLS(tlsConfig); err != nil {
return fmt.Errorf("failed to start TLS: %w", err)
}
}
}
defer func() {
if client != nil {
client.Quit()
}
}()
if auth != nil {
if err := client.Auth(auth); err != nil {
log.Printf("❌ sendEmail: SMTP authentication failed: %v", err)
return fmt.Errorf("SMTP authentication failed: %w", err)
}
log.Printf("📧 sendEmail: SMTP authentication successful")
}
err = sendSMTPMessage(client, settings.SMTP.From, to, msg)
if err != nil {
log.Printf("❌ sendEmail: Failed to send message: %v", err)
return err
}
log.Printf("📧 sendEmail: Successfully sent email to %s", to)
return nil
}
// Sends the actual message
// Performs the MAIL/RCPT/DATA sequence on an open SMTP connection.
func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error {
if err := client.Mail(from); err != nil {
return fmt.Errorf("failed to set sender: %w", err)
}
if err := client.Rcpt(to); err != nil {
return fmt.Errorf("failed to set recipient: %w", err)
}
wc, err := client.Data()
if err != nil {
return fmt.Errorf("failed to start data command: %w", err)
}
defer wc.Close()
if _, err = wc.Write(msg); err != nil {
return fmt.Errorf("failed to write email content: %w", err)
}
client.Quit()
return nil
}
// Builds paragraph-based details for the classic email template.
func renderClassicEmailDetails(details []emailDetail) string {
if len(details) == 0 {
return `<p>No metadata available.</p>`
}
var b strings.Builder
for _, d := range details {
b.WriteString(`<p><span class="label">` + html.EscapeString(d.Label) + `:</span> ` + html.EscapeString(d.Value) + `</p>`)
b.WriteString("\n")
}
return b.String()
}
// Renders the original email template layout.
func buildClassicEmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText, supportEmail string) string {
detailRows := renderClassicEmailDetails(details)
year := time.Now().Year()
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>%s</title>
<style>
body { font-family: Arial, sans-serif; background-color: #f4f4f4; margin: 0; padding: 0; }
.container { max-width: 600px; margin: 20px auto; background: #ffffff; padding: 20px; border-radius: 8px; box-shadow: 0px 2px 4px rgba(0,0,0,0.1); }
2025-01-30 13:44:39 +01:00
.header { text-align: center; padding-bottom: 10px; border-bottom: 2px solid #005DE0; }
.header img { max-width: 150px; }
2025-01-30 13:44:39 +01:00
.header h2 { color: #005DE0; margin: 10px 0; font-size: 24px; }
.content { padding: 15px; }
2025-01-30 13:44:39 +01:00
.details { background: #f9f9f9; padding: 15px; border-left: 4px solid #5579f8; margin-bottom: 10px; }
.footer { text-align: center; color: #888; font-size: 12px; padding-top: 10px; border-top: 1px solid #ddd; margin-top: 15px; }
.footer a { color: #005DE0; text-decoration: none; }
.footer a:hover { color: #0044b3; text-decoration: underline; }
.label { font-weight: bold; color: #333; }
a { color: #005DE0; text-decoration: none; }
a:hover { color: #0044b3; text-decoration: underline; }
2025-01-30 13:44:39 +01:00
pre {
background: #222;
color: #ddd;
font-family: "Courier New", Courier, monospace;
font-size: 12px;
2025-01-30 13:44:39 +01:00
padding: 10px;
border-radius: 5px;
overflow-x: auto;
white-space: pre-wrap;
2025-01-30 13:44:39 +01:00
}
@media screen and (max-width: 600px) {
.container { width: 90%%; padding: 10px; }
.header h2 { font-size: 20px; }
.details p { font-size: 14px; }
.footer { font-size: 10px; }
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img src="https://swissmakers.ch/wp-content/uploads/2023/09/cyber.png" alt="Swissmakers GmbH" width="150" />
<h2>🚨 %s</h2>
</div>
<div class="content">
<p>%s</p>
<div class="details">
%s
</div>
<h3>🔍 %s</h3>
%s
<h3>📄 %s</h3>
%s
</div>
<div class="footer">
<p>%s</p>
<p>For security inquiries, contact <a href="mailto:%s">%s</a></p>
<p>&copy; %d Swissmakers GmbH. All rights reserved.</p>
</div>
</div>
</body>
</html>`, html.EscapeString(title), html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), html.EscapeString(supportEmail), html.EscapeString(supportEmail), year)
}
// Renders the LOTR-themed email template.
2025-12-01 23:25:54 +01:00
func buildLOTREmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText string) string {
detailRows := renderEmailDetails(details)
year := strconv.Itoa(time.Now().Year())
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>%s</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { margin:0; padding:0; background: linear-gradient(135deg, #0d2818 0%%, #1a4d2e 50%%, #2d0a4f 100%%); font-family: Georgia, "Times New Roman", serif; color:#f4e8d0; line-height:1.6; -webkit-font-smoothing:antialiased; }
.email-wrapper { width:100%%; padding:20px 10px; background: linear-gradient(135deg, #0d2818 0%%, #1a4d2e 50%%, #2d0a4f 100%%); }
.email-container { max-width:640px; margin:0 auto; background:#f4e8d0; border:4px solid #d4af37; border-radius:12px; box-shadow:0 8px 32px rgba(0,0,0,0.6), inset 0 0 40px rgba(212,175,55,0.1); overflow:hidden; position:relative; }
.email-container::before { content:''; position:absolute; top:0; left:0; right:0; bottom:0; background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(139,115,85,0.03) 2px, rgba(139,115,85,0.03) 4px); pointer-events:none; }
.email-header { background: linear-gradient(180deg, #c1121f 0%%, #ff6b35 30%%, #d4af37 70%%, #1a4d2e 100%%); color:#ffffff; padding:40px 28px; text-align:center; position:relative; overflow:hidden; }
.email-header::before { content:''; position:absolute; top:0; left:0; right:0; bottom:0; background: radial-gradient(circle at center, rgba(255,255,255,0.1) 0%%, transparent 70%%); animation: fireFlicker 3s ease-in-out infinite; }
@keyframes fireFlicker { 0%%,100%% { opacity:0.6; } 50%% { opacity:1; } }
.email-header-brand { margin:0 0 12px; font-size:12px; letter-spacing:0.4em; text-transform:uppercase; opacity:0.9; font-weight:600; font-family:'Cinzel', serif; position:relative; z-index:1; }
.email-header-title { margin:20px 0; font-size:42px; font-weight:700; line-height:1.1; text-shadow: 0 0 20px rgba(255,255,255,0.8), 0 0 40px rgba(255,107,53,0.6), 0 0 60px rgba(193,18,31,0.4); font-family:'Cinzel', serif; letter-spacing:0.1em; position:relative; z-index:1; animation: textGlow 2s ease-in-out infinite; }
@keyframes textGlow { 0%%,100%% { text-shadow: 0 0 20px rgba(255,255,255,0.8), 0 0 40px rgba(255,107,53,0.6), 0 0 60px rgba(193,18,31,0.4); } 50%% { text-shadow: 0 0 30px rgba(255,255,255,1), 0 0 60px rgba(255,107,53,0.8), 0 0 90px rgba(193,18,31,0.6); } }
.ring-divider { text-align:center; margin:30px 0; position:relative; }
.ring-divider::before { content:'⚔'; position:absolute; left:20%%; top:50%%; transform:translateY(-50%%); font-size:24px; color:#d4af37; background:#f4e8d0; padding:0 15px; }
.ring-divider::after { content:'⚔'; position:absolute; right:20%%; top:50%%; transform:translateY(-50%%); font-size:24px; color:#d4af37; background:#f4e8d0; padding:0 15px; }
.ring-divider-line { height:3px; background:linear-gradient(90deg, transparent 0%%, #d4af37 20%%, #d4af37 80%%, transparent 100%%); margin:0 25%%; }
.email-body { padding:36px 28px; background:#f4e8d0; color:#3d2817; }
.email-intro { font-size:18px; line-height:1.8; margin:0 0 28px; color:#3d2817; font-style:italic; text-align:center; }
.email-details-wrapper { background:#e8d5b7; border:3px solid #8b7355; border-radius:8px; padding:24px; margin:0 0 32px; box-shadow:inset 0 2px 4px rgba(0,0,0,0.1); }
.email-details-wrapper p { margin:12px 0; font-size:15px; line-height:1.7; color:#3d2817; }
.email-details-wrapper p:first-child { margin-top:0; }
.email-details-wrapper p:last-child { margin-bottom:0; }
.email-detail-label { font-weight:700; color:#1a4d2e; margin-right:8px; font-family:'Cinzel', serif; }
.email-section { margin:36px 0 0; }
.email-section-title { font-size:16px; text-transform:uppercase; letter-spacing:0.2em; color:#1a4d2e; margin:0 0 16px; font-weight:700; font-family:'Cinzel', serif; border-bottom:2px solid #d4af37; padding-bottom:8px; }
.email-terminal { background:#1a1a1a; color:#d4af37; padding:20px; font-family:"Courier New", Courier, monospace; border-radius:8px; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-word; overflow-x:auto; margin:0; border:2px solid #8b7355; box-shadow:inset 0 0 20px rgba(212,175,55,0.1); }
.email-log-stack { background:#0f0f0f; border-radius:8px; padding:16px; border:2px solid #8b7355; }
.email-log-line { font-family:"Courier New", Courier, monospace; font-size:12px; line-height:1.6; color:#d4af37; padding:8px 12px; border-radius:6px; margin:0 0 6px; background:rgba(212,175,55,0.1); border-left:3px solid #d4af37; }
.email-log-line:last-child { margin-bottom:0; }
.email-log-line-alert { background:rgba(193,18,31,0.3); color:#ff6b35; border-left-color:#c1121f; }
.email-muted { color:#8b7355; font-size:14px; line-height:1.6; font-style:italic; }
.email-footer { border-top:3px solid #d4af37; padding:24px 28px; font-size:13px; color:#3d2817; text-align:center; background:#e8d5b7; font-family:'Cinzel', serif; }
.email-footer-text { margin:0 0 8px; font-weight:600; }
.email-footer-copyright { margin:0; font-size:11px; color:#8b7355; }
.email-header a { color:#ffffff !important; text-decoration:underline; }
.email-header a:hover { color:#f4e8d0 !important; }
a { color:#1a4d2e; text-decoration:none; }
a:hover { color:#2d0a4f; text-decoration:underline; }
2025-12-01 23:25:54 +01:00
@media only screen and (max-width:600px) {
.email-wrapper { padding:12px 8px; }
.email-header { padding:30px 20px; }
.email-header-title { font-size:32px; }
.email-body { padding:28px 20px; }
.email-intro { font-size:16px; }
.email-details-wrapper { padding:20px; }
.email-footer { padding:20px 16px; }
}
@media only screen and (max-width:480px) {
.email-header-title { font-size:28px; }
.email-body { padding:24px 16px; }
.email-details-wrapper { padding:16px; }
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<p class="email-header-brand">Middle-earth Security</p>
<h1 class="email-header-title">YOU SHALL NOT PASS</h1>
<div class="ring-divider">
<div class="ring-divider-line"></div>
</div>
</div>
<div class="email-body">
<p class="email-intro">%s</p>
<div class="email-details-wrapper">
%s
</div>
<div class="email-section">
<p class="email-section-title">%s</p>
%s
</div>
<div class="email-section">
<p class="email-section-title">%s</p>
%s
</div>
</div>
<div class="email-footer">
<p class="email-footer-text">%s</p>
<p class="email-footer-copyright">© %s Swissmakers GmbH. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>`, html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), year)
}
// Renders the default responsive email template.
func buildModernEmailBody(title, intro string, details []emailDetail, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText string) string {
detailRows := renderEmailDetails(details)
year := strconv.Itoa(time.Now().Year())
return fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title>%s</title>
<style>
* { box-sizing: border-box; }
body { margin:0; padding:0; background-color:#f6f8fb; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; color:#1f2933; line-height:1.6; -webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale; }
.email-wrapper { width:100%%; padding:20px 10px; }
.email-container { max-width:640px; margin:0 auto; background:#ffffff; border-radius:20px; box-shadow:0 4px 20px rgba(0,0,0,0.08), 0 0 0 1px rgba(0,0,0,0.04); overflow:hidden; }
.email-header { background:linear-gradient(135deg,#004cff 0%%,#6c2bd9 100%%); background-color:#004cff; color:#ffffff !important; padding:32px 28px; text-align:center; }
.email-header-brand { margin:0 0 8px; font-size:11px; letter-spacing:0.3em; text-transform:uppercase; opacity:0.9; font-weight:600; color:#ffffff !important; }
.email-header-title { margin:0 0 10px; font-size:26px; font-weight:700; line-height:1.2; color:#ffffff !important; }
.email-header a { color:#ffffff !important; text-decoration:underline; }
.email-header a:hover { color:#e0e7ff !important; }
.email-body { padding:36px 28px; }
a { color:#2563eb; text-decoration:none; }
a:hover { color:#1d4ed8; text-decoration:underline; }
.email-intro { font-size:16px; line-height:1.7; margin:0 0 28px; color:#4b5563; }
.email-details-wrapper { background:#f9fafb; border-radius:12px; padding:20px; margin:0 0 32px; border:1px solid #e5e7eb; }
.email-details-wrapper p { margin:8px 0; font-size:14px; line-height:1.6; color:#111827; }
.email-details-wrapper p:first-child { margin-top:0; }
.email-details-wrapper p:last-child { margin-bottom:0; }
.email-detail-label { font-weight:700; color:#374151; margin-right:8px; }
.email-section { margin:36px 0 0; }
.email-section-title { font-size:13px; text-transform:uppercase; letter-spacing:0.1em; color:#6b7280; margin:0 0 16px; font-weight:700; }
.email-terminal { background:#111827; color:#f3f4f6; padding:20px; font-family:"SFMono-Regular","Consolas","Liberation Mono","Courier New",monospace; border-radius:12px; font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-word; overflow-x:auto; margin:0; }
.email-log-stack { background:#0f172a; border-radius:12px; padding:16px; }
.email-log-line { font-family:"SFMono-Regular","Consolas","Liberation Mono","Courier New",monospace; font-size:12px; line-height:1.6; color:#cbd5f5; padding:8px 12px; border-radius:8px; margin:0 0 6px; background:rgba(255,255,255,0.05); }
.email-log-line:last-child { margin-bottom:0; }
.email-log-line-alert { background:rgba(248,113,113,0.25); color:#ffffff; border:1px solid rgba(248,113,113,0.5); }
.email-muted { color:#9ca3af; font-size:13px; line-height:1.6; }
.email-footer { border-top:1px solid #e5e7eb; padding:24px 28px; font-size:12px; color:#6b7280; text-align:center; background:#fafbfc; }
.email-footer-text { margin:0 0 8px; }
.email-footer-copyright { margin:0; font-size:11px; color:#9ca3af; }
@media only screen and (max-width:600px) {
.email-wrapper { padding:12px 8px; }
.email-header { padding:24px 20px; background-color:#004cff !important; }
.email-header-brand { color:#ffffff !important; }
.email-header-title { font-size:22px; color:#ffffff !important; }
.email-body { padding:28px 20px; }
.email-intro { font-size:15px; }
.email-details-wrapper { padding:16px; }
.email-details-wrapper p { font-size:14px; margin:10px 0; }
.email-footer { padding:20px 16px; }
}
@media only screen and (max-width:480px) {
.email-header { background-color:#004cff !important; }
.email-header-brand { color:#ffffff !important; }
.email-header-title { font-size:20px; color:#ffffff !important; }
.email-body { padding:24px 16px; }
.email-details-wrapper { padding:12px; }
}
@media print {
.email-header { background:#004cff !important; background-color:#004cff !important; color:#ffffff !important; }
.email-header-brand { color:#ffffff !important; }
.email-header-title { color:#ffffff !important; }
.email-header a { color:#ffffff !important; }
a { color:#2563eb !important; }
}
</style>
</head>
<body>
<div class="email-wrapper">
<div class="email-container">
<div class="email-header">
<p class="email-header-brand">Fail2Ban UI</p>
<h1 class="email-header-title">%s</h1>
</div>
<div class="email-body">
<p class="email-intro">%s</p>
<div class="email-details-wrapper">
%s
</div>
<div class="email-section">
<p class="email-section-title">%s</p>
%s
</div>
<div class="email-section">
<p class="email-section-title">%s</p>
%s
</div>
</div>
<div class="email-footer">
<p class="email-footer-text">%s</p>
<p class="email-footer-copyright">© %s Swissmakers GmbH. All rights reserved.</p>
</div>
</div>
</div>
</body>
</html>`, html.EscapeString(title), html.EscapeString(title), html.EscapeString(intro), detailRows, html.EscapeString(whoisTitle), whoisHTML, html.EscapeString(logsTitle), logsHTML, html.EscapeString(footerText), year)
}
// Builds table rows for the modern/LOTR email templates.
func renderEmailDetails(details []emailDetail) string {
if len(details) == 0 {
return `<p class="email-muted">No metadata available.</p>`
}
var b strings.Builder
for _, d := range details {
b.WriteString(`<p><span class="email-detail-label">` + html.EscapeString(d.Label) + `:</span> ` + html.EscapeString(d.Value) + `</p>`)
b.WriteString("\n")
}
return b.String()
}
// Wraps raw WHOIS text in a styled <pre> block for email.
func formatWhoisForEmail(whois string, lang string, isModern bool) string {
noDataMsg := getEmailTranslation(lang, "email.whois.no_data")
if strings.TrimSpace(whois) == "" {
if isModern {
return `<p class="email-muted">` + html.EscapeString(noDataMsg) + `</p>`
}
return `<pre style="background: #222; color: #ddd; font-family: 'Courier New', Courier, monospace; font-size: 12px; padding: 10px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap;">` + html.EscapeString(noDataMsg) + `</pre>`
}
if isModern {
return `<pre class="email-terminal">` + html.EscapeString(whois) + `</pre>`
}
return `<pre style="background: #222; color: #ddd; font-family: 'Courier New', Courier, monospace; font-size: 12px; padding: 10px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap;">` + html.EscapeString(whois) + `</pre>`
}
// Highlights suspicious lines and HTTP status codes in email logs.
func formatLogsForEmail(ip, logs string, lang string, isModern bool) string {
noLogsMsg := getEmailTranslation(lang, "email.logs.no_data")
if strings.TrimSpace(logs) == "" {
if isModern {
return `<p class="email-muted">` + html.EscapeString(noLogsMsg) + `</p>`
}
return `<pre style="background: #222; color: #ddd; font-family: 'Courier New', Courier, monospace; font-size: 12px; padding: 10px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap;">` + html.EscapeString(noLogsMsg) + `</pre>`
}
if isModern {
var b strings.Builder
b.WriteString(`<div class="email-log-stack">`)
lines := strings.Split(logs, "\n")
for _, line := range lines {
trimmed := strings.TrimRight(line, "\r")
if trimmed == "" {
continue
}
class := "email-log-line"
if isSuspiciousLogLineEmail(trimmed, ip) {
class = "email-log-line email-log-line-alert"
}
b.WriteString(`<div class="` + class + `">` + html.EscapeString(trimmed) + `</div>`)
}
b.WriteString(`</div>`)
return b.String()
}
return `<pre style="background: #222; color: #ddd; font-family: 'Courier New', Courier, monospace; font-size: 12px; padding: 10px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap;">` + html.EscapeString(logs) + `</pre>`
}
// Checks if the line contains known attack indicators.
func isSuspiciousLogLineEmail(line, ip string) bool {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
return false
}
lowered := strings.ToLower(trimmed)
containsIP := ip != "" && strings.Contains(trimmed, ip)
statusCode := extractStatusCodeFromLine(trimmed)
hasBadStatus := statusCode >= 300
hasIndicator := false
for _, indicator := range suspiciousLogIndicators {
if strings.Contains(lowered, indicator) {
hasIndicator = true
break
}
}
if containsIP {
return hasBadStatus || hasIndicator
}
return (hasBadStatus || hasIndicator) && ip == ""
}
// Parses the HTTP status code from a log line.
func extractStatusCodeFromLine(line string) int {
if match := httpQuotedStatusPattern.FindStringSubmatch(line); len(match) == 2 {
if code, err := strconv.Atoi(match[1]); err == nil {
return code
}
}
if match := httpPlainStatusPattern.FindStringSubmatch(line); len(match) == 2 {
if code, err := strconv.Atoi(match[1]); err == nil {
return code
}
}
return 0
}
// Composes and sends the ban notification email.
func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) error {
lang := settings.Language
if lang == "" {
lang = "en"
}
isLOTRMode := config.IsLOTRModeActive(settings.AlertCountries)
2025-12-01 23:25:54 +01:00
var subject string
if isLOTRMode {
subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s",
getEmailTranslation(lang, "lotr.email.title"),
ip,
getEmailTranslation(lang, "email.ban.subject.from"),
hostname)
2025-12-01 23:25:54 +01:00
} else {
subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail,
getEmailTranslation(lang, "email.ban.subject.banned"),
ip,
getEmailTranslation(lang, "email.ban.subject.from"),
hostname)
}
emailStyle := getEmailStyle()
isModern := emailStyle == "modern"
2025-12-01 23:25:54 +01:00
var title, intro, whoisTitle, logsTitle, footerText string
if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.title")
intro = getEmailTranslation(lang, "lotr.email.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
logsTitle = getEmailTranslation(lang, "email.ban.logs_title")
footerText = getEmailTranslation(lang, "lotr.email.footer")
} else {
title = getEmailTranslation(lang, "email.ban.title")
intro = getEmailTranslation(lang, "email.ban.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
logsTitle = getEmailTranslation(lang, "email.ban.logs_title")
footerText = getEmailTranslation(lang, "email.footer.text")
}
supportEmail := "support@swissmakers.ch"
var details []emailDetail
if isLOTRMode {
bannedIPLabel := getEmailTranslation(lang, "lotr.email.details.dark_servant_location")
jailLabel := getEmailTranslation(lang, "lotr.email.details.realm_protection")
countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins")
var countryLabel string
if country != "" {
countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country)
2025-12-01 23:25:54 +01:00
} else {
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
2025-12-01 23:25:54 +01:00
}
timestampLabel := getEmailTranslation(lang, "lotr.email.details.banished_at")
details = []emailDetail{
{Label: bannedIPLabel, Value: ip},
{Label: jailLabel, Value: jail},
{Label: getEmailTranslation(lang, "email.ban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.ban.details.failed_attempts"), Value: failures},
{Label: countryLabel, Value: ""},
{Label: timestampLabel, Value: time.Now().UTC().Format(time.RFC3339)},
}
} else {
details = []emailDetail{
{Label: getEmailTranslation(lang, "email.ban.details.banned_ip"), Value: ip},
{Label: getEmailTranslation(lang, "email.ban.details.jail"), Value: jail},
{Label: getEmailTranslation(lang, "email.ban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.ban.details.failed_attempts"), Value: failures},
{Label: getEmailTranslation(lang, "email.ban.details.country"), Value: country},
{Label: getEmailTranslation(lang, "email.ban.details.timestamp"), Value: time.Now().UTC().Format(time.RFC3339)},
}
}
whoisHTML := formatWhoisForEmail(whois, lang, isModern)
logsHTML := formatLogsForEmail(ip, logs, lang, isModern)
var body string
2025-12-01 23:25:54 +01:00
if isLOTRMode {
body = buildLOTREmailBody(title, intro, details, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText)
} else if isModern {
body = buildModernEmailBody(title, intro, details, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText)
} else {
body = buildClassicEmailBody(title, intro, details, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText, supportEmail)
}
return sendEmail(settings.Destemail, subject, body, settings)
}
// Composes and sends the unban notification email.
func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.AppSettings) error {
lang := settings.Language
if lang == "" {
lang = "en"
}
isLOTRMode := config.IsLOTRModeActive(settings.AlertCountries)
var subject string
if isLOTRMode {
subject = fmt.Sprintf("[Middle-earth] %s: %s %s %s",
getEmailTranslation(lang, "lotr.email.unban.title"),
ip,
getEmailTranslation(lang, "email.unban.subject.from"),
hostname)
} else {
subject = fmt.Sprintf("[Fail2Ban] %s: %s %s %s %s", jail,
getEmailTranslation(lang, "email.unban.subject.unbanned"),
ip,
getEmailTranslation(lang, "email.unban.subject.from"),
hostname)
}
emailStyle := getEmailStyle()
isModern := emailStyle == "modern"
var title, intro, whoisTitle, footerText string
if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.unban.title")
intro = getEmailTranslation(lang, "lotr.email.unban.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
footerText = getEmailTranslation(lang, "lotr.email.footer")
} else {
title = getEmailTranslation(lang, "email.unban.title")
intro = getEmailTranslation(lang, "email.unban.intro")
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
footerText = getEmailTranslation(lang, "email.footer.text")
}
supportEmail := "support@swissmakers.ch"
var details []emailDetail
if isLOTRMode {
details = []emailDetail{
{Label: getEmailTranslation(lang, "lotr.email.unban.details.restored_ip"), Value: ip},
{Label: getEmailTranslation(lang, "email.unban.details.jail"), Value: jail},
{Label: getEmailTranslation(lang, "email.unban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.unban.details.country"), Value: country},
{Label: getEmailTranslation(lang, "email.unban.details.timestamp"), Value: time.Now().UTC().Format(time.RFC3339)},
}
} else {
details = []emailDetail{
{Label: getEmailTranslation(lang, "email.unban.details.unbanned_ip"), Value: ip},
{Label: getEmailTranslation(lang, "email.unban.details.jail"), Value: jail},
{Label: getEmailTranslation(lang, "email.unban.details.hostname"), Value: hostname},
{Label: getEmailTranslation(lang, "email.unban.details.country"), Value: country},
{Label: getEmailTranslation(lang, "email.unban.details.timestamp"), Value: time.Now().UTC().Format(time.RFC3339)},
}
}
whoisHTML := formatWhoisForEmail(whois, lang, isModern)
var body string
if isLOTRMode {
body = buildLOTREmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText)
} else if isModern {
body = buildModernEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText)
} else {
body = buildClassicEmailBody(title, intro, details, whoisHTML, "", whoisTitle, "", footerText, supportEmail)
}
return sendEmail(settings.Destemail, subject, body, settings)
}
// Sends a test email to verify the SMTP configuration.
func TestEmailHandler(c *gin.Context) {
settings := config.GetSettings()
lang := settings.Language
if lang == "" {
lang = "en"
}
testDetails := []emailDetail{
{Label: getEmailTranslation(lang, "email.test.details.recipient"), Value: settings.Destemail},
{Label: getEmailTranslation(lang, "email.test.details.smtp_host"), Value: settings.SMTP.Host},
{Label: getEmailTranslation(lang, "email.test.details.triggered_at"), Value: time.Now().Format(time.RFC1123)},
}
title := getEmailTranslation(lang, "email.test.title")
intro := getEmailTranslation(lang, "email.test.intro")
whoisTitle := getEmailTranslation(lang, "email.ban.whois_title")
logsTitle := getEmailTranslation(lang, "email.ban.logs_title")
footerText := getEmailTranslation(lang, "email.footer.text")
whoisNoData := getEmailTranslation(lang, "email.test.whois_no_data")
supportEmail := "support@swissmakers.ch"
emailStyle := getEmailStyle()
isModern := emailStyle == "modern"
whoisHTML := `<pre style="background: #222; color: #ddd; font-family: 'Courier New', Courier, monospace; font-size: 12px; padding: 10px; border-radius: 5px; overflow-x: auto; white-space: pre-wrap;">` + html.EscapeString(whoisNoData) + `</pre>`
if isModern {
whoisHTML = `<p class="email-muted">` + html.EscapeString(whoisNoData) + `</p>`
}
sampleLogs := getEmailTranslation(lang, "email.test.sample_logs")
logsHTML := formatLogsForEmail("", sampleLogs, lang, isModern)
var testBody string
if isModern {
testBody = buildModernEmailBody(title, intro, testDetails, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText)
} else {
testBody = buildClassicEmailBody(title, intro, testDetails, whoisHTML, logsHTML, whoisTitle, logsTitle, footerText, supportEmail)
}
subject := getEmailTranslation(lang, "email.test.subject")
err := sendEmail(
settings.Destemail,
subject,
testBody,
settings,
)
if err != nil {
log.Printf("❌ Test email failed: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to send test email: " + err.Error()})
return
}
log.Println("✅ Test email sent successfully!")
c.JSON(http.StatusOK, gin.H{"message": "Test email sent successfully!"})
}
// Returns the SMTP auth mechanism based on authMethod ("auto", "login", "plain", "cram-md5").
func getSMTPAuth(username, password, authMethod, host string) (smtp.Auth, error) {
if username == "" || password == "" {
return nil, nil
}
authMethod = strings.ToLower(strings.TrimSpace(authMethod))
if authMethod == "" || authMethod == "auto" {
// Auto-detect: prefers LOGIN for Office365/Gmail, falls back to PLAIN (default)
authMethod = "login"
}
switch authMethod {
case "login":
return LoginAuth(username, password), nil
case "plain":
return smtp.PlainAuth("", username, password, host), nil
case "cram-md5":
return smtp.CRAMMD5Auth(username, password), nil
default:
return nil, fmt.Errorf("unsupported auth method: %s (supported: login, plain, cram-md5)", authMethod)
}
}
// Implements the LOGIN authentication mechanism used by Office365, Gmail, and other providers that require LOGIN instead of PLAIN
type loginAuth struct {
username, password string
}
func LoginAuth(username, password string) smtp.Auth {
return &loginAuth{username, password}
}
func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) {
return "LOGIN", []byte(a.username), nil
}
func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) {
if more {
switch string(fromServer) {
case "Username:":
return []byte(a.username), nil
case "Password:":
return []byte(a.password), nil
default:
return nil, errors.New("unexpected server challenge")
}
}
return nil, nil
}
// =========================================================================
// Auth Handlers
// =========================================================================
// Initiates the OIDC login flow or renders the login page.
func LoginHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
if oidcClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"})
return
}
oidcConfig := auth.GetConfig()
if oidcConfig != nil && oidcConfig.SkipLoginPage {
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"})
return
}
state := base64.URLEncoding.EncodeToString(stateBytes)
// Determine if we're using HTTPS (if not, the state cookie is not secure)
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// Stores the state in a session cookie for validation
stateCookie := &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/",
MaxAge: 600,
HttpOnly: true,
Secure: isSecure,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(c.Writer, stateCookie)
config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure)
// Gets the authorization URL and redirects to it
authURL := oidcClient.GetAuthURL(state)
c.Redirect(http.StatusFound, authURL)
return
}
// Checks if this is a redirect action (triggered by clicking the login button)
if c.Query("action") == "redirect" {
stateBytes := make([]byte, 32)
if _, err := rand.Read(stateBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate state parameter"})
return
}
state := base64.URLEncoding.EncodeToString(stateBytes)
// Determines if we're using HTTPS (if not, the state cookie is not secure)
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
// Stores the state in a session cookie for validation
stateCookie := &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/",
MaxAge: 600,
HttpOnly: true,
Secure: isSecure,
SameSite: http.SameSiteLaxMode,
}
http.SetCookie(c.Writer, stateCookie)
config.DebugLog("Set state cookie: %s (Secure: %v)", state, isSecure)
// Get authorization URL and redirect
authURL := oidcClient.GetAuthURL(state)
c.Redirect(http.StatusFound, authURL)
return
}
renderIndexPage(c)
}
// Handles the OIDC callback, exchanging the code for a session.
func CallbackHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
if oidcClient == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"})
return
}
stateCookie, err := c.Cookie("oidc_state")
if err != nil {
config.DebugLog("Failed to get state cookie: %v", err)
config.DebugLog("Request cookies: %v", c.Request.Cookies())
config.DebugLog("Request URL: %s", c.Request.URL.String())
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing state parameter", "details": err.Error()})
return
}
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
http.SetCookie(c.Writer, &http.Cookie{
Name: "oidc_state",
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isSecure,
SameSite: http.SameSiteLaxMode,
})
returnedState := c.Query("state")
if returnedState != stateCookie {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state parameter"})
return
}
code := c.Query("code")
if code == "" {
errorDesc := c.Query("error_description")
if errorDesc != "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "OIDC authentication failed: " + errorDesc})
} else {
c.JSON(http.StatusBadRequest, gin.H{"error": "Missing authorization code"})
}
return
}
token, err := oidcClient.ExchangeCode(c.Request.Context(), code)
if err != nil {
config.DebugLog("Failed to exchange code for token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to exchange authorization code"})
return
}
userInfo, err := oidcClient.VerifyToken(c.Request.Context(), token)
if err != nil {
config.DebugLog("Failed to verify token: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to verify authentication token"})
return
}
// Create the session
if err := auth.CreateSession(c.Writer, c.Request, userInfo, oidcClient.Config.SessionMaxAge); err != nil {
config.DebugLog("Failed to create session: %v", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create session"})
return
}
config.DebugLog("User authenticated: %s (%s)", userInfo.Username, userInfo.Email)
// Redirect to main page
c.Redirect(http.StatusFound, "/")
}
// Clears the session and redirects to the OIDC provider logout.
func LogoutHandler(c *gin.Context) {
oidcClient := auth.GetOIDCClient()
// Clears the session
auth.DeleteSession(c.Writer, c.Request)
// If a provider logout URL is configured, redirects there
// Otherwise, auto-constructs the logout URL for standard OIDC providers
if oidcClient != nil {
logoutURL := oidcClient.Config.LogoutURL
if logoutURL == "" && oidcClient.Config.IssuerURL != "" {
issuerURL := oidcClient.Config.IssuerURL
redirectURI := oidcClient.Config.RedirectURL
if strings.Contains(redirectURI, "/auth/callback") {
redirectURI = strings.TrimSuffix(redirectURI, "/auth/callback")
}
redirectURI = redirectURI + "/auth/login"
redirectURIEncoded := url.QueryEscape(redirectURI)
clientIDEncoded := url.QueryEscape(oidcClient.Config.ClientID)
switch oidcClient.Config.Provider {
case "keycloak":
// Keycloak requires client_id when using post_logout_redirect_uri
// Format: {issuer}/protocol/openid-connect/logout?post_logout_redirect_uri={redirect}&client_id={client_id}
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?post_logout_redirect_uri=%s&client_id=%s", issuerURL, redirectURIEncoded, clientIDEncoded)
2026-01-20 19:17:51 +01:00
case "pocketid":
// Pocket-ID uses a different logout endpoint (https://pocket-id.io/docs/oidc/#end-session)
2026-01-20 19:17:51 +01:00
// Format: {issuer}/api/oidc/end-session?redirect_uri={redirect}
logoutURL = fmt.Sprintf("%s/api/oidc/end-session?redirect_uri=%s", issuerURL, redirectURIEncoded)
case "authentik":
// OIDC format for Authentik (https://docs.goauthentik.io/docs/providers/oidc/#logout)
// Format: {issuer}/protocol/openid-connect/logout?redirect_uri={redirect}
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded)
default:
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded)
}
}
if logoutURL != "" {
config.DebugLog("Redirecting to provider logout: %s", logoutURL)
c.Redirect(http.StatusFound, logoutURL)
return
}
}
c.Redirect(http.StatusFound, "/auth/login")
}
// Returns the current authentication status as JSON.
func AuthStatusHandler(c *gin.Context) {
if !auth.IsEnabled() {
c.JSON(http.StatusOK, gin.H{
"enabled": false,
"authenticated": false,
})
return
}
oidcConfig := auth.GetConfig()
skipLoginPage := false
if oidcConfig != nil {
skipLoginPage = oidcConfig.SkipLoginPage
}
session, err := auth.GetSession(c.Request)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"authenticated": false,
"skipLoginPage": skipLoginPage,
})
return
}
c.JSON(http.StatusOK, gin.H{
"enabled": true,
"authenticated": true,
"skipLoginPage": skipLoginPage,
"user": gin.H{
"id": session.UserID,
"email": session.Email,
"name": session.Name,
"username": session.Username,
},
})
}
// Returns the authenticated user's profile information.
func UserInfoHandler(c *gin.Context) {
if !auth.IsEnabled() {
c.JSON(http.StatusOK, gin.H{"authenticated": false})
return
}
session, err := auth.GetSession(c.Request)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "Not authenticated"})
return
}
c.JSON(http.StatusOK, gin.H{
"authenticated": true,
"user": gin.H{
"id": session.UserID,
"email": session.Email,
"name": session.Name,
"username": session.Username,
},
})
}