tor-exit-update.sh raw

   1  #!/bin/bash
   2  # Tor exit node blocker for relay.mleku.dev (orly)
   3  # Fetches the Tor bulk exit list, builds/refreshes an nftables set, and drops
   4  # all packets from those IPs. Refreshed every 6h by a systemd timer.
   5  #
   6  # Sources (in order):
   7  #   1. https://check.torproject.org/torbulkexitlist  (canonical exit IPs only)
   8  #   2. https://www.dan.me.uk/torlist                  (fallback, also relays)
   9  # If every fetch fails, the existing ruleset is kept untouched.
  10  
  11  set -euo pipefail
  12  
  13  CONF="/etc/nftables/tor-exit.conf"
  14  FETCH_TIMEOUT=20
  15  TABLE="torblock"
  16  CHAIN="torblock_drop"
  17  
  18  log() { echo "[tor-exit-update] $(date -Is) $*" >&2; }
  19  
  20  fetch_list() {
  21      local url="$1"
  22      curl -fsSL --connect-timeout "$FETCH_TIMEOUT" --max-time 60 "$url" 2>/dev/null \
  23          | grep -E '^[0-9]{1,3}(\.[0-9]{1,3}){3}$' || true
  24  }
  25  
  26  # Validate that every line is a bare IPv4 address (defensive against
  27  # a compromised/mangled upstream returning anything else).
  28  validate_ips() {
  29      awk -F. 'NF==4 && $1<=255 && $2<=255 && $3<=255 && $4<=255 && $1>0' 2>/dev/null || true
  30  }
  31  
  32  list=""
  33  if tmp="$(fetch_list "https://check.torproject.org/torbulkexitlist")"; then
  34      list="$(printf '%s\n' "$tmp" | validate_ips)"
  35      log "fetched torproject exitlist: $(printf '%s\n' "$list" | sed '/^$/d' | wc -l) IPs"
  36  fi
  37  
  38  if [ -z "$list" ] && tmp="$(fetch_list "https://www.dan.me/torlist")"; then
  39      list="$(printf '%s\n' "$tmp" | validate_ips)"
  40      log "fetched dan.me fallback: $(printf '%s\n' "$list" | sed '/^$/d' | wc -l) IPs"
  41  fi
  42  
  43  if [ -z "$list" ]; then
  44      log "no list fetched, keeping existing ruleset"
  45      exit 0
  46  fi
  47  
  48  # Deduplicate while preserving numeric order.
  49  list="$(printf '%s\n' "$list" | sed '/^$/d' | sort -u -t. -k1,1n -k2,2n -k3,3n -k4,4n)"
  50  
  51  # Build the nftables ruleset: a set of exit IPs and a base chain that drops
  52  # them as the highest-priority filter chain so the drop wins before Docker's
  53  # accept rules on 80/443.
  54  {
  55      echo "table ip $TABLE {"
  56      echo "    set exits {"
  57      echo "        type ipv4_addr"
  58      echo "        flags interval"
  59      echo "        elements = {"
  60      # 8 IPs per line for readability
  61      printf '%s\n' "$list" | paste -sd' ' - | awk '{for(i=1;i<=NF;i++){printf "%s%s",$i,(i<NF)?", ":""; if(i%8==0) printf "\n"}}'
  62      echo "        }"
  63      echo "    }"
  64      echo "    chain $CHAIN {"
  65      echo "        type filter hook input priority filter - 10; policy accept;"
  66      echo "        ip saddr @exits counter drop"
  67      echo "    }"
  68      echo "}"
  69  } > "$CONF.tmp"
  70  
  71  # Verify the generated ruleset parses before installing.
  72  if ! nft -c -f "$CONF.tmp" >/dev/null 2>&1; then
  73      log "generated ruleset failed to parse, keeping existing ruleset"
  74      rm -f "$CONF.tmp"
  75      exit 1
  76  fi
  77  
  78  # Atomically replace: delete the old table (idempotent) and load the new one.
  79  nft delete table ip "$TABLE" 2>/dev/null || true
  80  nft -f "$CONF.tmp"
  81  mv "$CONF.tmp" "$CONF"
  82  log "applied $(printf '%s\n' "$list" | wc -l) exit IPs to nftables ($CONF)"