feat(geoip): Use local MaxMind database for offline GeoIP lookups

- WAF API: Replace ipapi.co HTTP calls with local GeoLite2-Country.mmdb
- CrowdSec frontend: Use backend /api/v1/waf/geoip/ instead of ipapi.co
- nginx: Add routes for /system/ and /api/v1/system/
- Avoids CORS issues and rate limiting from remote GeoIP services

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
CyberMind-FR 2026-05-07 13:56:39 +02:00
parent 25d2f87913
commit 38a3a45f19
4 changed files with 155 additions and 8 deletions

58
common/nginx/webui.conf Normal file
View File

@ -0,0 +1,58 @@
server {
listen 0.0.0.0:9080;
root /usr/share/secubox/www;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://127.0.0.1:8001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /shared/ {
alias /usr/share/secubox/www/shared/;
}
# CrowdSec Dashboard static files
location /crowdsec/ {
alias /usr/share/secubox/www/crowdsec/;
index index.html;
try_files $uri $uri/ /crowdsec/index.html;
}
# CrowdSec API proxy
location /api/v1/crowdsec/ {
proxy_pass http://unix:/run/secubox/crowdsec.sock:/;
include /etc/nginx/snippets/secubox-proxy.conf;
}
# WAF Dashboard static files
location /waf/ {
alias /usr/share/secubox/www/waf/;
index index.html;
try_files $uri $uri/ /waf/index.html;
}
# WAF API proxy
location /api/v1/waf/ {
proxy_pass http://unix:/run/secubox/waf.sock:/;
include /etc/nginx/snippets/secubox-proxy.conf;
}
# System Hub Dashboard static files
location /system/ {
alias /usr/share/secubox/www/system/;
index index.html;
try_files $uri $uri/ /system/index.html;
}
# System Hub API proxy
location /api/v1/system/ {
proxy_pass http://unix:/run/secubox/system.sock:/;
include /etc/nginx/snippets/secubox-proxy.conf;
}
}

View File

@ -246,10 +246,10 @@
// Use HTTPS GeoIP service with fallback
try {
// Primary: ipapi.co (free, HTTPS, 1000/day limit)
const res = await fetch('https://ipapi.co/' + ip + '/country/', { mode: 'cors' });
// Backend GeoIP API (local MaxMind database)
const res = await fetch('/api/v1/waf/geoip/' + ip);
if (res.ok) {
const cc = (await res.text()).trim();
const data = await res.json(); const cc = data.country || "";
if (cc.length === 2) {
setGeoCache(ip, cc);
return cc;

View File

@ -14,6 +14,8 @@ from fastapi import FastAPI, Depends, HTTPException, Request
from pydantic import BaseModel
from secubox_core.auth import require_jwt
from secubox_core.config import get_config
import geoip2.database
import geoip2.errors
app = FastAPI(title="SecuBox WAF")
@ -506,3 +508,49 @@ async def update_whitelist(req: WhitelistRequest):
"""Add or remove IP from whitelist."""
# In production, this would update the TOML config file
return {"success": True, "ip": req.ip, "action": req.action}
# GeoIP lookup with caching
import urllib.request
import ssl
_geoip_cache = {}
# GeoIP database reader (local MaxMind database)
_geoip_reader = None
_geoip_cache = {}
GEOIP_DB_PATH = "/var/lib/secubox/geoip/GeoLite2-Country.mmdb"
def _get_geoip_reader():
global _geoip_reader
if _geoip_reader is None and geoip2:
try:
_geoip_reader = geoip2.database.Reader(GEOIP_DB_PATH)
except:
pass
return _geoip_reader
@app.get("/geoip/{ip}")
async def get_geoip(ip: str):
"""Lookup country code for IP address using local MaxMind database."""
if ip in _geoip_cache:
return {"ip": ip, "country": _geoip_cache[ip]}
# Skip private IPs
if ip.startswith(("10.", "192.168.", "127.", "172.16.", "172.17.", "172.18.", "172.19.")):
_geoip_cache[ip] = "LAN"
return {"ip": ip, "country": "LAN"}
# Try local database
reader = _get_geoip_reader()
if reader:
try:
response = reader.country(ip)
country = response.country.iso_code or ""
if country:
_geoip_cache[ip] = country
return {"ip": ip, "country": country}
except Exception:
pass
return {"ip": ip, "country": ""}

View File

@ -414,6 +414,36 @@
return res.json();
} catch { return {}; }
}
// GeoIP cache for country flags
const GEOIP_CACHE_KEY = "secubox_waf_geoip";
const GEOIP_CACHE_TTL = 86400000;
function getGeoCache() { try { return JSON.parse(localStorage.getItem(GEOIP_CACHE_KEY) || "{}"); } catch { return {}; } }
function setGeoCache(ip, country) { const c = getGeoCache(); c[ip] = { country, ts: Date.now() }; localStorage.setItem(GEOIP_CACHE_KEY, JSON.stringify(c)); }
function countryToFlag(code) { if (!code || code === "LAN" || code.length !== 2) return ""; return String.fromCodePoint(...[...code.toUpperCase()].map(c => 127397 + c.charCodeAt(0))); }
function getSiteEmoji(host) {
if (!host) return "🌐";
const h = host.toLowerCase();
if (h.includes("git")) return "💻";
if (h.includes("mail")) return "📧";
if (h.includes("blog")) return "📝";
if (h.includes("admin")) return "🔐";
if (h.includes("api")) return "⚡";
if (h.includes("auth")) return "🔑";
if (h.includes("file") || h.includes("cloud")) return "📁";
if (h.includes("chat") || h.includes("matrix")) return "💬";
if (h.includes("media") || h.includes("stream")) return "🎬";
if (h.includes("secubox")) return "🛡️";
return "🌐";
}
async function lookupGeoIP(ip) { if (!ip) return "";
const cache = getGeoCache();
if (cache[ip] && Date.now() - cache[ip].ts < GEOIP_CACHE_TTL) return cache[ip].country;
if (ip.startsWith("10.") || ip.startsWith("192.168.") || ip.startsWith("127.")) { setGeoCache(ip, "LAN"); return "LAN"; }
try { const r = await fetch("/api/v1/waf/geoip/" + ip, { mode: "cors" }); const j = await r.json(); const c = j.country; if (c && c.length === 2) { setGeoCache(ip, c); return c; } } catch {}
return "";
}
function switchTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
@ -464,16 +494,21 @@
}
async function loadAlerts() {
const d = await api('/alerts?limit=30');
const d = await api('/alerts?limit=10');
const el = document.getElementById('alertsList');
if (!d.alerts || d.alerts.length === 0) {
el.innerHTML = '<p style="color:var(--text-dim)">No recent alerts</p>';
return;
}
// Fetch flags
const ips = [...new Set(d.alerts.map(a => a.client_ip))];
const flags = {};
await Promise.all(ips.map(async ip => { flags[ip] = countryToFlag(await lookupGeoIP(ip)); }));
el.innerHTML = d.alerts.map(a => `
<div class="alert-row">
<span class="alert-time">${new Date(a.timestamp).toLocaleString()}</span>
<span class="alert-ip">${a.ip}</span>
<span class="alert-flag" style="font-size:1.2em">${flags[a.client_ip] || ""}</span>
<span class="alert-ip">${a.client_ip}</span><span title="${a.host}" style="margin-left:4px">${getSiteEmoji(a.host)}</span>
<span class="badge ${a.severity}">${a.severity}</span>
<span class="alert-rule">${a.rule_id}: ${a.description}</span>
</div>
@ -514,14 +549,20 @@
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--text-dim)">No active bans</td></tr>';
return;
}
tbody.innerHTML = d.bans.map(b => `
// Fetch flags for bans
const banIps = [...new Set(d.bans.map(b => b.value || b.ip).filter(Boolean))];
const banFlags = {};
await Promise.all(banIps.map(async ip => { banFlags[ip] = countryToFlag(await lookupGeoIP(ip)); }));
tbody.innerHTML = d.bans.map(b => {
const ip = b.value || b.ip || '-';
return `
<tr>
<td style="font-family:monospace">${b.value || b.ip || '-'}</td>
<td><span style="font-size:1.2em">${banFlags[ip] || ''}</span> <span style="font-family:monospace">${ip}</span></td>
<td>${b.scenario || b.reason || '-'}</td>
<td>${b.duration || '-'}</td>
<td><button class="btn" onclick="unbanIp('${b.value || b.ip}')">Unban</button></td>
</tr>
`).join('');
`}).join('');
}
async function toggleCategory(cat, enabled) {