diff --git a/version/distro/distro.go b/version/distro/distro.go index ce61137cf..8128ce395 100644 --- a/version/distro/distro.go +++ b/version/distro/distro.go @@ -9,6 +9,7 @@ "os" "runtime" "strconv" + "strings" "tailscale.com/types/lazy" "tailscale.com/util/lineiter" @@ -30,6 +31,7 @@ WDMyCloud = Distro("wdmycloud") Unraid = Distro("unraid") Alpine = Distro("alpine") + UDMPro = Distro("udmpro") ) var distro lazy.SyncValue[Distro] @@ -75,6 +77,9 @@ func linuxDistro() Distro { case have("/usr/local/bin/freenas-debug"): // TrueNAS Scale runs on debian return TrueNAS + case isUDMPro(): + // UDM-Pro runs on debian + return UDMPro case have("/etc/debian_version"): return Debian case have("/etc/arch-release"): @@ -147,3 +152,44 @@ func DSMVersion() int { return 0 }) } + +// isUDMPro checks a couple of files known to exist on a UDM-Pro and returns +// true if the expected content exists in the files. +func isUDMPro() bool { + // This is a performance guardrail against trying to load both + // /etc/board.info and /sys/firmware/devicetree/base/soc/board-cfg/id when + // not running on Debian so we don't make unnecessary calls in situations + // where we definitely are NOT on a UDM Pro. In other words, the have() call + // is much cheaper than the two os.ReadFile() in fileContainsAnyString(). + // That said, on Debian systems we will still be making the two + // os.ReadFile() in fileContainsAnyString(). + if !have("/etc/debian_version") { + return false + } + if exists, err := fileContainsAnyString("/etc/board.info", "UDMPRO", "Dream Machine PRO"); err == nil && exists { + return true + } + if exists, err := fileContainsAnyString("/sys/firmware/devicetree/base/soc/board-cfg/id", "udm pro"); err == nil && exists { + return true + } + return false +} + +// fileContainsAnyString is used to determine if one or more of the provided +// strings exists in a file. This is not efficient for larger files. If you want +// to use this function to parse large files, please refactor to use +// `io.LimitedReader`. +func fileContainsAnyString(filePath string, searchStrings ...string) (bool, error) { + data, err := os.ReadFile(filePath) + if err != nil { + return false, err + } + + content := string(data) + for _, searchString := range searchStrings { + if strings.Contains(content, searchString) { + return true, nil + } + } + return false, nil +} diff --git a/wgengine/router/router_linux.go b/wgengine/router/router_linux.go index 2af73e26d..e154a30fa 100644 --- a/wgengine/router/router_linux.go +++ b/wgengine/router/router_linux.go @@ -32,6 +32,8 @@ "tailscale.com/version/distro" ) +var getDistroFunc = distro.Get + const ( netfilterOff = preftype.NetfilterOff netfilterNoDivert = preftype.NetfilterNoDivert @@ -222,7 +224,7 @@ func busyboxParseVersion(output string) (major, minor, patch int, err error) { } func useAmbientCaps() bool { - if distro.Get() != distro.Synology { + if getDistroFunc() != distro.Synology { return false } return distro.DSMVersion() >= 7 @@ -438,7 +440,7 @@ func (r *linuxRouter) Set(cfg *Config) error { // Issue 11405: enable IP forwarding on gokrazy. advertisingRoutes := len(cfg.SubnetRoutes) > 0 - if distro.Get() == distro.Gokrazy && advertisingRoutes { + if getDistroFunc() == distro.Gokrazy && advertisingRoutes { r.enableIPForwarding() } @@ -1181,7 +1183,9 @@ func mustRouteTable(num int) RouteTable { tailscaleRouteTable = newRouteTable("tailscale", 52) ) -// ipRules are the policy routing rules that Tailscale uses. +// baseIPRules are the policy routing rules that Tailscale uses, when not +// running on a UDM-Pro. +// // The priority is the value represented here added to r.ipPolicyPrefBase, // which is usually 5200. // @@ -1196,7 +1200,7 @@ func mustRouteTable(num int) RouteTable { // and 'ip rule' implementations (including busybox), don't support // checking for the lack of a fwmark, only the presence. The technique // below works even on very old kernels. -var ipRules = []netlink.Rule{ +var baseIPRules = []netlink.Rule{ // Packets from us, tagged with our fwmark, first try the kernel's // main routing table. { @@ -1232,6 +1236,34 @@ func mustRouteTable(num int) RouteTable { // usual rules (pref 32766 and 32767, ie. main and default). } +// udmProIPRules are the policy routing rules that Tailscale uses, when running +// on a UDM-Pro. +// +// The priority is the value represented here added to +// r.ipPolicyPrefBase, which is usually 5200. +// +// This represents an experiment that will be used to gather more information. +// If this goes well, Tailscale may opt to use this for all of Linux. +var udmProIPRules = []netlink.Rule{ + // non-fwmark packets fall through to the usual rules (pref 32766 and 32767, + // ie. main and default). + { + Priority: 70, + Invert: true, + Mark: linuxfw.TailscaleBypassMarkNum, + Table: tailscaleRouteTable.Num, + }, +} + +// ipRules returns the appropriate list of ip rules to be used by Tailscale. See +// comments on baseIPRules and udmProIPRules for more details. +func ipRules() []netlink.Rule { + if getDistroFunc() == distro.UDMPro { + return udmProIPRules + } + return baseIPRules +} + // justAddIPRules adds policy routing rule without deleting any first. func (r *linuxRouter) justAddIPRules() error { if !r.ipRuleAvailable { @@ -1243,7 +1275,7 @@ func (r *linuxRouter) justAddIPRules() error { var errAcc error for _, family := range r.addrFamilies() { - for _, ru := range ipRules { + for _, ru := range ipRules() { // Note: r is a value type here; safe to mutate it. ru.Family = family.netlinkInt() if ru.Mark != 0 { @@ -1272,7 +1304,7 @@ func (r *linuxRouter) addIPRulesWithIPCommand() error { rg := newRunGroup(nil, r.cmd) for _, family := range r.addrFamilies() { - for _, rule := range ipRules { + for _, rule := range ipRules() { args := []string{ "ip", family.dashArg(), "rule", "add", @@ -1320,7 +1352,7 @@ func (r *linuxRouter) delIPRules() error { } var errAcc error for _, family := range r.addrFamilies() { - for _, ru := range ipRules { + for _, ru := range ipRules() { // Note: r is a value type here; safe to mutate it. // When deleting rules, we want to be a bit specific (mention which // table we were routing to) but not *too* specific (fwmarks, etc). @@ -1363,7 +1395,7 @@ func (r *linuxRouter) delIPRulesWithIPCommand() error { // That leaves us some flexibility to change these values in later // versions without having ongoing hacks for every possible // combination. - for _, rule := range ipRules { + for _, rule := range ipRules() { args := []string{ "ip", family.dashArg(), "rule", "del", @@ -1500,7 +1532,7 @@ func normalizeCIDR(cidr netip.Prefix) string { // platformCanNetfilter reports whether the current distro/environment supports // running iptables/nftables commands. func platformCanNetfilter() bool { - switch distro.Get() { + switch getDistroFunc() { case distro.Synology: // Synology doesn't support iptables or nftables. Attempting to run it // just blocks for a long time while it logs about failures. @@ -1526,7 +1558,7 @@ func cleanUp(logf logger.Logf, interfaceName string) { // of the config file being present as well as a policy rule with a specific // priority (2000 + 1 - first interface mwan3 manages) and non-zero mark. func checkOpenWRTUsingMWAN3() (bool, error) { - if distro.Get() != distro.OpenWrt { + if getDistroFunc() != distro.OpenWrt { return false, nil } diff --git a/wgengine/router/router_linux_test.go b/wgengine/router/router_linux_test.go index dce69550d..7718f17c4 100644 --- a/wgengine/router/router_linux_test.go +++ b/wgengine/router/router_linux_test.go @@ -28,6 +28,7 @@ "tailscale.com/tstest" "tailscale.com/types/logger" "tailscale.com/util/linuxfw" + "tailscale.com/version/distro" ) func TestRouterStates(t *testing.T) { @@ -1231,3 +1232,24 @@ func adjustFwmask(t *testing.T, s string) string { return fwmaskAdjustRe.ReplaceAllString(s, "$1") } + +func TestIPRulesForUDMPro(t *testing.T) { + // Override the global getDistroFunc + getDistroFunc = func() distro.Distro { + return distro.UDMPro + } + defer func() { getDistroFunc = distro.Get }() // Restore original after the test + + expected := udmProIPRules + actual := ipRules() + + if len(expected) != len(actual) { + t.Fatalf("Expected %d rules, got %d", len(expected), len(actual)) + } + + for i, rule := range expected { + if rule != actual[i] { + t.Errorf("Rule mismatch at index %d: expected %+v, got %+v", i, rule, actual[i]) + } + } +}