Implement unban events and API and also add it to the Recent stored events, as well some cleanups

This commit is contained in:
2025-12-16 22:22:32 +01:00
parent 792bbe1939
commit 7b5c201936
19 changed files with 813 additions and 211 deletions

View File

@@ -261,6 +261,81 @@ func BanNotificationHandler(c *gin.Context) {
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")
@@ -658,6 +733,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Failures: failures,
Whois: whoisData,
Logs: filteredLogs,
EventType: "ban",
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
@@ -682,6 +758,12 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
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)
@@ -692,6 +774,93 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
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 {
@@ -2320,7 +2489,11 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
// Get translations
var subject string
if isLOTRMode {
subject = fmt.Sprintf("[Middle-earth] The Dark Lord's Servant Has Been Banished: %s from %s", ip, hostname)
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"),
@@ -2337,19 +2510,10 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
var title, intro, whoisTitle, logsTitle, footerText string
if isLOTRMode {
title = getEmailTranslation(lang, "lotr.email.title")
if title == "lotr.email.title" {
title = "A Dark Servant Has Been Banished"
}
intro = getEmailTranslation(lang, "lotr.email.intro")
if intro == "lotr.email.intro" {
intro = "The guardians of Middle-earth have detected a threat and banished it from the realm."
}
whoisTitle = getEmailTranslation(lang, "email.ban.whois_title")
logsTitle = getEmailTranslation(lang, "email.ban.logs_title")
footerText = getEmailTranslation(lang, "lotr.email.footer")
if footerText == "lotr.email.footer" {
footerText = "May the servers be protected. One ban to rule them all."
}
} else {
title = getEmailTranslation(lang, "email.ban.title")
intro = getEmailTranslation(lang, "email.ban.intro")
@@ -2364,34 +2528,15 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
if isLOTRMode {
// Transform labels to LOTR terminology
bannedIPLabel := getEmailTranslation(lang, "lotr.email.details.dark_servant_location")
if bannedIPLabel == "lotr.email.details.dark_servant_location" {
bannedIPLabel = "The Dark Servant's Location"
}
jailLabel := getEmailTranslation(lang, "lotr.email.details.realm_protection")
if jailLabel == "lotr.email.details.realm_protection" {
jailLabel = "The Realm of Protection"
}
countryLabelKey := getEmailTranslation(lang, "lotr.email.details.origins")
var countryLabel string
if countryLabelKey == "lotr.email.details.origins" {
// Use default English format
if country != "" {
countryLabel = fmt.Sprintf("Origins from the %s Lands", country)
} else {
countryLabel = "Origins from Unknown Lands"
}
if country != "" {
countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country)
} else {
// Use translated label and append country
if country != "" {
countryLabel = fmt.Sprintf("%s %s", countryLabelKey, country)
} else {
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
}
countryLabel = fmt.Sprintf("%s Unknown", countryLabelKey)
}
timestampLabel := getEmailTranslation(lang, "lotr.email.details.banished_at")
if timestampLabel == "lotr.email.details.banished_at" {
timestampLabel = "Banished at the"
}
details = []emailDetail{
{Label: bannedIPLabel, Value: ip},
@@ -2428,6 +2573,87 @@ func sendBanAlert(ip, jail, hostname, failures, whois, logs, country string, set
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 : *
// *******************************************************************

View File

@@ -69,6 +69,7 @@ func RegisterRoutes(r *gin.Engine, hub *Hub) {
// Handle Fail2Ban notifications
api.POST("/ban", BanNotificationHandler)
api.POST("/unban", UnbanNotificationHandler)
// Internal database overview
api.GET("/events/bans", ListBanEventsHandler)

View File

@@ -26,6 +26,10 @@
display: none;
}
#serverManagerList {
min-height: 480px;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
@@ -168,6 +172,18 @@ mark {
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
}
.toast-unban-event {
background-color: #14532d;
pointer-events: auto;
cursor: pointer;
}
.toast-unban-event:hover {
background-color: #166534;
transform: translateY(-2px);
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
}
/* Backend Status Indicator */
#backendStatus {
display: flex;

View File

@@ -46,24 +46,29 @@ function showBanEventToast(event) {
var container = document.getElementById('toast-container');
if (!container || !event) return;
var isUnban = event.eventType === 'unban';
var toast = document.createElement('div');
toast.className = 'toast toast-ban-event';
toast.className = isUnban ? 'toast toast-unban-event' : 'toast toast-ban-event';
var ip = event.ip || 'Unknown IP';
var jail = event.jail || 'Unknown Jail';
var server = event.serverName || event.serverId || 'Unknown Server';
var country = event.country || 'UNKNOWN';
var title = isUnban ? t('toast.unban.title', 'IP unblocked') : t('toast.ban.title', 'New block occurred');
var action = isUnban ? t('toast.unban.action', 'unblocked from') : t('toast.ban.action', 'banned in');
var icon = isUnban ? 'fas fa-check-circle text-green-400' : 'fas fa-shield-alt text-red-500';
toast.innerHTML = ''
+ '<div class="flex items-start gap-3">'
+ ' <div class="flex-shrink-0 mt-1">'
+ ' <i class="fas fa-shield-alt text-red-500"></i>'
+ ' <i class="' + icon + '"></i>'
+ ' </div>'
+ ' <div class="flex-1 min-w-0">'
+ ' <div class="font-semibold text-sm">New block occurred</div>'
+ ' <div class="font-semibold text-sm">' + title + '</div>'
+ ' <div class="text-sm mt-1">'
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
+ ' <span> banned in </span>'
+ ' <span> ' + action + ' </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">'

View File

@@ -85,7 +85,7 @@ function fetchBanEventsData() {
});
}
// Add new ban event from WebSocket
// Add new ban or unban event from WebSocket
function addBanEventFromWebSocket(event) {
// Check if event already exists (prevent duplicates)
// Only check by ID if both events have IDs
@@ -95,16 +95,21 @@ function addBanEventFromWebSocket(event) {
return e.id === event.id;
});
} else {
// If no ID, check by IP, jail, and occurredAt timestamp
// If no ID, check by IP, jail, eventType, and occurredAt timestamp
exists = latestBanEvents.some(function(e) {
return e.ip === event.ip &&
e.jail === event.jail &&
e.eventType === event.eventType &&
e.occurredAt === event.occurredAt;
});
}
if (!exists) {
console.log('Adding new ban event from WebSocket:', event);
// Ensure eventType is set (default to 'ban' for backward compatibility)
if (!event.eventType) {
event.eventType = 'ban';
}
console.log('Adding new event from WebSocket:', event);
// Prepend to the beginning of the array
latestBanEvents.unshift(event);
@@ -121,7 +126,7 @@ function addBanEventFromWebSocket(event) {
// Refresh dashboard data (summary, stats, insights) and re-render
refreshDashboardData();
} else {
console.log('Skipping duplicate ban event:', event);
console.log('Skipping duplicate event:', event);
}
}
@@ -453,9 +458,8 @@ function unbanIP(jail, ip) {
.then(function(data) {
if (data.error) {
showToast("Error unbanning IP: " + data.error, 'error');
} else {
showToast(data.message || "IP unbanned successfully", 'success');
}
// Don't show success toast here - the WebSocket unban event will show a proper toast
return refreshData({ silent: true });
})
.catch(function(err) {
@@ -761,12 +765,19 @@ function renderLogOverviewContent() {
if (event.ip && recurringMap[event.ip]) {
ipCell += ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-yellow-100 text-yellow-800">' + t('logs.badge.recurring', 'Recurring') + '</span>';
}
var eventType = event.eventType || 'ban';
var eventTypeBadge = '';
if (eventType === 'unban') {
eventTypeBadge = ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">Unban</span>';
} else {
eventTypeBadge = ' <span class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">Ban</span>';
}
html += ''
+ ' <tr class="hover:bg-gray-50">'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + escapeHtml(formatDateTime(event.occurredAt || event.createdAt)) + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + serverCell + '</td>'
+ ' <td class="hidden sm:table-cell px-2 py-2 whitespace-nowrap">' + jailCell + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">' + ipCell + eventTypeBadge + '</td>'
+ ' <td class="hidden md:table-cell px-2 py-2 whitespace-nowrap">' + escapeHtml(event.country || '—') + '</td>'
+ ' <td class="px-2 py-2 whitespace-nowrap">'
+ ' <div class="flex gap-2">'

View File

@@ -260,7 +260,7 @@ function openManageJailsModal() {
const jsEscapedJailName = jail.jailName.replace(/'/g, "\\'");
return ''
+ '<div class="flex items-center justify-between gap-3 p-3 bg-gray-50">'
+ ' <span class="text-sm font-medium flex-1">' + escapedJailName + '</span>'
+ ' <span class="text-sm font-medium flex-1 text-gray-900">' + escapedJailName + '</span>'
+ ' <div class="flex items-center gap-3">'
+ ' <button'
+ ' type="button"'
@@ -282,7 +282,7 @@ function openManageJailsModal() {
+ ' class="w-11 h-6 bg-gray-200 rounded-full peer-focus:ring-4 peer-focus:ring-blue-300 peer-checked:bg-blue-600 transition-colors"'
+ ' ></div>'
+ ' <span'
+ ' class="absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform peer-checked:translate-x-5"'
+ ' class="absolute left-1 top-1/2 -translate-y-1/2 bg-white w-4 h-4 rounded-full transition-transform peer-checked:translate-x-5"'
+ ' ></span>'
+ ' </label>'
+ ' </div>'

View File

@@ -13,6 +13,32 @@ function onGeoIPProviderChange(provider) {
}
}
// Update email fields state based on checkbox preferences
function updateEmailFieldsState() {
const emailAlertsForBans = document.getElementById('emailAlertsForBans').checked;
const emailAlertsForUnbans = document.getElementById('emailAlertsForUnbans').checked;
const emailEnabled = emailAlertsForBans || emailAlertsForUnbans;
// Get all email-related fields
const emailFields = [
document.getElementById('destEmail'),
document.getElementById('smtpHost'),
document.getElementById('smtpPort'),
document.getElementById('smtpUsername'),
document.getElementById('smtpPassword'),
document.getElementById('smtpFrom'),
document.getElementById('smtpUseTLS'),
document.getElementById('sendTestEmailBtn')
];
// Enable/disable all email fields
emailFields.forEach(field => {
if (field) {
field.disabled = !emailEnabled;
}
});
}
function loadSettings() {
showLoading(true);
fetch('/api/settings')
@@ -78,6 +104,11 @@ function loadSettings() {
document.getElementById('destEmail').value = data.destemail || '';
// Load email alert preferences
document.getElementById('emailAlertsForBans').checked = data.emailAlertsForBans !== undefined ? data.emailAlertsForBans : true;
document.getElementById('emailAlertsForUnbans').checked = data.emailAlertsForUnbans !== undefined ? data.emailAlertsForUnbans : false;
updateEmailFieldsState();
const select = document.getElementById('alertCountries');
for (let i = 0; i < select.options.length; i++) {
select.options[i].selected = false;
@@ -174,6 +205,8 @@ function saveSettings(event) {
callbackUrl: callbackUrl,
callbackSecret: document.getElementById('callbackSecret').value.trim(),
alertCountries: selectedCountries.length > 0 ? selectedCountries : ["ALL"],
emailAlertsForBans: document.getElementById('emailAlertsForBans').checked,
emailAlertsForUnbans: document.getElementById('emailAlertsForUnbans').checked,
bantimeIncrement: document.getElementById('bantimeIncrement').checked,
defaultJailEnable: document.getElementById('defaultJailEnable').checked,
bantime: document.getElementById('banTime').value.trim(),

View File

@@ -93,6 +93,9 @@ class WebSocketManager {
case 'ban_event':
this.handleBanEvent(message.data);
break;
case 'unban_event':
this.handleBanEvent(message.data); // Use same handler for unban events
break;
case 'heartbeat':
this.handleHeartbeat(message);
break;

View File

@@ -419,10 +419,42 @@ body.lotr-mode .bg-gray-50 {
}
body.lotr-mode .text-blue-600 {
color: var(--lotr-gold);
color: var(--lotr-gold) !important;
font-weight: 600;
}
/* Text colors in LOTR mode for better visibility */
body.lotr-mode .bg-gray-50 .text-gray-900,
body.lotr-mode .bg-gray-50 .text-sm,
body.lotr-mode .bg-gray-50 .text-sm.font-medium {
color: var(--lotr-text-dark) !important;
}
/* Open insights button styling */
body.lotr-mode .border-blue-200 {
border-color: var(--lotr-gold) !important;
}
body.lotr-mode .hover\:bg-blue-50:hover {
background-color: rgba(212, 175, 55, 0.1) !important;
}
/* Logpath results styling */
body.lotr-mode #logpathResults,
body.lotr-mode pre#logpathResults {
background: var(--lotr-warm-parchment) !important;
color: var(--lotr-text-dark) !important;
border: 2px solid var(--lotr-stone-gray) !important;
}
body.lotr-mode #logpathResults.text-red-600 {
color: var(--lotr-fire-red) !important;
}
body.lotr-mode #logpathResults.text-yellow-600 {
color: var(--lotr-dark-gold) !important;
}
/* Tables */
body.lotr-mode table {
border-collapse: separate;
@@ -570,12 +602,71 @@ body.lotr-mode .select2-container--default .select2-selection--multiple .select2
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
body.lotr-mode .select2-container--default .select2-selection--multiple > .select2-selection--inline > input.select2-search__field {
border: none !important;
box-shadow: none !important;
}
body.lotr-mode .select2-container--default .select2-selection--multiple .select2-selection__choice__remove {
color: var(--lotr-text-dark) !important;
font-weight: 700;
margin-right: 6px;
}
/* Select2 Dropdown Results Styling */
body.lotr-mode .select2-results__options {
background: var(--lotr-warm-parchment) !important;
border: 2px solid var(--lotr-stone-gray) !important;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important;
}
body.lotr-mode .select2-results__option {
background: transparent !important;
color: var(--lotr-text-dark) !important;
padding: 8px 12px;
border-bottom: 1px solid var(--lotr-stone-gray);
}
body.lotr-mode .select2-results__option:last-child {
border-bottom: none;
}
body.lotr-mode .select2-results__option--highlighted,
body.lotr-mode .select2-results__option[aria-selected="true"] {
background: linear-gradient(135deg, var(--lotr-gold) 0%, var(--lotr-dark-gold) 100%) !important;
color: var(--lotr-text-dark) !important;
font-weight: 600;
}
body.lotr-mode .select2-results__option--highlighted[aria-selected="true"] {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
}
/* Ignore IP Tags Styling */
body.lotr-mode .ignore-ip-tag {
background: linear-gradient(135deg, var(--lotr-warm-parchment) 0%, var(--lotr-dark-parchment) 100%) !important;
color: var(--lotr-text-dark) !important;
border: 2px solid var(--lotr-stone-gray) !important;
font-weight: 600;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
body.lotr-mode .ignore-ip-tag button {
color: var(--lotr-text-dark) !important;
font-weight: 700;
font-size: 1.1em;
line-height: 1;
padding: 0 2px;
transition: all 0.2s ease;
}
body.lotr-mode .ignore-ip-tag button:hover {
color: var(--lotr-fire-red) !important;
transform: scale(1.2);
text-shadow: 0 0 4px rgba(193, 18, 31, 0.5);
}
/* Scrollbar Styling */
body.lotr-mode ::-webkit-scrollbar {
width: 14px;
@@ -744,6 +835,52 @@ body.lotr-mode input[type="radio"] {
cursor: pointer;
}
/* Toggle Switch Styling for LOTR Mode */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer {
position: relative;
align-items: center;
}
/* Toggle switch track - default state */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > div.w-11 {
background: var(--lotr-stone-gray) !important;
border: 2px solid var(--lotr-brown) !important;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.3) !important;
position: relative;
}
/* Toggle switch track - checked state (using peer-checked) */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ div {
background: linear-gradient(135deg, var(--lotr-bright-gold) 0%, var(--lotr-gold) 100%) !important;
border-color: var(--lotr-gold) !important;
box-shadow:
0 0 10px rgba(212, 175, 55, 0.5),
inset 0 2px 4px rgba(255, 255, 255, 0.3) !important;
}
/* Toggle switch focus ring */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:focus ~ div {
box-shadow:
0 0 0 4px rgba(212, 175, 55, 0.3),
inset 0 2px 4px rgba(0, 0, 0, 0.3) !important;
}
/* Toggle switch thumb (the circle) - properly centered */
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer > span.absolute {
background: var(--lotr-parchment) !important;
border: 2px solid var(--lotr-brown) !important;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.3),
inset 0 1px 2px rgba(255, 255, 255, 0.5) !important;
top: 50% !important;
transform: translateY(-50%) !important;
margin-top: 0 !important;
}
body.lotr-mode label.inline-flex.relative.items-center.cursor-pointer input.peer:checked ~ span {
transform: translateX(1.25rem) translateY(-50%) !important;
}
/* Labels */
body.lotr-mode label {
color: var(--lotr-text-dark);

View File

@@ -341,9 +341,24 @@
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.alert">Alert Settings</h3>
<!-- Email Alert Preferences -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.email_alerts">Email Alert Preferences</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" id="emailAlertsForBans" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" onchange="updateEmailFieldsState()">
<span class="ml-2 text-sm text-gray-700" data-i18n="settings.email_alerts_for_bans">Enable email alerts for bans</span>
</label>
<label class="flex items-center">
<input type="checkbox" id="emailAlertsForUnbans" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500" onchange="updateEmailFieldsState()">
<span class="ml-2 text-sm text-gray-700" data-i18n="settings.email_alerts_for_unbans">Enable email alerts for unbans</span>
</label>
</div>
</div>
<div class="mb-4" id="emailFieldsContainer">
<label for="destEmail" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.destination_email">Destination Email (Alerts Receiver)</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="destEmail"
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="destEmail"
data-i18n-placeholder="settings.destination_email_placeholder" placeholder="alerts@swissmakers.ch" />
<p class="text-xs text-red-600 mt-1 hidden" id="destEmailError"></p>
</div>
@@ -579,40 +594,40 @@
</div>
<!-- SMTP Configuration Group -->
<div class="bg-white rounded-lg shadow p-6">
<div class="bg-white rounded-lg shadow p-6" id="smtpFieldsContainer">
<h3 class="text-lg font-medium text-gray-900 mb-4" data-i18n="settings.smtp">SMTP Configuration</h3>
<div class="mb-4">
<label for="smtpHost" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_host">SMTP Host</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpHost"
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpHost"
data-i18n-placeholder="settings.smtp_host_placeholder" placeholder="e.g., smtp.gmail.com" required />
</div>
<div class="mb-4">
<label for="smtpPort" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_port">SMTP Port</label>
<select id="smtpPort" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500">
<select id="smtpPort" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed">
<option value="587" selected>587 (Recommended - STARTTLS)</option>
<option value="465" disabled>465 (Not Supported)</option>
</select>
</div>
<div class="mb-4">
<label for="smtpUsername" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_username">SMTP Username</label>
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpUsername"
<input type="text" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpUsername"
data-i18n-placeholder="settings.smtp_username_placeholder" placeholder="e.g., user@example.com" required />
</div>
<div class="mb-4">
<label for="smtpPassword" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_password">SMTP Password</label>
<input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpPassword"
<input type="password" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpPassword"
data-i18n-placeholder="settings.smtp_password_placeholder" placeholder="Enter SMTP Password" required />
</div>
<div class="mb-4">
<label for="smtpFrom" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.smtp_sender">Sender Email</label>
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" id="smtpFrom"
<input type="email" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:cursor-not-allowed" id="smtpFrom"
data-i18n-placeholder="settings.smtp_sender_placeholder" placeholder="noreply@swissmakers.ch" required />
</div>
<div class="flex items-center mb-4">
<input type="checkbox" id="smtpUseTLS" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out">
<input type="checkbox" id="smtpUseTLS" class="h-4 w-7 text-blue-600 transition duration-150 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed">
<label for="smtpUseTLS" class="ml-2 block text-sm text-gray-700" data-i18n="settings.smtp_tls">Use TLS (Recommended)</label>
</div>
<button type="button" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors" onclick="sendTestEmail()" data-i18n="settings.send_test_email">Send Test Email</button>
<button type="button" class="bg-gray-600 text-white px-4 py-2 rounded hover:bg-gray-700 transition-colors disabled:bg-gray-400 disabled:cursor-not-allowed" onclick="sendTestEmail()" id="sendTestEmailBtn" data-i18n="settings.send_test_email">Send Test Email</button>
</div>
<!-- Fail2Ban Configuration Group -->

View File

@@ -169,6 +169,25 @@ func (h *Hub) BroadcastBanEvent(event storage.BanEventRecord) {
}
}
// BroadcastUnbanEvent broadcasts an unban event to all connected clients
func (h *Hub) BroadcastUnbanEvent(event storage.BanEventRecord) {
message := map[string]interface{}{
"type": "unban_event",
"data": event,
}
data, err := json.Marshal(message)
if err != nil {
log.Printf("Error marshaling unban event: %v", err)
return
}
select {
case h.broadcast <- data:
default:
log.Printf("Broadcast channel full, dropping unban event")
}
}
// readPump pumps messages from the WebSocket connection to the hub
func (c *Client) readPump() {
defer func() {