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:
schiwagoaandClaude Opus 5.5 committed 2026-10-25 15:38:17 +01:00
commit 2e3f79236c
44 files changed
+4280

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""fail2ban-webui – web interface for fail2ban on several hosts."""
+3
View File
@@ -0,0 +1,3 @@
from .cli import main
main()
+215
View File
@@ -0,0 +1,215 @@
"""fail2ban-webui – view, ban and unban fail2ban IPs on several hosts.
The hosts are reached exclusively via SSH with a restricted key; on every host
the agent (forced command f2b-webctl) decides what is allowed.
"""
import collections
import copy
import ipaddress
import json
import logging
import os
import re
import secrets
import threading
import urllib.parse
from datetime import datetime, timedelta, timezone
from flask import Flask, abort, g, jsonify, render_template, request, session
from werkzeug.middleware.proxy_fix import ProxyFix
from . import auth, i18n
from .backend import Agents, Geo, loki_history
from .config import Settings
log = logging.getLogger("webui")
JAIL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$") # same rule as the agent
_audit_lock = threading.Lock()
def load_secret_key(s):
if s.secret_key:
return s.secret_key
path = os.path.join(s.data_dir, "secret_key")
try:
with open(path, encoding="utf-8") as fh:
return fh.read().strip()
except FileNotFoundError:
key = secrets.token_urlsafe(48)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(key)
return key
def create_app(s=None):
s = s or Settings()
if not s.hosts:
s.load_hosts()
s.validate_auth()
app = Flask(__name__)
app.json.ensure_ascii = False
app.json.sort_keys = False # hosts in the order of hosts.yaml
app.config.update(
SETTINGS=s,
SECRET_KEY=load_secret_key(s),
SESSION_COOKIE_NAME="f2bwebui_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax", # Strict would drop the cookie on the OIDC redirect back
SESSION_COOKIE_SECURE=s.cookie_secure,
PERMANENT_SESSION_LIFETIME=timedelta(hours=s.session_hours),
)
if s.trusted_proxy_hops > 0:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=s.trusted_proxy_hops, x_proto=s.trusted_proxy_hops)
geo = Geo(s)
agents = Agents(s, geo)
app.extensions["webui"] = {"geo": geo, "agents": agents}
# Language first – the auth guard already needs g.t for its messages
@app.before_request
def select_language():
g.lang = i18n.pick(request.cookies.get("lang"), s.default_lang)
g.t = lambda key, **kw: i18n.translate(g.lang, key, **kw)
auth.init_app(app, s)
@app.context_processor
def template_globals():
return {
"t": g.t, "lang": g.lang, "languages": i18n.languages(), "user": g.get("user"),
"csrf": auth.csrf_token(), "geo_enabled": geo.available or s.geoip_auto_download,
"logout_local": s.auth_modes != ["proxy"], "logout_url": s.proxy_logout_url,
"version": s.version,
}
# -----------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------
def localize(obj):
"""Turns error_key/error_args from the backend into text in the request language."""
if isinstance(obj, dict):
if "error_key" in obj:
obj = {k: v for k, v in obj.items() if k not in ("error_key", "error_args")} | {
"error": g.t(obj["error_key"], **obj.get("error_args", {}))}
return {k: localize(v) for k, v in obj.items()}
if isinstance(obj, list):
return [localize(v) for v in obj]
return obj
def valid_ip(value):
try:
return str(ipaddress.ip_address(str(value).strip()))
except ValueError:
abort(400, description=g.t("err_invalid_ip", value=repr(value)))
def audit(action, **fields):
entry = {
"ts": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
"action": action, "user": g.user, "client": request.remote_addr, **fields,
}
line = json.dumps(entry, ensure_ascii=False)
log.info("AUDIT %s", line)
with _audit_lock, open(s.audit_log, "a", encoding="utf-8") as fh:
fh.write(line + "\n")
def check_write_request():
if not auth.csrf_valid(request.headers.get("X-CSRF-Token")):
abort(403, description=g.t("err_csrf"))
origin = request.headers.get("Origin")
if origin and urllib.parse.urlparse(origin).netloc != request.host:
abort(403, description=g.t("err_origin"))
if not request.is_json:
abort(415, description=g.t("err_json"))
# -----------------------------------------------------------------
# Routes
# -----------------------------------------------------------------
@app.after_request
def security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["Content-Security-Policy"] = (
"default-src 'self'; img-src 'self' data:; style-src 'self'; script-src 'self'; "
"connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
resp.headers["Cache-Control"] = "no-store"
return resp
for code in (400, 401, 403, 404, 415):
@app.errorhandler(code)
def json_error(err):
if request.path.startswith("/api/") or request.is_json:
return jsonify({"ok": False, "error": err.description}), err.code
return render_template("error.html", code=err.code, message=err.description), err.code
@app.get("/")
def index():
return render_template(
"index.html", hosts=list(s.hosts), manual_jail=s.manual_jail,
i18n=i18n.catalog(g.lang),
client_config={
"grafana": s.grafana_url, "grafanaDashboard": s.grafana_dashboard,
"grafanaLokiUid": s.grafana_loki_uid, "loki": bool(s.loki_url),
"lokiSelector": s.loki_selector, "lokiDays": s.loki_days, "lang": g.lang,
})
@app.get("/healthz")
def healthz():
return "ok"
@app.get("/api/bans")
def api_bans():
return jsonify(localize(copy.deepcopy(agents.bans(force=request.args.get("refresh") == "1"))))
@app.post("/api/ban")
def api_ban():
check_write_request()
body = request.get_json(silent=True) or {}
ip = valid_ip(body.get("ip"))
targets = [h for h in body.get("hosts", []) if h in s.hosts]
note = str(body.get("note", ""))[:200]
if not targets:
abort(400, description=g.t("err_no_host"))
results = agents.run_many(targets, "ban", ip)
audit("ban", ip=ip, hosts=targets, note=note,
ok={h: r.get("ok", False) for h, r in results.items()})
agents.bans(force=True)
return jsonify({"ok": all(r.get("ok") for r in results.values()), "results": localize(results)})
@app.post("/api/unban")
def api_unban():
check_write_request()
body = request.get_json(silent=True) or {}
ip = valid_ip(body.get("ip"))
host = body.get("host")
jail = str(body.get("jail", ""))
targets = list(s.hosts) if host == "*" else [host] if host in s.hosts else []
if not targets:
abort(400, description=g.t("err_unknown_host"))
if jail != "*" and not JAIL_RE.fullmatch(jail):
abort(400, description=g.t("err_invalid_jail"))
args = ("unban-all", ip) if jail == "*" else ("unban", jail, ip)
results = agents.run_many(targets, *args)
audit("unban", ip=ip, hosts=targets, jail=jail,
ok={h: r.get("ok", False) for h, r in results.items()})
agents.bans(force=True)
return jsonify({"ok": all(r.get("ok") for r in results.values()), "results": localize(results)})
@app.get("/api/history")
def api_history():
ip = valid_ip(request.args.get("ip"))
result, status = loki_history(s, ip)
return jsonify(localize({**result, "ip": ip, "geo": geo.lookup(ip)})), status
@app.get("/api/audit")
def api_audit():
try:
with open(s.audit_log, encoding="utf-8") as fh:
entries = [json.loads(line) for line in collections.deque(fh, 50) if line.strip()]
except FileNotFoundError:
entries = []
return jsonify({"ok": True, "entries": list(reversed(entries))})
return app
+226
View File
@@ -0,0 +1,226 @@
"""Authentication: local user, OpenID Connect and reverse-proxy mode.
AUTH_MODE=local own login form (user + password hash)
AUTH_MODE=oidc single sign-on via an OpenID Connect provider (e.g. Authentik)
AUTH_MODE=local,oidc both on the login page
AUTH_MODE=proxy the reverse proxy authenticates (forward auth, basic auth, IP list)
Failed logins are written to the container log as
LOGIN_FAILED user=<name> ip=<address>
so fail2ban on the Docker host can protect this UI as well.
"""
import hmac
import ipaddress
import logging
import re
import secrets
import threading
import time
from flask import (Blueprint, abort, current_app, flash, g, jsonify, redirect,
render_template, request, session, url_for)
from werkzeug.security import check_password_hash
log = logging.getLogger("webui")
bp = Blueprint("auth", __name__)
PUBLIC_ENDPOINTS = {"healthz", "static", "auth.login", "auth.login_post",
"auth.oidc_start", "auth.oidc_callback"}
# Used when the user name does not exist, so both cases take the same time
_DUMMY_HASH = "scrypt:32768:8:1$dummydummydummy$" + "0" * 128
_failures = {}
_failures_lock = threading.Lock()
oauth = None
def settings():
return current_app.config["SETTINGS"]
def csrf_token():
if "csrf" not in session:
session["csrf"] = secrets.token_urlsafe(32)
return session["csrf"]
def csrf_valid(value):
return bool(value) and hmac.compare_digest(str(value), session.get("csrf", ""))
def client_ip():
return clean(request.remote_addr or "?")
def clean(value, limit=64):
"""User-controlled text for log lines: no spaces or line breaks, so nobody can
forge an extra "LOGIN_FAILED … ip=" line that fail2ban would act on."""
return re.sub(r"[^\w.@:+-]", "?", str(value))[:limit] or "-"
def start_session(user, via):
session.clear()
session.permanent = True
session["user"] = user
session["via"] = via
csrf_token()
log.info("LOGIN_OK user=%s ip=%s via=%s", clean(user), client_ip(), via)
# ---------------------------------------------------------------------
# Brute-force protection (in memory, per client IP)
# ---------------------------------------------------------------------
def _locked(ip):
s = settings()
window = s.login_lock_minutes * 60
with _failures_lock:
recent = [t for t in _failures.get(ip, []) if time.time() - t < window]
_failures[ip] = recent
return len(recent) >= s.login_max_attempts
def _record_failure(ip, user):
with _failures_lock:
_failures.setdefault(ip, []).append(time.time())
log.warning("LOGIN_FAILED user=%s ip=%s", clean(user), ip)
# ---------------------------------------------------------------------
# Guard for every request
# ---------------------------------------------------------------------
def require_login():
s = settings()
g.user = None
if request.endpoint in PUBLIC_ENDPOINTS:
return None
if s.auth_modes == ["proxy"]:
if s.proxy_allowed_ips:
direct = request.environ.get("werkzeug.proxy_fix.orig", {}).get("REMOTE_ADDR", request.remote_addr)
try:
addr = ipaddress.ip_address(direct)
except ValueError:
addr = None
if addr is None or not any(addr in net for net in s.proxy_allowed_ips):
abort(403, description=g.t("err_forbidden_proxy"))
if s.proxy_user_header:
g.user = request.headers.get(s.proxy_user_header, "").strip()[:128]
if not g.user:
abort(401, description=g.t("err_login_required"))
else:
g.user = "proxy"
csrf_token()
return None
g.user = session.get("user")
if g.user:
return None
if request.path.startswith("/api/"):
return jsonify({"ok": False, "error": g.t("err_login_required")}), 401
return redirect(url_for("auth.login"))
# ---------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------
@bp.get("/login")
def login():
s = settings()
if s.auth_modes == ["proxy"] or session.get("user"):
return redirect(url_for("index"))
return render_template("login.html", csrf=csrf_token(),
local="local" in s.auth_modes, oidc="oidc" in s.auth_modes,
oidc_label=s.oidc_button_label)
@bp.post("/login")
def login_post():
s = settings()
if "local" not in s.auth_modes:
abort(404)
ip = client_ip()
user = request.form.get("username", "").strip()
if not csrf_valid(request.form.get("csrf")):
flash(g.t("login_csrf"), "error")
return redirect(url_for("auth.login"))
if _locked(ip):
log.warning("LOGIN_LOCKED ip=%s", ip)
flash(g.t("login_locked", minutes=s.login_lock_minutes), "error")
return redirect(url_for("auth.login"))
password = request.form.get("password", "")
user_ok = hmac.compare_digest(user, s.auth_user)
pass_ok = check_password_hash(s.auth_password_hash if user_ok else _DUMMY_HASH, password)
if not (user_ok and pass_ok):
_record_failure(ip, user)
flash(g.t("login_failed"), "error")
return redirect(url_for("auth.login"))
with _failures_lock:
_failures.pop(ip, None)
start_session(user, "local")
return redirect(url_for("index"))
@bp.post("/logout")
def logout():
if not csrf_valid(request.form.get("csrf")):
abort(403, description=g.t("err_csrf"))
log.info("LOGOUT user=%s ip=%s", clean(session.get("user")), client_ip())
session.clear()
return redirect(url_for("auth.login"))
@bp.get("/auth/oidc")
def oidc_start():
s = settings()
if "oidc" not in s.auth_modes:
abort(404)
return oauth.oidc.authorize_redirect(f"{s.public_url}/auth/callback")
@bp.get("/auth/callback")
def oidc_callback():
s = settings()
if "oidc" not in s.auth_modes:
abort(404)
try:
token = oauth.oidc.authorize_access_token()
info = token.get("userinfo") or oauth.oidc.userinfo(token=token)
except Exception as exc:
log.warning("OIDC_FAILED ip=%s error=%s", client_ip(), clean(exc, 200))
flash(g.t("login_oidc_failed", error=str(exc)[:200]), "error")
return redirect(url_for("auth.login"))
user = str(info.get(s.oidc_username_claim) or info.get("email") or info.get("sub") or "")[:128]
groups = info.get(s.oidc_groups_claim) or []
if isinstance(groups, str):
groups = [groups]
allowed = True
if s.oidc_allowed_groups and not set(groups) & set(s.oidc_allowed_groups):
allowed = False
if s.oidc_allowed_users and user not in s.oidc_allowed_users:
allowed = False
if not user or not allowed:
log.warning("OIDC_DENIED user=%s ip=%s groups=%s", clean(user), client_ip(), clean(",".join(map(str, groups)), 200))
flash(g.t("login_denied", user=user or "?"), "error")
return redirect(url_for("auth.login"))
start_session(user, "oidc")
return redirect(url_for("index"))
def init_app(app, s):
global oauth
app.register_blueprint(bp)
app.before_request(require_login)
if "oidc" in s.auth_modes:
from authlib.integrations.flask_client import OAuth
oauth = OAuth(app)
oauth.register(
name="oidc",
server_metadata_url=s.oidc_discovery_url,
client_id=s.oidc_client_id,
client_secret=s.oidc_client_secret,
client_kwargs={"scope": s.oidc_scopes, "code_challenge_method": "S256"},
)
if not s.oidc_allowed_groups and not s.oidc_allowed_users:
log.warning("OIDC without OIDC_ALLOWED_GROUPS/OIDC_ALLOWED_USERS: every account of the "
"provider that may use this client can sign in")
+219
View File
@@ -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
+180
View File
@@ -0,0 +1,180 @@
"""Command line: docker compose run --rm -it fail2ban-webui <command>
serve start the web server (default)
hash-password create AUTH_PASSWORD_HASH for the .env file
keygen create the SSH key in /data/ssh and show the public key
keyscan fetch the host keys of all hosts into known_hosts (shows fingerprints)
check test the connection to every agent
"""
import argparse
import getpass
import logging
import os
import subprocess
import sys
import threading
from .config import ConfigError, Settings
log = logging.getLogger("webui")
def die(msg, code=1):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(code)
def ensure_writable(path):
os.makedirs(path, exist_ok=True)
probe = os.path.join(path, ".write-test")
try:
with open(probe, "w", encoding="utf-8") as fh:
fh.write("ok")
os.remove(probe)
except OSError as exc:
die(f"{path} is not writable for UID {os.getuid()} ({exc}).\n"
f" Fix on the Docker host: chown -R {os.getuid()}:{os.getgid()} ./data")
def cmd_serve(s):
from waitress import serve
from .app import create_app
ensure_writable(s.data_dir)
try:
app = create_app(s)
except ConfigError as exc:
die(str(exc))
geo = app.extensions["webui"]["geo"]
if s.geoip_auto_download:
threading.Thread(target=geo.download_loop, daemon=True, name="geoip").start()
log.info("fail2ban-webui %s – hosts: %s – auth: %s – loki: %s – geoip: %s",
s.version, ", ".join(s.hosts), ",".join(s.auth_modes),
"on" if s.loki_url else "off", "on" if geo.available or s.geoip_auto_download else "off")
serve(app, host="0.0.0.0", port=8080, threads=8, ident=None)
def cmd_hash_password(_s):
from werkzeug.security import generate_password_hash
if sys.stdin.isatty():
pw = getpass.getpass("New password: ")
if pw != getpass.getpass("Repeat password: "):
die("Passwords do not match.")
else:
pw = sys.stdin.readline().rstrip("\r\n") # \r: input piped from Windows files
if len(pw) < 12:
die("Please use at least 12 characters.")
# Single quotes: docker compose then leaves the $ signs in the hash alone
print(f"AUTH_PASSWORD_HASH='{generate_password_hash(pw)}'")
def cmd_keygen(s, force=False):
key = s.ssh_key
ensure_writable(os.path.dirname(key))
if os.path.exists(key) and not force:
print(f"{key} already exists (use --force to replace it).", file=sys.stderr)
else:
if force:
for p in (key, key + ".pub"):
if os.path.exists(p):
os.remove(p)
subprocess.run(["ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "fail2ban-webui", "-f", key],
check=True)
with open(key + ".pub", encoding="utf-8") as fh:
print(fh.read().strip())
print("\nInstall this public key on every fail2ban host with the agent installer.", file=sys.stderr)
def cmd_keyscan(s, yes=False):
try:
s.load_hosts()
except ConfigError as exc:
die(str(exc))
lines, failed = [], []
for h in s.hosts.values():
r = subprocess.run(["ssh-keyscan", "-T", "10", "-p", str(h.port), "-t", "ed25519,ecdsa,rsa", h.address],
capture_output=True, text=True)
found = [x for x in r.stdout.splitlines() if x and not x.startswith("#")]
if not found:
failed.append(h.name)
print(f"{h.name:20} {h.address}:{h.port} NO HOST KEY ({r.stderr.strip()[:120]})")
continue
fp = subprocess.run(["ssh-keygen", "-l", "-f", "-"], input="\n".join(found) + "\n",
capture_output=True, text=True).stdout.strip()
print(f"{h.name:20} {h.address}:{h.port}")
for f in fp.splitlines():
print(f" {f}")
lines.extend(found)
if not lines:
die("No host keys received.")
print("\nCompare these fingerprints with the hosts, e.g.: ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub")
if not yes:
if not sys.stdin.isatty():
die("Run with -it to confirm, or add --yes.")
if input(f"Write {len(lines)} keys to {s.known_hosts}? [y/N] ").strip().lower() not in ("y", "yes", "j", "ja"):
die("Aborted, nothing written.", 0)
ensure_writable(os.path.dirname(s.known_hosts))
with open(s.known_hosts, "w", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
print(f"Written: {s.known_hosts}")
if failed:
die("Missing: " + ", ".join(failed))
def cmd_check(s):
from .backend import Agents, Geo
from . import i18n
try:
s.load_hosts()
except ConfigError as exc:
die(str(exc))
results = Agents(s, Geo(s)).run_many(list(s.hosts), "list")
bad = 0
for name, res in results.items():
if res.get("ok"):
jails = res.get("jails", {})
bans = sum(len(j["bans"]) for j in jails.values())
print(f"OK {name:20} agent {res.get('agent_version', '<1.0')}, {len(jails)} jails, {bans} bans")
else:
bad += 1
err = res.get("error") or i18n.translate("en", res.get("error_key", ""), **res.get("error_args", {}))
print(f"FAIL {name:20} {err}")
sys.exit(1 if bad else 0)
def main(argv=None):
logging.basicConfig(level=logging.INFO, stream=sys.stdout,
format="%(asctime)s %(levelname)s %(name)s: %(message)s")
parser = argparse.ArgumentParser(prog="fail2ban-webui", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = parser.add_subparsers(dest="cmd")
sub.add_parser("serve")
sub.add_parser("hash-password")
p = sub.add_parser("keygen")
p.add_argument("--force", action="store_true", help="replace an existing key")
p = sub.add_parser("keyscan")
p.add_argument("--yes", action="store_true", help="write without asking")
sub.add_parser("check")
args = parser.parse_args(argv)
try:
s = Settings()
except ConfigError as exc:
die(str(exc))
cmd = args.cmd or "serve"
if cmd == "serve":
try:
s.load_hosts()
except ConfigError as exc:
die(str(exc))
cmd_serve(s)
elif cmd == "hash-password":
cmd_hash_password(s)
elif cmd == "keygen":
cmd_keygen(s, args.force)
elif cmd == "keyscan":
cmd_keyscan(s, args.yes)
elif cmd == "check":
cmd_check(s)
+180
View File
@@ -0,0 +1,180 @@
"""Configuration from environment variables and the hosts file (config/hosts.yaml)."""
import ipaddress
import os
import re
from dataclasses import dataclass
import yaml
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
USER_RE = re.compile(r"^[a-z_][a-z0-9_-]{0,31}$")
AUTH_MODES = {"local", "oidc", "proxy"}
class ConfigError(Exception):
pass
def env(name, default=""):
return os.environ.get(name, default).strip()
def env_bool(name, default=False):
value = env(name).lower()
return default if value == "" else value in ("1", "true", "yes", "on")
def env_int(name, default):
value = env(name)
try:
return int(value) if value else default
except ValueError:
raise ConfigError(f"{name} must be an integer, got {value!r}") from None
def env_list(name, default=""):
return [x.strip() for x in env(name, default).split(",") if x.strip()]
@dataclass(frozen=True)
class Host:
name: str
address: str
port: int = 22
user: str = "f2bweb"
def load_hosts(path, default_user):
hosts = []
if os.path.exists(path):
with open(path, encoding="utf-8") as fh:
data = yaml.safe_load(fh) or {}
for item in data.get("hosts") or []:
if not isinstance(item, dict):
raise ConfigError(f"{path}: every entry below 'hosts' must be a mapping")
try:
port = int(item.get("port", 22))
except (TypeError, ValueError):
raise ConfigError(f"{path}: invalid port {item.get('port')!r}") from None
hosts.append(Host(
name=str(item.get("name", "")).strip(),
address=str(item.get("address", "")).strip(),
port=port,
user=str(item.get("user") or default_user).strip(),
))
# Older single-line form: F2B_HOSTS=name=address,name2=address2
for item in env("F2B_HOSTS").split(","):
if "=" in item:
name, address = item.split("=", 1)
hosts.append(Host(name.strip(), address.strip(), 22, default_user))
seen = set()
for h in hosts:
if not NAME_RE.fullmatch(h.name):
raise ConfigError(f"Invalid host name {h.name!r} (allowed: letters, digits, . _ -)")
# No leading dash or whitespace: the address ends up on the ssh command line
if not h.address or h.address.startswith("-") or any(c.isspace() for c in h.address):
raise ConfigError(f"Invalid address for host {h.name!r}: {h.address!r}")
if not 1 <= h.port <= 65535:
raise ConfigError(f"Invalid port for host {h.name!r}: {h.port}")
if not USER_RE.fullmatch(h.user):
raise ConfigError(f"Invalid SSH user for host {h.name!r}: {h.user!r}")
if h.name in seen:
raise ConfigError(f"Host name {h.name!r} is used twice")
seen.add(h.name)
if not hosts:
raise ConfigError(f"No hosts configured – create {path} (see hosts.yaml.example)")
return {h.name: h for h in hosts}
class Settings:
def __init__(self):
data = env("DATA_DIR", "/data")
self.data_dir = data
self.config_file = env("CONFIG_FILE", "/config/hosts.yaml")
self.version = env("APP_VERSION", "dev")
# SSH / agents
self.ssh_user = env("SSH_USER", "f2bweb")
self.ssh_key = env("SSH_KEY", f"{data}/ssh/id_ed25519")
self.known_hosts = env("KNOWN_HOSTS", f"{data}/ssh/known_hosts")
self.ssh_timeout = env_int("SSH_TIMEOUT", 40)
self.cache_seconds = env_int("CACHE_SECONDS", 15)
self.manual_jail = env("MANUAL_JAIL", "manual-block")
self.audit_log = env("AUDIT_LOG", f"{data}/audit.log")
# Web / sessions
self.public_url = env("PUBLIC_URL").rstrip("/")
self.trusted_proxy_hops = env_int("TRUSTED_PROXY_HOPS", 0)
self.default_lang = env("DEFAULT_LANG", "en")
self.secret_key = env("SECRET_KEY")
self.session_hours = env_int("SESSION_HOURS", 12)
self.cookie_secure = env_bool("SESSION_COOKIE_SECURE", self.public_url.startswith("https://"))
# Authentication
self.auth_modes = [m.lower() for m in env_list("AUTH_MODE", "local")]
self.auth_user = env("AUTH_USER", "admin")
self.auth_password_hash = env("AUTH_PASSWORD_HASH")
hash_file = env("AUTH_PASSWORD_HASH_FILE")
if not self.auth_password_hash and hash_file and os.path.exists(hash_file):
with open(hash_file, encoding="utf-8") as fh:
self.auth_password_hash = fh.read().strip()
self.login_max_attempts = env_int("LOGIN_MAX_ATTEMPTS", 5)
self.login_lock_minutes = env_int("LOGIN_LOCK_MINUTES", 15)
self.proxy_user_header = env("AUTH_PROXY_HEADER")
self.proxy_logout_url = env("AUTH_PROXY_LOGOUT_URL")
try:
self.proxy_allowed_ips = [ipaddress.ip_network(n, strict=False)
for n in env_list("AUTH_PROXY_ALLOWED_IPS")]
except ValueError as exc:
raise ConfigError(f"AUTH_PROXY_ALLOWED_IPS: {exc}") from None
issuer = env("OIDC_ISSUER").rstrip("/")
self.oidc_discovery_url = env("OIDC_DISCOVERY_URL") or (
f"{issuer}/.well-known/openid-configuration" if issuer else "")
self.oidc_client_id = env("OIDC_CLIENT_ID")
self.oidc_client_secret = env("OIDC_CLIENT_SECRET")
self.oidc_scopes = env("OIDC_SCOPES", "openid email profile")
self.oidc_username_claim = env("OIDC_USERNAME_CLAIM", "preferred_username")
self.oidc_groups_claim = env("OIDC_GROUPS_CLAIM", "groups")
self.oidc_allowed_groups = env_list("OIDC_ALLOWED_GROUPS")
self.oidc_allowed_users = env_list("OIDC_ALLOWED_USERS")
self.oidc_button_label = env("OIDC_BUTTON_LABEL", "SSO")
# Optional integrations
self.loki_url = env("LOKI_URL").rstrip("/")
self.loki_selector = env("LOKI_SELECTOR", '{job=~".+"}')
self.loki_days = env_int("LOKI_DAYS", 7)
self.loki_limit = env_int("LOKI_LIMIT", 50)
self.loki_user = env("LOKI_USER")
self.loki_password = env("LOKI_PASSWORD")
self.loki_tenant = env("LOKI_TENANT")
self.grafana_url = env("GRAFANA_URL").rstrip("/")
self.grafana_dashboard = env("GRAFANA_DASHBOARD")
self.grafana_loki_uid = env("GRAFANA_LOKI_UID", "loki")
self.geoip_db = env("GEOIP_DB", f"{data}/geoip/dbip-city-lite.mmdb")
self.geoip_auto_download = env_bool("GEOIP_AUTO_DOWNLOAD", False)
self.hosts = {}
def load_hosts(self):
self.hosts = load_hosts(self.config_file, self.ssh_user)
return self.hosts
def validate_auth(self):
modes = set(self.auth_modes)
if not modes or modes - AUTH_MODES:
raise ConfigError(f"AUTH_MODE must be local, oidc, local,oidc or proxy – got {','.join(self.auth_modes)!r}")
if "proxy" in modes and len(modes) > 1:
raise ConfigError("AUTH_MODE=proxy cannot be combined with other modes")
if "local" in modes and not self.auth_password_hash:
raise ConfigError("AUTH_MODE=local needs AUTH_PASSWORD_HASH – create one with: "
"docker compose run --rm -it fail2ban-webui hash-password")
if "oidc" in modes:
missing = [n for n, v in (("OIDC_ISSUER or OIDC_DISCOVERY_URL", self.oidc_discovery_url),
("OIDC_CLIENT_ID", self.oidc_client_id),
("OIDC_CLIENT_SECRET", self.oidc_client_secret),
("PUBLIC_URL", self.public_url)) if not v]
if missing:
raise ConfigError("AUTH_MODE=oidc needs " + ", ".join(missing))
+45
View File
@@ -0,0 +1,45 @@
"""Translations: one JSON file per language in webui/locales/ (e.g. en.json, de.json).
A new language only needs a new file; "_name" is the label shown in the dropdown.
Missing keys fall back to English.
"""
import json
import os
DIR = os.path.join(os.path.dirname(__file__), "locales")
FALLBACK = "en"
LANGS = {}
def load():
LANGS.clear()
for fname in sorted(os.listdir(DIR)):
if fname.endswith(".json"):
with open(os.path.join(DIR, fname), encoding="utf-8") as fh:
LANGS[fname[:-5]] = json.load(fh)
def pick(cookie_value, default):
if cookie_value in LANGS:
return cookie_value
return default if default in LANGS else FALLBACK
def catalog(lang):
"""Full dictionary for the browser, English as base."""
return {**LANGS.get(FALLBACK, {}), **LANGS.get(lang, {})}
def translate(lang, key, **params):
text = LANGS.get(lang, {}).get(key) or LANGS.get(FALLBACK, {}).get(key) or key
try:
return text.format(**params) if params else text
except (KeyError, IndexError, ValueError):
return text
def languages():
return [(code, data.get("_name", code)) for code, data in sorted(LANGS.items())]
load()
+101
View File
@@ -0,0 +1,101 @@
{
"_name": "Deutsch",
"title": "Fail2ban-Steuerung",
"language": "Sprache",
"refresh": "Aktualisieren",
"grafana": "Grafana",
"logout": "Abmelden",
"signed_in_as": "Angemeldet als {user}",
"generated": "Stand: {ts}",
"ban_heading": "IP sperren",
"ban_ip_placeholder": "IP-Adresse, z. B. 203.0.113.7",
"ban_note_placeholder": "Notiz (optional, nur fürs Protokoll)",
"ban_button": "Dauerhaft sperren",
"ban_hint": "Die Sperre landet im Jail {jail} (dauerhaft). Private Netze und auf dem Host geschützte Adressen lehnt der Agent ab.",
"bans_heading": "Aktuelle Sperren",
"filter_search": "Suchen (IP, Land, Stadt)",
"filter_all_hosts": "Alle Hosts",
"filter_all_jails": "Alle Jails",
"col_host": "Host",
"col_jail": "Jail",
"col_ip": "IP",
"col_origin": "Herkunft",
"col_since": "Gesperrt seit",
"col_until": "Bis",
"col_actions": "Aktionen",
"loading": "Lade …",
"no_bans": "Keine Sperren gefunden.",
"more_rows": "… {n} weitere – Filter verwenden.",
"permanent": "dauerhaft",
"btn_unban": "Entsperren",
"btn_unban_everywhere": "Überall",
"btn_history": "Verlauf",
"jail": "Jail",
"jail_current": "aktuell",
"jail_total": "seit Start",
"audit_heading": "Protokoll (letzte 50 Aktionen)",
"audit_empty": "Noch keine Aktionen.",
"audit_banned": "gesperrt",
"audit_unbanned": "entsperrt",
"audit_line": "{ts} – {ip} {what} auf {hosts}",
"audit_where_jail": " (Jail {jail})",
"audit_where_all": " (alle Jails)",
"audit_failed": " – FEHLER",
"audit_by": " · von {user} ({client})",
"history_title": "Verlauf {ip} ({days} Tage)",
"history_close": "Schließen",
"origin_unknown": "Herkunft unbekannt",
"history_none": "Keine Einträge in Loki.",
"confirm_unban_all": "{ip} auf ALLEN Hosts in ALLEN Jails entsperren?",
"confirm_unban": "{ip} auf {host} im Jail {jail} entsperren?",
"confirm_ban": "{ip} auf {hosts} DAUERHAFT sperren?",
"toast_unban": "Entsperren {ip}: {result}",
"toast_ban": "Sperren {ip}: {result}",
"toast_load_failed": "Laden fehlgeschlagen: {error}",
"select_host": "Bitte mindestens einen Host wählen.",
"result_ok": "OK",
"result_error": "Fehler",
"footer_geo": "IP-Geolokalisierung:",
"login_title": "Anmelden",
"login_user": "Benutzername",
"login_password": "Passwort",
"login_button": "Anmelden",
"login_oidc": "Anmelden mit {provider}",
"login_or": "oder",
"login_failed": "Benutzername oder Passwort falsch.",
"login_locked": "Zu viele Fehlversuche. Bitte in {minutes} Minuten erneut versuchen.",
"login_oidc_failed": "Single Sign-on fehlgeschlagen: {error}",
"login_denied": "Das Konto {user} darf diese Anwendung nicht verwenden.",
"login_csrf": "Sitzung abgelaufen – bitte erneut versuchen.",
"error_title": "Fehler {code}",
"error_back": "Zurück zur Startseite",
"err_login_required": "Nicht angemeldet oder Sitzung abgelaufen – bitte Seite neu laden.",
"err_csrf": "CSRF-Token fehlt oder ist ungültig – Seite neu laden.",
"err_origin": "Anfrage fremder Herkunft (Origin) abgelehnt.",
"err_json": "JSON erwartet.",
"err_invalid_ip": "Ungültige IP-Adresse: {value}",
"err_no_host": "Kein gültiger Host ausgewählt.",
"err_unknown_host": "Unbekannter Host.",
"err_invalid_jail": "Ungültiger Jail-Name.",
"err_timeout": "Zeitüberschreitung bei {host}",
"err_ssh": "{host}: {detail}",
"err_exit": "{host}: Exit-Code {code}",
"err_loki_disabled": "Loki ist nicht konfiguriert.",
"err_loki_http": "Loki-Fehler {code}: {detail}",
"err_loki_timeout": "Loki-Abfrage dauerte zu lange (Timeout).",
"err_loki_unreachable": "Loki nicht erreichbar: {error}",
"err_forbidden_proxy": "Zugriff nur über den konfigurierten Reverse Proxy erlaubt.",
"agent_f2b_down": "fail2ban ist auf dem Host nicht erreichbar: {detail}",
"agent_invalid_ip": "Ungültige IP-Adresse: {value}",
"agent_protected": "{ip} ist auf diesem Host geschützt (privates Netz, eigene Adresse oder PROTECTED-Liste).",
"agent_no_manual_jail": "Das Jail {jail} gibt es auf diesem Host nicht.",
"agent_unknown_jail": "Unbekanntes Jail: {jail}",
"agent_not_root": "Der Agent läuft nicht als root – sudo-Regel prüfen.",
"agent_not_allowed": "Der Agent hat den Befehl abgelehnt.",
"map_heading": "Herkunftskarte",
"map_summary": "{ips} Sperren aus {places} Orten",
"map_unknown": " · {n} ohne Standort",
"map_hint": "Punkt anklicken, um die Liste zu filtern.",
"map_point": "{place}: {n} Sperren",
"map_reset": "Alle anzeigen"
}
+101
View File
@@ -0,0 +1,101 @@
{
"_name": "English",
"title": "Fail2ban Web UI",
"language": "Language",
"refresh": "Refresh",
"grafana": "Grafana",
"logout": "Sign out",
"signed_in_as": "Signed in as {user}",
"generated": "As of: {ts}",
"ban_heading": "Ban IP address",
"ban_ip_placeholder": "IP address, e.g. 203.0.113.7",
"ban_note_placeholder": "Note (optional, audit log only)",
"ban_button": "Ban permanently",
"ban_hint": "The ban goes into the jail {jail} (permanent). Private networks and addresses protected on the host are rejected by the agent.",
"bans_heading": "Current bans",
"filter_search": "Search (IP, country, city)",
"filter_all_hosts": "All hosts",
"filter_all_jails": "All jails",
"col_host": "Host",
"col_jail": "Jail",
"col_ip": "IP",
"col_origin": "Origin",
"col_since": "Banned since",
"col_until": "Until",
"col_actions": "Actions",
"loading": "Loading …",
"no_bans": "No bans found.",
"more_rows": "… {n} more – use the filters.",
"permanent": "permanent",
"btn_unban": "Unban",
"btn_unban_everywhere": "Everywhere",
"btn_history": "History",
"jail": "Jail",
"jail_current": "current",
"jail_total": "since start",
"audit_heading": "Audit log (last 50 actions)",
"audit_empty": "No actions yet.",
"audit_banned": "banned",
"audit_unbanned": "unbanned",
"audit_line": "{ts} – {ip} {what} on {hosts}",
"audit_where_jail": " (jail {jail})",
"audit_where_all": " (all jails)",
"audit_failed": " – FAILED",
"audit_by": " · by {user} ({client})",
"history_title": "History {ip} ({days} days)",
"history_close": "Close",
"origin_unknown": "Origin unknown",
"history_none": "No entries in Loki.",
"confirm_unban_all": "Unban {ip} on ALL hosts in ALL jails?",
"confirm_unban": "Unban {ip} on {host} in jail {jail}?",
"confirm_ban": "Ban {ip} PERMANENTLY on {hosts}?",
"toast_unban": "Unban {ip}: {result}",
"toast_ban": "Ban {ip}: {result}",
"toast_load_failed": "Loading failed: {error}",
"select_host": "Please select at least one host.",
"result_ok": "OK",
"result_error": "Error",
"footer_geo": "IP geolocation:",
"login_title": "Sign in",
"login_user": "Username",
"login_password": "Password",
"login_button": "Sign in",
"login_oidc": "Sign in with {provider}",
"login_or": "or",
"login_failed": "Invalid username or password.",
"login_locked": "Too many failed attempts. Try again in {minutes} minutes.",
"login_oidc_failed": "Single sign-on failed: {error}",
"login_denied": "The account {user} is not allowed to use this application.",
"login_csrf": "Session expired – please try again.",
"error_title": "Error {code}",
"error_back": "Back to the start page",
"err_login_required": "Not signed in or session expired – please reload the page.",
"err_csrf": "CSRF token missing or invalid – reload the page.",
"err_origin": "Request from a foreign origin rejected.",
"err_json": "JSON expected.",
"err_invalid_ip": "Invalid IP address: {value}",
"err_no_host": "No valid host selected.",
"err_unknown_host": "Unknown host.",
"err_invalid_jail": "Invalid jail name.",
"err_timeout": "Timeout talking to {host}",
"err_ssh": "{host}: {detail}",
"err_exit": "{host}: exit code {code}",
"err_loki_disabled": "Loki is not configured.",
"err_loki_http": "Loki error {code}: {detail}",
"err_loki_timeout": "Loki query took too long (timeout).",
"err_loki_unreachable": "Loki not reachable: {error}",
"err_forbidden_proxy": "Access is only allowed via the configured reverse proxy.",
"agent_f2b_down": "fail2ban is not reachable on the host: {detail}",
"agent_invalid_ip": "Invalid IP address: {value}",
"agent_protected": "{ip} is protected on this host (private network, own address or PROTECTED list).",
"agent_no_manual_jail": "The jail {jail} does not exist on this host.",
"agent_unknown_jail": "Unknown jail: {jail}",
"agent_not_root": "The agent does not run as root – check the sudo rule.",
"agent_not_allowed": "The agent rejected the command.",
"map_heading": "Origin map",
"map_summary": "{ips} bans from {places} places",
"map_unknown": " · {n} without location",
"map_hint": "Click a point to filter the list.",
"map_point": "{place}: {n} bans",
"map_reset": "Show all"
}
+259
View File
@@ -0,0 +1,259 @@
"use strict";
const $ = (sel) => document.querySelector(sel);
const CSRF = document.querySelector('meta[name="csrf-token"]').content;
const I18N = JSON.parse($("#i18n").textContent);
const CFG = JSON.parse($("#client-config").textContent);
let state = { bans: [], hosts: {} };
// ---------- Helpers ----------
function t(key, params = {}) {
return (I18N[key] ?? key).replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [k, v] of Object.entries(attrs)) {
if (k === "class") node.className = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
}
for (const c of children) node.append(c instanceof Node ? c : document.createTextNode(c ?? ""));
return node;
}
function toast(msg, isError = false) {
const box = $("#toast");
box.textContent = msg;
box.className = "show" + (isError ? " error" : "");
clearTimeout(toast.timer);
toast.timer = setTimeout(() => { box.className = ""; }, isError ? 8000 : 4000);
}
async function api(path, body) {
const opts = body
? { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": CSRF }, body: JSON.stringify(body) }
: {};
const res = await fetch(path, opts);
let data;
try { data = await res.json(); } catch { data = { ok: false, error: `HTTP ${res.status}` }; }
if (!res.ok && data.ok !== false) data.ok = false;
if (res.status === 401) setTimeout(() => location.reload(), 2500);
return data;
}
function resultText(data) {
if (data.error) return data.error;
return Object.entries(data.results || {})
.map(([h, r]) => `${h}: ${r.ok ? t("result_ok") : (r.error || r.result || t("result_error"))}`).join(" · ");
}
function localName(names, fallback) {
return (names && names[CFG.lang]) || fallback || "";
}
function grafanaLink(ip) {
if (!CFG.grafana || !CFG.loki) return null;
const esc = ip.replaceAll(".", "\\\\.");
const panes = { a: { datasource: CFG.grafanaLokiUid, queries: [{ refId: "A",
datasource: { type: "loki", uid: CFG.grafanaLokiUid }, expr: `${CFG.lokiSelector} |~ "${esc}"` }],
range: { from: `now-${CFG.lokiDays}d`, to: "now" } } };
return `${CFG.grafana}/explore?schemaVersion=1&orgId=1&panes=${encodeURIComponent(JSON.stringify(panes))}`;
}
// ---------- Rendering ----------
function renderHosts() {
const box = $("#hosts");
box.replaceChildren();
for (const [host, info] of Object.entries(state.hosts)) {
const card = el("div", { class: "card" });
const sum = Object.values(info.jails || {}).reduce((a, j) => a + j.current, 0);
card.append(el("h3", {}, host, el("span", { class: "badge" }, String(sum))));
if (!info.ok) {
card.append(el("div", { class: "err" }, info.error));
} else {
const list = el("div", { class: "jail-list" },
el("span", { class: "h" }, t("jail")), el("span", { class: "h" }, t("jail_current")),
el("span", { class: "h" }, t("jail_total")));
for (const [jail, j] of Object.entries(info.jails).sort()) {
list.append(el("span", {}, jail), el("span", {}, String(j.current)), el("span", { class: "muted" }, String(j.total ?? "–")));
}
card.append(list);
}
box.append(card);
}
}
// World map: one point per place (rounded to 0.5°), size grows with the number of bans.
// Projection matches static/world.svg: x = lon + 180, y = 90 - lat.
const SVG_NS = "http://www.w3.org/2000/svg";
function renderMap(rows) {
const layer = $("#map-points");
if (!layer) return;
const places = new Map();
let unknown = 0;
for (const b of rows) {
if (b.lat == null || b.lon == null) { unknown++; continue; }
const key = `${Math.round(b.lat * 2) / 2},${Math.round(b.lon * 2) / 2}`;
const p = places.get(key) || { lat: b.lat, lon: b.lon, n: 0, cc: b.cc,
name: [localName(b.city_names, b.city), localName(b.country_names, b.country)].filter(Boolean).join(", ") || b.cc || "?" };
p.n++;
places.set(key, p);
}
layer.replaceChildren();
// big points first, so small ones stay clickable on top
for (const p of [...places.values()].sort((a, b) => b.n - a.n)) {
const c = document.createElementNS(SVG_NS, "circle");
c.setAttribute("cx", (p.lon + 180).toFixed(2));
c.setAttribute("cy", (90 - p.lat).toFixed(2));
c.setAttribute("r", (1.3 + Math.sqrt(p.n) * 0.9).toFixed(2));
const title = document.createElementNS(SVG_NS, "title");
title.textContent = t("map_point", { place: p.name, n: p.n });
c.append(title);
c.addEventListener("click", () => {
$("#filter-text").value = p.cc || "";
renderBans();
});
layer.append(c);
}
$("#map-summary").textContent = t("map_summary", { ips: rows.length - unknown, places: places.size })
+ (unknown ? t("map_unknown", { n: unknown }) : "");
$("#map-reset").hidden = !$("#filter-text").value;
}
function fillSelect(sel, values) {
const cur = sel.value;
const first = sel.options[0];
sel.replaceChildren(first, ...values.map((v) => el("option", { value: v }, v)));
sel.value = values.includes(cur) ? cur : "";
}
function renderBans() {
const text = $("#filter-text").value.trim().toLowerCase();
const host = $("#filter-host").value;
const jail = $("#filter-jail").value;
const rows = state.bans.filter((b) =>
(!host || b.host === host) && (!jail || b.jail === jail) &&
(!text || [b.ip, b.cc, b.country, b.city, localName(b.country_names), localName(b.city_names)]
.join(" ").toLowerCase().includes(text)));
$("#count").textContent = String(rows.length);
renderMap(rows);
const tbody = $("#bans");
tbody.replaceChildren();
if (!rows.length) {
tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("no_bans"))));
return;
}
for (const b of rows.slice(0, 1000)) {
const origin = [b.cc, localName(b.country_names, b.country), localName(b.city_names, b.city)]
.filter(Boolean).join(" · ") || "–";
// bantime null: older fail2ban without --with-time, start/end unknown
const until = b.bantime === null ? "–" : b.bantime < 0 ? el("span", { class: "perm" }, t("permanent")) : b.end;
const actions = el("td", { class: "actions" },
el("button", { class: "small", type: "button", onclick: () => unban(b.host, b.jail, b.ip) }, t("btn_unban")),
el("button", { class: "small", type: "button", onclick: () => unban("*", "*", b.ip) }, t("btn_unban_everywhere")));
if (CFG.loki) {
actions.append(el("button", { class: "small", type: "button", onclick: () => history(b.ip) }, t("btn_history")));
}
const link = grafanaLink(b.ip);
if (link) actions.append(el("a", { class: "btn-link small", href: link, target: "_blank", rel: "noopener" }, t("grafana")));
// data-label: column name for the card view on narrow screens
tbody.append(el("tr", {},
el("td", { "data-label": t("col_host") }, b.host), el("td", { "data-label": t("col_jail") }, b.jail),
el("td", { "data-label": t("col_ip"), class: "ip" }, el("code", {}, b.ip)),
el("td", { "data-label": t("col_origin") }, origin), el("td", { "data-label": t("col_since") }, b.start || "–"),
el("td", { "data-label": t("col_until") }, until), actions));
}
if (rows.length > 1000) {
tbody.append(el("tr", {}, el("td", { colspan: "7", class: "muted" }, t("more_rows", { n: rows.length - 1000 }))));
}
}
async function loadAudit() {
const data = await api("/api/audit");
const ul = $("#audit");
ul.replaceChildren();
if (!data.entries || !data.entries.length) { ul.append(el("li", { class: "muted" }, t("audit_empty"))); return; }
for (const e of data.entries) {
const failed = Object.values(e.ok || {}).some((v) => !v);
const what = e.action === "ban" ? t("audit_banned") : t("audit_unbanned");
const where = e.jail && e.jail !== "*" ? t("audit_where_jail", { jail: e.jail })
: e.action === "unban" ? t("audit_where_all") : "";
ul.append(el("li", { class: failed ? "bad" : "" },
t("audit_line", { ts: e.ts, ip: e.ip, what, hosts: (e.hosts || []).join(", ") }) + where,
e.note ? ` – „${e.note}“` : "", failed ? t("audit_failed") : "",
el("span", { class: "muted" }, t("audit_by", { user: e.user || "?", client: e.client }))));
}
}
async function load(force = false) {
const btn = $("#refresh");
btn.disabled = true;
try {
const data = await api("/api/bans" + (force ? "?refresh=1" : ""));
if (!data.hosts) throw new Error(data.error || "?");
state = data;
$("#generated").textContent = t("generated", { ts: data.generated });
fillSelect($("#filter-host"), Object.keys(data.hosts));
fillSelect($("#filter-jail"), [...new Set(data.bans.map((b) => b.jail))].sort());
renderHosts();
renderBans();
} catch (err) {
toast(t("toast_load_failed", { error: err.message || err }), true);
} finally {
btn.disabled = false;
}
loadAudit();
}
// ---------- Actions ----------
async function unban(host, jail, ip) {
const question = host === "*" ? t("confirm_unban_all", { ip }) : t("confirm_unban", { ip, host, jail });
if (!confirm(question)) return;
const data = await api("/api/unban", { ip, host, jail });
toast(t("toast_unban", { ip, result: resultText(data) }), !data.ok);
load();
}
async function ban(ev) {
ev.preventDefault();
const ip = $("#ban-ip").value.trim();
const note = $("#ban-note").value.trim();
const hosts = [...document.querySelectorAll('input[name="ban-host"]:checked')].map((c) => c.value);
if (!hosts.length) { toast(t("select_host"), true); return; }
if (!confirm(t("confirm_ban", { ip, hosts: hosts.join(", ") }))) return;
const data = await api("/api/ban", { ip, hosts, note });
toast(t("toast_ban", { ip, result: resultText(data) }), !data.ok);
if (data.ok) { $("#ban-ip").value = ""; $("#ban-note").value = ""; }
load();
}
async function history(ip) {
const dlg = $("#history");
$("#history-title").textContent = t("history_title", { ip, days: CFG.lokiDays });
const body = $("#history-body");
body.replaceChildren(el("p", { class: "muted" }, t("loading")));
dlg.showModal();
const data = await api(`/api/history?ip=${encodeURIComponent(ip)}`);
body.replaceChildren();
if (!data.ok) { body.append(el("p", { class: "err" }, data.error)); return; }
const g = data.geo || {};
body.append(el("p", {}, [g.cc, localName(g.country_names, g.country), localName(g.city_names, g.city)]
.filter(Boolean).join(" · ") || t("origin_unknown")));
if (!data.lines.length) { body.append(el("p", { class: "muted" }, t("history_none"))); return; }
for (const l of data.lines) {
body.append(el("div", { class: "line" },
el("span", { class: "muted" }, `${l.ts} · ${l.host} · ${l.job}`), el("pre", {}, l.line)));
}
}
// ---------- Start ----------
$("#refresh").addEventListener("click", () => load(true));
$("#ban-form").addEventListener("submit", ban);
$("#history-close").addEventListener("click", () => $("#history").close());
if ($("#map-reset")) $("#map-reset").addEventListener("click", () => { $("#filter-text").value = ""; renderBans(); });
for (const id of ["#filter-text", "#filter-host", "#filter-jail"]) $(id).addEventListener("input", renderBans);
load();
setInterval(() => load(), 60000);
+11
View File
@@ -0,0 +1,11 @@
"use strict";
// Language dropdown: remember the choice for a year and reload
const langSelect = document.getElementById("lang");
if (langSelect) {
langSelect.addEventListener("change", () => {
const secure = location.protocol === "https:" ? "; Secure" : "";
document.cookie = `lang=${encodeURIComponent(langSelect.value)}; Path=/; Max-Age=31536000; SameSite=Lax${secure}`;
location.reload();
});
}
+143
View File
@@ -0,0 +1,143 @@
:root {
--bg: #f5f6f8; --panel: #ffffff; --text: #1d2330; --muted: #6b7385; --border: #dfe3ea;
--accent: #2f6fdb; --danger: #c93b3b; --ok: #2e8b57; --warn: #c98a1b; --row: #f0f3f8;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #14171d; --panel: #1c2028; --text: #e3e7ee; --muted: #8f98a8; --border: #2c3240;
--accent: #5b8ff0; --danger: #e05d5d; --ok: #4fbf84; --warn: #e0a73c; --row: #222733;
}
}
* { box-sizing: border-box; }
body { margin: 0; font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; background: var(--bg); color: var(--text); }
header { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; justify-content: space-between; padding: 14px 20px; border-bottom: 1px solid var(--border); background: var(--panel); }
h1 { font-size: 18px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 10px; }
.meta { display: flex; gap: 10px; align-items: center; color: var(--muted); }
main { padding: 16px 20px; display: grid; gap: 16px; max-width: 1400px; margin: 0 auto; }
main > * { min-width: 0; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(280px, 100%), 1fr)); gap: 16px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; }
.card h3 { margin: 0 0 8px; font-size: 15px; display: flex; justify-content: space-between; }
.card .err { color: var(--danger); }
.jail-list { display: grid; grid-template-columns: 1fr auto auto; gap: 2px 12px; font-variant-numeric: tabular-nums; }
.jail-list .h { color: var(--muted); font-size: 12px; }
button, .btn-link { font: inherit; padding: 6px 12px; border-radius: 6px; border: 1px solid var(--border); background: var(--panel); color: var(--text); cursor: pointer; text-decoration: none; }
button:hover, .btn-link:hover { border-color: var(--accent); }
button.danger { background: var(--danger); border-color: var(--danger); color: #fff; }
button.small { padding: 2px 8px; font-size: 12px; }
button:disabled { opacity: .5; cursor: wait; }
input, select { font: inherit; padding: 6px 10px; border-radius: 6px; border: 1px solid var(--border); background: var(--bg); color: var(--text); }
#ban-form { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
#ban-ip { width: 260px; } #ban-note { flex: 1; min-width: 200px; }
.hosts-select { display: flex; gap: 10px; }
.hint { color: var(--muted); font-size: 12px; margin: 8px 0 0; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 10px; }
.toolbar h2 { margin: 0 auto 0 0; }
.badge { background: var(--accent); color: #fff; border-radius: 10px; padding: 0 8px; font-size: 12px; }
.table-wrap { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; }
th { color: var(--muted); font-weight: 600; font-size: 12px; }
tbody tr:hover { background: var(--row); }
td.actions { display: flex; gap: 4px; }
.muted { color: var(--muted); }
.perm { color: var(--danger); font-weight: 600; }
code { font-size: 12px; }
.audit { list-style: none; margin: 0; padding: 0; font-size: 13px; }
.audit li { padding: 4px 0; border-bottom: 1px solid var(--border); }
.audit .bad { color: var(--danger); }
dialog { width: min(1000px, 95vw); max-height: 85vh; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); color: var(--text); padding: 0; }
dialog::backdrop { background: rgba(0, 0, 0, .5); }
.dialog-head { display: flex; justify-content: space-between; align-items: center; padding: 12px 16px; border-bottom: 1px solid var(--border); position: sticky; top: 0; background: var(--panel); }
#history-body { padding: 12px 16px; overflow: auto; }
#history-body pre { white-space: pre-wrap; word-break: break-all; font-size: 12px; margin: 0; }
#history-body .line { padding: 4px 0; border-bottom: 1px solid var(--border); }
#toast { position: fixed; right: 20px; bottom: 20px; max-width: 420px; padding: 10px 14px; border-radius: 8px; background: var(--text); color: var(--panel); opacity: 0; transition: opacity .2s; pointer-events: none; }
#toast.show { opacity: .95; }
#toast.error { background: var(--danger); color: #fff; }
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 12px 0 24px; }
footer a { color: inherit; }
.audit li { overflow-wrap: anywhere; }
/* ---------- Tablet ---------- */
@media (max-width: 1000px) {
main { padding: 12px 16px; gap: 12px; }
#ban-ip { flex: 1 1 220px; width: auto; }
.toolbar h2 { flex: 1 1 100%; }
.toolbar input[type="search"] { flex: 1 1 220px; }
}
/* ---------- Handy ---------- */
@media (max-width: 820px) {
header { padding: 12px 16px; }
.meta { flex-wrap: wrap; width: 100%; }
#generated { flex: 1 1 100%; font-size: 12px; }
.meta button, .meta .btn-link { flex: 1; text-align: center; }
.panel, .card { padding: 12px; }
/* 16px verhindert das automatische Zoomen in iOS beim Antippen */
input, select { font-size: 16px; min-height: 40px; }
button, .btn-link { min-height: 40px; }
.btn-link { display: inline-flex; align-items: center; justify-content: center; }
#ban-form > input, #ban-form > button { flex: 1 1 100%; width: 100%; min-width: 0; }
.hosts-select { flex-wrap: wrap; gap: 6px 16px; }
.hosts-select label { display: inline-flex; align-items: center; gap: 6px; min-height: 32px; }
.toolbar input[type="search"] { flex: 1 1 100%; }
.toolbar select { flex: 1 1 0; min-width: 0; }
/* Tabelle wird zur Kartenliste */
.table-wrap { overflow: visible; }
table, tbody, tr, td { display: block; width: 100%; }
thead { display: none; }
tbody tr { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; margin-bottom: 10px; }
tbody tr:hover { background: none; }
td { display: flex; gap: 12px; justify-content: space-between; align-items: baseline; padding: 4px 0; border: 0; white-space: normal; text-align: right; overflow-wrap: anywhere; }
td[data-label]::before { content: attr(data-label); color: var(--muted); font-size: 12px; text-align: left; flex: none; }
td.ip { order: -1; justify-content: flex-start; font-size: 15px; font-weight: 600; padding-bottom: 6px; }
td.ip::before { display: none; }
td.ip code { font-size: 15px; }
td[colspan] { display: block; text-align: left; }
tbody tr:has(td[colspan]) { border: 0; padding: 0; }
td.actions { flex-wrap: wrap; gap: 6px; padding-top: 8px; margin-top: 4px; border-top: 1px solid var(--border); }
td.actions > * { flex: 1 1 calc(50% - 6px); text-align: center; min-height: 36px; font-size: 13px; padding: 6px 8px; }
dialog { width: 100vw; max-width: 100vw; height: 100dvh; max-height: 100dvh; margin: 0; border-radius: 0; border: 0; }
.dialog-head { padding: 10px 12px; }
#history-body { padding: 10px 12px; }
#toast { left: 12px; right: 12px; bottom: 12px; max-width: none; }
}
/* ---------- Header: language, user, logout ---------- */
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.lang-select select { padding: 5px 8px; }
.user { font-weight: 600; color: var(--text); max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
form.inline { display: inline; margin: 0; }
button.primary, .btn-link.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
/* ---------- Login / error page ---------- */
main.login { max-width: 420px; }
.login-form { display: grid; gap: 12px; }
.login-form label { display: grid; gap: 4px; color: var(--muted); font-size: 13px; }
.login-form input { width: 100%; }
.login .sso { display: block; text-align: center; }
.login .or { text-align: center; margin: 12px 0; }
.msg { padding: 8px 10px; border-radius: 6px; margin: 0 0 12px; overflow-wrap: anywhere; }
.msg.error { background: color-mix(in srgb, var(--danger) 15%, transparent); color: var(--danger); }
@media (max-width: 820px) {
.lang-select, .lang-select select { flex: 1; }
.user { flex: 1 1 100%; max-width: none; }
form.inline { flex: 1; display: flex; }
form.inline button { flex: 1; }
}
/* ---------- Origin map ---------- */
.map svg { display: block; width: 100%; height: auto; max-height: 360px; }
.map circle { fill: var(--danger); fill-opacity: .5; stroke: var(--danger); stroke-width: .3; cursor: pointer; transition: fill-opacity .15s; }
.map circle:hover { fill-opacity: .9; }
.map-panel .toolbar h2 { margin-right: 0; }
.map-panel #map-summary { margin-right: auto; font-size: 13px; }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 5 360 145"><!-- Natural Earth 110m land (public domain), equirectangular --><path fill="#8a8f98" fill-opacity=".35" d="M112.2 143.8L113.5 144.5L114.9 144.7L114.5 145.2L113.5 145.2L113 144.9L112.7 145.3L111.9 145.6L110.8 145.5L110 145.2L109 145.1L107.7 144.5L106.7 144L105.3 142.8L106.2 143L107.6 143.7L108.9 144.1L109.4 143.6L109.7 142.9L110.7 142.5L111.4 142.6L111.7 143.1L112.2 143.8ZM121.5 141.1L122.3 141.5L122 141.9L120.6 142.2L120.2 141.8L119.3 142.3L118.8 141.8L120 141.3L120.8 141.5L121.5 141.1ZM250.3 139.7L248.7 139.8L248.7 139.2L248.9 138.8L248.9 138.6L249.6 138.9L250.5 139.1L250.6 139.3L250.3 139.7ZM325.4 130.8L326.4 131.1L326.9 131L327.7 130.8L328.3 130.9L328.4 132.1L328 132.4L327.9 133.2L327.6 132.9L326.9 133.6L326.7 133.6L326 133.5L325.4 132.7L325.3 132L324.7 131.2L324.7 130.7L325.4 130.8ZM353 130.9L353.2 131.3L354 130.9L354.2 131.3L354.2 131.8L353.9 132.2L353.2 133L352.7 133.4L353.1 133.9L352.3 133.9L351.5 134.2L351.2 134.9L350.6 135.9L349.8 136.4L349.3 136.6L348.4 136.6L347.8 136.3L346.7 136.2L346.5 135.9L347 135.1L348.3 134.1L348.9 133.9L349.7 133.6L350.5 133L351.1 132.5L351.6 131.8L352 131.5L352.1 131L352.8 130.5L353 130.9ZM354.6 126.2L355.3 127.2L355.4 126.5L355.8 126.8L356 127.6L356.8 127.9L357.4 128L358 127.6L358.5 127.7L358.3 128.6L358 129.2L357.2 129.1L356.9 129.4L357 129.9L356.9 130.1L356.5 130.6L356 131.3L355.2 131.7L355.1 131.4L354.7 131.3L355.2 130.5L354.9 129.9L353.8 129.5L353.9 129.1L354.6 128.8L354.7 128L354.7 127.4L354.3 126.7L354.3 126.5L353.8 126.1L353.1 125.2L352.6 124.5L353 124.5L353.6 125L354.3 125.3L354.6 126.2ZM347.1 112.2L346.7 112.4L346.2 112.1L345.5 111.7L344.8 111.1L344.2 110.4L344 110.1L344.5 110.1L345 110.5L345.5 110.8L345.8 111.1L346.6 111.7L347.1 112.2ZM358.4 107.3L358.7 107.6L358.6 108.2L357.9 108.3L357.4 108.2L357.3 107.7L357.7 107.4L358.1 107.5L358.4 107.3ZM359.4 106.8L358.7 107L358.6 106.6L359.1 106.4L359.4 106.4ZM0 106.1L0.2 106L0.1 106.5L0 106.6ZM347.8 106.5L347.5 106.6L347.2 106.2L347.2 105.9L347.8 106.5ZM347.1 104.9L347.3 105.7L347 105.6L346.8 105.7L346.7 105.4L346.6 104.6L347.1 104.9ZM230.1 103.6L230.2 104.8L230.5 105.2L230.4 105.7L230.2 106L229.9 105.4L229.7 105.7L229.9 106.5L229.8 106.9L229.5 107.1L229.4 108L229 109.1L228.5 110.5L227.9 112.4L227.5 113.8L227.1 114.9L226.3 115.2L225.4 115.6L224.8 115.3L224 115L223.8 114.5L223.7 113.6L223.3 112.8L223.3 112.1L223.4 111.3L223.9 111.2L223.9 110.8L224.4 110.1L224.5 109.4L224.2 109L224 108.3L224 107.4L224.3 106.9L224.4 106.2L224.9 106.2L225.5 106L225.9 105.8L226.3 105.8L226.9 105.2L227.7 104.6L228 104.1L227.9 103.7L228.3 103.8L228.8 103.1L228.9 102.5L229.2 102L229.5 102.5L229.8 102.9L230.1 103.6ZM323.6 103.8L323.9 104.5L324.6 104.2L324.9 104.6L325.4 105L325.3 105.4L325.5 106.3L325.6 106.8L325.9 106.9L326.2 107.8L326.1 108.3L326.4 109L327.5 109.5L328.2 110L328.8 110.4L328.7 110.6L329.3 111.3L329.7 112.3L330.1 112.1L330.5 112.6L330.7 112.4L330.9 113.5L331.6 114.1L332.1 114.5L332.9 115.3L333.1 116.1L333.2 116.6L333.1 117.3L333.6 118.1L333.5 119L333.3 119.5L333.1 120.4L333.1 120.9L332.9 121.6L332.4 122.6L331.7 123L331.3 123.8L331 124.3L330.7 125.2L330.3 125.7L330.1 126.4L329.9 127.1L330 127.4L329.4 127.8L328.3 127.8L327.4 128.2L326.9 128.6L326.3 129L325.5 128.6L324.9 128.4L325 127.9L324.5 128.1L323.6 128.8L322.7 128.5L322.2 128.4L321.6 128.3L320.6 128L320 127.4L319.8 126.6L319.6 126.1L319.1 125.7L318.1 125.6L318.4 125.1L318.2 124.4L317.7 125.1L316.8 125.3L317.4 124.7L317.5 124.1L317.9 123.6L317.8 122.9L317 123.8L316.4 124.1L316 124.9L315.2 124.5L315.2 123.9L314.6 123.2L314.1 122.8L314.3 122.6L313 122L312.3 122L311.3 121.5L309.5 121.6L308.2 121.9L307.1 122.3L306.1 122.2L305.1 122.7L304.2 123L304 123.5L303.7 123.9L302.8 123.9L302.2 124L301.3 123.8L300.6 123.9L299.9 124L299.3 124.5L299 124.5L298.5 124.7L298 125.1L297.3 125L296.6 125L295.6 124.4L295 124.2L295 123.6L295.5 123.5L295.7 123.3L295.7 122.9L295.8 122.2L295.7 121.6L295.2 120.6L295 120L295 119.5L294.6 118.8L294.6 118.5L294.2 118.1L294 117.3L293.5 116.5L293.3 116.1L293.8 116.5L293.4 115.6L293.9 115.9L294.2 116.3L294.2 115.8L293.7 115L293.6 114.7L293.4 114.4L293.5 113.8L293.7 113.6L293.8 113.1L293.7 112.5L294.1 111.8L294.2 112.5L294.6 111.8L295.5 111.5L295.9 111.1L296.7 110.7L297.2 110.6L297.4 110.7L298.2 110.4L298.8 110.3L299 110L299.3 110L299.8 110L300.9 109.7L301.4 109.2L301.7 108.7L302.2 108.2L302.3 107.8L302.3 107.3L303 106.4L303.4 107.3L303.9 107.1L303.5 106.6L303.8 106.1L304.3 106.3L304.4 105.6L304.9 105.1L305.2 104.7L305.7 104.5L305.7 104.2L306.1 104.3L306.1 104.1L306.6 104L307.1 103.8L307.8 104.3L308.4 104.9L309 104.9L309.6 105L309.4 104.4L309.9 103.6L310.3 103.4L310.2 103.1L310.6 102.5L311.2 102.2L311.7 102.3L312.6 102.1L312.6 101.6L311.8 101.3L312.4 101.1L313 101.4L313.6 101.8L314.4 102L314.7 101.9L315.3 102.2L315.9 102L316.3 102L316.5 101.9L317 102.4L316.7 102.9L316.3 103.3L316 103.3L316.1 103.7L315.8 104.2L315.4 104.7L315.5 105L316.3 105.6L317.1 105.9L317.6 106.2L318.3 106.8L318.6 106.8L319.1 107.1L319Line truncated

After

Width:  |  Height:  |  Size: 46 KiB

+42
View File
@@ -0,0 +1,42 @@
<!doctype html>
<html lang="{{ lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ t('title') }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
<meta name="csrf-token" content="{{ csrf }}">
</head>
<body class="{% block body_class %}{% endblock %}">
<header>
<h1>{{ t('title') }}</h1>
<div class="meta">
{% block header_meta %}{% endblock %}
<label class="lang-select">
<span class="sr-only">{{ t('language') }}</span>
<select id="lang" aria-label="{{ t('language') }}">
{% for code, name in languages %}<option value="{{ code }}"{% if code == lang %} selected{% endif %}>{{ name }}</option>{% endfor %}
</select>
</label>
{% if user %}
<span class="user" title="{{ t('signed_in_as', user=user) }}">{{ user }}</span>
{% if logout_local %}
<form method="post" action="{{ url_for('auth.logout') }}" class="inline">
<input type="hidden" name="csrf" value="{{ csrf }}">
<button type="submit">{{ t('logout') }}</button>
</form>
{% elif logout_url %}
<a class="btn-link" href="{{ logout_url }}">{{ t('logout') }}</a>
{% endif %}
{% endif %}
</div>
</header>
{% block content %}{% endblock %}
<footer>
{% if geo_enabled %}{{ t('footer_geo') }} <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP Lite</a> (CC BY 4.0) · {% endif %}
<a href="https://git.retronerd.at/schiwagoa/fail2ban-webui" target="_blank" rel="noopener">fail2ban-webui</a> {{ version }}
</footer>
<script src="{{ url_for('static', filename='common.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}{{ t('error_title', code=code) }} – {{ t('title') }}{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('error_title', code=code) }}</h2>
<p class="msg error">{{ message }}</p>
<p><a href="{{ url_for('index') }}">{{ t('error_back') }}</a></p>
</section>
</main>
{% endblock %}
+79
View File
@@ -0,0 +1,79 @@
{% extends "base.html" %}
{% block header_meta %}
<span id="generated">–</span>
<button id="refresh" type="button">{{ t('refresh') }}</button>
{% if client_config.grafana and client_config.grafanaDashboard %}<a class="btn-link" href="{{ client_config.grafana }}{{ client_config.grafanaDashboard }}" target="_blank" rel="noopener">{{ t('grafana') }}</a>{% endif %}
{% endblock %}
{% block content %}
<main>
<section id="hosts" class="cards"></section>
{% if geo_enabled %}
<section class="panel map-panel">
<div class="toolbar">
<h2>{{ t('map_heading') }}</h2>
<span id="map-summary" class="muted"></span>
<button id="map-reset" type="button" class="small" hidden>{{ t('map_reset') }}</button>
</div>
<div class="map">
<svg id="map" viewBox="0 5 360 145" role="img" aria-label="{{ t('map_heading') }}">
<image href="{{ url_for('static', filename='world.svg') }}" x="0" y="5" width="360" height="145"/>
<g id="map-points"></g>
</svg>
</div>
<p class="hint">{{ t('map_hint') }}</p>
</section>
{% endif %}
<section class="panel">
<h2>{{ t('ban_heading') }}</h2>
<form id="ban-form" autocomplete="off">
<input id="ban-ip" type="text" placeholder="{{ t('ban_ip_placeholder') }}" required>
<input id="ban-note" type="text" placeholder="{{ t('ban_note_placeholder') }}" maxlength="200">
<span class="hosts-select">
{% for h in hosts %}<label><input type="checkbox" name="ban-host" value="{{ h }}" checked> {{ h }}</label>{% endfor %}
</span>
<button type="submit" class="danger">{{ t('ban_button') }}</button>
</form>
<p class="hint">{{ t('ban_hint', jail=manual_jail) }}</p>
</section>
<section class="panel">
<div class="toolbar">
<h2>{{ t('bans_heading') }} <span id="count" class="badge">0</span></h2>
<input id="filter-text" type="search" placeholder="{{ t('filter_search') }}">
<select id="filter-host"><option value="">{{ t('filter_all_hosts') }}</option></select>
<select id="filter-jail"><option value="">{{ t('filter_all_jails') }}</option></select>
</div>
<div class="table-wrap">
<table>
<thead>
<tr><th>{{ t('col_host') }}</th><th>{{ t('col_jail') }}</th><th>{{ t('col_ip') }}</th><th>{{ t('col_origin') }}</th><th>{{ t('col_since') }}</th><th>{{ t('col_until') }}</th><th>{{ t('col_actions') }}</th></tr>
</thead>
<tbody id="bans"><tr><td colspan="7" class="muted">{{ t('loading') }}</td></tr></tbody>
</table>
</div>
</section>
<section class="panel">
<h2>{{ t('audit_heading') }}</h2>
<ul id="audit" class="audit"></ul>
</section>
</main>
<dialog id="history">
<div class="dialog-head">
<h2 id="history-title"></h2>
<button type="button" id="history-close" aria-label="{{ t('history_close') }}">✕</button>
</div>
<div id="history-body"></div>
</dialog>
<div id="toast" role="status" aria-live="polite"></div>
<script id="i18n" type="application/json">{{ i18n | tojson }}</script>
<script id="client-config" type="application/json">{{ client_config | tojson }}</script>
{% endblock %}
{% block scripts %}<script src="{{ url_for('static', filename='app.js') }}"></script>{% endblock %}
+26
View File
@@ -0,0 +1,26 @@
{% extends "base.html" %}
{% block title %}{{ t('login_title') }} – {{ t('title') }}{% endblock %}
{% block body_class %}login-page{% endblock %}
{% block content %}
<main class="login">
<section class="panel">
<h2>{{ t('login_title') }}</h2>
{% for category, message in get_flashed_messages(with_categories=true) %}
<p class="msg {{ category }}">{{ message }}</p>
{% endfor %}
{% if local %}
<form method="post" action="{{ url_for('auth.login_post') }}" class="login-form">
<input type="hidden" name="csrf" value="{{ csrf }}">
<label>{{ t('login_user') }}<input name="username" autocomplete="username" required autofocus></label>
<label>{{ t('login_password') }}<input name="password" type="password" autocomplete="current-password" required></label>
<button type="submit" class="primary">{{ t('login_button') }}</button>
</form>
{% endif %}
{% if local and oidc %}<p class="or muted">{{ t('login_or') }}</p>{% endif %}
{% if oidc %}
<a class="btn-link primary sso" href="{{ url_for('auth.oidc_start') }}">{{ t('login_oidc', provider=oidc_label) }}</a>
{% endif %}
</section>
</main>
{% endblock %}