A little script that can be handy when you arent using any automated blocking strategy. Maintaining a secure system is a proactive process. Every once in a while you will need to do stuff manually; this script can help out when fail2ban cant be installed or you just need something temporary.

I have kept iptables first because it is the workflow I know. The updated script mainly makes repeated start and stop operations predictable.

/etc/firewall/manual-blocklist.txt contains one IPv4 address or CIDR per line. Blank lines and comments are allowed:

# noisy scanner
192.0.2.10
198.51.100.0/28

Idempotent iptables Script

#!/usr/bin/env bash
set -euo pipefail

readonly CHAIN=MANUAL_BLOCK
readonly LIST=/etc/firewall/manual-blocklist.txt
IPTABLES=${IPTABLES:-/usr/sbin/iptables}

valid_cidr() {
  python3 -c 'import ipaddress,sys; n=ipaddress.ip_network(sys.argv[1], strict=False); raise SystemExit(n.version != 4)' "$1" 2>/dev/null
}

load_entries() {
  sed 's/#.*//; /^[[:space:]]*$/d' "$LIST" | sort -u
}

start() {
  test -r "$LIST" || { echo "Cannot read $LIST" >&2; exit 1; }
  "$IPTABLES" -N "$CHAIN" 2>/dev/null || true
  "$IPTABLES" -F "$CHAIN"
  while IFS= read -r network; do
    valid_cidr "$network" || { echo "Invalid IPv4/CIDR: $network" >&2; exit 1; }
    "$IPTABLES" -A "$CHAIN" -s "$network" -j DROP
  done < <(load_entries)
  "$IPTABLES" -C INPUT -j "$CHAIN" 2>/dev/null || "$IPTABLES" -I INPUT 1 -j "$CHAIN"
}

stop() {
  while "$IPTABLES" -C INPUT -j "$CHAIN" 2>/dev/null; do
    "$IPTABLES" -D INPUT -j "$CHAIN"
  done
  "$IPTABLES" -F "$CHAIN" 2>/dev/null || true
  "$IPTABLES" -X "$CHAIN" 2>/dev/null || true
}

add() {
  valid_cidr "${1:-}" || { echo "Usage: $0 add IPv4[/prefix]" >&2; exit 2; }
  sudo install -d -m 755 "$(dirname "$LIST")"
  touch "$LIST"
  grep -Fxq -- "$1" "$LIST" || printf '%s\n' "$1" >> "$LIST"
  start
}

remove() {
  valid_cidr "${1:-}" || { echo "Usage: $0 remove IPv4[/prefix]" >&2; exit 2; }
  test -f "$LIST" || { echo "Cannot read $LIST" >&2; exit 1; }
  tmp=$(mktemp "${LIST}.XXXXXX")
  grep -Fvx -- "$1" "$LIST" > "$tmp" || true
  chmod --reference="$LIST" "$tmp"
  chown --reference="$LIST" "$tmp"
  mv -f "$tmp" "$LIST"
  start
}

status() {
  "$IPTABLES" -nvL "$CHAIN" --line-numbers
}

case "${1:-}" in
  start|stop|status) "$1" ;;
  add|remove) "$1" "${2:-}" ;;
  *) echo "Usage: $0 {start|stop|status|add IP|remove IP}" >&2; exit 2 ;;
esac

Breaking Down the Script

  • start creates the chain if needed, flushes only that chain, loads the de-duplicated list, and inserts one INPUT jump if it is missing.
  • stop removes every jump to the custom chain, then flushes and deletes it without wiping unrelated firewall rules.
  • add and remove validate the address, update the file, and reload the chain.
  • status shows the chain with counters and line numbers, replacing the old watch command with something that also works in a script.

How to Use It

I preview the parsed list before loading it:

sed 's/#.*//; /^[[:space:]]*$/d' /etc/firewall/manual-blocklist.txt | sort -u
sudo ./manual-blocklist start
sudo ./manual-blocklist status

# Later:
sudo ./manual-blocklist add 203.0.113.10
sudo ./manual-blocklist remove 203.0.113.10
sudo ./manual-blocklist stop

For persistence on Debian/Ubuntu I save after verifying the chain:

sudo netfilter-persistent save

Optional nftables Named Set

The current equivalent is compact because a named set holds the addresses:

table inet manual_filter {
  set blocked_v4 {
    type ipv4_addr
    flags interval
    elements = { 192.0.2.10, 198.51.100.0/28 }
  }
  chain input {
    type filter hook input priority filter;
    ip saddr @blocked_v4 drop
  }
}

Named sets can be updated without rebuilding one rule per address. I treat this as the translation to learn later, not a reason to remove the iptables script. nftables sets



Buy Me a Coffee