From 043a1a4e0be1aa9527490f49924e4b1bbca1f90e Mon Sep 17 00:00:00 2001 From: wmd-ntex Date: Thu, 8 Jan 2026 15:35:17 -0600 Subject: [PATCH] windows: avoid subnet-route override of on-link routes Set higher route metrics for advertised subnet routes so Windows prefers local on-link routes when present (tailscale/tailscale#12248). --- wgengine/router/osrouter/ifconfig_windows.go | 10 +++- wgengine/router/osrouter/metric_windows.go | 45 ++++++++++++++ .../router/osrouter/metric_windows_test.go | 58 +++++++++++++++++++ 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 wgengine/router/osrouter/metric_windows.go create mode 100644 wgengine/router/osrouter/metric_windows_test.go diff --git a/wgengine/router/osrouter/ifconfig_windows.go b/wgengine/router/osrouter/ifconfig_windows.go index cb87ad5f2..a2f03b2cb 100644 --- a/wgengine/router/osrouter/ifconfig_windows.go +++ b/wgengine/router/osrouter/ifconfig_windows.go @@ -405,7 +405,15 @@ func configureInterface(cfg *router.Config, tun *tun.NativeTun, ht *health.Track RouteData: winipcfg.RouteData{ Destination: route, NextHop: gateway, - Metric: 0, + // Explicit route metrics are important on Windows: if we leave the + // metric at 0 ("automatic"), Windows will often assign a very low + // effective metric to routes on the Tailscale interface (commonly 5), + // which can cause a host that is physically on a LAN to prefer the + // subnet-router path for that same LAN. When the subnet router is + // unavailable, this can blackhole local traffic. + // + // See https://github.com/tailscale/tailscale/issues/12248. + Metric: windowsRouteMetric(route), }, } diff --git a/wgengine/router/osrouter/metric_windows.go b/wgengine/router/osrouter/metric_windows.go new file mode 100644 index 000000000..90a905a4b --- /dev/null +++ b/wgengine/router/osrouter/metric_windows.go @@ -0,0 +1,45 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package osrouter + +import ( + "net/netip" + + "tailscale.com/net/tsaddr" +) + +// windowsSubnetRouteMetric is an intentionally "high" route metric used for +// non-Tailscale routes (e.g. advertised subnet routes) on Windows. +// +// Rationale: when a Windows client is physically on a LAN that's also +// advertised via a subnet router, Windows may otherwise prefer the Tailscale +// route due to a low Tailscale interface metric, resulting in local traffic +// being sent to (and dependent on) the subnet router. +// +// See https://github.com/tailscale/tailscale/issues/12248. +const windowsSubnetRouteMetric = uint32(5000) + +// windowsRouteMetric returns the route metric to use when installing routes on +// Windows. +// +// For exit-node default routes and single-host Tailscale routes, we keep the +// metric low so the routes behave as expected. For advertised subnet routes, we +// set a higher metric so on-link / locally-connected routes win when present. +func windowsRouteMetric(route netip.Prefix) uint32 { + if !route.IsValid() { + return 0 + } + // Default route (exit node) should stay preferred when configured. + if route.Bits() == 0 { + return 0 + } + // Single-host Tailscale routes should stay preferred. + if route.IsSingleIP() && tsaddr.IsTailscaleIP(route.Addr().Unmap()) { + return 0 + } + // Everything else (notably: advertised subnet routes) should not override + // on-link routes with the same prefix. + return windowsSubnetRouteMetric +} + diff --git a/wgengine/router/osrouter/metric_windows_test.go b/wgengine/router/osrouter/metric_windows_test.go new file mode 100644 index 000000000..6b3709221 --- /dev/null +++ b/wgengine/router/osrouter/metric_windows_test.go @@ -0,0 +1,58 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package osrouter + +import ( + "net/netip" + "testing" + + "tailscale.com/net/tsaddr" +) + +func TestWindowsRouteMetric(t *testing.T) { + tests := []struct { + name string + route netip.Prefix + want uint32 + }{ + { + name: "default_route_v4", + route: netip.MustParsePrefix("0.0.0.0/0"), + want: 0, + }, + { + name: "default_route_v6", + route: netip.MustParsePrefix("::/0"), + want: 0, + }, + { + name: "tailscale_service_ip_v4_single_host", + route: netip.PrefixFrom(tsaddr.TailscaleServiceIP(), 32), + want: 0, + }, + { + name: "tailscale_service_ip_v6_single_host", + route: netip.PrefixFrom(tsaddr.TailscaleServiceIPv6(), 128), + want: 0, + }, + { + name: "advertised_subnet_v4", + route: netip.MustParsePrefix("192.168.1.0/24"), + want: windowsSubnetRouteMetric, + }, + { + name: "advertised_subnet_v6", + route: netip.MustParsePrefix("fd00::/64"), + want: windowsSubnetRouteMetric, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := windowsRouteMetric(tt.route); got != tt.want { + t.Fatalf("windowsRouteMetric(%v)=%v; want %v", tt.route, got, tt.want) + } + }) + } +} +