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

+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