mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
3311 lines
114 KiB
Go
3311 lines
114 KiB
Go
// Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
||
//
|
||
// Copyright (C) 2025 Swissmakers GmbH (https://swissmakers.ch)
|
||
//
|
||
// Licensed under the GNU General Public License, Version 3 (GPL-3.0)
|
||
// You may not use this file except in compliance with the License.
|
||
// You may obtain a copy of the License at
|
||
//
|
||
// https://www.gnu.org/licenses/gpl-3.0.en.html
|
||
//
|
||
// Unless required by applicable law or agreed to in writing, software
|
||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||
// See the License for the specific language governing permissions and
|
||
// limitations under the License.
|
||
|
||
package web
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"crypto/rand"
|
||
"crypto/subtle"
|
||
"crypto/tls"
|
||
"encoding/base64"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"html"
|
||
"io"
|
||
"log"
|
||
"net"
|
||
"net/http"
|
||
"net/smtp"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/go-playground/validator/v10"
|
||
"github.com/oschwald/maxminddb-golang"
|
||
"github.com/swissmakers/fail2ban-ui/internal/auth"
|
||
"github.com/swissmakers/fail2ban-ui/internal/config"
|
||
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
|
||
"github.com/swissmakers/fail2ban-ui/internal/integrations"
|
||
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
||
)
|
||
|
||
// wsHub is the global WebSocket hub instance
|
||
var wsHub *Hub
|
||
|
||
// SetWebSocketHub sets the global WebSocket hub instance
|
||
func SetWebSocketHub(hub *Hub) {
|
||
wsHub = hub
|
||
}
|
||
|
||
// SummaryResponse is what we return from /api/summary
|
||
type SummaryResponse struct {
|
||
Jails []fail2ban.JailInfo `json:"jails"`
|
||
}
|
||
|
||
type emailDetail struct {
|
||
Label string
|
||
Value string
|
||
}
|
||
|
||
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
|
||
)
|
||
|
||
func resolveConnector(c *gin.Context) (fail2ban.Connector, error) {
|
||
serverID := c.Query("serverId")
|
||
if serverID == "" {
|
||
serverID = c.GetHeader("X-F2B-Server")
|
||
}
|
||
manager := fail2ban.GetManager()
|
||
if serverID != "" {
|
||
return manager.Connector(serverID)
|
||
}
|
||
return manager.DefaultConnector()
|
||
}
|
||
|
||
func resolveServerForNotification(serverID, hostname string) (config.Fail2banServer, error) {
|
||
if serverID != "" {
|
||
if srv, ok := config.GetServerByID(serverID); ok {
|
||
if !srv.Enabled {
|
||
return config.Fail2banServer{}, fmt.Errorf("server %s is disabled", serverID)
|
||
}
|
||
return srv, nil
|
||
}
|
||
return config.Fail2banServer{}, fmt.Errorf("serverId %s not found", serverID)
|
||
}
|
||
if hostname != "" {
|
||
if srv, ok := config.GetServerByHostname(hostname); ok {
|
||
if !srv.Enabled {
|
||
return config.Fail2banServer{}, fmt.Errorf("server for hostname %s is disabled", hostname)
|
||
}
|
||
return srv, nil
|
||
}
|
||
}
|
||
srv := config.GetDefaultServer()
|
||
if srv.ID == "" {
|
||
return config.Fail2banServer{}, fmt.Errorf("no default fail2ban server configured")
|
||
}
|
||
if !srv.Enabled {
|
||
return config.Fail2banServer{}, fmt.Errorf("default fail2ban server is disabled")
|
||
}
|
||
return srv, nil
|
||
}
|
||
|
||
// SummaryHandler returns a JSON summary of all jails, including
|
||
// number of banned IPs, how many are new in the last hour, etc.
|
||
func SummaryHandler(c *gin.Context) {
|
||
conn, err := resolveConnector(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
jailInfos, err := conn.GetJailInfos(c.Request.Context())
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
resp := SummaryResponse{
|
||
Jails: jailInfos,
|
||
}
|
||
c.JSON(http.StatusOK, resp)
|
||
}
|
||
|
||
// UnbanIPHandler unbans a given IP in a specific jail.
|
||
func UnbanIPHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("UnbanIPHandler called (handlers.go)") // entry point
|
||
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",
|
||
})
|
||
}
|
||
|
||
// BanIPHandler bans a given IP in a specific jail.
|
||
func BanIPHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("BanIPHandler called (handlers.go)") // entry point
|
||
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.BanIP(c.Request.Context(), jail, ip); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
fmt.Println(ip + " in jail " + jail + " banned successfully.")
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "IP banned successfully",
|
||
})
|
||
}
|
||
|
||
// BanNotificationHandler processes incoming ban notifications from Fail2Ban.
|
||
func BanNotificationHandler(c *gin.Context) {
|
||
// Validate callback secret
|
||
settings := config.GetSettings()
|
||
providedSecret := c.GetHeader("X-Callback-Secret")
|
||
expectedSecret := settings.CallbackSecret
|
||
|
||
// Use constant-time comparison to prevent timing attacks
|
||
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
|
||
}
|
||
|
||
// Constant-time comparison
|
||
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"` // Optional for backward compatibility
|
||
Logs string `json:"logs"`
|
||
}
|
||
|
||
// **DEBUGGING: Log Raw JSON Body**
|
||
body, _ := io.ReadAll(c.Request.Body)
|
||
log.Printf("----------------------------------------------------")
|
||
log.Printf("Request Content-Length: %d", c.Request.ContentLength)
|
||
log.Printf("Request Headers: %v", c.Request.Header)
|
||
log.Printf("Request Headers: %v", c.Request.Body)
|
||
|
||
log.Printf("----------------------------------------------------")
|
||
|
||
config.DebugLog("📩 Incoming Ban Notification: %s\n", string(body))
|
||
|
||
// Rebind body so Gin can parse it again (important!)
|
||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||
|
||
log.Printf("Request Content-Length: %d", c.Request.ContentLength)
|
||
log.Printf("Request Headers: %v", c.Request.Header)
|
||
log.Printf("Request Headers: %v", c.Request.Body)
|
||
|
||
// Parse JSON request body
|
||
if err := c.ShouldBindJSON(&request); err != nil {
|
||
var verr validator.ValidationErrors
|
||
if errors.As(err, &verr) {
|
||
for _, fe := range verr {
|
||
log.Printf("❌ Validierungsfehler: Feld '%s' verletzt Regel '%s'", fe.Field(), fe.ActualTag())
|
||
}
|
||
} else {
|
||
log.Printf("❌ JSON-Parsing Fehler: %v", err)
|
||
}
|
||
log.Printf("Raw JSON: %s", string(body))
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
// **DEBUGGING: Log 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
|
||
}
|
||
|
||
// Handle the Fail2Ban notification
|
||
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
|
||
}
|
||
|
||
// Respond with success
|
||
c.JSON(http.StatusOK, gin.H{"message": "Ban notification processed successfully"})
|
||
}
|
||
|
||
// UnbanNotificationHandler processes incoming unban notifications from Fail2Ban.
|
||
func UnbanNotificationHandler(c *gin.Context) {
|
||
// Validate callback secret
|
||
settings := config.GetSettings()
|
||
providedSecret := c.GetHeader("X-Callback-Secret")
|
||
expectedSecret := settings.CallbackSecret
|
||
|
||
// Use constant-time comparison to prevent timing attacks
|
||
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
|
||
}
|
||
|
||
// Constant-time comparison
|
||
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))
|
||
|
||
// Rebind body so Gin can parse it again
|
||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||
|
||
// Parse JSON 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: %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
|
||
}
|
||
|
||
// Handle the Fail2Ban notification
|
||
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
|
||
}
|
||
|
||
// Respond with success
|
||
c.JSON(http.StatusOK, gin.H{"message": "Unban notification processed successfully"})
|
||
}
|
||
|
||
// ListBanEventsHandler returns stored ban events from the internal database.
|
||
func ListBanEventsHandler(c *gin.Context) {
|
||
serverID := c.Query("serverId")
|
||
limit := 100
|
||
if limitStr := c.DefaultQuery("limit", "100"); limitStr != "" {
|
||
if parsed, err := strconv.Atoi(limitStr); err == nil && parsed > 0 {
|
||
limit = parsed
|
||
}
|
||
}
|
||
|
||
var since time.Time
|
||
if sinceStr := c.Query("since"); sinceStr != "" {
|
||
if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil {
|
||
since = parsed
|
||
}
|
||
}
|
||
|
||
events, err := storage.ListBanEvents(c.Request.Context(), serverID, limit, since)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"events": events})
|
||
}
|
||
|
||
// BanStatisticsHandler returns aggregated ban counts per server.
|
||
func BanStatisticsHandler(c *gin.Context) {
|
||
var since time.Time
|
||
if sinceStr := c.Query("since"); sinceStr != "" {
|
||
if parsed, err := time.Parse(time.RFC3339, sinceStr); err == nil {
|
||
since = parsed
|
||
}
|
||
}
|
||
|
||
stats, err := storage.CountBanEventsByServer(c.Request.Context(), since)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"counts": stats})
|
||
}
|
||
|
||
// BanInsightsHandler returns aggregate stats for countries and recurring IPs.
|
||
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,
|
||
},
|
||
})
|
||
}
|
||
|
||
// ListServersHandler returns configured Fail2ban servers.
|
||
func ListServersHandler(c *gin.Context) {
|
||
servers := config.ListServers()
|
||
c.JSON(http.StatusOK, gin.H{"servers": servers})
|
||
}
|
||
|
||
// UpsertServerHandler creates or updates a Fail2ban server configuration.
|
||
func UpsertServerHandler(c *gin.Context) {
|
||
var req config.Fail2banServer
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
switch strings.ToLower(req.Type) {
|
||
case "", "local":
|
||
req.Type = "local"
|
||
case "ssh":
|
||
if req.Host == "" || req.SSHUser == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "ssh servers require host and sshUser"})
|
||
return
|
||
}
|
||
case "agent":
|
||
if req.AgentURL == "" || req.AgentSecret == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "agent servers require agentUrl and agentSecret"})
|
||
return
|
||
}
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported server type"})
|
||
return
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// Only update action files if:
|
||
// 1. Server was just enabled (transition from disabled to enabled)
|
||
// 2. Server is a remote server (SSH or Agent)
|
||
// Note: ReloadFromSettings already calls ensureAction when creating connectors,
|
||
// but we need to update if the server was just enabled to ensure it has the latest callback URL
|
||
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)
|
||
// Don't fail the request, just log the warning
|
||
}
|
||
}
|
||
|
||
// Ensure jail.local structure is properly initialized for newly enabled/added servers
|
||
if justEnabled || !wasEnabled {
|
||
conn, err := fail2ban.GetManager().Connector(server.ID)
|
||
if err == nil {
|
||
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)
|
||
// Don't fail the request, just log the warning
|
||
} else {
|
||
config.DebugLog("Successfully ensured jail.local structure for server %s", server.Name)
|
||
|
||
// If the server was just enabled, try to restart fail2ban and perform a basic health check.
|
||
if justEnabled {
|
||
if err := conn.Restart(c.Request.Context()); err != nil {
|
||
// Surface restart failures to the UI so the user sees that the service did not restart.
|
||
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 {
|
||
// Basic health check: attempt to fetch jail infos, which runs fail2ban-client status.
|
||
if _, err := conn.GetJailInfos(c.Request.Context()); err != nil {
|
||
config.DebugLog("Warning: fail2ban appears unhealthy on server %s after restart: %v", server.Name, err)
|
||
// Again, we log instead of failing the request to avoid breaking existing flows.
|
||
} else {
|
||
config.DebugLog("Fail2ban service appears healthy on server %s after restart", server.Name)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"server": server})
|
||
}
|
||
|
||
// DeleteServerHandler removes a server configuration.
|
||
func DeleteServerHandler(c *gin.Context) {
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
|
||
return
|
||
}
|
||
if err := config.DeleteServer(id); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "server deleted"})
|
||
}
|
||
|
||
// SetDefaultServerHandler marks a server as default.
|
||
func SetDefaultServerHandler(c *gin.Context) {
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
|
||
return
|
||
}
|
||
server, err := config.SetDefaultServer(id)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
if err := fail2ban.GetManager().ReloadFromSettings(config.GetSettings()); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"server": server})
|
||
}
|
||
|
||
// ListSSHKeysHandler returns SSH keys available on the UI host.
|
||
func ListSSHKeysHandler(c *gin.Context) {
|
||
var dir string
|
||
// Check if running inside a container
|
||
if _, container := os.LookupEnv("CONTAINER"); container {
|
||
// In container, check /config/.ssh
|
||
dir = "/config/.ssh"
|
||
} else {
|
||
// On host, check ~/.ssh
|
||
home, err := os.UserHomeDir()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
dir = filepath.Join(home, ".ssh")
|
||
}
|
||
|
||
entries, err := os.ReadDir(dir)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
c.JSON(http.StatusOK, gin.H{"keys": []string{}, "messageKey": "servers.form.no_keys"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var keys []string
|
||
for _, entry := range entries {
|
||
if entry.IsDir() {
|
||
continue
|
||
}
|
||
name := entry.Name()
|
||
if strings.HasPrefix(name, "id_") || strings.HasSuffix(name, ".pem") || strings.HasSuffix(name, ".key") {
|
||
keys = append(keys, filepath.Join(dir, name))
|
||
}
|
||
}
|
||
if len(keys) == 0 {
|
||
c.JSON(http.StatusOK, gin.H{"keys": []string{}, "messageKey": "servers.form.no_keys"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"keys": keys})
|
||
}
|
||
|
||
// TestServerHandler verifies connectivity to a configured Fail2ban server.
|
||
func TestServerHandler(c *gin.Context) {
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing id parameter"})
|
||
return
|
||
}
|
||
server, ok := config.GetServerByID(id)
|
||
if !ok {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||
return
|
||
}
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 15*time.Second)
|
||
defer cancel()
|
||
|
||
var (
|
||
conn fail2ban.Connector
|
||
err error
|
||
)
|
||
|
||
switch server.Type {
|
||
case "local":
|
||
conn = fail2ban.NewLocalConnector(server)
|
||
case "ssh":
|
||
conn, err = fail2ban.NewSSHConnector(server)
|
||
case "agent":
|
||
conn, err = fail2ban.NewAgentConnector(server)
|
||
default:
|
||
err = fmt.Errorf("unsupported server type %s", server.Type)
|
||
}
|
||
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error(), "messageKey": "servers.actions.test_failure"})
|
||
return
|
||
}
|
||
|
||
if _, err := conn.GetJailInfos(ctx); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error(), "messageKey": "servers.actions.test_failure"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{"messageKey": "servers.actions.test_success"})
|
||
}
|
||
|
||
// HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts.
|
||
func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error {
|
||
// Load settings to get alert countries and GeoIP provider
|
||
settings := config.GetSettings()
|
||
|
||
// Perform whois lookup if not provided (backward compatibility)
|
||
var whoisData string
|
||
var err error
|
||
if whois == "" || whois == "missing whois program" {
|
||
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
|
||
}
|
||
|
||
// Filter logs to show relevant lines
|
||
filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines)
|
||
|
||
// Lookup the country for the given IP using configured provider
|
||
country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
|
||
if err != nil {
|
||
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
|
||
// Try to extract country from whois as fallback
|
||
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)
|
||
}
|
||
|
||
// Broadcast ban event to WebSocket clients
|
||
if wsHub != nil {
|
||
wsHub.BroadcastBanEvent(event)
|
||
}
|
||
|
||
evaluateAdvancedActions(ctx, settings, server, ip)
|
||
|
||
// Check if country is in alert list
|
||
displayCountry := country
|
||
if displayCountry == "" {
|
||
displayCountry = "UNKNOWN"
|
||
}
|
||
|
||
if !shouldAlertForCountry(country, settings.AlertCountries) {
|
||
log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
|
||
return nil
|
||
}
|
||
|
||
// Check if email alerts for bans are enabled
|
||
if !settings.EmailAlertsForBans {
|
||
log.Printf("❌ Email alerts for bans are disabled. No alert sent for IP %s", ip)
|
||
return nil
|
||
}
|
||
|
||
// Send email notification
|
||
if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil {
|
||
log.Printf("❌ Failed to send alert email: %v", err)
|
||
return err
|
||
}
|
||
|
||
log.Printf("✅ Email alert sent for banned IP %s (%s)", ip, displayCountry)
|
||
return nil
|
||
}
|
||
|
||
// HandleUnbanNotification processes Fail2Ban unban notifications, stores the event, and sends alerts.
|
||
func HandleUnbanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, whois, country string) error {
|
||
// Load settings to get alert countries and GeoIP provider
|
||
settings := config.GetSettings()
|
||
|
||
// Perform whois lookup if not provided
|
||
var whoisData string
|
||
var err error
|
||
if whois == "" || whois == "missing whois program" {
|
||
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
|
||
}
|
||
|
||
// Lookup the country for the given IP if not provided
|
||
if country == "" {
|
||
country, err = lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
|
||
if err != nil {
|
||
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
|
||
// Try to extract country from whois as fallback
|
||
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)
|
||
}
|
||
|
||
// Broadcast unban event to WebSocket clients
|
||
if wsHub != nil {
|
||
wsHub.BroadcastUnbanEvent(event)
|
||
}
|
||
|
||
// Check if email alerts for unbans are enabled
|
||
if !settings.EmailAlertsForUnbans {
|
||
log.Printf("❌ Email alerts for unbans are disabled. No alert sent for IP %s", ip)
|
||
return nil
|
||
}
|
||
|
||
// Check if country is in alert list
|
||
displayCountry := country
|
||
if displayCountry == "" {
|
||
displayCountry = "UNKNOWN"
|
||
}
|
||
|
||
if !shouldAlertForCountry(country, settings.AlertCountries) {
|
||
log.Printf("❌ IP %s belongs to %s, which is NOT in alert countries (%v). No alert sent.", ip, displayCountry, settings.AlertCountries)
|
||
return nil
|
||
}
|
||
|
||
// Send email notification
|
||
if err := sendUnbanAlert(ip, jail, hostname, whoisData, country, settings); err != nil {
|
||
log.Printf("❌ Failed to send unban alert email: %v", err)
|
||
return err
|
||
}
|
||
|
||
log.Printf("✅ Email alert sent for unbanned IP %s (%s)", ip, displayCountry)
|
||
return nil
|
||
}
|
||
|
||
// lookupCountry finds the country ISO code for a given IP using the configured provider.
|
||
func lookupCountry(ip, provider, dbPath string) (string, error) {
|
||
switch provider {
|
||
case "builtin":
|
||
return lookupCountryBuiltin(ip)
|
||
case "maxmind", "":
|
||
// Default to maxmind if empty
|
||
if dbPath == "" {
|
||
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
|
||
}
|
||
return lookupCountryMaxMind(ip, dbPath)
|
||
default:
|
||
// Unknown provider, try maxmind as fallback
|
||
log.Printf("Unknown GeoIP provider '%s', falling back to MaxMind", provider)
|
||
if dbPath == "" {
|
||
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
|
||
}
|
||
return lookupCountryMaxMind(ip, dbPath)
|
||
}
|
||
}
|
||
|
||
// lookupCountryMaxMind finds the country ISO code using MaxMind GeoLite2 database.
|
||
func lookupCountryMaxMind(ip, dbPath string) (string, error) {
|
||
// Convert the IP string to net.IP
|
||
parsedIP := net.ParseIP(ip)
|
||
if parsedIP == nil {
|
||
return "", fmt.Errorf("invalid IP address: %s", ip)
|
||
}
|
||
|
||
// Open the GeoIP database
|
||
db, err := maxminddb.Open(dbPath)
|
||
if err != nil {
|
||
return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err)
|
||
}
|
||
defer db.Close()
|
||
|
||
// Define the structure to store the lookup result
|
||
var record struct {
|
||
Country struct {
|
||
ISOCode string `maxminddb:"iso_code"`
|
||
} `maxminddb:"country"`
|
||
}
|
||
|
||
// Perform the lookup using net.IP type
|
||
if err := db.Lookup(parsedIP, &record); err != nil {
|
||
return "", fmt.Errorf("GeoIP lookup error: %w", err)
|
||
}
|
||
|
||
// Return the country code
|
||
return record.Country.ISOCode, nil
|
||
}
|
||
|
||
// lookupCountryBuiltin finds the country ISO code using ip-api.com free API.
|
||
func lookupCountryBuiltin(ip string) (string, error) {
|
||
// Convert the IP string to net.IP to validate
|
||
parsedIP := net.ParseIP(ip)
|
||
if parsedIP == nil {
|
||
return "", fmt.Errorf("invalid IP address: %s", ip)
|
||
}
|
||
|
||
// Use 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
|
||
}
|
||
|
||
// filterRelevantLogs filters log lines to show the most relevant ones that caused the block.
|
||
func filterRelevantLogs(logs, ip string, maxLines int) string {
|
||
if logs == "" {
|
||
return ""
|
||
}
|
||
|
||
if maxLines <= 0 {
|
||
maxLines = 50 // Default
|
||
}
|
||
|
||
lines := strings.Split(logs, "\n")
|
||
if len(lines) <= maxLines {
|
||
return logs // Return as-is if within limit
|
||
}
|
||
|
||
// Priority indicators for relevant log lines
|
||
priorityPatterns := []string{
|
||
"denied", "deny", "forbidden", "unauthorized", "failed", "failure",
|
||
"error", "403", "404", "401", "500", "502", "503",
|
||
"invalid", "rejected", "blocked", "ban",
|
||
}
|
||
|
||
// Score each line based on relevance
|
||
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
|
||
|
||
// Check if line contains the IP
|
||
if strings.Contains(line, ip) {
|
||
score += 10
|
||
}
|
||
|
||
// Check for priority patterns
|
||
for _, pattern := range priorityPatterns {
|
||
if strings.Contains(lineLower, pattern) {
|
||
score += 5
|
||
}
|
||
}
|
||
|
||
// Recent lines get higher score (lines at the end are more recent)
|
||
score += (len(lines) - i) / 10
|
||
|
||
scored[i] = scoredLine{
|
||
line: line,
|
||
score: score,
|
||
index: i,
|
||
}
|
||
}
|
||
|
||
// Sort by score (descending)
|
||
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]
|
||
}
|
||
}
|
||
}
|
||
|
||
// Take top N lines and sort by original index to maintain chronological order
|
||
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]
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build result
|
||
result := make([]string, len(selected))
|
||
for i, s := range selected {
|
||
result[i] = s.line
|
||
}
|
||
|
||
// Remove duplicate consecutive lines
|
||
filtered := []string{}
|
||
lastLine := ""
|
||
for _, line := range result {
|
||
if line != lastLine {
|
||
filtered = append(filtered, line)
|
||
lastLine = line
|
||
}
|
||
}
|
||
|
||
return strings.Join(filtered, "\n")
|
||
}
|
||
|
||
// shouldAlertForCountry 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 // If "ALL" is selected, alert for all bans
|
||
}
|
||
for _, c := range alertCountries {
|
||
if strings.EqualFold(country, c) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// renderIndexPage renders the index.html template with common data
|
||
func renderIndexPage(c *gin.Context) {
|
||
// Check if external IP lookup is disabled via environment variable
|
||
// Default is enabled (false means enabled, true means disabled)
|
||
disableExternalIP := os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "true" || os.Getenv("DISABLE_EXTERNAL_IP_LOOKUP") == "1"
|
||
|
||
// Check 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
|
||
}
|
||
}
|
||
|
||
c.HTML(http.StatusOK, "index.html", gin.H{
|
||
"timestamp": time.Now().Format(time.RFC1123),
|
||
"version": time.Now().Unix(),
|
||
"disableExternalIP": disableExternalIP,
|
||
"oidcEnabled": oidcEnabled,
|
||
"skipLoginPage": skipLoginPage,
|
||
})
|
||
}
|
||
|
||
// GetJailFilterConfigHandler returns both the filter config and jail config for a given jail
|
||
func GetJailFilterConfigHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("GetJailFilterConfigHandler called (handlers.go)") // entry point
|
||
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)
|
||
|
||
// Extract filter name from jail config, or use jail name as fallback
|
||
filterName := fail2ban.ExtractFilterFromJailConfig(jailCfg)
|
||
if filterName == "" {
|
||
// No filter directive found, use jail name as filter name (default behavior)
|
||
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)
|
||
}
|
||
|
||
// Load filter config using the determined filter name
|
||
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)
|
||
// Don't fail completely - allow editing even if filter doesn't exist yet
|
||
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)
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"jail": jail,
|
||
"filter": filterCfg,
|
||
"filterFilePath": filterFilePath,
|
||
"jailConfig": jailCfg,
|
||
"jailFilePath": jailFilePath,
|
||
})
|
||
}
|
||
|
||
// SetJailFilterConfigHandler overwrites both the filter config and jail config with new content
|
||
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)") // entry point
|
||
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)
|
||
|
||
// Parse JSON body (containing both filter and jail content)
|
||
var req struct {
|
||
Filter string `json:"filter"`
|
||
Jail string `json:"jail"`
|
||
}
|
||
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()})
|
||
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))])
|
||
}
|
||
|
||
// Save filter config - use original filter name, not the one from the new jail config
|
||
if req.Filter != "" {
|
||
// Load the original jail config to determine which filter was originally loaded
|
||
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)
|
||
// Fallback: extract from new jail config
|
||
originalJailCfg = req.Jail
|
||
}
|
||
|
||
// Extract 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, use jail name as filter name (default behavior)
|
||
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)
|
||
}
|
||
|
||
// Extract the NEW filter name from the updated jail config
|
||
newFilterName := fail2ban.ExtractFilterFromJailConfig(req.Jail)
|
||
if newFilterName == "" {
|
||
newFilterName = jail
|
||
}
|
||
|
||
// If the filter name changed, save to the ORIGINAL filter name (not the new one)
|
||
// 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")
|
||
}
|
||
|
||
// Save jail config
|
||
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")
|
||
}
|
||
|
||
// Reload fail2ban
|
||
config.DebugLog("Reloading fail2ban")
|
||
if err := conn.Reload(c.Request.Context()); err != nil {
|
||
config.DebugLog("Failed to reload fail2ban: %v", err)
|
||
// Still return success but warn about reload failure
|
||
// The config was saved successfully, user can manually reload
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "Config saved successfully, but fail2ban reload failed",
|
||
"warning": "Please check the fail2ban configuration and reload manually: " + err.Error(),
|
||
})
|
||
return
|
||
}
|
||
config.DebugLog("Fail2ban reloaded successfully")
|
||
|
||
c.JSON(http.StatusOK, gin.H{"message": "Filter and jail config updated and fail2ban reloaded"})
|
||
}
|
||
|
||
func min(a, b int) int {
|
||
if a < b {
|
||
return a
|
||
}
|
||
return b
|
||
}
|
||
|
||
// equalStringSlices compares two string slices for equality
|
||
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
|
||
}
|
||
|
||
// TestLogpathHandler tests a logpath and returns matching files
|
||
// Resolves Fail2Ban variables before testing
|
||
// Accepts optional logpath in request body, otherwise reads from saved jail config
|
||
func TestLogpathHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("TestLogpathHandler called (handlers.go)") // entry point
|
||
jail := c.Param("jail")
|
||
conn, err := resolveConnector(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
var originalLogpath string
|
||
|
||
// Check if logpath is provided in request body
|
||
var reqBody struct {
|
||
Logpath string `json:"logpath"`
|
||
}
|
||
if err := c.ShouldBindJSON(&reqBody); err == nil && reqBody.Logpath != "" {
|
||
// Use logpath from request body (from textarea)
|
||
originalLogpath = strings.TrimSpace(reqBody.Logpath)
|
||
config.DebugLog("Using logpath from request body: %s", originalLogpath)
|
||
} else {
|
||
// Fall back to reading from 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
|
||
}
|
||
|
||
// Extract logpath from 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
|
||
}
|
||
|
||
// Test the logpath with variable resolution
|
||
originalPath, resolvedPath, files, err := conn.TestLogpathWithResolution(c.Request.Context(), originalLogpath)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to test logpath: " + err.Error()})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"original_logpath": originalPath,
|
||
"resolved_logpath": resolvedPath,
|
||
"files": files,
|
||
})
|
||
}
|
||
|
||
// ManageJailsHandler returns a list of all jails (from jail.local and jail.d)
|
||
// including their enabled status.
|
||
func ManageJailsHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("ManageJailsHandler called (handlers.go)") // entry point
|
||
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})
|
||
}
|
||
|
||
// ListPermanentBlocksHandler exposes the permanent block log.
|
||
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})
|
||
}
|
||
|
||
// AdvancedActionsTestHandler allows manual block/unblock tests.
|
||
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()
|
||
|
||
// Check if integration is configured
|
||
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
|
||
}
|
||
|
||
// Verify integration exists
|
||
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
|
||
}
|
||
|
||
// Validate integration configuration
|
||
if err := integration.Validate(settings.AdvancedActions); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("integration configuration is invalid: %v", err)})
|
||
return
|
||
}
|
||
|
||
// Advanced actions work globally, not per server
|
||
server := config.Fail2banServer{}
|
||
|
||
// Check if IP is already blocked before attempting action (for block action only)
|
||
skipLoggingIfAlreadyBlocked := false
|
||
if action == "block" && settings.AdvancedActions.Integration != "" {
|
||
active, checkErr := storage.IsPermanentBlockActive(c.Request.Context(), req.IP, settings.AdvancedActions.Integration)
|
||
if checkErr == nil && active {
|
||
// IP is already blocked, we'll check the error message after the call
|
||
skipLoggingIfAlreadyBlocked = true
|
||
}
|
||
}
|
||
|
||
err := runAdvancedIntegrationAction(
|
||
c.Request.Context(),
|
||
action,
|
||
req.IP,
|
||
settings,
|
||
server,
|
||
map[string]any{"manual": true},
|
||
skipLoggingIfAlreadyBlocked,
|
||
)
|
||
if err != nil {
|
||
// Check if error indicates IP is already blocked - show as info instead of error
|
||
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, return info message with original error
|
||
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)})
|
||
}
|
||
|
||
// UpdateJailManagementHandler updates the enabled state for each jail.
|
||
// Expected JSON format: { "JailName1": true, "JailName2": false, ... }
|
||
// getJailNames converts a map of jail names to a sorted slice of jail names
|
||
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
|
||
}
|
||
|
||
// parseJailErrorsFromReloadOutput extracts jail names that have errors from reload output.
|
||
// Looks for patterns like "Errors in jail 'jailname'. Skipping..." or "Unable to read the filter 'filtername'"
|
||
func parseJailErrorsFromReloadOutput(output string) []string {
|
||
var problematicJails []string
|
||
lines := strings.Split(output, "\n")
|
||
|
||
for _, line := range lines {
|
||
// Look for "Errors in jail 'jailname'. Skipping..."
|
||
if strings.Contains(line, "Errors in jail") && strings.Contains(line, "Skipping") {
|
||
// Extract jail name between single quotes
|
||
re := regexp.MustCompile(`Errors in jail '([^']+)'`)
|
||
matches := re.FindStringSubmatch(line)
|
||
if len(matches) > 1 {
|
||
problematicJails = append(problematicJails, matches[1])
|
||
}
|
||
}
|
||
// Also check for filter errors that might indicate jail problems
|
||
// "Unable to read the filter 'filtername'" - this might be referenced by a jail
|
||
// Note: Filter errors are often associated with jails, but we primarily track
|
||
// jail errors directly via "Errors in jail" messages above
|
||
_ = strings.Contains(line, "Unable to read the filter") // Track for future enhancement
|
||
}
|
||
|
||
// Remove duplicates
|
||
seen := make(map[string]bool)
|
||
uniqueJails := []string{}
|
||
for _, jail := range problematicJails {
|
||
if !seen[jail] {
|
||
seen[jail] = true
|
||
uniqueJails = append(uniqueJails, jail)
|
||
}
|
||
}
|
||
|
||
return uniqueJails
|
||
}
|
||
|
||
// After updating, fail2ban is reloaded to apply the changes.
|
||
func UpdateJailManagementHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("UpdateJailManagementHandler called (handlers.go)") // entry point
|
||
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
|
||
}
|
||
|
||
// Track which jails were enabled (for error recovery)
|
||
enabledJails := make(map[string]bool)
|
||
for jailName, enabled := range updates {
|
||
if enabled {
|
||
enabledJails[jailName] = true
|
||
}
|
||
}
|
||
|
||
// Update jail configuration file(s) with the new enabled states.
|
||
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")
|
||
|
||
// Reload fail2ban to apply the changes (reload is sufficient for jail enable/disable)
|
||
reloadErr := conn.Reload(c.Request.Context())
|
||
|
||
// Check for errors in reload output even if reload "succeeded"
|
||
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)
|
||
|
||
// Extract output from error message (format: "fail2ban reload completed but with errors (output: ...)")
|
||
if strings.Contains(errMsg, "(output:") {
|
||
// Extract the output part
|
||
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:") {
|
||
// Alternative format: "fail2ban reload error: ... (output: ...)"
|
||
outputStart := strings.Index(errMsg, "output:") + 7
|
||
if outputStart < len(errMsg) {
|
||
detailedErrorOutput = strings.TrimSpace(errMsg[outputStart:])
|
||
problematicJails = parseJailErrorsFromReloadOutput(detailedErrorOutput)
|
||
}
|
||
}
|
||
|
||
// If we found problematic jails, disable them
|
||
if len(problematicJails) > 0 {
|
||
config.DebugLog("Found %d problematic jail(s) in reload output: %v", len(problematicJails), problematicJails)
|
||
|
||
// Create disable update for problematic jails
|
||
disableUpdate := make(map[string]bool)
|
||
for _, jailName := range problematicJails {
|
||
disableUpdate[jailName] = false
|
||
}
|
||
|
||
// Also disable any jails that were enabled in this request if they're in the problematic list
|
||
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)
|
||
}
|
||
}
|
||
}
|
||
|
||
// Update enabledJails to include problematic jails for response
|
||
for _, jailName := range problematicJails {
|
||
enabledJails[jailName] = true
|
||
}
|
||
}
|
||
|
||
// Update errMsg with detailed error output when debug mode is enabled
|
||
settings := config.GetSettings()
|
||
if settings.Debug && detailedErrorOutput != "" {
|
||
errMsg = fmt.Sprintf("%s\n\nDetailed error output:\n%s", errMsg, detailedErrorOutput)
|
||
} else if detailedErrorOutput != "" {
|
||
// Even without debug mode, include basic error info
|
||
errMsg = fmt.Sprintf("%s (check debug mode for details)", errMsg)
|
||
}
|
||
|
||
// If any jails were enabled in this request and reload failed, disable them all
|
||
if len(enabledJails) > 0 {
|
||
config.DebugLog("Reload failed after enabling %d jail(s), auto-disabling all enabled jails: %v", len(enabledJails), enabledJails)
|
||
|
||
// Disable all jails that were just enabled
|
||
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
|
||
}
|
||
|
||
// Reload 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
|
||
}
|
||
|
||
// Error occurred but no jails were enabled (only disabled), so just report the error
|
||
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"})
|
||
}
|
||
|
||
// CreateJailHandler creates a new jail.
|
||
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
|
||
}
|
||
|
||
// Validate jail name
|
||
if err := fail2ban.ValidateJailName(req.JailName); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// If no content provided, create minimal jail config
|
||
if req.Content == "" {
|
||
req.Content = fmt.Sprintf("[%s]\nenabled = false\n", req.JailName)
|
||
}
|
||
|
||
// Create the jail
|
||
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)})
|
||
}
|
||
|
||
// DeleteJailHandler deletes a jail.
|
||
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
|
||
}
|
||
|
||
// Validate jail name
|
||
if err := fail2ban.ValidateJailName(jailName); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Delete the jail
|
||
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)})
|
||
}
|
||
|
||
// GetSettingsHandler returns the entire AppSettings struct as JSON
|
||
func GetSettingsHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("GetSettingsHandler called (handlers.go)") // entry point
|
||
s := config.GetSettings()
|
||
|
||
// Check if PORT environment variable is set
|
||
envPort, envPortSet := config.GetPortFromEnv()
|
||
|
||
// Create response with PORT env info
|
||
response := make(map[string]interface{})
|
||
responseBytes, _ := json.Marshal(s)
|
||
json.Unmarshal(responseBytes, &response)
|
||
|
||
// Add PORT environment variable information
|
||
response["portFromEnv"] = envPort
|
||
response["portEnvSet"] = envPortSet
|
||
|
||
// If PORT env is set, override the port value in response
|
||
if envPortSet {
|
||
response["port"] = envPort
|
||
}
|
||
|
||
c.JSON(http.StatusOK, response)
|
||
}
|
||
|
||
// UpdateSettingsHandler updates the AppSettings from a JSON body
|
||
func UpdateSettingsHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("UpdateSettingsHandler called (handlers.go)") // entry point
|
||
var req config.AppSettings
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
fmt.Println("JSON binding error:", err) // Debug
|
||
c.JSON(http.StatusBadRequest, gin.H{
|
||
"error": "invalid JSON",
|
||
"details": err.Error(),
|
||
})
|
||
return
|
||
}
|
||
config.DebugLog("JSON binding successful, updating settings (handlers.go)")
|
||
|
||
// Check if PORT environment variable is set - if so, ignore port changes from request
|
||
envPort, envPortSet := config.GetPortFromEnv()
|
||
if envPortSet {
|
||
// Don't allow port changes when PORT env is set
|
||
req.Port = envPort
|
||
}
|
||
|
||
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)")
|
||
|
||
// Check if callback URL changed - if so, update 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
|
||
}
|
||
|
||
// Update action files for remote servers if callback URL changed
|
||
if callbackURLChanged {
|
||
if err := fail2ban.GetManager().UpdateActionFiles(c.Request.Context()); err != nil {
|
||
config.DebugLog("Warning: failed to update some remote action files: %v", err)
|
||
// Don't fail the request, just log the warning
|
||
}
|
||
// Also update local action file if 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)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Check if Fail2Ban DEFAULT settings changed and push to all enabled servers
|
||
// Compare IgnoreIPs arrays
|
||
ignoreIPsChanged := !equalStringSlices(oldSettings.IgnoreIPs, newSettings.IgnoreIPs)
|
||
defaultSettingsChanged := oldSettings.BantimeIncrement != newSettings.BantimeIncrement ||
|
||
oldSettings.DefaultJailEnable != newSettings.DefaultJailEnable ||
|
||
ignoreIPsChanged ||
|
||
oldSettings.Bantime != newSettings.Bantime ||
|
||
oldSettings.Findtime != newSettings.Findtime ||
|
||
oldSettings.Maxretry != newSettings.Maxretry ||
|
||
oldSettings.Destemail != newSettings.Destemail ||
|
||
oldSettings.Banaction != newSettings.Banaction ||
|
||
oldSettings.BanactionAllports != newSettings.BanactionAllports
|
||
|
||
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)
|
||
config.DebugLog("Error: %s", errorMsg)
|
||
errors = append(errors, errorMsg)
|
||
} else {
|
||
config.DebugLog("Successfully updated DEFAULT settings on %s", server.Name)
|
||
// Reload fail2ban to apply the changes
|
||
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)
|
||
// Don't fail the request, but include warnings in response
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "Settings updated",
|
||
"restartNeeded": false, // We reloaded, so no restart needed
|
||
"warnings": errors,
|
||
})
|
||
return
|
||
}
|
||
// Settings were updated and reloaded successfully, no restart needed
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "Settings updated and fail2ban reloaded",
|
||
"restartNeeded": false, // We reloaded, so no restart needed
|
||
})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"message": "Settings updated",
|
||
"restartNeeded": newSettings.RestartNeeded,
|
||
})
|
||
}
|
||
|
||
// ListFiltersHandler returns a JSON array of filter names
|
||
// found as *.conf in /etc/fail2ban/filter.d
|
||
func ListFiltersHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("ListFiltersHandler called (handlers.go)") // entry point
|
||
conn, err := resolveConnector(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
server := conn.Server()
|
||
if server.Type == "local" {
|
||
// For local, check if directory exists first
|
||
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
|
||
}
|
||
}
|
||
|
||
filters, err := conn.GetFilters(c.Request.Context())
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list filters: " + err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"filters": filters})
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// Remove comments for display in Filter Debug page only
|
||
content = fail2ban.RemoveComments(content)
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"content": content,
|
||
"filterPath": filePath,
|
||
})
|
||
}
|
||
|
||
func TestFilterHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("TestFilterHandler called (handlers.go)") // entry point
|
||
conn, err := resolveConnector(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
var req struct {
|
||
FilterName string `json:"filterName"`
|
||
LogLines []string `json:"logLines"`
|
||
FilterContent string `json:"filterContent"` // Optional: if provided, use this instead of reading from file
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
|
||
// CreateFilterHandler creates a new filter.
|
||
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 no content provided, create empty filter
|
||
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)})
|
||
}
|
||
|
||
// DeleteFilterHandler deletes a filter.
|
||
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
|
||
}
|
||
|
||
// Validate filter name
|
||
if err := fail2ban.ValidateFilterName(filterName); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Delete the filter
|
||
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)})
|
||
}
|
||
|
||
// ApplyFail2banSettings updates /etc/fail2ban/jail.local [DEFAULT] with our JSON
|
||
func ApplyFail2banSettings(jailLocalPath string) error {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("ApplyFail2banSettings called (handlers.go)") // entry point
|
||
s := config.GetSettings()
|
||
|
||
// open /etc/fail2ban/jail.local, parse or do a simplistic approach:
|
||
// TODO: -> maybe we store [DEFAULT] block in memory, replace lines
|
||
// or do a line-based approach. Example is simplistic:
|
||
|
||
newLines := []string{
|
||
"[DEFAULT]",
|
||
fmt.Sprintf("enabled = %t", s.DefaultJailEnable),
|
||
fmt.Sprintf("bantime.increment = %t", s.BantimeIncrement),
|
||
fmt.Sprintf("ignoreip = %s", strings.Join(s.IgnoreIPs, " ")),
|
||
fmt.Sprintf("bantime = %s", s.Bantime),
|
||
fmt.Sprintf("findtime = %s", s.Findtime),
|
||
fmt.Sprintf("maxretry = %d", s.Maxretry),
|
||
fmt.Sprintf("destemail = %s", s.Destemail),
|
||
//fmt.Sprintf("sender = %s", s.Sender),
|
||
"",
|
||
}
|
||
content := strings.Join(newLines, "\n")
|
||
|
||
return os.WriteFile(jailLocalPath, []byte(content), 0644)
|
||
}
|
||
|
||
// RestartFail2banHandler reloads the Fail2ban service
|
||
func RestartFail2banHandler(c *gin.Context) {
|
||
config.DebugLog("----------------------------")
|
||
config.DebugLog("RestartFail2banHandler called (handlers.go)") // entry point
|
||
|
||
// Check if serverId is provided in query parameter
|
||
serverID := c.Query("serverId")
|
||
var conn fail2ban.Connector
|
||
var err error
|
||
|
||
if serverID != "" {
|
||
// Use specific server
|
||
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 {
|
||
// Use default connector from context
|
||
conn, err = resolveConnector(c)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
}
|
||
|
||
server := conn.Server()
|
||
|
||
// Attempt to restart the fail2ban service via the connector.
|
||
// Any error here means the service was not restarted, so we surface it to the UI.
|
||
mode, err := fail2ban.RestartFail2ban(server.ID)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
|
||
// Only call MarkRestartDone if we successfully restarted the service.
|
||
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,
|
||
})
|
||
}
|
||
|
||
// loadLocale loads a locale JSON file and returns a map of translations
|
||
func loadLocale(lang string) (map[string]string, error) {
|
||
localeCacheLock.RLock()
|
||
if cached, ok := localeCache[lang]; ok {
|
||
localeCacheLock.RUnlock()
|
||
return cached, nil
|
||
}
|
||
localeCacheLock.RUnlock()
|
||
|
||
// Determine 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)
|
||
}
|
||
|
||
// Read locale file
|
||
data, err := os.ReadFile(localePath)
|
||
if err != nil {
|
||
// Fallback to English if locale file 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)
|
||
}
|
||
|
||
// Cache the translations
|
||
localeCacheLock.Lock()
|
||
localeCache[lang] = translations
|
||
localeCacheLock.Unlock()
|
||
|
||
return translations, nil
|
||
}
|
||
|
||
// getEmailTranslation gets a translation key from the locale, with fallback to English
|
||
func getEmailTranslation(lang, key string) string {
|
||
translations, err := loadLocale(lang)
|
||
if err != nil {
|
||
// Try English as fallback
|
||
if lang != "en" {
|
||
translations, err = loadLocale("en")
|
||
if err != nil {
|
||
return key // Return key if all else fails
|
||
}
|
||
} else {
|
||
return key
|
||
}
|
||
}
|
||
|
||
if translation, ok := translations[key]; ok {
|
||
return translation
|
||
}
|
||
|
||
// Fallback to English if key not found
|
||
if lang != "en" {
|
||
enTranslations, err := loadLocale("en")
|
||
if err == nil {
|
||
if enTranslation, ok := enTranslations[key]; ok {
|
||
return enTranslation
|
||
}
|
||
}
|
||
}
|
||
|
||
return key
|
||
}
|
||
|
||
// getEmailStyle returns the email style from environment variable, defaulting to "modern"
|
||
func getEmailStyle() string {
|
||
style := os.Getenv("emailStyle")
|
||
if style == "classic" {
|
||
return "classic"
|
||
}
|
||
return "modern"
|
||
}
|
||
|
||
// isLOTRModeActive checks if LOTR mode is enabled in alert countries
|
||
func isLOTRModeActive(alertCountries []string) bool {
|
||
if len(alertCountries) == 0 {
|
||
return false
|
||
}
|
||
for _, country := range alertCountries {
|
||
if strings.EqualFold(country, "LOTR") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * Unified Email Sending Function : *
|
||
// *******************************************************************
|
||
func sendEmail(to, subject, body string, settings config.AppSettings) error {
|
||
// Validate SMTP settings
|
||
if settings.SMTP.Host == "" || settings.SMTP.Username == "" || settings.SMTP.Password == "" || settings.SMTP.From == "" {
|
||
return errors.New("SMTP settings are incomplete. Please configure all required fields")
|
||
}
|
||
|
||
// Format message with **correct HTML headers**
|
||
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)
|
||
|
||
// SMTP Connection Config
|
||
smtpHost := settings.SMTP.Host
|
||
smtpPort := settings.SMTP.Port
|
||
auth := LoginAuth(settings.SMTP.Username, settings.SMTP.Password)
|
||
smtpAddr := net.JoinHostPort(smtpHost, fmt.Sprintf("%d", smtpPort))
|
||
|
||
// **Choose Connection Type**
|
||
switch smtpPort {
|
||
case 465:
|
||
// SMTPS (Implicit TLS) - Not supported at the moment.
|
||
tlsConfig := &tls.Config{ServerName: smtpHost}
|
||
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)
|
||
}
|
||
defer client.Quit()
|
||
|
||
if err := client.Auth(auth); err != nil {
|
||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||
}
|
||
|
||
return sendSMTPMessage(client, settings.SMTP.From, to, msg)
|
||
|
||
case 587:
|
||
// STARTTLS (Explicit TLS)
|
||
conn, err := net.Dial("tcp", smtpAddr)
|
||
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)
|
||
}
|
||
defer client.Quit()
|
||
|
||
// Start TLS Upgrade
|
||
tlsConfig := &tls.Config{ServerName: smtpHost}
|
||
if err := client.StartTLS(tlsConfig); err != nil {
|
||
return fmt.Errorf("failed to start TLS: %w", err)
|
||
}
|
||
|
||
if err := client.Auth(auth); err != nil {
|
||
return fmt.Errorf("SMTP authentication failed: %w", err)
|
||
}
|
||
|
||
return sendSMTPMessage(client, settings.SMTP.From, to, msg)
|
||
}
|
||
|
||
return errors.New("unsupported SMTP port. Use 587 (STARTTLS) or 465 (SMTPS)")
|
||
}
|
||
|
||
// Helper Function to Send SMTP Message
|
||
func sendSMTPMessage(client *smtp.Client, from, to string, msg []byte) error {
|
||
// Set sender & recipient
|
||
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)
|
||
}
|
||
|
||
// Send email body
|
||
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)
|
||
}
|
||
|
||
// Close connection
|
||
client.Quit()
|
||
return nil
|
||
}
|
||
|
||
// renderClassicEmailDetails creates paragraph-based details for 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()
|
||
}
|
||
|
||
// buildClassicEmailBody creates the classic email template (original design with multilingual support)
|
||
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); }
|
||
.header { text-align: center; padding-bottom: 10px; border-bottom: 2px solid #005DE0; }
|
||
.header img { max-width: 150px; }
|
||
.header h2 { color: #005DE0; margin: 10px 0; font-size: 24px; }
|
||
.content { padding: 15px; }
|
||
.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; }
|
||
.label { font-weight: bold; color: #333; }
|
||
pre {
|
||
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;
|
||
}
|
||
@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>© %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)
|
||
}
|
||
|
||
// buildLOTREmailBody creates the dramatic LOTR-themed email template with "You Shall Not Pass" styling
|
||
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; }
|
||
@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)
|
||
}
|
||
|
||
// buildModernEmailBody creates the modern responsive email template (new design)
|
||
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%%); color:#ffffff; 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; }
|
||
.email-header-title { margin:0 0 10px; font-size:26px; font-weight:700; line-height:1.2; }
|
||
.email-body { padding:36px 28px; }
|
||
.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; }
|
||
.email-header-title { font-size:22px; }
|
||
.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-title { font-size:20px; }
|
||
.email-body { padding:24px 16px; }
|
||
.email-details-wrapper { padding:12px; }
|
||
}
|
||
</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)
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
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>`
|
||
}
|
||
// Use <pre> to preserve all whitespace and newlines exactly as they are
|
||
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>`
|
||
}
|
||
|
||
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()
|
||
}
|
||
|
||
// Classic format: simple pre tag
|
||
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>`
|
||
}
|
||
|
||
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 == ""
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * sendBanAlert Function : *
|
||
// *******************************************************************
|
||
func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, settings config.AppSettings) error {
|
||
lang := settings.Language
|
||
if lang == "" {
|
||
lang = "en"
|
||
}
|
||
|
||
// Check if LOTR mode is active for subject line
|
||
isLOTRMode := isLOTRModeActive(settings.AlertCountries)
|
||
|
||
// Get translations
|
||
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)
|
||
} 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)
|
||
}
|
||
|
||
// Determine email style and LOTR mode
|
||
emailStyle := getEmailStyle()
|
||
isModern := emailStyle == "modern"
|
||
|
||
// Get translations - use LOTR translations if in LOTR mode
|
||
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"
|
||
|
||
// Format details with LOTR terminology if in LOTR mode
|
||
var details []emailDetail
|
||
if isLOTRMode {
|
||
// Transform labels to LOTR terminology
|
||
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)
|
||
} else {
|
||
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
|
||
}
|
||
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
|
||
if isLOTRMode {
|
||
// Use LOTR-themed email template
|
||
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)
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * sendUnbanAlert Function : *
|
||
// *******************************************************************
|
||
func sendUnbanAlert(ip, jail, hostname, whois, country string, settings config.AppSettings) error {
|
||
lang := settings.Language
|
||
if lang == "" {
|
||
lang = "en"
|
||
}
|
||
|
||
isLOTRMode := isLOTRModeActive(settings.AlertCountries)
|
||
|
||
// Get translations
|
||
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)
|
||
}
|
||
|
||
// Determine email style and LOTR mode
|
||
emailStyle := getEmailStyle()
|
||
isModern := emailStyle == "modern"
|
||
|
||
// Get translations - use LOTR translations if in LOTR mode
|
||
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"
|
||
|
||
// Format details - use shared keys for common fields, LOTR-specific only for restored_ip
|
||
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 {
|
||
// Use LOTR-themed email template
|
||
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)
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * TestEmailHandler to send test-mail : *
|
||
// *******************************************************************
|
||
func TestEmailHandler(c *gin.Context) {
|
||
settings := config.GetSettings()
|
||
|
||
lang := settings.Language
|
||
if lang == "" {
|
||
lang = "en"
|
||
}
|
||
|
||
// Get translations
|
||
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"
|
||
|
||
// Determine email style
|
||
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!"})
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * Office365 LOGIN Authentication : *
|
||
// *******************************************************************
|
||
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
|
||
}
|
||
|
||
// *******************************************************************
|
||
// * OIDC Authentication Handlers *
|
||
// *******************************************************************
|
||
|
||
// LoginHandler shows the login page or initiates the OIDC login flow
|
||
// If action=redirect query parameter is present, redirects to OIDC provider
|
||
// If OIDC_SKIP_LOGINPAGE is true, redirects directly to OIDC provider
|
||
// Otherwise, 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
|
||
}
|
||
|
||
// Check if skip login page is enabled
|
||
oidcConfig := auth.GetConfig()
|
||
if oidcConfig != nil && oidcConfig.SkipLoginPage {
|
||
// Skip login page - redirect directly to OIDC provider
|
||
// Generate state parameter for CSRF protection
|
||
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
|
||
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||
|
||
// Store state in session cookie for validation
|
||
stateCookie := &http.Cookie{
|
||
Name: "oidc_state",
|
||
Value: state,
|
||
Path: "/",
|
||
MaxAge: 600, // 10 minutes
|
||
HttpOnly: true,
|
||
Secure: isSecure, // Only secure over HTTPS
|
||
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
|
||
}
|
||
|
||
// Check if this is a redirect action (triggered by clicking the login button)
|
||
if c.Query("action") == "redirect" {
|
||
// Generate state parameter for CSRF protection
|
||
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
|
||
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||
|
||
// Store state in session cookie for validation
|
||
stateCookie := &http.Cookie{
|
||
Name: "oidc_state",
|
||
Value: state,
|
||
Path: "/",
|
||
MaxAge: 600, // 10 minutes
|
||
HttpOnly: true,
|
||
Secure: isSecure, // Only secure over HTTPS
|
||
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
|
||
}
|
||
|
||
// Otherwise, render the login page (index.html)
|
||
// The JavaScript will handle showing the login page and redirecting when button is clicked
|
||
renderIndexPage(c)
|
||
}
|
||
|
||
// CallbackHandler handles the OIDC callback after user authentication
|
||
func CallbackHandler(c *gin.Context) {
|
||
oidcClient := auth.GetOIDCClient()
|
||
if oidcClient == nil {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "OIDC authentication is not configured"})
|
||
return
|
||
}
|
||
|
||
// Get state from cookie
|
||
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
|
||
}
|
||
|
||
// Determine if we're using HTTPS
|
||
isSecure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https"
|
||
|
||
// Clear state cookie
|
||
http.SetCookie(c.Writer, &http.Cookie{
|
||
Name: "oidc_state",
|
||
Value: "",
|
||
Path: "/",
|
||
MaxAge: -1,
|
||
HttpOnly: true,
|
||
Secure: isSecure, // Only secure over HTTPS
|
||
SameSite: http.SameSiteLaxMode,
|
||
})
|
||
|
||
// Verify state parameter
|
||
returnedState := c.Query("state")
|
||
if returnedState != stateCookie {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid state parameter"})
|
||
return
|
||
}
|
||
|
||
// Get authorization code
|
||
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
|
||
}
|
||
|
||
// Exchange code for tokens
|
||
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
|
||
}
|
||
|
||
// Verify token and extract user info
|
||
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 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, "/")
|
||
}
|
||
|
||
// LogoutHandler clears the session and optionally redirects to provider logout
|
||
func LogoutHandler(c *gin.Context) {
|
||
oidcClient := auth.GetOIDCClient()
|
||
|
||
// Clear session first
|
||
auth.DeleteSession(c.Writer, c.Request)
|
||
|
||
// If provider logout URL is configured, redirect there
|
||
// Auto-construct logout URL for standard OIDC providers if not explicitly set
|
||
if oidcClient != nil {
|
||
logoutURL := oidcClient.Config.LogoutURL
|
||
if logoutURL == "" && oidcClient.Config.IssuerURL != "" {
|
||
// Auto-construct standard OIDC logout URL for Keycloak, Authentik, and Pocket-ID
|
||
issuerURL := oidcClient.Config.IssuerURL
|
||
redirectURI := oidcClient.Config.RedirectURL
|
||
// Extract base URL from redirect URI for logout redirect (remove /auth/callback)
|
||
if strings.Contains(redirectURI, "/auth/callback") {
|
||
redirectURI = strings.TrimSuffix(redirectURI, "/auth/callback")
|
||
}
|
||
// Redirect to login page after logout
|
||
redirectURI = redirectURI + "/auth/login"
|
||
// URL encode the redirect_uri parameter
|
||
redirectURIEncoded := url.QueryEscape(redirectURI)
|
||
clientIDEncoded := url.QueryEscape(oidcClient.Config.ClientID)
|
||
|
||
// Provider-specific logout URL construction
|
||
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)
|
||
case "pocketid":
|
||
// Pocket-ID uses a different logout endpoint
|
||
// Format: {issuer}/api/oidc/end-session?redirect_uri={redirect}
|
||
logoutURL = fmt.Sprintf("%s/api/oidc/end-session?redirect_uri=%s", issuerURL, redirectURIEncoded)
|
||
case "authentik":
|
||
// Standard OIDC format for Authentik
|
||
// Format: {issuer}/protocol/openid-connect/logout?redirect_uri={redirect}
|
||
logoutURL = fmt.Sprintf("%s/protocol/openid-connect/logout?redirect_uri=%s", issuerURL, redirectURIEncoded)
|
||
default:
|
||
// Fallback to standard OIDC format
|
||
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
|
||
}
|
||
}
|
||
|
||
// Otherwise, redirect to login page
|
||
c.Redirect(http.StatusFound, "/auth/login")
|
||
}
|
||
|
||
// AuthStatusHandler returns the current authentication status
|
||
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,
|
||
},
|
||
})
|
||
}
|
||
|
||
// UserInfoHandler returns the current user 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,
|
||
},
|
||
})
|
||
}
|