Side view of crop anonymous cyber spy in hoodie hacking computer system with information on screen at night

Tailscale Exit Nodes and ACLs: Beyond the Five-Minute Setup

Getting Tailscale running takes about five minutes, and most write-ups stop there. The interesting part starts afterwards: routing your internet traffic through a box you trust, pulling legacy kit onto the tailnet, and writing an access policy that actually says no to something. This post assumes tailscale up already works on at least two of your machines.

Exit Nodes: Sending All Traffic Through One Machine

Tailscale exit nodes let a device push every packet — not just tailnet traffic — through another node. That is how you get out through your home connection while sat on hotel Wi-Fi. On the machine that will do the routing:

echo 'net.ipv4.ip_forward = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
echo 'net.ipv6.conf.all.forwarding = 1' | sudo tee -a /etc/sysctl.d/99-tailscale.conf
sudo sysctl -p /etc/sysctl.d/99-tailscale.conf

sudo tailscale up --advertise-exit-node

Nothing happens until you approve it. Either tick the box in the admin console under the machine’s route settings, or let an autoApprovers rule do it (below). Then on the client:

tailscale exit-node list
sudo tailscale up --exit-node=100.101.102.103 --exit-node-allow-lan-access

That --exit-node-allow-lan-access flag matters more than people expect. Without it you lose your local printer, your NAS and your router’s admin page the moment the exit node is active, because the default route swallows everything. Drop the exit node again with sudo tailscale up --exit-node=.

Two things worth knowing before you rely on it. Throughput is bounded by the exit node’s upstream, so a Pi on a 20 Mbps upload will feel like a Pi on a 20 Mbps upload. And a node that advertises an exit route does not have to be allowed to use one — those are separate permissions in policy.

Subnet Routers for Kit That Cannot Run Tailscale

Printers, IPMI cards, switches, that one appliance still on CentOS 7 — none of them will ever run a Tailscale client. A subnet router advertises a whole LAN range into the tailnet instead:

sudo tailscale up \
  --advertise-routes=192.168.10.0/24,192.168.30.0/24 \
  --advertise-exit-node \
  --snat-subnet-routes=false

Turning SNAT off means the LAN devices see the real 100.64.0.0/10 source address rather than the router’s LAN IP, which is what you want if you ever intend to filter on it. The cost is that your LAN needs a static route pointing the CGNAT range back at the subnet router. Clients that should receive the routes need --accept-routes; on macOS and Windows that is the default, on Linux it is not.

ACL Policy Files That Actually Restrict Something

The default policy is "action": "accept" from * to *. Every device can reach every other device on every port. Fine on day one, not fine once a smart plug’s Linux box is on the tailnet. The policy file is HuJSON, so comments and trailing commas are legal.

{
  "tagOwners": {
    "tag:server":   ["autogroup:admin"],
    "tag:router":   ["autogroup:admin"],
    "tag:ci":       ["autogroup:admin"],
  },

  "acls": [
    // Admins reach everything.
    {
      "action": "accept",
      "src":    ["autogroup:admin"],
      "dst":    ["*:*"],
    },
    // Everyone else gets named services on servers, nothing more.
    {
      "action": "accept",
      "src":    ["autogroup:member"],
      "dst":    ["tag:server:22,80,443,5432"],
    },
    // CI runners may push to the registry and nowhere else.
    {
      "action": "accept",
      "src":    ["tag:ci"],
      "dst":    ["tag:server:5000"],
    },
  ],

  "autoApprovers": {
    "exitNode": ["tag:router"],
    "routes": {
      "192.168.10.0/24": ["tag:router"],
    },
  },

  "ssh": [
    {
      "action": "check",
      "src":    ["autogroup:admin"],
      "dst":    ["tag:server"],
      "users":  ["root", "autogroup:nonroot"],
    },
  ],
}

Test before you save. The admin console has a preview pane, and the policy file supports a tests block that fails the save if an expected flow breaks:

"tests": [
  {
    "src": "tag:ci",
    "accept": ["tag:server:5000"],
    "deny":   ["tag:server:22"],
  },
]

Locking yourself out of your own tailnet is entirely possible and genuinely annoying, so keep the admin rule first and keep at least one machine reachable another way.

Tags Instead of Users

Anything unattended should be tagged rather than owned by a human account. A tagged node has no user, does not inherit your permissions, and — importantly — its key never expires, so your server does not drop off the tailnet at 3am six months from now.

sudo tailscale up --authkey tskey-auth-xxxx --advertise-tags=tag:server
  • Tag every server, container host and router.
  • Use ephemeral auth keys for CI runners so dead nodes clean themselves up.
  • A node can hold several tags; policy matches any of them.
  • You cannot add a tag to an existing node without re-authenticating it.

MagicDNS and Split DNS

MagicDNS gives every node a name under your-tailnet.ts.net, which beats memorising 100.x addresses. The more useful trick is split DNS: point a specific domain at a resolver that only exists inside the tailnet.

{
  "dns": {
    "nameservers": {
      "global": ["1.1.1.1"],
    },
    "routes": {
      "lab.example.com": ["100.101.102.104"],
    },
    "magicDNS": true,
  },
}

Queries for lab.example.com go to the internal resolver, everything else goes to the global one. If the records you are serving there are new to you, the A record is the building block worth understanding first. Note that MagicDNS names are not valid in ACL destinations — use tags or the node name, not the FQDN.

What I Would Actually Do

Tag your servers on day one, because retrofitting tags means re-authenticating every node. Put one always-on box — a Pi or a small mini PC — on the LAN advertising both a subnet route and an exit node, auto-approved by tag:router. Then replace the default accept-all ACL with something closer to the policy above and add a tests block so you notice when you break it. If you are running services on that box, the same pattern works nicely alongside Vaultwarden behind Tailscale on a Raspberry Pi — tailnet-only, no ports open to the internet at all.


Leave a Reply