Motivation: Certain foreign websites are now blocking access to users based on their European location. And Europeans are blocking access to certain websites such as rt.com at quite a fundamental level, not just DNS blocking but de-routeing them. It's not just for porn, it's more pernicious than that - Bitchute and Videy, for insatnce, are choosing to not server UK users because of the Online Safety Act. And I've finally snapped.
Solution: I've rented a Linux VM outside Europe and decide to run a Wireguard VPN
But I don't want to send *every* request because of traffic limitations and whathaveyou.
I got Claude to write me a script to take a list of domain names, resolve their IPs and route traffic to just those IPs to my VM.
I use nftables
Here's the script. It takes the domains from "/etc/wireguard/domains.txt" and routes them through wg0
/usr/local/sbin/wireguard-domain-router.sh
#!/bin/bash
# Routes specified domains through WireGuard interface
set -euo pipefail
# Configuration
DOMAIN_FILE="/etc/wireguard/domains.txt"
WG_INTERFACE="wg0"
WG_TABLE="200"
WG_TABLE_NAME="wireguard"
NFT_MARK="0x00000001"
LOG_FILE="/var/log/wireguard-domain-router.log"
# Ensure log file exists
touch "$LOG_FILE"
log() {
echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
# Check if running as root
if [[ $EUID -ne 0 ]]; then
log "ERROR: This script must be run as root"
exit 1
fi
# Ensure domain file exists
if [[ ! -f "$DOMAIN_FILE" ]]; then
log "ERROR: Domain file $DOMAIN_FILE does not exist"
exit 1
fi
# Setup routing table if it doesn't exist
if ! grep -q "$WG_TABLE_NAME" /etc/iproute2/rt_tables; then
echo "$WG_TABLE $WG_TABLE_NAME" >> /etc/iproute2/rt_tables
log "Added routing table $WG_TABLE_NAME"
sleep 1
fi
# Verify WireGuard interface exists
if ! ip link show "$WG_INTERFACE" >/dev/null 2>&1; then
log "ERROR: WireGuard interface $WG_INTERFACE does not exist"
exit 1
fi
# Create WireGuard default route in custom table
if ! ip route show table "$WG_TABLE" 2>/dev/null | grep -q "default dev $WG_INTERFACE"; then
if ip route add default dev "$WG_INTERFACE" table "$WG_TABLE" 2>/dev/null; then
log "Added default route for $WG_INTERFACE in table $WG_TABLE_NAME"
else
log "WARNING: Could not add default route for $WG_INTERFACE (interface may be down)"
fi
fi
# Ensure policy routing rule exists
if ! ip rule list | grep -q "fwmark 0x1 lookup $WG_TABLE_NAME"; then
ip rule add fwmark 0x1 table "$WG_TABLE_NAME"
log "Added policy routing rule for mark 0x1"
fi
# Create nftables table and chains if they don't exist
nft list table ip mangle >/dev/null 2>&1 || nft add table ip mangle
# COMPLETELY FLUSH the output chain and recreate it
log "Flushing and recreating nftables output chain..."
nft delete chain ip mangle output 2>/dev/null || true
nft add chain ip mangle output "{ type route hook output priority mangle; }" 2>/dev/null || true
# Resolve domains and collect new IPs
new_ips=()
log "Starting domain resolution..."
while IFS= read -r domain || [[ -n "$domain" ]]; do
# Skip empty lines and comments
[[ -z "$domain" || "$domain" =~ ^[[:space:]]*# ]] && continue
# Trim whitespace
domain=$(echo "$domain" | xargs)
[[ -z "$domain" ]] && continue
log "Resolving domain: $domain"
# Resolve domain to IPv4 addresses
resolved_ips=$(dig +short "$domain" A | grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || true)
if [[ -z "$resolved_ips" ]]; then
log "WARNING: No A records found for $domain"
continue
fi
for ip in $resolved_ips; do
new_ips+=("$ip")
log " $domain -> $ip"
done
done < "$DOMAIN_FILE"
# Remove duplicates and sort
if [ ${#new_ips[@]} -gt 0 ]; then
new_ips_sorted=$(printf '%s\n' "${new_ips[@]}" | sort -u)
log "Found $(echo "$new_ips_sorted" | wc -w) unique IP addresses"
else
new_ips_sorted=""
log "No IP addresses resolved from domains"
fi
# Clean up old routes that are no longer needed
existing_routes=$(ip route show table "$WG_TABLE" | grep -v "default" | awk '{print $1}' | sort || true)
if [[ -n "$existing_routes" ]]; then
for ip in $existing_routes; do
if [[ -z "$new_ips_sorted" ]] || ! echo "$new_ips_sorted" | grep -Fxq "$ip"; then
log "Removing old route for $ip"
ip route del "$ip" dev "$WG_INTERFACE" table "$WG_TABLE" 2>/dev/null || true
fi
done
fi
# Add new rules and routes
if [[ -n "$new_ips_sorted" ]]; then
log "Adding new rules and routes..."
# Use a simple for loop with the string directly
for ip in $new_ips_sorted; do
[[ -z "$ip" ]] && continue
log "Processing IP: $ip"
# Add nftables rule to mark packets
if nft add rule ip mangle output ip daddr "$ip" mark set "$NFT_MARK"; then
log "Added nftables rule for $ip"
# Add route via WireGuard if it doesn't exist
if ! ip route show table "$WG_TABLE" | grep -q "^$ip "; then
if ip route add "$ip" dev "$WG_INTERFACE" table "$WG_TABLE" 2>/dev/null; then
log "Added route for $ip"
else
log "WARNING: Could not add route for $ip"
fi
else
log "Route for $ip already exists"
fi
else
log "ERROR: Could not add nftables rule for $ip"
exit 1
fi
done
fi
# Count total rules
total_domains=$(grep -v '^[[:space:]]*#\|^[[:space:]]*$' "$DOMAIN_FILE" | wc -l)
if [[ -n "$new_ips_sorted" ]]; then
total_ips=$(echo "$new_ips_sorted" | wc -w)
else
total_ips=0
fi
log "Update complete: $total_domains domains resolved to $total_ips unique IP addresses"
# Final verification
final_rules=$(nft list chain ip mangle output 2>/dev/null | grep "mark set 0x00000001" | wc -l)
log "Final rule count: $final_rules"
# Check for duplicates one more time
nft list chain ip mangle output 2>/dev/null | grep "mark set 0x00000001" | sort | while read -r rule; do
ip=$(echo "$rule" | sed -n 's/.*ip daddr \([0-9.]*\).*/\1/p')
[[ -n "$ip" ]] && log "Active rule: $ip -> WireGuard"
done
I then have some systemd magic.
I have a service file
[Unit]
Description=WireGuard Domain Router
Documentation=man:systemd.service(5)
Wants=network-online.target
After=network-online.target nss-lookup.target
ConditionPathExists=/etc/wireguard/domains.txt
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/wg_domain_script.sh
User=root
StandardOutput=journal
StandardError=journal
# Security settings
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadWritePaths=/var/log /etc/iproute2
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW
[Install]
WantedBy=multi-user.target
One type of which I didn't know about which will wait for file changes
[Unit]
Description=Watch /etc/wireguard/domains.txt and run WireGuard Domain Router on changes
Documentation=man:systemd.path(5)
ConditionPathExists=/etc/wireguard/domains.txt
[Path]
PathChanged=/etc/wireguard/domains.txt
PathModified=/etc/wireguard/domains.txt
Unit=wireguard-domain-router.service
[Install]
WantedBy=multi-user.target
and a timer that runs once a day in case IPs change
[Unit]
Description=Run WireGuard Domain Router every 15 minutes
Documentation=man:systemd.timer(5)
Requires=wireguard-domain-router.service
[Timer]
OnCalendar=*-*-* 04:00:00
Persistent=true
[Install]
WantedBy=timers.target
All I have to do now is add domains to /etc/wireguard/domains.txt and then wait for the country I am using to adopt the same blocks and rent another one!