From 88aa2e430eabc841fe5b1e4327fbd8d03e2604ce Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= <i@sekai.icu>
Date: Sun, 12 Jan 2025 12:45:27 +0800
Subject: [PATCH] refactor: Outbound domain resolver

---
 adapter/network.go                   |  14 +--
 box.go                               |   6 +-
 common/dialer/default.go             |   7 --
 common/dialer/detour.go              |  16 ++--
 common/dialer/dialer.go              | 130 ++++++++++++++++++---------
 common/dialer/resolve.go             | 113 ++++++++++++++++++-----
 common/tls/reality_server.go         |   2 +-
 dns/transport_adapter.go             |  12 ++-
 dns/transport_dialer.go              |  50 ++++++-----
 experimental/deprecated/constants.go |  26 +++++-
 option/dns.go                        |  50 ++++++-----
 option/outbound.go                   |  40 ++++++++-
 option/route.go                      |   1 +
 protocol/direct/outbound.go          |  12 ++-
 protocol/http/outbound.go            |   2 +-
 protocol/hysteria/outbound.go        |   2 +-
 protocol/hysteria2/outbound.go       |   2 +-
 protocol/shadowsocks/outbound.go     |   2 +-
 protocol/shadowtls/inbound.go        |   4 +-
 protocol/shadowtls/outbound.go       |   2 +-
 protocol/socks/outbound.go           |   2 +-
 protocol/ssh/outbound.go             |   2 +-
 protocol/tor/outbound.go             |   2 +-
 protocol/trojan/outbound.go          |   2 +-
 protocol/tuic/outbound.go            |   2 +-
 protocol/vless/outbound.go           |   2 +-
 protocol/vmess/outbound.go           |   2 +-
 protocol/wireguard/endpoint.go       |  13 ++-
 protocol/wireguard/outbound.go       |  13 ++-
 route/network.go                     |  13 ++-
 route/rule/rule_action.go            |   2 +-
 route/rule/rule_dns.go               |   2 +-
 route/rule/rule_item_outbound.go     |   9 +-
 33 files changed, 392 insertions(+), 167 deletions(-)

diff --git a/adapter/network.go b/adapter/network.go
index 3adfaaeb..50670831 100644
--- a/adapter/network.go
+++ b/adapter/network.go
@@ -29,12 +29,14 @@ type NetworkManager interface {
 }
 
 type NetworkOptions struct {
-	NetworkStrategy     *C.NetworkStrategy
-	NetworkType         []C.InterfaceType
-	FallbackNetworkType []C.InterfaceType
-	FallbackDelay       time.Duration
-	BindInterface       string
-	RoutingMark         uint32
+	BindInterface        string
+	RoutingMark          uint32
+	DomainResolver       string
+	DomainResolveOptions DNSQueryOptions
+	NetworkStrategy      *C.NetworkStrategy
+	NetworkType          []C.InterfaceType
+	FallbackNetworkType  []C.InterfaceType
+	FallbackDelay        time.Duration
 }
 
 type InterfaceUpdateListener interface {
diff --git a/box.go b/box.go
index 05431663..25a84742 100644
--- a/box.go
+++ b/box.go
@@ -187,7 +187,7 @@ func New(options Options) (*Box, error) {
 			transportOptions.Options,
 		)
 		if err != nil {
-			return nil, E.Cause(err, "initialize inbound[", i, "]")
+			return nil, E.Cause(err, "initialize DNS server[", i, "]")
 		}
 	}
 	err = dnsRouter.Initialize(dnsOptions.Rules)
@@ -217,7 +217,7 @@ func New(options Options) (*Box, error) {
 			endpointOptions.Options,
 		)
 		if err != nil {
-			return nil, E.Cause(err, "initialize inbound[", i, "]")
+			return nil, E.Cause(err, "initialize endpoint[", i, "]")
 		}
 	}
 	for i, inboundOptions := range options.Inbounds {
@@ -316,7 +316,7 @@ func New(options Options) (*Box, error) {
 		}
 	}
 	if ntpOptions.Enabled {
-		ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions)
+		ntpDialer, err := dialer.New(ctx, ntpOptions.DialerOptions, ntpOptions.ServerIsDomain())
 		if err != nil {
 			return nil, E.Cause(err, "create NTP service")
 		}
diff --git a/common/dialer/default.go b/common/dialer/default.go
index 77536c43..50244ac5 100644
--- a/common/dialer/default.go
+++ b/common/dialer/default.go
@@ -35,7 +35,6 @@ type DefaultDialer struct {
 	udpListener            net.ListenConfig
 	udpAddr4               string
 	udpAddr6               string
-	isWireGuardListener    bool
 	networkManager         adapter.NetworkManager
 	networkStrategy        *C.NetworkStrategy
 	defaultNetworkStrategy bool
@@ -183,11 +182,6 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
 		}
 		setMultiPathTCP(&dialer4)
 	}
-	if options.IsWireGuardListener {
-		for _, controlFn := range WgControlFns {
-			listener.Control = control.Append(listener.Control, controlFn)
-		}
-	}
 	tcpDialer4, err := newTCPDialer(dialer4, options.TCPFastOpen)
 	if err != nil {
 		return nil, err
@@ -204,7 +198,6 @@ func NewDefault(ctx context.Context, options option.DialerOptions) (*DefaultDial
 		udpListener:            listener,
 		udpAddr4:               udpAddr4,
 		udpAddr6:               udpAddr6,
-		isWireGuardListener:    options.IsWireGuardListener,
 		networkManager:         networkManager,
 		networkStrategy:        networkStrategy,
 		defaultNetworkStrategy: defaultNetworkStrategy,
diff --git a/common/dialer/detour.go b/common/dialer/detour.go
index c1d40faa..e4a46049 100644
--- a/common/dialer/detour.go
+++ b/common/dialer/detour.go
@@ -29,16 +29,18 @@ func (d *DetourDialer) Start() error {
 }
 
 func (d *DetourDialer) Dialer() (N.Dialer, error) {
-	d.initOnce.Do(func() {
-		var loaded bool
-		d.dialer, loaded = d.outboundManager.Outbound(d.detour)
-		if !loaded {
-			d.initErr = E.New("outbound detour not found: ", d.detour)
-		}
-	})
+	d.initOnce.Do(d.init)
 	return d.dialer, d.initErr
 }
 
+func (d *DetourDialer) init() {
+	var loaded bool
+	d.dialer, loaded = d.outboundManager.Outbound(d.detour)
+	if !loaded {
+		d.initErr = E.New("outbound detour not found: ", d.detour)
+	}
+}
+
 func (d *DetourDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
 	dialer, err := d.Dialer()
 	if err != nil {
diff --git a/common/dialer/dialer.go b/common/dialer/dialer.go
index f63e3864..812e5575 100644
--- a/common/dialer/dialer.go
+++ b/common/dialer/dialer.go
@@ -8,6 +8,7 @@ import (
 
 	"github.com/sagernet/sing-box/adapter"
 	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/experimental/deprecated"
 	"github.com/sagernet/sing-box/option"
 	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
@@ -15,60 +16,105 @@ import (
 	"github.com/sagernet/sing/service"
 )
 
-func New(ctx context.Context, options option.DialerOptions) (N.Dialer, error) {
-	if options.IsWireGuardListener {
-		return NewDefault(ctx, options)
-	}
+type Options struct {
+	Context          context.Context
+	Options          option.DialerOptions
+	RemoteIsDomain   bool
+	DirectResolver   bool
+	ResolverOnDetour bool
+}
+
+// TODO: merge with NewWithOptions
+func New(ctx context.Context, options option.DialerOptions, remoteIsDomain bool) (N.Dialer, error) {
+	return NewWithOptions(Options{
+		Context:        ctx,
+		Options:        options,
+		RemoteIsDomain: remoteIsDomain,
+	})
+}
+
+func NewWithOptions(options Options) (N.Dialer, error) {
+	dialOptions := options.Options
 	var (
 		dialer N.Dialer
 		err    error
 	)
-	if options.Detour == "" {
-		dialer, err = NewDefault(ctx, options)
-		if err != nil {
-			return nil, err
-		}
-	} else {
-		outboundManager := service.FromContext[adapter.OutboundManager](ctx)
+	if dialOptions.Detour != "" {
+		outboundManager := service.FromContext[adapter.OutboundManager](options.Context)
 		if outboundManager == nil {
 			return nil, E.New("missing outbound manager")
 		}
-		dialer = NewDetour(outboundManager, options.Detour)
-	}
-	if options.Detour == "" {
-		router := service.FromContext[adapter.DNSRouter](ctx)
-		if router != nil {
-			dialer = NewResolveDialer(
-				router,
-				dialer,
-				options.Detour == "" && !options.TCPFastOpen,
-				C.DomainStrategy(options.DomainStrategy),
-				time.Duration(options.FallbackDelay))
+		dialer = NewDetour(outboundManager, dialOptions.Detour)
+	} else {
+		dialer, err = NewDefault(options.Context, dialOptions)
+		if err != nil {
+			return nil, err
 		}
 	}
+	if options.RemoteIsDomain && (dialOptions.Detour == "" || options.ResolverOnDetour) {
+		networkManager := service.FromContext[adapter.NetworkManager](options.Context)
+		dnsTransport := service.FromContext[adapter.DNSTransportManager](options.Context)
+		var defaultOptions adapter.NetworkOptions
+		if networkManager != nil {
+			defaultOptions = networkManager.DefaultOptions()
+		}
+		var (
+			server               string
+			dnsQueryOptions      adapter.DNSQueryOptions
+			resolveFallbackDelay time.Duration
+		)
+		if dialOptions.DomainResolver != nil && dialOptions.DomainResolver.Server != "" {
+			var transport adapter.DNSTransport
+			if !options.DirectResolver {
+				var loaded bool
+				transport, loaded = dnsTransport.Transport(dialOptions.DomainResolver.Server)
+				if !loaded {
+					return nil, E.New("domain resolver not found: " + dialOptions.DomainResolver.Server)
+				}
+			}
+			var strategy C.DomainStrategy
+			if dialOptions.DomainResolver.Strategy != option.DomainStrategy(C.DomainStrategyAsIS) {
+				strategy = C.DomainStrategy(dialOptions.DomainResolver.Strategy)
+			} else if
+			//nolint:staticcheck
+			dialOptions.DomainStrategy != option.DomainStrategy(C.DomainStrategyAsIS) {
+				//nolint:staticcheck
+				strategy = C.DomainStrategy(dialOptions.DomainStrategy)
+			}
+			server = dialOptions.DomainResolver.Server
+			dnsQueryOptions = adapter.DNSQueryOptions{
+				Transport:    transport,
+				Strategy:     strategy,
+				DisableCache: dialOptions.DomainResolver.DisableCache,
+				RewriteTTL:   dialOptions.DomainResolver.RewriteTTL,
+				ClientSubnet: dialOptions.DomainResolver.ClientSubnet.Build(netip.Prefix{}),
+			}
+			resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay)
+		} else if options.DirectResolver {
+			return nil, E.New("missing domain resolver for domain server address")
+		} else if defaultOptions.DomainResolver != "" {
+			dnsQueryOptions = defaultOptions.DomainResolveOptions
+			transport, loaded := dnsTransport.Transport(defaultOptions.DomainResolver)
+			if !loaded {
+				return nil, E.New("default domain resolver not found: " + defaultOptions.DomainResolver)
+			}
+			dnsQueryOptions.Transport = transport
+			resolveFallbackDelay = time.Duration(dialOptions.FallbackDelay)
+		} else {
+			deprecated.Report(options.Context, deprecated.OptionMissingDomainResolver)
+		}
+		dialer = NewResolveDialer(
+			options.Context,
+			dialer,
+			dialOptions.Detour == "" && !dialOptions.TCPFastOpen,
+			server,
+			dnsQueryOptions,
+			resolveFallbackDelay,
+		)
+	}
 	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")
-	}
-	if options.IsWireGuardListener {
-		return NewDefault(ctx, options)
-	}
-	dialer, err := NewDefault(ctx, options)
-	if err != nil {
-		return nil, err
-	}
-	return NewResolveParallelInterfaceDialer(
-		service.FromContext[adapter.DNSRouter](ctx),
-		dialer,
-		true,
-		C.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, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error)
diff --git a/common/dialer/resolve.go b/common/dialer/resolve.go
index 3d667a6c..66b74e3c 100644
--- a/common/dialer/resolve.go
+++ b/common/dialer/resolve.go
@@ -3,14 +3,17 @@ package dialer
 import (
 	"context"
 	"net"
+	"sync"
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing/common/bufio"
+	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"
 )
 
 var (
@@ -18,21 +21,37 @@ var (
 	_ ParallelInterfaceDialer = (*resolveParallelNetworkDialer)(nil)
 )
 
+type ResolveDialer interface {
+	N.Dialer
+	QueryOptions() adapter.DNSQueryOptions
+}
+
+type ParallelInterfaceResolveDialer interface {
+	ParallelInterfaceDialer
+	QueryOptions() adapter.DNSQueryOptions
+}
+
 type resolveDialer struct {
+	transport     adapter.DNSTransportManager
+	router        adapter.DNSRouter
 	dialer        N.Dialer
 	parallel      bool
-	router        adapter.DNSRouter
-	strategy      C.DomainStrategy
+	server        string
+	initOnce      sync.Once
+	initErr       error
+	queryOptions  adapter.DNSQueryOptions
 	fallbackDelay time.Duration
 }
 
-func NewResolveDialer(router adapter.DNSRouter, dialer N.Dialer, parallel bool, strategy C.DomainStrategy, fallbackDelay time.Duration) N.Dialer {
+func NewResolveDialer(ctx context.Context, dialer N.Dialer, parallel bool, server string, queryOptions adapter.DNSQueryOptions, fallbackDelay time.Duration) ResolveDialer {
 	return &resolveDialer{
-		dialer,
-		parallel,
-		router,
-		strategy,
-		fallbackDelay,
+		transport:     service.FromContext[adapter.DNSTransportManager](ctx),
+		router:        service.FromContext[adapter.DNSRouter](ctx),
+		dialer:        dialer,
+		parallel:      parallel,
+		server:        server,
+		queryOptions:  queryOptions,
+		fallbackDelay: fallbackDelay,
 	}
 }
 
@@ -41,41 +60,68 @@ type resolveParallelNetworkDialer struct {
 	dialer ParallelInterfaceDialer
 }
 
-func NewResolveParallelInterfaceDialer(router adapter.DNSRouter, dialer ParallelInterfaceDialer, parallel bool, strategy C.DomainStrategy, fallbackDelay time.Duration) ParallelInterfaceDialer {
+func NewResolveParallelInterfaceDialer(ctx context.Context, dialer ParallelInterfaceDialer, parallel bool, server string, queryOptions adapter.DNSQueryOptions, fallbackDelay time.Duration) ParallelInterfaceResolveDialer {
 	return &resolveParallelNetworkDialer{
 		resolveDialer{
-			dialer,
-			parallel,
-			router,
-			strategy,
-			fallbackDelay,
+			transport:     service.FromContext[adapter.DNSTransportManager](ctx),
+			router:        service.FromContext[adapter.DNSRouter](ctx),
+			dialer:        dialer,
+			parallel:      parallel,
+			server:        server,
+			queryOptions:  queryOptions,
+			fallbackDelay: fallbackDelay,
 		},
 		dialer,
 	}
 }
 
+func (d *resolveDialer) initialize() error {
+	d.initOnce.Do(d.initServer)
+	return d.initErr
+}
+
+func (d *resolveDialer) initServer() {
+	if d.server == "" {
+		return
+	}
+	transport, loaded := d.transport.Transport(d.server)
+	if !loaded {
+		d.initErr = E.New("domain resolver not found: " + d.server)
+		return
+	}
+	d.queryOptions.Transport = transport
+}
+
 func (d *resolveDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
+	err := d.initialize()
+	if err != nil {
+		return nil, err
+	}
 	if !destination.IsFqdn() {
 		return d.dialer.DialContext(ctx, network, destination)
 	}
 	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
-	addresses, err := d.router.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{Strategy: d.strategy})
+	addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions)
 	if err != nil {
 		return nil, err
 	}
 	if d.parallel {
-		return N.DialParallel(ctx, d.dialer, network, destination, addresses, d.strategy == C.DomainStrategyPreferIPv6, d.fallbackDelay)
+		return N.DialParallel(ctx, d.dialer, network, destination, addresses, d.queryOptions.Strategy == C.DomainStrategyPreferIPv6, d.fallbackDelay)
 	} else {
 		return N.DialSerial(ctx, d.dialer, network, destination, addresses)
 	}
 }
 
 func (d *resolveDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+	err := d.initialize()
+	if err != nil {
+		return nil, err
+	}
 	if !destination.IsFqdn() {
 		return d.dialer.ListenPacket(ctx, destination)
 	}
 	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
-	addresses, err := d.router.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{Strategy: d.strategy})
+	addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions)
 	if err != nil {
 		return nil, err
 	}
@@ -86,14 +132,24 @@ 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) QueryOptions() adapter.DNSQueryOptions {
+	return d.queryOptions
+}
+
+func (d *resolveDialer) Upstream() any {
+	return d.dialer
+}
+
 func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context, network string, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.Conn, error) {
+	err := d.initialize()
+	if err != nil {
+		return nil, err
+	}
 	if !destination.IsFqdn() {
 		return d.dialer.DialContext(ctx, network, destination)
 	}
 	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
-	addresses, err := d.router.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{
-		Strategy: d.strategy,
-	})
+	addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions)
 	if err != nil {
 		return nil, err
 	}
@@ -101,21 +157,28 @@ func (d *resolveParallelNetworkDialer) DialParallelInterface(ctx context.Context
 		fallbackDelay = d.fallbackDelay
 	}
 	if d.parallel {
-		return DialParallelNetwork(ctx, d.dialer, network, destination, addresses, d.strategy == C.DomainStrategyPreferIPv6, strategy, interfaceType, fallbackInterfaceType, fallbackDelay)
+		return DialParallelNetwork(ctx, d.dialer, network, destination, addresses, d.queryOptions.Strategy == C.DomainStrategyPreferIPv6, strategy, interfaceType, fallbackInterfaceType, fallbackDelay)
 	} else {
 		return DialSerialNetwork(ctx, d.dialer, network, destination, addresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay)
 	}
 }
 
 func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.Context, destination M.Socksaddr, strategy *C.NetworkStrategy, interfaceType []C.InterfaceType, fallbackInterfaceType []C.InterfaceType, fallbackDelay time.Duration) (net.PacketConn, error) {
+	err := d.initialize()
+	if err != nil {
+		return nil, err
+	}
 	if !destination.IsFqdn() {
 		return d.dialer.ListenPacket(ctx, destination)
 	}
 	ctx = log.ContextWithOverrideLevel(ctx, log.LevelDebug)
-	addresses, err := d.router.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{Strategy: d.strategy})
+	addresses, err := d.router.Lookup(ctx, destination.Fqdn, d.queryOptions)
 	if err != nil {
 		return nil, err
 	}
+	if fallbackDelay == 0 {
+		fallbackDelay = d.fallbackDelay
+	}
 	conn, destinationAddress, err := ListenSerialNetworkPacket(ctx, d.dialer, destination, addresses, strategy, interfaceType, fallbackInterfaceType, fallbackDelay)
 	if err != nil {
 		return nil, err
@@ -123,6 +186,10 @@ func (d *resolveParallelNetworkDialer) ListenSerialInterfacePacket(ctx context.C
 	return bufio.NewNATPacketConn(bufio.NewPacketConn(conn), M.SocksaddrFrom(destinationAddress, destination.Port), destination), nil
 }
 
-func (d *resolveDialer) Upstream() any {
+func (d *resolveParallelNetworkDialer) QueryOptions() adapter.DNSQueryOptions {
+	return d.queryOptions
+}
+
+func (d *resolveParallelNetworkDialer) Upstream() any {
 	return d.dialer
 }
diff --git a/common/tls/reality_server.go b/common/tls/reality_server.go
index cf429815..912d13dd 100644
--- a/common/tls/reality_server.go
+++ b/common/tls/reality_server.go
@@ -101,7 +101,7 @@ func NewRealityServer(ctx context.Context, logger log.Logger, options option.Inb
 		tlsConfig.ShortIds[shortID] = true
 	}
 
-	handshakeDialer, err := dialer.New(ctx, options.Reality.Handshake.DialerOptions)
+	handshakeDialer, err := dialer.New(ctx, options.Reality.Handshake.DialerOptions, options.Reality.Handshake.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/dns/transport_adapter.go b/dns/transport_adapter.go
index 02c84621..47345709 100644
--- a/dns/transport_adapter.go
+++ b/dns/transport_adapter.go
@@ -27,9 +27,14 @@ func NewTransportAdapter(transportType string, transportTag string, dependencies
 }
 
 func NewTransportAdapterWithLocalOptions(transportType string, transportTag string, localOptions option.LocalDNSServerOptions) TransportAdapter {
+	var dependencies []string
+	if localOptions.DomainResolver != nil && localOptions.DomainResolver.Server != "" {
+		dependencies = append(dependencies, localOptions.DomainResolver.Server)
+	}
 	return TransportAdapter{
 		transportType: transportType,
 		transportTag:  transportTag,
+		dependencies:  dependencies,
 		strategy:      C.DomainStrategy(localOptions.LegacyStrategy),
 		clientSubnet:  localOptions.LegacyClientSubnet,
 	}
@@ -37,8 +42,11 @@ func NewTransportAdapterWithLocalOptions(transportType string, transportTag stri
 
 func NewTransportAdapterWithRemoteOptions(transportType string, transportTag string, remoteOptions option.RemoteDNSServerOptions) TransportAdapter {
 	var dependencies []string
-	if remoteOptions.AddressResolver != "" {
-		dependencies = []string{remoteOptions.AddressResolver}
+	if remoteOptions.DomainResolver != nil && remoteOptions.DomainResolver.Server != "" {
+		dependencies = append(dependencies, remoteOptions.DomainResolver.Server)
+	}
+	if remoteOptions.LegacyAddressResolver != "" {
+		dependencies = append(dependencies, remoteOptions.LegacyAddressResolver)
 	}
 	return TransportAdapter{
 		transportType: transportType,
diff --git a/dns/transport_dialer.go b/dns/transport_dialer.go
index 14e1188d..0b15c7ea 100644
--- a/dns/transport_dialer.go
+++ b/dns/transport_dialer.go
@@ -19,29 +19,39 @@ func NewLocalDialer(ctx context.Context, options option.LocalDNSServerOptions) (
 	if options.LegacyDefaultDialer {
 		return dialer.NewDefaultOutbound(ctx), nil
 	} else {
-		return dialer.New(ctx, options.DialerOptions)
+		return dialer.NewWithOptions(dialer.Options{
+			Context:        ctx,
+			Options:        options.DialerOptions,
+			DirectResolver: true,
+		})
 	}
 }
 
 func NewRemoteDialer(ctx context.Context, options option.RemoteDNSServerOptions) (N.Dialer, error) {
-	transportDialer, err := NewLocalDialer(ctx, options.LocalDNSServerOptions)
-	if err != nil {
-		return nil, err
-	}
-	if options.AddressResolver != "" {
-		transport := service.FromContext[adapter.DNSTransportManager](ctx)
-		resolverTransport, loaded := transport.Transport(options.AddressResolver)
-		if !loaded {
-			return nil, E.New("address resolver not found: ", options.AddressResolver)
+	if options.LegacyDefaultDialer {
+		transportDialer := dialer.NewDefaultOutbound(ctx)
+		if options.LegacyAddressResolver != "" {
+			transport := service.FromContext[adapter.DNSTransportManager](ctx)
+			resolverTransport, loaded := transport.Transport(options.LegacyAddressResolver)
+			if !loaded {
+				return nil, E.New("address resolver not found: ", options.LegacyAddressResolver)
+			}
+			transportDialer = newTransportDialer(transportDialer, service.FromContext[adapter.DNSRouter](ctx), resolverTransport, C.DomainStrategy(options.LegacyAddressStrategy), time.Duration(options.LegacyAddressFallbackDelay))
+		} else if options.ServerIsDomain() {
+			return nil, E.New("missing address resolver for server: ", options.Server)
 		}
-		transportDialer = NewTransportDialer(transportDialer, service.FromContext[adapter.DNSRouter](ctx), resolverTransport, C.DomainStrategy(options.AddressStrategy), time.Duration(options.AddressFallbackDelay))
-	} else if M.IsDomainName(options.Server) {
-		return nil, E.New("missing address resolver for server: ", options.Server)
+		return transportDialer, nil
+	} else {
+		return dialer.NewWithOptions(dialer.Options{
+			Context:        ctx,
+			Options:        options.DialerOptions,
+			RemoteIsDomain: options.ServerIsDomain(),
+			DirectResolver: true,
+		})
 	}
-	return transportDialer, nil
 }
 
-type TransportDialer struct {
+type legacyTransportDialer struct {
 	dialer        N.Dialer
 	dnsRouter     adapter.DNSRouter
 	transport     adapter.DNSTransport
@@ -49,8 +59,8 @@ type TransportDialer struct {
 	fallbackDelay time.Duration
 }
 
-func NewTransportDialer(dialer N.Dialer, dnsRouter adapter.DNSRouter, transport adapter.DNSTransport, strategy C.DomainStrategy, fallbackDelay time.Duration) *TransportDialer {
-	return &TransportDialer{
+func newTransportDialer(dialer N.Dialer, dnsRouter adapter.DNSRouter, transport adapter.DNSTransport, strategy C.DomainStrategy, fallbackDelay time.Duration) *legacyTransportDialer {
+	return &legacyTransportDialer{
 		dialer,
 		dnsRouter,
 		transport,
@@ -59,7 +69,7 @@ func NewTransportDialer(dialer N.Dialer, dnsRouter adapter.DNSRouter, transport
 	}
 }
 
-func (d *TransportDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
+func (d *legacyTransportDialer) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
 	if destination.IsIP() {
 		return d.dialer.DialContext(ctx, network, destination)
 	}
@@ -73,7 +83,7 @@ func (d *TransportDialer) DialContext(ctx context.Context, network string, desti
 	return N.DialParallel(ctx, d.dialer, network, destination, addresses, d.strategy == C.DomainStrategyPreferIPv6, d.fallbackDelay)
 }
 
-func (d *TransportDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+func (d *legacyTransportDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
 	if destination.IsIP() {
 		return d.dialer.ListenPacket(ctx, destination)
 	}
@@ -88,6 +98,6 @@ func (d *TransportDialer) ListenPacket(ctx context.Context, destination M.Socksa
 	return conn, err
 }
 
-func (d *TransportDialer) Upstream() any {
+func (d *legacyTransportDialer) Upstream() any {
 	return d.dialer
 }
diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go
index bf648365..d5c3ca48 100644
--- a/experimental/deprecated/constants.go
+++ b/experimental/deprecated/constants.go
@@ -148,10 +148,11 @@ var OptionTUNGSO = Note{
 
 var OptionLegacyDNSTransport = Note{
 	Name:              "legacy-dns-transport",
-	Description:       "legacy DNS transport",
+	Description:       "legacy DNS servers",
 	DeprecatedVersion: "1.12.0",
 	ScheduledVersion:  "1.14.0",
-	EnvName:           "LEGACY_DNS_TRANSPORT",
+	EnvName:           "LEGACY_DNS_SERVERS",
+	MigrationLink:     "https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats",
 }
 
 var OptionLegacyDNSFakeIPOptions = Note{
@@ -159,6 +160,23 @@ var OptionLegacyDNSFakeIPOptions = Note{
 	Description:       "legacy DNS fakeip options",
 	DeprecatedVersion: "1.12.0",
 	ScheduledVersion:  "1.14.0",
+	MigrationLink:     "https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats",
+}
+
+var OptionOutboundDNSRuleItem = Note{
+	Name:              "outbound-dns-rule-item",
+	Description:       "outbound DNS rule item",
+	DeprecatedVersion: "1.12.0",
+	ScheduledVersion:  "1.14.0",
+	MigrationLink:     "https://sing-box.sagernet.org/migration/#migrate-outbound-dns-rule-items-to-domain-resolver",
+}
+
+var OptionMissingDomainResolver = Note{
+	Name:              "missing-domain-resolver",
+	Description:       "missing `route.default_domain_resolver` or `domain_resolver` in dial fields",
+	DeprecatedVersion: "1.12.0",
+	ScheduledVersion:  "1.14.0",
+	MigrationLink:     "https://sing-box.sagernet.org/migration/#migrate-outbound-dns-rule-items-to-domain-resolver",
 }
 
 var Options = []Note{
@@ -172,4 +190,8 @@ var Options = []Note{
 	OptionWireGuardOutbound,
 	OptionWireGuardGSO,
 	OptionTUNGSO,
+	OptionLegacyDNSTransport,
+	OptionLegacyDNSFakeIPOptions,
+	OptionOutboundDNSRuleItem,
+	OptionMissingDomainResolver,
 }
diff --git a/option/dns.go b/option/dns.go
index 8c9b8bda..f4418468 100644
--- a/option/dns.go
+++ b/option/dns.go
@@ -4,7 +4,6 @@ import (
 	"context"
 	"net/netip"
 	"net/url"
-	"os"
 
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/experimental/deprecated"
@@ -121,11 +120,6 @@ func (o *NewDNSServerOptions) Upgrade(ctx context.Context) error {
 	if o.Type != C.DNSTypeLegacy {
 		return nil
 	}
-	defer func() {
-		encoder := json.NewEncoder(os.Stderr)
-		encoder.SetIndent("", "  ")
-		encoder.Encode(o)
-	}()
 	options := o.Options.(*LegacyDNSServerOptions)
 	serverURL, _ := url.Parse(options.Address)
 	var serverType string
@@ -139,18 +133,34 @@ func (o *NewDNSServerOptions) Upgrade(ctx context.Context) error {
 			serverType = C.DNSTypeUDP
 		}
 	}
-	remoteOptions := RemoteDNSServerOptions{
-		LocalDNSServerOptions: LocalDNSServerOptions{
-			DialerOptions: DialerOptions{
-				Detour: options.Detour,
+	var remoteOptions RemoteDNSServerOptions
+	if options.Detour == "" {
+		remoteOptions = RemoteDNSServerOptions{
+			LocalDNSServerOptions: LocalDNSServerOptions{
+				LegacyStrategy:      options.Strategy,
+				LegacyDefaultDialer: options.Detour == "",
+				LegacyClientSubnet:  options.ClientSubnet.Build(netip.Prefix{}),
 			},
-			LegacyStrategy:      options.Strategy,
-			LegacyDefaultDialer: options.Detour == "",
-			LegacyClientSubnet:  options.ClientSubnet.Build(netip.Prefix{}),
-		},
-		AddressResolver:      options.AddressResolver,
-		AddressStrategy:      options.AddressStrategy,
-		AddressFallbackDelay: options.AddressFallbackDelay,
+			LegacyAddressResolver:      options.AddressResolver,
+			LegacyAddressStrategy:      options.AddressStrategy,
+			LegacyAddressFallbackDelay: options.AddressFallbackDelay,
+		}
+	} else {
+		remoteOptions = RemoteDNSServerOptions{
+			LocalDNSServerOptions: LocalDNSServerOptions{
+				DialerOptions: DialerOptions{
+					Detour: options.Detour,
+					DomainResolver: &DomainResolveOptions{
+						Server:   options.AddressResolver,
+						Strategy: options.AddressStrategy,
+					},
+					FallbackDelay: options.AddressFallbackDelay,
+				},
+				LegacyStrategy:      options.Strategy,
+				LegacyDefaultDialer: options.Detour == "",
+				LegacyClientSubnet:  options.ClientSubnet.Build(netip.Prefix{}),
+			},
+		}
 	}
 	switch serverType {
 	case C.DNSTypeLocal:
@@ -291,9 +301,9 @@ type LocalDNSServerOptions struct {
 type RemoteDNSServerOptions struct {
 	LocalDNSServerOptions
 	ServerOptions
-	AddressResolver      string             `json:"address_resolver,omitempty"`
-	AddressStrategy      DomainStrategy     `json:"address_strategy,omitempty"`
-	AddressFallbackDelay badoption.Duration `json:"address_fallback_delay,omitempty"`
+	LegacyAddressResolver      string             `json:"-"`
+	LegacyAddressStrategy      DomainStrategy     `json:"-"`
+	LegacyAddressFallbackDelay badoption.Duration `json:"-"`
 }
 
 type RemoteTLSDNSServerOptions struct {
diff --git a/option/outbound.go b/option/outbound.go
index 5cadd3e2..99e3361a 100644
--- a/option/outbound.go
+++ b/option/outbound.go
@@ -77,12 +77,46 @@ type DialerOptions struct {
 	TCPMultiPath        bool                              `json:"tcp_multi_path,omitempty"`
 	UDPFragment         *bool                             `json:"udp_fragment,omitempty"`
 	UDPFragmentDefault  bool                              `json:"-"`
-	DomainStrategy      DomainStrategy                    `json:"domain_strategy,omitempty"`
+	DomainResolver      *DomainResolveOptions             `json:"domain_resolver,omitempty"`
 	NetworkStrategy     *NetworkStrategy                  `json:"network_strategy,omitempty"`
 	NetworkType         badoption.Listable[InterfaceType] `json:"network_type,omitempty"`
 	FallbackNetworkType badoption.Listable[InterfaceType] `json:"fallback_network_type,omitempty"`
 	FallbackDelay       badoption.Duration                `json:"fallback_delay,omitempty"`
 	IsWireGuardListener bool                              `json:"-"`
+
+	// Deprecated: migrated to domain resolver
+	DomainStrategy DomainStrategy `json:"domain_strategy,omitempty"`
+}
+
+type _DomainResolveOptions struct {
+	Server       string                `json:"server"`
+	Strategy     DomainStrategy        `json:"strategy,omitempty"`
+	DisableCache bool                  `json:"disable_cache,omitempty"`
+	RewriteTTL   *uint32               `json:"rewrite_ttl,omitempty"`
+	ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"`
+}
+
+type DomainResolveOptions _DomainResolveOptions
+
+func (o DomainResolveOptions) MarshalJSON() ([]byte, error) {
+	if o.Strategy == DomainStrategy(C.DomainStrategyAsIS) &&
+		!o.DisableCache &&
+		o.RewriteTTL == nil &&
+		o.ClientSubnet == nil {
+		return json.Marshal(o.Server)
+	} else {
+		return json.Marshal((_DomainResolveOptions)(o))
+	}
+}
+
+func (o *DomainResolveOptions) UnmarshalJSON(bytes []byte) error {
+	var stringValue string
+	err := json.Unmarshal(bytes, &stringValue)
+	if err == nil {
+		o.Server = stringValue
+		return nil
+	}
+	return json.Unmarshal(bytes, (*_DomainResolveOptions)(o))
 }
 
 func (o *DialerOptions) TakeDialerOptions() DialerOptions {
@@ -107,6 +141,10 @@ func (o ServerOptions) Build() M.Socksaddr {
 	return M.ParseSocksaddrHostPort(o.Server, o.ServerPort)
 }
 
+func (o ServerOptions) ServerIsDomain() bool {
+	return M.IsDomainName(o.Server)
+}
+
 func (o *ServerOptions) TakeServerOptions() ServerOptions {
 	return *o
 }
diff --git a/option/route.go b/option/route.go
index 1eb2294b..f4b65391 100644
--- a/option/route.go
+++ b/option/route.go
@@ -13,6 +13,7 @@ type RouteOptions struct {
 	OverrideAndroidVPN         bool                              `json:"override_android_vpn,omitempty"`
 	DefaultInterface           string                            `json:"default_interface,omitempty"`
 	DefaultMark                FwMark                            `json:"default_mark,omitempty"`
+	DefaultDomainResolver      *DomainResolveOptions             `json:"default_domain_resolver,omitempty"`
 	DefaultNetworkStrategy     *NetworkStrategy                  `json:"default_network_strategy,omitempty"`
 	DefaultNetworkType         badoption.Listable[InterfaceType] `json:"default_network_type,omitempty"`
 	DefaultFallbackNetworkType badoption.Listable[InterfaceType] `json:"default_fallback_network_type,omitempty"`
diff --git a/protocol/direct/outbound.go b/protocol/direct/outbound.go
index d173ec53..9b454f58 100644
--- a/protocol/direct/outbound.go
+++ b/protocol/direct/outbound.go
@@ -42,16 +42,20 @@ type Outbound struct {
 
 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.NewDirect(ctx, options.DialerOptions)
+	if options.Detour != "" {
+		return nil, E.New("`detour` is not supported in direct context")
+	}
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, false)
 	if err != nil {
 		return nil, err
 	}
 	outbound := &Outbound{
-		Adapter:        outbound.NewAdapterWithDialerOptions(C.TypeDirect, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
-		logger:         logger,
+		Adapter: outbound.NewAdapterWithDialerOptions(C.TypeDirect, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.DialerOptions),
+		logger:  logger,
+		//nolint:staticcheck
 		domainStrategy: C.DomainStrategy(options.DomainStrategy),
 		fallbackDelay:  time.Duration(options.FallbackDelay),
-		dialer:         outboundDialer,
+		dialer:         outboundDialer.(dialer.ParallelInterfaceDialer),
 		// loopBack:       newLoopBackDetector(router),
 	}
 	//nolint:staticcheck
diff --git a/protocol/http/outbound.go b/protocol/http/outbound.go
index c58f3071..0570dde5 100644
--- a/protocol/http/outbound.go
+++ b/protocol/http/outbound.go
@@ -30,7 +30,7 @@ type Outbound struct {
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.HTTPOutboundOptions) (adapter.Outbound, error) {
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/hysteria/outbound.go b/protocol/hysteria/outbound.go
index e1d8716c..7746df13 100644
--- a/protocol/hysteria/outbound.go
+++ b/protocol/hysteria/outbound.go
@@ -47,7 +47,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 	if err != nil {
 		return nil, err
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/hysteria2/outbound.go b/protocol/hysteria2/outbound.go
index 74e87b37..c805f07e 100644
--- a/protocol/hysteria2/outbound.go
+++ b/protocol/hysteria2/outbound.go
@@ -60,7 +60,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 			return nil, E.New("unknown obfs type: ", options.Obfs.Type)
 		}
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/shadowsocks/outbound.go b/protocol/shadowsocks/outbound.go
index 7e7277ef..875c9e69 100644
--- a/protocol/shadowsocks/outbound.go
+++ b/protocol/shadowsocks/outbound.go
@@ -44,7 +44,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 	if err != nil {
 		return nil, err
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/shadowtls/inbound.go b/protocol/shadowtls/inbound.go
index 5ae5656f..1db191d8 100644
--- a/protocol/shadowtls/inbound.go
+++ b/protocol/shadowtls/inbound.go
@@ -47,7 +47,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
 	if options.Version > 1 {
 		handshakeForServerName = make(map[string]shadowtls.HandshakeConfig)
 		for serverName, serverOptions := range options.HandshakeForServerName {
-			handshakeDialer, err := dialer.New(ctx, serverOptions.DialerOptions)
+			handshakeDialer, err := dialer.New(ctx, serverOptions.DialerOptions, serverOptions.ServerIsDomain())
 			if err != nil {
 				return nil, err
 			}
@@ -57,7 +57,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
 			}
 		}
 	}
-	handshakeDialer, err := dialer.New(ctx, options.Handshake.DialerOptions)
+	handshakeDialer, err := dialer.New(ctx, options.Handshake.DialerOptions, options.Handshake.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/shadowtls/outbound.go b/protocol/shadowtls/outbound.go
index 2b480729..0731b033 100644
--- a/protocol/shadowtls/outbound.go
+++ b/protocol/shadowtls/outbound.go
@@ -68,7 +68,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 			tlsHandshakeFunc = shadowtls.DefaultTLSHandshakeFunc(options.Password, stdTLSConfig)
 		}
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/socks/outbound.go b/protocol/socks/outbound.go
index 323149e2..851412ff 100644
--- a/protocol/socks/outbound.go
+++ b/protocol/socks/outbound.go
@@ -46,7 +46,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 	if err != nil {
 		return nil, err
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go
index eb9970b5..304ea389 100644
--- a/protocol/ssh/outbound.go
+++ b/protocol/ssh/outbound.go
@@ -49,7 +49,7 @@ type Outbound struct {
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.SSHOutboundOptions) (adapter.Outbound, error) {
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/tor/outbound.go b/protocol/tor/outbound.go
index 58824b53..9a0e2d65 100644
--- a/protocol/tor/outbound.go
+++ b/protocol/tor/outbound.go
@@ -75,7 +75,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 		}
 		startConf.TorrcFile = torrcFile
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, false)
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/trojan/outbound.go b/protocol/trojan/outbound.go
index 82889bc1..37a6933c 100644
--- a/protocol/trojan/outbound.go
+++ b/protocol/trojan/outbound.go
@@ -38,7 +38,7 @@ type Outbound struct {
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TrojanOutboundOptions) (adapter.Outbound, error) {
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/tuic/outbound.go b/protocol/tuic/outbound.go
index 49b01f96..a31d4850 100644
--- a/protocol/tuic/outbound.go
+++ b/protocol/tuic/outbound.go
@@ -60,7 +60,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 	case "quic":
 		tuicUDPStream = true
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/vless/outbound.go b/protocol/vless/outbound.go
index 1d832a65..e0208be9 100644
--- a/protocol/vless/outbound.go
+++ b/protocol/vless/outbound.go
@@ -41,7 +41,7 @@ type Outbound struct {
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VLESSOutboundOptions) (adapter.Outbound, error) {
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/vmess/outbound.go b/protocol/vmess/outbound.go
index d41b30d9..be05990e 100644
--- a/protocol/vmess/outbound.go
+++ b/protocol/vmess/outbound.go
@@ -41,7 +41,7 @@ type Outbound struct {
 }
 
 func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.VMessOutboundOptions) (adapter.Outbound, error) {
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.New(ctx, options.DialerOptions, options.ServerIsDomain())
 	if err != nil {
 		return nil, err
 	}
diff --git a/protocol/wireguard/endpoint.go b/protocol/wireguard/endpoint.go
index 300701a9..e167bec1 100644
--- a/protocol/wireguard/endpoint.go
+++ b/protocol/wireguard/endpoint.go
@@ -53,7 +53,14 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
 	if options.Detour == "" {
 		options.IsWireGuardListener = true
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.NewWithOptions(dialer.Options{
+		Context: ctx,
+		Options: options.DialerOptions,
+		RemoteIsDomain: common.Any(options.Peers, func(it option.WireGuardPeer) bool {
+			return !M.ParseAddr(it.Address).IsValid()
+		}),
+		ResolverOnDetour: true,
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -81,9 +88,7 @@ func NewEndpoint(ctx context.Context, router adapter.Router, logger log.ContextL
 		PrivateKey: options.PrivateKey,
 		ListenPort: options.ListenPort,
 		ResolvePeer: func(domain string) (netip.Addr, error) {
-			endpointAddresses, lookupErr := ep.dnsRouter.Lookup(ctx, domain, adapter.DNSQueryOptions{
-				Strategy: C.DomainStrategy(options.DomainStrategy),
-			})
+			endpointAddresses, lookupErr := ep.dnsRouter.Lookup(ctx, domain, outboundDialer.(dialer.ResolveDialer).QueryOptions())
 			if lookupErr != nil {
 				return netip.Addr{}, lookupErr
 			}
diff --git a/protocol/wireguard/outbound.go b/protocol/wireguard/outbound.go
index 4aa49a8d..edd8184c 100644
--- a/protocol/wireguard/outbound.go
+++ b/protocol/wireguard/outbound.go
@@ -56,7 +56,14 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 	} else if options.GSO {
 		return nil, E.New("gso is conflict with detour")
 	}
-	outboundDialer, err := dialer.New(ctx, options.DialerOptions)
+	outboundDialer, err := dialer.NewWithOptions(dialer.Options{
+		Context: ctx,
+		Options: options.DialerOptions,
+		RemoteIsDomain: options.ServerIsDomain() || common.Any(options.Peers, func(it option.LegacyWireGuardPeer) bool {
+			return it.ServerIsDomain()
+		}),
+		ResolverOnDetour: true,
+	})
 	if err != nil {
 		return nil, err
 	}
@@ -94,9 +101,7 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL
 		Address:    options.LocalAddress,
 		PrivateKey: options.PrivateKey,
 		ResolvePeer: func(domain string) (netip.Addr, error) {
-			endpointAddresses, lookupErr := outbound.dnsRouter.Lookup(ctx, domain, adapter.DNSQueryOptions{
-				Strategy: C.DomainStrategy(options.DomainStrategy),
-			})
+			endpointAddresses, lookupErr := outbound.dnsRouter.Lookup(ctx, domain, outboundDialer.(dialer.ResolveDialer).QueryOptions())
 			if lookupErr != nil {
 				return netip.Addr{}, lookupErr
 			}
diff --git a/route/network.go b/route/network.go
index ab1be76c..a494ce09 100644
--- a/route/network.go
+++ b/route/network.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"errors"
 	"net"
+	"net/netip"
 	"os"
 	"runtime"
 	"strings"
@@ -55,13 +56,21 @@ type NetworkManager struct {
 }
 
 func NewNetworkManager(ctx context.Context, logger logger.ContextLogger, routeOptions option.RouteOptions) (*NetworkManager, error) {
+	defaultDomainResolver := common.PtrValueOrDefault(routeOptions.DefaultDomainResolver)
 	nm := &NetworkManager{
 		logger:              logger,
 		interfaceFinder:     control.NewDefaultInterfaceFinder(),
 		autoDetectInterface: routeOptions.AutoDetectInterface,
 		defaultOptions: adapter.NetworkOptions{
-			BindInterface:       routeOptions.DefaultInterface,
-			RoutingMark:         uint32(routeOptions.DefaultMark),
+			BindInterface:  routeOptions.DefaultInterface,
+			RoutingMark:    uint32(routeOptions.DefaultMark),
+			DomainResolver: defaultDomainResolver.Server,
+			DomainResolveOptions: adapter.DNSQueryOptions{
+				Strategy:     C.DomainStrategy(defaultDomainResolver.Strategy),
+				DisableCache: defaultDomainResolver.DisableCache,
+				RewriteTTL:   defaultDomainResolver.RewriteTTL,
+				ClientSubnet: defaultDomainResolver.ClientSubnet.Build(netip.Prefix{}),
+			},
 			NetworkStrategy:     (*C.NetworkStrategy)(routeOptions.DefaultNetworkStrategy),
 			NetworkType:         common.Map(routeOptions.DefaultNetworkType, option.InterfaceType.Build),
 			FallbackNetworkType: common.Map(routeOptions.DefaultFallbackNetworkType, option.InterfaceType.Build),
diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go
index 7287ad86..88149aff 100644
--- a/route/rule/rule_action.go
+++ b/route/rule/rule_action.go
@@ -49,7 +49,7 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti
 			UDPTimeout:                time.Duration(action.RouteOptionsOptions.UDPTimeout),
 		}, nil
 	case C.RuleActionTypeDirect:
-		directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions))
+		directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false)
 		if err != nil {
 			return nil, err
 		}
diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go
index 9d1c69b8..087fb7b2 100644
--- a/route/rule/rule_dns.go
+++ b/route/rule/rule_dns.go
@@ -210,7 +210,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op
 		rule.allItems = append(rule.allItems, item)
 	}
 	if len(options.Outbound) > 0 {
-		item := NewOutboundRule(options.Outbound)
+		item := NewOutboundRule(ctx, options.Outbound)
 		rule.items = append(rule.items, item)
 		rule.allItems = append(rule.allItems, item)
 	}
diff --git a/route/rule/rule_item_outbound.go b/route/rule/rule_item_outbound.go
index 3f37dee7..a13d0597 100644
--- a/route/rule/rule_item_outbound.go
+++ b/route/rule/rule_item_outbound.go
@@ -1,9 +1,11 @@
 package rule
 
 import (
+	"context"
 	"strings"
 
 	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/experimental/deprecated"
 	F "github.com/sagernet/sing/common/format"
 )
 
@@ -15,7 +17,8 @@ type OutboundItem struct {
 	matchAny    bool
 }
 
-func NewOutboundRule(outbounds []string) *OutboundItem {
+func NewOutboundRule(ctx context.Context, outbounds []string) *OutboundItem {
+	deprecated.Report(ctx, deprecated.OptionOutboundDNSRuleItem)
 	rule := &OutboundItem{outbounds: outbounds, outboundMap: make(map[string]bool)}
 	for _, outbound := range outbounds {
 		if outbound == "any" {
@@ -28,8 +31,8 @@ func NewOutboundRule(outbounds []string) *OutboundItem {
 }
 
 func (r *OutboundItem) Match(metadata *adapter.InboundContext) bool {
-	if r.matchAny && metadata.Outbound != "" {
-		return true
+	if r.matchAny {
+		return metadata.Outbound != ""
 	}
 	return r.outboundMap[metadata.Outbound]
 }