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)"