#!/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 ban an IP permanently in the manual jail # unban unban an IP in one jail # unban-all 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()