Skip to content

Direct IP CIDRs go to route.rules but never to tun.route_exclude_address, breaking Tailscale #1804

Description

@rokokol

Operating system

Linux

System version

NixOS 26.11.20260816.e5bdc4a, kernel 6.18

Installation type

package manager

Version

1.2.2

Description

Throne generates the TUN inbound with a route_exclude_address that holds a fixed set of private ranges and nothing else:

"route_exclude_address": ["127.0.0.0/8","10.0.0.0/8","172.16.0.0/12",
                          "192.168.0.0/16","169.254.0.0/16","224.0.0.0/4","255.255.255.255/32"]

IP CIDRs entered in the routing profile's Direct column do not go there. They are emitted as a routing rule instead:

{"action":"route","ip_cidr":["100.64.0.0/10"],"outbound":"direct"}

For ordinary destinations the two are equivalent. For another TUN interface on the same host they are not, and Tailscale is the case where it breaks:

  1. the packet is captured by throne-tun before routing runs
  2. the rule sends it to the direct outbound, so sing-box opens its own socket and the reply comes back fine
  3. sing-box then writes that reply back into throne-tun
  4. the kernel now sees a packet with a 100.64.0.0/10 source address on an interface that is not tailscale0, and Tailscale's own anti-spoofing rule drops it:
-A ts-input -s 100.64.0.0/10 ! -i tailscale0 -j DROP

The result is that every TCP connection to a tailnet peer fails while Throne is connected, no matter what the user puts in Direct. route_exclude_address is the only thing that helps, because it keeps the traffic out of the TUN in the first place — this is the documented way to run sing-box alongside Tailscale, and mihomo exposes the same option as route-exclude-address.

Observed with strict_route: false and auto_redirect: true, so neither of those is involved.

Reproduction

  1. Install Tailscale and join a tailnet; note a peer address, e.g. 100.108.115.80
  2. In the routing profile, add ip:100.64.0.0/10 to Direct
  3. Connect any profile in TUN mode
  4. curl -v http://100.108.115.80:22 — TCP connect completes in a few ms (the local proxy accepts it) and then stalls until it is reset. tailscale ping 100.108.115.80 still works, because the daemon's own packets carry fwmark 0x80000 and bypass the tunnel
  5. Inspect the generated config: the rule from step 2 is present in route.rules, and inbounds[].route_exclude_address still holds only the private ranges

There is also no other place to express this. The TUN settings dialog offers Stack, MTU, Tun Enable IPv6, Strict Route, Enable Tun Routing, Auto Redirect, the TUN addresses, and a Disable Private Range Bypass checkbox — that last one only switches the built-in list off, it cannot take an address. The Route tab lists routing profiles and nothing else, and the settings file carries no key for TUN address exclusions either.

Expected

IP CIDRs from Direct are appended to the TUN's route_exclude_address as well as to route.rules — or a dedicated field for TUN exclusions is exposed.

Related

Logs

Nothing is logged for this. The core starts normally and the connection is accepted locally, so the failure is silent — it only shows as a stalled TCP session that is eventually reset. The evidence is in the generated configuration quoted above.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions