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
startcreates the chain if needed, flushes only that chain, loads the de-duplicated list, and inserts one INPUT jump if it is missing.stopremoves every jump to the custom chain, then flushes and deletes it without wiping unrelated firewall rules.addandremovevalidate the address, update the file, and reload the chain.statusshows 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