diff --git a/README.md b/README.md index 0f79533..58a7757 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,28 @@ curl -fsSL https://git.vanusys.com/VanuSystems/shell-scripts/raw/branch/main/ins ```bash curl -fsSL https://git.vanusys.com/VanuSystems/shell-scripts/raw/branch/main/set-vim-2space.sh | bash ``` + +> **Tip:** Set up fail2ban with a strict profile (3 failures -> 24h ban, repeat offenders -> 1 week). + +```bash +curl -fsSL https://git.vanusys.com/VanuSystems/shell-scripts/raw/branch/main/setup-fail2ban.sh | sudo bash +``` + +Override the detected SSH port, the sshd filter mode (`normal` by default: `aggressive` +also counts pre-auth client disconnects as failures, which bans legitimate users) or +whitelist an extra address: + +```bash +curl -fsSL https://git.vanusys.com/VanuSystems/shell-scripts/raw/branch/main/setup-fail2ban.sh | sudo SSH_PORT=22,2222 IGNORE_IP=198.51.100.0/24 bash +``` + +> **Note:** the script whitelists the address of the current session when it can detect it +> (`SSH_CONNECTION`, `SSH_CLIENT`, or the utmp entry of its terminal). Piped through +> `curl ... | sudo bash` there is no terminal, so it prints a warning and you should pass +> `IGNORE_IP=` yourself — otherwise 3 failed attempts ban you for 24h +> (and 3 bans for a week). + +> **Note:** the script refuses to continue when fail2ban's `logtarget` is not a file +> (`SYSLOG`, `STDERR`, ...), because the `recidive` jail reads that file to count bans +> and could otherwise never fire. Set `logtarget = /var/log/fail2ban.log` in +> `/etc/fail2ban/fail2ban.local` and re-run. diff --git a/setup-fail2ban.sh b/setup-fail2ban.sh new file mode 100644 index 0000000..98f5104 --- /dev/null +++ b/setup-fail2ban.sh @@ -0,0 +1,389 @@ +#!/bin/bash +set -euo pipefail + +# Quick fail2ban setup with a strict risk profile (Debian/Ubuntu). +# Strict defaults: 3 failed attempts -> 24h ban; repeat offenders -> 1 week. +# Override via env, e.g. SSH_PORT=2222 SSHD_MODE=extra ./setup-fail2ban.sh +# +# Notes on the defaults (verified against fail2ban 0.11/1.0/1.1 and the Debian +# and Ubuntu packages): +# * The systemd backend is used for sshd so the jail works on hosts without +# /var/log/auth.log (i.e. without rsyslog). It requires python3-systemd, +# which Debian/Ubuntu install with fail2ban. +# * A systemd backend makes fail2ban ignore "logpath" and use the filter's +# journalmatch instead - so the [recidive] jail, whose log only exists as a +# file, must keep a file backend, otherwise it silently never bans. +# * SSHD_MODE defaults to "normal": "aggressive" also counts pre-auth client +# disconnects ("Connection closed by authenticating user ... [preauth]") as +# failures, which bans legitimate users with a 24h/1w profile. +# * "dbpurgeage" is a fail2ban.conf [Definition] option; setting it here would +# be silently ignored, so it is not written. +# * The effective logtarget (fail2ban.conf, then fail2ban.d/*.conf, then +# fail2ban.local) must resolve to a file: [recidive] reads it to count bans, +# and a SYSLOG/STDERR target would leave that jail silently inert. The value +# is therefore resolved, validated and verified to actually receive log +# output before the script reports success. + +SSH_PORT="${SSH_PORT:-}" +IGNORE_IP="${IGNORE_IP:-}" +SSHD_MODE="${SSHD_MODE:-normal}" +MAXRETRY="${MAXRETRY:-3}" +FINDTIME="${FINDTIME:-10m}" +BANTIME="${BANTIME:-24h}" +RECIDIVE_MAXRETRY="${RECIDIVE_MAXRETRY:-3}" +RECIDIVE_FINDTIME="${RECIDIVE_FINDTIME:-1d}" +RECIDIVE_BANTIME="${RECIDIVE_BANTIME:-1w}" + +JAIL_LOCAL="/etc/fail2ban/jail.local" +JAIL_D="/etc/fail2ban/jail.d" + +if [ "$EUID" -ne 0 ]; then + echo "Please run as root (sudo)." >&2 + exit 1 +fi + +if [ ! -r /etc/os-release ]; then + echo "/etc/os-release is missing; cannot detect the distribution." >&2 + exit 1 +fi + +. /etc/os-release + +case "${ID:-}" in + debian|ubuntu|raspbian) ;; + *) + echo "Unsupported distribution: ${ID:-unknown} (only Debian, Ubuntu and Raspbian are supported)." >&2 + exit 1 + ;; +esac + +# fail2ban silently ignores an unknown filter mode, which would leave sshd +# protected by less (or more) than the operator expects. +case "$SSHD_MODE" in + normal|ddos|extra|aggressive) ;; + *) + echo "Invalid SSHD_MODE: '$SSHD_MODE' (expected normal, ddos, extra or aggressive)." >&2 + exit 1 + ;; +esac + +export DEBIAN_FRONTEND=noninteractive + +sshd_effective() { + local key="$1" out="" + out="$(timeout 10 sshd -T 2>/dev/null || true)" + if [ -n "$out" ]; then + printf '%s\n' "$out" | awk -v k="$key" '$1 == k { print $2 }' + return 0 + fi + awk -v k="$key" 'tolower($1) == k { print $2 }' /etc/ssh/sshd_config 2>/dev/null || true +} + +detect_ssh_port() { + local ports + ports="$(sshd_effective port | sort -u | paste -sd, -)" + if [ -z "$ports" ]; then + ports="$(ss -H -tlnp 2>/dev/null | awk '/sshd/ { n = split($4, a, ":"); print a[n] }' | sort -u | paste -sd, -)" + fi + printf '%s' "${ports:-22}" +} + +detect_client_ip() { + local ip="" tty_name="" + if [ -n "${SSH_CONNECTION:-}" ]; then + ip="${SSH_CONNECTION%% *}" + fi + if [ -z "$ip" ] && [ -n "${SSH_CLIENT:-}" ]; then + ip="${SSH_CLIENT%% *}" + fi + if [ -z "$ip" ]; then + # sudo's env_reset usually drops SSH_CONNECTION/SSH_CLIENT; fall back to the + # utmp entry of our own terminal. Note that "who am i" cannot be used here: + # it resolves the entry through stdin, which is a pipe for the documented + # "curl ... | sudo bash" invocation. + tty_name="$(tty 2>/dev/null || true)" + case "$tty_name" in + /dev/*) + ip="$(who 2>/dev/null | awk -v t="${tty_name#/dev/}" '$2 == t { print $NF }' | tail -n1 | tr -d '()' || true)" + ;; + esac + fi + case "$ip" in + *[!0-9a-fA-F:.]*|"") ip="" ;; + esac + printf '%s' "$ip" +} + +detect_banaction() { + if command -v nft >/dev/null 2>&1; then + printf 'nftables-multiport' + elif command -v iptables >/dev/null 2>&1; then + printf 'iptables-multiport' + else + echo "Neither nft nor iptables is available; fail2ban cannot enforce bans." >&2 + exit 1 + fi +} + +# [recidive] (and other "allports" jails) ignore banaction and use +# banaction_allports, whose default on older Debian/Ubuntu is iptables-allports. +# Keep both in sync with the firewall actually present. +detect_banaction_allports() { + case "$1" in + nftables-*) printf 'nftables-allports' ;; + iptables-*) printf 'iptables-allports' ;; + *) + echo "Cannot derive an all-ports banaction from '$1'." >&2 + exit 1 + ;; + esac +} + +# fail2ban reads fail2ban.conf, then fail2ban.d/*.conf, then fail2ban.local, so +# the value from the last file that sets logtarget is the effective one. +logtarget_effective() { + local f value="" current + for f in /etc/fail2ban/fail2ban.conf /etc/fail2ban/fail2ban.d/*.conf \ + /etc/fail2ban/fail2ban.local; do + [ -r "$f" ] || continue + current="$(awk ' + /^[[:space:]]*logtarget[[:space:]]*=/ { + line = $0 + sub(/^[^=]*=[[:space:]]*/, "", line) + sub(/[[:space:]]*#.*/, "", line) + gsub(/^[[:space:]]+|[[:space:]]+$/, "", line) + value = line + } + END { if (value != "") print value } + ' "$f")" + if [ -n "$current" ]; then + value="$current" + fi + done + printf '%s' "$value" +} + +echo "==> Installing fail2ban" +apt-get update +apt-get install -y fail2ban + +echo "==> Resolving fail2ban log target" +FAIL2BAN_LOG="$(logtarget_effective)" +case "$FAIL2BAN_LOG" in + \"*\") FAIL2BAN_LOG="${FAIL2BAN_LOG#\"}"; FAIL2BAN_LOG="${FAIL2BAN_LOG%\"}" ;; + \'*\') FAIL2BAN_LOG="${FAIL2BAN_LOG#\'}"; FAIL2BAN_LOG="${FAIL2BAN_LOG%\'}" ;; +esac + +# [recidive] can only count bans in a file. Refuse to configure a jail that +# could never fire instead of leaving the host with a false sense of coverage. +case "$FAIL2BAN_LOG" in + '') + echo "Cannot determine fail2ban's logtarget: no logtarget in fail2ban.conf," >&2 + echo "fail2ban.d/*.conf or fail2ban.local. The [recidive] jail reads that log." >&2 + exit 1 + ;; + /*) ;; + *) + echo "fail2ban logs to '$FAIL2BAN_LOG', which is not a file path." >&2 + echo "The [recidive] jail reads the log file and would never see a ban." >&2 + echo "Set 'logtarget = /var/log/fail2ban.log' in /etc/fail2ban/fail2ban.local" >&2 + echo "and run this script again." >&2 + exit 1 + ;; +esac + +case "$FAIL2BAN_LOG" in + *[!A-Za-z0-9._/-]*) + echo "Refusing suspicious logtarget path: '$FAIL2BAN_LOG'." >&2 + exit 1 + ;; +esac + +echo "==> Detecting SSH and firewall" +SSH_PORT="${SSH_PORT:-$(detect_ssh_port)}" +SSH_PORT="$(printf '%s' "$SSH_PORT" | tr -d '[:space:]')" +case "$SSH_PORT" in + ''|*[!0-9,]*) + echo "Invalid SSH port: '$SSH_PORT' (expected digits, e.g. 22 or 22,2222)." >&2 + exit 1 + ;; +esac + +BANACTION="$(detect_banaction)" +BANACTION_ALLPORTS="$(detect_banaction_allports "$BANACTION")" +CLIENT_IP="$(detect_client_ip | tr -d '[:space:]')" +IGNORE_IP="$(printf '%s' "$IGNORE_IP" | tr -d '[:space:]')" + +# Values are interpolated into the jail config, so reject anything that is not +# a plain address/CIDR list before it can add or override a directive. +for entry in ${CLIENT_IP:-} ${IGNORE_IP:-}; do + case "$entry" in + *[!0-9a-fA-F:.,/]*|*/*/*) + echo "Invalid IP entry: '$entry' (expected address or CIDR)." >&2 + exit 1 + ;; + esac +done + +IGNORE_LIST="127.0.0.1/8 ::1" +if [ -n "$CLIENT_IP" ]; then + IGNORE_LIST="$IGNORE_LIST $CLIENT_IP" +fi +if [ -n "$IGNORE_IP" ]; then + IGNORE_LIST="$IGNORE_LIST $IGNORE_IP" +fi + +echo " ssh port : $SSH_PORT" +echo " sshd mode : $SSHD_MODE" +echo " banaction : $BANACTION (all-ports: $BANACTION_ALLPORTS)" +echo " logtarget : $FAIL2BAN_LOG" +echo " ignoreip : $IGNORE_LIST" + +if [ -z "$CLIENT_IP" ]; then + echo " WARNING: this session's client address could not be detected, so it is" >&2 + echo " NOT whitelisted. With $MAXRETRY attempts in $FINDTIME -> $BANTIME" >&2 + echo " ($RECIDIVE_MAXRETRY bans -> $RECIDIVE_BANTIME) you can lock yourself out." >&2 + echo " Re-run with IGNORE_IP= if you administer over SSH." >&2 +fi + +echo "==> Ensuring sshd logs failed attempts" +if [ "$(sshd_effective loglevel | head -n1)" = "QUIET" ]; then + install -d -m 0755 /etc/ssh/sshd_config.d + printf 'LogLevel VERBOSE\n' > /etc/ssh/sshd_config.d/99-fail2ban-loglevel.conf + if sshd -t 2>/dev/null; then + systemctl reload ssh 2>/dev/null || systemctl reload sshd 2>/dev/null || \ + echo " warning: could not reload sshd; the LogLevel change applies after its next restart" >&2 + echo " set LogLevel VERBOSE" + else + rm -f /etc/ssh/sshd_config.d/99-fail2ban-loglevel.conf + echo " skipped: config test failed" >&2 + fi +else + echo " already sufficient" +fi + +echo "==> Writing $JAIL_LOCAL" +if [ -f "$JAIL_LOCAL" ]; then + cp "$JAIL_LOCAL" "${JAIL_LOCAL}.bak.$(date +%s)" +fi + +# jail.d/*.conf is read BEFORE jail.local, so our settings win and entries that +# other packages or the admin put there (nginx, postfix, ...) keep working. +# They are therefore left untouched - only reported. +if [ -d "$JAIL_D" ]; then + existing_jails="$(find "$JAIL_D" -maxdepth 1 -type f -name '*.conf' -printf '%f ' 2>/dev/null || true)" + if [ -n "${existing_jails:-}" ]; then + echo " keeping existing entries in $JAIL_D: $existing_jails" + fi +fi + +cat > "$JAIL_LOCAL" </dev/null | tr -d '[:space:]')" +LOG_SIZE_BEFORE="${LOG_SIZE_BEFORE:-0}" + +echo "==> Enabling and restarting fail2ban" +systemctl enable fail2ban >/dev/null 2>&1 || true +if ! systemctl restart fail2ban; then + echo "fail2ban failed to start. Recent log:" >&2 + journalctl -u fail2ban -n 30 --no-pager >&2 || true + exit 1 +fi + +echo "==> Verifying" +# fail2ban can be "active" while a jail silently failed to load, so wait for the +# jails themselves and fail loudly instead of printing a success summary. +jails_ok=false +tries=0 +while [ "$tries" -lt 10 ]; do + if systemctl is-active --quiet fail2ban \ + && fail2ban-client status sshd >/dev/null 2>&1 \ + && fail2ban-client status recidive >/dev/null 2>&1; then + jails_ok=true + break + fi + tries=$((tries + 1)) + sleep 1 +done + +if [ "$jails_ok" != true ]; then + echo "fail2ban is not enforcing: the sshd/recidive jails did not come up." >&2 + journalctl -u fail2ban -n 30 --no-pager >&2 || true + fail2ban-client status >&2 || true + exit 1 +fi + +# An active jail proves nothing about [recidive]: it only bans when the log it +# polls receives lines. Wait for the restart to actually write there. +log_written=false +tries=0 +while [ "$tries" -lt 5 ]; do + LOG_SIZE_NOW="$(wc -c < "$FAIL2BAN_LOG" 2>/dev/null | tr -d '[:space:]')" + if [ "${LOG_SIZE_NOW:-0}" -gt "$LOG_SIZE_BEFORE" ]; then + log_written=true + break + fi + tries=$((tries + 1)) + sleep 1 +done + +if [ "$log_written" != true ]; then + echo "fail2ban is not writing to $FAIL2BAN_LOG, so the [recidive] jail would" >&2 + echo "never observe a ban. Check 'logtarget' in fail2ban.conf/fail2ban.local." >&2 + journalctl -u fail2ban -n 30 --no-pager >&2 || true + exit 1 +fi + +fail2ban-client status +fail2ban-client status sshd +fail2ban-client status recidive + +echo +echo "fail2ban configured with a strict profile." +echo "Jails: sshd (port $SSH_PORT, mode $SSHD_MODE), recidive" +echo "Ban policy: $MAXRETRY failures in $FINDTIME -> $BANTIME" +echo "Repeat offenders: $RECIDIVE_MAXRETRY bans in $RECIDIVE_FINDTIME -> $RECIDIVE_BANTIME" +echo "Recidive source: $FAIL2BAN_LOG" +echo "Never banned: $IGNORE_LIST" +echo +echo "Check: fail2ban-client status sshd" +echo "Unban: fail2ban-client set sshd unbanip " +echo "Config: $JAIL_LOCAL"