Add docker-tailscale-route: fix Docker losing internet under Tailscale exit-node
Tailscale's exit-node "route all traffic" mode captures Docker's forwarded container traffic into its tunnel and drops it as an unrecognized flow, causing every outbound request from inside a container to hang until timeout. This installs a systemd timer that keeps an ip rule in place routing Docker's bridge subnet around Tailscale's catch-all route instead. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
90
README.md
Normal file
90
README.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# ddev-tailscale-docker-fix
|
||||||
|
|
||||||
|
> **Note:** these scripts were written by an LLM (Claude Code), diagnosing
|
||||||
|
> and fixing an issue live on one specific machine (Fedora + NetworkManager
|
||||||
|
> + Tailscale + Docker). They worked there, but review them yourself before
|
||||||
|
> running as root on another machine — network/firewall behavior varies
|
||||||
|
> across distros, Tailscale versions, and configurations.
|
||||||
|
|
||||||
|
Fixes Docker containers (e.g. ddev) losing internet access when Tailscale
|
||||||
|
is running with an exit node ("route all traffic") enabled.
|
||||||
|
|
||||||
|
## The problem
|
||||||
|
|
||||||
|
With an exit node and "route all traffic" enabled, Tailscale installs a
|
||||||
|
catch-all `ip rule` that captures every packet not explicitly exempted
|
||||||
|
and routes it out via the `tailscale0` interface. Tailscale exempts its
|
||||||
|
own daemon traffic from that capture with a firewall mark, but has no
|
||||||
|
way to exempt traffic that only passes *through* the host (forwarded)
|
||||||
|
rather than originating from it.
|
||||||
|
|
||||||
|
Docker containers' outbound traffic is exactly that: forwarded, not
|
||||||
|
locally-originated. It gets no exemption, so it's swept into
|
||||||
|
Tailscale's catch-all route, sent out via `tailscale0`, and dropped by
|
||||||
|
Tailscale's own anti-spoofing firewall rule (`ts-forward`) because it
|
||||||
|
isn't a recognized Tailscale-tunneled flow.
|
||||||
|
|
||||||
|
Symptom: every DNS lookup / outbound HTTP request from inside a
|
||||||
|
container hangs until timeout whenever Tailscale is connected — WP core
|
||||||
|
update checks, plugin license pings, `composer`/`npm` installs,
|
||||||
|
anything. The host's own applications aren't affected, since their
|
||||||
|
traffic is locally-originated and gets Tailscale's exemption mark.
|
||||||
|
|
||||||
|
## The fix
|
||||||
|
|
||||||
|
A higher-priority `ip rule` that routes Docker's private bridge subnet
|
||||||
|
(default: `172.16.0.0/12`, covering Docker's default address pool)
|
||||||
|
through the normal routing table instead of Tailscale's catch-all. This
|
||||||
|
doesn't weaken the VPN for anything else on the host — it only affects
|
||||||
|
packets forwarded from Docker's own bridge networks.
|
||||||
|
|
||||||
|
The rule is kept in place by a systemd timer that re-checks/re-adds it
|
||||||
|
every 15s (and 5s after boot), rather than hooked to a network event:
|
||||||
|
`tailscale0` is a tun device created directly by `tailscaled`, not
|
||||||
|
through a NetworkManager connection profile, so NetworkManager never
|
||||||
|
fires dispatcher/udev events we could reliably hook into for it
|
||||||
|
(confirmed by testing — don't waste time re-trying that route).
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Optional overrides if Docker's default address pool doesn't apply on
|
||||||
|
your machine (e.g. you have 15+ concurrent Docker networks and Docker
|
||||||
|
has fallen back to a different private range):
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo ./install.sh --subnet 172.16.0.0/12 --priority 5200 --table main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
```
|
||||||
|
ip rule list | grep 172.16.0.0/12
|
||||||
|
ddev exec curl -sS -o /dev/null -w '%{time_total}\n' https://api.wordpress.org
|
||||||
|
```
|
||||||
|
|
||||||
|
## Uninstall
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo ./uninstall.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- Only covers Docker networks within `172.16.0.0/12`. If you have more
|
||||||
|
than ~15 concurrent Docker networks on one machine, Docker falls back
|
||||||
|
to `192.168.0.0/20` blocks and then `10.0.0.0/8` — those aren't
|
||||||
|
covered by default. Don't just widen the subnet to `192.168.0.0/16`
|
||||||
|
or `10.0.0.0/8` to compensate: those ranges are also real LAN/VPN
|
||||||
|
subnet-route space, and blanket-exempting them could route traffic
|
||||||
|
around the tunnel that's actually supposed to go through it. If you
|
||||||
|
hit this, either raise `--subnet` to the specific overflow range
|
||||||
|
Docker actually assigned, or extend the script to enumerate live
|
||||||
|
`docker network inspect` subnets instead of using a fixed range.
|
||||||
|
- Assumes `tailscaled`'s own ip rules stay in the 5210+ priority range
|
||||||
|
observed on the machine this was built on; if a future Tailscale
|
||||||
|
version changes that, priority 5200 should still safely sit above it
|
||||||
|
as long as it's below whatever Tailscale uses for its catch-all rule.
|
||||||
18
docker-tailscale-route.service
Normal file
18
docker-tailscale-route.service
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Written by an LLM (Claude Code) — review before trusting on a machine
|
||||||
|
# other than the one this was diagnosed on. See README.md.
|
||||||
|
#
|
||||||
|
# Ensures Docker's bridge traffic bypasses Tailscale's exit-node route
|
||||||
|
# (see docker-tailscale-route.sh for the full explanation of why this is
|
||||||
|
# needed). Triggered on a schedule by the companion
|
||||||
|
# docker-tailscale-route.timer unit rather than a network event: on
|
||||||
|
# Linux, tailscale0 is a tun device created directly by tailscaled, not
|
||||||
|
# through a NetworkManager connection profile, so NetworkManager never
|
||||||
|
# fires dispatcher events for it going up/down.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Ensure Docker bridge traffic bypasses Tailscale's exit-node route
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/usr/local/sbin/docker-tailscale-route.sh
|
||||||
57
docker-tailscale-route.sh
Executable file
57
docker-tailscale-route.sh
Executable file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Written by an LLM (Claude Code) — review before trusting on a machine
|
||||||
|
# other than the one this was diagnosed on. See README.md.
|
||||||
|
#
|
||||||
|
# Ensures Docker's own bridge traffic bypasses Tailscale's exit-node
|
||||||
|
# catch-all route.
|
||||||
|
#
|
||||||
|
# Background: a machine running Tailscale with an exit node and "route
|
||||||
|
# all traffic" enabled installs a catch-all `ip rule` that captures
|
||||||
|
# every packet not explicitly exempted and sends it out via the
|
||||||
|
# tailscale0 interface. Tailscale exempts its own daemon traffic from
|
||||||
|
# that capture with a firewall mark, but it has no way to exempt
|
||||||
|
# traffic that only passes THROUGH the host (forwarded) rather than
|
||||||
|
# originating from it.
|
||||||
|
#
|
||||||
|
# Docker containers' outbound traffic (dev environments, package
|
||||||
|
# managers, anything containers do that talks to the internet) is
|
||||||
|
# exactly that: forwarded traffic, not locally-originated. With no
|
||||||
|
# exemption, it gets swept into Tailscale's catch-all route, sent out
|
||||||
|
# via tailscale0, and dropped by Tailscale's own anti-spoofing firewall
|
||||||
|
# rule because it isn't a recognized Tailscale-tunneled flow. Symptom:
|
||||||
|
# every DNS lookup / outbound HTTP request from inside a container
|
||||||
|
# hangs until timeout whenever Tailscale is connected.
|
||||||
|
#
|
||||||
|
# The fix: keep a higher-priority `ip rule` in place that routes
|
||||||
|
# Docker's private bridge subnet through the normal routing table (the
|
||||||
|
# real gateway/NIC) instead of Tailscale's catch-all. This does NOT
|
||||||
|
# weaken or bypass the VPN for anything else on the host — it only
|
||||||
|
# affects packets forwarded from Docker's own bridge networks, which
|
||||||
|
# were never meant to be tunneled through the exit node in the first
|
||||||
|
# place.
|
||||||
|
#
|
||||||
|
# Defaults assume Docker's default address-pool behavior (bridge
|
||||||
|
# networks allocated from 172.17.0.0/16 through 172.31.0.0/16, all
|
||||||
|
# within 172.16.0.0/12). If you have more than ~15 concurrent Docker
|
||||||
|
# networks on a machine, Docker falls back to other private ranges
|
||||||
|
# (192.168.0.0/20 blocks, then 10.0.0.0/8) that this default won't
|
||||||
|
# cover — override DOCKER_SUBNET in /etc/default/docker-tailscale-route
|
||||||
|
# if that applies to you. Widening the range casually isn't advisable:
|
||||||
|
# 192.168.0.0/16 and 10.0.0.0/8 are also real LAN/VPN-subnet-route
|
||||||
|
# space, and blanket-exempting them could route traffic around the
|
||||||
|
# tunnel that's actually supposed to go through it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DOCKER_SUBNET="172.16.0.0/12"
|
||||||
|
RULE_PRIORITY="5200"
|
||||||
|
TARGET_TABLE="main"
|
||||||
|
|
||||||
|
# Optional overrides, e.g. if Docker's address pool differs on this
|
||||||
|
# machine or the priority collides with something else.
|
||||||
|
[ -f /etc/default/docker-tailscale-route ] && . /etc/default/docker-tailscale-route
|
||||||
|
|
||||||
|
if ! ip rule list | grep -q "from ${DOCKER_SUBNET} lookup ${TARGET_TABLE}"; then
|
||||||
|
ip rule add from "$DOCKER_SUBNET" lookup "$TARGET_TABLE" priority "$RULE_PRIORITY"
|
||||||
|
fi
|
||||||
22
docker-tailscale-route.timer
Normal file
22
docker-tailscale-route.timer
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Written by an LLM (Claude Code) — review before trusting on a machine
|
||||||
|
# other than the one this was diagnosed on. See README.md.
|
||||||
|
#
|
||||||
|
# Runs docker-tailscale-route.service shortly after boot and then every
|
||||||
|
# 15s afterward, so the ip rule that exempts Docker's bridge traffic from
|
||||||
|
# Tailscale's exit-node catch-all route gets reinstated quickly after a
|
||||||
|
# reboot or a Tailscale reconnect (both can wipe/recreate Tailscale's own
|
||||||
|
# ip rules and leave ours missing in the process). Polling is used
|
||||||
|
# instead of a network-event hook because NetworkManager doesn't manage
|
||||||
|
# the tailscale0 interface and never fires dispatcher/udev events we
|
||||||
|
# could reliably hook into for it (confirmed by testing).
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Periodically ensure Docker bridge traffic bypasses Tailscale's exit-node route
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5s
|
||||||
|
OnUnitActiveSec=15s
|
||||||
|
Unit=docker-tailscale-route.service
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
63
install.sh
Executable file
63
install.sh
Executable file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Written by an LLM (Claude Code) — review before trusting on a machine
|
||||||
|
# other than the one this was diagnosed on. See README.md.
|
||||||
|
#
|
||||||
|
# Installs the docker-tailscale-route fix: a systemd timer that keeps an
|
||||||
|
# ip rule in place routing Docker's bridge traffic around Tailscale's
|
||||||
|
# exit-node catch-all route. See docker-tailscale-route.sh for the full
|
||||||
|
# explanation of the underlying problem.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# sudo ./install.sh [--subnet CIDR] [--priority N] [--table NAME]
|
||||||
|
#
|
||||||
|
# The optional flags only need to be passed if Docker's default address
|
||||||
|
# pool doesn't apply on this machine (see the comment in
|
||||||
|
# docker-tailscale-route.sh); they're written to
|
||||||
|
# /etc/default/docker-tailscale-route and sourced by the script at
|
||||||
|
# runtime.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Must be run as root (sudo ./install.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
|
||||||
|
SUBNET=""
|
||||||
|
PRIORITY=""
|
||||||
|
TABLE=""
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--subnet) SUBNET="$2"; shift 2 ;;
|
||||||
|
--priority) PRIORITY="$2"; shift 2 ;;
|
||||||
|
--table) TABLE="$2"; shift 2 ;;
|
||||||
|
*) echo "Unknown argument: $1" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
install -o root -g root -m 0755 "$SCRIPT_DIR/docker-tailscale-route.sh" /usr/local/sbin/docker-tailscale-route.sh
|
||||||
|
install -o root -g root -m 0644 "$SCRIPT_DIR/docker-tailscale-route.service" /etc/systemd/system/docker-tailscale-route.service
|
||||||
|
install -o root -g root -m 0644 "$SCRIPT_DIR/docker-tailscale-route.timer" /etc/systemd/system/docker-tailscale-route.timer
|
||||||
|
|
||||||
|
if [ -n "$SUBNET" ] || [ -n "$PRIORITY" ] || [ -n "$TABLE" ]; then
|
||||||
|
{
|
||||||
|
[ -n "$SUBNET" ] && echo "DOCKER_SUBNET=\"$SUBNET\""
|
||||||
|
[ -n "$PRIORITY" ] && echo "RULE_PRIORITY=\"$PRIORITY\""
|
||||||
|
[ -n "$TABLE" ] && echo "TARGET_TABLE=\"$TABLE\""
|
||||||
|
} > /etc/default/docker-tailscale-route
|
||||||
|
chown root:root /etc/default/docker-tailscale-route
|
||||||
|
chmod 0644 /etc/default/docker-tailscale-route
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable --now docker-tailscale-route.timer
|
||||||
|
|
||||||
|
echo "Installed. Current status:"
|
||||||
|
systemctl status docker-tailscale-route.timer --no-pager
|
||||||
|
echo
|
||||||
|
echo "ip rule list:"
|
||||||
|
ip rule list
|
||||||
33
uninstall.sh
Executable file
33
uninstall.sh
Executable file
@@ -0,0 +1,33 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
#
|
||||||
|
# Written by an LLM (Claude Code) — review before trusting on a machine
|
||||||
|
# other than the one this was diagnosed on. See README.md.
|
||||||
|
#
|
||||||
|
# Removes everything install.sh set up: the timer, service, script,
|
||||||
|
# optional config override, and the ip rule itself.
|
||||||
|
#
|
||||||
|
# Usage: sudo ./uninstall.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
echo "Must be run as root (sudo ./uninstall.sh)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl disable --now docker-tailscale-route.timer 2>/dev/null || true
|
||||||
|
|
||||||
|
rm -f /etc/systemd/system/docker-tailscale-route.timer
|
||||||
|
rm -f /etc/systemd/system/docker-tailscale-route.service
|
||||||
|
rm -f /usr/local/sbin/docker-tailscale-route.sh
|
||||||
|
rm -f /etc/default/docker-tailscale-route
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
|
||||||
|
DOCKER_SUBNET="172.16.0.0/12"
|
||||||
|
TARGET_TABLE="main"
|
||||||
|
if ip rule list | grep -q "from ${DOCKER_SUBNET} lookup ${TARGET_TABLE}"; then
|
||||||
|
ip rule del from "$DOCKER_SUBNET" lookup "$TARGET_TABLE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Uninstalled."
|
||||||
Reference in New Issue
Block a user