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:
commit
2e3f79236c
44 files changed
+4280
No files matched your search
Executable
+226
@@ -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()
|
||||
Executable
+314
@@ -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
|
||||
Executable
+125
@@ -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"
|
||||
Reference in new issue
Block a user