浏览代码

Add multi network dialing

世界 11 月之前
父节点
当前提交
bb46cdb2b3

+ 4 - 0
adapter/inbound.go

@@ -3,8 +3,10 @@ package adapter
 import (
 	"context"
 	"net/netip"
+	"time"
 
 	"github.com/sagernet/sing-box/common/process"
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	M "github.com/sagernet/sing/common/metadata"
@@ -66,6 +68,8 @@ type InboundContext struct {
 	InboundOptions            option.InboundOptions
 	UDPDisableDomainUnmapping bool
 	UDPConnect                bool
+	NetworkStrategy           C.NetworkStrategy
+	FallbackDelay             time.Duration
 
 	DNSServer string
 

+ 12 - 2
adapter/network.go

@@ -1,6 +1,9 @@
 package adapter
 
 import (
+	"time"
+
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing/common/control"
 )
@@ -11,10 +14,10 @@ type NetworkManager interface {
 	UpdateInterfaces() error
 	DefaultNetworkInterface() *NetworkInterface
 	NetworkInterfaces() []NetworkInterface
-	DefaultInterface() string
 	AutoDetectInterface() bool
 	AutoDetectInterfaceFunc() control.Func
-	DefaultMark() uint32
+	ProtectFunc() control.Func
+	DefaultOptions() NetworkOptions
 	RegisterAutoRedirectOutputMark(mark uint32) error
 	AutoRedirectOutputMark() uint32
 	NetworkMonitor() tun.NetworkUpdateMonitor
@@ -24,6 +27,13 @@ type NetworkManager interface {
 	ResetNetwork()
 }
 
+type NetworkOptions struct {
+	DefaultNetworkStrategy C.NetworkStrategy
+	DefaultFallbackDelay   time.Duration
+	DefaultInterface       string
+	DefaultMark            uint32
+}
+
 type InterfaceUpdateListener interface {
 	InterfaceUpdated()
 }

+ 15 - 101
adapter/outbound/default.go

@@ -8,8 +8,8 @@ import (
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/dialer"
 	C "github.com/sagernet/sing-box/constant"
-	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/buf"
 	"github.com/sagernet/sing/common/bufio"
@@ -25,35 +25,11 @@ func NewConnection(ctx context.Context, this N.Dialer, conn net.Conn, metadata a
 	var outConn net.Conn
 	var err error
 	if len(metadata.DestinationAddresses) > 0 {
-		outConn, err = N.DialSerial(ctx, this, N.NetworkTCP, metadata.Destination, metadata.DestinationAddresses)
-	} else {
-		outConn, err = this.DialContext(ctx, N.NetworkTCP, metadata.Destination)
-	}
-	if err != nil {
-		return N.ReportHandshakeFailure(conn, err)
-	}
-	err = N.ReportConnHandshakeSuccess(conn, outConn)
-	if err != nil {
-		outConn.Close()
-		return err
-	}
-	return CopyEarlyConn(ctx, conn, outConn)
-}
-
-func NewDirectConnection(ctx context.Context, router adapter.Router, this N.Dialer, conn net.Conn, metadata adapter.InboundContext, domainStrategy dns.DomainStrategy) error {
-	defer conn.Close()
-	ctx = adapter.WithContext(ctx, &metadata)
-	var outConn net.Conn
-	var err error
-	if len(metadata.DestinationAddresses) > 0 {
-		outConn, err = N.DialSerial(ctx, this, N.NetworkTCP, metadata.Destination, metadata.DestinationAddresses)
-	} else if metadata.Destination.IsFqdn() {
-		var destinationAddresses []netip.Addr
-		destinationAddresses, err = router.Lookup(ctx, metadata.Destination.Fqdn, domainStrategy)
-		if err != nil {
-			return N.ReportHandshakeFailure(conn, err)
+		if parallelDialer, isParallelDialer := this.(dialer.ParallelInterfaceDialer); isParallelDialer {
+			outConn, err = dialer.DialSerialNetwork(ctx, parallelDialer, N.NetworkTCP, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.FallbackDelay)
+		} else {
+			outConn, err = N.DialSerial(ctx, this, N.NetworkTCP, metadata.Destination, metadata.DestinationAddresses)
 		}
-		outConn, err = N.DialSerial(ctx, this, N.NetworkTCP, metadata.Destination, destinationAddresses)
 	} else {
 		outConn, err = this.DialContext(ctx, N.NetworkTCP, metadata.Destination)
 	}
@@ -79,7 +55,11 @@ func NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn,
 	)
 	if metadata.UDPConnect {
 		if len(metadata.DestinationAddresses) > 0 {
-			outConn, err = N.DialSerial(ctx, this, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses)
+			if parallelDialer, isParallelDialer := this.(dialer.ParallelInterfaceDialer); isParallelDialer {
+				outConn, err = dialer.DialSerialNetwork(ctx, parallelDialer, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.FallbackDelay)
+			} else {
+				outConn, err = N.DialSerial(ctx, this, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses)
+			}
 		} else {
 			outConn, err = this.DialContext(ctx, N.NetworkUDP, metadata.Destination)
 		}
@@ -93,7 +73,11 @@ func NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn,
 		}
 	} else {
 		if len(metadata.DestinationAddresses) > 0 {
-			outPacketConn, destinationAddress, err = N.ListenSerial(ctx, this, metadata.Destination, metadata.DestinationAddresses)
+			if parallelDialer, isParallelDialer := this.(dialer.ParallelInterfaceDialer); isParallelDialer {
+				outPacketConn, destinationAddress, err = dialer.ListenSerialNetworkPacket(ctx, parallelDialer, metadata.Destination, metadata.DestinationAddresses, metadata.NetworkStrategy, metadata.FallbackDelay)
+			} else {
+				outPacketConn, destinationAddress, err = N.ListenSerial(ctx, this, metadata.Destination, metadata.DestinationAddresses)
+			}
 		} else {
 			outPacketConn, err = this.ListenPacket(ctx, metadata.Destination)
 		}
@@ -129,76 +113,6 @@ func NewPacketConnection(ctx context.Context, this N.Dialer, conn N.PacketConn,
 	return bufio.CopyPacketConn(ctx, conn, bufio.NewPacketConn(outPacketConn))
 }
 
-func NewDirectPacketConnection(ctx context.Context, router adapter.Router, this N.Dialer, conn N.PacketConn, metadata adapter.InboundContext, domainStrategy dns.DomainStrategy) error {
-	defer conn.Close()
-	ctx = adapter.WithContext(ctx, &metadata)
-	var (
-		outPacketConn      net.PacketConn
-		outConn            net.Conn
-		destinationAddress netip.Addr
-		err                error
-	)
-	if metadata.UDPConnect {
-		if len(metadata.DestinationAddresses) > 0 {
-			outConn, err = N.DialSerial(ctx, this, N.NetworkUDP, metadata.Destination, metadata.DestinationAddresses)
-		} else if metadata.Destination.IsFqdn() {
-			var destinationAddresses []netip.Addr
-			destinationAddresses, err = router.Lookup(ctx, metadata.Destination.Fqdn, domainStrategy)
-			if err != nil {
-				return N.ReportHandshakeFailure(conn, err)
-			}
-			outConn, err = N.DialSerial(ctx, this, N.NetworkUDP, metadata.Destination, destinationAddresses)
-		} else {
-			outConn, err = this.DialContext(ctx, N.NetworkUDP, metadata.Destination)
-		}
-		if err != nil {
-			return N.ReportHandshakeFailure(conn, err)
-		}
-		connRemoteAddr := M.AddrFromNet(outConn.RemoteAddr())
-		if connRemoteAddr != metadata.Destination.Addr {
-			destinationAddress = connRemoteAddr
-		}
-	} else {
-		if len(metadata.DestinationAddresses) > 0 {
-			outPacketConn, destinationAddress, err = N.ListenSerial(ctx, this, metadata.Destination, metadata.DestinationAddresses)
-		} else if metadata.Destination.IsFqdn() {
-			var destinationAddresses []netip.Addr
-			destinationAddresses, err = router.Lookup(ctx, metadata.Destination.Fqdn, domainStrategy)
-			if err != nil {
-				return N.ReportHandshakeFailure(conn, err)
-			}
-			outPacketConn, destinationAddress, err = N.ListenSerial(ctx, this, metadata.Destination, destinationAddresses)
-		} else {
-			outPacketConn, err = this.ListenPacket(ctx, metadata.Destination)
-		}
-		if err != nil {
-			return N.ReportHandshakeFailure(conn, err)
-		}
-	}
-	err = N.ReportPacketConnHandshakeSuccess(conn, outPacketConn)
-	if err != nil {
-		outPacketConn.Close()
-		return err
-	}
-	if destinationAddress.IsValid() {
-		if metadata.Destination.IsFqdn() {
-			outPacketConn = bufio.NewNATPacketConn(bufio.NewPacketConn(outPacketConn), M.SocksaddrFrom(destinationAddress, metadata.Destination.Port), metadata.Destination)
-		}
-		if natConn, loaded := common.Cast[bufio.NATPacketConn](conn); loaded {
-			natConn.UpdateDestination(destinationAddress)
-		}
-	}
-	switch metadata.Protocol {
-	case C.ProtocolSTUN:
-		ctx, conn = canceler.NewPacketConn(ctx, conn, C.STUNTimeout)
-	case C.ProtocolQUIC:
-		ctx, conn = canceler.NewPacketConn(ctx, conn, C.QUICTimeout)
-	case C.ProtocolDNS:
-		ctx, conn = canceler.NewPacketConn(ctx, conn, C.DNSTimeout)
-	}
-	return bufio.CopyPacketConn(ctx, conn, bufio.NewPacketConn(outPacketConn))
-}
-
 func CopyEarlyConn(ctx context.Context, conn net.Conn, serverConn net.Conn) error {
 	if cachedReader, isCached := conn.(N.CachedReader); isCached {
 		payload := cachedReader.ReadCached()

+ 148 - 60
common/dialer/default.go

@@ -10,66 +10,93 @@ import (
 	"github.com/sagernet/sing-box/common/conntrack"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common/atomic"
 	"github.com/sagernet/sing/common/control"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 )
 
-var _ WireGuardListener = (*DefaultDialer)(nil)
+var (
+	_ ParallelInterfaceDialer = (*DefaultDialer)(nil)
+	_ WireGuardListener       = (*DefaultDialer)(nil)
+)
 
 type DefaultDialer struct {
-	dialer4             tcpDialer
-	dialer6             tcpDialer
-	udpDialer4          net.Dialer
-	udpDialer6          net.Dialer
-	udpListener         net.ListenConfig
-	udpAddr4            string
-	udpAddr6            string
-	isWireGuardListener bool
+	dialer4              tcpDialer
+	dialer6              tcpDialer
+	udpDialer4           net.Dialer
+	udpDialer6           net.Dialer
+	udpListener          net.ListenConfig
+	udpAddr4             string
+	udpAddr6             string
+	isWireGuardListener  bool
+	networkManager       adapter.NetworkManager
+	networkStrategy      C.NetworkStrategy
+	networkFallbackDelay time.Duration
+	networkLastFallback  atomic.TypedValue[time.Time]
 }
 
 func NewDefault(networkManager adapter.NetworkManager, options option.DialerOptions) (*DefaultDialer, error) {
-	var dialer net.Dialer
-	var listener net.ListenConfig
+	var (
+		dialer               net.Dialer
+		listener             net.ListenConfig
+		interfaceFinder      control.InterfaceFinder
+		networkStrategy      C.NetworkStrategy
+		networkFallbackDelay time.Duration
+	)
+	if networkManager != nil {
+		interfaceFinder = networkManager.InterfaceFinder()
+	} else {
+		interfaceFinder = control.NewDefaultInterfaceFinder()
+	}
 	if options.BindInterface != "" {
-		var interfaceFinder control.InterfaceFinder
-		if networkManager != nil {
-			interfaceFinder = networkManager.InterfaceFinder()
-		} else {
-			interfaceFinder = control.NewDefaultInterfaceFinder()
-		}
 		bindFunc := control.BindToInterface(interfaceFinder, options.BindInterface, -1)
 		dialer.Control = control.Append(dialer.Control, bindFunc)
 		listener.Control = control.Append(listener.Control, bindFunc)
-	} else if networkManager != nil && networkManager.AutoDetectInterface() {
-		bindFunc := networkManager.AutoDetectInterfaceFunc()
-		dialer.Control = control.Append(dialer.Control, bindFunc)
-		listener.Control = control.Append(listener.Control, bindFunc)
-	} else if networkManager != nil && networkManager.DefaultInterface() != "" {
-		bindFunc := control.BindToInterface(networkManager.InterfaceFinder(), networkManager.DefaultInterface(), -1)
-		dialer.Control = control.Append(dialer.Control, bindFunc)
-		listener.Control = control.Append(listener.Control, bindFunc)
-	}
-	var autoRedirectOutputMark uint32
-	if networkManager != nil {
-		autoRedirectOutputMark = networkManager.AutoRedirectOutputMark()
-	}
-	if autoRedirectOutputMark > 0 {
-		dialer.Control = control.Append(dialer.Control, control.RoutingMark(autoRedirectOutputMark))
-		listener.Control = control.Append(listener.Control, control.RoutingMark(autoRedirectOutputMark))
 	}
 	if options.RoutingMark > 0 {
 		dialer.Control = control.Append(dialer.Control, control.RoutingMark(options.RoutingMark))
 		listener.Control = control.Append(listener.Control, control.RoutingMark(options.RoutingMark))
+	}
+	if networkManager != nil {
+		autoRedirectOutputMark := networkManager.AutoRedirectOutputMark()
 		if autoRedirectOutputMark > 0 {
-			return nil, E.New("`auto_redirect` with `route_[_exclude]_address_set is conflict with `routing_mark`")
+			if options.RoutingMark > 0 {
+				return nil, E.New("`routing_mark` is conflict with `tun.auto_redirect` with `tun.route_[_exclude]_address_set")
+			}
+			dialer.Control = control.Append(dialer.Control, control.RoutingMark(autoRedirectOutputMark))
+			listener.Control = control.Append(listener.Control, control.RoutingMark(autoRedirectOutputMark))
 		}
-	} else if networkManager != nil && networkManager.DefaultMark() > 0 {
-		dialer.Control = control.Append(dialer.Control, control.RoutingMark(networkManager.DefaultMark()))
-		listener.Control = control.Append(listener.Control, control.RoutingMark(networkManager.DefaultMark()))
-		if autoRedirectOutputMark > 0 {
-			return nil, E.New("`auto_redirect` with `route_[_exclude]_address_set is conflict with `default_mark`")
+	}
+	if C.NetworkStrategy(options.NetworkStrategy) != C.NetworkStrategyDefault {
+		if options.BindInterface != "" || options.Inet4BindAddress != nil || options.Inet6BindAddress != nil {
+			return nil, E.New("`network_strategy` is conflict with `bind_interface`, `inet4_bind_address` and `inet6_bind_address`")
+		}
+		networkStrategy = C.NetworkStrategy(options.NetworkStrategy)
+		networkFallbackDelay = time.Duration(options.NetworkFallbackDelay)
+		if networkManager == nil || !networkManager.AutoDetectInterface() {
+			return nil, E.New("`route.auto_detect_interface` is require by `network_strategy`")
+		}
+	}
+	if networkManager != nil && options.BindInterface == "" && options.Inet4BindAddress == nil && options.Inet6BindAddress == nil {
+		defaultOptions := networkManager.DefaultOptions()
+		if defaultOptions.DefaultInterface != "" {
+			bindFunc := control.BindToInterface(networkManager.InterfaceFinder(), defaultOptions.DefaultInterface, -1)
+			dialer.Control = control.Append(dialer.Control, bindFunc)
+			listener.Control = control.Append(listener.Control, bindFunc)
+		} else if networkManager.AutoDetectInterface() {
+			if defaultOptions.DefaultNetworkStrategy != C.NetworkStrategyDefault && C.NetworkStrategy(options.NetworkStrategy) == C.NetworkStrategyDefault {
+				networkStrategy = defaultOptions.DefaultNetworkStrategy
+				networkFallbackDelay = defaultOptions.DefaultFallbackDelay
+				bindFunc := networkManager.ProtectFunc()
+				dialer.Control = control.Append(dialer.Control, bindFunc)
+				listener.Control = control.Append(listener.Control, bindFunc)
+			} else {
+				bindFunc := networkManager.AutoDetectInterfaceFunc()
+				dialer.Control = control.Append(dialer.Control, bindFunc)
+				listener.Control = control.Append(listener.Control, bindFunc)
+			}
 		}
 	}
 	if options.ReuseAddr {
@@ -130,6 +157,9 @@ func NewDefault(networkManager adapter.NetworkManager, options option.DialerOpti
 			listener.Control = control.Append(listener.Control, controlFn)
 		}
 	}
+	if networkStrategy != C.NetworkStrategyDefault && options.TCPFastOpen {
+		return nil, E.New("`tcp_fast_open` is conflict with `network_strategy` or `route.default_network_strategy`")
+	}
 	tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen)
 	if err != nil {
 		return nil, err
@@ -139,14 +169,17 @@ func NewDefault(networkManager adapter.NetworkManager, options option.DialerOpti
 		return nil, err
 	}
 	return &DefaultDialer{
-		tcpDialer4,
-		tcpDialer6,
-		udpDialer4,
-		udpDialer6,
-		listener,
-		udpAddr4,
-		udpAddr6,
-		options.IsWireGuardListener,
+		dialer4:              tcpDialer4,
+		dialer6:              tcpDialer6,
+		udpDialer4:           udpDialer4,
+		udpDialer6:           udpDialer6,
+		udpListener:          listener,
+		udpAddr4:             udpAddr4,
+		udpAddr6:             udpAddr6,
+		isWireGuardListener:  options.IsWireGuardListener,
+		networkManager:       networkManager,
+		networkStrategy:      networkStrategy,
+		networkFallbackDelay: networkFallbackDelay,
 	}, nil
 }
 
@@ -154,33 +187,88 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address
 	if !address.IsValid() {
 		return nil, E.New("invalid address")
 	}
-	switch N.NetworkName(network) {
-	case N.NetworkUDP:
+	if d.networkStrategy == C.NetworkStrategyDefault {
+		switch N.NetworkName(network) {
+		case N.NetworkUDP:
+			if !address.IsIPv6() {
+				return trackConn(d.udpDialer4.DialContext(ctx, network, address.String()))
+			} else {
+				return trackConn(d.udpDialer6.DialContext(ctx, network, address.String()))
+			}
+		}
 		if !address.IsIPv6() {
-			return trackConn(d.udpDialer4.DialContext(ctx, network, address.String()))
+			return trackConn(DialSlowContext(&d.dialer4, ctx, network, address))
 		} else {
-			return trackConn(d.udpDialer6.DialContext(ctx, network, address.String()))
+			return trackConn(DialSlowContext(&d.dialer6, ctx, network, address))
 		}
+	} else {
+		return d.DialParallelInterface(ctx, network, address, d.networkStrategy, d.networkFallbackDelay)
+	}
+}
+
+func (d *DefaultDialer) DialParallelInterface(ctx context.Context, network string, address M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error) {
+	if strategy == C.NetworkStrategyDefault {
+		return d.DialContext(ctx, network, address)
 	}
-	if !address.IsIPv6() {
-		return trackConn(DialSlowContext(&d.dialer4, ctx, network, address))
+	if !d.networkManager.AutoDetectInterface() {
+		return nil, E.New("`route.auto_detect_interface` is require by `network_strategy`")
+	}
+	var dialer net.Dialer
+	if N.NetworkName(network) == N.NetworkTCP {
+		dialer = dialerFromTCPDialer(d.dialer4)
+	} else {
+		dialer = d.udpDialer4
+	}
+	fastFallback := time.Now().Sub(d.networkLastFallback.Load()) < C.TCPTimeout
+	var (
+		conn      net.Conn
+		isPrimary bool
+		err       error
+	)
+	if !fastFallback {
+		conn, isPrimary, err = d.dialParallelInterface(ctx, dialer, network, address.String(), strategy, fallbackDelay)
 	} else {
-		return trackConn(DialSlowContext(&d.dialer6, ctx, network, address))
+		conn, isPrimary, err = d.dialParallelInterfaceFastFallback(ctx, dialer, network, address.String(), strategy, fallbackDelay, d.networkLastFallback.Store)
 	}
+	if err != nil {
+		return nil, err
+	}
+	if !fastFallback && !isPrimary {
+		d.networkLastFallback.Store(time.Now())
+	}
+	return trackConn(conn, nil)
 }
 
 func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
-	if destination.IsIPv6() {
-		return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr6))
-	} else if destination.IsIPv4() && !destination.Addr.IsUnspecified() {
-		return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP+"4", d.udpAddr4))
+	if d.networkStrategy == C.NetworkStrategyDefault {
+		if destination.IsIPv6() {
+			return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr6))
+		} else if destination.IsIPv4() && !destination.Addr.IsUnspecified() {
+			return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP+"4", d.udpAddr4))
+		} else {
+			return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr4))
+		}
 	} else {
-		return trackPacketConn(d.udpListener.ListenPacket(ctx, N.NetworkUDP, d.udpAddr4))
+		return d.ListenSerialInterfacePacket(ctx, destination, d.networkStrategy, d.networkFallbackDelay)
+	}
+}
+
+func (d *DefaultDialer) ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, error) {
+	if strategy == C.NetworkStrategyDefault {
+		return d.ListenPacket(ctx, destination)
+	}
+	if !d.networkManager.AutoDetectInterface() {
+		return nil, E.New("`route.auto_detect_interface` is require by `network_strategy`")
+	}
+	network := N.NetworkUDP
+	if destination.IsIPv4() && !destination.Addr.IsUnspecified() {
+		network += "4"
 	}
+	return trackPacketConn(d.listenSerialInterfacePacket(ctx, d.udpListener, network, "", strategy, fallbackDelay))
 }
 
 func (d *DefaultDialer) ListenPacketCompat(network, address string) (net.PacketConn, error) {
-	return d.udpListener.ListenPacket(context.Background(), network, address)
+	return d.listenSerialInterfacePacket(context.Background(), d.udpListener, network, address, d.networkStrategy, d.networkFallbackDelay)
 }
 
 func trackConn(conn net.Conn, err error) (net.Conn, error) {

+ 4 - 0
common/dialer/default_go1.20.go

@@ -13,3 +13,7 @@ type tcpDialer = tfo.Dialer
 func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) {
 	return tfo.Dialer{Dialer: dialer, DisableTFO: !tfoEnabled}, nil
 }
+
+func dialerFromTCPDialer(dialer tcpDialer) net.Dialer {
+	return dialer.Dialer
+}

+ 4 - 0
common/dialer/default_nongo1.20.go

@@ -16,3 +16,7 @@ func newTCPDialer(dialer net.Dialer, tfoEnabled bool) (tcpDialer, error) {
 	}
 	return dialer, nil
 }
+
+func dialerFromTCPDialer(dialer tcpDialer) net.Dialer {
+	return dialer
+}

+ 241 - 0
common/dialer/default_parallel_interface.go

@@ -0,0 +1,241 @@
+package dialer
+
+import (
+	"context"
+	"net"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common/control"
+	E "github.com/sagernet/sing/common/exceptions"
+	F "github.com/sagernet/sing/common/format"
+	N "github.com/sagernet/sing/common/network"
+)
+
+func (d *DefaultDialer) dialParallelInterface(ctx context.Context, dialer net.Dialer, network string, addr string, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, bool, error) {
+	primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy)
+	if len(primaryInterfaces)+len(fallbackInterfaces) == 0 {
+		return nil, false, E.New("no available network interface")
+	}
+	if fallbackDelay == 0 {
+		fallbackDelay = N.DefaultFallbackDelay
+	}
+	returned := make(chan struct{})
+	defer close(returned)
+	type dialResult struct {
+		net.Conn
+		error
+		primary bool
+	}
+	results := make(chan dialResult) // unbuffered
+	startRacer := func(ctx context.Context, primary bool, iif adapter.NetworkInterface) {
+		perNetDialer := dialer
+		perNetDialer.Control = control.Append(perNetDialer.Control, control.BindToInterface(nil, iif.Name, iif.Index))
+		conn, err := perNetDialer.DialContext(ctx, network, addr)
+		if err != nil {
+			select {
+			case results <- dialResult{error: E.Cause(err, "dial ", iif.Name, " (", iif.Name, ")"), primary: primary}:
+			case <-returned:
+			}
+		} else {
+			select {
+			case results <- dialResult{Conn: conn}:
+			case <-returned:
+				conn.Close()
+			}
+		}
+	}
+	primaryCtx, primaryCancel := context.WithCancel(ctx)
+	defer primaryCancel()
+	for _, iif := range primaryInterfaces {
+		go startRacer(primaryCtx, true, iif)
+	}
+	var (
+		fallbackTimer *time.Timer
+		fallbackChan  <-chan time.Time
+	)
+	if len(fallbackInterfaces) > 0 {
+		fallbackTimer = time.NewTimer(fallbackDelay)
+		defer fallbackTimer.Stop()
+		fallbackChan = fallbackTimer.C
+	}
+	var errors []error
+	for {
+		select {
+		case <-fallbackChan:
+			fallbackCtx, fallbackCancel := context.WithCancel(ctx)
+			defer fallbackCancel()
+			for _, iif := range fallbackInterfaces {
+				go startRacer(fallbackCtx, false, iif)
+			}
+		case res := <-results:
+			if res.error == nil {
+				return res.Conn, res.primary, nil
+			}
+			errors = append(errors, res.error)
+			if len(errors) == len(primaryInterfaces)+len(fallbackInterfaces) {
+				return nil, false, E.Errors(errors...)
+			}
+			if res.primary && fallbackTimer != nil && fallbackTimer.Stop() {
+				fallbackTimer.Reset(0)
+			}
+		}
+	}
+}
+
+func (d *DefaultDialer) dialParallelInterfaceFastFallback(ctx context.Context, dialer net.Dialer, network string, addr string, strategy C.NetworkStrategy, fallbackDelay time.Duration, resetFastFallback func(time.Time)) (net.Conn, bool, error) {
+	primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy)
+	if len(primaryInterfaces)+len(fallbackInterfaces) == 0 {
+		return nil, false, E.New("no available network interface")
+	}
+	if fallbackDelay == 0 {
+		fallbackDelay = N.DefaultFallbackDelay
+	}
+	returned := make(chan struct{})
+	defer close(returned)
+	type dialResult struct {
+		net.Conn
+		error
+		primary bool
+	}
+	startAt := time.Now()
+	results := make(chan dialResult) // unbuffered
+	startRacer := func(ctx context.Context, primary bool, iif adapter.NetworkInterface) {
+		perNetDialer := dialer
+		perNetDialer.Control = control.Append(perNetDialer.Control, control.BindToInterface(nil, iif.Name, iif.Index))
+		conn, err := perNetDialer.DialContext(ctx, network, addr)
+		if err != nil {
+			select {
+			case results <- dialResult{error: E.Cause(err, "dial ", iif.Name, " (", iif.Name, ")"), primary: primary}:
+			case <-returned:
+			}
+		} else {
+			select {
+			case results <- dialResult{Conn: conn}:
+			case <-returned:
+				if primary && time.Since(startAt) <= fallbackDelay {
+					resetFastFallback(time.Time{})
+				}
+				conn.Close()
+			}
+		}
+	}
+	for _, iif := range primaryInterfaces {
+		go startRacer(ctx, true, iif)
+	}
+	fallbackCtx, fallbackCancel := context.WithCancel(ctx)
+	defer fallbackCancel()
+	for _, iif := range fallbackInterfaces {
+		go startRacer(fallbackCtx, false, iif)
+	}
+	var errors []error
+	for {
+		select {
+		case res := <-results:
+			if res.error == nil {
+				return res.Conn, res.primary, nil
+			}
+			errors = append(errors, res.error)
+			if len(errors) == len(primaryInterfaces)+len(fallbackInterfaces) {
+				return nil, false, E.Errors(errors...)
+			}
+		}
+	}
+}
+
+func (d *DefaultDialer) listenSerialInterfacePacket(ctx context.Context, listener net.ListenConfig, network string, addr string, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, error) {
+	primaryInterfaces, fallbackInterfaces := selectInterfaces(d.networkManager, strategy)
+	if len(primaryInterfaces)+len(fallbackInterfaces) == 0 {
+		return nil, E.New("no available network interface")
+	}
+	if fallbackDelay == 0 {
+		fallbackDelay = N.DefaultFallbackDelay
+	}
+	var errors []error
+	for _, primaryInterface := range primaryInterfaces {
+		perNetListener := listener
+		perNetListener.Control = control.Append(perNetListener.Control, control.BindToInterface(nil, primaryInterface.Name, primaryInterface.Index))
+		conn, err := perNetListener.ListenPacket(ctx, network, addr)
+		if err == nil {
+			return conn, nil
+		}
+		errors = append(errors, E.Cause(err, "listen ", primaryInterface.Name, " (", primaryInterface.Name, ")"))
+	}
+	for _, fallbackInterface := range fallbackInterfaces {
+		perNetListener := listener
+		perNetListener.Control = control.Append(perNetListener.Control, control.BindToInterface(nil, fallbackInterface.Name, fallbackInterface.Index))
+		conn, err := perNetListener.ListenPacket(ctx, network, addr)
+		if err == nil {
+			return conn, nil
+		}
+		errors = append(errors, E.Cause(err, "listen ", fallbackInterface.Name, " (", fallbackInterface.Name, ")"))
+	}
+	return nil, E.Errors(errors...)
+}
+
+func selectInterfaces(networkManager adapter.NetworkManager, strategy C.NetworkStrategy) (primaryInterfaces []adapter.NetworkInterface, fallbackInterfaces []adapter.NetworkInterface) {
+	interfaces := networkManager.NetworkInterfaces()
+	switch strategy {
+	case C.NetworkStrategyFallback:
+		defaultIf := networkManager.InterfaceMonitor().DefaultInterface()
+		if defaultIf != nil {
+			for _, iif := range interfaces {
+				if iif.Index == defaultIf.Index {
+					primaryInterfaces = append(primaryInterfaces, iif)
+				} else {
+					fallbackInterfaces = append(fallbackInterfaces, iif)
+				}
+			}
+		} else {
+			primaryInterfaces = interfaces
+		}
+	case C.NetworkStrategyHybrid:
+		primaryInterfaces = interfaces
+	case C.NetworkStrategyWIFI:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeWIFI {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			} else {
+				fallbackInterfaces = append(fallbackInterfaces, iif)
+			}
+		}
+	case C.NetworkStrategyCellular:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeCellular {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			} else {
+				fallbackInterfaces = append(fallbackInterfaces, iif)
+			}
+		}
+	case C.NetworkStrategyEthernet:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeEthernet {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			} else {
+				fallbackInterfaces = append(fallbackInterfaces, iif)
+			}
+		}
+	case C.NetworkStrategyWIFIOnly:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeWIFI {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			}
+		}
+	case C.NetworkStrategyCellularOnly:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeCellular {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			}
+		}
+	case C.NetworkStrategyEthernetOnly:
+		for _, iif := range interfaces {
+			if iif.Type == C.InterfaceTypeEthernet {
+				primaryInterfaces = append(primaryInterfaces, iif)
+			}
+		}
+	default:
+		panic(F.ToString("unknown network strategy: ", strategy))
+	}
+	return primaryInterfaces, fallbackInterfaces
+}

+ 122 - 0
common/dialer/default_parallel_network.go

@@ -0,0 +1,122 @@
+package dialer
+
+import (
+	"context"
+	"net"
+	"net/netip"
+	"time"
+
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common"
+	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+func DialSerialNetwork(ctx context.Context, dialer ParallelInterfaceDialer, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error) {
+	if parallelDialer, isParallel := dialer.(ParallelNetworkDialer); isParallel {
+		return parallelDialer.DialParallelNetwork(ctx, network, destination, destinationAddresses, strategy, fallbackDelay)
+	}
+	var errors []error
+	for _, address := range destinationAddresses {
+		conn, err := dialer.DialParallelInterface(ctx, network, M.SocksaddrFrom(address, destination.Port), strategy, fallbackDelay)
+		if err == nil {
+			return conn, nil
+		}
+		errors = append(errors, err)
+	}
+	return nil, E.Errors(errors...)
+}
+
+func DialParallelNetwork(ctx context.Context, dialer ParallelInterfaceDialer, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, preferIPv6 bool, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error) {
+	if fallbackDelay == 0 {
+		fallbackDelay = N.DefaultFallbackDelay
+	}
+
+	returned := make(chan struct{})
+	defer close(returned)
+
+	addresses4 := common.Filter(destinationAddresses, func(address netip.Addr) bool {
+		return address.Is4() || address.Is4In6()
+	})
+	addresses6 := common.Filter(destinationAddresses, func(address netip.Addr) bool {
+		return address.Is6() && !address.Is4In6()
+	})
+	if len(addresses4) == 0 || len(addresses6) == 0 {
+		return DialSerialNetwork(ctx, dialer, network, destination, destinationAddresses, strategy, fallbackDelay)
+	}
+	var primaries, fallbacks []netip.Addr
+	if preferIPv6 {
+		primaries = addresses6
+		fallbacks = addresses4
+	} else {
+		primaries = addresses4
+		fallbacks = addresses6
+	}
+	type dialResult struct {
+		net.Conn
+		error
+		primary bool
+		done    bool
+	}
+	results := make(chan dialResult) // unbuffered
+	startRacer := func(ctx context.Context, primary bool) {
+		ras := primaries
+		if !primary {
+			ras = fallbacks
+		}
+		c, err := DialSerialNetwork(ctx, dialer, network, destination, ras, strategy, fallbackDelay)
+		select {
+		case results <- dialResult{Conn: c, error: err, primary: primary, done: true}:
+		case <-returned:
+			if c != nil {
+				c.Close()
+			}
+		}
+	}
+	var primary, fallback dialResult
+	primaryCtx, primaryCancel := context.WithCancel(ctx)
+	defer primaryCancel()
+	go startRacer(primaryCtx, true)
+	fallbackTimer := time.NewTimer(fallbackDelay)
+	defer fallbackTimer.Stop()
+	for {
+		select {
+		case <-fallbackTimer.C:
+			fallbackCtx, fallbackCancel := context.WithCancel(ctx)
+			defer fallbackCancel()
+			go startRacer(fallbackCtx, false)
+
+		case res := <-results:
+			if res.error == nil {
+				return res.Conn, nil
+			}
+			if res.primary {
+				primary = res
+			} else {
+				fallback = res
+			}
+			if primary.done && fallback.done {
+				return nil, primary.error
+			}
+			if res.primary && fallbackTimer.Stop() {
+				fallbackTimer.Reset(0)
+			}
+		}
+	}
+}
+
+func ListenSerialNetworkPacket(ctx context.Context, dialer ParallelInterfaceDialer, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) {
+	if parallelDialer, isParallel := dialer.(ParallelNetworkDialer); isParallel {
+		return parallelDialer.ListenSerialNetworkPacket(ctx, destination, destinationAddresses, strategy, fallbackDelay)
+	}
+	var errors []error
+	for _, address := range destinationAddresses {
+		conn, err := dialer.ListenSerialInterfacePacket(ctx, M.SocksaddrFrom(address, destination.Port), strategy, fallbackDelay)
+		if err == nil {
+			return conn, address, nil
+		}
+		errors = append(errors, err)
+	}
+	return nil, netip.Addr{}, E.Errors(errors...)
+}

+ 36 - 0
common/dialer/dialer.go

@@ -2,12 +2,16 @@ package dialer
 
 import (
 	"context"
+	"net"
+	"net/netip"
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/option"
 	"github.com/sagernet/sing-dns"
 	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	"github.com/sagernet/sing/service"
 )
@@ -49,3 +53,35 @@ func New(ctx context.Context, options option.DialerOptions) (N.Dialer, error) {
 	}
 	return dialer, nil
 }
+
+func NewDirect(ctx context.Context, options option.DialerOptions) (ParallelInterfaceDialer, error) {
+	if options.Detour != "" {
+		return nil, E.New("`detour` is not supported in direct context")
+	}
+	networkManager := service.FromContext[adapter.NetworkManager](ctx)
+	if options.IsWireGuardListener {
+		return NewDefault(networkManager, options)
+	}
+	dialer, err := NewDefault(networkManager, options)
+	if err != nil {
+		return nil, err
+	}
+	return NewResolveParallelInterfaceDialer(
+		service.FromContext[adapter.Router](ctx),
+		dialer,
+		true,
+		dns.DomainStrategy(options.DomainStrategy),
+		time.Duration(options.FallbackDelay),
+	), nil
+}
+
+type ParallelInterfaceDialer interface {
+	N.Dialer
+	DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error)
+	ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, error)
+}
+
+type ParallelNetworkDialer interface {
+	DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error)
+	ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error)
+}

+ 83 - 6
common/dialer/resolve.go

@@ -7,6 +7,7 @@ import (
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing/common/bufio"
@@ -14,7 +15,12 @@ import (
 	N "github.com/sagernet/sing/common/network"
 )
 
-type ResolveDialer struct {
+var (
+	_ N.Dialer                = (*resolveDialer)(nil)
+	_ ParallelInterfaceDialer = (*resolveParallelNetworkDialer)(nil)
+)
+
+type resolveDialer struct {
 	dialer        N.Dialer
 	parallel      bool
 	router        adapter.Router
@@ -22,8 +28,8 @@ type ResolveDialer struct {
 	fallbackDelay time.Duration
 }
 
-func NewResolveDialer(router adapter.Router, dialer N.Dialer, parallel bool, strategy dns.DomainStrategy, fallbackDelay time.Duration) *ResolveDialer {
-	return &ResolveDialer{
+func NewResolveDialer(router adapter.Router, dialer N.Dialer, parallel bool, strategy dns.DomainStrategy, fallbackDelay time.Duration) N.Dialer {
+	return &resolveDialer{
 		dialer,
 		parallel,
 		router,
@@ -32,7 +38,25 @@ func NewResolveDialer(router adapter.Router, dialer N.Dialer, parallel bool, str
 	}
 }
 
-func (d *ResolveDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
+type resolveParallelNetworkDialer struct {
+	resolveDialer
+	dialer ParallelInterfaceDialer
+}
+
+func NewResolveParallelInterfaceDialer(router adapter.Router, dialer ParallelInterfaceDialer, parallel bool, strategy dns.DomainStrategy, fallbackDelay time.Duration) ParallelInterfaceDialer {
+	return &resolveParallelNetworkDialer{
+		resolveDialer{
+			dialer,
+			parallel,
+			router,
+			strategy,
+			fallbackDelay,
+		},
+		dialer,
+	}
+}
+
+func (d *resolveDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
 	if !destination.IsFqdn() {
 		return d.dialer.DialContext(ctx, network, destination)
 	}
@@ -57,7 +81,7 @@ func (d *ResolveDialer) DialContext(ctx context.Context, network string, destina
 	}
 }
 
-func (d *ResolveDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+func (d *resolveDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
 	if !destination.IsFqdn() {
 		return d.dialer.ListenPacket(ctx, destination)
 	}
@@ -82,6 +106,59 @@ func (d *ResolveDialer) ListenPacket(ctx context.Context, destination M.Socksadd
 	return bufio.NewNATPacketConn(bufio.NewPacketConn(conn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil
 }
 
-func (d *ResolveDialer) Upstream() any {
+func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error) {
+	if !destination.IsFqdn() {
+		return d.dialer.DialContext(ctx, network, destination)
+	}
+	ctx, metadata := adapter.ExtendContext(ctx)
+	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
+	metadata.Destination = destination
+	metadata.Domain = ""
+	var addresses []netip.Addr
+	var err error
+	if d.strategy == dns.DomainStrategyAsIS {
+		addresses, err = d.router.LookupDefault(ctx, destination.Fqdn)
+	} else {
+		addresses, err = d.router.Lookup(ctx, destination.Fqdn, d.strategy)
+	}
+	if err != nil {
+		return nil, err
+	}
+	if fallbackDelay == 0 {
+		fallbackDelay = d.fallbackDelay
+	}
+	if d.parallel {
+		return DialParallelNetwork(ctx, d.dialer, network, destination, addresses, d.strategy == dns.DomainStrategyPreferIPv6, strategy, fallbackDelay)
+	} else {
+		return DialSerialNetwork(ctx, d.dialer, network, destination, addresses, strategy, fallbackDelay)
+	}
+}
+
+func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, error) {
+	if !destination.IsFqdn() {
+		return d.dialer.ListenPacket(ctx, destination)
+	}
+	ctx, metadata := adapter.ExtendContext(ctx)
+	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
+	metadata.Destination = destination
+	metadata.Domain = ""
+	var addresses []netip.Addr
+	var err error
+	if d.strategy == dns.DomainStrategyAsIS {
+		addresses, err = d.router.LookupDefault(ctx, destination.Fqdn)
+	} else {
+		addresses, err = d.router.Lookup(ctx, destination.Fqdn, d.strategy)
+	}
+	if err != nil {
+		return nil, err
+	}
+	conn, destinationAddress, err := ListenSerialNetworkPacket(ctx, d.dialer, destination, addresses, strategy, fallbackDelay)
+	if err != nil {
+		return nil, err
+	}
+	return bufio.NewNATPacketConn(bufio.NewPacketConn(conn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil
+}
+
+func (d *resolveDialer) Upstream() any {
 	return d.dialer
 }

+ 4 - 6
common/settings/proxy_darwin.go

@@ -7,6 +7,7 @@ import (
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-tun"
+	"github.com/sagernet/sing/common/control"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 	"github.com/sagernet/sing/common/shell"
@@ -33,7 +34,7 @@ func NewSystemProxy(ctx context.Context, serverAddr M.Socksaddr, supportSOCKS bo
 		serverAddr:   serverAddr,
 		supportSOCKS: supportSOCKS,
 	}
-	proxy.element = interfaceMonitor.RegisterCallback(proxy.update)
+	proxy.element = interfaceMonitor.RegisterCallback(proxy.routeUpdate)
 	return proxy, nil
 }
 
@@ -65,11 +66,8 @@ func (p *DarwinSystemProxy) Disable() error {
 	return err
 }
 
-func (p *DarwinSystemProxy) update(event int) {
-	if event&tun.EventInterfaceUpdate == 0 {
-		return
-	}
-	if !p.isEnabled {
+func (p *DarwinSystemProxy) routeUpdate(defaultInterface *control.Interface, flags int) {
+	if !p.isEnabled || defaultInterface == nil {
 		return
 	}
 	_ = p.update0()

+ 42 - 0
constant/network.go

@@ -1,8 +1,50 @@
 package constant
 
+import (
+	"github.com/sagernet/sing/common"
+	F "github.com/sagernet/sing/common/format"
+)
+
 const (
 	InterfaceTypeWIFI     = "wifi"
 	InterfaceTypeCellular = "cellular"
 	InterfaceTypeEthernet = "ethernet"
 	InterfaceTypeOther    = "other"
 )
+
+type NetworkStrategy int
+
+const (
+	NetworkStrategyDefault NetworkStrategy = iota
+	NetworkStrategyFallback
+	NetworkStrategyHybrid
+	NetworkStrategyWIFI
+	NetworkStrategyCellular
+	NetworkStrategyEthernet
+	NetworkStrategyWIFIOnly
+	NetworkStrategyCellularOnly
+	NetworkStrategyEthernetOnly
+)
+
+var (
+	NetworkStrategyToString = map[NetworkStrategy]string{
+		NetworkStrategyDefault:      "default",
+		NetworkStrategyFallback:     "fallback",
+		NetworkStrategyHybrid:       "hybrid",
+		NetworkStrategyWIFI:         "wifi",
+		NetworkStrategyCellular:     "cellular",
+		NetworkStrategyEthernet:     "ethernet",
+		NetworkStrategyWIFIOnly:     "wifi_only",
+		NetworkStrategyCellularOnly: "cellular_only",
+		NetworkStrategyEthernetOnly: "ethernet_only",
+	}
+	StringToNetworkStrategy = common.ReverseMap(NetworkStrategyToString)
+)
+
+func (s NetworkStrategy) String() string {
+	name, loaded := NetworkStrategyToString[s]
+	if !loaded {
+		return F.ToString(int(s))
+	}
+	return name
+}

+ 40 - 5
docs/configuration/route/index.md

@@ -1,5 +1,14 @@
+---
+icon: material/new-box
+---
+
 # Route
 
+!!! quote "Changes in sing-box 1.11.0"
+
+    :material-plus: [default_network_strategy](#default_network_strategy)  
+    :material-alert: [default_fallback_delay](#default_fallback_delay)
+
 !!! quote "Changes in sing-box 1.8.0"
 
     :material-plus: [rule_set](#rule_set)  
@@ -18,16 +27,18 @@
     "final": "",
     "auto_detect_interface": false,
     "override_android_vpn": false,
-    "default_interface": "en0",
-    "default_mark": 233
+    "default_interface": "",
+    "default_mark": 0,
+    "default_network_strategy": "",
+    "default_fallback_delay": ""
   }
 }
 ```
 
 ### Fields
 
-| Key       | Format               |
-|-----------|----------------------|
+| Key       | Format                |
+|-----------|-----------------------|
 | `geoip`   | [GeoIP](./geoip/)     |
 | `geosite` | [Geosite](./geosite/) |
 
@@ -81,4 +92,28 @@ Takes no effect if `auto_detect_interface` is set.
 
 Set routing mark by default.
 
-Takes no effect if `outbound.routing_mark` is set.
+Takes no effect if `outbound.routing_mark` is set.
+
+#### default_network_strategy
+
+!!! quote ""
+
+    Only supported in graphical clients on Android and iOS with `auto_detect_interface` enabled.
+
+Strategy for selecting network interfaces.
+
+Takes no effect if `outbound.bind_interface`, `outbound.inet4_bind_address` or `outbound.inet6_bind_address` is set.
+
+Can be overrides by `outbound.network_strategy`.
+
+Conflicts with `default_interface`.
+
+See [Dial Fields](/configuration/shared/dial/#network_strategy) for available values.
+
+#### default_fallback_delay
+
+!!! quote ""
+
+    Only supported in graphical clients on Android and iOS with `auto_detect_interface` enabled and `network_strategy` set.
+
+See [Dial Fields](/configuration/shared/dial/#fallback_delay) for details.

+ 28 - 2
docs/configuration/route/index.zh.md

@@ -18,8 +18,10 @@
     "final": "",
     "auto_detect_interface": false,
     "override_android_vpn": false,
-    "default_interface": "en0",
-    "default_mark": 233
+    "default_interface": "",
+    "default_mark": 0,
+    "default_network_strategy": "",
+    "default_fallback_delay": ""
   }
 }
 ```
@@ -82,3 +84,27 @@
 默认为出站连接设置路由标记。
 
 如果设置了 `outbound.routing_mark` 设置,则不生效。
+
+#### network_strategy
+
+!!! quote ""
+
+    仅在 Android 与 Apple 平台图形客户端中支持,并且需要 `auto_detect_interface`。
+
+选择网络接口的策略。
+
+当 `outbound.bind_interface`, `outbound.inet4_bind_address` 或 `outbound.inet6_bind_address` 已设置时不生效。
+
+可以被 `outbound.network_strategy` 覆盖。
+
+与 `default_interface` 冲突。
+
+可用值请参阅 [拨号字段](/configuration/shared/dial/#network_strategy)。
+
+#### fallback_delay
+
+!!! quote ""
+
+    仅在 Android 与 Apple 平台图形客户端中支持,并且需要 `auto_detect_interface` 且 `network_strategy` 已设置。
+
+详情请参阅 [拨号字段](/configuration/shared/dial/#fallback_delay)。

+ 25 - 4
docs/configuration/route/rule_action.md

@@ -2,10 +2,6 @@
 icon: material/new-box
 ---
 
-# Rule Action
-
-!!! question "Since sing-box 1.11.0"
-
 ## Final actions
 
 ### route
@@ -14,6 +10,8 @@ icon: material/new-box
 {
   "action": "route", // default
   "outbound": "",
+  "network_strategy": "",
+  "fallback_delay": "",
   "udp_disable_domain_unmapping": false,
   "udp_connect": false
 }
@@ -27,6 +25,27 @@ icon: material/new-box
 
 Tag of target outbound.
 
+#### network_strategy
+
+!!! quote ""
+
+    Only supported in graphical clients on Android and iOS with `auto_detect_interface` enabled.
+
+Strategy for selecting network interfaces.
+
+Only take effect if outbound is direct without `outbound.bind_interface`,
+`outbound.inet4_bind_address` and `outbound.inet6_bind_address` set.
+
+See [Dial Fields](/configuration/shared/dial/#network_strategy) for available values.
+
+#### fallback_delay
+
+!!! quote ""
+
+    Only supported in graphical clients on Android and iOS with `auto_detect_interface` enabled and `network_strategy` set.
+
+See [Dial Fields](/configuration/shared/dial/#fallback_delay) for details.
+
 #### udp_disable_domain_unmapping
 
 If enabled, for UDP proxy requests addressed to a domain,
@@ -44,6 +63,8 @@ If enabled, attempts to connect UDP connection to the destination instead of lis
 ```json
 {
   "action": "route-options",
+  "network_strategy": "",
+  "fallback_delay": "",
   "udp_disable_domain_unmapping": false,
   "udp_connect": false
 }

+ 25 - 4
docs/configuration/route/rule_action.zh.md

@@ -2,10 +2,6 @@
 icon: material/new-box
 ---
 
-# 规则动作
-
-!!! question "自 sing-box 1.11.0 起"
-
 ## 最终动作
 
 ### route
@@ -14,6 +10,8 @@ icon: material/new-box
 {
   "action": "route", // 默认
   "outbound": "",
+  "network_strategy": "",
+  "fallback_delay": "",
   "udp_disable_domain_unmapping": false,
   "udp_connect": false
 }
@@ -27,6 +25,27 @@ icon: material/new-box
 
 目标出站的标签。
 
+#### network_strategy
+
+!!! quote ""
+
+    仅在 Android 与 Apple 平台图形客户端中支持,并且需要 `auto_detect_interface`。
+
+选择网络接口的策略。
+
+仅当出站为 `direct` 且 `outbound.bind_interface`, `outbound.inet4_bind_address`
+且 `outbound.inet6_bind_address` 未设置时生效。
+
+可用值参阅 [拨号字段](/configuration/shared/dial/#network_strategy)。
+
+#### fallback_delay
+
+!!! quote ""
+
+    仅在 Android 与 Apple 平台图形客户端中支持,并且需要 `auto_detect_interface` 且 `network_strategy` 已设置。
+
+详情参阅 [拨号字段](/configuration/shared/dial/#fallback_delay)。
+
 #### udp_disable_domain_unmapping
 
 如果启用,对于地址为域的 UDP 代理请求,将在响应中发送原始包地址而不是映射的域。
@@ -42,6 +61,8 @@ icon: material/new-box
 ```json
 {
   "action": "route-options",
+  "network_strategy": "",
+  "fallback_delay": "",
   "udp_disable_domain_unmapping": false,
   "udp_connect": false
 }

+ 49 - 8
docs/configuration/shared/dial.md

@@ -1,3 +1,12 @@
+---
+icon: material/new-box
+---
+
+!!! quote "Changes in sing-box 1.11.0"
+
+    :material-plus: [network_strategy](#network_strategy)  
+    :material-alert: [fallback_delay](#fallback_delay)
+
 ### Structure
 
 ```json
@@ -13,20 +22,19 @@
   "tcp_multi_path": false,
   "udp_fragment": false,
   "domain_strategy": "prefer_ipv6",
+  "network_strategy": "default",
   "fallback_delay": "300ms"
 }
 ```
 
 ### Fields
 
-| Field                                                                                                                                    | Available Context |
-|------------------------------------------------------------------------------------------------------------------------------------------|-------------------|
-| `bind_interface` /`*bind_address` /`routing_mark` /`reuse_addr` / `tcp_fast_open` / `tcp_multi_path` / `udp_fragment` /`connect_timeout` | `detour` not set  |
-
 #### detour
 
 The tag of the upstream outbound.
 
+If enabled, all other fields will be ignored.
+
 #### bind_interface
 
 The network interface to bind to.
@@ -78,7 +86,7 @@ Valid time units are "ns", "us" (or "µs"), "ms", "s", "m", "h".
 
 #### domain_strategy
 
-One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`.
+Available values: `prefer_ipv4`, `prefer_ipv6`, `ipv4_only`, `ipv6_only`.
 
 If set, the requested domain name will be resolved to IP before connect.
 
@@ -87,11 +95,44 @@ If set, the requested domain name will be resolved to IP before connect.
 | `direct` | Domain in request        | Take `inbound.domain_strategy` if not set | 
 | others   | Domain in server address | /                                         |
 
+#### network_strategy
+
+!!! question "Since sing-box 1.11.0"
+
+!!! quote ""
+
+    Only supported in graphical clients on Android and iOS with `auto_detect_interface` enabled.
+
+Strategy for selecting network interfaces.
+
+Available values:
+
+- `default` (default): Connect to the default interface.
+- `fallback`: Try all other interfaces when timeout.
+- `hybrid`: Connect to all interfaces concurrently and choose the fastest one.
+- `wifi`:  Prioritize WIFI, but try all other interfaces when unavailable or timeout.
+- `cellular`: Prioritize Cellular, but try all other interfaces when unavailable or timeout.
+- `ethernet`: Prioritize Ethernet, but try all other interfaces when unavailable or timeout.
+- `wifi_only`: Connect to WIFI only.
+- `cellular_only`: Connect to Cellular only.
+- `ethernet_only`: Connect to Ethernet only.
+
+For fallback strategies, when preferred interfaces fails or times out,
+it will enter a 15s fast fallback state (upgraded to `hybrid`),
+and exit immediately if recovers.
+
+Conflicts with `bind_interface`, `inet4_bind_address` and `inet6_bind_address`.
+
 #### fallback_delay
 
 The length of time to wait before spawning a RFC 6555 Fast Fallback connection.
-That is, is the amount of time to wait for connection to succeed before assuming
+
+For `domain_strategy`, is the amount of time to wait for connection to succeed before assuming
 that IPv4/IPv6 is misconfigured and falling back to other type of addresses.
-If zero, a default delay of 300ms is used.
 
-Only take effect when `domain_strategy` is set.
+For `network_strategy`, is the amount of time to wait for connection to succeed before falling
+back to other interfaces.
+
+Only take effect when `domain_strategy` or `network_strategy` is set.
+
+`300ms` is used by default.

+ 47 - 12
docs/configuration/shared/dial.zh.md

@@ -1,3 +1,12 @@
+---
+icon: material/new-box
+---
+
+!!! quote "sing-box 1.11.0 中的更改"
+
+    :material-plus: [network_strategy](#network_strategy)  
+    :material-alert: [fallback_delay](#fallback_delay)
+
 ### 结构
 
 ```json
@@ -13,17 +22,13 @@
   "tcp_multi_path": false,
   "udp_fragment": false,
   "domain_strategy": "prefer_ipv6",
+  "network_strategy": "",
   "fallback_delay": "300ms"
 }
 ```
 
 ### 字段
 
-| 字段                                                                                                                                       | 可用上下文        |
-|------------------------------------------------------------------------------------------------------------------------------------------|--------------|
-| `bind_interface` /`*bind_address` /`routing_mark` /`reuse_addr` / `tcp_fast_open` / `tcp_mutli_path` / `udp_fragment` /`connect_timeout` | `detour` 未设置 |
-
-
 #### detour
 
 上游出站的标签。
@@ -83,15 +88,45 @@
 
 如果设置,域名将在请求发出之前解析为 IP。
 
-| 出站     | 受影响的域名             | 默认回退值                                |
-|----------|--------------------------|-------------------------------------------|
-| `direct` | 请求中的域名              | `inbound.domain_strategy`                 | 
-|  others  | 服务器地址中的域名        | /                                         |
+| 出站       | 受影响的域名    | 默认回退值                     |
+|----------|-----------|---------------------------|
+| `direct` | 请求中的域名    | `inbound.domain_strategy` | 
+| others   | 服务器地址中的域名 | /                         |
+
+#### network_strategy
+
+!!! question "自 sing-box 1.11.0 起"
+
+!!! quote ""
+
+    仅在 Android 与 iOS 平台图形客户端中支持。
+
+用于选择网络接口的策略。
+
+可用值:
+
+- `default` (默认): 连接到默认接口,
+- `fallback`: 如果超时,尝试所有剩余接口。
+- `hybrid`: 同时尝试所有接口,选择最快的一个。
+- `wifi`:  优先使用 WIFI,但在不可用或超时时尝试所有其他接口。
+- `cellular`: 优先使用蜂窝数据,但在不可用或超时时尝试所有其他接口。
+- `ethernet`: 优先使用以太网,但在不可用或超时时尝试所有其他接口。
+- `wifi_only`: 仅连接到 WIFI。
+- `cellular_only`: 仅连接到蜂窝数据。
+- `ethernet_only`: 仅连接到以太网。
+
+对于回退策略, 当优先使用的接口发生故障或超时时, 将进入 15 秒的快速回退状态(升级为 `hybrid`), 且恢复后立即退出。
+
+与 `bind_interface`, `bind_inet4_address` 和 `bind_inet6_address` 冲突。
 
 #### fallback_delay
 
 在生成 RFC 6555 快速回退连接之前等待的时间长度。
-也就是说,是在假设之前等待 IPv6 成功的时间量如果设置了 "prefer_ipv4",则 IPv6 配置错误并回退到 IPv4。
-如果为零,则使用 300 毫秒的默认延迟。
 
-仅当 `domain_strategy` 为 `prefer_ipv4` 或 `prefer_ipv6` 时生效。
+对于 `domain_strategy`,是在假设之前等待 IPv6 成功的时间量如果设置了 "prefer_ipv4",则 IPv6 配置错误并回退到 IPv4。
+
+对于 `network_strategy`,对于 `network_strategy`,是在回退到其他接口之前等待连接成功的时间。
+
+仅当 `domain_strategy` 或 `network_strategy` 已设置时生效。
+
+默认使用 `300ms`。

+ 2 - 2
experimental/libbox/monitor.go

@@ -75,7 +75,7 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s
 		callbacks := m.callbacks.Array()
 		m.defaultInterfaceAccess.Unlock()
 		for _, callback := range callbacks {
-			callback(tun.EventInterfaceUpdate)
+			callback(nil, 0)
 		}
 		return
 	}
@@ -94,6 +94,6 @@ func (m *platformDefaultInterfaceMonitor) updateDefaultInterface(interfaceName s
 	callbacks := m.callbacks.Array()
 	m.defaultInterfaceAccess.Unlock()
 	for _, callback := range callbacks {
-		callback(tun.EventInterfaceUpdate)
+		callback(newInterface, 0)
 	}
 }

+ 17 - 15
option/outbound.go

@@ -65,21 +65,23 @@ type DialerOptionsWrapper interface {
 }
 
 type DialerOptions struct {
-	Detour              string             `json:"detour,omitempty"`
-	BindInterface       string             `json:"bind_interface,omitempty"`
-	Inet4BindAddress    *badoption.Addr    `json:"inet4_bind_address,omitempty"`
-	Inet6BindAddress    *badoption.Addr    `json:"inet6_bind_address,omitempty"`
-	ProtectPath         string             `json:"protect_path,omitempty"`
-	RoutingMark         uint32             `json:"routing_mark,omitempty"`
-	ReuseAddr           bool               `json:"reuse_addr,omitempty"`
-	ConnectTimeout      badoption.Duration `json:"connect_timeout,omitempty"`
-	TCPFastOpen         bool               `json:"tcp_fast_open,omitempty"`
-	TCPMultiPath        bool               `json:"tcp_multi_path,omitempty"`
-	UDPFragment         *bool              `json:"udp_fragment,omitempty"`
-	UDPFragmentDefault  bool               `json:"-"`
-	DomainStrategy      DomainStrategy     `json:"domain_strategy,omitempty"`
-	FallbackDelay       badoption.Duration `json:"fallback_delay,omitempty"`
-	IsWireGuardListener bool               `json:"-"`
+	Detour               string             `json:"detour,omitempty"`
+	BindInterface        string             `json:"bind_interface,omitempty"`
+	Inet4BindAddress     *badoption.Addr    `json:"inet4_bind_address,omitempty"`
+	Inet6BindAddress     *badoption.Addr    `json:"inet6_bind_address,omitempty"`
+	ProtectPath          string             `json:"protect_path,omitempty"`
+	RoutingMark          uint32             `json:"routing_mark,omitempty"`
+	ReuseAddr            bool               `json:"reuse_addr,omitempty"`
+	ConnectTimeout       badoption.Duration `json:"connect_timeout,omitempty"`
+	TCPFastOpen          bool               `json:"tcp_fast_open,omitempty"`
+	TCPMultiPath         bool               `json:"tcp_multi_path,omitempty"`
+	UDPFragment          *bool              `json:"udp_fragment,omitempty"`
+	UDPFragmentDefault   bool               `json:"-"`
+	DomainStrategy       DomainStrategy     `json:"domain_strategy,omitempty"`
+	NetworkStrategy      NetworkStrategy    `json:"network_strategy,omitempty"`
+	FallbackDelay        badoption.Duration `json:"fallback_delay,omitempty"`
+	NetworkFallbackDelay badoption.Duration `json:"network_fallback_delay,omitempty"`
+	IsWireGuardListener  bool               `json:"-"`
 }
 
 func (o *DialerOptions) TakeDialerOptions() DialerOptions {

+ 14 - 10
option/route.go

@@ -1,16 +1,20 @@
 package option
 
+import "github.com/sagernet/sing/common/json/badoption"
+
 type RouteOptions struct {
-	GeoIP               *GeoIPOptions   `json:"geoip,omitempty"`
-	Geosite             *GeositeOptions `json:"geosite,omitempty"`
-	Rules               []Rule          `json:"rules,omitempty"`
-	RuleSet             []RuleSet       `json:"rule_set,omitempty"`
-	Final               string          `json:"final,omitempty"`
-	FindProcess         bool            `json:"find_process,omitempty"`
-	AutoDetectInterface bool            `json:"auto_detect_interface,omitempty"`
-	OverrideAndroidVPN  bool            `json:"override_android_vpn,omitempty"`
-	DefaultInterface    string          `json:"default_interface,omitempty"`
-	DefaultMark         uint32          `json:"default_mark,omitempty"`
+	GeoIP                  *GeoIPOptions      `json:"geoip,omitempty"`
+	Geosite                *GeositeOptions    `json:"geosite,omitempty"`
+	Rules                  []Rule             `json:"rules,omitempty"`
+	RuleSet                []RuleSet          `json:"rule_set,omitempty"`
+	Final                  string             `json:"final,omitempty"`
+	FindProcess            bool               `json:"find_process,omitempty"`
+	AutoDetectInterface    bool               `json:"auto_detect_interface,omitempty"`
+	OverrideAndroidVPN     bool               `json:"override_android_vpn,omitempty"`
+	DefaultInterface       string             `json:"default_interface,omitempty"`
+	DefaultMark            uint32             `json:"default_mark,omitempty"`
+	DefaultNetworkStrategy NetworkStrategy    `json:"default_network_strategy,omitempty"`
+	DefaultFallbackDelay   badoption.Duration `json:"default_fallback_delay,omitempty"`
 }
 
 type GeoIPOptions struct {

+ 9 - 5
option/rule_action.go

@@ -137,14 +137,18 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e
 }
 
 type RouteActionOptions struct {
-	Outbound                  string `json:"outbound,omitempty"`
-	UDPDisableDomainUnmapping bool   `json:"udp_disable_domain_unmapping,omitempty"`
-	UDPConnect                bool   `json:"udp_connect,omitempty"`
+	Outbound                  string          `json:"outbound,omitempty"`
+	NetworkStrategy           NetworkStrategy `json:"network_strategy,omitempty"`
+	FallbackDelay             uint32          `json:"fallback_delay,omitempty"`
+	UDPDisableDomainUnmapping bool            `json:"udp_disable_domain_unmapping,omitempty"`
+	UDPConnect                bool            `json:"udp_connect,omitempty"`
 }
 
 type _RouteOptionsActionOptions struct {
-	UDPDisableDomainUnmapping bool `json:"udp_disable_domain_unmapping,omitempty"`
-	UDPConnect                bool `json:"udp_connect,omitempty"`
+	NetworkStrategy           NetworkStrategy `json:"network_strategy,omitempty"`
+	FallbackDelay             uint32          `json:"fallback_delay,omitempty"`
+	UDPDisableDomainUnmapping bool            `json:"udp_disable_domain_unmapping,omitempty"`
+	UDPConnect                bool            `json:"udp_connect,omitempty"`
 }
 
 type RouteOptionsActionOptions _RouteOptionsActionOptions

+ 21 - 0
option/types.go

@@ -3,6 +3,7 @@ package option
 import (
 	"strings"
 
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-dns"
 	E "github.com/sagernet/sing/common/exceptions"
 	F "github.com/sagernet/sing/common/format"
@@ -150,3 +151,23 @@ func DNSQueryTypeToString(queryType uint16) string {
 	}
 	return F.ToString(queryType)
 }
+
+type NetworkStrategy C.NetworkStrategy
+
+func (n NetworkStrategy) MarshalJSON() ([]byte, error) {
+	return json.Marshal(C.NetworkStrategy(n).String())
+}
+
+func (n *NetworkStrategy) UnmarshalJSON(content []byte) error {
+	var value string
+	err := json.Unmarshal(content, &value)
+	if err != nil {
+		return err
+	}
+	strategy, loaded := C.StringToNetworkStrategy[value]
+	if !loaded {
+		return E.New("unknown network strategy: ", value)
+	}
+	*n = NetworkStrategy(strategy)
+	return nil
+}

+ 108 - 23
protocol/direct/outbound.go

@@ -13,6 +13,7 @@ import (
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	dns "github.com/sagernet/sing-dns"
+	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/bufio"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/logger"
@@ -24,31 +25,38 @@ func RegisterOutbound(registry *outbound.Registry) {
 	outbound.Register[option.DirectOutboundOptions](registry, C.TypeDirect, NewOutbound)
 }
 
-var _ N.ParallelDialer = (*Outbound)(nil)
+var (
+	_ N.ParallelDialer             = (*Outbound)(nil)
+	_ dialer.ParallelNetworkDialer = (*Outbound)(nil)
+)
 
 type Outbound struct {
 	outbound.Adapter
-	logger              logger.ContextLogger
-	dialer              N.Dialer
-	domainStrategy      dns.DomainStrategy
-	fallbackDelay       time.Duration
-	overrideOption      int
-	overrideDestination M.Socksaddr
+	logger               logger.ContextLogger
+	dialer               dialer.ParallelInterfaceDialer
+	domainStrategy       dns.DomainStrategy
+	fallbackDelay        time.Duration
+	networkStrategy      C.NetworkStrategy
+	networkFallbackDelay time.Duration
+	overrideOption       int
+	overrideDestination  M.Socksaddr
 	// loopBack *loopBackDetector
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.DirectOutboundOptions) (adapter.Outbound, error) {
 	options.UDPFragmentDefault = true
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.NewDirect(ctx, options.DialerOptions)
 	if err != nil {
 		return nil, err
 	}
 	outbound := &Outbound{
-		Adapter:        outbound.NewAdapterWithDialerOptions(C.TypeDirect, []string{N.NetworkTCP, N.NetworkUDP}, tag, options.DialerOptions),
-		logger:         logger,
-		domainStrategy: dns.DomainStrategy(options.DomainStrategy),
-		fallbackDelay:  time.Duration(options.FallbackDelay),
-		dialer:         outboundDialer,
+		Adapter:              outbound.NewAdapterWithDialerOptions(C.TypeDirect, []string{N.NetworkTCP, N.NetworkUDP}, tag, options.DialerOptions),
+		logger:               logger,
+		domainStrategy:       dns.DomainStrategy(options.DomainStrategy),
+		fallbackDelay:        time.Duration(options.FallbackDelay),
+		networkStrategy:      C.NetworkStrategy(options.NetworkStrategy),
+		networkFallbackDelay: time.Duration(options.NetworkFallbackDelay),
+		dialer:               outboundDialer,
 		// loopBack:       newLoopBackDetector(router),
 	}
 	if options.ProxyProtocol != 0 {
@@ -96,6 +104,37 @@ func (h *Outbound) DialContext(ctx context.Context, network string, destination
 	return h.dialer.DialContext(ctx, network, destination)
 }
 
+func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+	ctx, metadata := adapter.ExtendContext(ctx)
+	metadata.Outbound = h.Tag()
+	metadata.Destination = destination
+	originDestination := destination
+	switch h.overrideOption {
+	case 1:
+		destination = h.overrideDestination
+	case 2:
+		newDestination := h.overrideDestination
+		newDestination.Port = destination.Port
+		destination = newDestination
+	case 3:
+		destination.Port = h.overrideDestination.Port
+	}
+	if h.overrideOption == 0 {
+		h.logger.InfoContext(ctx, "outbound packet connection")
+	} else {
+		h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
+	}
+	conn, err := h.dialer.ListenPacket(ctx, destination)
+	if err != nil {
+		return nil, err
+	}
+	// conn = h.loopBack.NewPacketConn(bufio.NewPacketConn(conn), destination)
+	if originDestination != destination {
+		conn = bufio.NewNATPacketConn(bufio.NewPacketConn(conn), destination, originDestination)
+	}
+	return conn, nil
+}
+
 func (h *Outbound) DialParallel(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr) (net.Conn, error) {
 	ctx, metadata := adapter.ExtendContext(ctx)
 	metadata.Outbound = h.Tag()
@@ -120,14 +159,64 @@ func (h *Outbound) DialParallel(ctx context.Context, network string, destination
 	} else {
 		domainStrategy = dns.DomainStrategy(metadata.InboundOptions.DomainStrategy)
 	}
-	return N.DialParallel(ctx, h.dialer, network, destination, destinationAddresses, domainStrategy == dns.DomainStrategyPreferIPv6, h.fallbackDelay)
+	switch domainStrategy {
+	case dns.DomainStrategyUseIPv4:
+		destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4)
+		if len(destinationAddresses) == 0 {
+			return nil, E.New("no IPv4 address available for ", destination)
+		}
+	case dns.DomainStrategyUseIPv6:
+		destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6)
+		if len(destinationAddresses) == 0 {
+			return nil, E.New("no IPv6 address available for ", destination)
+		}
+	}
+	return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, domainStrategy == dns.DomainStrategyPreferIPv6, h.networkStrategy, h.fallbackDelay)
 }
 
-func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+func (h *Outbound) DialParallelNetwork(ctx context.Context, network string, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy C.NetworkStrategy, fallbackDelay time.Duration) (net.Conn, error) {
+	ctx, metadata := adapter.ExtendContext(ctx)
+	metadata.Outbound = h.Tag()
+	metadata.Destination = destination
+	switch h.overrideOption {
+	case 1, 2:
+		// override address
+		return h.DialContext(ctx, network, destination)
+	case 3:
+		destination.Port = h.overrideDestination.Port
+	}
+	network = N.NetworkName(network)
+	switch network {
+	case N.NetworkTCP:
+		h.logger.InfoContext(ctx, "outbound connection to ", destination)
+	case N.NetworkUDP:
+		h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
+	}
+	var domainStrategy dns.DomainStrategy
+	if h.domainStrategy != dns.DomainStrategyAsIS {
+		domainStrategy = h.domainStrategy
+	} else {
+		domainStrategy = dns.DomainStrategy(metadata.InboundOptions.DomainStrategy)
+	}
+	switch domainStrategy {
+	case dns.DomainStrategyUseIPv4:
+		destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is4)
+		if len(destinationAddresses) == 0 {
+			return nil, E.New("no IPv4 address available for ", destination)
+		}
+	case dns.DomainStrategyUseIPv6:
+		destinationAddresses = common.Filter(destinationAddresses, netip.Addr.Is6)
+		if len(destinationAddresses) == 0 {
+			return nil, E.New("no IPv6 address available for ", destination)
+		}
+	}
+	return dialer.DialParallelNetwork(ctx, h.dialer, network, destination, destinationAddresses, domainStrategy == dns.DomainStrategyPreferIPv6, networkStrategy, fallbackDelay)
+}
+
+func (h *Outbound) ListenSerialNetworkPacket(ctx context.Context, destination M.Socksaddr, destinationAddresses []netip.Addr, networkStrategy C.NetworkStrategy, fallbackDelay time.Duration) (net.PacketConn, netip.Addr, error) {
 	ctx, metadata := adapter.ExtendContext(ctx)
 	metadata.Outbound = h.Tag()
 	metadata.Destination = destination
-	originDestination := destination
 	switch h.overrideOption {
 	case 1:
 		destination = h.overrideDestination
@@ -143,15 +232,11 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
 	} else {
 		h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
 	}
-	conn, err := h.dialer.ListenPacket(ctx, destination)
+	conn, newDestination, err := dialer.ListenSerialNetworkPacket(ctx, h.dialer, destination, destinationAddresses, networkStrategy, fallbackDelay)
 	if err != nil {
-		return nil, err
-	}
-	// conn = h.loopBack.NewPacketConn(bufio.NewPacketConn(conn), destination)
-	if originDestination != destination {
-		conn = bufio.NewNATPacketConn(bufio.NewPacketConn(conn), destination, originDestination)
+		return nil, netip.Addr{}, err
 	}
-	return conn, nil
+	return conn, newDestination, nil
 }
 
 /*func (h *Outbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {

+ 0 - 21
protocol/socks/outbound.go

@@ -10,7 +10,6 @@ import (
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
-	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing/common"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/logger"
@@ -115,23 +114,3 @@ func (h *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
 	h.logger.InfoContext(ctx, "outbound packet connection to ", destination)
 	return h.client.ListenPacket(ctx, destination)
 }
-
-// TODO
-// Deprecated
-func (h *Outbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
-	if h.resolve {
-		return outbound.NewDirectConnection(ctx, h.router, h, conn, metadata, dns.DomainStrategyUseIPv4)
-	} else {
-		return outbound.NewConnection(ctx, h, conn, metadata)
-	}
-}
-
-// TODO
-// Deprecated
-func (h *Outbound) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
-	if h.resolve {
-		return outbound.NewDirectPacketConnection(ctx, h.router, h, conn, metadata, dns.DomainStrategyUseIPv4)
-	} else {
-		return outbound.NewPacketConnection(ctx, h, conn, metadata)
-	}
-}

+ 0 - 13
protocol/wireguard/outbound.go

@@ -16,7 +16,6 @@ import (
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	"github.com/sagernet/sing-box/transport/wireguard"
-	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing/common"
 	E "github.com/sagernet/sing/common/exceptions"
@@ -238,15 +237,3 @@ func (w *Outbound) ListenPacket(ctx context.Context, destination M.Socksaddr) (n
 	}
 	return w.tunDevice.ListenPacket(ctx, destination)
 }
-
-// TODO
-// Deprecated
-func (w *Outbound) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
-	return outbound.NewDirectConnection(ctx, w.router, w, conn, metadata, dns.DomainStrategyAsIS)
-}
-
-// TODO
-// Deprecated
-func (w *Outbound) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
-	return outbound.NewDirectPacketConnection(ctx, w.router, w, conn, metadata, dns.DomainStrategyAsIS)
-}

+ 71 - 50
route/network.go

@@ -8,6 +8,7 @@ import (
 	"runtime"
 	"strings"
 	"syscall"
+	"time"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/conntrack"
@@ -38,8 +39,7 @@ type NetworkManager struct {
 	networkInterfaces atomic.TypedValue[[]adapter.NetworkInterface]
 
 	autoDetectInterface    bool
-	defaultInterface       string
-	defaultMark            uint32
+	defaultOptions         adapter.NetworkOptions
 	autoRedirectOutputMark uint32
 
 	networkMonitor    tun.NetworkUpdateMonitor
@@ -58,11 +58,23 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
 		logger:              logger,
 		interfaceFinder:     control.NewDefaultInterfaceFinder(),
 		autoDetectInterface: routeOptions.AutoDetectInterface,
-		defaultInterface:    routeOptions.DefaultInterface,
-		defaultMark:         routeOptions.DefaultMark,
-		pauseManager:        service.FromContext[pause.Manager](ctx),
-		platformInterface:   service.FromContext[platform.Interface](ctx),
-		outboundManager:     service.FromContext[adapter.OutboundManager](ctx),
+		defaultOptions: adapter.NetworkOptions{
+			DefaultInterface:       routeOptions.DefaultInterface,
+			DefaultMark:            routeOptions.DefaultMark,
+			DefaultNetworkStrategy: C.NetworkStrategy(routeOptions.DefaultNetworkStrategy),
+			DefaultFallbackDelay:   time.Duration(routeOptions.DefaultFallbackDelay),
+		},
+		pauseManager:      service.FromContext[pause.Manager](ctx),
+		platformInterface: service.FromContext[platform.Interface](ctx),
+		outboundManager:   service.FromContext[adapter.OutboundManager](ctx),
+	}
+	if C.NetworkStrategy(routeOptions.DefaultNetworkStrategy) != C.NetworkStrategyDefault {
+		if routeOptions.DefaultInterface != "" {
+			return nil, E.New("`default_network_strategy` is conflict with `default_interface`")
+		}
+		if !routeOptions.AutoDetectInterface {
+			return nil, E.New("`auto_detect_interface` is required by `default_network_strategy`")
+		}
 	}
 	usePlatformDefaultInterfaceMonitor := nm.platformInterface != nil
 	enforceInterfaceMonitor := routeOptions.AutoDetectInterface
@@ -81,12 +93,12 @@ func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOp
 			if err != nil {
 				return nil, E.New("auto_detect_interface unsupported on current platform")
 			}
-			interfaceMonitor.RegisterCallback(nm.notifyNetworkUpdate)
+			interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate)
 			nm.interfaceMonitor = interfaceMonitor
 		}
 	} else {
 		interfaceMonitor := nm.platformInterface.CreateDefaultInterfaceMonitor(logger)
-		interfaceMonitor.RegisterCallback(nm.notifyNetworkUpdate)
+		interfaceMonitor.RegisterCallback(nm.notifyInterfaceUpdate)
 		nm.interfaceMonitor = interfaceMonitor
 	}
 	return nm, nil
@@ -262,10 +274,6 @@ func (r *NetworkManager) NetworkInterfaces() []adapter.NetworkInterface {
 	return r.networkInterfaces.Load()
 }
 
-func (r *NetworkManager) DefaultInterface() string {
-	return r.defaultInterface
-}
-
 func (r *NetworkManager) AutoDetectInterface() bool {
 	return r.autoDetectInterface
 }
@@ -298,8 +306,19 @@ func (r *NetworkManager) AutoDetectInterfaceFunc() control.Func {
 	}
 }
 
-func (r *NetworkManager) DefaultMark() uint32 {
-	return r.defaultMark
+func (r *NetworkManager) ProtectFunc() control.Func {
+	if r.platformInterface != nil && r.platformInterface.UsePlatformAutoDetectInterfaceControl() {
+		return func(network, address string, conn syscall.RawConn) error {
+			return control.Raw(conn, func(fd uintptr) error {
+				return r.platformInterface.AutoDetectInterfaceControl(int(fd))
+			})
+		}
+	}
+	return nil
+}
+
+func (r *NetworkManager) DefaultOptions() adapter.NetworkOptions {
+	return r.defaultOptions
 }
 
 func (r *NetworkManager) RegisterAutoRedirectOutputMark(mark uint32) error {
@@ -341,45 +360,47 @@ func (r *NetworkManager) ResetNetwork() {
 	}
 }
 
-func (r *NetworkManager) notifyNetworkUpdate(event int) {
-	if event == tun.EventNoRoute {
+func (r *NetworkManager) notifyInterfaceUpdate(defaultInterface *control.Interface, flags int) {
+	if defaultInterface == nil {
 		r.pauseManager.NetworkPause()
 		r.logger.Error("missing default interface")
-	} else {
-		r.pauseManager.NetworkWake()
-		defaultInterface := r.DefaultNetworkInterface()
-		if defaultInterface == nil {
-			panic("invalid interface context")
-		}
-		var options []string
-		options = append(options, F.ToString("index ", defaultInterface.Index))
-		if C.IsAndroid && r.platformInterface == nil {
-			var vpnStatus string
-			if r.interfaceMonitor.AndroidVPNEnabled() {
-				vpnStatus = "enabled"
-			} else {
-				vpnStatus = "disabled"
-			}
-			options = append(options, "vpn "+vpnStatus)
+		return
+	}
+
+	r.pauseManager.NetworkWake()
+	var options []string
+	options = append(options, F.ToString("index ", defaultInterface.Index))
+	if C.IsAndroid && r.platformInterface == nil {
+		var vpnStatus string
+		if r.interfaceMonitor.AndroidVPNEnabled() {
+			vpnStatus = "enabled"
 		} else {
-			if defaultInterface.Type != "" {
-				options = append(options, F.ToString("type ", defaultInterface.Type))
-			}
-			if defaultInterface.Expensive {
-				options = append(options, "expensive")
-			}
-			if defaultInterface.Constrained {
-				options = append(options, "constrained")
-			}
+			vpnStatus = "disabled"
 		}
-		r.logger.Info("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", "))
-		if r.platformInterface != nil {
-			state := r.platformInterface.ReadWIFIState()
-			if state != r.wifiState {
-				r.wifiState = state
-				if state.SSID != "" {
-					r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
-				}
+		options = append(options, "vpn "+vpnStatus)
+	} else if r.platformInterface != nil {
+		networkInterface := common.Find(r.networkInterfaces.Load(), func(it adapter.NetworkInterface) bool {
+			return it.Interface.Index == defaultInterface.Index
+		})
+		if networkInterface.Type == "" {
+			// race
+			return
+		}
+		options = append(options, F.ToString("type ", networkInterface.Type))
+		if networkInterface.Expensive {
+			options = append(options, "expensive")
+		}
+		if networkInterface.Constrained {
+			options = append(options, "constrained")
+		}
+	}
+	r.logger.Info("updated default interface ", defaultInterface.Name, ", ", strings.Join(options, ", "))
+	if r.platformInterface != nil {
+		state := r.platformInterface.ReadWIFIState()
+		if state != r.wifiState {
+			r.wifiState = state
+			if state.SSID != "" {
+				r.logger.Info("updated WIFI state: SSID=", state.SSID, ", BSSID=", state.BSSID)
 			}
 		}
 	}

+ 4 - 0
route/route.go

@@ -424,9 +424,13 @@ match:
 		}
 		switch action := currentRule.Action().(type) {
 		case *rule.RuleActionRoute:
+			metadata.NetworkStrategy = action.NetworkStrategy
+			metadata.FallbackDelay = action.FallbackDelay
 			metadata.UDPDisableDomainUnmapping = action.UDPDisableDomainUnmapping
 			metadata.UDPConnect = action.UDPConnect
 		case *rule.RuleActionRouteOptions:
+			metadata.NetworkStrategy = action.NetworkStrategy
+			metadata.FallbackDelay = action.FallbackDelay
 			metadata.UDPDisableDomainUnmapping = action.UDPDisableDomainUnmapping
 			metadata.UDPConnect = action.UDPConnect
 		case *rule.RuleActionSniff:

+ 6 - 0
route/rule/rule_action.go

@@ -30,12 +30,16 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti
 		return &RuleActionRoute{
 			Outbound: action.RouteOptions.Outbound,
 			RuleActionRouteOptions: RuleActionRouteOptions{
+				NetworkStrategy:           C.NetworkStrategy(action.RouteOptions.NetworkStrategy),
+				FallbackDelay:             time.Duration(action.RouteOptions.FallbackDelay),
 				UDPDisableDomainUnmapping: action.RouteOptions.UDPDisableDomainUnmapping,
 				UDPConnect:                action.RouteOptions.UDPConnect,
 			},
 		}, nil
 	case C.RuleActionTypeRouteOptions:
 		return &RuleActionRouteOptions{
+			NetworkStrategy:           C.NetworkStrategy(action.RouteOptionsOptions.NetworkStrategy),
+			FallbackDelay:             time.Duration(action.RouteOptionsOptions.FallbackDelay),
 			UDPDisableDomainUnmapping: action.RouteOptionsOptions.UDPDisableDomainUnmapping,
 			UDPConnect:                action.RouteOptionsOptions.UDPConnect,
 		}, nil
@@ -135,6 +139,8 @@ func (r *RuleActionRoute) String() string {
 }
 
 type RuleActionRouteOptions struct {
+	NetworkStrategy           C.NetworkStrategy
+	FallbackDelay             time.Duration
 	UDPDisableDomainUnmapping bool
 	UDPConnect                bool
 }

+ 1 - 1
transport/dhcp/server.go

@@ -166,7 +166,7 @@ func (t *Transport) updateServers() error {
 	}
 }
 
-func (t *Transport) interfaceUpdated(int) {
+func (t *Transport) interfaceUpdated(defaultInterface *control.Interface, flags int) {
 	err := t.updateServers()
 	if err != nil {
 		t.options.Logger.Error("update servers: ", err)