diff --git a/net/netutil/ip_forward.go b/net/netutil/ip_forward.go index afcea4e5a..691743d80 100644 --- a/net/netutil/ip_forward.go +++ b/net/netutil/ip_forward.go @@ -9,6 +9,7 @@ import ( "fmt" "net/netip" "os" + "os/exec" "path/filepath" "runtime" "strconv" @@ -140,6 +141,76 @@ func CheckIPForwarding(routes []netip.Prefix, state *interfaces.State) (warn, er return nil, nil } +// CheckReversePathFiltering reports whether reverse path filtering is either +// disabled or set to 'loose' mode for exit node functionality on any +// interface. +// +// The state param can be nil, in which case interfaces.GetState is used. +// +// The routes should only be advertised routes, and should not contain the +// node's Tailscale IPs. +// +// This function returns an error if it is unable to determine whether reverse +// path filtering is enabled, or a warning describing configuration issues if +// reverse path fitering is non-functional or partly functional. +func CheckReversePathFiltering(routes []netip.Prefix, state *interfaces.State) (warn []string, err error) { + if runtime.GOOS != "linux" { + return nil, nil + } + + if state == nil { + var err error + state, err = interfaces.GetState() + if err != nil { + return nil, err + } + } + + // Reverse path filtering as a syscall is only implemented on Linux for IPv4. + wantV4, _ := protocolsRequiredForForwarding(routes, state) + if !wantV4 { + return nil, nil + } + + // The kernel uses the maximum value for rp_filter between the 'all' + // setting and each per-interface config, so we need to fetch both. + allSetting, err := reversePathFilterValueLinux("all") + if err != nil { + return nil, fmt.Errorf("reading global rp_filter value: %w", err) + } + + const ( + filtOff = 0 + filtStrict = 1 + filtLoose = 2 + ) + + // Because the kernel use the max rp_filter value, each interface will use 'loose', so we + // can abort early. + if allSetting == filtLoose { + return nil, nil + } + + for _, iface := range state.Interface { + if iface.IsLoopback() { + continue + } + + iSetting, err := reversePathFilterValueLinux(iface.Name) + if err != nil { + return nil, fmt.Errorf("reading interface rp_filter value for %q: %w", iface.Name, err) + } + // Perform the same max() that the kernel does + if allSetting > iSetting { + iSetting = allSetting + } + if iSetting == filtStrict { + warn = append(warn, fmt.Sprintf("Interface %q has strict reverse-path filtering enabled", iface.Name)) + } + } + return warn, nil +} + // ipForwardSysctlKey returns the sysctl key for the given protocol and iface. // When the dotFormat parameter is true the output is formatted as `net.ipv4.ip_forward`, // else it is `net/ipv4/ip_forward` @@ -171,6 +242,25 @@ func ipForwardSysctlKey(format sysctlFormat, p protocol, iface string) string { return fmt.Sprintf(k, iface) } +// rpFilterSysctlKey returns the sysctl key for the given iface. +// +// Format controls whether the output is formatted as +// `net.ipv4.conf.iface.rp_filter` or `net/ipv4/conf/iface/rp_filter`. +func rpFilterSysctlKey(format sysctlFormat, iface string) string { + // No iface means all interfaces + if iface == "" { + iface = "all" + } + + k := "net/ipv4/conf/%s/rp_filter" + if format == dotFormat { + // Swap the delimiters. + iface = strings.ReplaceAll(iface, ".", "/") + k = strings.ReplaceAll(k, "/", ".") + } + return fmt.Sprintf(k, iface) +} + type sysctlFormat int const ( @@ -221,3 +311,29 @@ func ipForwardingEnabledLinux(p protocol, iface string) (bool, error) { on := val == 1 || val == 2 return on, nil } + +// reversePathFilterValueLinux reports the reverse path filter setting on Linux +// for the given interface. +// +// The iface param determines which interface to check against; the empty +// string means to check the global config. +// +// This function tries to look up the value directly from `/proc/sys`, and +// falls back to using the `sysctl` command on failure. +func reversePathFilterValueLinux(iface string) (int, error) { + k := rpFilterSysctlKey(slashFormat, iface) + bs, err := os.ReadFile(filepath.Join("/proc/sys", k)) + if err != nil { + // Fall back to the sysctl command + k := rpFilterSysctlKey(dotFormat, iface) + bs, err = exec.Command("sysctl", "-n", k).Output() + if err != nil { + return -1, fmt.Errorf("couldn't check %s (%v)", k, err) + } + } + v, err := strconv.Atoi(string(bytes.TrimSpace(bs))) + if err != nil { + return -1, fmt.Errorf("couldn't parse %s (%v)", k, err) + } + return v, nil +} diff --git a/net/netutil/netutil_test.go b/net/netutil/netutil_test.go index 61db318a2..6c97f610a 100644 --- a/net/netutil/netutil_test.go +++ b/net/netutil/netutil_test.go @@ -6,6 +6,7 @@ package netutil import ( "io" "net" + "net/netip" "runtime" "testing" ) @@ -65,3 +66,14 @@ func TestIPForwardingEnabledLinux(t *testing.T) { t.Errorf("got true; want false") } } + +func TestCheckReversePathFiltering(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skipf("skipping on %s", runtime.GOOS) + } + warn, err := CheckReversePathFiltering([]netip.Prefix{ + netip.MustParsePrefix("192.168.1.1/24"), + }, nil) + t.Logf("err: %v", err) + t.Logf("warnings: %v", warn) +}