Initial version of fail2ban-webui
Web interface to view, ban and unban fail2ban IPs on several hosts via SSH, generalised from the private f2b-web tool. - Web UI (Flask/Waitress): hosts.yaml, local/OIDC/proxy authentication, English/German UI, origin map, optional Loki/Grafana/GeoIP, CLI helpers - Agent f2b-webctl with install.sh/uninstall.sh (dedicated user + sudo, from= restriction, protected networks, manual jail) - Hardened Docker image, examples, documentation, smoke test, testbed Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
2e3f79236c
44 files changed
+4280
No files matched your search
@@ -0,0 +1,219 @@
|
||||
"""Talks to the agents (SSH), GeoIP and Loki.
|
||||
|
||||
Errors are returned as {"ok": False, "error_key": ..., "error_args": {...}} so the
|
||||
web layer can translate them into the language of each request. Errors coming from
|
||||
an agent arrive as plain text in "error" and are passed through unchanged.
|
||||
"""
|
||||
import base64
|
||||
import gzip
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import date, datetime, timedelta
|
||||
|
||||
import maxminddb
|
||||
|
||||
log = logging.getLogger("webui")
|
||||
|
||||
|
||||
def fail(key, **args):
|
||||
return {"ok": False, "error_key": key, "error_args": args}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# GeoIP (optional)
|
||||
# ---------------------------------------------------------------------
|
||||
class Geo:
|
||||
def __init__(self, settings):
|
||||
self.settings = settings
|
||||
self._reader = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
return os.path.exists(self.settings.geoip_db)
|
||||
|
||||
def reload(self):
|
||||
with self._lock:
|
||||
self._reader = None
|
||||
|
||||
def lookup(self, ip):
|
||||
with self._lock:
|
||||
if self._reader is None:
|
||||
try:
|
||||
self._reader = maxminddb.open_database(self.settings.geoip_db)
|
||||
except Exception:
|
||||
self._reader = False
|
||||
reader = self._reader
|
||||
if not reader:
|
||||
return {}
|
||||
try:
|
||||
rec = reader.get(ip) or {}
|
||||
except ValueError:
|
||||
return {}
|
||||
country = rec.get("country", {})
|
||||
names = country.get("names", {})
|
||||
city = rec.get("city", {}).get("names", {})
|
||||
location = rec.get("location", {})
|
||||
# The browser picks the name matching its UI language, English otherwise
|
||||
return {
|
||||
"cc": country.get("iso_code", ""),
|
||||
"country": names.get("en") or "",
|
||||
"country_names": names,
|
||||
"city": city.get("en") or "",
|
||||
"city_names": city,
|
||||
"lat": location.get("latitude"),
|
||||
"lon": location.get("longitude"),
|
||||
}
|
||||
|
||||
def download_loop(self):
|
||||
"""Keeps the free DB-IP Lite database current (monthly release)."""
|
||||
while True:
|
||||
try:
|
||||
self._download_if_old()
|
||||
except Exception as exc:
|
||||
log.warning("GeoIP download failed: %s", exc)
|
||||
time.sleep(86400)
|
||||
|
||||
def _download_if_old(self):
|
||||
path = self.settings.geoip_db
|
||||
if os.path.exists(path) and time.time() - os.path.getmtime(path) < 32 * 86400:
|
||||
return
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
first = date.today().replace(day=1)
|
||||
for month in (first, (first - timedelta(days=1)).replace(day=1)):
|
||||
url = f"https://download.db-ip.com/free/dbip-city-lite-{month:%Y-%m}.mmdb.gz"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=300) as resp, \
|
||||
tempfile.NamedTemporaryFile(dir=os.path.dirname(path), delete=False) as tmp:
|
||||
with gzip.GzipFile(fileobj=resp) as gz:
|
||||
shutil.copyfileobj(gz, tmp)
|
||||
except urllib.error.HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
continue
|
||||
raise
|
||||
os.replace(tmp.name, path)
|
||||
log.info("GeoIP database updated from %s", url)
|
||||
self.reload()
|
||||
return
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Agents via SSH
|
||||
# ---------------------------------------------------------------------
|
||||
class Agents:
|
||||
def __init__(self, settings, geo):
|
||||
self.settings = settings
|
||||
self.geo = geo
|
||||
self._cache = {"ts": 0.0, "data": None}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def hosts(self):
|
||||
return self.settings.hosts
|
||||
|
||||
def ssh(self, name, *args, timeout=None):
|
||||
s = self.settings
|
||||
h = self.hosts[name]
|
||||
# -F none: ignore /etc/ssh/ssh_config (Debian enables GSSAPI there, which is never needed here)
|
||||
cmd = [
|
||||
"ssh", "-F", "none", "-i", s.ssh_key, "-p", str(h.port),
|
||||
"-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "GSSAPIAuthentication=no",
|
||||
"-o", "StrictHostKeyChecking=yes", "-o", f"UserKnownHostsFile={s.known_hosts}",
|
||||
"-o", "ConnectTimeout=8", "-o", "ServerAliveInterval=5",
|
||||
"-o", "LogLevel=ERROR",
|
||||
"-l", h.user, "--", h.address, " ".join(args),
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout or s.ssh_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
return fail("err_timeout", host=name)
|
||||
try:
|
||||
return json.loads(r.stdout.strip().splitlines()[-1])
|
||||
except (ValueError, IndexError):
|
||||
detail = (r.stderr or r.stdout).strip()[:300]
|
||||
if detail:
|
||||
return fail("err_ssh", host=name, detail=detail)
|
||||
return fail("err_exit", host=name, code=r.returncode)
|
||||
|
||||
def run_many(self, names, *args):
|
||||
if not names:
|
||||
return {}
|
||||
with ThreadPoolExecutor(max_workers=min(len(names), 16)) as pool:
|
||||
return dict(zip(names, pool.map(lambda n: self.ssh(n, *args), names)))
|
||||
|
||||
def bans(self, force=False):
|
||||
with self._lock:
|
||||
if not force and self._cache["data"] and time.time() - self._cache["ts"] < self.settings.cache_seconds:
|
||||
return self._cache["data"]
|
||||
results = self.run_many(list(self.hosts), "list")
|
||||
|
||||
rows, hosts = [], {}
|
||||
for name, res in results.items():
|
||||
if not res.get("ok"):
|
||||
hosts[name] = {**{k: v for k, v in res.items() if k != "jails"}, "ok": False, "jails": {}}
|
||||
continue
|
||||
hosts[name] = {"ok": True, "jails": {}}
|
||||
for jail, info in res.get("jails", {}).items():
|
||||
hosts[name]["jails"][jail] = {"current": len(info["bans"]), "total": info.get("total")}
|
||||
for b in info["bans"]:
|
||||
rows.append({"host": name, "jail": jail, **b, **self.geo.lookup(b["ip"])})
|
||||
rows.sort(key=lambda r: r["start"], reverse=True)
|
||||
data = {"generated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), "hosts": hosts, "bans": rows}
|
||||
with self._lock:
|
||||
self._cache.update(ts=time.time(), data=data)
|
||||
return data
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Loki (optional)
|
||||
# ---------------------------------------------------------------------
|
||||
def loki_history(settings, ip):
|
||||
s = settings
|
||||
if not s.loki_url:
|
||||
return fail("err_loki_disabled"), 404
|
||||
esc = re.escape(ip).replace("\\", "\\\\")
|
||||
# |= is a fast pre-filter, the regex then only checks the hits
|
||||
query = (f'{s.loki_selector} |= "{ip}" '
|
||||
f'|~ "(^|[^0-9a-fA-F.:]){esc}([^0-9a-fA-F.:]|$)"')
|
||||
now_ns = int(time.time() * 1e9)
|
||||
params = urllib.parse.urlencode({
|
||||
"query": query, "limit": s.loki_limit, "direction": "backward",
|
||||
"start": now_ns - s.loki_days * 86400 * 10**9, "end": now_ns,
|
||||
})
|
||||
req = urllib.request.Request(f"{s.loki_url}/loki/api/v1/query_range?{params}")
|
||||
if s.loki_user:
|
||||
token = base64.b64encode(f"{s.loki_user}:{s.loki_password}".encode()).decode()
|
||||
req.add_header("Authorization", f"Basic {token}")
|
||||
if s.loki_tenant:
|
||||
req.add_header("X-Scope-OrgID", s.loki_tenant)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as r:
|
||||
res = json.load(r)
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read()[:200].decode(errors="replace")
|
||||
return fail("err_loki_http", code=exc.code, detail=detail), 502
|
||||
except TimeoutError:
|
||||
return fail("err_loki_timeout"), 504
|
||||
except Exception as exc:
|
||||
return fail("err_loki_unreachable", error=str(exc)), 502
|
||||
lines = []
|
||||
for stream in res.get("data", {}).get("result", []):
|
||||
labels = stream.get("stream", {})
|
||||
for ts, line in stream.get("values", []):
|
||||
lines.append({
|
||||
"ts": datetime.fromtimestamp(int(ts) / 1e9).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"host": labels.get("host", ""), "job": labels.get("job", ""), "line": line[:400],
|
||||
})
|
||||
lines.sort(key=lambda x: x["ts"], reverse=True)
|
||||
return {"ok": True, "lines": lines[:s.loki_limit]}, 200
|
||||
Reference in new issue
Block a user