mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-17 05:53:15 +02:00
Implementing WebSocked Support for immediately ban-messages
This commit is contained in:
@@ -73,8 +73,12 @@ func main() {
|
|||||||
router.Static("/static", "./pkg/web/static")
|
router.Static("/static", "./pkg/web/static")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize WebSocket hub
|
||||||
|
wsHub := web.NewHub()
|
||||||
|
go wsHub.Run()
|
||||||
|
|
||||||
// Register all application routes, including the static files and templates.
|
// Register all application routes, including the static files and templates.
|
||||||
web.RegisterRoutes(router)
|
web.RegisterRoutes(router, wsHub)
|
||||||
|
|
||||||
// Check if LOTR mode is active
|
// Check if LOTR mode is active
|
||||||
isLOTRMode := isLOTRModeActive(settings.AlertCountries)
|
isLOTRMode := isLOTRModeActive(settings.AlertCountries)
|
||||||
|
|||||||
1
go.mod
1
go.mod
@@ -21,6 +21,7 @@ require (
|
|||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -34,6 +34,8 @@ github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlG
|
|||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/python3.9
|
|
||||||
"""
|
|
||||||
Fail2ban UI - A Swiss made, management interface for Fail2ban.
|
|
||||||
|
|
||||||
Copyright (C) 2025 Swissmakers GmbH
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# This file is for testing purposes only.
|
|
||||||
# (Must be copied to "/etc/fail2ban/action.d/geoip_notify.py")
|
|
||||||
#python3.9 -c "import maxminddb; print('maxminddb is installed successfully')"
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
# Manually set Python path where maxminddb is installed
|
|
||||||
sys.path.append("/usr/local/lib64/python3.9/site-packages/")
|
|
||||||
|
|
||||||
try:
|
|
||||||
import maxminddb
|
|
||||||
except ImportError:
|
|
||||||
print("Error: maxminddb module not found, even after modifying PYTHONPATH.")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
|
|
||||||
# Path to MaxMind GeoIP2 database
|
|
||||||
GEOIP_DB_PATH = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
|
|
||||||
|
|
||||||
def get_country(ip):
|
|
||||||
"""
|
|
||||||
Perform a GeoIP lookup to get the country code from an IP address.
|
|
||||||
Returns the country code (e.g., "CH") or None if lookup fails.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
with maxminddb.open_database(GEOIP_DB_PATH) as reader:
|
|
||||||
geo_data = reader.get(ip)
|
|
||||||
if geo_data and "country" in geo_data and "iso_code" in geo_data["country"]:
|
|
||||||
return geo_data["country"]["iso_code"]
|
|
||||||
except Exception as e:
|
|
||||||
print(f"GeoIP lookup failed: {e}", file=sys.stderr)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def parse_placeholders(placeholder_str):
|
|
||||||
"""
|
|
||||||
Parses Fail2Ban placeholders passed as a string in "key=value" format.
|
|
||||||
Returns a dictionary.
|
|
||||||
"""
|
|
||||||
placeholders = {}
|
|
||||||
for item in placeholder_str.split(";"):
|
|
||||||
key_value = item.split("=", 1)
|
|
||||||
if len(key_value) == 2:
|
|
||||||
key, value = key_value
|
|
||||||
placeholders[key.strip()] = value.strip()
|
|
||||||
return placeholders
|
|
||||||
|
|
||||||
def send_email(placeholders):
|
|
||||||
"""
|
|
||||||
Generates and sends the email alert using sendmail.
|
|
||||||
"""
|
|
||||||
email_content = f"""Subject: [Fail2Ban] {placeholders['name']}: banned {placeholders['ip']} from {placeholders['fq-hostname']}
|
|
||||||
Date: $(LC_ALL=C date +"%a, %d %h %Y %T %z")
|
|
||||||
From: {placeholders['sendername']} <{placeholders['sender']}>
|
|
||||||
To: {placeholders['dest']}
|
|
||||||
|
|
||||||
Hi,
|
|
||||||
|
|
||||||
The IP {placeholders['ip']} has just been banned by Fail2Ban after {placeholders['failures']} attempts against {placeholders['name']}.
|
|
||||||
|
|
||||||
Here is more information about {placeholders['ip']}:
|
|
||||||
{subprocess.getoutput(placeholders['_whois_command'])}
|
|
||||||
|
|
||||||
Lines containing failures of {placeholders['ip']} (max {placeholders['grepmax']}):
|
|
||||||
{subprocess.getoutput(placeholders['_grep_logs'])}
|
|
||||||
|
|
||||||
Regards,
|
|
||||||
Fail2Ban"""
|
|
||||||
|
|
||||||
try:
|
|
||||||
subprocess.run(
|
|
||||||
["/usr/sbin/sendmail", "-f", placeholders["sender"], placeholders["dest"]],
|
|
||||||
input=email_content,
|
|
||||||
text=True,
|
|
||||||
check=True
|
|
||||||
)
|
|
||||||
print("Email sent successfully.")
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
print(f"Failed to send email: {e}", file=sys.stderr)
|
|
||||||
|
|
||||||
def main(ip, allowed_countries, placeholder_str):
|
|
||||||
"""
|
|
||||||
Main function to check the IP's country and send an email if it matches the allowed list.
|
|
||||||
"""
|
|
||||||
allowed_countries = allowed_countries.split(",")
|
|
||||||
placeholders = parse_placeholders(placeholder_str)
|
|
||||||
|
|
||||||
# Perform GeoIP lookup
|
|
||||||
country = get_country(ip)
|
|
||||||
if not country:
|
|
||||||
print(f"Could not determine country for IP {ip}", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
print(f"IP {ip} belongs to country: {country}")
|
|
||||||
|
|
||||||
# If the country is in the allowed list or "ALL" is selected, send the email
|
|
||||||
if "ALL" in allowed_countries or country in allowed_countries:
|
|
||||||
print(f"IP {ip} is in the alert countries list. Sending email...")
|
|
||||||
send_email(placeholders)
|
|
||||||
else:
|
|
||||||
print(f"IP {ip} is NOT in the alert countries list. No email sent.")
|
|
||||||
sys.exit(0) # Exit normally without error
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 4:
|
|
||||||
print("Usage: geoip_notify.py <ip> <allowed_countries> <placeholders>", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
ip = sys.argv[1]
|
|
||||||
allowed_countries = sys.argv[2]
|
|
||||||
placeholders = sys.argv[3]
|
|
||||||
|
|
||||||
main(ip, allowed_countries, placeholders)
|
|
||||||
@@ -451,7 +451,7 @@ INSERT INTO ban_events (
|
|||||||
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
server_id, server_name, jail, ip, country, hostname, failures, whois, logs, occurred_at, created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
|
||||||
_, err := db.ExecContext(
|
result, err := db.ExecContext(
|
||||||
ctx,
|
ctx,
|
||||||
query,
|
query,
|
||||||
record.ServerID,
|
record.ServerID,
|
||||||
@@ -466,7 +466,17 @@ INSERT INTO ban_events (
|
|||||||
record.OccurredAt.UTC(),
|
record.OccurredAt.UTC(),
|
||||||
record.CreatedAt.UTC(),
|
record.CreatedAt.UTC(),
|
||||||
)
|
)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the inserted ID
|
||||||
|
id, err := result.LastInsertId()
|
||||||
|
if err == nil {
|
||||||
|
record.ID = id
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListBanEvents returns ban events ordered by creation date descending.
|
// ListBanEvents returns ban events ordered by creation date descending.
|
||||||
|
|||||||
@@ -46,6 +46,14 @@ import (
|
|||||||
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
"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
|
// SummaryResponse is what we return from /api/summary
|
||||||
type SummaryResponse struct {
|
type SummaryResponse struct {
|
||||||
Jails []fail2ban.JailInfo `json:"jails"`
|
Jails []fail2ban.JailInfo `json:"jails"`
|
||||||
@@ -603,6 +611,11 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
|
|||||||
log.Printf("⚠️ Failed to record ban event: %v", err)
|
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)
|
evaluateAdvancedActions(ctx, settings, server, ip)
|
||||||
|
|
||||||
// Check if country is in alert list
|
// Check if country is in alert list
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// RegisterRoutes sets up the routes for the Fail2ban UI.
|
// RegisterRoutes sets up the routes for the Fail2ban UI.
|
||||||
func RegisterRoutes(r *gin.Engine) {
|
func RegisterRoutes(r *gin.Engine, hub *Hub) {
|
||||||
|
// Set the global WebSocket hub
|
||||||
|
SetWebSocketHub(hub)
|
||||||
|
|
||||||
// Render the dashboard
|
// Render the dashboard
|
||||||
r.GET("/", IndexHandler)
|
r.GET("/", IndexHandler)
|
||||||
@@ -72,5 +74,8 @@ func RegisterRoutes(r *gin.Engine) {
|
|||||||
api.GET("/events/bans", ListBanEventsHandler)
|
api.GET("/events/bans", ListBanEventsHandler)
|
||||||
api.GET("/events/bans/stats", BanStatisticsHandler)
|
api.GET("/events/bans/stats", BanStatisticsHandler)
|
||||||
api.GET("/events/bans/insights", BanInsightsHandler)
|
api.GET("/events/bans/insights", BanInsightsHandler)
|
||||||
|
|
||||||
|
// WebSocket endpoint
|
||||||
|
api.GET("/ws", WebSocketHandler(hub))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,148 @@ mark {
|
|||||||
background-color: #d97706;
|
background-color: #d97706;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-ban-event {
|
||||||
|
background-color: #7f1d1d;
|
||||||
|
pointer-events: auto;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-ban-event:hover {
|
||||||
|
background-color: #991b1b;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 15px 20px -3px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Backend Status Indicator */
|
||||||
|
#backendStatus {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
/* background-color: rgba(255, 255, 255, 0.1); */
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#backendStatus:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusDot {
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-block;
|
||||||
|
transition: background-color 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||||
|
animation: pulse 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusDot.bg-green-500 {
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||||
|
animation: pulseGreen 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusDot.bg-yellow-500 {
|
||||||
|
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.7);
|
||||||
|
animation: pulseYellow 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusDot.bg-red-500 {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||||
|
animation: pulseRed 2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusDot.bg-gray-400 {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusText {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseGreen {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(34, 197, 94, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseYellow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(234, 179, 8, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(234, 179, 8, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulseRed {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 4px rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clock Display */
|
||||||
|
#clockDisplay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockDisplay:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockTime {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: rgba(255, 255, 255, 0.95);
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#backendStatus {
|
||||||
|
padding: 0.125rem 0.375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#statusText {
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockDisplay {
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#clockTime {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#advancedMikrotikFields, #advancedPfSenseFields {
|
#advancedMikrotikFields, #advancedPfSenseFields {
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,13 @@ function showLoading(show) {
|
|||||||
function showToast(message, type) {
|
function showToast(message, type) {
|
||||||
var container = document.getElementById('toast-container');
|
var container = document.getElementById('toast-container');
|
||||||
if (!container || !message) return;
|
if (!container || !message) return;
|
||||||
|
|
||||||
|
// Handle ban event objects
|
||||||
|
if (typeof message === 'object' && message.type === 'ban_event') {
|
||||||
|
showBanEventToast(message.data || message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var toast = document.createElement('div');
|
var toast = document.createElement('div');
|
||||||
var variant = type || 'info';
|
var variant = type || 'info';
|
||||||
toast.className = 'toast toast-' + variant;
|
toast.className = 'toast toast-' + variant;
|
||||||
@@ -34,6 +41,60 @@ function showToast(message, type) {
|
|||||||
}, 5000);
|
}, 5000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show toast for ban event
|
||||||
|
function showBanEventToast(event) {
|
||||||
|
var container = document.getElementById('toast-container');
|
||||||
|
if (!container || !event) return;
|
||||||
|
|
||||||
|
var toast = document.createElement('div');
|
||||||
|
toast.className = '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';
|
||||||
|
|
||||||
|
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>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div class="flex-1 min-w-0">'
|
||||||
|
+ ' <div class="font-semibold text-sm">New Block Detected</div>'
|
||||||
|
+ ' <div class="text-sm mt-1">'
|
||||||
|
+ ' <span class="font-mono font-semibold">' + escapeHtml(ip) + '</span>'
|
||||||
|
+ ' <span class="text-gray-500"> banned in </span>'
|
||||||
|
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' <div class="text-xs text-gray-400 mt-1">'
|
||||||
|
+ ' ' + escapeHtml(server) + ' • ' + escapeHtml(country)
|
||||||
|
+ ' </div>'
|
||||||
|
+ ' </div>'
|
||||||
|
+ '</div>';
|
||||||
|
|
||||||
|
// Add click handler to scroll to ban events table
|
||||||
|
toast.addEventListener('click', function() {
|
||||||
|
var logSection = document.getElementById('logOverviewSection');
|
||||||
|
if (logSection) {
|
||||||
|
logSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.style.cursor = 'pointer';
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(function() {
|
||||||
|
toast.classList.add('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(function() {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
setTimeout(function() {
|
||||||
|
toast.remove();
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Escape HTML to prevent XSS
|
// Escape HTML to prevent XSS
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
if (value === undefined || value === null) return '';
|
if (value === undefined || value === null) return '';
|
||||||
|
|||||||
@@ -74,6 +74,10 @@ function fetchBanEventsData() {
|
|||||||
.then(function(res) { return res.json(); })
|
.then(function(res) { return res.json(); })
|
||||||
.then(function(data) {
|
.then(function(data) {
|
||||||
latestBanEvents = data && data.events ? data.events : [];
|
latestBanEvents = data && data.events ? data.events : [];
|
||||||
|
// Track the last event ID to prevent duplicates from WebSocket
|
||||||
|
if (latestBanEvents.length > 0 && wsManager) {
|
||||||
|
wsManager.lastBanEventId = latestBanEvents[0].id;
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function(err) {
|
||||||
console.error('Error fetching ban events:', err);
|
console.error('Error fetching ban events:', err);
|
||||||
@@ -81,6 +85,72 @@ function fetchBanEventsData() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add new ban event from WebSocket
|
||||||
|
function addBanEventFromWebSocket(event) {
|
||||||
|
// Check if event already exists (prevent duplicates)
|
||||||
|
// Only check by ID if both events have IDs
|
||||||
|
var exists = false;
|
||||||
|
if (event.id) {
|
||||||
|
exists = latestBanEvents.some(function(e) {
|
||||||
|
return e.id === event.id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// If no ID, check by IP, jail, and occurredAt timestamp
|
||||||
|
exists = latestBanEvents.some(function(e) {
|
||||||
|
return e.ip === event.ip &&
|
||||||
|
e.jail === event.jail &&
|
||||||
|
e.occurredAt === event.occurredAt;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
console.log('Adding new ban event from WebSocket:', event);
|
||||||
|
|
||||||
|
// Prepend to the beginning of the array
|
||||||
|
latestBanEvents.unshift(event);
|
||||||
|
// Keep only the last 200 events
|
||||||
|
if (latestBanEvents.length > 200) {
|
||||||
|
latestBanEvents = latestBanEvents.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show toast notification first
|
||||||
|
if (typeof showBanEventToast === 'function') {
|
||||||
|
showBanEventToast(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh dashboard data (summary, stats, insights) and re-render
|
||||||
|
refreshDashboardData();
|
||||||
|
} else {
|
||||||
|
console.log('Skipping duplicate ban event:', event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh dashboard data when new ban event arrives via WebSocket
|
||||||
|
function refreshDashboardData() {
|
||||||
|
// Refresh ban statistics and insights in the background
|
||||||
|
// Also refresh summary if we have a server selected
|
||||||
|
var enabledServers = serversCache.filter(function(s) { return s.enabled; });
|
||||||
|
var summaryPromise;
|
||||||
|
if (serversCache.length && enabledServers.length && currentServerId) {
|
||||||
|
summaryPromise = fetchSummaryData();
|
||||||
|
} else {
|
||||||
|
summaryPromise = Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
summaryPromise,
|
||||||
|
fetchBanStatisticsData(),
|
||||||
|
fetchBanInsightsData()
|
||||||
|
]).then(function() {
|
||||||
|
// Re-render the dashboard to show updated stats
|
||||||
|
renderDashboard();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Error refreshing dashboard data:', err);
|
||||||
|
// Still re-render even if refresh fails
|
||||||
|
renderDashboard();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function fetchBanInsightsData() {
|
function fetchBanInsightsData() {
|
||||||
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
|
var sevenDaysAgo = new Date(Date.now() - (7 * 24 * 60 * 60 * 1000)).toISOString();
|
||||||
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);
|
var sinceQuery = '?since=' + encodeURIComponent(sevenDaysAgo);
|
||||||
|
|||||||
102
pkg/web/static/js/header.js
Normal file
102
pkg/web/static/js/header.js
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Header components: Clock and Backend Status Indicator
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var clockInterval = null;
|
||||||
|
var statusUpdateCallback = null;
|
||||||
|
|
||||||
|
// Initialize clock
|
||||||
|
function initClock() {
|
||||||
|
function updateClock() {
|
||||||
|
var now = new Date();
|
||||||
|
var hours = String(now.getHours()).padStart(2, '0');
|
||||||
|
var minutes = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
var seconds = String(now.getSeconds()).padStart(2, '0');
|
||||||
|
var timeString = hours + ':' + minutes + ':' + seconds;
|
||||||
|
|
||||||
|
var clockElement = document.getElementById('clockTime');
|
||||||
|
if (clockElement) {
|
||||||
|
clockElement.textContent = timeString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update immediately
|
||||||
|
updateClock();
|
||||||
|
|
||||||
|
// Update every second
|
||||||
|
if (clockInterval) {
|
||||||
|
clearInterval(clockInterval);
|
||||||
|
}
|
||||||
|
clockInterval = setInterval(updateClock, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update status indicator
|
||||||
|
function updateStatusIndicator(state, text) {
|
||||||
|
var statusDot = document.getElementById('statusDot');
|
||||||
|
var statusText = document.getElementById('statusText');
|
||||||
|
|
||||||
|
if (!statusDot || !statusText) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all color classes
|
||||||
|
statusDot.classList.remove('bg-green-500', 'bg-yellow-500', 'bg-red-500', 'bg-gray-400');
|
||||||
|
|
||||||
|
// Set color and text based on state
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
statusDot.classList.add('bg-green-500');
|
||||||
|
statusText.textContent = text || 'Connected';
|
||||||
|
break;
|
||||||
|
case 'connecting':
|
||||||
|
case 'reconnecting':
|
||||||
|
statusDot.classList.add('bg-yellow-500');
|
||||||
|
statusText.textContent = text || 'Connecting...';
|
||||||
|
break;
|
||||||
|
case 'disconnected':
|
||||||
|
case 'error':
|
||||||
|
statusDot.classList.add('bg-red-500');
|
||||||
|
statusText.textContent = text || 'Disconnected';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
statusDot.classList.add('bg-gray-400');
|
||||||
|
statusText.textContent = text || 'Unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize status indicator
|
||||||
|
function initStatusIndicator() {
|
||||||
|
// Set initial state
|
||||||
|
updateStatusIndicator('connecting', 'Connecting...');
|
||||||
|
|
||||||
|
// Register callback with WebSocket manager when available
|
||||||
|
if (typeof wsManager !== 'undefined' && wsManager) {
|
||||||
|
wsManager.onStatusChange(function(state, text) {
|
||||||
|
updateStatusIndicator(state, text);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Wait for WebSocket manager to be available
|
||||||
|
var checkInterval = setInterval(function() {
|
||||||
|
if (typeof wsManager !== 'undefined' && wsManager) {
|
||||||
|
wsManager.onStatusChange(function(state, text) {
|
||||||
|
updateStatusIndicator(state, text);
|
||||||
|
});
|
||||||
|
clearInterval(checkInterval);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize all header components
|
||||||
|
function initHeader() {
|
||||||
|
initClock();
|
||||||
|
initStatusIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (clockInterval) {
|
||||||
|
clearInterval(clockInterval);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,6 +5,38 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||||||
showLoading(true);
|
showLoading(true);
|
||||||
displayExternalIP();
|
displayExternalIP();
|
||||||
|
|
||||||
|
// Initialize header components (clock and status indicator)
|
||||||
|
if (typeof initHeader === 'function') {
|
||||||
|
initHeader();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize WebSocket connection and register ban event handler
|
||||||
|
function registerBanEventHandler() {
|
||||||
|
if (typeof wsManager !== 'undefined' && wsManager) {
|
||||||
|
wsManager.onBanEvent(function(event) {
|
||||||
|
if (typeof addBanEventFromWebSocket === 'function') {
|
||||||
|
addBanEventFromWebSocket(event);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!registerBanEventHandler()) {
|
||||||
|
// Wait for WebSocket manager to be available
|
||||||
|
var wsCheckInterval = setInterval(function() {
|
||||||
|
if (registerBanEventHandler()) {
|
||||||
|
clearInterval(wsCheckInterval);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Stop checking after 5 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
clearInterval(wsCheckInterval);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
// Check LOTR mode on page load to apply immediately
|
// Check LOTR mode on page load to apply immediately
|
||||||
fetch('/api/settings')
|
fetch('/api/settings')
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
@@ -114,4 +146,3 @@ window.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
205
pkg/web/static/js/websocket.js
Normal file
205
pkg/web/static/js/websocket.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// WebSocket Manager for Fail2ban UI
|
||||||
|
// Handles real-time communication with the backend
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
class WebSocketManager {
|
||||||
|
constructor() {
|
||||||
|
this.ws = null;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.maxReconnectAttempts = Infinity;
|
||||||
|
this.reconnectDelay = 1000; // Start with 1 second
|
||||||
|
this.maxReconnectDelay = 30000; // Max 30 seconds
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.lastBanEventId = null;
|
||||||
|
this.statusCallbacks = [];
|
||||||
|
this.banEventCallbacks = [];
|
||||||
|
|
||||||
|
// Get WebSocket URL
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const host = window.location.host;
|
||||||
|
this.wsUrl = `${protocol}//${host}/api/ws`;
|
||||||
|
|
||||||
|
this.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
if (this.isConnecting || (this.ws && this.ws.readyState === WebSocket.OPEN)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isConnecting = true;
|
||||||
|
this.updateStatus('connecting', 'Connecting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.ws = new WebSocket(this.wsUrl);
|
||||||
|
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.isConnected = true;
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.reconnectDelay = 1000;
|
||||||
|
this.updateStatus('connected', 'Connected');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
this.handleMessage(message);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error parsing WebSocket message:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
this.updateStatus('error', 'Connection error');
|
||||||
|
};
|
||||||
|
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.isConnected = false;
|
||||||
|
this.updateStatus('disconnected', 'Disconnected');
|
||||||
|
console.log('WebSocket disconnected');
|
||||||
|
|
||||||
|
// Attempt to reconnect
|
||||||
|
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating WebSocket connection:', error);
|
||||||
|
this.isConnecting = false;
|
||||||
|
this.updateStatus('error', 'Connection failed');
|
||||||
|
this.scheduleReconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleReconnect() {
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
const delay = Math.min(this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1), this.maxReconnectDelay);
|
||||||
|
|
||||||
|
this.updateStatus('reconnecting', 'Reconnecting...');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
this.connect();
|
||||||
|
}, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleMessage(message) {
|
||||||
|
switch (message.type) {
|
||||||
|
case 'ban_event':
|
||||||
|
this.handleBanEvent(message.data);
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
this.handleHeartbeat(message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('Unknown message type:', message.type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleBanEvent(eventData) {
|
||||||
|
// Check if we've already processed this event (prevent duplicates)
|
||||||
|
// Only check if event has an ID and we have a lastBanEventId
|
||||||
|
if (eventData.id && this.lastBanEventId !== null && eventData.id <= this.lastBanEventId) {
|
||||||
|
console.log('Skipping duplicate ban event:', eventData.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update lastBanEventId if event has an ID
|
||||||
|
if (eventData.id) {
|
||||||
|
if (this.lastBanEventId === null || eventData.id > this.lastBanEventId) {
|
||||||
|
this.lastBanEventId = eventData.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Processing ban event:', eventData);
|
||||||
|
|
||||||
|
// Notify all registered callbacks
|
||||||
|
this.banEventCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(eventData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in ban event callback:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleHeartbeat(message) {
|
||||||
|
// Update status to show backend is healthy
|
||||||
|
if (this.isConnected) {
|
||||||
|
this.updateStatus('connected', 'Connected');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(state, text) {
|
||||||
|
this.statusCallbacks.forEach(callback => {
|
||||||
|
try {
|
||||||
|
callback(state, text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error in status callback:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onStatusChange(callback) {
|
||||||
|
this.statusCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
onBanEvent(callback) {
|
||||||
|
this.banEventCallbacks.push(callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.ws) {
|
||||||
|
this.ws.close();
|
||||||
|
this.ws = null;
|
||||||
|
}
|
||||||
|
this.isConnected = false;
|
||||||
|
this.isConnecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getConnectionState() {
|
||||||
|
if (!this.ws) {
|
||||||
|
return 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (this.ws.readyState) {
|
||||||
|
case WebSocket.CONNECTING:
|
||||||
|
return 'connecting';
|
||||||
|
case WebSocket.OPEN:
|
||||||
|
return 'connected';
|
||||||
|
case WebSocket.CLOSING:
|
||||||
|
return 'disconnecting';
|
||||||
|
case WebSocket.CLOSED:
|
||||||
|
return 'disconnected';
|
||||||
|
default:
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy() {
|
||||||
|
return this.isConnected && this.ws && this.ws.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global instance - initialize immediately
|
||||||
|
var wsManager = null;
|
||||||
|
|
||||||
|
// Initialize WebSocket manager
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
// Initialize immediately if DOM is already loaded, otherwise wait
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
if (!wsManager) {
|
||||||
|
wsManager = new WebSocketManager();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
wsManager = new WebSocketManager();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -75,12 +75,19 @@
|
|||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<span class="text-xl font-bold">Fail2ban UI</span>
|
<span class="text-xl font-bold">Fail2ban UI</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="backendStatus" class="ml-4 flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400" id="statusDot"></span>
|
||||||
|
<span id="statusText" class="text-xs">Connecting...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hidden md:block">
|
<div class="hidden md:block">
|
||||||
<div class="ml-10 flex items-baseline space-x-4">
|
<div class="ml-10 flex items-baseline space-x-4 items-center">
|
||||||
<a href="#" onclick="showSection('dashboardSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
|
<a href="#" onclick="showSection('dashboardSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.dashboard">Dashboard</a>
|
||||||
<a href="#" onclick="showSection('filterSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
|
<a href="#" onclick="showSection('filterSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.filter_debug">Filter Debug</a>
|
||||||
<a href="#" onclick="showSection('settingsSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
|
<a href="#" onclick="showSection('settingsSection')" class="px-3 py-2 rounded-md text-sm font-medium hover:bg-blue-700 transition-colors" data-i18n="nav.settings">Settings</a>
|
||||||
|
<div id="clockDisplay" class="ml-4 text-sm font-mono">
|
||||||
|
<span id="clockTime">--:--:--</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="md:hidden">
|
<div class="md:hidden">
|
||||||
@@ -1163,6 +1170,8 @@
|
|||||||
<script src="/static/js/jails.js"></script>
|
<script src="/static/js/jails.js"></script>
|
||||||
<script src="/static/js/settings.js"></script>
|
<script src="/static/js/settings.js"></script>
|
||||||
<script src="/static/js/filters.js"></script>
|
<script src="/static/js/filters.js"></script>
|
||||||
|
<script src="/static/js/websocket.js"></script>
|
||||||
|
<script src="/static/js/header.js"></script>
|
||||||
<script src="/static/js/lotr.js"></script>
|
<script src="/static/js/lotr.js"></script>
|
||||||
<script src="/static/js/init.js"></script>
|
<script src="/static/js/init.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
268
pkg/web/websocket.go
Normal file
268
pkg/web/websocket.go
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
// 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 "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 (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
"github.com/swissmakers/fail2ban-ui/internal/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Time allowed to write a message to the peer
|
||||||
|
writeWait = 10 * time.Second
|
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer
|
||||||
|
pongWait = 60 * time.Second
|
||||||
|
|
||||||
|
// Send pings to peer with this period (must be less than pongWait)
|
||||||
|
pingPeriod = (pongWait * 9) / 10
|
||||||
|
|
||||||
|
// Maximum message size allowed from peer
|
||||||
|
maxMessageSize = 512
|
||||||
|
)
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 1024,
|
||||||
|
WriteBufferSize: 1024,
|
||||||
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
|
// Allow all origins for now - can be restricted in production
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client represents a WebSocket connection
|
||||||
|
type Client struct {
|
||||||
|
hub *Hub
|
||||||
|
conn *websocket.Conn
|
||||||
|
send chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hub maintains the set of active clients and broadcasts messages to them
|
||||||
|
type Hub struct {
|
||||||
|
// Registered clients
|
||||||
|
clients map[*Client]bool
|
||||||
|
|
||||||
|
// Inbound messages from clients
|
||||||
|
broadcast chan []byte
|
||||||
|
|
||||||
|
// Register requests from clients
|
||||||
|
register chan *Client
|
||||||
|
|
||||||
|
// Unregister requests from clients
|
||||||
|
unregister chan *Client
|
||||||
|
|
||||||
|
// Mutex for thread-safe operations
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHub creates a new WebSocket hub
|
||||||
|
func NewHub() *Hub {
|
||||||
|
return &Hub{
|
||||||
|
clients: make(map[*Client]bool),
|
||||||
|
broadcast: make(chan []byte, 256),
|
||||||
|
register: make(chan *Client),
|
||||||
|
unregister: make(chan *Client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the hub's main loop
|
||||||
|
func (h *Hub) Run() {
|
||||||
|
// Start heartbeat ticker
|
||||||
|
ticker := time.NewTicker(30 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case client := <-h.register:
|
||||||
|
h.mu.Lock()
|
||||||
|
h.clients[client] = true
|
||||||
|
h.mu.Unlock()
|
||||||
|
log.Printf("WebSocket client connected. Total clients: %d", len(h.clients))
|
||||||
|
|
||||||
|
case client := <-h.unregister:
|
||||||
|
h.mu.Lock()
|
||||||
|
if _, ok := h.clients[client]; ok {
|
||||||
|
delete(h.clients, client)
|
||||||
|
close(client.send)
|
||||||
|
}
|
||||||
|
h.mu.Unlock()
|
||||||
|
log.Printf("WebSocket client disconnected. Total clients: %d", len(h.clients))
|
||||||
|
|
||||||
|
case message := <-h.broadcast:
|
||||||
|
h.mu.RLock()
|
||||||
|
for client := range h.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- message:
|
||||||
|
default:
|
||||||
|
close(client.send)
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
// Send heartbeat to all clients
|
||||||
|
h.sendHeartbeat()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHeartbeat sends a heartbeat message to all connected clients
|
||||||
|
func (h *Hub) sendHeartbeat() {
|
||||||
|
message := map[string]interface{}{
|
||||||
|
"type": "heartbeat",
|
||||||
|
"time": time.Now().UTC().Unix(),
|
||||||
|
"status": "healthy",
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error marshaling heartbeat: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.mu.RLock()
|
||||||
|
for client := range h.clients {
|
||||||
|
select {
|
||||||
|
case client.send <- data:
|
||||||
|
default:
|
||||||
|
close(client.send)
|
||||||
|
delete(h.clients, client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.mu.RUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// BroadcastBanEvent broadcasts a ban event to all connected clients
|
||||||
|
func (h *Hub) BroadcastBanEvent(event storage.BanEventRecord) {
|
||||||
|
message := map[string]interface{}{
|
||||||
|
"type": "ban_event",
|
||||||
|
"data": event,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(message)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error marshaling ban event: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case h.broadcast <- data:
|
||||||
|
default:
|
||||||
|
log.Printf("Broadcast channel full, dropping ban event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPump pumps messages from the WebSocket connection to the hub
|
||||||
|
func (c *Client) readPump() {
|
||||||
|
defer func() {
|
||||||
|
c.hub.unregister <- c
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
c.conn.SetPongHandler(func(string) error {
|
||||||
|
c.conn.SetReadDeadline(time.Now().Add(pongWait))
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
for {
|
||||||
|
_, _, err := c.conn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
||||||
|
log.Printf("WebSocket error: %v", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePump pumps messages from the hub to the WebSocket connection
|
||||||
|
func (c *Client) writePump() {
|
||||||
|
ticker := time.NewTicker(pingPeriod)
|
||||||
|
defer func() {
|
||||||
|
ticker.Stop()
|
||||||
|
c.conn.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case message, ok := <-c.send:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if !ok {
|
||||||
|
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w, err := c.conn.NextWriter(websocket.TextMessage)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Write(message)
|
||||||
|
|
||||||
|
// Add queued messages to the current websocket message
|
||||||
|
n := len(c.send)
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
w.Write([]byte{'\n'})
|
||||||
|
w.Write(<-c.send)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Close(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
c.conn.SetWriteDeadline(time.Now().Add(writeWait))
|
||||||
|
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveWS handles WebSocket requests from clients
|
||||||
|
func serveWS(hub *Hub, c *gin.Context) {
|
||||||
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("WebSocket upgrade error: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{
|
||||||
|
hub: hub,
|
||||||
|
conn: conn,
|
||||||
|
send: make(chan []byte, 256),
|
||||||
|
}
|
||||||
|
|
||||||
|
client.hub.register <- client
|
||||||
|
|
||||||
|
// Allow collection of memory referenced by the caller by doing all work in new goroutines
|
||||||
|
go client.writePump()
|
||||||
|
go client.readPump()
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocketHandler is the Gin handler for WebSocket connections
|
||||||
|
func WebSocketHandler(hub *Hub) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
serveWS(hub, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user