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

+226
View File
@@ -0,0 +1,226 @@
#!/usr/bin/env python3
# =====================================================================
# f2b-webctl – restricted fail2ban control for fail2ban-webui
# Path: /usr/local/sbin/f2b-webctl (root:root, 0750)
# Config: /etc/f2b-webctl.conf
#
# Runs only as SSH "forced command" of the fail2ban-webui key, e.g.
# restrict,from="198.51.100.10",command="sudo -n /usr/local/sbin/f2b-webctl" ssh-ed25519 AAAA…
# The requested command comes from $SSH_ORIGINAL_COMMAND
# (when started by hand as root: from the arguments):
#
# list all jails with banned IPs as JSON
# ban <ip> ban an IP permanently in the manual jail
# unban <jail> <ip> unban an IP in one jail
# unban-all <ip> unban an IP in all jails
# version agent version
#
# Everything else is rejected. Every action goes to syslog (tag f2b-webctl).
# Errors carry "error_key"/"error_args" so the web UI can translate them.
# =====================================================================
import ipaddress
import json
import os
import re
import shlex
import socket
import subprocess
import sys
import syslog
VERSION = "1.0.0"
CONF_FILE = "/etc/f2b-webctl.conf"
JAIL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$")
BAN_RE = re.compile(
r"^(\S+)\s+(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d) \+ (-?\d+) = (\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)")
# Never banned, regardless of the configuration: loopback, private, link-local,
# CGNAT, multicast and reserved ranges
BUILTIN_PROTECTED = (
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
"172.16.0.0/12", "192.168.0.0/16", "224.0.0.0/4", "240.0.0.0/4",
"::/128", "::1/128", "fc00::/7", "fe80::/10", "ff00::/8",
)
# ---------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------
def out(obj, code=0):
print(json.dumps(obj, ensure_ascii=False))
sys.exit(code)
def fail(key, text, code=1, **args):
out({"ok": False, "error_key": key, "error_args": args, "error": text}, code)
def client():
return os.environ.get("SSH_CLIENT", "local").split(" ")[0]
def log(msg):
syslog.openlog("f2b-webctl", 0, syslog.LOG_AUTH)
syslog.syslog(syslog.LOG_NOTICE, f"{msg} (from {client()})")
def load_conf():
conf = {"MANUAL_JAIL": "manual-block", "PROTECTED": "",
"PROTECT_OWN_ADDRESSES": "yes", "PROTECT_CLIENT": "yes"}
try:
with open(CONF_FILE, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
value = " ".join(shlex.split(value, comments=True)) if value.strip() else ""
conf[key.strip().upper()] = value
except FileNotFoundError:
pass
return conf
def own_addresses():
addrs = set()
try:
r = subprocess.run(["ip", "-o", "addr", "show"], capture_output=True, text=True, timeout=5)
addrs.update(m.split("/")[0] for m in re.findall(r"\binet6? (\S+)", r.stdout))
except (OSError, subprocess.SubprocessError):
pass
try:
for info in socket.getaddrinfo(socket.getfqdn(), None):
addrs.add(info[4][0])
except OSError:
pass
return addrs
def protected_networks(conf):
nets = [ipaddress.ip_network(n) for n in BUILTIN_PROTECTED]
for item in re.split(r"[\s,]+", conf["PROTECTED"]):
if item:
try:
nets.append(ipaddress.ip_network(item, strict=False))
except ValueError:
log(f"invalid entry in PROTECTED ignored: {item!r}")
extra = set()
if conf["PROTECT_OWN_ADDRESSES"].lower() in ("yes", "true", "1"):
extra |= own_addresses()
if conf["PROTECT_CLIENT"].lower() in ("yes", "true", "1") and client() != "local":
extra.add(client())
for a in extra:
try:
nets.append(ipaddress.ip_network(a.split("%")[0]))
except ValueError:
pass
return nets
def f2b(*args):
return subprocess.run(["fail2ban-client", *args], capture_output=True, text=True, timeout=30)
def jails():
r = f2b("status")
if r.returncode != 0:
detail = (r.stderr or r.stdout).strip()[:200]
fail("agent_f2b_down", f"fail2ban is not reachable: {detail}", 1, detail=detail)
m = re.search(r"Jail list:\s*(.*)", r.stdout)
return [j.strip() for j in m.group(1).split(",") if j.strip()] if m else []
def valid_ip(value):
try:
return str(ipaddress.ip_address(value))
except ValueError:
fail("agent_invalid_ip", f"Invalid IP address: {value!r}", 2, value=value)
def result(r):
return (r.stdout or r.stderr).strip()[:300]
# ---------------------------------------------------------------------
# Commands
# ---------------------------------------------------------------------
def cmd_list(conf):
data = {"host": socket.gethostname(), "agent_version": VERSION,
"manual_jail": conf["MANUAL_JAIL"], "jails": {}}
with_time = True
for jail in jails():
bans = []
if with_time:
r = f2b("get", jail, "banip", "--with-time")
if r.returncode == 0:
for line in r.stdout.splitlines():
m = BAN_RE.match(line.strip())
if m:
bans.append({"ip": m[1], "start": m[2], "bantime": int(m[3]), "end": m[4]})
else:
with_time = False # older fail2ban without --with-time
if not with_time:
r = f2b("get", jail, "banip")
for ip in re.split(r"[\s,]+", r.stdout.strip()):
try:
ipaddress.ip_address(ip)
except ValueError:
continue
bans.append({"ip": ip, "start": "", "bantime": None, "end": ""})
total = re.search(r"Total banned:\s*(\d+)", f2b("status", jail).stdout)
data["jails"][jail] = {"bans": bans, "total": int(total[1]) if total else None}
out({"ok": True, **data})
def cmd_ban(conf, ip):
addr = ipaddress.ip_address(ip)
if any(addr in net for net in protected_networks(conf)):
log(f"REJECTED ban {ip} (protected address)")
fail("agent_protected", f"{ip} is protected (private network, own address or PROTECTED list).", 3, ip=ip)
jail = conf["MANUAL_JAIL"]
if jail not in jails():
fail("agent_no_manual_jail", f"Jail {jail!r} does not exist on this host.", 2, jail=jail)
r = f2b("set", jail, "banip", ip)
ok = r.returncode == 0
log(f"ban {ip} -> {jail}: {'OK' if ok else result(r)}")
out({"ok": ok, "jail": jail, "ip": ip, "result": result(r)}, 0 if ok else 1)
def cmd_unban(jail, ip):
if jail not in jails():
fail("agent_unknown_jail", f"Unknown jail: {jail!r}", 2, jail=jail)
r = f2b("set", jail, "unbanip", ip)
ok = r.returncode == 0
log(f"unban {ip} from {jail}: {'OK' if ok else result(r)}")
out({"ok": ok, "jail": jail, "ip": ip, "result": result(r)}, 0 if ok else 1)
def cmd_unban_all(ip):
r = f2b("unban", ip)
ok = r.returncode == 0
log(f"unban-all {ip}: {'OK' if ok else result(r)}")
out({"ok": ok, "jail": "*", "ip": ip, "result": result(r)}, 0 if ok else 1)
def main():
if os.geteuid() != 0:
fail("agent_not_root", "f2b-webctl must run as root (check the sudo rule).", 4)
original = os.environ.get("SSH_ORIGINAL_COMMAND")
args = original.split() if original is not None else sys.argv[1:]
conf = load_conf()
if args == ["list"]:
cmd_list(conf)
elif args == ["version"]:
out({"ok": True, "agent_version": VERSION})
elif len(args) == 2 and args[0] == "ban":
cmd_ban(conf, valid_ip(args[1]))
elif len(args) == 3 and args[0] == "unban" and JAIL_RE.fullmatch(args[1]):
cmd_unban(args[1], valid_ip(args[2]))
elif len(args) == 2 and args[0] == "unban-all":
cmd_unban_all(valid_ip(args[1]))
else:
log(f"REJECTED unknown command: {' '.join(args)[:100]!r}")
fail("agent_not_allowed", "Command not allowed.", 2)
if __name__ == "__main__":
main()