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()
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env bash
# =====================================================================
# fail2ban-webui agent – installer
#
# Installs on a host with fail2ban (bare metal, VM, LXC, Docker host):
# /usr/local/sbin/f2b-webctl restricted fail2ban control (forced command)
# /etc/f2b-webctl.conf agent settings (protected networks, manual jail)
# user f2bweb + /etc/sudoers.d/f2b-webctl (not with --user root)
# authorized_keys entry restrict,from=…,command=… <key of the web UI>
# jail manual-block for manual bans (only if it does not exist yet)
#
# Idempotent: running it again updates the script and replaces the key entry.
# Existing files are backed up to --backup-dir first.
#
# Example:
# ./install.sh --key-file id_ed25519.pub --from 198.51.100.10
# =====================================================================
set -euo pipefail
SRC_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
AGENT_BIN=/usr/local/sbin/f2b-webctl
CONF_FILE=/etc/f2b-webctl.conf
SUDOERS_FILE=/etc/sudoers.d/f2b-webctl
USER_NAME=f2bweb
KEY=""
KEY_FILE=""
FROM=""
AK_FILE=""
MANUAL_JAIL=manual-block
BANACTION=""
PROTECT=""
INSTALL_JAIL=1
FORCE_CONFIG=0
BACKUP_DIR=/var/backups/fail2ban-webui-agent
DRY_RUN=0
YES=0
usage() {
cat <<'EOF'
Usage: install.sh --key-file FILE | --key "ssh-ed25519 AAAA…" [options]
Required:
--key-file FILE public key of the web UI (from: fail2ban-webui keygen)
--key "KEY" the same, given directly
Recommended:
--from ADDRESSES only accept the key from these IPs/networks
(comma separated, e.g. 198.51.100.10,2001:db8::10)
Options:
--user NAME SSH user of the agent (default: f2bweb, uses sudo);
"root" works without sudo
--authorized-keys-file PATH where the key entry goes
(default: ~USER/.ssh/authorized_keys)
--manual-jail NAME jail for manual bans (default: manual-block)
--banaction ACTION banaction for a newly created manual jail
(default: the banaction of your jail.local)
--protect NETWORKS additional addresses that must never be banned
(comma separated, e.g. your home IP)
--no-jail do not create the manual jail
--force-config overwrite an existing /etc/f2b-webctl.conf
--backup-dir DIR backups of changed files (default: /var/backups/fail2ban-webui-agent)
--dry-run only show what would be done
-y, --yes do not ask for confirmation
-h, --help this help
EOF
}
die() { echo "ERROR: $*" >&2; exit 1; }
warn() { echo "WARNING: $*" >&2; }
info() { echo "==> $*"; }
need() { [ $# -ge 2 ] && [ -n "$2" ] || die "option $1 needs a value"; }
while [ $# -gt 0 ]; do
case "$1" in
--key) need "$@"; KEY="$2"; shift 2 ;;
--key-file) need "$@"; KEY_FILE="$2"; shift 2 ;;
--from) need "$@"; FROM="$2"; shift 2 ;;
--user) need "$@"; USER_NAME="$2"; shift 2 ;;
--authorized-keys-file) need "$@"; AK_FILE="$2"; shift 2 ;;
--manual-jail) need "$@"; MANUAL_JAIL="$2"; shift 2 ;;
--banaction) need "$@"; BANACTION="$2"; shift 2 ;;
--protect) need "$@"; PROTECT="$2"; shift 2 ;;
--no-jail) INSTALL_JAIL=0; shift ;;
--force-config) FORCE_CONFIG=1; shift ;;
--backup-dir) need "$@"; BACKUP_DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-y|--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
# ---------------------------------------------------------------------
# Checks (read only)
# ---------------------------------------------------------------------
[ "$(id -u)" -eq 0 ] || die "please run as root"
[ -f "$SRC_DIR/f2b-webctl" ] || die "$SRC_DIR/f2b-webctl not found – run install.sh from the agent directory"
command -v python3 >/dev/null || die "python3 is missing (apt install python3)"
command -v fail2ban-client >/dev/null || die "fail2ban-client not found – is fail2ban installed?"
fail2ban-client ping >/dev/null 2>&1 || die "fail2ban is not running (fail2ban-client ping failed)"
SSHD=$(command -v sshd || echo /usr/sbin/sshd)
[ -x "$SSHD" ] || die "sshd not found – the agent is reached via SSH"
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid user name: $USER_NAME"
[[ "$MANUAL_JAIL" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$ ]] || die "invalid jail name: $MANUAL_JAIL"
[ -z "$FROM" ] || [[ "$FROM" =~ ^[0-9A-Fa-f.:/,*?!]+$ ]] || die "invalid --from: $FROM"
[ -z "$PROTECT" ] || [[ "$PROTECT" =~ ^[0-9A-Fa-f.:/,]+$ ]] || die "invalid --protect: $PROTECT"
[ -z "$BANACTION" ] || [[ "$BANACTION" != *$'\n'* ]] || die "invalid --banaction"
if [ -n "$KEY_FILE" ]; then
[ -r "$KEY_FILE" ] || die "cannot read $KEY_FILE"
KEY=$(grep -m1 -E '^(ssh-|ecdsa-|sk-)' "$KEY_FILE" || true)
fi
[ -n "$KEY" ] || die "no key given (--key-file or --key)"
read -r KEY_TYPE KEY_BLOB _ <<<"$KEY"
[[ "$KEY_TYPE" =~ ^(ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com)$ ]] \
|| die "unsupported key type: $KEY_TYPE"
[[ "$KEY_BLOB" =~ ^[A-Za-z0-9+/]+=*$ ]] || die "the key looks broken"
KEY_FP=$(ssh-keygen -lf /dev/stdin <<<"$KEY_TYPE $KEY_BLOB" 2>/dev/null) || die "ssh-keygen cannot read the key"
if [ "$USER_NAME" = root ]; then
FORCED_CMD="$AGENT_BIN"
HOME_DIR=/root
else
command -v sudo >/dev/null || die "sudo is missing (apt install sudo) – or use --user root"
command -v visudo >/dev/null || die "visudo is missing"
FORCED_CMD="sudo -n $AGENT_BIN"
HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6 || true)
[ -n "$HOME_DIR" ] || HOME_DIR=/var/lib/$USER_NAME
fi
AK_FILE=${AK_FILE:-$HOME_DIR/.ssh/authorized_keys}
OPTS="restrict"
[ -z "$FROM" ] || OPTS="$OPTS,from=\"$FROM\""
OPTS="$OPTS,command=\"$FORCED_CMD\""
AK_LINE="$OPTS $KEY_TYPE $KEY_BLOB fail2ban-webui"
JAIL_EXISTS=0
fail2ban-client status "$MANUAL_JAIL" >/dev/null 2>&1 && JAIL_EXISTS=1
F2B_VERSION=$(fail2ban-client version 2>/dev/null | tail -n1 || echo "?")
AGENT_VERSION=$(sed -n 's/^VERSION = "\(.*\)"/\1/p' "$SRC_DIR/f2b-webctl")
# sshd: is the chosen file read at all, and may the user log in?
SSHD_T=$("$SSHD" -T -C "user=$USER_NAME,host=localhost,addr=127.0.0.1" 2>/dev/null || true)
AK_OK=0
for entry in $(awk '/^authorizedkeysfile /{for(i=2;i<=NF;i++)print $i}' <<<"$SSHD_T"); do
entry=${entry//%h/$HOME_DIR}; entry=${entry//%u/$USER_NAME}; entry=${entry//%%/%}
[[ "$entry" = /* ]] || entry="$HOME_DIR/$entry"
[ "$entry" = "$AK_FILE" ] && AK_OK=1
done
# ---------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------
cat <<EOF
fail2ban-webui agent $AGENT_VERSION – fail2ban $F2B_VERSION – $(hostname -f 2>/dev/null || hostname)
script $AGENT_BIN
config $CONF_FILE $( [ -e "$CONF_FILE" ] && [ $FORCE_CONFIG -eq 0 ] && echo "(exists, kept)" )
SSH user $USER_NAME $( [ "$USER_NAME" != root ] && ! id "$USER_NAME" >/dev/null 2>&1 && echo "(will be created)" )
sudo rule $( [ "$USER_NAME" = root ] && echo "not needed" || echo "$SUDOERS_FILE" )
key file $AK_FILE
key $KEY_FP
allowed from ${FROM:-ANY ADDRESS (consider --from)}
manual jail $MANUAL_JAIL $( [ $JAIL_EXISTS -eq 1 ] && echo "(exists, kept)" || { [ $INSTALL_JAIL -eq 1 ] && echo "(will be created)" || echo "(MISSING – bans will fail)"; } )
backups $BACKUP_DIR
EOF
echo
[ $AK_OK -eq 1 ] || warn "sshd does not read $AK_FILE (AuthorizedKeysFile) – the key would not work"
grep -qE '^(allowusers|allowgroups) ' <<<"$SSHD_T" && warn "sshd has AllowUsers/AllowGroups – make sure $USER_NAME is allowed"
[ "$USER_NAME" = root ] && grep -q '^permitrootlogin no' <<<"$SSHD_T" && warn "PermitRootLogin is 'no' – use the default user f2bweb instead"
[ -n "$FROM" ] || warn "without --from the key works from every address"
if [ $DRY_RUN -eq 1 ]; then
echo "authorized_keys entry:"; echo " $AK_LINE"; echo
info "dry run – nothing changed"
exit 0
fi
if [ $YES -eq 0 ]; then
[ -t 0 ] || die "no terminal for the confirmation – use --yes"
read -r -p "Install? [y/N] " answer
[[ "$answer" =~ ^[yYjJ] ]] || die "aborted, nothing changed"
fi
# ---------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------
STAMP=$(date +%Y-%m-%d_%H-%M-%S)
BK="$BACKUP_DIR/agent_$STAMP"
for f in "$AGENT_BIN" "$CONF_FILE" "$SUDOERS_FILE" "$AK_FILE" \
"/etc/fail2ban/jail.d/$MANUAL_JAIL.local" "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf"; do
if [ -e "$f" ]; then
mkdir -p "$BK"; chmod 700 "$BACKUP_DIR" "$BK"
cp -a --parents "$f" "$BK/"
fi
done
[ -d "$BK" ] && info "backup: $BK"
# ---------------------------------------------------------------------
# Install
# ---------------------------------------------------------------------
info "installing $AGENT_BIN"
install -o root -g root -m 0750 "$SRC_DIR/f2b-webctl" "$AGENT_BIN"
if [ ! -e "$CONF_FILE" ] || [ $FORCE_CONFIG -eq 1 ]; then
info "writing $CONF_FILE"
umask 022
cat > "$CONF_FILE" <<EOF
# fail2ban-webui agent (f2b-webctl) – settings
# Jail used for manual bans from the web UI
MANUAL_JAIL=$MANUAL_JAIL
# Addresses/networks that must never be banned (comma separated), e.g. your home IP.
# Private, loopback, link-local and CGNAT ranges are always protected.
PROTECTED=$PROTECT
# Also protect all addresses of this host and the address of the web UI (yes/no)
PROTECT_OWN_ADDRESSES=yes
PROTECT_CLIENT=yes
EOF
chmod 0644 "$CONF_FILE"
elif [ -n "$PROTECT" ]; then
warn "$CONF_FILE exists – --protect was NOT applied (edit the file or use --force-config)"
fi
if [ "$USER_NAME" != root ]; then
if ! id "$USER_NAME" >/dev/null 2>&1; then
info "creating user $USER_NAME"
useradd --system --create-home --home-dir "$HOME_DIR" --shell /bin/sh \
--comment "fail2ban-webui agent" "$USER_NAME"
fi
# "*" = no password, but not locked (a locked account may be refused by sshd)
usermod -p '*' "$USER_NAME"
info "writing $SUDOERS_FILE"
tmp=$(mktemp)
cat > "$tmp" <<EOF
# fail2ban-webui agent: $USER_NAME may only run f2b-webctl as root.
# SSH_ORIGINAL_COMMAND carries the requested command, SSH_CLIENT the caller for the log.
Defaults!$AGENT_BIN env_keep += "SSH_ORIGINAL_COMMAND SSH_CLIENT"
$USER_NAME ALL=(root) NOPASSWD: $AGENT_BIN
EOF
visudo -cf "$tmp" >/dev/null || { rm -f "$tmp"; die "sudoers syntax check failed"; }
install -o root -g root -m 0440 "$tmp" "$SUDOERS_FILE"
rm -f "$tmp"
fi
info "adding key to $AK_FILE"
AK_DIR=$(dirname "$AK_FILE")
mkdir -p "$AK_DIR"
touch "$AK_FILE"
tmp=$(mktemp)
grep -vF "$KEY_BLOB" "$AK_FILE" > "$tmp" || true
echo "$AK_LINE" >> "$tmp"
cat "$tmp" > "$AK_FILE"
rm -f "$tmp"
if [ "$AK_DIR" = "$HOME_DIR/.ssh" ]; then
chown "$USER_NAME:" "$AK_DIR"
chmod 700 "$AK_DIR"
fi
chown "$USER_NAME:" "$AK_FILE" 2>/dev/null || true
chmod 600 "$AK_FILE"
if [ $JAIL_EXISTS -eq 0 ] && [ $INSTALL_JAIL -eq 1 ]; then
info "creating jail $MANUAL_JAIL"
cat > "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf" <<EOF
# fail2ban-webui: never matches – the jail is only filled via
# "fail2ban-client set $MANUAL_JAIL banip <ip>"
[Definition]
failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES <HOST>\$
ignoreregex =
EOF
touch "/var/log/fail2ban-$MANUAL_JAIL.log"
{
echo "# fail2ban-webui: permanent manual bans from the web UI"
echo "[$MANUAL_JAIL]"
echo "enabled = true"
echo "filter = $MANUAL_JAIL"
echo "backend = polling"
echo "logpath = /var/log/fail2ban-$MANUAL_JAIL.log"
echo "maxretry = 1"
echo "bantime = -1"
[ -z "$BANACTION" ] || echo "banaction = $BANACTION"
} > "/etc/fail2ban/jail.d/$MANUAL_JAIL.local"
fail2ban-client reload >/dev/null || die "fail2ban reload failed – check: fail2ban-client -t"
fail2ban-client status "$MANUAL_JAIL" >/dev/null 2>&1 || die "jail $MANUAL_JAIL did not start – check the fail2ban log"
fi
# ---------------------------------------------------------------------
# Self test
# ---------------------------------------------------------------------
info "self test"
if [ "$USER_NAME" = root ]; then
run_test() { SSH_ORIGINAL_COMMAND=list "$AGENT_BIN"; }
else
# same path as over SSH: as $USER_NAME through the sudo rule
run_test() { runuser -u "$USER_NAME" -- env SSH_ORIGINAL_COMMAND=list sudo -n "$AGENT_BIN"; }
fi
run_test | python3 -c '
import json, sys
d = json.loads(sys.stdin.read())
if not d.get("ok"):
sys.exit("self test failed: " + d.get("error", "?"))
print(" %d jails, %d bans: %s" % (len(d["jails"]), sum(len(j["bans"]) for j in d["jails"].values()), ", ".join(d["jails"])))
'
cat <<EOF
Done. Next steps on the web UI host:
1. add this host to config/hosts.yaml (user: $USER_NAME)
2. docker compose run --rm -it fail2ban-webui keyscan
3. docker compose run --rm fail2ban-webui check
EOF
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env bash
# =====================================================================
# fail2ban-webui agent – uninstaller
#
# Removes the key entry, the sudo rule, f2b-webctl and its config.
# The user and the manual jail are only removed on request, because
# removing the jail also lifts all manual bans.
# Every removed file is backed up to --backup-dir first.
# =====================================================================
set -euo pipefail
AGENT_BIN=/usr/local/sbin/f2b-webctl
CONF_FILE=/etc/f2b-webctl.conf
SUDOERS_FILE=/etc/sudoers.d/f2b-webctl
USER_NAME=f2bweb
AK_FILE=""
MANUAL_JAIL=""
REMOVE_USER=0
REMOVE_JAIL=0
BACKUP_DIR=/var/backups/fail2ban-webui-agent
DRY_RUN=0
YES=0
usage() {
cat <<'EOF'
Usage: uninstall.sh [options]
--user NAME SSH user of the agent (default: f2bweb)
--authorized-keys-file PATH file with the key entry (default: ~USER/.ssh/authorized_keys)
--remove-user also delete the user (not possible for root)
--remove-jail also delete the manual jail – lifts all its bans!
--manual-jail NAME name of the manual jail (default: from /etc/f2b-webctl.conf)
--backup-dir DIR backups (default: /var/backups/fail2ban-webui-agent)
--dry-run only show what would be done
-y, --yes do not ask for confirmation
EOF
}
die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }
need() { [ $# -ge 2 ] && [ -n "$2" ] || die "option $1 needs a value"; }
while [ $# -gt 0 ]; do
case "$1" in
--user) need "$@"; USER_NAME="$2"; shift 2 ;;
--authorized-keys-file) need "$@"; AK_FILE="$2"; shift 2 ;;
--remove-user) REMOVE_USER=1; shift ;;
--remove-jail) REMOVE_JAIL=1; shift ;;
--manual-jail) need "$@"; MANUAL_JAIL="$2"; shift 2 ;;
--backup-dir) need "$@"; BACKUP_DIR="$2"; shift 2 ;;
--dry-run) DRY_RUN=1; shift ;;
-y|--yes) YES=1; shift ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; die "unknown option: $1" ;;
esac
done
[ "$(id -u)" -eq 0 ] || die "please run as root"
[[ "$USER_NAME" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]] || die "invalid user name: $USER_NAME"
[ "$USER_NAME" != root ] || [ $REMOVE_USER -eq 0 ] || die "root cannot be removed"
if [ -z "$MANUAL_JAIL" ]; then
MANUAL_JAIL=$(sed -n 's/^MANUAL_JAIL=//p' "$CONF_FILE" 2>/dev/null | tail -n1)
MANUAL_JAIL=${MANUAL_JAIL:-manual-block}
fi
[[ "$MANUAL_JAIL" =~ ^[A-Za-z0-9][A-Za-z0-9_.-]{0,40}$ ]] || die "invalid jail name: $MANUAL_JAIL"
HOME_DIR=$(getent passwd "$USER_NAME" | cut -d: -f6 || true)
AK_FILE=${AK_FILE:-${HOME_DIR:-/nonexistent}/.ssh/authorized_keys}
JAIL_FILES=("/etc/fail2ban/jail.d/$MANUAL_JAIL.local" "/etc/fail2ban/filter.d/$MANUAL_JAIL.conf")
KEY_LINES=0
[ -f "$AK_FILE" ] && KEY_LINES=$(grep -c 'command="[^"]*f2b-webctl"' "$AK_FILE" || true)
echo
echo "fail2ban-webui agent – removing from $(hostname -f 2>/dev/null || hostname):"
echo " key entries $KEY_LINES in $AK_FILE"
for f in "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE"; do
[ -e "$f" ] && echo " file $f"
done
[ $REMOVE_JAIL -eq 1 ] && for f in "${JAIL_FILES[@]}"; do [ -e "$f" ] && echo " jail file $f"; done
[ $REMOVE_USER -eq 1 ] && id "$USER_NAME" >/dev/null 2>&1 && echo " user $USER_NAME (with home directory)"
echo " backups $BACKUP_DIR"
echo
if [ $REMOVE_JAIL -eq 1 ]; then
echo "WARNING: removing jail $MANUAL_JAIL lifts all manual bans!" >&2
if fail2ban-client get "$MANUAL_JAIL" banip >/dev/null 2>&1; then
echo " currently banned: $(fail2ban-client get "$MANUAL_JAIL" banip | tr '\n' ' ')" >&2
fi
fi
[ $DRY_RUN -eq 0 ] || { info "dry run – nothing changed"; exit 0; }
if [ $YES -eq 0 ]; then
[ -t 0 ] || die "no terminal for the confirmation – use --yes"
read -r -p "Remove? [y/N] " answer
[[ "$answer" =~ ^[yYjJ] ]] || die "aborted, nothing changed"
fi
STAMP=$(date +%Y-%m-%d_%H-%M-%S)
BK="$BACKUP_DIR/uninstall_$STAMP"
mkdir -p "$BK"; chmod 700 "$BACKUP_DIR" "$BK"
for f in "$AK_FILE" "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE" "${JAIL_FILES[@]}"; do
[ -e "$f" ] && cp -a --parents "$f" "$BK/"
done
info "backup: $BK"
if [ "$KEY_LINES" -gt 0 ]; then
info "removing key entries from $AK_FILE"
tmp=$(mktemp)
grep -v 'command="[^"]*f2b-webctl"' "$AK_FILE" > "$tmp" || true
cat "$tmp" > "$AK_FILE"
rm -f "$tmp"
fi
for f in "$SUDOERS_FILE" "$AGENT_BIN" "$CONF_FILE"; do
[ -e "$f" ] && { info "removing $f"; rm -f "$f"; }
done
if [ $REMOVE_JAIL -eq 1 ]; then
for f in "${JAIL_FILES[@]}"; do
[ -e "$f" ] && { info "removing $f"; rm -f "$f"; }
done
fail2ban-client reload >/dev/null || echo "WARNING: fail2ban reload failed – check: fail2ban-client -t" >&2
fi
if [ $REMOVE_USER -eq 1 ] && id "$USER_NAME" >/dev/null 2>&1; then
info "removing user $USER_NAME"
userdel -r "$USER_NAME" 2>/dev/null || userdel "$USER_NAME"
fi
info "done"