ASUS Merlin: Route via VPN for specific destination hosts

I’m a user of and a huge fan of the Merlin firmware for Asus routers. It rocks. I moved from DD-WRT a few years ago and haven’t looked back, much simpler and just as powerful.

One of the new features recently is the VPN Director. This lets you more easily set policy rules for what traffic should route via your VPN and even has a ‘kill switch’ which prevents that traffic from leaking out via your normal WAN connection if the VPN dies, which is great. However, it has a few limitations:

Kill switch limitations

The ‘kill switch’ only applies to rules where you have specified a local IP. If you leave that blank to create a rule that applies to all local IPs trying to reach a specific destination IP, when the VPN is disabled, these packets will flow over the WAN. Damn.

No support for host names in destinations

You can only specify a destination IP or range, not a hostname. This sucks because it would be nice to have a policy rule to send all traffic to netflix.com (for example) via the VPN, but have all other traffic flow out directly over the WAN as normal.

This limitation makes sense because the sub-systems that handle this kind of routing use IP addresses not hostnames. Additionally, IPs change so you’d have to somehow keep doing DNS lookups and updating the rules. Annoying though – I wonder if there is a workaround?…

The solution

I’ve created a custom script that solves both problems by converting a list of hostnames to route via VPN into VPN Director policy rules as well as corresponding iptables rules to block traffic to those hosts routing via the WAN.

Prerequisites

  • You should have already successfully configured your VPN and set the ‘Redirect Internet traffic through tunnel‘ setting to ‘VPN Director (policy rules)‘ mode.
  • You should be familiar with using user scripts and be familiar enough with scripting to be able to make sense of my script below. Don’t just copy/paste and hope you can trust me not to break your router.

Setup

The first script is a firewall-start script. This will run when the router boots, sets everything up and:

  1. Creates a new custom iptables chain that we can put our rules in.
  2. Adds that chain to the start of the built in FORWARD chain.
  3. Executes the vpn_director_host_rules.sh script (below) in order to generate the rules for the hosts we want to route.
  4. Adds that same script to the crontab so that it runs every 10 minutes. This is so that we can keep on top of IP address changes.

Add this script as /jffs/scripts/firewall-start and make it executable.

#!/bin/sh
# Firewall Startup Script:

# Create a custom chain
# We use a custom chain so that our rules don't get mixed up with any others.
# This makes updating them much safer.
iptables -N CUSTOM_FORWARD
# Add custom chain to the top of the FORWARD chain so the rules get executed early.
iptables -I FORWARD -j CUSTOM_FORWARD

# Example of prevent 192.168.1.11 from reaching the internet directly (so no connection if VPN down)
#iptables -I CUSTOM_FORWARD -s 192.168.1.11 -o eth0 -j DROP

# Setup custom rules
/jffs/scripts/vpn_director_host_rules.sh

# Add crontab entry to refresh domain based rules every 10 minutes
cru a setup_vpn_director "*/10 * * * *" /jffs/scripts/vpn_director_host_rules.sh

The second script is the meat of the operation. It has the list of hosts at the top which you can edit to include the hostnames you want to route to over the VPN and ONLY over the VPN.

You can read the code and the comments to see exactly how it does this, but in a nutshell:

  1. It iterates over the list of hostnames, using nslookup to resolve the IPs for each and awk to do some filtering of the nslookup output.
  2. For each IP, it:
    1. generates a rule in the VPN Director format and adds it to a temporary VPN Director rules file.
    2. generates a corresponding iptables rule that rejects any packets trying to leave for that IP over the WAN (this is the ‘kill switch’). It adds each rule to the begining of the custom FORWARD iptables chain that we created earlier, pushing all the existing rules (from the last run) down the chain.
  3. Next it trims the old rules that have been pushed down the chain. This feels a bit of a ‘clunky’ way to do it, but this was the best way I could come up with without creating a small window where the rules were not in effect at each run. Flushing the table and then re-building it would be easier, but would have that side-effect.
  4. Diffs the newly created temporary VPN Director rules file with the existing one and replaces it only if there are any changes. This is done to avoid writing to the JFFS parition over and over, causing wear on the flash.

