Mike Dalrymple Tailscale Subnet Router for AWS

Tailscale Subnet Router for AWS

IP address allow lists are a handy tool for restricting access to cloud resources. However, managing an allow list can be difficult if you have client devices with dynamically assigned IP addresses. This post describes how to use Tailscale’s subnet router capability to route AWS bound traffic from your Tailnet through a subnet router so it will present as coming from a single IP address.

Our goal is to ensure that traffic reaching specific AWS resources does so using a known (fixed) IP address. For my specific use case, I’m using allow lists to restrict client access to Cloudfront distributions and OpenSearch endpoints.

Why not use an Exit Node?

The simplest way to achieve this would be to use a Tailscale exit node to route all traffic leaving the Tailnet. The obvious disadvantage is that all traffic leaving the Tailnet, no matter the destination, must be routed through this node.

While running a similar setup using WireGuard, I would often get blocked from certain websites or be forced to go through annoying CAPTCHA tests to verify I was human. It was a usable solution, but it would be better if I didn’t have to route all my traffic through the exit node.

Subnet Router

With Tailscale’s subnet router, traffic destined for specific IP address ranges will be routed through a designated node while all other traffic is routed normally. This is more complicated than the Exit Node approach because we need to know the IP address or address ranges that should have their traffic routed through our subnet router node. For example, if we wanted to send traffic that is destined for 203.0.113.15 through our subnet router, we would start tailscale with the advertise-routes argument set to the CIDR notation for the destination address.

sudo tailscale up --advertise-routes=203.0.113.15/32

All traffic for 203.0.113.15 will be sent through the subnet router once the route is enabled in the Tailscale admin console. One IP address is easy, but for our AWS example we’ll need several ranges of IP addresses.

AWS IP Ranges

AWS provides a JSON file containing their current IP address ranges. Using a combination of curl and jq, we can extract the IP ranges we’re interested in, create a comma separated list, and pass that to the advertise-routes argument.

In this example, we pass all IPv4 (.prefixes[]) and IPv6 addresses (.ipv6_prefixes) associated with Cloudfront (.service=="CLOUDFRONT").

sudo tailscale up --advertise-routes=$( curl -s https://ip-ranges.amazonaws.com/ip-ranges.json \
        | jq -r \
        ' [ (.prefixes[] | select(.service=="CLOUDFRONT").ip_prefix)]
        + [ (.ipv6_prefixes[] | select(.service=="CLOUDFRONT").ipv6_prefix)]
        | join(",")')

With this configuration, I can now add my subnet router node’s public IP address to the allow list in the Web Application Firewall I have protecting my Cloudfront distribution.

Not every service is uniquely identified in the JSON file’s service attribute. Let’s say you have an OpenSearch cluster running in us-east-2 and you want to create an IP-based access policy. OpenSearch is not a service in the JSON file but it does fall under the general AMAZON service. The AMAZON service’s IP ranges are large but we can further reduce them by filtering on region. That makes our us-east-2 OpenSearch subnet router command look like the following:

sudo tailscale up --advertise-routes=$( curl -s https://ip-ranges.amazonaws.com/ip-ranges.json \
        | jq -r \
        ' [ (.prefixes[] | select(.service=="AMAZON" and .region=="us-east-2").ip_prefix)]
        + [ (.ipv6_prefixes[] | select(.service=="AMAZON" and .region=="us-east-2").ipv6_prefix)]
        | join(",")')

I have not found a limit on the number of routes you can advertise in the --advertise-routes argument so you can easily combine our Cloudfront and OpenSearch routes into the following command.

sudo tailscale up --advertise-routes=$( curl -s https://ip-ranges.amazonaws.com/ip-ranges.json \
        | jq -r \
        ' [ (.prefixes[] | select(.service=="CLOUDFRONT").ip_prefix)]
        + [ (.prefixes[] | select(.service=="AMAZON" and .region=="us-east-2").ip_prefix)]
        + [ (.ipv6_prefixes[] | select(.service=="CLOUDFRONT").ipv6_prefix)]
        + [ (.ipv6_prefixes[] | select(.service=="AMAZON" and .region=="us-east-2").ipv6_prefix)]
        | join(",")')

Drawbacks

There are a few drawbacks to keep in mind when implementing this solution.

  1. All AWS services hosted on the IP ranges you pass to advertise-routes will be routed through your node. This will include way more than just the resources you are using. Certain sites may detect VPN connections and block you until you turn off Tailscale.
  2. The list of AWS IP ranges will change, so you may need to update your advertised routes. AWS provides an SNS topic you can use to receive notifications when the IP ranges change.
  3. The --advertise-routes argument can only be supplied when logging into Tailscale or when the node has already been logged in. I tried using tailscale set --advertise-routes=... when I created my node (through automation). However, when I followed that with a manual tailscale login, the advertised routes were not retained.