ASUS Merlin: Route via VPN for specific destination hosts

EDIT 8th Jan 2022 – A few improvements

  • You can now configure the interface for each domain.
  • FIXED: Rules now show as enabled in the GUI.
  • FIXED: Manually created rules are now preserved.

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

# 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 fetches the currently live list of rules and filters out the auto-generated rules from the last run to get a list of all the manually created rules in order to preserve them.
  2. It then iterates over the list of hostnames, using nslookup to resolve the IPs for each and awk to do some filtering of the nslookup output.
  3. 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.
  4. 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.
  5. 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 rules (just be careful with the single quote at the beginning and end of the list):
RULES='
whatismyipaddress.com|OVPN1
netflix.com|WAN'

# Create a new temp file with any manually created rules
# When we edit the rules via the GUI, the rules file gets put on one line
# The sed command splits it on '<' chars not preceded by whitespace in order to split by line.
# The grep then excludes all the auto-generated rules.
# We use '|| true' to force the command result to be 0 even if no rows were found by
# grep (because there were no manually created rules).
sed 's:\(.\)<:\1\n<:g' /jffs/openvpn/vpndirector_rulelist | grep -v 'DNS-AUTO-' > /tmp/vpndirector_rulelist || true
cat /tmp/vpndirector_rulelist

IPS=""
INDEX=1
for RULE in ${RULES}; do
  # ${RULE%-*} deletes the shortest substring of $RULE that matches the pattern -* starting
  # from the end of the string. ${RULE#*-} does the same, but with the *- pattern and starting
  # from the beginning of the string.
  HOST=${RULE%\|*}
  INTERFACE=${RULE#*\|}

  # 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 '<1>DNS-AUTO-'$HOST'>>'$IP'>'$INTERFACE

    # 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 '<1>DNS-AUTO-'$HOST'>>'$IP'>'$INTERFACE >> /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
  logger -s -p user.info '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!

9 Comments on “ASUS Merlin: Route via VPN for specific destination hosts”

  1. Hi, thanks for the script, butt there a serious flaw on it, it completely removes any custom rules one added manually.
    Perhaps its best to check for rules already there and then add new ones to it?

    • Ok, so I think I have fixed it. I have done limited testing, so let me know if it works ok for you.
      All generated rules get the prefix ‘AUTO-DNS-‘ so the script can separate them from manually created rules.

    • Ok, so NOW it’s working. Was fighting a weird bug where the manually created rules were being deleted only when the job ran in cron, not when run manually. Turned out to be a different PATH invoking different versions of grep. I thought I was going mad.

  2. Hi Charles

    Amazing work, thanks for doing this. Appreciate the detailed comments within, makes it easier to see what’s going on. Quality work.

    Q: can this be used in the opposite? I want to funnel all traffic from a local IP thru the VPN and have certain domains/hostnames whitelisted so they just pass thru regular WAN.

    • Let me quickly note that I enabled the VPN connection for a specific streaming device (local IP) and then hostname|WAN’d all the hostnames I want to “whitelist”, but that didn’t work. Am I doing it right?

  3. Just a quick comment to those who are just getting to grips with split tunnelling. I found it easier to push a “service” such as netflix through the WAN by using x3mRouting Utility Scripts (https://github.com/Xentrk/x3mRouting) to “watch” for the domain names for the “service” in question (could be anything BBCiPLAYER, IPTV etc) I then added these to the vpn_director_host_rules.sh. That meant that I can set these as WAN only and the set all other IP’s as VPN (192.168.x.x/24).

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 )

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: