Skip to content

Argona7/tailscale-route-fix

Repository files navigation

tailscale-route-fix

Run Tailscale alongside any other macOS VPN — Happ, AmneziaVPN, Outline, OpenVPN — without your tailnet going dark.

A tiny launchd daemon that fixes a long-standing routing collision between Tailscale and any third-party VPN that installs a default route with the cloning (C) flag on macOS. Zero CPU when idle. Pure shell, no dependencies, ~100 lines.


The problem

Two VPN tunnels on the same Mac, and one silently sabotages the other:

  1. You install a system VPN — Happ, AmneziaVPN, Outline, OpenVPN, AnyConnect, anything that uses a NetworkExtension PacketTunnelProvider — and turn it on.
  2. Tailscale is already running. tailscale status looks healthy. Peers are listed.
  3. But ping 100.x.x.x to any peer times out. tailscale ping reports nothing through. SSH to a tailnet host hangs.

Turn the other VPN off and Tailscale comes back. Turn it on and Tailscale dies again.

Why it happens

Most macOS VPN clients install their default route with the C (cloning) flag:

default            link#42            UCSg                utun6

C means: every time the kernel needs to route a packet to a destination it has never seen before, clone a host-specific (/32) route from this entry. So the first time you try to reach 100.122.123.37 (a Tailscale peer), the kernel matches the cloning default route, manufactures a 100.122.123.37 → utun6 host route on the spot, and from then on every packet for that peer disappears into the other VPN's tunnel. Forever — until you reboot or that route is manually removed.

Tailscale does install its own 100.64.0.0/10 → utunN route, but the cloned /32 is more specific (/32 > /10) and wins longest-prefix match.

You can see the wreckage with netstat -nr:

100.64/10          link#44            UCSI                utun9       ← Tailscale, ignored
100.122.123.37     link#42            UHWIig              utun6       ← cloned, wins
100.118.181.65     link#42            UHWIig              utun6       ← cloned, wins

UHWIig = Up, Host, Was-cloned, Ifscope, indirect, global. Deleting them by hand doesn't help — the cloning rule re-creates them on the next packet.

The fix

Install a static host route through Tailscale's utun for every tailnet peer. Static routes outrank cloned ones — kernel honours S over W. Re-applies automatically whenever the routing table changes (new peer, VPN reconnect, network flap).

The same daemon also handles a second class of failure: the IPv6 controlplane lockout. DNS returns AAAA records for controlplane.tailscale.com first, but most third-party VPN clients don't tunnel IPv6, and many networks (mobile hotspots, restricted-country ISPs) don't provide it. Tailscale tries v6 first, gets "no route to host", and never falls back to v4 — staying logged out indefinitely with a cryptic dial tcp [2606:b740:49::111]:443: connect: no route to host in the logs. The daemon disables IPv6 on every physical network service (Wi-Fi, Ethernet, USB tether) so DNS only returns A records. VPN-owned services are skipped, so their internal IPv6 stays intact.

100.83.97.27       utun9              UHS                 utun9        ← S = STATIC, wins
100.118.181.65     utun9              UHS                 utun9
100.122.123.37     utun9              UHS                 utun9
100.100.100.100    utun9              UHS                 utun9        ← MagicDNS

And:

$ networksetup -getinfo "Wi-Fi" | grep IPv6
IPv6: Off
$ networksetup -getinfo "Happ Plus" | grep IPv6
IPv6: Automatic    ← VPN service untouched

The daemon is event-driven via route monitor (a native macOS kernel API that streams routing-socket events). It sleeps until the kernel actually announces a route change — so CPU at idle is effectively zero. A 60-second safety tick covers anything missed.


Install

git clone https://github.com/Argona7/tailscale-route-fix.git
cd tailscale-route-fix
sudo ./install-tailscale-fix.sh

That's it. The installer:

  1. Copies fix-tailscale-route-shadow.sh to /usr/local/bin/
  2. Installs com.user.tailscale-route-fix.plist to /Library/LaunchDaemons/
  3. Loads the daemon via launchctl bootstrap

