Implement geoIP and whois lookups directly from fail2ban-UI

This commit is contained in:
2025-12-15 21:50:19 +01:00
parent 3ad4821cb7
commit c57322e38d
19 changed files with 523 additions and 42 deletions

View File

@@ -5,7 +5,9 @@ import (
"encoding/json"
"fmt"
"log"
"strings"
"golang.org/x/text/cases"
"golang.org/x/text/language"
"github.com/swissmakers/fail2ban-ui/internal/config"
"github.com/swissmakers/fail2ban-ui/internal/integrations"
@@ -84,7 +86,7 @@ func runAdvancedIntegrationAction(ctx context.Context, action, ip string, settin
"unblock": "unblocked",
}[action]
message := fmt.Sprintf("%s via %s", strings.Title(action), cfg.Integration)
message := fmt.Sprintf("%s via %s", cases.Title(language.English).String(action), cfg.Integration)
if err != nil && !skipLoggingIfAlreadyBlocked {
status = "error"
message = err.Error()

View File

@@ -177,7 +177,7 @@ func BanNotificationHandler(c *gin.Context) {
Jail string `json:"jail" binding:"required"`
Hostname string `json:"hostname"`
Failures string `json:"failures"`
Whois string `json:"whois"`
Whois string `json:"whois"` // Optional for backward compatibility
Logs string `json:"logs"`
}
@@ -585,14 +585,41 @@ func TestServerHandler(c *gin.Context) {
// HandleBanNotification processes Fail2Ban notifications, checks geo-location, stores the event, and sends alerts.
func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip, jail, hostname, failures, whois, logs string) error {
// Load settings to get alert countries
// Load settings to get alert countries and GeoIP provider
settings := config.GetSettings()
// Lookup the country for the given IP
country, err := lookupCountry(ip)
// Perform whois lookup if not provided (backward compatibility)
var whoisData string
var err error
if whois == "" || whois == "missing whois program" {
log.Printf("Performing whois lookup for IP %s", ip)
whoisData, err = lookupWhois(ip)
if err != nil {
log.Printf("⚠️ Whois lookup failed for IP %s: %v", ip, err)
whoisData = ""
}
} else {
log.Printf("Using provided whois data for IP %s", ip)
whoisData = whois
}
// Filter logs to show relevant lines
filteredLogs := filterRelevantLogs(logs, ip, settings.MaxLogLines)
// Lookup the country for the given IP using configured provider
country, err := lookupCountry(ip, settings.GeoIPProvider, settings.GeoIPDatabasePath)
if err != nil {
log.Printf("⚠️ GeoIP lookup failed for IP %s: %v", ip, err)
country = ""
// 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{
@@ -603,8 +630,8 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
Country: country,
Hostname: hostname,
Failures: failures,
Whois: whois,
Logs: logs,
Whois: whoisData,
Logs: filteredLogs,
OccurredAt: time.Now().UTC(),
}
if err := storage.RecordBanEvent(ctx, event); err != nil {
@@ -630,7 +657,7 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
}
// Send email notification
if err := sendBanAlert(ip, jail, hostname, failures, whois, logs, country, settings); err != nil {
if err := sendBanAlert(ip, jail, hostname, failures, whoisData, filteredLogs, country, settings); err != nil {
log.Printf("❌ Failed to send alert email: %v", err)
return err
}
@@ -639,8 +666,29 @@ func HandleBanNotification(ctx context.Context, server config.Fail2banServer, ip
return nil
}
// lookupCountry finds the country ISO code for a given IP using MaxMind GeoLite2 database.
func lookupCountry(ip string) (string, error) {
// lookupCountry finds the country ISO code for a given IP using the configured provider.
func lookupCountry(ip, provider, dbPath string) (string, error) {
switch provider {
case "builtin":
return lookupCountryBuiltin(ip)
case "maxmind", "":
// Default to maxmind if empty
if dbPath == "" {
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
}
return lookupCountryMaxMind(ip, dbPath)
default:
// Unknown provider, try maxmind as fallback
log.Printf("Unknown GeoIP provider '%s', falling back to MaxMind", provider)
if dbPath == "" {
dbPath = "/usr/share/GeoIP/GeoLite2-Country.mmdb"
}
return lookupCountryMaxMind(ip, dbPath)
}
}
// lookupCountryMaxMind finds the country ISO code using MaxMind GeoLite2 database.
func lookupCountryMaxMind(ip, dbPath string) (string, error) {
// Convert the IP string to net.IP
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
@@ -648,9 +696,9 @@ func lookupCountry(ip string) (string, error) {
}
// Open the GeoIP database
db, err := maxminddb.Open("/usr/share/GeoIP/GeoLite2-Country.mmdb")
db, err := maxminddb.Open(dbPath)
if err != nil {
return "", fmt.Errorf("failed to open GeoIP database: %w", err)
return "", fmt.Errorf("failed to open GeoIP database at %s: %w", dbPath, err)
}
defer db.Close()
@@ -670,6 +718,150 @@ func lookupCountry(ip string) (string, error) {
return record.Country.ISOCode, nil
}
// lookupCountryBuiltin finds the country ISO code using ip-api.com free API.
func lookupCountryBuiltin(ip string) (string, error) {
// Convert the IP string to net.IP to validate
parsedIP := net.ParseIP(ip)
if parsedIP == nil {
return "", fmt.Errorf("invalid IP address: %s", ip)
}
// Use ip-api.com free API (no account needed, rate limited to 45 requests/minute)
url := fmt.Sprintf("http://ip-api.com/json/%s?fields=countryCode", ip)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to query ip-api.com: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ip-api.com returned status %d", resp.StatusCode)
}
var result struct {
CountryCode string `json:"countryCode"`
Status string `json:"status"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.Status == "fail" {
return "", fmt.Errorf("ip-api.com error: %s", result.Message)
}
return result.CountryCode, nil
}
// filterRelevantLogs filters log lines to show the most relevant ones that caused the block.
func filterRelevantLogs(logs, ip string, maxLines int) string {
if logs == "" {
return ""
}
if maxLines <= 0 {
maxLines = 50 // Default
}
lines := strings.Split(logs, "\n")
if len(lines) <= maxLines {
return logs // Return as-is if within limit
}
// Priority indicators for relevant log lines
priorityPatterns := []string{
"denied", "deny", "forbidden", "unauthorized", "failed", "failure",
"error", "403", "404", "401", "500", "502", "503",
"invalid", "rejected", "blocked", "ban",
}
// Score each line based on relevance
type scoredLine struct {
line string
score int
index int
}
scored := make([]scoredLine, len(lines))
for i, line := range lines {
lineLower := strings.ToLower(line)
score := 0
// Check if line contains the IP
if strings.Contains(line, ip) {
score += 10
}
// Check for priority patterns
for _, pattern := range priorityPatterns {
if strings.Contains(lineLower, pattern) {
score += 5
}
}
// Recent lines get higher score (lines at the end are more recent)
score += (len(lines) - i) / 10
scored[i] = scoredLine{
line: line,
score: score,
index: i,
}
}
// Sort by score (descending)
for i := 0; i < len(scored)-1; i++ {
for j := i + 1; j < len(scored); j++ {
if scored[i].score < scored[j].score {
scored[i], scored[j] = scored[j], scored[i]
}
}
}
// Take top N lines and sort by original index to maintain chronological order
selected := scored[:maxLines]
for i := 0; i < len(selected)-1; i++ {
for j := i + 1; j < len(selected); j++ {
if selected[i].index > selected[j].index {
selected[i], selected[j] = selected[j], selected[i]
}
}
}
// Build result
result := make([]string, len(selected))
for i, s := range selected {
result[i] = s.line
}
// Remove duplicate consecutive lines
filtered := []string{}
lastLine := ""
for _, line := range result {
if line != lastLine {
filtered = append(filtered, line)
lastLine = line
}
}
return strings.Join(filtered, "\n")
}
// shouldAlertForCountry checks if an IPs country is in the allowed alert list.
func shouldAlertForCountry(country string, alertCountries []string) bool {
if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") {

View File

@@ -180,9 +180,9 @@ mark {
transition: background-color 0.2s ease;
}
#backendStatus:hover {
/*#backendStatus:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}*/
#statusDot {
width: 0.5rem;

View File

@@ -63,7 +63,7 @@ function showBanEventToast(event) {
+ ' <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> banned in </span>'
+ ' <span class="font-semibold">' + escapeHtml(jail) + '</span>'
+ ' </div>'
+ ' <div class="text-xs text-gray-400 mt-1">'

View File

@@ -1,6 +1,18 @@
// Settings page functions for Fail2ban UI
"use strict";
// Handle GeoIP provider change
function onGeoIPProviderChange(provider) {
const dbPathContainer = document.getElementById('geoipDatabasePathContainer');
if (dbPathContainer) {
if (provider === 'maxmind') {
dbPathContainer.style.display = 'block';
} else {
dbPathContainer.style.display = 'none';
}
}
}
function loadSettings() {
showLoading(true);
fetch('/api/settings')
@@ -83,6 +95,13 @@ function loadSettings() {
document.getElementById('bantimeIncrement').checked = data.bantimeIncrement || false;
document.getElementById('defaultJailEnable').checked = data.defaultJailEnable || false;
// GeoIP settings
const geoipProvider = data.geoipProvider || 'builtin';
document.getElementById('geoipProvider').value = geoipProvider;
onGeoIPProviderChange(geoipProvider);
document.getElementById('geoipDatabasePath').value = data.geoipDatabasePath || '/usr/share/GeoIP/GeoLite2-Country.mmdb';
document.getElementById('maxLogLines').value = data.maxLogLines || 50;
document.getElementById('banTime').value = data.bantime || '';
document.getElementById('findTime').value = data.findtime || '';
document.getElementById('maxRetry').value = data.maxretry || '';
@@ -149,6 +168,9 @@ function saveSettings(event) {
ignoreips: getIgnoreIPsArray(),
banaction: document.getElementById('banaction').value,
banactionAllports: document.getElementById('banactionAllports').value,
geoipProvider: document.getElementById('geoipProvider').value || 'builtin',
geoipDatabasePath: document.getElementById('geoipDatabasePath').value || '/usr/share/GeoIP/GeoLite2-Country.mmdb',
maxLogLines: parseInt(document.getElementById('maxLogLines').value, 10) || 50,
smtp: smtpSettings,
advancedActions: collectAdvancedActionsSettings()
};

View File

@@ -330,12 +330,38 @@
<!-- Alert Settings Group -->
<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>
<div class="mb-4">
<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"
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>
<!-- GeoIP Provider -->
<div class="mb-4">
<label for="geoipProvider" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_provider">GeoIP Provider</label>
<select id="geoipProvider" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" onchange="onGeoIPProviderChange(this.value)">
<option value="builtin" data-i18n="settings.geoip_provider.builtin">Built-in (ip-api.com)</option>
<option value="maxmind" data-i18n="settings.geoip_provider.maxmind">MaxMind (Local Database)</option>
</select>
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_provider.description">Choose the GeoIP lookup provider. MaxMind requires a local database file, while Built-in uses a free online API.</p>
</div>
<!-- GeoIP Database Path (shown only for MaxMind) -->
<div id="geoipDatabasePathContainer" class="mb-4">
<label for="geoipDatabasePath" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.geoip_database_path">GeoIP Database Path</label>
<input type="text" id="geoipDatabasePath" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="/usr/share/GeoIP/GeoLite2-Country.mmdb">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.geoip_database_path.description">Path to the MaxMind GeoLite2-Country database file.</p>
</div>
<!-- Max Log Lines -->
<div class="mb-4">
<label for="maxLogLines" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.max_log_lines">Maximum Log Lines</label>
<input type="number" id="maxLogLines" min="1" max="500" class="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="50">
<p class="text-xs text-gray-500 mt-1" data-i18n="settings.max_log_lines.description">Maximum number of log lines to include in ban notifications. Most relevant lines are selected automatically.</p>
</div>
<div class="mb-4">
<label for="alertCountries" class="block text-sm font-medium text-gray-700 mb-2" data-i18n="settings.alert_countries">Alert Countries</label>
<p class="text-sm text-gray-500 mb-2" data-i18n="settings.alert_countries_description">

View File

@@ -37,9 +37,6 @@ const (
// 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{

125
pkg/web/whois.go Normal file
View File

@@ -0,0 +1,125 @@
// 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 (
"fmt"
"strings"
"sync"
"time"
"github.com/likexian/whois"
)
var (
whoisCache = make(map[string]cachedWhois)
whoisCacheMutex sync.RWMutex
cacheExpiry = 24 * time.Hour
)
type cachedWhois struct {
data string
timestamp time.Time
}
// lookupWhois performs a whois lookup for the given IP address.
// It uses caching to avoid repeated queries for the same IP.
func lookupWhois(ip string) (string, error) {
// Check cache first
whoisCacheMutex.RLock()
if cached, ok := whoisCache[ip]; ok {
if time.Since(cached.timestamp) < cacheExpiry {
whoisCacheMutex.RUnlock()
return cached.data, nil
}
}
whoisCacheMutex.RUnlock()
// Perform whois lookup with timeout
done := make(chan string, 1)
errChan := make(chan error, 1)
go func() {
whoisData, err := whois.Whois(ip)
if err != nil {
errChan <- err
return
}
done <- whoisData
}()
var whoisData string
select {
case whoisData = <-done:
// Success - cache will be updated below
case err := <-errChan:
return "", fmt.Errorf("whois lookup failed: %w", err)
case <-time.After(10 * time.Second):
return "", fmt.Errorf("whois lookup timeout after 10 seconds")
}
// Cache the result
whoisCacheMutex.Lock()
whoisCache[ip] = cachedWhois{
data: whoisData,
timestamp: time.Now(),
}
// Clean old cache entries if cache is getting large
if len(whoisCache) > 1000 {
now := time.Now()
for k, v := range whoisCache {
if now.Sub(v.timestamp) > cacheExpiry {
delete(whoisCache, k)
}
}
}
whoisCacheMutex.Unlock()
return whoisData, nil
}
// extractCountryFromWhois attempts to extract country code from whois data.
// This is a fallback if GeoIP lookup fails.
func extractCountryFromWhois(whoisData string) string {
lines := strings.Split(whoisData, "\n")
for _, line := range lines {
line = strings.TrimSpace(line)
lineLower := strings.ToLower(line)
// Look for country field
if strings.HasPrefix(lineLower, "country:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
country := strings.TrimSpace(parts[1])
if len(country) == 2 {
return strings.ToUpper(country)
}
}
}
// Alternative format
if strings.HasPrefix(lineLower, "country code:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
country := strings.TrimSpace(parts[1])
if len(country) == 2 {
return strings.ToUpper(country)
}
}
}
}
return ""
}