- README: step-by-step installation of web UI and agent on one host, exactly as tested (agent after keygen, all "run" helpers before "up -d", http PUBLIC_URL and TRUSTED_PROXY_HOPS=0 without a proxy) - README: attach to an existing Nginx Proxy Manager in Docker – shared network via docker-compose.override.yaml, no published port, gw_priority so SSH to the agent keeps the source address of the own network (otherwise from= rejects it), TLS settings, test - install.sh --docker: the new manual jail bans in INPUT and DOCKER-USER - Running container: helpers via "docker compose exec … python -m webui" (run fails with "Address already in use"); troubleshooting entries Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
328 lines
14 KiB
Bash
Executable File
328 lines
14 KiB
Bash
Executable File
#!/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=""
|
||
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 <<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)"; } )
|
||
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" <<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"
|
||
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 <<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
|