mirror of
https://github.com/swissmakers/fail2ban-ui.git
synced 2026-04-11 13:47:05 +02:00
Implement geoIP and whois lookups directly from fail2ban-UI
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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 IP’s country is in the allowed alert list.
|
||||
func shouldAlertForCountry(country string, alertCountries []string) bool {
|
||||
if len(alertCountries) == 0 || strings.Contains(strings.Join(alertCountries, ","), "ALL") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">'
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
125
pkg/web/whois.go
Normal 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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user