initial push

This commit is contained in:
Michael Reber
2025-01-25 16:21:14 +01:00
parent ac4b39b966
commit 217312cdad
12 changed files with 1005 additions and 2 deletions

130
pkg/web/handlers.go Normal file
View File

@@ -0,0 +1,130 @@
package web
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/swissmakers/fail2ban-ui/internal/fail2ban"
)
// SummaryResponse is what we return from /api/summary
type SummaryResponse struct {
Jails []fail2ban.JailInfo `json:"jails"`
LastBans []fail2ban.BanEvent `json:"lastBans"`
}
// SummaryHandler returns a JSON summary of all jails, including
// number of banned IPs, how many are new in the last hour, etc.
// and the last 5 overall ban events from the log.
func SummaryHandler(c *gin.Context) {
const logPath = "/var/log/fail2ban.log"
jailInfos, err := fail2ban.BuildJailInfos(logPath)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Parse the log to find last 5 ban events
eventsByJail, err := fail2ban.ParseBanLog(logPath)
lastBans := make([]fail2ban.BanEvent, 0)
if err == nil {
// If we can parse logs successfully, let's gather all events
var all []fail2ban.BanEvent
for _, evs := range eventsByJail {
all = append(all, evs...)
}
// Sort by descending time
sortByTimeDesc(all)
if len(all) > 5 {
lastBans = all[:5]
} else {
lastBans = all
}
}
resp := SummaryResponse{
Jails: jailInfos,
LastBans: lastBans,
}
c.JSON(http.StatusOK, resp)
}
// UnbanIPHandler unbans a given IP in a specific jail.
func UnbanIPHandler(c *gin.Context) {
jail := c.Param("jail")
ip := c.Param("ip")
err := fail2ban.UnbanIP(jail, ip)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "IP unbanned successfully",
})
}
func sortByTimeDesc(events []fail2ban.BanEvent) {
for i := 0; i < len(events); i++ {
for j := i + 1; j < len(events); j++ {
if events[j].Time.After(events[i].Time) {
events[i], events[j] = events[j], events[i]
}
}
}
}
// IndexHandler serves the main HTML page
func IndexHandler(c *gin.Context) {
c.HTML(http.StatusOK, "index.html", gin.H{
"timestamp": time.Now().Format(time.RFC1123),
})
}
// GetJailConfigHandler returns the raw config for a given jail
func GetJailConfigHandler(c *gin.Context) {
jail := c.Param("jail")
cfg, err := fail2ban.GetJailConfig(jail)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"jail": jail,
"config": cfg,
})
}
// SetJailConfigHandler overwrites the jail config with new content
func SetJailConfigHandler(c *gin.Context) {
jail := c.Param("jail")
var req struct {
Config string `json:"config"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON body"})
return
}
if err := fail2ban.SetJailConfig(jail, req.Config); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "jail config updated"})
}
// ReloadFail2banHandler reloads the Fail2ban service
func ReloadFail2banHandler(c *gin.Context) {
err := fail2ban.ReloadFail2ban()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "Fail2ban reloaded successfully"})
}

24
pkg/web/routes.go Normal file
View File

@@ -0,0 +1,24 @@
package web
import (
"github.com/gin-gonic/gin"
)
// RegisterRoutes sets up the routes for the Fail2ban UI.
func RegisterRoutes(r *gin.Engine) {
// Render the dashboard
r.GET("/", IndexHandler)
api := r.Group("/api")
{
api.GET("/summary", SummaryHandler)
api.POST("/jails/:jail/unban/:ip", UnbanIPHandler)
// New config endpoints
api.GET("/jails/:jail/config", GetJailConfigHandler)
api.POST("/jails/:jail/config", SetJailConfigHandler)
// Reload endpoint
api.POST("/fail2ban/reload", ReloadFail2banHandler)
}
}

View File

@@ -0,0 +1,350 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<title>Fail2ban UI Dashboard</title>
<!-- Bootstrap 5 (CDN) -->
<link
href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css"
rel="stylesheet"
/>
<style>
/* Loading overlay styling */
#loading-overlay {
display: none; /* hidden by default */
position: fixed;
top: 0; left: 0;
width: 100%; height: 100%;
background: rgba(0,0,0,0.5);
z-index: 9999; /* on top */
align-items: center;
justify-content: center;
}
.spinner-border {
width: 4rem; height: 4rem;
}
/* Reload banner */
#reloadBanner {
display: none;
}
</style>
</head>
<body class="bg-light">
<!-- NavBar -->
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
<div class="container-fluid">
<a class="navbar-brand" href="#">
<strong>Fail2ban UI</strong>
</a>
</div>
</nav>
<!-- Reload Banner -->
<div id="reloadBanner" class="bg-warning text-dark p-3 text-center">
<strong>Configuration changed! </strong>
<button class="btn btn-dark" onclick="reloadFail2ban()">
Reload Fail2ban
</button>
</div>
<div class="container my-4">
<h1 class="mb-4">Dashboard</h1>
<div id="dashboard"></div>
</div>
<!-- Footer -->
<footer class="text-center mt-4 mb-4">
<p class="mb-0">
&copy; <a href="https://swissmakers.ch" target="_blank">Swissmakers GmbH</a>
-
<a href="https://github.com/swissmakers/fail2ban-ui" target="_blank">
GitHub
</a>
</p>
</footer>
<!-- Loading Overlay -->
<div id="loading-overlay" class="d-flex">
<div class="spinner-border text-light" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Jail Config Modal -->
<div class="modal fade" id="jailConfigModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
Filter Config: <span id="modalJailName"></span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"
aria-label="Close"></button>
</div>
<div class="modal-body">
<textarea id="jailConfigTextarea" class="form-control" rows="15"></textarea>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary"
data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveJailConfig()">
Save
</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap 5 JS (for modal, etc.) -->
<script
src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.bundle.min.js">
</script>
<script>
"use strict";
// We avoid ES6 backticks here to prevent confusion with the Go template parser.
var currentJailForConfig = null;
// Toggle the loading overlay (with !important)
function showLoading(show) {
var overlay = document.getElementById('loading-overlay');
if (show) {
overlay.style.setProperty('display', 'flex', 'important');
} else {
overlay.style.setProperty('display', 'none', 'important');
}
}
window.addEventListener('DOMContentLoaded', function() {
showLoading(true);
fetchSummary().then(function() {
showLoading(false);
});
});
// Fetch summary (jails, stats, last 5 bans)
function fetchSummary() {
return fetch('/api/summary')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
document.getElementById('dashboard').innerHTML =
'<div class="alert alert-danger">' + data.error + '</div>';
return;
}
renderDashboard(data);
})
.catch(function(err) {
document.getElementById('dashboard').innerHTML =
'<div class="alert alert-danger">Error: ' + err + '</div>';
});
}
// Render the main dashboard
function renderDashboard(data) {
var html = "";
// Jails table
if (!data.jails || data.jails.length === 0) {
html += '<p>No jails found.</p>';
} else {
html += ''
+ '<h2>Overview</h2>'
+ '<table class="table table-striped">'
+ ' <thead>'
+ ' <tr>'
+ ' <th>Jail Name</th>'
+ ' <th>Total Banned</th>'
+ ' <th>New in Last Hour</th>'
+ ' <th>Banned IPs (Unban)</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody>';
data.jails.forEach(function(jail) {
var bannedHTML = renderBannedIPs(jail.jailName, jail.bannedIPs);
html += ''
+ '<tr>'
+ ' <td>'
+ ' <a href="#" onclick="openJailConfigModal(\'' + jail.jailName + '\')">'
+ jail.jailName
+ ' </a>'
+ ' </td>'
+ ' <td>' + jail.totalBanned + '</td>'
+ ' <td>' + jail.newInLastHour + '</td>'
+ ' <td>' + bannedHTML + '</td>'
+ '</tr>';
});
html += '</tbody></table>';
}
// Last 5 bans
html += '<h2>Last 5 Ban Events</h2>';
if (!data.lastBans || data.lastBans.length === 0) {
html += '<p>No recent bans found.</p>';
} else {
html += ''
+ '<table class="table table-bordered">'
+ ' <thead>'
+ ' <tr>'
+ ' <th>Time</th>'
+ ' <th>Jail</th>'
+ ' <th>IP</th>'
+ ' <th>Log Line</th>'
+ ' </tr>'
+ ' </thead>'
+ ' <tbody>';
data.lastBans.forEach(function(e) {
html += ''
+ '<tr>'
+ ' <td>' + e.Time + '</td>'
+ ' <td>' + e.Jail + '</td>'
+ ' <td>' + e.IP + '</td>'
+ ' <td>' + e.LogLine + '</td>'
+ '</tr>';
});
html += '</tbody></table>';
}
document.getElementById('dashboard').innerHTML = html;
}
// Render banned IPs with "Unban" button
function renderBannedIPs(jailName, ips) {
if (!ips || ips.length === 0) {
return '<em>No banned IPs</em>';
}
var content = '<ul class="list-unstyled mb-0">';
ips.forEach(function(ip) {
content += ''
+ '<li class="d-flex align-items-center mb-1">'
+ ' <span class="me-auto">' + ip + '</span>'
+ ' <button class="btn btn-sm btn-warning"'
+ ' onclick="unbanIP(\'' + jailName + '\', \'' + ip + '\')">'
+ ' Unban'
+ ' </button>'
+ '</li>';
});
content += '</ul>';
return content;
}
// Unban IP
function unbanIP(jail, ip) {
if (!confirm("Unban IP " + ip + " from jail " + jail + "?")) {
return;
}
showLoading(true);
fetch('/api/jails/' + jail + '/unban/' + ip, { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
alert("Error: " + data.error);
} else {
alert(data.message || "IP unbanned");
}
return fetchSummary();
})
.catch(function(err) {
alert("Error: " + err);
})
.finally(function() {
showLoading(false);
});
}
// Open the jail config modal
function openJailConfigModal(jailName) {
currentJailForConfig = jailName;
var textArea = document.getElementById('jailConfigTextarea');
textArea.value = '';
document.getElementById('modalJailName').textContent = jailName;
showLoading(true);
fetch('/api/jails/' + jailName + '/config')
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
alert("Error loading config: " + data.error);
} else {
textArea.value = data.config;
var modalEl = document.getElementById('jailConfigModal');
var myModal = new bootstrap.Modal(modalEl);
myModal.show();
}
})
.catch(function(err) {
alert("Error: " + err);
})
.finally(function() {
showLoading(false);
});
}
// Save jail config
function saveJailConfig() {
if (!currentJailForConfig) return;
showLoading(true);
var newConfig = document.getElementById('jailConfigTextarea').value;
fetch('/api/jails/' + currentJailForConfig + '/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: newConfig }),
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
alert("Error saving config: " + data.error);
} else {
alert(data.message || "Config saved");
// Hide modal
var modalEl = document.getElementById('jailConfigModal');
var modalObj = bootstrap.Modal.getInstance(modalEl);
modalObj.hide();
// Show the reload banner
document.getElementById('reloadBanner').style.display = 'block';
}
})
.catch(function(err) {
alert("Error: " + err);
})
.finally(function() {
showLoading(false);
});
}
// Reload Fail2ban
function reloadFail2ban() {
if (!confirm("Reload Fail2ban now?")) return;
showLoading(true);
fetch('/api/fail2ban/reload', { method: 'POST' })
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.error) {
alert("Error: " + data.error);
} else {
alert(data.message || "Fail2ban reloaded");
// Hide reload banner
document.getElementById('reloadBanner').style.display = 'none';
// Refresh data
return fetchSummary();
}
})
.catch(function(err) {
alert("Error: " + err);
})
.finally(function() {
showLoading(false);
});
}
</script>
</body>
</html>