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

+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