Files
fail2ban-webui/agent/install.sh
T
schiwagoaandClaude Opus 5.5 2e3f79236c 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>
2026-10-25 15:38:17 +01:00

315 lines
13 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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