One of those challenges in operations is the constant threat of cyberattacks from different parts of the world. One way to mitigate this risk is by utilizing iptables, as it is commonly available and quite easy to use.

If a server is being attacked from a region I do not need to serve, I want a prepared answer that still works offline. Country blocking is blunt and IP allocations change, but a cached list lets me apply an emergency mitigation without making the firewall script fetch data during an incident.

This remains an iptables workflow. It does not identify a person’s real location; it matches the allocation data in the cached CIDR files.

How it Works

The script turns country allocation lists into ordinary source-network rules. Unlike my older GeoIP wording, iptables is not discovering a country on its own: the result is only as current as the CIDR files I last cached.

Why it’s Good to Have

  1. Targeted Security: If a system is being hit heavily from regions it does not serve, a country block can provide an immediate mitigation.
  2. Offline Operation: Preparing the lists ahead of time means the emergency action does not depend on DNS, a download site, or package installation.
  3. Customization: The allow ports, country codes, IPv4/IPv6 handling, and direction remain explicit and easy to change.

How to Implement It

The basic routine is deliberately simple: run update while things are quiet, inspect the cached lists, use test, then run start only when the block is needed. stop removes only the chains created by this script.

A country rule can block legitimate users and administration paths. I keep an existing root session open, preserve established connections, and test the allow ports before relying on it remotely.

Offline-First Script

The important separation is:

  • update is the only network operation. It downloads into a temporary directory, validates every entry, then replaces cache files.
  • start, stop, status, and test use only local files.
  • A stale cache prints its age but remains usable when I explicitly run start during an incident.

/usr/local/sbin/iptables-country-block:

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

readonly CACHE_ROOT=/var/lib/iptables-country-block
readonly CACHE=$CACHE_ROOT/current
readonly CHAIN4_IN=COUNTRY_BLOCK_V4_IN
readonly CHAIN4_OUT=COUNTRY_BLOCK_V4_OUT
readonly CHAIN6_IN=COUNTRY_BLOCK_V6_IN
readonly CHAIN6_OUT=COUNTRY_BLOCK_V6_OUT
readonly SOURCE4=https://www.ipdeny.com/ipblocks/data/aggregated
readonly SOURCE6=https://www.ipdeny.com/ipv6/ipaddresses/aggregated

COUNTRIES=${COUNTRIES:-"cn ru"}       # lower-case ISO 3166-1 alpha-2 codes
ALLOW_TCP_PORTS=${ALLOW_TCP_PORTS:-"22,80,443"}
BLOCK_OUTPUT=${BLOCK_OUTPUT:-0}
ENABLE_IPV6=${ENABLE_IPV6:-0}
MAX_AGE_DAYS=${MAX_AGE_DAYS:-30}

need_root() { test "$(id -u)" -eq 0 || { echo "Run as root" >&2; exit 1; }; }

validate_code() { [[ $1 =~ ^[a-z]{2}$ ]]; }

validate_file() {
  local family=$1 file=$2
  test -s "$file" || return 1
  python3 - "$family" "$file" <<'PY'
import ipaddress, pathlib, sys
version = int(sys.argv[1])
for number, text in enumerate(pathlib.Path(sys.argv[2]).read_text().splitlines(), 1):
    text = text.strip()
    if not text or text.startswith("#"):
        continue
    try:
        network = ipaddress.ip_network(text, strict=False)
    except ValueError as error:
        raise SystemExit(f"{sys.argv[2]}:{number}: {error}")
    if network.version != version:
        raise SystemExit(f"{sys.argv[2]}:{number}: expected IPv{version}")
PY
}

cache_file() { printf '%s/%s-v%s.zone\n' "$CACHE" "$1" "$2"; }

update() {
  install -d -m 700 "$CACHE_ROOT"
  local stage next_link
  stage=$(mktemp -d "$CACHE_ROOT/cache.XXXXXX")
  trap "rm -rf -- '$stage'" EXIT
  for country in $COUNTRIES; do
    validate_code "$country" || { echo "Invalid country code: $country" >&2; exit 1; }
    curl --fail --location --silent --show-error --connect-timeout 5 --max-time 60 \
      "$SOURCE4/$country-aggregated.zone" -o "$stage/$country-v4.zone"
    validate_file 4 "$stage/$country-v4.zone"
    if [[ $ENABLE_IPV6 == 1 ]]; then
      curl --fail --location --silent --show-error --connect-timeout 5 --max-time 60 \
        "$SOURCE6/$country-aggregated.zone" -o "$stage/$country-v6.zone"
      validate_file 6 "$stage/$country-v6.zone"
    fi
  done
  date -u +%FT%TZ > "$stage/updated-at"
  next_link=$CACHE_ROOT/.current.$$
  trap "rm -rf -- '$stage'; rm -f -- '$next_link'" EXIT
  ln -s "$(basename "$stage")" "$next_link"
  mv -Tf -- "$next_link" "$CACHE"
  trap - EXIT
  echo "Cache updated"
}

cache_age() {
  local stamp=$CACHE/updated-at
  test -f "$stamp" || { echo "Cache has no update timestamp" >&2; return; }
  local now modified days
  now=$(date +%s)
  modified=$(date -r "$stamp" +%s)
  days=$(( (now - modified) / 86400 ))
  echo "Cache age: $days day(s)"
  (( days <= MAX_AGE_DAYS )) || echo "Warning: cache is older than $MAX_AGE_DAYS days; continuing with cached data" >&2
}

