#!/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=… # 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="" DOCKER_CHAIN=0 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) --docker Docker host: the new manual jail bans in INPUT and in DOCKER-USER, so published container ports (e.g. a Nginx Proxy Manager) are blocked as well (iptables) --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 ;; --docker) DOCKER_CHAIN=1; shift ;; --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" [ -z "$BANACTION" ] || [ $DOCKER_CHAIN -eq 0 ] || die "--banaction and --docker cannot be combined" [ $DOCKER_CHAIN -eq 0 ] || command -v iptables >/dev/null || die "--docker needs iptables (installed together with Docker)" 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 </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)"; } ) ban chains $( [ $DOCKER_CHAIN -eq 1 ] && echo "INPUT + DOCKER-USER (--docker)" || echo "from the banaction in jail.local" ) 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" </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" </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" <" [Definition] failregex = ^FAIL2BAN-WEBUI-NEVER-MATCHES \$ 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" if [ $DOCKER_CHAIN -eq 1 ]; then # published Docker ports bypass INPUT and pass DOCKER-USER instead echo "action = iptables-allports[name=$MANUAL_JAIL]" echo " iptables-allports[name=$MANUAL_JAIL-docker, chain=DOCKER-USER]" fi } > "/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 <