#!/bin/sh
# https://www.progressiverobot.com
# Copyright (c) 2026 Christopher Holloway / Progressive Robot Ltd
# SPDX-License-Identifier: AGPL-3.0-or-later

# What hMailServer's auto-ban does below the server on Linux.
#
# Installed as /usr/lib/hmailserver/autoban-hook and run by the server when
# AutoBanFirewall=1 is set under [Settings] in hMailServer.ini:
#
#    autoban-hook ban   <address> <minutes> <ports>|-   block the address
#    autoban-hook unban <address>                      release it
#    autoban-hook check                                say whether this can work
#    autoban-hook reset                                remove everything it made
#
# nftables where it exists, iptables/ip6tables where it does not. With nft the
# address goes into a set with a timeout, so a ban expires on its own even if the
# server is stopped before it can say "unban"; with iptables there is no timeout
# and the server's own expiry pass releases it. Only TCP to the ports the server
# listens on is dropped - the list the server passes - so a banned address can
# still reach anything else on the machine. Idempotent on purpose: the server
# announces every active ban again when it starts, because it cannot know what
# this script did before.
#
# The service runs as its own user with CAP_NET_BIND_SERVICE and nothing else, and
# changing the firewall needs CAP_NET_ADMIN. The unit does not grant it by default,
# because a mail server that can rewrite the host's firewall is a bigger thing to
# grant than one that can bind port 25. Grant it once, deliberately:
#
#    systemctl edit hmailserver
#      [Service]
#      AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
#      CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
#    systemctl restart hmailserver
#
# fail2ban is the other half and needs none of this: it runs as root, reads the
# server's application log, and bans on its own account. The package installs a
# filter and a jail for it; see /etc/fail2ban/jail.d/hmailserver.conf.

set -eu

TABLE=hmailserver
CHAIN=HMAILSERVER-AUTOBAN

usage() {
   echo "usage: $0 ban <address> <minutes> <ports>|- | unban <address> | check | reset" >&2
   exit 64
}

need_cap() {
   cat >&2 <<'EOF'
autoban-hook: the firewall cannot be changed from here - the service lacks CAP_NET_ADMIN.
Grant it once, deliberately, with a drop-in:
   systemctl edit hmailserver
     [Service]
     AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
     CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
   systemctl restart hmailserver
EOF
   exit 2
}

# An address is the only thing this script ever puts into a command line, and it
# comes from a network peer, so it is checked to the character.
check_address() {
   case "$1" in
      "") usage ;;
      *[!0-9a-fA-F:.]*) echo "autoban-hook: '$1' is not an address" >&2; exit 65 ;;
   esac
}

check_number() {
   case "$1" in
      ""|*[!0-9]*) echo "autoban-hook: '$1' is not a number" >&2; exit 65 ;;
   esac
}

check_ports() {
   case "$1" in
      -) ;;
      ""|*[!0-9,]*) echo "autoban-hook: '$1' is not a port list" >&2; exit 65 ;;
   esac
}

family_of() {
   case "$1" in
      *:*) echo 6 ;;
      *) echo 4 ;;
   esac
}

have_nft() { command -v nft >/dev/null 2>&1; }

# ------------------------------------------------------------------ nftables

nft_ensure() {
   # table, two address sets with timeouts, a port set, one chain, two rules.
   nft list table inet $TABLE >/dev/null 2>&1 || nft add table inet $TABLE
   nft list set inet $TABLE autoban4 >/dev/null 2>&1 || nft add set inet $TABLE autoban4 '{ type ipv4_addr; flags timeout; }'
   nft list set inet $TABLE autoban6 >/dev/null 2>&1 || nft add set inet $TABLE autoban6 '{ type ipv6_addr; flags timeout; }'
   nft list set inet $TABLE ports >/dev/null 2>&1 || nft add set inet $TABLE ports '{ type inet_service; }'
   nft list chain inet $TABLE input >/dev/null 2>&1 || nft add chain inet $TABLE input '{ type filter hook input priority -10; policy accept; }'
   if ! nft list chain inet $TABLE input | grep -q '@autoban4'; then
      nft add rule inet $TABLE input ip saddr @autoban4 tcp dport @ports counter drop
   fi
   if ! nft list chain inet $TABLE input | grep -q '@autoban6'; then
      nft add rule inet $TABLE input ip6 saddr @autoban6 tcp dport @ports counter drop
   fi
}

nft_ports() {
   # The server passes the ports it listens on with every ban; the set follows.
   [ "$1" = "-" ] && return 0
   nft flush set inet $TABLE ports
   nft add element inet $TABLE ports "{ $(echo "$1" | sed 's/,/, /g') }"
}

nft_ban() {
   nft_ensure
   nft_ports "$3"
   set_name=autoban$(family_of "$1")
   nft delete element inet $TABLE "$set_name" "{ $1 }" 2>/dev/null || true
   nft add element inet $TABLE "$set_name" "{ $1 timeout ${2}m }"
}

nft_unban() {
   nft list table inet $TABLE >/dev/null 2>&1 || return 0
   set_name=autoban$(family_of "$1")
   nft delete element inet $TABLE "$set_name" "{ $1 }" 2>/dev/null || true
}

nft_reset() {
   nft list table inet $TABLE >/dev/null 2>&1 && nft delete table inet $TABLE
   return 0
}

nft_check() {
   nft list tables >/dev/null 2>&1 || need_cap
}

# ------------------------------------------------------------------ iptables

ipt_for() {
   if [ "$(family_of "$1")" = 6 ]; then echo ip6tables; else echo iptables; fi
}

ipt_ensure() {
   $1 -n -L $CHAIN >/dev/null 2>&1 || $1 -N $CHAIN
   $1 -C INPUT -j $CHAIN >/dev/null 2>&1 || $1 -I INPUT -j $CHAIN
}

ipt_ban() {
   ipt=$(ipt_for "$1")
   ipt_ensure "$ipt"
   if [ "$3" = "-" ]; then
      $ipt -C $CHAIN -s "$1" -p tcp -j DROP >/dev/null 2>&1 || $ipt -I $CHAIN -s "$1" -p tcp -j DROP
   else
      $ipt -C $CHAIN -s "$1" -p tcp -m multiport --dports "$3" -j DROP >/dev/null 2>&1 || \
         $ipt -I $CHAIN -s "$1" -p tcp -m multiport --dports "$3" -j DROP
   fi
}

ipt_unban() {
   ipt=$(ipt_for "$1")
   $ipt -n -L $CHAIN >/dev/null 2>&1 || return 0
   # every rule for the address, whatever port list it was made with
   while $ipt -S $CHAIN | grep -q -- "-s $1/"; do
      rule=$($ipt -S $CHAIN | grep -- "-s $1/" | head -1 | sed 's/^-A //')
      # shellcheck disable=SC2086
      $ipt -D $rule
   done
}

ipt_reset() {
   for ipt in iptables ip6tables; do
      command -v $ipt >/dev/null 2>&1 || continue
      $ipt -n -L $CHAIN >/dev/null 2>&1 || continue
      $ipt -D INPUT -j $CHAIN 2>/dev/null || true
      $ipt -F $CHAIN
      $ipt -X $CHAIN
   done
}

ipt_check() {
   command -v iptables >/dev/null 2>&1 || { echo "autoban-hook: neither nft nor iptables is installed" >&2; exit 3; }
   iptables -S INPUT >/dev/null 2>&1 || need_cap
}

# ------------------------------------------------------------------ dispatch

verb=${1:-}
case "$verb" in
   ban)
      [ $# -eq 4 ] || usage
      check_address "$2"; check_number "$3"; check_ports "$4"
      if have_nft; then nft_ban "$2" "$3" "$4"; else ipt_ban "$2" "$3" "$4"; fi
      ;;
   unban)
      [ $# -eq 2 ] || usage
      check_address "$2"
      if have_nft; then nft_unban "$2"; else ipt_unban "$2"; fi
      ;;
   check)
      if have_nft; then nft_check; else ipt_check; fi
      echo "autoban-hook: ok ($(have_nft && echo nftables || echo iptables))"
      ;;
   reset)
      if have_nft; then nft_reset; fi
      ipt_reset
      ;;
   *)
      usage
      ;;
esac