Add this script as /jffs/scripts/vpn_director_host_rules.sh and make it executable, then edit the list of hostnames near the top. Take care to respect the single quotes that wrap the whole list.

#!/bin/sh

# Cause the script to exit if errors are encountered
set -e
set -u

# Edit this list of hosts (just be careful with the single quote at the beginning and end of the list):
HOSTS='
whatismyipaddress.com
netflix.com'

# Create/clear temporary vpndirector_rulelist file:
cat /dev/null > /tmp/vpndirector_rulelist

IPS=""
INDEX=1
for HOST in ${HOSTS}; do
  # Run nslookup for each host to get it's IP addresses, discarding the first two lines
  # and filter for lines with 'Address' in them. N.B. there is often more than one.
  # Then ditch any lines with a ':' in them, since those will be IPv6 results.
  # Then sort the results so that we get some consitency when checking for changes later.
  for IP in $(nslookup $HOST | awk '(NR>2) && /^Address/ {print $3}' | awk '!/:/' | sort); do
    echo '<'$INDEX'> '$HOST' - '$IP
    
    # Add the IP to a list for later when we create the corresponding iptables rules
    IPS="${IPS} ${IP}"

    # Add an entry to VPN Director rules temporary file:
    # Rule example:
    # #<1>WhatIsMyIP>>104.16.154.36>OVPN1
    echo '<'$INDEX'>'$HOST'>>'$IP'>OVPN1' >> /tmp/vpndirector_rulelist

    let INDEX=$INDEX+1
  done
done

let RULE_COUNT=$INDEX-1

# Compare the new rule list with the old one and see if anything has changed.
# This saves on writes to jffs and reduces wear on the flash drive.
if ! diff /tmp/vpndirector_rulelist /jffs/openvpn/vpndirector_rulelist >/dev/null; then
  echo 'New changes to VPN Director policies detected, writing to jffs...'
  date >> /tmp/vpn_rules_update_audit.log
  cp /tmp/vpndirector_rulelist /jffs/openvpn/vpndirector_rulelist

  # Restart VPN routing in order to refresh rules:
  service restart_vpnrouting0

  # Use iptables to prevent connecting to the IPs via WAN (so no connection if VPN down)
  # We insert all the rules at the start of the chain, then delete the old rules later.
  # This is a bit cumbersome, but iptables doesn't give you a neat way to check if a rule already exists.
  echo 'Creating iptables rules...'
  for IP in ${IPS}; do
    iptables -I CUSTOM_FORWARD 1 -d $IP -o eth0 -j REJECT --reject-with icmp-net-unreachable
  done

  # Add rule to return to the calling chain
  echo 'Inserting new iptables RETURN rule at position '$(($RULE_COUNT+1))
  iptables -I CUSTOM_FORWARD $(($RULE_COUNT+1)) -j RETURN

  let RULE_TO_REMOVE=$RULE_COUNT+2

  # Now we trim any old rules from the end of the chain.
  # N.B. As the chain shrinks with each one you remove, we don't need to increment the index.
  REMOVED_RULE_COUNT=0
  while iptables -D CUSTOM_FORWARD $RULE_TO_REMOVE 2> /dev/null; do
    let REMOVED_RULE_COUNT=$REMOVED_RULE_COUNT+1
  done
  echo 'Removed '$REMOVED_RULE_COUNT' old rules'

  else
    echo 'No changes to VPN Director policies since last run.'
fi

Reboot your router and you should be good to go!

Testing

To test, leave whatismyipaddress.com in your hostname list. If you visit that site when your VPN is up, you should see your external IP is that of your VPN provider. If you turn off the VPN client and try again, the page should fail to load.

You can manually run /jffs/scripts/vpn_director_host_rules.sh and see if it is spitting out any errors.

You can run iptables -S CUSTOM_FORWARD to see the list of iptables rules that the script has generated.

Enjoy!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Google photo

You are commenting using your Google account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

%d bloggers like this: