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.
Two VPN tunnels on the same Mac, and one silently sabotages the other:
- You install a system VPN — Happ, AmneziaVPN, Outline, OpenVPN, AnyConnect, anything that uses a NetworkExtension PacketTunnelProvider — and turn it on.
- Tailscale is already running.
tailscale statuslooks healthy. Peers are listed. - But
ping 100.x.x.xto any peer times out.tailscale pingreports nothing through. SSH to a tailnet host hangs.
Turn the other VPN off and Tailscale comes back. Turn it on and Tailscale dies again.
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.
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.
git clone https://github.com/Argona7/tailscale-route-fix.git
cd tailscale-route-fix
sudo ./install-tailscale-fix.shThat's it. The installer:
- Copies
fix-tailscale-route-shadow.shto/usr/local/bin/ - Installs
com.user.tailscale-route-fix.plistto/Library/LaunchDaemons/ - Loads the daemon via
launchctl bootstrap
It starts immediately and at every boot from now on. Tested on macOS 15 (Sequoia, Apple Silicon).
# 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)
sudo ./uninstall-tailscale-fix.sh ┌──────────────────────────────────────┐
│ /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 "firstutunwith 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 looser100.0.0.0/8. - Single merged pipe for both
route monitorand the 60-second heartbeat — the loop sees both kinds of events. -ifscopeguard preventsroute delete ... -ifscope ""when the route is unscoped.
- macOS-only. This is a
launchd+ BSDrouteproblem. 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 yournetstat -nrif you need that.
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.
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.
- Codex (review pass): caught a subtle bug where the safety-tick
echowas 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.
MIT — see LICENSE.