It starts immediately and at every boot from now on. Tested on macOS 15 (Sequoia, Apple Silicon).

Verify

# Daemon process running?
pgrep -fl fix-tailscale-route-shadow

# Live log
tail -f /var/log/tailscale-route-fix.log

# Static routes installed?
netstat -nr -f inet | grep -E "100\..*UHS"

# Peer reachable while the other VPN is up?
ping 100.x.x.x   # use any IP from `tailscale status`

You should see lines like this in the log every time the other VPN reconnects:

[2026-05-14 20:30:44] pin 100.118.181.65 → utun9 (was: utun6)
[2026-05-14 20:30:44] pin 100.83.97.27   → utun9 (was: utun6)

Uninstall

sudo ./uninstall-tailscale-fix.sh

How it works under the hood

                ┌──────────────────────────────────────┐
                │  /Library/LaunchDaemons/com.user.…  │
                │  (RunAtLoad + KeepAlive)             │
                └────────────────┬─────────────────────┘
                                 │ root, persistent
                                 ▼
         ┌─────────────────────────────────────────────┐
         │  fix-tailscale-route-shadow.sh --watch      │
         │                                             │
         │   ┌──────────────────┐  ┌─────────────────┐ │
         │   │ route -n monitor │  │ tick every 60s  │ │
         │   └────────┬─────────┘  └────────┬────────┘ │
         │            └─────────┬───────────┘          │
         │                      ▼                      │
         │              merged event stream            │
         │                      │                      │
         │                      ▼                      │
         │   for ip in $(tailscale status):            │
         │     if route_iface_for(ip) != ts_utun:      │
         │        route delete -host $ip               │
         │        route add -host $ip                  │
         │              -interface ts_utun -static     │
         └─────────────────────────────────────────────┘

Key correctness properties:

  • Tailscale interface is anchored to its self IPv4 (tailscale ip -4), not "first utun with a 100.x address" — so it won't mis-identify another VPN's tunnel as Tailscale.
  • Peer IPs are filtered to the real CGNAT range 100.64.0.0/10, not the looser 100.0.0.0/8.
  • Single merged pipe for both route monitor and the 60-second heartbeat — the loop sees both kinds of events.
  • -ifscope guard prevents route delete ... -ifscope "" when the route is unscoped.

Caveats

  • macOS-only. This is a launchd + BSD route problem. Linux uses different mechanisms.
  • IPv4 only. Tailscale also assigns IPv6 from fd7a:115c:a1e0::/48. The same class of conflict can in principle occur over IPv6, but in practice apps prefer A records and the failure mode is rare. Open an issue if you actually hit it.
  • Not a Tailscale problem. This is a macOS routing-table semantics issue. Tailscale staff have acknowledged it. Until the kernel offers a way to override cloned routes per-tunnel, this is the workaround.
  • Subnet routing & exit nodes through Tailscale (tailscale up --accept-routes) need their own treatment. Open an issue with your netstat -nr if you need that.

Why a LaunchDaemon and not a LaunchAgent

LaunchDaemons run as root, before login, system-wide. LaunchAgents run as the user, after login. Manipulating the routing table requires root, and we want the fix active from the moment Tailscale and the other VPN come up — both happen before you reach the login screen on most setups. Hence: daemon.

Why bash and not Swift / Go

The whole thing is a hundred lines of glue around two binaries (route, ifconfig) and one stream (route monitor). A native binary would be larger, less auditable, and harder to install. There is nothing here that benefits from a real language.


Acknowledgements

  • Codex (review pass): caught a subtle bug where the safety-tick echo was going to the daemon's stdout (the log file) instead of the consumer pipe, breaking the 60-second fallback. Also tightened the interface-detection heuristic and the CGNAT range filter.

License

MIT — see LICENSE.

About

macOS daemon that keeps Tailscale working alongside other VPN clients (Happ, AmneziaVPN, Outline, OpenVPN, …). Event-driven, root-owned LaunchDaemon, ~100 lines of bash.

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages