diff --git a/redirect_iptables.go b/redirect_iptables.go index ad614ce1..f190a0e4 100644 --- a/redirect_iptables.go +++ b/redirect_iptables.go @@ -96,6 +96,13 @@ func (r *autoRedirect) setupIPTablesForFamily(iptablesPath string) error { if err != nil { return err } + if r.tunOptions.AutoRoute { + err = r.runShell(iptablesPath, "-t nat -A", tableNamePreRouteing, + "-m conntrack --ctstate DNAT -j RETURN") + if err != nil { + return err + } + } var ( routeAddress []netip.Prefix routeExcludeAddress []netip.Prefix @@ -136,6 +143,12 @@ func (r *autoRedirect) setupIPTablesForFamily(iptablesPath string) error { return err } } + if r.tunOptions.AutoRoute { + err = r.runShell(iptablesPath, "-t nat -A", tableNamePreRouteing, "-m addrtype --dst-type LOCAL -j RETURN") + if err != nil { + return err + } + } if !r.tunOptions.EXP_DisableDNSHijack { dnsServer := common.Find(r.tunOptions.DNSServers, func(it netip.Addr) bool { return it.Is4() == (iptablesPath == r.iptablesPath) @@ -187,9 +200,11 @@ func (r *autoRedirect) setupIPTablesForFamily(iptablesPath string) error { } } - err = r.runShell(iptablesPath, "-t nat -A", tableNamePreRouteing, "-m addrtype --dst-type LOCAL -j RETURN") - if err != nil { - return err + if !r.tunOptions.AutoRoute { + err = r.runShell(iptablesPath, "-t nat -A", tableNamePreRouteing, "-m addrtype --dst-type LOCAL -j RETURN") + if err != nil { + return err + } } if len(routeAddress) > 0 { @@ -224,7 +239,11 @@ func (r *autoRedirect) setupIPTablesForFamily(iptablesPath string) error { return err } } - err = r.runShell(iptablesPath, "-t nat -I PREROUTING -j", tableNamePreRouteing) + preRoutingOperation := "-I" + if r.tunOptions.AutoRoute { + preRoutingOperation = "-A" + } + err = r.runShell(iptablesPath, "-t nat", preRoutingOperation, "PREROUTING -j", tableNamePreRouteing) if err != nil { return err } diff --git a/redirect_iptables_test.go b/redirect_iptables_test.go new file mode 100644 index 00000000..b7b11349 --- /dev/null +++ b/redirect_iptables_test.go @@ -0,0 +1,70 @@ +//go:build linux + +package tun + +import ( + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAutoRedirectIPTablesLocalDestinationOrder(t *testing.T) { + for name, testCase := range map[string]struct { + autoRoute bool + localBeforeDNSHijack bool + }{ + "auto route": {autoRoute: true, localBeforeDNSHijack: true}, + "without auto route": {autoRoute: false, localBeforeDNSHijack: false}, + } { + t.Run(name, func(t *testing.T) { + tempDir := t.TempDir() + commandLogPath := filepath.Join(tempDir, "iptables.log") + iptablesPath := filepath.Join(tempDir, "iptables") + require.NoError(t, os.WriteFile( + iptablesPath, + []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$IPTABLES_LOG\"\n"), + 0o700, + )) + t.Setenv("IPTABLES_LOG", commandLogPath) + + redirect := &autoRedirect{ + tunOptions: &Options{ + Name: "tun0", + AutoRoute: testCase.autoRoute, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + DNSServers: []netip.Addr{netip.MustParseAddr("198.18.0.2")}, + }, + tableName: "test", + enableIPv4: true, + iptablesPath: iptablesPath, + customRedirectPort: 12345, + } + + require.NoError(t, redirect.setupIPTablesForFamily(iptablesPath)) + commandLog, err := os.ReadFile(commandLogPath) + require.NoError(t, err) + + localReturnIndex := -1 + dnsHijackIndex := -1 + for index, command := range strings.Split(strings.TrimSpace(string(commandLog)), "\n") { + if strings.Contains(command, "test-prerouting -m addrtype --dst-type LOCAL -j RETURN") { + localReturnIndex = index + } + if strings.Contains(command, "test-prerouting -p udp --dport 53 -j DNAT --to") { + dnsHijackIndex = index + } + } + require.NotEqual(t, -1, localReturnIndex) + require.NotEqual(t, -1, dnsHijackIndex) + if testCase.localBeforeDNSHijack { + require.Less(t, localReturnIndex, dnsHijackIndex) + } else { + require.Greater(t, localReturnIndex, dnsHijackIndex) + } + }) + } +} diff --git a/redirect_nftables_rules.go b/redirect_nftables_rules.go index 1f40067e..442bcdf3 100644 --- a/redirect_nftables_rules.go +++ b/redirect_nftables_rules.go @@ -133,6 +133,15 @@ func (r *autoRedirect) nftablesCreateLoopbackAddressSets( } func (r *autoRedirect) nftablesCreateExcludeRules(nft *nftables.Conn, table *nftables.Table, chain *nftables.Chain) error { + if r.tunOptions.AutoRoute && chain.Hooknum == nftables.ChainHookPrerouting { + expressions := nftablesDNATStatusExpressions() + expressions = append(expressions, &expr.Counter{}, &expr.Verdict{Kind: expr.VerdictReturn}) + nft.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: expressions, + }) + } if r.tunOptions.AutoRedirectMarkMode && chain.Hooknum == nftables.ChainHookOutput { if chain.Type == nftables.ChainTypeRoute { ipProto := &nftables.Set{ diff --git a/route_dnat_linux.go b/route_dnat_linux.go new file mode 100644 index 00000000..09fdfa66 --- /dev/null +++ b/route_dnat_linux.go @@ -0,0 +1,367 @@ +//go:build linux + +package tun + +import ( + "bytes" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + + "github.com/metacubex/nftables" + "github.com/metacubex/nftables/binaryutil" + "github.com/metacubex/nftables/expr" + E "github.com/metacubex/sing/common/exceptions" + "github.com/metacubex/sing/common/logger" +) + +const ( + conntrackStatusDNAT = 1 << 5 + // Reserve the low 12 bits for the bypass mark and preserve marks owned by other network components. + autoRouteDNATBypassMarkFieldMask = 0xFFF +) + +const ( + ipv4ConfPath = "/proc/sys/net/ipv4/conf" + srcValidMarkPath = ipv4ConfPath + "/all/src_valid_mark" +) + +type autoRouteDNATBypass struct { + options *Options + tableName string + chainName string + useNFTables bool + iptablesPath string + ip6tablesPath string +} + +func (t *NativeTun) enableAutoRouteDNATBypass( + prepare func(*Options) (*autoRouteDNATBypass, error), +) { + bypass, err := prepare(&t.options) + if err != nil { + if t.options.Logger != nil { + t.options.Logger.Warn(E.Cause( + err, + "auto-route DNAT bypass unavailable; continuing without Docker DNAT protection", + )) + } + return + } + t.dnatBypass = bypass +} + +func prepareAutoRouteDNATBypass(options *Options) (*autoRouteDNATBypass, error) { + bypass, err := newAutoRouteDNATBypass(options) + if err != nil { + return nil, E.Cause(err, "initialize auto-route DNAT bypass") + } + err = bypass.Start() + if err != nil { + return nil, E.Cause(err, "start auto-route DNAT bypass") + } + return bypass, nil +} + +func newAutoRouteDNATBypass(options *Options) (*autoRouteDNATBypass, error) { + bypass := &autoRouteDNATBypass{ + options: options, + tableName: "sing-tun-route-" + strconv.Itoa(options.IPRoute2TableIndex), + chainName: "STUN-DNAT-" + strconv.Itoa(options.IPRoute2TableIndex), + } + var err error + nft, nftErr := nftables.New() + if nftErr == nil { + _, nftErr = nft.ListTablesOfFamily(nftables.TableFamilyIPv4) + _ = nft.CloseLasting() + } + if nftErr == nil { + bypass.useNFTables = true + return bypass, nil + } + if len(options.Inet4Address) > 0 { + bypass.iptablesPath, err = exec.LookPath("iptables") + if err != nil { + return nil, E.Cause(err, "iptables is required for auto-route DNAT bypass") + } + } + if len(options.Inet6Address) > 0 { + bypass.ip6tablesPath, err = exec.LookPath("ip6tables") + if err != nil { + return nil, E.Cause(err, "ip6tables is required for auto-route DNAT bypass") + } + } + return bypass, nil +} + +func (o *Options) autoRouteDNATBypassMark() uint32 { + outputMark := o.AutoRedirectOutputMark + if outputMark == 0 { + outputMark = DefaultAutoRedirectOutputMark + } + return outputMark & o.autoRouteDNATBypassMask() +} + +func (o *Options) autoRouteDNATBypassMask() uint32 { + outputMark := o.AutoRedirectOutputMark + if outputMark == 0 { + outputMark = DefaultAutoRedirectOutputMark + } + if outputMark&autoRouteDNATBypassMarkFieldMask == 0 { + return outputMark + } + return autoRouteDNATBypassMarkFieldMask +} + +func (b *autoRouteDNATBypass) Start() error { + var err error + if len(b.options.Inet4Address) > 0 { + err = enableSrcValidMark(ipv4ConfPath, b.options.Logger) + if err != nil { + return E.Cause(err, "enable src_valid_mark for auto-route DNAT bypass") + } + } + if b.useNFTables { + b.cleanupNFTables() + err = b.setupNFTables() + } else { + b.cleanupIPTables() + err = b.setupIPTables() + } + if err != nil { + return E.Errors(err, b.Close()) + } + return nil +} + +func (b *autoRouteDNATBypass) Close() error { + if b.useNFTables { + b.cleanupNFTables() + } else { + b.cleanupIPTables() + } + return nil +} + +func enableSrcValidMark(confPath string, log logger.Logger) error { + strictRPFilter, err := hasStrictRPFilter(confPath) + if err != nil { + return err + } + if !strictRPFilter { + return nil + } + path := filepath.Join(confPath, "all", "src_valid_mark") + value, err := os.ReadFile(path) + if err != nil { + return err + } + value = bytes.TrimSpace(value) + if bytes.Equal(value, []byte("1")) { + return nil + } + if !bytes.Equal(value, []byte("0")) { + return E.New("invalid src_valid_mark value: ", string(value)) + } + err = os.WriteFile(path, []byte("1"), 0) + if err != nil { + return err + } + if log != nil { + log.Warn("changed net.ipv4.conf.all.src_valid_mark from 0 to 1 for auto-route DNAT bypass; will remain enabled after close") + } + return nil +} + +func hasStrictRPFilter(confPath string) (bool, error) { + allValue, err := readRPFilter(filepath.Join(confPath, "all", "rp_filter")) + if err != nil { + return false, err + } + entries, err := os.ReadDir(confPath) + if err != nil { + return false, err + } + for _, entry := range entries { + if !entry.IsDir() || entry.Name() == "all" { + continue + } + value, readErr := readRPFilter(filepath.Join(confPath, entry.Name(), "rp_filter")) + if os.IsNotExist(readErr) { + continue + } + if readErr != nil { + return false, readErr + } + effectiveValue := allValue + if value > effectiveValue { + effectiveValue = value + } + if effectiveValue == 1 { + return true, nil + } + } + return false, nil +} + +func readRPFilter(path string) (int, error) { + content, err := os.ReadFile(path) + if err != nil { + return 0, err + } + value, err := strconv.Atoi(string(bytes.TrimSpace(content))) + if err != nil { + return 0, err + } + if value < 0 || value > 2 { + return 0, E.New("invalid rp_filter value: ", value) + } + return value, nil +} + +func (b *autoRouteDNATBypass) setupNFTables() error { + nft, err := nftables.New() + if err != nil { + return err + } + defer nft.CloseLasting() + table := nft.AddTable(&nftables.Table{ + Name: b.tableName, + Family: nftables.TableFamilyINet, + }) + chain := nft.AddChain(&nftables.Chain{ + Name: "prerouting", + Table: table, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityRef(*nftables.ChainPriorityNATDest + 2), + Type: nftables.ChainTypeFilter, + }) + nft.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: dnatMarkExpressions(b.options), + }) + return nft.Flush() +} + +func (b *autoRouteDNATBypass) cleanupNFTables() { + nft, err := nftables.New() + if err != nil { + return + } + nft.DelTable(&nftables.Table{ + Name: b.tableName, + Family: nftables.TableFamilyINet, + }) + _ = nft.Flush() + _ = nft.CloseLasting() +} + +func dnatMarkExpressions(options *Options) []expr.Any { + expressions := nftablesDNATStatusExpressions() + markMask := options.autoRouteDNATBypassMask() + if options.AutoRedirectMarkMode { + expressions = append(expressions, + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Cmp{ + Op: expr.CmpOpNeq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(options.AutoRedirectInputMark), + }, + ) + } + return append(expressions, + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + }, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: binaryutil.NativeEndian.PutUint32(^markMask), + Xor: binaryutil.NativeEndian.PutUint32(options.autoRouteDNATBypassMark()), + }, + &expr.Meta{ + Key: expr.MetaKeyMARK, + Register: 1, + SourceRegister: true, + }, + &expr.Counter{}, + ) +} + +func (b *autoRouteDNATBypass) setupIPTables() error { + markMask := b.options.autoRouteDNATBypassMask() + for _, path := range []string{b.iptablesPath, b.ip6tablesPath} { + if path == "" { + continue + } + if err := b.runIPTables(path, "-t", "mangle", "-N", b.chainName); err != nil { + return err + } + if err := b.runIPTables( + path, "-t", "mangle", "-A", b.chainName, + "-m", "addrtype", "--dst-type", "LOCAL", + "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x/%#x", b.options.autoRouteDNATBypassMark(), markMask), + ); err != nil { + return err + } + if err := b.runIPTables( + path, "-t", "mangle", "-A", b.chainName, + "-m", "conntrack", "--ctstate", "DNAT", "--ctdir", "REPLY", + "-j", "MARK", "--set-xmark", fmt.Sprintf("%#x/%#x", b.options.autoRouteDNATBypassMark(), markMask), + ); err != nil { + return err + } + if err := b.runIPTables(path, "-t", "mangle", "-I", "PREROUTING", "-j", b.chainName); err != nil { + return err + } + } + return nil +} + +func (b *autoRouteDNATBypass) cleanupIPTables() { + for _, path := range []string{b.iptablesPath, b.ip6tablesPath} { + if path == "" { + continue + } + _ = b.runIPTables(path, "-t", "mangle", "-D", "PREROUTING", "-j", b.chainName) + _ = b.runIPTables(path, "-t", "mangle", "-F", b.chainName) + _ = b.runIPTables(path, "-t", "mangle", "-X", b.chainName) + } +} + +func (b *autoRouteDNATBypass) runIPTables(path string, args ...string) error { + output, err := exec.Command(path, args...).CombinedOutput() + if err != nil { + return E.Extend(err, fmt.Sprintf("%s %v: %s", path, args, output)) + } + return nil +} + +func nftablesDNATStatusExpressions() []expr.Any { + return []expr.Any{ + &expr.Ct{ + Key: expr.CtKeySTATUS, + Register: 1, + }, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: binaryutil.NativeEndian.PutUint32(conntrackStatusDNAT), + Xor: make([]byte, 4), + }, + &expr.Cmp{ + Op: expr.CmpOpEq, + Register: 1, + Data: binaryutil.NativeEndian.PutUint32(conntrackStatusDNAT), + }, + } +} diff --git a/route_ipvs_linux.go b/route_ipvs_linux.go new file mode 100644 index 00000000..f5c2c7c0 --- /dev/null +++ b/route_ipvs_linux.go @@ -0,0 +1,326 @@ +//go:build linux + +package tun + +import ( + "bufio" + "encoding/hex" + "errors" + "io" + "net/netip" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + E "github.com/metacubex/sing/common/exceptions" + "github.com/sagernet/netlink" + "golang.org/x/sys/unix" +) + +const ( + ipvsTablePath = "/proc/net/ip_vs" + ipvsDNSRefreshInterval = 5 * time.Second +) + +type autoRouteIPVSDNSBypass struct { + path string + + access sync.RWMutex + destinations []ipvsDNSDestination + started bool + closeOnce sync.Once + stop chan struct{} + done chan struct{} +} + +type ipvsDNSDestination struct { + protocol int + address netip.Addr +} + +func newAutoRouteIPVSDNSBypass(path string) (*autoRouteIPVSDNSBypass, error) { + bypass := &autoRouteIPVSDNSBypass{ + path: path, + stop: make(chan struct{}), + done: make(chan struct{}), + } + destinations, err := bypass.Load() + if err != nil { + return nil, err + } + bypass.destinations = destinations + return bypass, nil +} + +func (b *autoRouteIPVSDNSBypass) Start(update func()) { + b.access.Lock() + if b.started { + b.access.Unlock() + return + } + b.started = true + b.access.Unlock() + go func() { + defer close(b.done) + ticker := time.NewTicker(ipvsDNSRefreshInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + update() + case <-b.stop: + return + } + } + }() +} + +func (b *autoRouteIPVSDNSBypass) Close() { + b.access.RLock() + started := b.started + b.access.RUnlock() + if !started { + return + } + b.closeOnce.Do(func() { close(b.stop) }) + <-b.done +} + +func (b *autoRouteIPVSDNSBypass) Load() ([]ipvsDNSDestination, error) { + destinations, err := readIPVSDNSDestinations(b.path) + if os.IsNotExist(err) { + return nil, nil + } + return destinations, err +} + +func (b *autoRouteIPVSDNSBypass) Destinations() []ipvsDNSDestination { + b.access.RLock() + defer b.access.RUnlock() + return append([]ipvsDNSDestination(nil), b.destinations...) +} + +func (b *autoRouteIPVSDNSBypass) Replace(destinations []ipvsDNSDestination) { + b.access.Lock() + b.destinations = append([]ipvsDNSDestination(nil), destinations...) + b.access.Unlock() +} + +func (t *NativeTun) enableAutoRouteIPVSDNSBypass(path string) { + bypass, err := newAutoRouteIPVSDNSBypass(path) + if err != nil { + if t.options.Logger != nil { + t.options.Logger.Warn(E.Cause(err, "auto-route IPVS DNS bypass unavailable; continuing without IPVS DNS protection")) + } + return + } + t.ipvsDNSBypass = bypass +} + +func (t *NativeTun) refreshIPVSDNSBypass() { + newDestinations, err := t.ipvsDNSBypass.Load() + if err != nil { + if t.options.Logger != nil { + t.options.Logger.Warn(E.Cause(err, "refresh auto-route IPVS DNS bypass")) + } + return + } + oldDestinations := t.ipvsDNSBypass.Destinations() + if !ipvsDNSDestinationsChanged(oldDestinations, newDestinations) { + return + } + updatedDestinations, err := t.updateIPVSDNSRules(oldDestinations, newDestinations, addIPVSDNSRule, deleteIPVSDNSRule) + t.ipvsDNSBypass.Replace(updatedDestinations) + if err != nil { + if t.options.Logger != nil { + t.options.Logger.Error(E.Cause(err, "update auto-route IPVS DNS bypass")) + } + return + } +} + +type ipvsDNSRuleOperation func(*netlink.Rule) error + +func (t *NativeTun) updateIPVSDNSRules( + oldDestinations, newDestinations []ipvsDNSDestination, + addRule, deleteRule ipvsDNSRuleOperation, +) ([]ipvsDNSDestination, error) { + currentDestinations := make(map[ipvsDNSDestination]struct{}, len(oldDestinations)) + for _, destination := range oldDestinations { + currentDestinations[destination] = struct{}{} + } + for _, destination := range ipvsDNSDestinationDifference(newDestinations, oldDestinations) { + rule := t.ipvsDNSRule(destination) + if rule != nil { + if err := addRule(rule); err != nil { + return sortedIPVSDNSDestinations(currentDestinations), E.Cause(err, "add IPVS DNS bypass rule for ", destination.address) + } + } + currentDestinations[destination] = struct{}{} + } + for _, destination := range ipvsDNSDestinationDifference(oldDestinations, newDestinations) { + rule := t.ipvsDNSRule(destination) + if rule != nil { + if err := deleteRule(rule); err != nil { + return sortedIPVSDNSDestinations(currentDestinations), E.Cause(err, "delete IPVS DNS bypass rule for ", destination.address) + } + } + delete(currentDestinations, destination) + } + return sortedIPVSDNSDestinations(currentDestinations), nil +} + +func (t *NativeTun) ipvsDNSRule(destination ipvsDNSDestination) *netlink.Rule { + rule := netlink.NewRule() + if destination.address.Is4() { + if len(t.options.Inet4Address) == 0 { + return nil + } + rule.Family = unix.AF_INET + } else { + if len(t.options.Inet6Address) == 0 { + return nil + } + rule.Family = unix.AF_INET6 + } + rule.Priority = t.options.IPRoute2RuleIndex + if t.dnatBypass != nil { + rule.Priority++ + } + rule.Dst = netip.PrefixFrom(destination.address, destination.address.BitLen()) + rule.IPProto = destination.protocol + rule.Dport = netlink.NewRulePortRange(53, 53) + rule.Goto = t.options.IPRoute2RuleIndex + 10 + return rule +} + +func addIPVSDNSRule(rule *netlink.Rule) error { + err := netlink.RuleAdd(rule) + if errors.Is(err, unix.EEXIST) { + return nil + } + return err +} + +func deleteIPVSDNSRule(rule *netlink.Rule) error { + err := netlink.RuleDel(rule) + if errors.Is(err, unix.ENOENT) || errors.Is(err, unix.ESRCH) { + return nil + } + return err +} + +func readIPVSDNSDestinations(path string) ([]ipvsDNSDestination, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + return parseIPVSDNSDestinations(file) +} + +func parseIPVSDNSDestinations(reader io.Reader) ([]ipvsDNSDestination, error) { + destinationSet := make(map[ipvsDNSDestination]struct{}) + var dnsProtocol int + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + if len(fields) == 0 { + continue + } + switch fields[0] { + case "TCP", "UDP": + dnsProtocol = 0 + if len(fields) < 2 { + continue + } + _, port, parseErr := parseIPVSAddressPort(fields[1]) + if parseErr == nil && port == 53 { + if fields[0] == "TCP" { + dnsProtocol = unix.IPPROTO_TCP + } else { + dnsProtocol = unix.IPPROTO_UDP + } + } + case "->": + if dnsProtocol == 0 || len(fields) < 2 { + continue + } + address, port, parseErr := parseIPVSAddressPort(fields[1]) + if parseErr == nil && port == 53 { + destinationSet[ipvsDNSDestination{protocol: dnsProtocol, address: address}] = struct{}{} + } + default: + dnsProtocol = 0 + } + } + if err := scanner.Err(); err != nil { + return nil, err + } + return sortedIPVSDNSDestinations(destinationSet), nil +} + +func sortedIPVSDNSDestinations(destinationSet map[ipvsDNSDestination]struct{}) []ipvsDNSDestination { + destinations := make([]ipvsDNSDestination, 0, len(destinationSet)) + for destination := range destinationSet { + destinations = append(destinations, destination) + } + sort.Slice(destinations, func(i, j int) bool { + addressCompare := destinations[i].address.Compare(destinations[j].address) + if addressCompare == 0 { + return destinations[i].protocol < destinations[j].protocol + } + return addressCompare < 0 + }) + return destinations +} + +func parseIPVSAddressPort(value string) (netip.Addr, uint16, error) { + separator := strings.LastIndexByte(value, ':') + if separator == -1 { + return netip.Addr{}, 0, strconv.ErrSyntax + } + portValue, err := strconv.ParseUint(value[separator+1:], 16, 16) + if err != nil { + return netip.Addr{}, 0, err + } + addressValue := strings.Trim(value[:separator], "[]") + if len(addressValue) == 8 && !strings.ContainsRune(addressValue, ':') { + addressBytes, decodeErr := hex.DecodeString(addressValue) + if decodeErr != nil { + return netip.Addr{}, 0, decodeErr + } + return netip.AddrFrom4([4]byte(addressBytes)), uint16(portValue), nil + } + address, err := netip.ParseAddr(addressValue) + return address, uint16(portValue), err +} + +func ipvsDNSDestinationsChanged(oldDestinations, newDestinations []ipvsDNSDestination) bool { + if len(oldDestinations) != len(newDestinations) { + return true + } + for index := range oldDestinations { + if oldDestinations[index] != newDestinations[index] { + return true + } + } + return false +} + +func ipvsDNSDestinationDifference(destinations, excluded []ipvsDNSDestination) []ipvsDNSDestination { + excludedSet := make(map[ipvsDNSDestination]struct{}, len(excluded)) + for _, destination := range excluded { + excludedSet[destination] = struct{}{} + } + difference := make([]ipvsDNSDestination, 0, len(destinations)) + for _, destination := range destinations { + if _, exists := excludedSet[destination]; !exists { + difference = append(difference, destination) + } + } + return difference +} diff --git a/route_ipvs_linux_test.go b/route_ipvs_linux_test.go new file mode 100644 index 00000000..5f6629e6 --- /dev/null +++ b/route_ipvs_linux_test.go @@ -0,0 +1,188 @@ +//go:build linux + +package tun + +import ( + "errors" + "net/netip" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sagernet/netlink" + "github.com/stretchr/testify/require" + "golang.org/x/sys/unix" +) + +func TestParseIPVSDNSDestinations(t *testing.T) { + destinations, err := parseIPVSDNSDestinations(strings.NewReader(`IP Virtual Server version 1.2.1 (size=4096) +Prot LocalAddress:Port Scheduler Flags + -> RemoteAddress:Port Forward Weight ActiveConn InActConn +TCP 0A0A000A:0035 rr + -> 0A7ADB47:0035 Masq 1 0 0 + -> 0A7ADB46:0035 Masq 1 0 0 +UDP 0A0A000A:0035 rr + -> 0A7ADB47:0035 Masq 1 0 0 +TCP 0A0A9B4E:7578 rr + -> C0A8001B:1C20 Masq 1 0 0 +TCP [fd00::a]:0035 rr + -> [fd00::46]:0035 Masq 1 0 0 +`)) + require.NoError(t, err) + require.Equal(t, []ipvsDNSDestination{ + {protocol: unix.IPPROTO_TCP, address: netip.MustParseAddr("10.122.219.70")}, + {protocol: unix.IPPROTO_TCP, address: netip.MustParseAddr("10.122.219.71")}, + {protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.71")}, + {protocol: unix.IPPROTO_TCP, address: netip.MustParseAddr("fd00::46")}, + }, destinations) +} + +func TestAutoRouteIPVSDNSBypassRule(t *testing.T) { + nativeTun := &NativeTun{ + ipvsDNSBypass: &autoRouteIPVSDNSBypass{ + destinations: []ipvsDNSDestination{ + {protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}, + }, + }, + options: Options{ + AutoRoute: true, + IPRoute2RuleIndex: DefaultIPRoute2RuleIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + Inet4RouteAddress: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + Inet4Gateway: netip.MustParseAddr("198.18.0.2"), + }, + } + + var found bool + for _, rule := range nativeTun.rules() { + if rule.Dst == netip.MustParsePrefix("10.122.219.70/32") { + found = true + require.Equal(t, unix.IPPROTO_UDP, rule.IPProto) + require.NotNil(t, rule.Dport) + require.Equal(t, uint16(53), rule.Dport.Start) + require.Equal(t, uint16(53), rule.Dport.End) + require.Equal(t, DefaultIPRoute2RuleIndex+10, rule.Goto) + } + } + require.True(t, found) +} + +func TestIPVSDNSDestinationsChanged(t *testing.T) { + oldDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}} + newDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.71")}} + require.False(t, ipvsDNSDestinationsChanged(oldDestinations, oldDestinations)) + require.True(t, ipvsDNSDestinationsChanged(oldDestinations, newDestinations)) +} + +func TestAutoRouteIPVSDNSBypassReload(t *testing.T) { + path := filepath.Join(t.TempDir(), "ip_vs") + require.NoError(t, os.WriteFile(path, []byte("UDP 0A0A000A:0035 rr\n -> 0A7ADB46:0035 Masq 1 0 0\n"), 0o600)) + bypass, err := newAutoRouteIPVSDNSBypass(path) + require.NoError(t, err) + require.Equal(t, []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}}, bypass.Destinations()) + + require.NoError(t, os.WriteFile(path, []byte("UDP 0A0A000A:0035 rr\n -> 0A7ADB47:0035 Masq 1 0 0\n"), 0o600)) + destinations, err := bypass.Load() + require.NoError(t, err) + require.True(t, ipvsDNSDestinationsChanged(bypass.Destinations(), destinations)) + bypass.Replace(destinations) + require.Equal(t, []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.71")}}, bypass.Destinations()) +} + +func TestAutoRouteIPVSDNSBypassMissingTable(t *testing.T) { + path := filepath.Join(t.TempDir(), "ip_vs") + bypass, err := newAutoRouteIPVSDNSBypass(path) + require.NoError(t, err) + require.NotNil(t, bypass) + require.Empty(t, bypass.Destinations()) + + require.NoError(t, os.WriteFile(path, []byte("UDP 0A0A000A:0035 rr\n -> 0A7ADB46:0035 Masq 1 0 0\n"), 0o600)) + destinations, err := bypass.Load() + require.NoError(t, err) + require.Equal(t, []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}}, destinations) +} + +func TestUpdateIPVSDNSRulesUsesExactDifference(t *testing.T) { + nativeTun := newIPVSDNSRuleTestTun() + oldDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}} + newDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_TCP, address: netip.MustParseAddr("10.122.219.71")}} + var operations []string + updatedDestinations, err := nativeTun.updateIPVSDNSRules(oldDestinations, newDestinations, + func(rule *netlink.Rule) error { + operations = append(operations, "add "+rule.Dst.String()+" tcp") + require.Equal(t, unix.IPPROTO_TCP, rule.IPProto) + return nil + }, + func(rule *netlink.Rule) error { + operations = append(operations, "delete "+rule.Dst.String()+" udp") + require.Equal(t, unix.IPPROTO_UDP, rule.IPProto) + return nil + }, + ) + require.NoError(t, err) + require.Equal(t, []string{"add 10.122.219.71/32 tcp", "delete 10.122.219.70/32 udp"}, operations) + require.Equal(t, newDestinations, updatedDestinations) +} + +func TestUpdateIPVSDNSRulesKeepsOldRulesWhenAddFails(t *testing.T) { + nativeTun := newIPVSDNSRuleTestTun() + oldDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.70")}} + newDestinations := []ipvsDNSDestination{{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.71")}} + deleteCalls := 0 + updatedDestinations, err := nativeTun.updateIPVSDNSRules(oldDestinations, newDestinations, + func(*netlink.Rule) error { return errors.New("add failed") }, + func(*netlink.Rule) error { + deleteCalls++ + return nil + }, + ) + require.EqualError(t, err, "add IPVS DNS bypass rule for 10.122.219.71: add failed") + require.Zero(t, deleteCalls) + require.Equal(t, oldDestinations, updatedDestinations) +} + +func TestUpdateIPVSDNSRulesTracksPartialUpdate(t *testing.T) { + nativeTun := newIPVSDNSRuleTestTun() + first := ipvsDNSDestination{protocol: unix.IPPROTO_TCP, address: netip.MustParseAddr("10.122.219.70")} + second := ipvsDNSDestination{protocol: unix.IPPROTO_UDP, address: netip.MustParseAddr("10.122.219.71")} + addCalls := 0 + updatedDestinations, err := nativeTun.updateIPVSDNSRules(nil, []ipvsDNSDestination{first, second}, + func(*netlink.Rule) error { + addCalls++ + if addCalls == 2 { + return errors.New("add failed") + } + return nil + }, + func(*netlink.Rule) error { return nil }, + ) + require.EqualError(t, err, "add IPVS DNS bypass rule for 10.122.219.71: add failed") + require.Equal(t, []ipvsDNSDestination{first}, updatedDestinations) + + deleteCalls := 0 + updatedDestinations, err = nativeTun.updateIPVSDNSRules([]ipvsDNSDestination{first, second}, nil, + func(*netlink.Rule) error { return nil }, + func(*netlink.Rule) error { + deleteCalls++ + if deleteCalls == 2 { + return errors.New("delete failed") + } + return nil + }, + ) + require.EqualError(t, err, "delete IPVS DNS bypass rule for 10.122.219.71: delete failed") + require.Equal(t, []ipvsDNSDestination{second}, updatedDestinations) +} + +func newIPVSDNSRuleTestTun() *NativeTun { + return &NativeTun{ + options: Options{ + AutoRoute: true, + IPRoute2RuleIndex: DefaultIPRoute2RuleIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + Inet4RouteAddress: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + Inet4Gateway: netip.MustParseAddr("198.18.0.2"), + }, + } +} diff --git a/tun_linux.go b/tun_linux.go index dac98e5a..5b4fa6a6 100644 --- a/tun_linux.go +++ b/tun_linux.go @@ -33,6 +33,8 @@ type NativeTun struct { tunFile *os.File interfaceCallback *list.Element[DefaultInterfaceUpdateCallback] options Options + dnatBypass *autoRouteDNATBypass + ipvsDNSBypass *autoRouteIPVSDNSBypass ruleIndex6 []int readAccess sync.Mutex writeAccess sync.Mutex @@ -387,23 +389,33 @@ func (t *NativeTun) configure(tunLink netlink.Link) error { } } + if t.options.AutoRoute && runtime.GOOS != "android" { + t.enableAutoRouteDNATBypass(prepareAutoRouteDNATBypass) + if !t.options.AutoRedirectMarkMode { + t.enableAutoRouteIPVSDNSBypass(ipvsTablePath) + } + } + err = t.setRoute(tunLink) if err != nil { _ = t.unsetRoute0(tunLink) - return err + return E.Errors(err, common.Close(common.PtrOrNil(t.dnatBypass))) } err = t.unsetRules() if err != nil { - return E.Cause(err, "cleanup rules") + return E.Errors(E.Cause(err, "cleanup rules"), t.unsetRoute0(tunLink), common.Close(common.PtrOrNil(t.dnatBypass))) } err = t.setRules() if err != nil { _ = t.unsetRules() - return err + return E.Errors(err, t.unsetRoute0(tunLink), common.Close(common.PtrOrNil(t.dnatBypass))) } t.setSearchDomainForSystemdResolved() + if t.ipvsDNSBypass != nil { + t.ipvsDNSBypass.Start(t.refreshIPVSDNSBypass) + } if t.options.AutoRoute && runtime.GOOS == "android" { t.interfaceCallback = t.options.InterfaceMonitor.RegisterCallback(t.routeUpdate) @@ -435,12 +447,15 @@ func (t *NativeTun) enableGSO() error { } func (t *NativeTun) Close() error { + if t.ipvsDNSBypass != nil { + t.ipvsDNSBypass.Close() + } if t.interfaceCallback != nil { t.options.InterfaceMonitor.UnregisterCallback(t.interfaceCallback) } t.unsetSearchDomainForSystemdResolved() t.unsetAddresses() - return E.Errors(t.unsetRoute(), t.unsetRules(), common.Close(common.PtrOrNil(t.tunFile))) + return E.Errors(t.unsetRules(), t.unsetRoute(), common.Close(common.PtrOrNil(t.dnatBypass)), common.Close(common.PtrOrNil(t.tunFile))) } func (t *NativeTun) TXChecksumOffload() bool { @@ -524,6 +539,7 @@ func (t *NativeTun) rules() []*netlink.Rule { var it *netlink.Rule excludeRanges := t.options.ExcludedRanges() + dnatBypassMarkMask := int(t.options.autoRouteDNATBypassMask()) ruleStart := t.options.IPRoute2RuleIndex priority := ruleStart @@ -534,6 +550,10 @@ func (t *NativeTun) rules() []*netlink.Rule { it = netlink.NewRule() it.Priority = priority it.Mark = t.options.AutoRedirectOutputMark + if t.dnatBypass != nil { + it.Mark = t.options.autoRouteDNATBypassMark() + it.Mask = dnatBypassMarkMask + } it.MarkSet = true it.Goto = priority + 2 it.Family = unix.AF_INET @@ -558,6 +578,10 @@ func (t *NativeTun) rules() []*netlink.Rule { it = netlink.NewRule() it.Priority = priority6 it.Mark = t.options.AutoRedirectOutputMark + if t.dnatBypass != nil { + it.Mark = t.options.autoRouteDNATBypassMark() + it.Mask = dnatBypassMarkMask + } it.MarkSet = true it.Goto = priority6 + 2 it.Family = unix.AF_INET6 @@ -598,6 +622,51 @@ func (t *NativeTun) rules() []*netlink.Rule { } nopPriority := ruleStart + 10 + if t.dnatBypass != nil { + if p4 { + it = netlink.NewRule() + it.Priority = priority + it.Mark = t.options.autoRouteDNATBypassMark() + it.Mask = dnatBypassMarkMask + it.MarkSet = true + it.Goto = nopPriority + it.Family = unix.AF_INET + rules = append(rules, it) + priority++ + } + if p6 { + it = netlink.NewRule() + it.Priority = priority6 + it.Mark = t.options.autoRouteDNATBypassMark() + it.Mask = dnatBypassMarkMask + it.MarkSet = true + it.Goto = nopPriority + it.Family = unix.AF_INET6 + rules = append(rules, it) + priority6++ + } + } + if t.ipvsDNSBypass != nil { + var added4, added6 bool + for _, destination := range t.ipvsDNSBypass.Destinations() { + it = t.ipvsDNSRule(destination) + if it == nil { + continue + } + rules = append(rules, it) + if destination.address.Is4() { + added4 = true + } else { + added6 = true + } + } + if added4 { + priority++ + } + if added6 { + priority6++ + } + } for _, excludePort := range t.options.ExcludeSrcPort { if p4 { it = netlink.NewRule() diff --git a/tun_linux_dnat_test.go b/tun_linux_dnat_test.go new file mode 100644 index 00000000..33f8c06b --- /dev/null +++ b/tun_linux_dnat_test.go @@ -0,0 +1,325 @@ +//go:build linux + +package tun + +import ( + "bytes" + "errors" + "fmt" + "net/netip" + "os" + "os/exec" + "strings" + "testing" + + "github.com/metacubex/nftables" + "github.com/metacubex/nftables/binaryutil" + "github.com/metacubex/nftables/expr" + "github.com/metacubex/sing/common/logger" + "github.com/stretchr/testify/require" +) + +type warningRecorder struct { + logger.Logger + warnings []string +} + +func (r *warningRecorder) Warn(args ...any) { + r.warnings = append(r.warnings, fmt.Sprint(args...)) +} + +func TestAutoRouteDNATBypassRule(t *testing.T) { + nativeTun := &NativeTun{ + dnatBypass: &autoRouteDNATBypass{}, + options: Options{ + AutoRoute: true, + AutoRedirectOutputMark: DefaultAutoRedirectOutputMark, + IPRoute2RuleIndex: DefaultIPRoute2RuleIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + Inet4RouteAddress: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + }, + } + + rules := nativeTun.rules() + require.NotEmpty(t, rules) + require.Equal(t, DefaultIPRoute2RuleIndex, rules[0].Priority) + require.True(t, rules[0].MarkSet) + require.Equal(t, uint32(0x024), rules[0].Mark) + require.Equal(t, 0xFFF, rules[0].Mask) + require.Equal(t, DefaultIPRoute2RuleIndex+10, rules[0].Goto) +} + +func TestAutoRedirectDNATBypassRule(t *testing.T) { + nativeTun := &NativeTun{ + dnatBypass: &autoRouteDNATBypass{}, + options: Options{ + AutoRoute: true, + AutoRedirectMarkMode: true, + AutoRedirectInputMark: DefaultAutoRedirectInputMark, + AutoRedirectOutputMark: DefaultAutoRedirectOutputMark, + IPRoute2RuleIndex: DefaultIPRoute2RuleIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + Inet6Address: []netip.Prefix{netip.MustParsePrefix("fdfe:dcba:9876::1/126")}, + }, + } + + rules := nativeTun.rules() + require.GreaterOrEqual(t, len(rules), 6) + for _, index := range []int{0, 3} { + require.True(t, rules[index].MarkSet) + require.Equal(t, uint32(0x024), rules[index].Mark) + require.Equal(t, 0xFFF, rules[index].Mask) + } +} + +func TestAutoRouteWithoutDNATBypassRule(t *testing.T) { + nativeTun := &NativeTun{ + options: Options{ + AutoRoute: true, + AutoRedirectOutputMark: DefaultAutoRedirectOutputMark, + IPRoute2RuleIndex: DefaultIPRoute2RuleIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + Inet4RouteAddress: []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0")}, + }, + } + + for _, rule := range nativeTun.rules() { + require.False(t, + rule.MarkSet && + rule.Goto == DefaultIPRoute2RuleIndex+10, + "DNAT bypass rule must not be installed without an active firewall backend", + ) + } +} + +func TestEnableAutoRouteDNATBypassDegradesOnUnavailableBackend(t *testing.T) { + testLogger := &warningRecorder{Logger: logger.NOP()} + nativeTun := &NativeTun{ + options: Options{Logger: testLogger}, + } + + nativeTun.enableAutoRouteDNATBypass(func(*Options) (*autoRouteDNATBypass, error) { + return nil, errors.New("no firewall backend") + }) + + require.Nil(t, nativeTun.dnatBypass) + require.Len(t, testLogger.warnings, 1) + require.True(t, strings.Contains(testLogger.warnings[0], "continuing without Docker DNAT protection")) +} + +func TestDNATMarkExpressions(t *testing.T) { + options := &Options{AutoRedirectOutputMark: DefaultAutoRedirectOutputMark} + expressions := dnatMarkExpressions(options) + require.Len(t, expressions, 7) + + status, loaded := expressions[0].(*expr.Ct) + require.True(t, loaded) + require.Equal(t, expr.CtKeySTATUS, status.Key) + + mark, loaded := expressions[3].(*expr.Meta) + require.True(t, loaded) + require.Equal(t, expr.MetaKeyMARK, mark.Key) + require.False(t, mark.SourceRegister) + + mask, loaded := expressions[4].(*expr.Bitwise) + require.True(t, loaded) + require.Equal(t, binaryutil.NativeEndian.PutUint32(^uint32(0xFFF)), mask.Mask) + require.Equal(t, binaryutil.NativeEndian.PutUint32(0x024), mask.Xor) + + meta, loaded := expressions[5].(*expr.Meta) + require.True(t, loaded) + require.Equal(t, expr.MetaKeyMARK, meta.Key) + require.True(t, meta.SourceRegister) +} + +func TestDNATMarkExpressionsExcludeAutoRedirectInputMark(t *testing.T) { + options := &Options{ + AutoRedirectMarkMode: true, + AutoRedirectInputMark: DefaultAutoRedirectInputMark, + } + expressions := dnatMarkExpressions(options) + require.Len(t, expressions, 9) + mark, loaded := expressions[3].(*expr.Meta) + require.True(t, loaded) + require.Equal(t, expr.MetaKeyMARK, mark.Key) + compare, loaded := expressions[4].(*expr.Cmp) + require.True(t, loaded) + require.Equal(t, expr.CmpOpNeq, compare.Op) +} + +func TestAutoRouteDNATBypassUsesDefaultMark(t *testing.T) { + options := Options{} + require.Equal(t, uint32(0x024), options.autoRouteDNATBypassMark()) +} + +func TestAutoRouteDNATBypassUsesConfiguredMarkField(t *testing.T) { + options := Options{AutoRedirectOutputMark: 0x12345678} + require.Equal(t, uint32(0x678), options.autoRouteDNATBypassMark()) + require.Equal(t, uint32(0xFFF), options.autoRouteDNATBypassMask()) +} + +func TestAutoRouteDNATBypassUsesConfiguredHighMark(t *testing.T) { + options := Options{AutoRedirectOutputMark: 0x5000} + require.Equal(t, uint32(0x5000), options.autoRouteDNATBypassMark()) + require.Equal(t, uint32(0x5000), options.autoRouteDNATBypassMask()) +} + +func TestAutoRedirectDisablesNFTables(t *testing.T) { + options := &Options{} + redirect, err := NewAutoRedirect(AutoRedirectOptions{ + TunOptions: options, + DisableNFTables: true, + }) + require.NoError(t, err) + require.False(t, redirect.(*autoRedirect).useNFTables) +} + +func TestEnsureSrcValidMarkWithStrictRPFilter(t *testing.T) { + confPath := createIPv4Conf(t, "1", "0", "0") + testLogger := &warningRecorder{Logger: logger.NOP()} + + err := enableSrcValidMark(confPath, testLogger) + require.NoError(t, err) + value, err := os.ReadFile(confPath + "/all/src_valid_mark") + require.NoError(t, err) + require.Equal(t, "1", string(value)) + require.Len(t, testLogger.warnings, 1) + require.True(t, strings.Contains(testLogger.warnings[0], "will remain enabled after close")) +} + +func TestEnsureSrcValidMarkSkipsNonStrictRPFilter(t *testing.T) { + confPath := createIPv4Conf(t, "0", "0", "2") + require.NoError(t, os.Remove(confPath+"/all/src_valid_mark")) + + err := enableSrcValidMark(confPath, logger.NOP()) + require.NoError(t, err) + _, err = os.Stat(confPath + "/all/src_valid_mark") + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestEnsureSrcValidMarkPreservesEnabledValue(t *testing.T) { + confPath := createIPv4Conf(t, "1", "0", "0") + require.NoError(t, os.WriteFile(confPath+"/all/src_valid_mark", []byte("1"), 0o600)) + + err := enableSrcValidMark(confPath, logger.NOP()) + require.NoError(t, err) + value, err := os.ReadFile(confPath + "/all/src_valid_mark") + require.NoError(t, err) + require.Equal(t, "1", string(value)) +} + +func TestEnsureSrcValidMarkIsIdempotent(t *testing.T) { + confPath := createIPv4Conf(t, "1", "0", "0") + + err := enableSrcValidMark(confPath, logger.NOP()) + require.NoError(t, err) + + err = enableSrcValidMark(confPath, logger.NOP()) + require.NoError(t, err) + + value, err := os.ReadFile(confPath + "/all/src_valid_mark") + require.NoError(t, err) + require.Equal(t, "1", string(value)) +} + +func createIPv4Conf(t *testing.T, allRPFilter string, defaultRPFilter string, interfaceRPFilter string) string { + t.Helper() + confPath := t.TempDir() + for name, rpFilter := range map[string]string{ + "all": allRPFilter, + "default": defaultRPFilter, + "eth0": interfaceRPFilter, + } { + path := confPath + "/" + name + require.NoError(t, os.MkdirAll(path, 0o700)) + require.NoError(t, os.WriteFile(path+"/rp_filter", []byte(rpFilter), 0o600)) + } + require.NoError(t, os.WriteFile(confPath+"/all/src_valid_mark", []byte("0"), 0o600)) + return confPath +} + +func TestHasStrictRPFilter(t *testing.T) { + for name, testCase := range map[string]struct { + all string + defaultIf string + eth0 string + expected bool + }{ + "interface strict": {all: "0", defaultIf: "0", eth0: "1", expected: true}, + "default strict": {all: "0", defaultIf: "1", eth0: "0", expected: true}, + "all strict": {all: "1", defaultIf: "0", eth0: "0", expected: true}, + "all loose": {all: "2", defaultIf: "0", eth0: "0", expected: false}, + "interfaces loose": {all: "1", defaultIf: "2", eth0: "2", expected: false}, + } { + t.Run(name, func(t *testing.T) { + confPath := createIPv4Conf(t, testCase.all, testCase.defaultIf, testCase.eth0) + strict, err := hasStrictRPFilter(confPath) + require.NoError(t, err) + require.Equal(t, testCase.expected, strict) + }) + } +} + +func TestAutoRouteDNATBypassNFTables(t *testing.T) { + if os.Getenv("SING_TUN_INTEGRATION") != "1" { + t.Skip("requires a Linux network namespace with CAP_NET_ADMIN") + } + bypass := &autoRouteDNATBypass{ + options: &Options{ + AutoRedirectOutputMark: DefaultAutoRedirectOutputMark, + IPRoute2TableIndex: DefaultIPRoute2TableIndex, + Inet4Address: []netip.Prefix{netip.MustParsePrefix("198.18.0.1/30")}, + }, + tableName: "sing-tun-route-test", + useNFTables: true, + } + originalSrcValidMark, err := os.ReadFile(srcValidMarkPath) + require.NoError(t, err) + t.Cleanup(func() { _ = os.WriteFile(srcValidMarkPath, originalSrcValidMark, 0) }) + strictRPFilter, err := hasStrictRPFilter(ipv4ConfPath) + require.NoError(t, err) + require.NoError(t, bypass.Start()) + currentSrcValidMark, err := os.ReadFile(srcValidMarkPath) + require.NoError(t, err) + if strictRPFilter { + require.Equal(t, "1", string(bytes.TrimSpace(currentSrcValidMark))) + } else { + require.Equal(t, string(bytes.TrimSpace(originalSrcValidMark)), string(bytes.TrimSpace(currentSrcValidMark))) + } + require.NoError(t, bypass.Close()) + afterCloseSrcValidMark, err := os.ReadFile(srcValidMarkPath) + require.NoError(t, err) + require.Equal(t, string(bytes.TrimSpace(currentSrcValidMark)), string(bytes.TrimSpace(afterCloseSrcValidMark))) + + nft, err := nftables.New() + require.NoError(t, err) + t.Cleanup(func() { _ = nft.CloseLasting() }) + _, err = nft.ListTableOfFamily(bypass.tableName, nftables.TableFamilyINet) + require.Error(t, err) +} + +func TestAutoRouteDNATBypassIPTables(t *testing.T) { + if os.Getenv("SING_TUN_INTEGRATION") != "1" { + t.Skip("requires a Linux network namespace with CAP_NET_ADMIN") + } + iptablesPath, err := exec.LookPath("iptables") + if err != nil { + t.Skip("iptables is unavailable") + } + bypass := &autoRouteDNATBypass{ + options: &Options{ + AutoRedirectOutputMark: DefaultAutoRedirectOutputMark, + IPRoute2TableIndex: DefaultIPRoute2TableIndex, + }, + chainName: "STUN-DNAT-TEST", + iptablesPath: iptablesPath, + } + require.NoError(t, bypass.Start()) + output, err := exec.Command(iptablesPath, "-t", "mangle", "-S", bypass.chainName).CombinedOutput() + require.NoError(t, err, string(output)) + require.Contains(t, string(output), "--set-xmark 0x24/0xfff") + require.NoError(t, bypass.Close()) + + output, err = exec.Command(iptablesPath, "-t", "mangle", "-S", bypass.chainName).CombinedOutput() + require.Error(t, err, string(output)) +}