remove_jump() {
  local tool=$1 parent=$2 chain=$3
  while "$tool" -C "$parent" -j "$chain" 2>/dev/null; do "$tool" -D "$parent" -j "$chain"; done
}

stop_family() {
  local tool=$1 input_chain=$2 output_chain=$3
  remove_jump "$tool" INPUT "$input_chain"
  remove_jump "$tool" OUTPUT "$output_chain"
  "$tool" -F "$input_chain" 2>/dev/null || true
  "$tool" -X "$input_chain" 2>/dev/null || true
  "$tool" -F "$output_chain" 2>/dev/null || true
  "$tool" -X "$output_chain" 2>/dev/null || true
}

stop() {
  stop_family iptables "$CHAIN4_IN" "$CHAIN4_OUT"
  command -v ip6tables >/dev/null && stop_family ip6tables "$CHAIN6_IN" "$CHAIN6_OUT"
}

load_family() {
  local version=$1 tool=$2 input_chain=$3 output_chain=$4
  "$tool" -N "$input_chain"
  "$tool" -A "$input_chain" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
  if [[ -n $ALLOW_TCP_PORTS ]]; then
    "$tool" -A "$input_chain" -p tcp -m multiport --dports "$ALLOW_TCP_PORTS" -j RETURN
  fi
  if [[ $BLOCK_OUTPUT == 1 ]]; then
    "$tool" -N "$output_chain"
    "$tool" -A "$output_chain" -m conntrack --ctstate ESTABLISHED,RELATED -j RETURN
  fi
  for country in $COUNTRIES; do
    local file
    file=$(cache_file "$country" "$version")
    validate_file "$version" "$file"
    while IFS= read -r network; do
      [[ -z $network || $network == \#* ]] && continue
      "$tool" -A "$input_chain" -s "$network" -j DROP
      if [[ $BLOCK_OUTPUT == 1 ]]; then
        "$tool" -A "$output_chain" -d "$network" -j DROP
      fi
    done < "$file"
  done
  "$tool" -I INPUT 1 -j "$input_chain"
  [[ $BLOCK_OUTPUT == 1 ]] && "$tool" -I OUTPUT 1 -j "$output_chain"
}

test_cache() {
  for country in $COUNTRIES; do
    validate_code "$country" || return 1
    validate_file 4 "$(cache_file "$country" 4)"
    [[ $ENABLE_IPV6 == 0 ]] || validate_file 6 "$(cache_file "$country" 6)"
  done
  cache_age
  echo "Cached CIDRs are valid"
}

start() {
  test_cache
  stop
  if ! load_family 4 iptables "$CHAIN4_IN" "$CHAIN4_OUT"; then
    stop
    return 1
  fi
  if [[ $ENABLE_IPV6 == 1 ]]; then
    command -v ip6tables >/dev/null || { echo "ENABLE_IPV6=1 but ip6tables is unavailable" >&2; stop; return 1; }
    load_family 6 ip6tables "$CHAIN6_IN" "$CHAIN6_OUT" || { stop; return 1; }
  fi
}

status() {
  cache_age
  iptables -nvL "$CHAIN4_IN" --line-numbers 2>/dev/null || echo "IPv4 input chain is not loaded"
  [[ $BLOCK_OUTPUT == 0 ]] || iptables -nvL "$CHAIN4_OUT" --line-numbers 2>/dev/null || true
  [[ $ENABLE_IPV6 == 0 ]] || ip6tables -nvL "$CHAIN6_IN" --line-numbers 2>/dev/null || true
  [[ $ENABLE_IPV6 == 0 || $BLOCK_OUTPUT == 0 ]] || ip6tables -nvL "$CHAIN6_OUT" --line-numbers 2>/dev/null || true
}

need_root
case "${1:-}" in
  update|start|stop|status) "$1" ;;
  test) test_cache ;;
  *) echo "Usage: $0 {update|start|stop|status|test}" >&2; exit 2 ;;
esac

Prepare While Things Are Quiet

I configure the countries and update the cache before I need it:

sudo COUNTRIES='cn ru' ENABLE_IPV6=0 iptables-country-block update
sudo COUNTRIES='cn ru' ENABLE_IPV6=0 iptables-country-block test

During an incident, start is deterministic and offline:

sudo COUNTRIES='cn ru' ENABLE_IPV6=0 iptables-country-block start
sudo iptables-country-block status

ALLOW_TCP_PORTS is evaluated before inbound country drops. The optional OUTPUT chain uses only destination matching and is disabled by default. IPv6 is also explicit: it is unchanged unless ENABLE_IPV6=1 and validated v6 cache files exist.

IPdeny documents its aggregated country-zone downloads on the IPdeny block downloads page. I still refresh periodically because allocation data ages.

Faster Optional Backends

Thousands of one-rule-per-CIDR iptables entries can be slow to load. ipset lets the iptables rule refer to one set, while nftables has native interval sets:

ipset create country_block hash:net family inet -exist
ipset add country_block 192.0.2.0/24 -exist
iptables -C INPUT -m set --match-set country_block src -j DROP 2>/dev/null || \
  iptables -I INPUT 1 -m set --match-set country_block src -j DROP
set blocked_countries {
  type ipv4_addr
  flags interval
  elements = { 192.0.2.0/24 }
}

Those are performance options, not dependencies for the offline script. The simple cached iptables approach remains available when I need it quickly.



Buy Me a Coffee