diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e82..67b012d9f2 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -25,8 +25,8 @@ type DNSRouter interface { type DNSClient interface { Start() - Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) - Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) + Exchange(ctx context.Context, transport DNSTransport, message *dns.Msg, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) + Lookup(ctx context.Context, transport DNSTransport, domain string, options DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) ClearCache() } @@ -72,11 +72,6 @@ type DNSTransport interface { Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) } -type LegacyDNSTransport interface { - LegacyStrategy() C.DomainStrategy - LegacyClientSubnet() netip.Prefix -} - type DNSTransportRegistry interface { option.DNSTransportOptionsRegistry CreateDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) (DNSTransport, error) diff --git a/adapter/inbound.go b/adapter/inbound.go index 52af336e5b..6f53b1222e 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -10,6 +10,8 @@ import ( "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" M "github.com/sagernet/sing/common/metadata" + + "github.com/miekg/dns" ) type Inbound interface { @@ -79,14 +81,16 @@ type InboundContext struct { FallbackNetworkType []C.InterfaceType FallbackDelay time.Duration - DestinationAddresses []netip.Addr - SourceGeoIPCode string - GeoIPCode string - ProcessInfo *ConnectionOwner - SourceMACAddress net.HardwareAddr - SourceHostname string - QueryType uint16 - FakeIP bool + DestinationAddresses []netip.Addr + DNSResponse *dns.Msg + DestinationAddressMatchFromResponse bool + SourceGeoIPCode string + GeoIPCode string + ProcessInfo *ConnectionOwner + SourceMACAddress net.HardwareAddr + SourceHostname string + QueryType uint16 + FakeIP bool // rule cache @@ -115,6 +119,51 @@ func (c *InboundContext) ResetRuleMatchCache() { c.DidMatch = false } +func (c *InboundContext) DNSResponseAddressesForMatch() []netip.Addr { + return DNSResponseAddresses(c.DNSResponse) +} + +func DNSResponseAddresses(response *dns.Msg) []netip.Addr { + if response == nil || response.Rcode != dns.RcodeSuccess { + return nil + } + addresses := make([]netip.Addr, 0, len(response.Answer)) + for _, rawRecord := range response.Answer { + switch record := rawRecord.(type) { + case *dns.A: + addr := M.AddrFromIP(record.A) + if addr.IsValid() { + addresses = append(addresses, addr) + } + case *dns.AAAA: + addr := M.AddrFromIP(record.AAAA) + if addr.IsValid() { + addresses = append(addresses, addr) + } + case *dns.HTTPS: + for _, value := range record.SVCB.Value { + switch hint := value.(type) { + case *dns.SVCBIPv4Hint: + for _, ip := range hint.Hint { + addr := M.AddrFromIP(ip).Unmap() + if addr.IsValid() { + addresses = append(addresses, addr) + } + } + case *dns.SVCBIPv6Hint: + for _, ip := range hint.Hint { + addr := M.AddrFromIP(ip) + if addr.IsValid() { + addresses = append(addresses, addr) + } + } + } + } + } + } + return addresses +} + type inboundContextKey struct{} func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { diff --git a/adapter/inbound_test.go b/adapter/inbound_test.go new file mode 100644 index 0000000000..ec8c31289c --- /dev/null +++ b/adapter/inbound_test.go @@ -0,0 +1,45 @@ +package adapter + +import ( + "net" + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestDNSResponseAddressesUnmapsHTTPSIPv4Hints(t *testing.T) { + t.Parallel() + + ipv4Hint := net.ParseIP("1.1.1.1") + require.NotNil(t, ipv4Hint) + + response := &dns.Msg{ + MsgHdr: dns.MsgHdr{ + Response: true, + Rcode: dns.RcodeSuccess, + }, + Answer: []dns.RR{ + &dns.HTTPS{ + SVCB: dns.SVCB{ + Hdr: dns.RR_Header{ + Name: dns.Fqdn("example.com"), + Rrtype: dns.TypeHTTPS, + Class: dns.ClassINET, + Ttl: 60, + }, + Priority: 1, + Target: ".", + Value: []dns.SVCBKeyValue{ + &dns.SVCBIPv4Hint{Hint: []net.IP{ipv4Hint}}, + }, + }, + }, + }, + } + + addresses := DNSResponseAddresses(response) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) + require.True(t, addresses[0].Is4()) +} diff --git a/adapter/router.go b/adapter/router.go index 82e6881a60..f1e3da9a0c 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -66,10 +66,16 @@ type RuleSet interface { type RuleSetUpdateCallback func(it RuleSet) +type DNSRuleSetUpdateValidator interface { + ValidateRuleSetMetadataUpdate(tag string, metadata RuleSetMetadata) error +} + +// ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. type RuleSetMetadata struct { - ContainsProcessRule bool - ContainsWIFIRule bool - ContainsIPCIDRRule bool + ContainsProcessRule bool + ContainsWIFIRule bool + ContainsIPCIDRRule bool + ContainsDNSQueryTypeRule bool } type HTTPStartContext struct { ctx context.Context diff --git a/adapter/rule.go b/adapter/rule.go index f8ee797d44..2117ba45a6 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -2,6 +2,8 @@ package adapter import ( C "github.com/sagernet/sing-box/constant" + + "github.com/miekg/dns" ) type HeadlessRule interface { @@ -18,8 +20,9 @@ type Rule interface { type DNSRule interface { Rule + LegacyPreMatch(metadata *InboundContext) bool WithAddressLimit() bool - MatchAddressLimit(metadata *InboundContext) bool + MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool } type RuleAction interface { @@ -29,7 +32,7 @@ type RuleAction interface { func IsFinalAction(action RuleAction) bool { switch action.Type() { - case C.RuleActionTypeSniff, C.RuleActionTypeResolve: + case C.RuleActionTypeSniff, C.RuleActionTypeResolve, C.RuleActionTypeEvaluate: return false default: return true diff --git a/box.go b/box.go index a765e21d8f..04faabbb23 100644 --- a/box.go +++ b/box.go @@ -199,6 +199,7 @@ func New(options Options) (*Box, error) { service.MustRegister[adapter.CertificateProviderManager](ctx, certificateProviderManager) dnsRouter := dns.NewRouter(ctx, logFactory, dnsOptions) service.MustRegister[adapter.DNSRouter](ctx, dnsRouter) + service.MustRegister[adapter.DNSRuleSetUpdateValidator](ctx, dnsRouter) networkManager, err := route.NewNetworkManager(ctx, logFactory.NewLogger("network"), routeOptions, dnsOptions) if err != nil { return nil, E.Cause(err, "initialize network manager") @@ -486,7 +487,7 @@ func (s *Box) preStart() error { if err != nil { return err } - err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.dnsRouter, s.network, s.connection, s.router) + err = adapter.Start(s.logger, adapter.StartStateStart, s.outbound, s.dnsTransport, s.network, s.connection, s.router, s.dnsRouter) if err != nil { return err } diff --git a/constant/dns.go b/constant/dns.go index 15d6096c78..c7cd0d0374 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -15,19 +15,18 @@ const ( ) const ( - DNSTypeLegacy = "legacy" - DNSTypeLegacyRcode = "legacy_rcode" - DNSTypeUDP = "udp" - DNSTypeTCP = "tcp" - DNSTypeTLS = "tls" - DNSTypeHTTPS = "https" - DNSTypeQUIC = "quic" - DNSTypeHTTP3 = "h3" - DNSTypeLocal = "local" - DNSTypeHosts = "hosts" - DNSTypeFakeIP = "fakeip" - DNSTypeDHCP = "dhcp" - DNSTypeTailscale = "tailscale" + DNSTypeLegacy = "legacy" + DNSTypeUDP = "udp" + DNSTypeTCP = "tcp" + DNSTypeTLS = "tls" + DNSTypeHTTPS = "https" + DNSTypeQUIC = "quic" + DNSTypeHTTP3 = "h3" + DNSTypeLocal = "local" + DNSTypeHosts = "hosts" + DNSTypeFakeIP = "fakeip" + DNSTypeDHCP = "dhcp" + DNSTypeTailscale = "tailscale" ) const ( diff --git a/constant/rule.go b/constant/rule.go index 55cad2e137..15d71c5301 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -29,6 +29,8 @@ const ( const ( RuleActionTypeRoute = "route" RuleActionTypeRouteOptions = "route-options" + RuleActionTypeEvaluate = "evaluate" + RuleActionTypeRespond = "respond" RuleActionTypeDirect = "direct" RuleActionTypeBypass = "bypass" RuleActionTypeReject = "reject" diff --git a/dns/client.go b/dns/client.go index 70b53c951c..e86a90977b 100644 --- a/dns/client.go +++ b/dns/client.go @@ -5,7 +5,6 @@ import ( "errors" "net" "net/netip" - "strings" "time" "github.com/sagernet/sing-box/adapter" @@ -14,7 +13,6 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" - M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/task" "github.com/sagernet/sing/contrab/freelru" "github.com/sagernet/sing/contrab/maphash" @@ -109,7 +107,7 @@ func extractNegativeTTL(response *dns.Msg) (uint32, bool) { return 0, false } -func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) (*dns.Msg, error) { +func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, message *dns.Msg, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) (*dns.Msg, error) { if len(message.Question) == 0 { if c.logger != nil { c.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) @@ -239,13 +237,10 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m disableCache = disableCache || (response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError) if responseChecker != nil { var rejected bool - // TODO: add accept_any rule and support to check response instead of addresses if response.Rcode != dns.RcodeSuccess && response.Rcode != dns.RcodeNameError { rejected = true - } else if len(response.Answer) == 0 { - rejected = !responseChecker(nil) } else { - rejected = !responseChecker(MessageToAddresses(response)) + rejected = !responseChecker(response) } if rejected { if !disableCache && c.rdrc != nil { @@ -315,7 +310,7 @@ func (c *Client) Exchange(ctx context.Context, transport adapter.DNSTransport, m return response, nil } -func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { domain = FqdnToDomain(domain) dnsName := dns.Fqdn(domain) var strategy C.DomainStrategy @@ -400,7 +395,7 @@ func (c *Client) storeCache(transport adapter.DNSTransport, question dns.Questio } } -func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(responseAddrs []netip.Addr) bool) ([]netip.Addr, error) { +func (c *Client) lookupToExchange(ctx context.Context, transport adapter.DNSTransport, name string, qType uint16, options adapter.DNSQueryOptions, responseChecker func(response *dns.Msg) bool) ([]netip.Addr, error) { question := dns.Question{ Name: name, Qtype: qType, @@ -515,25 +510,7 @@ func (c *Client) loadResponse(question dns.Question, transport adapter.DNSTransp } func MessageToAddresses(response *dns.Msg) []netip.Addr { - if response == nil || response.Rcode != dns.RcodeSuccess { - return nil - } - addresses := make([]netip.Addr, 0, len(response.Answer)) - for _, rawAnswer := range response.Answer { - switch answer := rawAnswer.(type) { - case *dns.A: - addresses = append(addresses, M.AddrFromIP(answer.A)) - case *dns.AAAA: - addresses = append(addresses, M.AddrFromIP(answer.AAAA)) - case *dns.HTTPS: - for _, value := range answer.SVCB.Value { - if value.Key() == dns.SVCB_IPV4HINT || value.Key() == dns.SVCB_IPV6HINT { - addresses = append(addresses, common.Map(strings.Split(value.String(), ","), M.ParseAddr)...) - } - } - } - } - return addresses + return adapter.DNSResponseAddresses(response) } func wrapError(err error) error { diff --git a/dns/repro_test.go b/dns/repro_test.go new file mode 100644 index 0000000000..113f7c49b9 --- /dev/null +++ b/dns/repro_test.go @@ -0,0 +1,111 @@ +package dns + +import ( + "context" + "net/netip" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestReproLookupWithRulesUsesRequestStrategy(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + var qTypes []uint16 + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + qTypes = append(qTypes, message.Question[0].Qtype) + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + }, + }) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + Strategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []uint16{mDNS.TypeA}, qTypes) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) +} + +func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} diff --git a/dns/router.go b/dns/router.go index 4f18959b7c..79ff81a573 100644 --- a/dns/router.go +++ b/dns/router.go @@ -5,11 +5,13 @@ import ( "errors" "net/netip" "strings" + "sync" "time" "github.com/sagernet/sing-box/adapter" "github.com/sagernet/sing-box/common/taskmonitor" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" R "github.com/sagernet/sing-box/route/rule" @@ -19,6 +21,7 @@ import ( F "github.com/sagernet/sing/common/format" "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" + "github.com/sagernet/sing/common/task" "github.com/sagernet/sing/contrab/freelru" "github.com/sagernet/sing/contrab/maphash" "github.com/sagernet/sing/service" @@ -27,17 +30,25 @@ import ( ) var _ adapter.DNSRouter = (*Router)(nil) +var _ adapter.DNSRuleSetUpdateValidator = (*Router)(nil) type Router struct { - ctx context.Context - logger logger.ContextLogger - transport adapter.DNSTransportManager - outbound adapter.OutboundManager - client adapter.DNSClient - rules []adapter.DNSRule - defaultDomainStrategy C.DomainStrategy - dnsReverseMapping freelru.Cache[netip.Addr, string] - platformInterface adapter.PlatformInterface + ctx context.Context + logger logger.ContextLogger + transport adapter.DNSTransportManager + outbound adapter.OutboundManager + client adapter.DNSClient + rawRules []option.DNSRule + rules []adapter.DNSRule + defaultDomainStrategy C.DomainStrategy + dnsReverseMapping freelru.Cache[netip.Addr, string] + platformInterface adapter.PlatformInterface + legacyDNSMode bool + rulesAccess sync.RWMutex + started bool + closing bool + addressFilterDeprecatedReported bool + ruleStrategyDeprecatedReported bool } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -46,6 +57,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp logger: logFactory.NewLogger("dns"), transport: service.FromContext[adapter.DNSTransportManager](ctx), outbound: service.FromContext[adapter.OutboundManager](ctx), + rawRules: make([]option.DNSRule, 0, len(options.Rules)), rules: make([]adapter.DNSRule, 0, len(options.Rules)), defaultDomainStrategy: C.DomainStrategy(options.Strategy), } @@ -74,13 +86,12 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp } func (r *Router) Initialize(rules []option.DNSRule) error { - for i, ruleOptions := range rules { - dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true) - if err != nil { - return E.Cause(err, "parse dns rule[", i, "]") - } - r.rules = append(r.rules, dnsRule) + r.rawRules = append(r.rawRules[:0], rules...) + newRules, _, _, err := r.buildRules(false) + if err != nil { + return err } + closeRules(newRules) return nil } @@ -92,32 +103,158 @@ func (r *Router) Start(stage adapter.StartStage) error { r.client.Start() monitor.Finish() - for i, rule := range r.rules { - monitor.Start("initialize DNS rule[", i, "]") - err := rule.Start() - monitor.Finish() - if err != nil { - return E.Cause(err, "initialize DNS rule[", i, "]") - } + monitor.Start("initialize DNS rules") + newRules, legacyDNSMode, modeFlags, err := r.buildRules(true) + monitor.Finish() + if err != nil { + return err + } + shouldReportAddressFilterDeprecated := legacyDNSMode && + !r.addressFilterDeprecatedReported && + common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) + shouldReportRuleStrategyDeprecated := legacyDNSMode && + !r.ruleStrategyDeprecatedReported && + modeFlags.neededFromStrategy + r.rulesAccess.Lock() + if r.closing { + r.rulesAccess.Unlock() + closeRules(newRules) + return nil + } + r.rules = newRules + r.legacyDNSMode = legacyDNSMode + r.started = true + if shouldReportAddressFilterDeprecated { + r.addressFilterDeprecatedReported = true + } + if shouldReportRuleStrategyDeprecated { + r.ruleStrategyDeprecatedReported = true + } + r.rulesAccess.Unlock() + if shouldReportAddressFilterDeprecated { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + } + if shouldReportRuleStrategyDeprecated { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSRuleStrategy) } } return nil } func (r *Router) Close() error { - monitor := taskmonitor.New(r.logger, C.StopTimeout) - var err error - for i, rule := range r.rules { - monitor.Start("close dns rule[", i, "]") - err = E.Append(err, rule.Close(), func(err error) error { - return E.Cause(err, "close dns rule[", i, "]") - }) - monitor.Finish() + r.rulesAccess.Lock() + if r.closing { + r.rulesAccess.Unlock() + return nil + } + r.closing = true + runtimeRules := r.rules + r.rules = nil + r.rulesAccess.Unlock() + closeRules(runtimeRules) + return nil +} + +func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleModeFlags, error) { + for i, ruleOptions := range r.rawRules { + err := R.ValidateNoNestedDNSRuleActions(ruleOptions) + if err != nil { + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") + } + } + router := service.FromContext[adapter.Router](r.ctx) + legacyDNSMode, modeFlags, err := resolveLegacyDNSMode(router, r.rawRules, nil) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + if !legacyDNSMode { + err = validateLegacyDNSModeDisabledRules(r.rawRules) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + } + err = validateEvaluateFakeIPRules(r.rawRules, r.transport) + if err != nil { + return nil, false, dnsRuleModeFlags{}, err + } + newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) + for i, ruleOptions := range r.rawRules { + var dnsRule adapter.DNSRule + dnsRule, err = R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) + if err != nil { + closeRules(newRules) + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") + } + newRules = append(newRules, dnsRule) + } + if startRules { + for i, rule := range newRules { + err = rule.Start() + if err != nil { + closeRules(newRules) + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "initialize DNS rule[", i, "]") + } + } + } + return newRules, legacyDNSMode, modeFlags, nil +} + +func closeRules(rules []adapter.DNSRule) { + for _, rule := range rules { + _ = rule.Close() + } +} + +func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.RuleSetMetadata) error { + if len(r.rawRules) == 0 { + return nil + } + router := service.FromContext[adapter.Router](r.ctx) + if router == nil { + return E.New("router service not found") + } + overrides := map[string]adapter.RuleSetMetadata{ + tag: metadata, + } + r.rulesAccess.RLock() + started := r.started + legacyDNSMode := r.legacyDNSMode + closing := r.closing + r.rulesAccess.RUnlock() + if closing { + return nil + } + if !started { + candidateLegacyDNSMode, _, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + if err != nil { + return err + } + if !candidateLegacyDNSMode { + return validateLegacyDNSModeDisabledRules(r.rawRules) + } + return nil + } + candidateLegacyDNSMode, flags, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + if err != nil { + return err + } + if legacyDNSMode { + if !candidateLegacyDNSMode && flags.disabled { + err := validateLegacyDNSModeDisabledRules(r.rawRules) + if err != nil { + return err + } + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + } + return nil } - return err + if candidateLegacyDNSMode { + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + } + return nil } -func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { +func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") @@ -126,22 +263,18 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if ruleIndex != -1 { currentRuleIndex = ruleIndex + 1 } - for ; currentRuleIndex < len(r.rules); currentRuleIndex++ { - currentRule := r.rules[currentRuleIndex] + for ; currentRuleIndex < len(rules); currentRuleIndex++ { + currentRule := rules[currentRuleIndex] if currentRule.WithAddressLimit() && !isAddressQuery { continue } metadata.ResetRuleCache() - if currentRule.Match(metadata) { - displayRuleIndex := currentRuleIndex - if displayRuleIndex != -1 { - displayRuleIndex += displayRuleIndex + 1 - } - ruleDescription := currentRule.String() - if ruleDescription != "" { - r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + metadata.DestinationAddressMatchFromResponse = false + if currentRule.LegacyPreMatch(metadata) { + if ruleDescription := currentRule.String(); ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] ", currentRule, " => ", currentRule.Action()) } else { - r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] => ", currentRule.Action()) + r.logger.DebugContext(ctx, "match[", currentRuleIndex, "] => ", currentRule.Action()) } switch action := currentRule.Action().(type) { case *R.RuleActionDNSRoute: @@ -166,14 +299,6 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, if action.ClientSubnet.IsValid() { options.ClientSubnet = action.ClientSubnet } - if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = legacyTransport.LegacyStrategy() - } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() - } - } return transport, currentRule, currentRuleIndex case *R.RuleActionDNSRouteOptions: if action.Strategy != C.DomainStrategyAsIS { @@ -196,15 +321,270 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } } transport := r.transport.Default() - if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = legacyTransport.LegacyStrategy() + return transport, nil, -1 +} + +func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) { + // Strategy is intentionally skipped here. A non-default DNS rule action strategy + // forces legacy mode via resolveLegacyDNSMode, so this path is only reachable + // when strategy remains at its default value. + if routeOptions.DisableCache { + options.DisableCache = true + } + if routeOptions.RewriteTTL != nil { + options.RewriteTTL = routeOptions.RewriteTTL + } + if routeOptions.ClientSubnet.IsValid() { + options.ClientSubnet = routeOptions.ClientSubnet + } +} + +type dnsRouteStatus uint8 + +const ( + dnsRouteStatusMissing dnsRouteStatus = iota + dnsRouteStatusSkipped + dnsRouteStatusResolved +) + +func (r *Router) resolveDNSRoute(server string, routeOptions R.RuleActionDNSRouteOptions, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus) { + transport, loaded := r.transport.Transport(server) + if !loaded { + return nil, dnsRouteStatusMissing + } + isFakeIP := transport.Type() == C.DNSTypeFakeIP + if isFakeIP && !allowFakeIP { + return transport, dnsRouteStatusSkipped + } + r.applyDNSRouteOptions(options, routeOptions) + if isFakeIP { + options.DisableCache = true + } + return transport, dnsRouteStatusResolved +} + +func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { + if ruleDescription := currentRule.String(); ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", ruleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "match[", ruleIndex, "] => ", currentRule.Action()) + } +} + +type exchangeWithRulesResult struct { + response *mDNS.Msg + transport adapter.DNSTransport + rejectAction *R.RuleActionReject + err error +} + +const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" + +func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { + metadata := adapter.ContextFrom(ctx) + if metadata == nil { + panic("no context") + } + effectiveOptions := options + var evaluatedResponse *mDNS.Msg + var evaluatedTransport adapter.DNSTransport + for currentRuleIndex, currentRule := range rules { + metadata.ResetRuleCache() + metadata.DNSResponse = evaluatedResponse + metadata.DestinationAddressMatchFromResponse = false + if !currentRule.Match(metadata) { + continue } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() + r.logRuleMatch(ctx, currentRuleIndex, currentRule) + switch action := currentRule.Action().(type) { + case *R.RuleActionDNSRouteOptions: + r.applyDNSRouteOptions(&effectiveOptions, *action) + case *R.RuleActionEvaluate: + queryOptions := effectiveOptions + transport, loaded := r.transport.Transport(action.Server) + if !loaded { + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + evaluatedResponse = nil + evaluatedTransport = nil + continue + } + r.applyDNSRouteOptions(&queryOptions, action.RuleActionDNSRouteOptions) + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + if err != nil { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + evaluatedResponse = nil + evaluatedTransport = nil + continue + } + evaluatedResponse = response + evaluatedTransport = transport + case *R.RuleActionRespond: + if evaluatedResponse == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + } + } + return exchangeWithRulesResult{ + response: evaluatedResponse, + transport: evaluatedTransport, + } + case *R.RuleActionDNSRoute: + queryOptions := effectiveOptions + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + case dnsRouteStatusSkipped: + continue + } + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return exchangeWithRulesResult{ + response: response, + transport: transport, + err: err, + } + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return exchangeWithRulesResult{ + response: &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, + rejectAction: action, + } + case C.RuleActionRejectMethodDrop: + return exchangeWithRulesResult{ + rejectAction: action, + err: tun.ErrDrop, + } + } + case *R.RuleActionPredefined: + return exchangeWithRulesResult{ + response: action.Response(message), + } } } - return transport, nil, -1 + transport := r.transport.Default() + exchangeOptions := effectiveOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return exchangeWithRulesResult{ + response: response, + transport: transport, + err: err, + } +} + +func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { + if options.LookupStrategy != C.DomainStrategyAsIS { + return options.LookupStrategy + } + if options.Strategy != C.DomainStrategyAsIS { + return options.Strategy + } + return r.defaultDomainStrategy +} + +func withLookupQueryMetadata(ctx context.Context, qType uint16) context.Context { + ctx, metadata := adapter.ExtendContext(ctx) + metadata.QueryType = qType + metadata.IPVersion = 0 + switch qType { + case mDNS.TypeA: + metadata.IPVersion = 4 + case mDNS.TypeAAAA: + metadata.IPVersion = 6 + } + return ctx +} + +func filterAddressesByQueryType(addresses []netip.Addr, qType uint16) []netip.Addr { + switch qType { + case mDNS.TypeA: + return common.Filter(addresses, func(address netip.Addr) bool { + return address.Is4() + }) + case mDNS.TypeAAAA: + return common.Filter(addresses, func(address netip.Addr) bool { + return address.Is6() + }) + default: + return addresses + } +} + +func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + strategy := r.resolveLookupStrategy(options) + lookupOptions := options + if strategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy + } + if strategy == C.DomainStrategyIPv4Only { + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) + } + if strategy == C.DomainStrategyIPv6Only { + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) + } + var ( + response4 []netip.Addr + response6 []netip.Addr + ) + var group task.Group + group.Append("exchange4", func(ctx context.Context) error { + result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) + response4 = result + return err + }) + group.Append("exchange6", func(ctx context.Context) error { + result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) + response6 = result + return err + }) + err := group.Run(ctx) + if len(response4) == 0 && len(response6) == 0 { + return nil, err + } + return sortAddresses(response4, response6, strategy), nil +} + +func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRule, domain string, qType uint16, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{{ + Name: mDNS.Fqdn(domain), + Qtype: qType, + Qclass: mDNS.ClassINET, + }}, + } + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) + if exchangeResult.rejectAction != nil { + return nil, exchangeResult.rejectAction.Error(ctx) + } + if exchangeResult.err != nil { + return nil, exchangeResult.err + } + if exchangeResult.response.Rcode != mDNS.RcodeSuccess { + return nil, RcodeError(exchangeResult.response.Rcode) + } + return filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType), nil } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { @@ -220,6 +600,13 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } return &responseMessage, nil } + r.rulesAccess.RLock() + defer r.rulesAccess.RUnlock() + if r.closing { + return nil, E.New("dns router closed") + } + rules := r.rules + legacyDNSMode := r.legacyDNSMode r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) var ( response *mDNS.Msg @@ -230,6 +617,8 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte ctx, metadata = adapter.ExtendContext(ctx) metadata.Destination = M.Socksaddr{} metadata.QueryType = message.Question[0].Qtype + metadata.DNSResponse = nil + metadata.DestinationAddressMatchFromResponse = false switch metadata.QueryType { case mDNS.TypeA: metadata.IPVersion = 4 @@ -239,18 +628,13 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte metadata.Domain = FqdnToDomain(message.Question[0].Name) if options.Transport != nil { transport = options.Transport - if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = legacyTransport.LegacyStrategy() - } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() - } - } if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } response, err = r.client.Exchange(ctx, transport, message, options, nil) + } else if !legacyDNSMode { + exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) + response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { var ( rule adapter.DNSRule @@ -260,7 +644,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte for { dnsCtx := adapter.OverrideContext(ctx) dnsOptions := options - transport, rule, ruleIndex = r.matchDNS(ctx, true, ruleIndex, isAddressQuery(message), &dnsOptions) + transport, rule, ruleIndex = r.matchDNS(ctx, rules, true, ruleIndex, isAddressQuery(message), &dnsOptions) if rule != nil { switch action := rule.Action().(type) { case *R.RuleActionReject: @@ -278,7 +662,9 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte return nil, tun.ErrDrop } case *R.RuleActionPredefined: - return action.Response(message), nil + err = nil + response = action.Response(message) + goto done } } responseCheck := addressLimitResponseCheck(rule, metadata) @@ -306,6 +692,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte break } } +done: if err != nil { return nil, err } @@ -325,6 +712,13 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + r.rulesAccess.RLock() + defer r.rulesAccess.RUnlock() + if r.closing { + return nil, E.New("dns router closed") + } + rules := r.rules + legacyDNSMode := r.legacyDNSMode var ( responseAddrs []netip.Addr err error @@ -338,6 +732,8 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ r.logger.DebugContext(ctx, "response rejected for ", domain, " (cached)") } else if errors.Is(err, ErrResponseRejected) { r.logger.DebugContext(ctx, "response rejected for ", domain) + } else if R.IsRejected(err) { + r.logger.DebugContext(ctx, "lookup rejected for ", domain) } else { r.logger.ErrorContext(ctx, E.Cause(err, "lookup failed for ", domain)) } @@ -350,20 +746,16 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ ctx, metadata := adapter.ExtendContext(ctx) metadata.Destination = M.Socksaddr{} metadata.Domain = FqdnToDomain(domain) + metadata.DNSResponse = nil + metadata.DestinationAddressMatchFromResponse = false if options.Transport != nil { transport := options.Transport - if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = legacyTransport.LegacyStrategy() - } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() - } - } if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) + } else if !legacyDNSMode { + responseAddrs, err = r.lookupWithRules(ctx, rules, domain, options) } else { var ( transport adapter.DNSTransport @@ -374,7 +766,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ for { dnsCtx := adapter.OverrideContext(ctx) dnsOptions := options - transport, rule, ruleIndex = r.matchDNS(ctx, false, ruleIndex, true, &dnsOptions) + transport, rule, ruleIndex = r.matchDNS(ctx, rules, false, ruleIndex, true, &dnsOptions) if rule != nil { switch action := rule.Action().(type) { case *R.RuleActionReject: @@ -425,15 +817,14 @@ func isAddressQuery(message *mDNS.Msg) bool { return false } -func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundContext) func(responseAddrs []netip.Addr) bool { +func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundContext) func(response *mDNS.Msg) bool { if rule == nil || !rule.WithAddressLimit() { return nil } responseMetadata := *metadata - return func(responseAddrs []netip.Addr) bool { + return func(response *mDNS.Msg) bool { checkMetadata := responseMetadata - checkMetadata.DestinationAddresses = responseAddrs - return rule.MatchAddressLimit(&checkMetadata) + return rule.MatchAddressLimit(&checkMetadata, response) } } @@ -458,3 +849,268 @@ func (r *Router) ResetNetwork() { transport.Reset() } } + +func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) bool { + if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + return true + } + return !rule.MatchResponse && (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) +} + +func hasResponseMatchFields(rule option.DefaultDNSRule) bool { + return rule.ResponseRcode != nil || + len(rule.ResponseAnswer) > 0 || + len(rule.ResponseNs) > 0 || + len(rule.ResponseExtra) > 0 +} + +func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { + return rule.MatchResponse || + hasResponseMatchFields(rule) || + rule.Action == C.RuleActionTypeEvaluate || + rule.Action == C.RuleActionTypeRespond || + rule.IPVersion > 0 || + len(rule.QueryType) > 0 +} + +type dnsRuleModeFlags struct { + disabled bool + needed bool + neededFromStrategy bool +} + +func (f *dnsRuleModeFlags) merge(other dnsRuleModeFlags) { + f.disabled = f.disabled || other.disabled + f.needed = f.needed || other.needed + f.neededFromStrategy = f.neededFromStrategy || other.neededFromStrategy +} + +func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (bool, dnsRuleModeFlags, error) { + flags, err := dnsRuleModeRequirements(router, rules, metadataOverrides) + if err != nil { + return false, flags, err + } + if flags.disabled && flags.neededFromStrategy { + return false, flags, E.New(deprecated.OptionLegacyDNSRuleStrategy.MessageWithLink()) + } + if flags.disabled { + return false, flags, nil + } + return flags.needed, flags, nil +} + +func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + var flags dnsRuleModeFlags + for i, rule := range rules { + ruleFlags, err := dnsRuleModeRequirementsInRule(router, rule, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, E.Cause(err, "dns rule[", i, "]") + } + flags.merge(ruleFlags) + } + return flags, nil +} + +func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + switch rule.Type { + case "", C.RuleTypeDefault: + return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions, metadataOverrides) + case C.RuleTypeLogical: + flags := dnsRuleModeFlags{ + disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate || dnsRuleActionType(rule) == C.RuleActionTypeRespond, + neededFromStrategy: dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction), + } + flags.needed = flags.neededFromStrategy + for i, subRule := range rule.LogicalOptions.Rules { + subFlags, err := dnsRuleModeRequirementsInRule(router, subRule, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, E.Cause(err, "sub rule[", i, "]") + } + flags.merge(subFlags) + } + return flags, nil + default: + return dnsRuleModeFlags{}, nil + } +} + +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { + flags := dnsRuleModeFlags{ + disabled: defaultRuleDisablesLegacyDNSMode(rule), + neededFromStrategy: dnsRuleActionHasStrategy(rule.DNSRuleAction), + } + flags.needed = defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule) || flags.neededFromStrategy + if len(rule.RuleSet) == 0 { + return flags, nil + } + if router == nil { + return dnsRuleModeFlags{}, E.New("router service not found") + } + for _, tag := range rule.RuleSet { + metadata, err := lookupDNSRuleSetMetadata(router, tag, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, err + } + // ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. + flags.disabled = flags.disabled || metadata.ContainsDNSQueryTypeRule + if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { + flags.needed = true + } + } + return flags, nil +} + +func lookupDNSRuleSetMetadata(router adapter.Router, tag string, metadataOverrides map[string]adapter.RuleSetMetadata) (adapter.RuleSetMetadata, error) { + if metadataOverrides != nil { + if metadata, loaded := metadataOverrides[tag]; loaded { + return metadata, nil + } + } + ruleSet, loaded := router.RuleSet(tag) + if !loaded { + return adapter.RuleSetMetadata{}, E.New("rule-set not found: ", tag) + } + return ruleSet.Metadata(), nil +} + +func referencedDNSRuleSetTags(rules []option.DNSRule) []string { + tagMap := make(map[string]bool) + var walkRule func(rule option.DNSRule) + walkRule = func(rule option.DNSRule) { + switch rule.Type { + case "", C.RuleTypeDefault: + for _, tag := range rule.DefaultOptions.RuleSet { + tagMap[tag] = true + } + case C.RuleTypeLogical: + for _, subRule := range rule.LogicalOptions.Rules { + walkRule(subRule) + } + } + } + for _, rule := range rules { + walkRule(rule) + } + tags := make([]string, 0, len(tagMap)) + for tag := range tagMap { + if tag != "" { + tags = append(tags, tag) + } + } + return tags +} + +func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { + var seenEvaluate bool + for i, rule := range rules { + requiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(rule) + if err != nil { + return E.Cause(err, "validate dns rule[", i, "]") + } + if requiresPriorEvaluate && !seenEvaluate { + return E.New("dns rule[", i, "]: response-based matching requires a preceding evaluate action") + } + if dnsRuleActionType(rule) == C.RuleActionTypeEvaluate { + seenEvaluate = true + } + } + return nil +} + +func validateEvaluateFakeIPRules(rules []option.DNSRule, transportManager adapter.DNSTransportManager) error { + if transportManager == nil { + return nil + } + for i, rule := range rules { + if dnsRuleActionType(rule) != C.RuleActionTypeEvaluate { + continue + } + server := dnsRuleActionServer(rule) + if server == "" { + continue + } + transport, loaded := transportManager.Transport(server) + if !loaded || transport.Type() != C.DNSTypeFakeIP { + continue + } + return E.New("dns rule[", i, "]: evaluate action cannot use fakeip server: ", server) + } + return nil +} + +func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { + switch rule.Type { + case "", C.RuleTypeDefault: + return validateLegacyDNSModeDisabledDefaultRule(rule.DefaultOptions) + case C.RuleTypeLogical: + requiresPriorEvaluate := dnsRuleActionType(rule) == C.RuleActionTypeRespond + for i, subRule := range rule.LogicalOptions.Rules { + subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(subRule) + if err != nil { + return false, E.Cause(err, "sub rule[", i, "]") + } + requiresPriorEvaluate = requiresPriorEvaluate || subRequiresPriorEvaluate + } + return requiresPriorEvaluate, nil + default: + return false, nil + } +} + +func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, error) { + hasResponseRecords := hasResponseMatchFields(rule) + if (hasResponseRecords || len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { + return false, E.New("Response Match Fields (ip_cidr, ip_is_private, response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") + } + // Intentionally do not reject rule_set here. A referenced rule set may mix + // destination-IP predicates with pre-response predicates such as domain items. + // When match_response is false, those destination-IP branches fail closed during + // pre-response evaluation instead of consuming DNS response state, while sibling + // non-response branches remain matchable. + if rule.IPAcceptAny { //nolint:staticcheck + return false, E.New(deprecated.OptionIPAcceptAny.MessageWithLink()) + } + if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + return rule.MatchResponse || rule.Action == C.RuleActionTypeRespond, nil +} + +func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { + switch action.Action { + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: + return C.DomainStrategy(action.RouteOptions.Strategy) != C.DomainStrategyAsIS + case C.RuleActionTypeRouteOptions: + return C.DomainStrategy(action.RouteOptionsOptions.Strategy) != C.DomainStrategyAsIS + default: + return false + } +} + +func dnsRuleActionType(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + if rule.DefaultOptions.Action == "" { + return C.RuleActionTypeRoute + } + return rule.DefaultOptions.Action + case C.RuleTypeLogical: + if rule.LogicalOptions.Action == "" { + return C.RuleActionTypeRoute + } + return rule.LogicalOptions.Action + default: + return "" + } +} + +func dnsRuleActionServer(rule option.DNSRule) string { + switch rule.Type { + case "", C.RuleTypeDefault: + return rule.DefaultOptions.RouteOptions.Server + case C.RuleTypeLogical: + return rule.LogicalOptions.RouteOptions.Server + default: + return "" + } +} diff --git a/dns/router_test.go b/dns/router_test.go new file mode 100644 index 0000000000..54213b23c3 --- /dev/null +++ b/dns/router_test.go @@ -0,0 +1,2547 @@ +package dns + +import ( + "context" + "net" + "net/netip" + "strings" + "sync" + "testing" + "time" + + "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/log" + "github.com/sagernet/sing-box/option" + rulepkg "github.com/sagernet/sing-box/route/rule" + "github.com/sagernet/sing-tun" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +type fakeDNSTransport struct { + tag string + transportType string +} + +func (t *fakeDNSTransport) Start(adapter.StartStage) error { return nil } +func (t *fakeDNSTransport) Close() error { return nil } +func (t *fakeDNSTransport) Type() string { return t.transportType } +func (t *fakeDNSTransport) Tag() string { return t.tag } +func (t *fakeDNSTransport) Dependencies() []string { return nil } +func (t *fakeDNSTransport) Reset() {} +func (t *fakeDNSTransport) Exchange(context.Context, *mDNS.Msg) (*mDNS.Msg, error) { + return nil, E.New("unused transport exchange") +} + +type fakeDNSTransportManager struct { + defaultTransport adapter.DNSTransport + transports map[string]adapter.DNSTransport +} + +func (m *fakeDNSTransportManager) Start(adapter.StartStage) error { return nil } +func (m *fakeDNSTransportManager) Close() error { return nil } +func (m *fakeDNSTransportManager) Transports() []adapter.DNSTransport { + transports := make([]adapter.DNSTransport, 0, len(m.transports)) + for _, transport := range m.transports { + transports = append(transports, transport) + } + return transports +} + +func (m *fakeDNSTransportManager) Transport(tag string) (adapter.DNSTransport, bool) { + transport, loaded := m.transports[tag] + return transport, loaded +} +func (m *fakeDNSTransportManager) Default() adapter.DNSTransport { return m.defaultTransport } +func (m *fakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { + return nil +} +func (m *fakeDNSTransportManager) Remove(string) error { return nil } +func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, string, string, any) error { + return E.New("unsupported") +} + +type fakeDNSClient struct { + beforeExchange func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg) + exchange func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) + lookupWithCtx func(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) + lookup func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) +} + +type fakeDeprecatedManager struct { + features []deprecated.Note +} + +type fakeRouter struct { + access sync.RWMutex + ruleSets map[string]adapter.RuleSet +} + +func (r *fakeRouter) Start(adapter.StartStage) error { return nil } +func (r *fakeRouter) Close() error { return nil } +func (r *fakeRouter) PreMatch(metadata adapter.InboundContext, _ tun.DirectRouteContext, _ time.Duration, _ bool) (tun.DirectRouteDestination, error) { + return nil, nil +} + +func (r *fakeRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { + return nil +} + +func (r *fakeRouter) RoutePacketConnection(context.Context, N.PacketConn, adapter.InboundContext) error { + return nil +} + +func (r *fakeRouter) RouteConnectionEx(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *fakeRouter) RoutePacketConnectionEx(context.Context, N.PacketConn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *fakeRouter) RuleSet(tag string) (adapter.RuleSet, bool) { + r.access.RLock() + defer r.access.RUnlock() + ruleSet, loaded := r.ruleSets[tag] + return ruleSet, loaded +} + +func (r *fakeRouter) setRuleSet(tag string, ruleSet adapter.RuleSet) { + r.access.Lock() + defer r.access.Unlock() + if r.ruleSets == nil { + r.ruleSets = make(map[string]adapter.RuleSet) + } + r.ruleSets[tag] = ruleSet +} +func (r *fakeRouter) Rules() []adapter.Rule { return nil } +func (r *fakeRouter) NeedFindProcess() bool { return false } +func (r *fakeRouter) NeedFindNeighbor() bool { return false } +func (r *fakeRouter) NeighborResolver() adapter.NeighborResolver { return nil } +func (r *fakeRouter) AppendTracker(adapter.ConnectionTracker) {} +func (r *fakeRouter) ResetNetwork() {} + +type fakeRuleSet struct { + access sync.Mutex + metadata adapter.RuleSetMetadata + metadataRead func(adapter.RuleSetMetadata) adapter.RuleSetMetadata + match func(*adapter.InboundContext) bool + callbacks list.List[adapter.RuleSetUpdateCallback] + refs int + afterIncrementReference func() + beforeDecrementReference func() +} + +func (s *fakeRuleSet) Name() string { return "fake-rule-set" } +func (s *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } +func (s *fakeRuleSet) PostStart() error { return nil } +func (s *fakeRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.Lock() + metadata := s.metadata + metadataRead := s.metadataRead + s.access.Unlock() + if metadataRead != nil { + return metadataRead(metadata) + } + return metadata +} +func (s *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *fakeRuleSet) IncRef() { + s.access.Lock() + s.refs++ + afterIncrementReference := s.afterIncrementReference + s.access.Unlock() + if afterIncrementReference != nil { + afterIncrementReference() + } +} + +func (s *fakeRuleSet) DecRef() { + s.access.Lock() + beforeDecrementReference := s.beforeDecrementReference + s.access.Unlock() + if beforeDecrementReference != nil { + beforeDecrementReference() + } + s.access.Lock() + defer s.access.Unlock() + s.refs-- + if s.refs < 0 { + panic("rule-set: negative refs") + } +} +func (s *fakeRuleSet) Cleanup() {} +func (s *fakeRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.PushBack(callback) +} + +func (s *fakeRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) { + s.access.Lock() + defer s.access.Unlock() + s.callbacks.Remove(element) +} +func (s *fakeRuleSet) Close() error { return nil } +func (s *fakeRuleSet) Match(metadata *adapter.InboundContext) bool { + s.access.Lock() + match := s.match + s.access.Unlock() + if match != nil { + return match(metadata) + } + return true +} +func (s *fakeRuleSet) String() string { return "fake-rule-set" } +func (s *fakeRuleSet) updateMetadata(metadata adapter.RuleSetMetadata) { + s.access.Lock() + s.metadata = metadata + callbacks := s.callbacks.Array() + s.access.Unlock() + for _, callback := range callbacks { + callback(s) + } +} + +func (s *fakeRuleSet) snapshotCallbacks() []adapter.RuleSetUpdateCallback { + s.access.Lock() + defer s.access.Unlock() + return s.callbacks.Array() +} + +func (s *fakeRuleSet) refCount() int { + s.access.Lock() + defer s.access.Unlock() + return s.refs +} + +func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { + m.features = append(m.features, feature) +} + +func (c *fakeDNSClient) Start() {} + +func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func(*mDNS.Msg) bool) (*mDNS.Msg, error) { + if c.beforeExchange != nil { + c.beforeExchange(ctx, transport, message) + } + if c.exchange == nil { + if len(message.Question) != 1 { + return nil, E.New("unused client exchange") + } + var ( + addresses []netip.Addr + response *mDNS.Msg + err error + ) + if c.lookupWithCtx != nil { + addresses, response, err = c.lookupWithCtx(ctx, transport, FqdnToDomain(message.Question[0].Name), adapter.DNSQueryOptions{}) + } else if c.lookup != nil { + addresses, response, err = c.lookup(transport, FqdnToDomain(message.Question[0].Name), adapter.DNSQueryOptions{}) + } else { + return nil, E.New("unused client exchange") + } + if err != nil { + return nil, err + } + if response != nil { + return response, nil + } + return FixedResponse(0, message.Question[0], addresses, 60), nil + } + return c.exchange(transport, message) +} + +func (c *fakeDNSClient) Lookup(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { + if c.lookup == nil && c.lookupWithCtx == nil { + return nil, E.New("unused client lookup") + } + var ( + addresses []netip.Addr + response *mDNS.Msg + err error + ) + if c.lookupWithCtx != nil { + addresses, response, err = c.lookupWithCtx(ctx, transport, domain, options) + } else { + addresses, response, err = c.lookup(transport, domain, options) + } + if err != nil { + return nil, err + } + if response == nil { + response = FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), addresses, 60) + } + if responseChecker != nil && !responseChecker(response) { + return nil, ErrResponseRejected + } + if addresses != nil { + return addresses, nil + } + return MessageToAddresses(response), nil +} + +func (c *fakeDNSClient) ClearCache() {} + +func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + router := newTestRouterWithContext(t, context.Background(), rules, transportManager, client) + t.Cleanup(func() { + router.Close() + }) + return router +} + +func newTestRouterWithContext(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + return newTestRouterWithContextAndLogger(t, ctx, rules, transportManager, client, log.NewNOPFactory().NewLogger("dns")) +} + +func newTestRouterWithContextAndLogger(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient, dnsLogger log.ContextLogger) *Router { + t.Helper() + router := &Router{ + ctx: ctx, + logger: dnsLogger, + transport: transportManager, + client: client, + rawRules: make([]option.DNSRule, 0, len(rules)), + rules: make([]adapter.DNSRule, 0, len(rules)), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + if rules != nil { + err := router.Initialize(rules) + require.NoError(t, err) + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + } + return router +} + +func waitForLogMessageContaining(t *testing.T, entries <-chan log.Entry, done <-chan struct{}, substring string) log.Entry { + t.Helper() + timeout := time.After(time.Second) + for { + select { + case entry, ok := <-entries: + if !ok { + t.Fatal("log subscription closed") + } + if strings.Contains(entry.Message, substring) { + return entry + } + case <-done: + t.Fatal("log subscription closed") + case <-timeout: + t.Fatalf("timed out waiting for log message containing %q", substring) + } + } +} + +func fixedQuestion(name string, qType uint16) mDNS.Question { + return mDNS.Question{ + Name: mDNS.Fqdn(name), + Qtype: qType, + Qclass: mDNS.ClassINET, + } +} + +func mustRecord(t *testing.T, record string) option.DNSRecordOptions { + t.Helper() + var value option.DNSRecordOptions + require.NoError(t, value.UnmarshalJSON([]byte(`"`+record+`"`))) + return value +} + +func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "query-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "query-set": ruleSet, + }, + }) + + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err = router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"query-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }, + }) + require.ErrorContains(t, err, "Response Match Fields") + require.ErrorContains(t, err, "require match_response") +} + +func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "legacy-ipcidr-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + IPCIDR: badoption.Listable[string]{"10.0.0.0/8"}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "legacy-ipcidr-set": ruleSet, + }, + }) + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"legacy-ipcidr-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, "private", transport.Tag()) + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + }, + }) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) +} + +func TestRuleSetUpdateReleasesOldRuleSetRefs(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + }) + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + + require.Equal(t, 1, fakeSet.refCount()) + + fakeSet.updateMetadata(adapter.RuleSetMetadata{}) + require.Equal(t, 1, fakeSet.refCount()) + + fakeSet.updateMetadata(adapter.RuleSetMetadata{}) + require.Equal(t, 1, fakeSet.refCount()) + + require.NoError(t, router.Close()) + require.Zero(t, fakeSet.refCount()) +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldDisableLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "require match_response") +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetOnlyLegacyModeSwitchToNew(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") +} + +func TestValidateRuleSetMetadataUpdateBeforeStartUsesStartupValidation(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + rules: make([]adapter.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }) + require.NoError(t, err) + require.False(t, router.started) + + err = router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "require match_response") +} + +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("1.1.1.1")}, nil, nil + }, + }) + require.False(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") +} + +func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNonLegacyDNSMode(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + require.NoError(t, err) +} + +func TestValidateRuleSetMetadataUpdateAllowsRelaxingLegacyRequirement(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil + }, + }) + require.True(t, router.legacyDNSMode) + + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{}) + require.NoError(t, err) +} + +func TestCloseWaitsForInFlightLookupUntilContextCancellation(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} + lookupStarted := make(chan struct{}) + var lookupStartedOnce sync.Once + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": selectedTransport, + }, + }, &fakeDNSClient{ + lookupWithCtx: func(ctx context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "selected", transport.Tag()) + require.Equal(t, "example.com", domain) + lookupStartedOnce.Do(func() { + close(lookupStarted) + }) + <-ctx.Done() + return nil, nil, ctx.Err() + }, + }) + + lookupCtx, cancelLookup := context.WithCancel(context.Background()) + defer cancelLookup() + var ( + lookupErr error + closeErr error + ) + lookupDone := make(chan struct{}) + go func() { + _, lookupErr = router.Lookup(lookupCtx, "example.com", adapter.DNSQueryOptions{}) + close(lookupDone) + }() + + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("lookup did not reach DNS client") + } + + closeDone := make(chan struct{}) + go func() { + closeErr = router.Close() + close(closeDone) + }() + + select { + case <-closeDone: + t.Fatal("close finished before lookup context cancellation") + default: + } + + cancelLookup() + + select { + case <-lookupDone: + case <-time.After(time.Second): + t.Fatal("lookup did not finish after cancellation") + } + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatal("close did not finish after lookup cancellation") + } + + require.ErrorIs(t, lookupErr, context.Canceled) + require.NoError(t, closeErr) +} + +func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + client := &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + case "default": + t.Fatal("default transport should not be used when legacy rule matches after response") + } + return nil, nil, E.New("unexpected transport") + }, + } + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, client) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) +} + +func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + var lookupAccess sync.Mutex + var lookupTags []string + recordLookup := func(tag string) { + lookupAccess.Lock() + lookupTags = append(lookupTags, tag) + lookupAccess.Unlock() + } + currentLookupTags := func() []string { + lookupAccess.Lock() + defer lookupAccess.Unlock() + return append([]string(nil), lookupTags...) + } + client := &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + recordLookup(transport.Tag()) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) + return MessageToAddresses(response), response, nil + case "default": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) + return MessageToAddresses(response), response, nil + } + return nil, nil, E.New("unexpected transport") + }, + } + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, client) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) +} + +func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testing.T) { + t.Parallel() + + ctx := context.Background() + ruleSet, err := rulepkg.NewRuleSet(ctx, log.NewNOPFactory().NewLogger("router"), option.RuleSet{ + Type: C.RuleSetTypeInline, + Tag: "legacy-ipcidr-set", + InlineOptions: option.PlainRuleSet{ + Rules: []option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + IPCIDR: badoption.Listable[string]{"10.0.0.0/8"}, + }, + }}, + }, + }) + require.NoError(t, err) + ctx = service.ContextWith[adapter.Router](ctx, &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "legacy-ipcidr-set": ruleSet, + }, + }) + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + var lookupAccess sync.Mutex + var lookupTags []string + recordLookup := func(tag string) { + lookupAccess.Lock() + lookupTags = append(lookupTags, tag) + lookupAccess.Unlock() + } + currentLookupTags := func() []string { + lookupAccess.Lock() + defer lookupAccess.Unlock() + return append([]string(nil), lookupTags...) + } + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"legacy-ipcidr-set"}, + RuleSetIPCIDRAcceptEmpty: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "private"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "private": privateTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "example.com", domain) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + recordLookup(transport.Tag()) + switch transport.Tag() { + case "private": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) + return MessageToAddresses(response), response, nil + case "default": + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) + return MessageToAddresses(response), response, nil + } + return nil, nil, E.New("unexpected transport") + }, + }) + + require.True(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRcodeRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeNameError, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rcode := option.DNSRCode(mDNS.RcodeNameError) + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseRcode: &rcode, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseNsRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + nsRecord := mustRecord(t, "example.com. IN NS ns1.example.com.") + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{message.Question[0]}, + Ns: []mDNS.RR{nsRecord.Build()}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseNs: badoption.Listable[option.DNSRecordOptions]{nsRecord}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseExtraRoute(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + extraRecord := mustRecord(t, "ns1.example.com. IN A 192.0.2.53") + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{message.Question[0]}, + Extra: []mDNS.RR{extraRecord.Build()}, + }, nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseExtra: badoption.Listable[option.DNSRecordOptions]{extraRecord}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + var inspectedSelected bool + client := &fakeDNSClient{ + beforeExchange: func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg) { + if transport.Tag() != "selected" { + return + } + inspectedSelected = true + metadata := adapter.ContextFrom(ctx) + require.NotNil(t, metadata) + require.Empty(t, metadata.DestinationAddresses) + require.NotNil(t, metadata.DNSResponse) + }, + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.True(t, inspectedSelected) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsResponse(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "missing"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "first-upstream": &fakeDNSTransport{tag: "first-upstream", transportType: C.DNSTypeUDP}, + "second-upstream": &fakeDNSTransport{tag: "second-upstream", transportType: C.DNSTypeUDP}, + "first-match": &fakeDNSTransport{tag: "first-match", transportType: C.DNSTypeUDP}, + "second-match": &fakeDNSTransport{tag: "second-match", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "first-upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case "second-upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case "first-match": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("7.7.7.7")}, 60), nil + case "second-match": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "first-upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "second-upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "first-match"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 2.2.2.2")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "second-match"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + +func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + invert bool + expectedAddr netip.Addr + }{ + { + name: "plain match_response rule stays false", + expectedAddr: netip.MustParseAddr("4.4.4.4"), + }, + { + name: "invert match_response rule becomes true", + invert: true, + expectedAddr: netip.MustParseAddr("8.8.8.8"), + }, + } + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return nil, E.New("upstream exchange failed") + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + Invert: testCase.invert, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{testCase.expectedAddr}, MessageToAddresses(response)) + }) + } +} + +func TestExchangeLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { + t.Parallel() + + var exchanges []string + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + exchanges = append(exchanges, transport.Tag()) + require.Equal(t, "upstream", transport.Tag()) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + }, + }) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []string{"upstream"}, exchanges) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, MessageToAddresses(response)) +} + +func TestLookupLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "upstream", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + default: + return nil, E.New("unexpected qtype") + } + }, + }) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{ + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2001:db8::1"), + }, addresses) +} + +func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsError(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, _ *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "upstream", transport.Tag()) + return nil, E.New("upstream exchange failed") + }, + }) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.Nil(t, response) + require.ErrorContains(t, err, dnsRespondMissingResponseMessage) +} + +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForExchangeFailure(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return nil, E.New("ipv6 failed") + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) +} + +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForRcodeError(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, nil, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + case mDNS.TypeAAAA: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeNameError, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) +} + +func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Equal(t, "default", transport.Tag()) + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) +} + +func TestExchangeLegacyDNSModeDisabledAllowsRouteFakeIPRule(t *testing.T) { + t.Parallel() + + fakeTransport := &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP} + router := newTestRouter(t, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "fake": fakeTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + require.Same(t, fakeTransport, transport) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("198.18.0.1")}, 60), nil + }, + }) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("198.18.0.1")}, MessageToAddresses(response)) +} + +func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") +} + +func TestInitializeRejectsEvaluateFakeIPServerInDefaultRule(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{transports: map[string]adapter.DNSTransport{"fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}}}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}) + require.ErrorContains(t, err, "evaluate action cannot use fakeip server") + require.ErrorContains(t, err, "fake") +} + +func TestInitializeRejectsEvaluateFakeIPServerInLogicalRule(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{transports: map[string]adapter.DNSTransport{"fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}}}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }}) + require.ErrorContains(t, err, "evaluate action cannot use fakeip server") + require.ErrorContains(t, err, "fake") +} + +func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchResponse(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRouteOptions, + RouteOptionsOptions: option.DNSRouteOptionsActionOptions{ + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") +} + +func TestInitializeRejectsDNSMatchResponseWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsDNSRespondWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsLogicalDNSRespondWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRespond, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeRejectsEvaluateRuleWithResponseMatchWithoutPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.ErrorContains(t, err, "preceding evaluate action") +} + +func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t *testing.T) { + t.Parallel() + + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 2), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"bootstrap.example"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "bootstrap"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + }, + }, + }, + }, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }) + require.NoError(t, err) +} + +func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDefault, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.Nil(t, addresses) + require.Error(t, err) + require.True(t, rulepkg.IsRejected(err)) +} + +func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDefault, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, mDNS.RcodeRefused, response.Rcode) + require.Equal(t, []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, response.Question) +} + +func TestExchangeLegacyDNSModeDisabledReturnsDropErrorForRejectDropAction(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodDrop, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.Nil(t, response) + require.ErrorIs(t, err, tun.ErrDrop) +} + +func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + router := newTestRouter(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypePredefined, + PredefinedOptions: option.DNSRouteActionPredefined{ + Answer: badoption.Listable[option.DNSRecordOptions]{ + mustRecord(t, "example.com. IN A 1.1.1.1"), + mustRecord(t, "example.com. IN AAAA 2001:db8::1"), + }, + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{}) + require.False(t, router.legacyDNSMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{ + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2001:db8::1"), + }, addresses) +} + +func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { + t.Parallel() + + transportManager := &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + transports: map[string]adapter.DNSTransport{ + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + }, + } + client := &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + case "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + default: + return nil, E.New("unexpected transport") + } + }, + } + rules := []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + }, + }, + }, + { + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeOr, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, + }, + } + router := newTestRouter(t, rules, transportManager, client) + + response, err := router.Exchange(context.Background(), &mDNS.Msg{ + Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) +} + +func TestLegacyDNSModeReportsLegacyAddressFilterDeprecation(t *testing.T) { + t.Parallel() + + manager := &fakeDeprecatedManager{} + ctx := service.ContextWith[deprecated.Manager](context.Background(), manager) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + client: &fakeDNSClient{}, + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.NoError(t, err) + + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + require.Len(t, manager.features, 1) + require.Equal(t, deprecated.OptionLegacyDNSAddressFilter.Name, manager.features[0].Name) +} + +func TestLegacyDNSModeReportsDNSRuleStrategyDeprecation(t *testing.T) { + t.Parallel() + + manager := &fakeDeprecatedManager{} + ctx := service.ContextWith[deprecated.Manager](context.Background(), manager) + router := &Router{ + ctx: ctx, + logger: log.NewNOPFactory().NewLogger("dns"), + client: &fakeDNSClient{}, + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }}) + require.NoError(t, err) + + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + require.Len(t, manager.features, 1) + require.Equal(t, deprecated.OptionLegacyDNSRuleStrategy.Name, manager.features[0].Name) +} diff --git a/dns/transport_adapter.go b/dns/transport_adapter.go index 4734570978..1e6620f25d 100644 --- a/dns/transport_adapter.go +++ b/dns/transport_adapter.go @@ -1,21 +1,13 @@ package dns import ( - "net/netip" - - "github.com/sagernet/sing-box/adapter" - C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/option" ) -var _ adapter.LegacyDNSTransport = (*TransportAdapter)(nil) - type TransportAdapter struct { transportType string transportTag string dependencies []string - strategy C.DomainStrategy - clientSubnet netip.Prefix } func NewTransportAdapter(transportType string, transportTag string, dependencies []string) TransportAdapter { @@ -35,8 +27,6 @@ func NewTransportAdapterWithLocalOptions(transportType string, transportTag stri transportType: transportType, transportTag: transportTag, dependencies: dependencies, - strategy: C.DomainStrategy(localOptions.LegacyStrategy), - clientSubnet: localOptions.LegacyClientSubnet, } } @@ -45,15 +35,10 @@ func NewTransportAdapterWithRemoteOptions(transportType string, transportTag str 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, transportTag: transportTag, dependencies: dependencies, - strategy: C.DomainStrategy(remoteOptions.LegacyStrategy), - clientSubnet: remoteOptions.LegacyClientSubnet, } } @@ -68,11 +53,3 @@ func (a *TransportAdapter) Tag() string { func (a *TransportAdapter) Dependencies() []string { return a.dependencies } - -func (a *TransportAdapter) LegacyStrategy() C.DomainStrategy { - return a.strategy -} - -func (a *TransportAdapter) LegacyClientSubnet() netip.Prefix { - return a.clientSubnet -} diff --git a/dns/transport_dialer.go b/dns/transport_dialer.go index b3ee8082ab..971002ac40 100644 --- a/dns/transport_dialer.go +++ b/dns/transport_dialer.go @@ -2,104 +2,25 @@ package dns import ( "context" - "net" - "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-box/option" - 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" ) func NewLocalDialer(ctx context.Context, options option.LocalDNSServerOptions) (N.Dialer, error) { - if options.LegacyDefaultDialer { - return dialer.NewDefaultOutbound(ctx), nil - } else { - return dialer.NewWithOptions(dialer.Options{ - Context: ctx, - Options: options.DialerOptions, - DirectResolver: true, - LegacyDNSDialer: options.Legacy, - }) - } -} - -func NewRemoteDialer(ctx context.Context, options option.RemoteDNSServerOptions) (N.Dialer, error) { - 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) - } - return transportDialer, nil - } else { - return dialer.NewWithOptions(dialer.Options{ - Context: ctx, - Options: options.DialerOptions, - RemoteIsDomain: options.ServerIsDomain(), - DirectResolver: true, - LegacyDNSDialer: options.Legacy, - }) - } -} - -type legacyTransportDialer struct { - dialer N.Dialer - dnsRouter adapter.DNSRouter - transport adapter.DNSTransport - strategy C.DomainStrategy - fallbackDelay time.Duration -} - -func newTransportDialer(dialer N.Dialer, dnsRouter adapter.DNSRouter, transport adapter.DNSTransport, strategy C.DomainStrategy, fallbackDelay time.Duration) *legacyTransportDialer { - return &legacyTransportDialer{ - dialer, - dnsRouter, - transport, - strategy, - fallbackDelay, - } -} - -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) - } - addresses, err := d.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{ - Transport: d.transport, - Strategy: d.strategy, + return dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + DirectResolver: true, }) - if err != nil { - return nil, err - } - return N.DialParallel(ctx, d.dialer, network, destination, addresses, d.strategy == C.DomainStrategyPreferIPv6, d.fallbackDelay) } -func (d *legacyTransportDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) { - if destination.IsIP() { - return d.dialer.ListenPacket(ctx, destination) - } - addresses, err := d.dnsRouter.Lookup(ctx, destination.Fqdn, adapter.DNSQueryOptions{ - Transport: d.transport, - Strategy: d.strategy, +func NewRemoteDialer(ctx context.Context, options option.RemoteDNSServerOptions) (N.Dialer, error) { + return dialer.NewWithOptions(dialer.Options{ + Context: ctx, + Options: options.DialerOptions, + RemoteIsDomain: options.ServerIsDomain(), + DirectResolver: true, }) - if err != nil { - return nil, err - } - conn, _, err := N.ListenSerial(ctx, d.dialer, destination, addresses) - return conn, err -} - -func (d *legacyTransportDialer) Upstream() any { - return d.dialer } diff --git a/docs/changelog.md b/docs/changelog.md index 3ef3ca02ae..66fd0f6130 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -744,7 +744,7 @@ DNS servers are refactored for better performance and scalability. See [DNS server](/configuration/dns/server/). -For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-servers). +For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-server-formats). Compatibility for old formats will be removed in sing-box 1.14.0. @@ -1214,7 +1214,7 @@ DNS servers are refactored for better performance and scalability. See [DNS server](/configuration/dns/server/). -For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-servers). +For migration, see [Migrate to new DNS server formats](/migration/#migrate-to-new-dns-server-formats). Compatibility for old formats will be removed in sing-box 1.14.0. @@ -2050,7 +2050,7 @@ See [Migration](/migration/#process_path-format-update-on-windows). The new DNS feature allows you to more precisely bypass Chinese websites via **DNS leaks**. Do not use plain local DNS if using this method. -See [Address Filter Fields](/configuration/dns/rule#address-filter-fields). +See [Legacy Address Filter Fields](/configuration/dns/rule#legacy-address-filter-fields). [Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) updated. @@ -2064,7 +2064,7 @@ the [Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users **5**: The new feature allows you to cache the check results of -[Address filter DNS rule items](/configuration/dns/rule/#address-filter-fields) until expiration. +[Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) until expiration. **6**: @@ -2245,7 +2245,7 @@ See [TUN](/configuration/inbound/tun) inbound. **1**: The new feature allows you to cache the check results of -[Address filter DNS rule items](/configuration/dns/rule/#address-filter-fields) until expiration. +[Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) until expiration. #### 1.9.0-alpha.7 @@ -2292,7 +2292,7 @@ See [Migration](/migration/#process_path-format-update-on-windows). The new DNS feature allows you to more precisely bypass Chinese websites via **DNS leaks**. Do not use plain local DNS if using this method. -See [Address Filter Fields](/configuration/dns/rule#address-filter-fields). +See [Legacy Address Filter Fields](/configuration/dns/rule#legacy-address-filter-fields). [Client example](/manual/proxy/client#traffic-bypass-usage-for-chinese-users) updated. diff --git a/docs/configuration/dns/fakeip.md b/docs/configuration/dns/fakeip.md index f9204d3452..a0524dc8b0 100644 --- a/docs/configuration/dns/fakeip.md +++ b/docs/configuration/dns/fakeip.md @@ -1,10 +1,10 @@ --- -icon: material/delete-clock +icon: material/note-remove --- -!!! failure "Deprecated in sing-box 1.12.0" +!!! failure "Removed in sing-box 1.14.0" - Legacy fake-ip configuration is deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-servers). + Legacy fake-ip configuration is deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-server-formats). ### Structure @@ -26,6 +26,6 @@ Enable FakeIP service. IPv4 address range for FakeIP. -#### inet6_address +#### inet6_range IPv6 address range for FakeIP. diff --git a/docs/configuration/dns/fakeip.zh.md b/docs/configuration/dns/fakeip.zh.md index c8d5dfe301..1e5eca60b6 100644 --- a/docs/configuration/dns/fakeip.zh.md +++ b/docs/configuration/dns/fakeip.zh.md @@ -1,10 +1,10 @@ --- -icon: material/delete-clock +icon: material/note-remove --- -!!! failure "已在 sing-box 1.12.0 废弃" +!!! failure "已在 sing-box 1.14.0 移除" - 旧的 fake-ip 配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 + 旧的 fake-ip 配置已在 sing-box 1.12.0 废弃且已在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 ### 结构 diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index c6750a01bb..cbb58906f1 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -39,7 +39,7 @@ icon: material/alert-decagram |----------|---------------------------------| | `server` | List of [DNS Server](./server/) | | `rules` | List of [DNS Rule](./rule/) | -| `fakeip` | [FakeIP](./fakeip/) | +| `fakeip` | :material-note-remove: [FakeIP](./fakeip/) | #### final @@ -88,4 +88,4 @@ Append a `edns0-subnet` OPT extra record with the specified IP prefix to every q If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. -Can be overrides by `servers.[].client_subnet` or `rules.[].client_subnet`. +Can be overridden by `servers.[].client_subnet` or `rules.[].client_subnet`. diff --git a/docs/configuration/dns/index.zh.md b/docs/configuration/dns/index.zh.md index 68927a5f41..cd2518107c 100644 --- a/docs/configuration/dns/index.zh.md +++ b/docs/configuration/dns/index.zh.md @@ -88,6 +88,6 @@ LRU 缓存容量。 可以被 `servers.[].client_subnet` 或 `rules.[].client_subnet` 覆盖。 -#### fakeip +#### fakeip :material-note-remove: [FakeIP](./fakeip/) 设置。 diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 0b3e56da69..bc28e92685 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -5,7 +5,14 @@ icon: material/alert-decagram !!! quote "Changes in sing-box 1.14.0" :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [match_response](#match_response) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-delete-clock: [ip_accept_any](#ip_accept_any) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) !!! quote "Changes in sing-box 1.13.0" @@ -94,12 +101,6 @@ icon: material/alert-decagram "192.168.0.1" ], "source_ip_is_private": false, - "ip_cidr": [ - "10.0.0.0/24", - "192.168.0.1" - ], - "ip_is_private": false, - "ip_accept_any": false, "source_port": [ 12345 ], @@ -171,7 +172,16 @@ icon: material/alert-decagram "geosite-cn" ], "rule_set_ip_cidr_match_source": false, - "rule_set_ip_cidr_accept_empty": false, + "match_response": false, + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_is_private": false, + "response_rcode": "", + "response_answer": [], + "response_ns": [], + "response_extra": [], "invert": false, "outbound": [ "direct" @@ -180,7 +190,9 @@ icon: material/alert-decagram "server": "local", // Deprecated - + + "ip_accept_any": false, + "rule_set_ip_cidr_accept_empty": false, "rule_set_ipcidr_match_source": false, "geosite": [ "cn" @@ -477,6 +489,19 @@ Make `ip_cidr` rule items in rule-sets match the source IP. Make `ip_cidr` rule items in rule-sets match the source IP. +#### match_response + +!!! question "Since sing-box 1.14.0" + +Enable response-based matching. When enabled, this rule matches against DNS response data +(set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) +instead of only matching the original query. + +The evaluated response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. + +Required for Response Match Fields (`response_rcode`, `response_answer`, `response_ns`, `response_extra`). +Also required for `ip_cidr` and `ip_is_private` when used with `evaluate` or Response Match Fields. + #### invert Invert match result. @@ -521,7 +546,12 @@ See [DNS Rule Actions](../rule_action/) for details. Moved to [DNS Rule Action](../rule_action#route). -### Address Filter Fields +### Legacy Address Filter Fields + +!!! failure "Deprecated in sing-box 1.14.0" + + Legacy Address Filter Fields are deprecated and will be removed in sing-box 1.16.0, + check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). Only takes effect for address requests (A/AAAA/HTTPS). When the query results do not match the address filtering rule items, the current rule will be skipped. @@ -547,24 +577,73 @@ Match GeoIP with query response. Match IP CIDR with query response. +As a Legacy Address Filter Field, deprecated. Use with `match_response` instead, +check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + #### ip_is_private !!! question "Since sing-box 1.9.0" Match private IP with query response. +As a Legacy Address Filter Field, deprecated. Use with `match_response` instead, +check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + #### rule_set_ip_cidr_accept_empty !!! question "Since sing-box 1.10.0" +!!! failure "Deprecated in sing-box 1.14.0" + + `rule_set_ip_cidr_accept_empty` is deprecated and will be removed in sing-box 1.16.0, + check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + Make `ip_cidr` rules in rule-sets accept empty query response. #### ip_accept_any !!! question "Since sing-box 1.12.0" +!!! failure "Deprecated in sing-box 1.14.0" + + `ip_accept_any` is deprecated and will be removed in sing-box 1.16.0, + check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). + Match any IP with query response. +### Response Match Fields + +!!! question "Since sing-box 1.14.0" + +Match fields for DNS response data. Require `match_response` to be set to `true` +and a preceding rule with [`evaluate`](/configuration/dns/rule_action/#evaluate) action to populate the response. + +That evaluated response may also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. + +#### response_rcode + +Match DNS response code. + +Accepted values are the same as in the [predefined action rcode](/configuration/dns/rule_action/#rcode). + +#### response_answer + +Match DNS answer records. + +Record format is the same as in [predefined action answer](/configuration/dns/rule_action/#answer). + +#### response_ns + +Match DNS name server records. + +Record format is the same as in [predefined action ns](/configuration/dns/rule_action/#ns). + +#### response_extra + +Match DNS extra records. + +Record format is the same as in [predefined action extra](/configuration/dns/rule_action/#extra). + ### Logical Fields #### type diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 82f85648f0..cf98b7a8cf 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -5,7 +5,14 @@ icon: material/alert-decagram !!! quote "sing-box 1.14.0 中的更改" :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) + :material-plus: [source_hostname](#source_hostname) + :material-plus: [match_response](#match_response) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-delete-clock: [ip_accept_any](#ip_accept_any) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) !!! quote "sing-box 1.13.0 中的更改" @@ -94,12 +101,6 @@ icon: material/alert-decagram "192.168.0.1" ], "source_ip_is_private": false, - "ip_cidr": [ - "10.0.0.0/24", - "192.168.0.1" - ], - "ip_is_private": false, - "ip_accept_any": false, "source_port": [ 12345 ], @@ -171,7 +172,16 @@ icon: material/alert-decagram "geosite-cn" ], "rule_set_ip_cidr_match_source": false, - "rule_set_ip_cidr_accept_empty": false, + "match_response": false, + "ip_cidr": [ + "10.0.0.0/24", + "192.168.0.1" + ], + "ip_is_private": false, + "response_rcode": "", + "response_answer": [], + "response_ns": [], + "response_extra": [], "invert": false, "outbound": [ "direct" @@ -180,6 +190,9 @@ icon: material/alert-decagram "server": "local", // 已弃用 + + "ip_accept_any": false, + "rule_set_ip_cidr_accept_empty": false, "rule_set_ipcidr_match_source": false, "geosite": [ "cn" @@ -476,6 +489,17 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 使规则集中的 `ip_cidr` 规则匹配源 IP。 +#### match_response + +!!! question "自 sing-box 1.14.0 起" + +启用响应匹配。启用后,此规则将匹配 DNS 响应数据(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 + +响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 +当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr` 和 `ip_is_private` 也需要此选项。 + #### invert 反选匹配结果。 @@ -520,7 +544,12 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 已移动到 [DNS 规则动作](../rule_action#route). -### 地址筛选字段 +### 旧版地址筛选字段 + +!!! failure "已在 sing-box 1.14.0 废弃" + + 旧版地址筛选字段已废弃,且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 仅对地址请求 (A/AAAA/HTTPS) 生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 @@ -547,23 +576,72 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配 IP CIDR。 +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + #### ip_is_private !!! question "自 sing-box 1.9.0 起" 与查询响应匹配非公开 IP。 +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +#### rule_set_ip_cidr_accept_empty + +!!! question "自 sing-box 1.10.0 起" + +!!! failure "已在 sing-box 1.14.0 废弃" + + `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +使规则集中的 `ip_cidr` 规则接受空查询响应。 + #### ip_accept_any !!! question "自 sing-box 1.12.0 起" +!!! failure "已在 sing-box 1.14.0 废弃" + + `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + 匹配任意 IP。 -#### rule_set_ip_cidr_accept_empty +### 响应匹配字段 -!!! question "自 sing-box 1.10.0 起" +!!! question "自 sing-box 1.14.0 起" -使规则集中的 `ip_cidr` 规则接受空查询响应。 +DNS 响应数据的匹配字段。需要将 `match_response` 设为 `true`, +且需要前序规则使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作来填充响应。 + +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 + +#### response_rcode + +匹配 DNS 响应码。 + +接受的值与 [predefined 动作 rcode](/zh/configuration/dns/rule_action/#rcode) 中相同。 + +#### response_answer + +匹配 DNS 应答记录。 + +记录格式与 [predefined 动作 answer](/zh/configuration/dns/rule_action/#answer) 中相同。 + +#### response_ns + +匹配 DNS 名称服务器记录。 + +记录格式与 [predefined 动作 ns](/zh/configuration/dns/rule_action/#ns) 中相同。 + +#### response_extra + +匹配 DNS 额外记录。 + +记录格式与 [predefined 动作 extra](/zh/configuration/dns/rule_action/#extra) 中相同。 ### 逻辑字段 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index db9033f8b7..00a8e1aa88 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -2,6 +2,12 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-delete-clock: [strategy](#strategy) + :material-plus: [evaluate](#evaluate) + :material-plus: [respond](#respond) + !!! quote "Changes in sing-box 1.12.0" :material-plus: [strategy](#strategy) @@ -34,7 +40,11 @@ Tag of target server. !!! question "Since sing-box 1.12.0" -Set domain strategy for this query. +!!! failure "Deprecated in sing-box 1.14.0" + + `strategy` is deprecated in sing-box 1.14.0 and will be removed in sing-box 1.16.0. + +Set domain strategy for this query. Deprecated, check [Migration](/migration/#migrate-dns-rule-action-strategy-to-rule-items). One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. @@ -52,7 +62,68 @@ Append a `edns0-subnet` OPT extra record with the specified IP prefix to every q If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. -Will overrides `dns.client_subnet`. +Will override `dns.client_subnet`. + +### evaluate + +!!! question "Since sing-box 1.14.0" + +```json +{ + "action": "evaluate", + "server": "", + "disable_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`evaluate` sends a DNS query to the specified server and saves the evaluated response for subsequent rules +to match against using [`match_response`](/configuration/dns/rule/#match_response) and response fields. +Unlike `route`, it does **not** terminate rule evaluation. + +Only allowed on top-level DNS rules (not inside logical sub-rules). +Rules that use [`match_response`](/configuration/dns/rule/#match_response) or Response Match Fields +require a preceding top-level rule with `evaluate` action. A rule's own `evaluate` action +does not satisfy this requirement, because matching happens before the action runs. + +#### server + +==Required== + +Tag of target server. + +#### disable_cache + +Disable cache and save cache in this query. + +#### rewrite_ttl + +Rewrite TTL in DNS responses. + +#### client_subnet + +Append a `edns0-subnet` OPT extra record with the specified IP prefix to every query by default. + +If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. + +Will override `dns.client_subnet`. + +### respond + +!!! question "Since sing-box 1.14.0" + +```json +{ + "action": "respond" +} +``` + +`respond` terminates rule evaluation and returns the DNS response previously saved by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action. + +This action does not send a new DNS query and has no extra options. + +Only allowed after a preceding top-level `evaluate` rule. If the action is reached without an evaluated response at runtime, the request fails with an error instead of falling through to later rules. ### route-options diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 9e59c6bd2b..f11bb58920 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -2,6 +2,12 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-delete-clock: [strategy](#strategy) + :material-plus: [evaluate](#evaluate) + :material-plus: [respond](#respond) + !!! quote "sing-box 1.12.0 中的更改" :material-plus: [strategy](#strategy) @@ -34,7 +40,11 @@ icon: material/new-box !!! question "自 sing-box 1.12.0 起" -为此查询设置域名策略。 +!!! failure "已在 sing-box 1.14.0 废弃" + + `strategy` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除。 + +为此查询设置域名策略。已废弃,参阅[迁移指南](/zh/migration/#迁移-dns-规则动作-strategy-到规则项)。 可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 @@ -54,6 +64,65 @@ icon: material/new-box 将覆盖 `dns.client_subnet`. +### evaluate + +!!! question "自 sing-box 1.14.0 起" + +```json +{ + "action": "evaluate", + "server": "", + "disable_cache": false, + "rewrite_ttl": null, + "client_subnet": null +} +``` + +`evaluate` 向指定服务器发送 DNS 查询并保存已评估的响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 + +仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 +使用 [`match_response`](/zh/configuration/dns/rule/#match_response) 或响应匹配字段的规则, +需要位于更早的顶层 `evaluate` 规则之后。规则自身的 `evaluate` 动作不能满足这个条件, +因为匹配发生在动作执行之前。 + +#### server + +==必填== + +目标 DNS 服务器的标签。 + +#### disable_cache + +在此查询中禁用缓存。 + +#### rewrite_ttl + +重写 DNS 回应中的 TTL。 + +#### client_subnet + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +将覆盖 `dns.client_subnet`. + +### respond + +!!! question "自 sing-box 1.14.0 起" + +```json +{ + "action": "respond" +} +``` + +`respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 + +此动作不会发起新的 DNS 查询,也没有额外选项。 + +只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 + ### route-options ```json @@ -84,7 +153,7 @@ icon: material/new-box - `default`: 返回 REFUSED。 - `drop`: 丢弃请求。 -默认使用 `defualt`。 +默认使用 `default`。 #### no_drop diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index 4f10948e58..b610cf5b02 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -29,7 +29,7 @@ The type of the DNS server. | Type | Format | |-----------------|---------------------------| -| empty (default) | [Legacy](./legacy/) | +| empty (default) | :material-note-remove: [Legacy](./legacy/) | | `local` | [Local](./local/) | | `hosts` | [Hosts](./hosts/) | | `tcp` | [TCP](./tcp/) | diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index d6deef5a33..d1a4dc3c40 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -29,7 +29,7 @@ DNS 服务器的类型。 | 类型 | 格式 | |-----------------|---------------------------| -| empty (default) | [Legacy](./legacy/) | +| empty (default) | :material-note-remove: [Legacy](./legacy/) | | `local` | [Local](./local/) | | `hosts` | [Hosts](./hosts/) | | `tcp` | [TCP](./tcp/) | diff --git a/docs/configuration/dns/server/legacy.md b/docs/configuration/dns/server/legacy.md index 387d76ec26..e27b19cbfd 100644 --- a/docs/configuration/dns/server/legacy.md +++ b/docs/configuration/dns/server/legacy.md @@ -1,10 +1,10 @@ --- -icon: material/delete-clock +icon: material/note-remove --- -!!! failure "Deprecated in sing-box 1.12.0" +!!! failure "Removed in sing-box 1.14.0" - Legacy DNS servers is deprecated and will be removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-servers). + Legacy DNS servers are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-server-formats). !!! quote "Changes in sing-box 1.9.0" @@ -108,6 +108,6 @@ Append a `edns0-subnet` OPT extra record with the specified IP prefix to every q If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. -Can be overrides by `rules.[].client_subnet`. +Can be overridden by `rules.[].client_subnet`. -Will overrides `dns.client_subnet`. +Will override `dns.client_subnet`. diff --git a/docs/configuration/dns/server/legacy.zh.md b/docs/configuration/dns/server/legacy.zh.md index 906db47c77..2ad36839f8 100644 --- a/docs/configuration/dns/server/legacy.zh.md +++ b/docs/configuration/dns/server/legacy.zh.md @@ -1,10 +1,10 @@ --- -icon: material/delete-clock +icon: material/note-remove --- -!!! failure "Deprecated in sing-box 1.12.0" +!!! failure "已在 sing-box 1.14.0 移除" - 旧的 DNS 服务器配置已废弃且将在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 + 旧的 DNS 服务器配置已在 sing-box 1.12.0 废弃且已在 sing-box 1.14.0 中被移除,参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式)。 !!! quote "sing-box 1.9.0 中的更改" diff --git a/docs/configuration/experimental/cache-file.md b/docs/configuration/experimental/cache-file.md index 4ad0361c86..f91ee50fde 100644 --- a/docs/configuration/experimental/cache-file.md +++ b/docs/configuration/experimental/cache-file.md @@ -44,7 +44,7 @@ Store fakeip in the cache file Store rejected DNS response cache in the cache file -The check results of [Address filter DNS rule items](/configuration/dns/rule/#address-filter-fields) +The check results of [Legacy Address Filter Fields](/configuration/dns/rule/#legacy-address-filter-fields) will be cached until expiration. #### rdrc_timeout diff --git a/docs/configuration/experimental/cache-file.zh.md b/docs/configuration/experimental/cache-file.zh.md index 309e13a1ea..a998aa7736 100644 --- a/docs/configuration/experimental/cache-file.zh.md +++ b/docs/configuration/experimental/cache-file.zh.md @@ -42,7 +42,7 @@ 将拒绝的 DNS 响应缓存存储在缓存文件中。 -[地址筛选 DNS 规则项](/zh/configuration/dns/rule/#地址筛选字段) 的检查结果将被缓存至过期。 +[旧版地址筛选字段](/zh/configuration/dns/rule/#旧版地址筛选字段) 的检查结果将被缓存至过期。 #### rdrc_timeout diff --git a/docs/configuration/route/index.md b/docs/configuration/route/index.md index 40104b619e..6c59f85079 100644 --- a/docs/configuration/route/index.md +++ b/docs/configuration/route/index.md @@ -153,7 +153,7 @@ Automatically detected from common DHCP servers (dnsmasq, odhcpd, ISC dhcpd, Kea See [Dial Fields](/configuration/shared/dial/#domain_resolver) for details. -Can be overrides by `outbound.domain_resolver`. +Can be overridden by `outbound.domain_resolver`. #### default_network_strategy @@ -163,7 +163,7 @@ See [Dial Fields](/configuration/shared/dial/#network_strategy) for details. 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`. +Can be overridden by `outbound.network_strategy`. Conflicts with `default_interface`. diff --git a/docs/configuration/route/rule_action.md b/docs/configuration/route/rule_action.md index 523ffec206..4f2a35cbd6 100644 --- a/docs/configuration/route/rule_action.md +++ b/docs/configuration/route/rule_action.md @@ -316,4 +316,4 @@ Append a `edns0-subnet` OPT extra record with the specified IP prefix to every q If value is an IP address instead of prefix, `/32` or `/128` will be appended automatically. -Will overrides `dns.client_subnet`. +Will override `dns.client_subnet`. diff --git a/docs/deprecated.md b/docs/deprecated.md index 3faf986e08..70084b6df9 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -14,14 +14,43 @@ check [Migration](../migration/#migrate-inline-acme-to-certificate-provider). Old fields will be removed in sing-box 1.16.0. +#### Legacy `strategy` DNS rule action option + +Legacy `strategy` DNS rule action option is deprecated, +check [Migration](../migration/#migrate-dns-rule-action-strategy-to-rule-items). + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy `ip_accept_any` DNS rule item + +Legacy `ip_accept_any` DNS rule item is deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy `rule_set_ip_cidr_accept_empty` DNS rule item + +Legacy `rule_set_ip_cidr_accept_empty` DNS rule item is deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy Address Filter Fields in DNS rules + +Legacy Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) +in DNS rules are deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). + +Old behavior will be removed in sing-box 1.16.0. + ## 1.12.0 #### Legacy DNS server formats DNS servers are refactored, -check [Migration](../migration/#migrate-to-new-dns-servers). +check [Migration](../migration/#migrate-to-new-dns-server-formats). -Compatibility for old formats will be removed in sing-box 1.14.0. +Old formats were removed in sing-box 1.14.0. #### `outbound` DNS rule item diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index e710e78ce7..f98b0c010a 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -14,6 +14,34 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, 旧字段将在 sing-box 1.16.0 中被移除。 +#### 旧版 DNS 规则动作 `strategy` 选项 + +旧版 DNS 规则动作 `strategy` 选项已废弃, +参阅[迁移指南](/zh/migration/#迁移-dns-规则动作-strategy-到规则项)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版 `ip_accept_any` DNS 规则项 + +旧版 `ip_accept_any` DNS 规则项已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项 + +旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧版地址筛选字段 (DNS 规则) + +旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 + +旧行为将在 sing-box 1.16.0 中被移除。 + ## 1.12.0 #### 旧的 DNS 服务器格式 @@ -21,7 +49,7 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, DNS 服务器已重构, 参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式). -对旧格式的兼容性将在 sing-box 1.14.0 中被移除。 +旧格式已在 sing-box 1.14.0 中被移除。 #### `outbound` DNS 规则项 diff --git a/docs/migration.md b/docs/migration.md index 810bae190a..91e771babd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -79,6 +79,111 @@ See [ACME](/configuration/shared/certificate-provider/acme/) for fields newly ad } ``` +### Migrate DNS rule action strategy to rule items + +Legacy `strategy` DNS rule action option is deprecated. + +In sing-box 1.14.0, internal domain resolution (Lookup) now splits A and AAAA queries +at the rule level, so each query type is evaluated independently through the full rule chain. +Use `ip_version` or `query_type` rule items to control which query types a rule matches. + +!!! info "References" + + [DNS Rule](/configuration/dns/rule/) / + [DNS Rule Action](/configuration/dns/rule_action/) + +=== ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "rules": [ + { + "domain_suffix": ".cn", + "action": "route", + "server": "local", + "strategy": "ipv4_only" + } + ] + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": { + "rules": [ + { + "domain_suffix": ".cn", + "ip_version": 4, + "action": "route", + "server": "local" + } + ] + } + } + ``` + +### Migrate address filter fields to response matching + +Legacy Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) in DNS rules are deprecated, +along with Legacy `ip_accept_any` and Legacy `rule_set_ip_cidr_accept_empty` DNS rule items. + +In sing-box 1.14.0, use the [`evaluate`](/configuration/dns/rule_action/#evaluate) action +to fetch a DNS response, then match against it explicitly with `match_response`. + +!!! info "References" + + [DNS Rule](/configuration/dns/rule/) / + [DNS Rule Action](/configuration/dns/rule_action/#evaluate) + +=== ":material-card-remove: Deprecated" + + ```json + { + "dns": { + "rules": [ + { + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +=== ":material-card-multiple: New" + + ```json + { + "dns": { + "rules": [ + { + "action": "evaluate", + "server": "remote" + }, + { + "match_response": true, + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + ## 1.12.0 ### Migrate to new DNS server formats diff --git a/docs/migration.zh.md b/docs/migration.zh.md index 18e2872613..3f12740553 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -79,6 +79,111 @@ sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-p } ``` +### 迁移 DNS 规则动作 strategy 到规则项 + +旧版 DNS 规则动作 `strategy` 选项已废弃。 + +在 sing-box 1.14.0 中,内部域名解析(Lookup)现在在规则层拆分 A 和 AAAA 查询, +每种查询类型独立通过完整的规则链评估。 +请使用 `ip_version` 或 `query_type` 规则项来控制规则匹配的查询类型。 + +!!! info "参考" + + [DNS 规则](/zh/configuration/dns/rule/) / + [DNS 规则动作](/zh/configuration/dns/rule_action/) + +=== ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "rules": [ + { + "domain_suffix": ".cn", + "action": "route", + "server": "local", + "strategy": "ipv4_only" + } + ] + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": { + "rules": [ + { + "domain_suffix": ".cn", + "ip_version": 4, + "action": "route", + "server": "local" + } + ] + } + } + ``` + +### 迁移地址筛选字段到响应匹配 + +旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +旧版 `ip_accept_any` 和旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项也已废弃。 + +在 sing-box 1.14.0 中,请使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作 +获取 DNS 响应,然后通过 `match_response` 显式匹配。 + +!!! info "参考" + + [DNS 规则](/zh/configuration/dns/rule/) / + [DNS 规则动作](/zh/configuration/dns/rule_action/#evaluate) + +=== ":material-card-remove: 弃用的" + + ```json + { + "dns": { + "rules": [ + { + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + +=== ":material-card-multiple: 新的" + + ```json + { + "dns": { + "rules": [ + { + "action": "evaluate", + "server": "remote" + }, + { + "match_response": true, + "rule_set": "geoip-cn", + "action": "route", + "server": "local" + }, + { + "action": "route", + "server": "remote" + } + ] + } + } + ``` + ## 1.12.0 ### 迁移到新的 DNS 服务器格式 diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 3526cda831..543a10bb6c 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -57,24 +57,6 @@ func (n Note) MessageWithLink() string { } } -var OptionLegacyDNSTransport = Note{ - Name: "legacy-dns-transport", - Description: "legacy DNS servers", - DeprecatedVersion: "1.12.0", - ScheduledVersion: "1.14.0", - EnvName: "LEGACY_DNS_SERVERS", - MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats", -} - -var OptionLegacyDNSFakeIPOptions = Note{ - Name: "legacy-dns-fakeip-options", - Description: "legacy DNS fakeip options", - DeprecatedVersion: "1.12.0", - ScheduledVersion: "1.14.0", - EnvName: "LEGACY_DNS_FAKEIP_OPTIONS", - 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", @@ -111,11 +93,49 @@ var OptionInlineACME = Note{ MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-inline-acme-to-certificate-provider", } +var OptionIPAcceptAny = Note{ + Name: "dns-rule-ip-accept-any", + Description: "Legacy `ip_accept_any` DNS rule item", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "DNS_RULE_IP_ACCEPT_ANY", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", +} + +var OptionRuleSetIPCIDRAcceptEmpty = Note{ + Name: "dns-rule-rule-set-ip-cidr-accept-empty", + Description: "Legacy `rule_set_ip_cidr_accept_empty` DNS rule item", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "DNS_RULE_RULE_SET_IP_CIDR_ACCEPT_EMPTY", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", +} + +var OptionLegacyDNSAddressFilter = Note{ + Name: "legacy-dns-address-filter", + Description: "Legacy Address Filter Fields in DNS rules", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_ADDRESS_FILTER", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", +} + +var OptionLegacyDNSRuleStrategy = Note{ + Name: "legacy-dns-rule-strategy", + Description: "Legacy `strategy` DNS rule action option", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_RULE_STRATEGY", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-dns-rule-action-strategy-to-rule-items", +} + var Options = []Note{ - OptionLegacyDNSTransport, - OptionLegacyDNSFakeIPOptions, OptionOutboundDNSRuleItem, OptionMissingDomainResolver, OptionLegacyDomainStrategyOptions, OptionInlineACME, + OptionIPAcceptAny, + OptionRuleSetIPCIDRAcceptEmpty, + OptionLegacyDNSAddressFilter, + OptionLegacyDNSRuleStrategy, } diff --git a/option/dns.go b/option/dns.go index b5ccf20804..ee29ce096f 100644 --- a/option/dns.go +++ b/option/dns.go @@ -3,19 +3,14 @@ package option import ( "context" "net/netip" - "net/url" C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/experimental/deprecated" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/common/json/badjson" "github.com/sagernet/sing/common/json/badoption" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/service" - - "github.com/miekg/dns" ) type RawDNSOptions struct { @@ -26,80 +21,29 @@ type RawDNSOptions struct { DNSClientOptions } -type LegacyDNSOptions struct { - FakeIP *LegacyDNSFakeIPOptions `json:"fakeip,omitempty"` -} - type DNSOptions struct { RawDNSOptions - LegacyDNSOptions } -type contextKeyDontUpgrade struct{} - -func ContextWithDontUpgrade(ctx context.Context) context.Context { - return context.WithValue(ctx, (*contextKeyDontUpgrade)(nil), true) -} +const ( + legacyDNSFakeIPRemovedMessage = "legacy DNS fakeip options are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats" + legacyDNSServerRemovedMessage = "legacy DNS server formats are deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, checkout migration: https://sing-box.sagernet.org/migration/#migrate-to-new-dns-server-formats" +) -func dontUpgradeFromContext(ctx context.Context) bool { - return ctx.Value((*contextKeyDontUpgrade)(nil)) == true +type removedLegacyDNSOptions struct { + FakeIP json.RawMessage `json:"fakeip,omitempty"` } func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) error { - err := json.UnmarshalContext(ctx, content, &o.LegacyDNSOptions) + var legacyOptions removedLegacyDNSOptions + err := json.UnmarshalContext(ctx, content, &legacyOptions) if err != nil { return err } - dontUpgrade := dontUpgradeFromContext(ctx) - legacyOptions := o.LegacyDNSOptions - if !dontUpgrade { - if o.FakeIP != nil && o.FakeIP.Enabled { - deprecated.Report(ctx, deprecated.OptionLegacyDNSFakeIPOptions) - ctx = context.WithValue(ctx, (*LegacyDNSFakeIPOptions)(nil), o.FakeIP) - } - o.LegacyDNSOptions = LegacyDNSOptions{} - } - err = badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) - if err != nil { - return err - } - if !dontUpgrade { - rcodeMap := make(map[string]int) - o.Servers = common.Filter(o.Servers, func(it DNSServerOptions) bool { - if it.Type == C.DNSTypeLegacyRcode { - rcodeMap[it.Tag] = it.Options.(int) - return false - } - return true - }) - if len(rcodeMap) > 0 { - for i := 0; i < len(o.Rules); i++ { - rewriteRcode(rcodeMap, &o.Rules[i]) - } - } - } - return nil -} - -func rewriteRcode(rcodeMap map[string]int, rule *DNSRule) { - switch rule.Type { - case C.RuleTypeDefault: - rewriteRcodeAction(rcodeMap, &rule.DefaultOptions.DNSRuleAction) - case C.RuleTypeLogical: - rewriteRcodeAction(rcodeMap, &rule.LogicalOptions.DNSRuleAction) - } -} - -func rewriteRcodeAction(rcodeMap map[string]int, ruleAction *DNSRuleAction) { - if ruleAction.Action != C.RuleActionTypeRoute { - return - } - rcode, loaded := rcodeMap[ruleAction.RouteOptions.Server] - if !loaded { - return + if len(legacyOptions.FakeIP) != 0 { + return E.New(legacyDNSFakeIPRemovedMessage) } - ruleAction.Action = C.RuleActionTypePredefined - ruleAction.PredefinedOptions.Rcode = common.Ptr(DNSRCode(rcode)) + return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) } type DNSClientOptions struct { @@ -111,12 +55,6 @@ type DNSClientOptions struct { ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` } -type LegacyDNSFakeIPOptions struct { - Enabled bool `json:"enabled,omitempty"` - Inet4Range *badoption.Prefix `json:"inet4_range,omitempty"` - Inet6Range *badoption.Prefix `json:"inet6_range,omitempty"` -} - type DNSTransportOptionsRegistry interface { CreateOptions(transportType string) (any, bool) } @@ -129,10 +67,6 @@ type _DNSServerOptions struct { type DNSServerOptions _DNSServerOptions func (o *DNSServerOptions) MarshalJSONContext(ctx context.Context) ([]byte, error) { - switch o.Type { - case C.DNSTypeLegacy: - o.Type = "" - } return badjson.MarshallObjectsContext(ctx, (*_DNSServerOptions)(o), o.Options) } @@ -148,9 +82,7 @@ func (o *DNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []b var options any switch o.Type { case "", C.DNSTypeLegacy: - o.Type = C.DNSTypeLegacy - options = new(LegacyDNSServerOptions) - deprecated.Report(ctx, deprecated.OptionLegacyDNSTransport) + return E.New(legacyDNSServerRemovedMessage) default: var loaded bool options, loaded = registry.CreateOptions(o.Type) @@ -163,169 +95,6 @@ func (o *DNSServerOptions) UnmarshalJSONContext(ctx context.Context, content []b return err } o.Options = options - if o.Type == C.DNSTypeLegacy && !dontUpgradeFromContext(ctx) { - err = o.Upgrade(ctx) - if err != nil { - return err - } - } - return nil -} - -func (o *DNSServerOptions) Upgrade(ctx context.Context) error { - if o.Type != C.DNSTypeLegacy { - return nil - } - options := o.Options.(*LegacyDNSServerOptions) - serverURL, _ := url.Parse(options.Address) - var serverType string - if serverURL != nil && serverURL.Scheme != "" { - serverType = serverURL.Scheme - } else { - switch options.Address { - case "local", "fakeip": - serverType = options.Address - default: - serverType = C.DNSTypeUDP - } - } - remoteOptions := RemoteDNSServerOptions{ - RawLocalDNSServerOptions: RawLocalDNSServerOptions{ - DialerOptions: DialerOptions{ - Detour: options.Detour, - DomainResolver: &DomainResolveOptions{ - Server: options.AddressResolver, - Strategy: options.AddressStrategy, - }, - FallbackDelay: options.AddressFallbackDelay, - }, - Legacy: true, - LegacyStrategy: options.Strategy, - LegacyDefaultDialer: options.Detour == "", - LegacyClientSubnet: options.ClientSubnet.Build(netip.Prefix{}), - }, - LegacyAddressResolver: options.AddressResolver, - LegacyAddressStrategy: options.AddressStrategy, - LegacyAddressFallbackDelay: options.AddressFallbackDelay, - } - switch serverType { - case C.DNSTypeLocal: - o.Type = C.DNSTypeLocal - o.Options = &LocalDNSServerOptions{ - RawLocalDNSServerOptions: remoteOptions.RawLocalDNSServerOptions, - } - case C.DNSTypeUDP: - o.Type = C.DNSTypeUDP - o.Options = &remoteOptions - var serverAddr M.Socksaddr - if serverURL == nil || serverURL.Scheme == "" { - serverAddr = M.ParseSocksaddr(options.Address) - } else { - serverAddr = M.ParseSocksaddr(serverURL.Host) - } - if !serverAddr.IsValid() { - return E.New("invalid server address") - } - remoteOptions.Server = serverAddr.AddrString() - if serverAddr.Port != 0 && serverAddr.Port != 53 { - remoteOptions.ServerPort = serverAddr.Port - } - case C.DNSTypeTCP: - o.Type = C.DNSTypeTCP - o.Options = &remoteOptions - if serverURL == nil { - return E.New("invalid server address") - } - serverAddr := M.ParseSocksaddr(serverURL.Host) - if !serverAddr.IsValid() { - return E.New("invalid server address") - } - remoteOptions.Server = serverAddr.AddrString() - if serverAddr.Port != 0 && serverAddr.Port != 53 { - remoteOptions.ServerPort = serverAddr.Port - } - case C.DNSTypeTLS, C.DNSTypeQUIC: - o.Type = serverType - if serverURL == nil { - return E.New("invalid server address") - } - serverAddr := M.ParseSocksaddr(serverURL.Host) - if !serverAddr.IsValid() { - return E.New("invalid server address") - } - remoteOptions.Server = serverAddr.AddrString() - if serverAddr.Port != 0 && serverAddr.Port != 853 { - remoteOptions.ServerPort = serverAddr.Port - } - o.Options = &RemoteTLSDNSServerOptions{ - RemoteDNSServerOptions: remoteOptions, - } - case C.DNSTypeHTTPS, C.DNSTypeHTTP3: - o.Type = serverType - httpsOptions := RemoteHTTPSDNSServerOptions{ - RemoteTLSDNSServerOptions: RemoteTLSDNSServerOptions{ - RemoteDNSServerOptions: remoteOptions, - }, - } - o.Options = &httpsOptions - if serverURL == nil { - return E.New("invalid server address") - } - serverAddr := M.ParseSocksaddr(serverURL.Host) - if !serverAddr.IsValid() { - return E.New("invalid server address") - } - httpsOptions.Server = serverAddr.AddrString() - if serverAddr.Port != 0 && serverAddr.Port != 443 { - httpsOptions.ServerPort = serverAddr.Port - } - if serverURL.Path != "/dns-query" { - httpsOptions.Path = serverURL.Path - } - case "rcode": - var rcode int - if serverURL == nil { - return E.New("invalid server address") - } - switch serverURL.Host { - case "success": - rcode = dns.RcodeSuccess - case "format_error": - rcode = dns.RcodeFormatError - case "server_failure": - rcode = dns.RcodeServerFailure - case "name_error": - rcode = dns.RcodeNameError - case "not_implemented": - rcode = dns.RcodeNotImplemented - case "refused": - rcode = dns.RcodeRefused - default: - return E.New("unknown rcode: ", serverURL.Host) - } - o.Type = C.DNSTypeLegacyRcode - o.Options = rcode - case C.DNSTypeDHCP: - o.Type = C.DNSTypeDHCP - dhcpOptions := DHCPDNSServerOptions{} - if serverURL == nil { - return E.New("invalid server address") - } - if serverURL.Host != "" && serverURL.Host != "auto" { - dhcpOptions.Interface = serverURL.Host - } - o.Options = &dhcpOptions - case C.DNSTypeFakeIP: - o.Type = C.DNSTypeFakeIP - fakeipOptions := FakeIPDNSServerOptions{} - if legacyOptions, loaded := ctx.Value((*LegacyDNSFakeIPOptions)(nil)).(*LegacyDNSFakeIPOptions); loaded { - fakeipOptions.Inet4Range = legacyOptions.Inet4Range - fakeipOptions.Inet6Range = legacyOptions.Inet6Range - } - o.Options = &fakeipOptions - default: - return E.New("unsupported DNS server scheme: ", serverType) - } return nil } @@ -350,16 +119,6 @@ func (o *DNSServerAddressOptions) ReplaceServerOptions(options ServerOptions) { *o = DNSServerAddressOptions(options) } -type LegacyDNSServerOptions struct { - Address string `json:"address"` - AddressResolver string `json:"address_resolver,omitempty"` - AddressStrategy DomainStrategy `json:"address_strategy,omitempty"` - AddressFallbackDelay badoption.Duration `json:"address_fallback_delay,omitempty"` - Strategy DomainStrategy `json:"strategy,omitempty"` - Detour string `json:"detour,omitempty"` - ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"` -} - type HostsDNSServerOptions struct { Path badoption.Listable[string] `json:"path,omitempty"` Predefined *badjson.TypedMap[string, badoption.Listable[netip.Addr]] `json:"predefined,omitempty"` @@ -367,10 +126,6 @@ type HostsDNSServerOptions struct { type RawLocalDNSServerOptions struct { DialerOptions - Legacy bool `json:"-"` - LegacyStrategy DomainStrategy `json:"-"` - LegacyDefaultDialer bool `json:"-"` - LegacyClientSubnet netip.Prefix `json:"-"` } type LocalDNSServerOptions struct { @@ -381,9 +136,6 @@ type LocalDNSServerOptions struct { type RemoteDNSServerOptions struct { RawLocalDNSServerOptions DNSServerAddressOptions - LegacyAddressResolver string `json:"-"` - LegacyAddressStrategy DomainStrategy `json:"-"` - LegacyAddressFallbackDelay badoption.Duration `json:"-"` } type RemoteTLSDNSServerOptions struct { diff --git a/option/dns_record.go b/option/dns_record.go index fa72b61b73..f10e03d9b6 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -2,6 +2,7 @@ package option import ( "encoding/base64" + "strings" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" @@ -11,6 +12,8 @@ import ( "github.com/miekg/dns" ) +const defaultDNSRecordTTL uint32 = 3600 + type DNSRCode int func (r DNSRCode) MarshalJSON() ([]byte, error) { @@ -76,10 +79,13 @@ func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { if err == nil { return o.unmarshalBase64(binary) } - record, err := dns.NewRR(stringValue) + record, err := parseDNSRecord(stringValue) if err != nil { return err } + if record == nil { + return E.New("empty DNS record") + } if a, isA := record.(*dns.A); isA { a.A = M.AddrFromIP(a.A).Unmap().AsSlice() } @@ -87,6 +93,16 @@ func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { return nil } +func parseDNSRecord(stringValue string) (dns.RR, error) { + if len(stringValue) > 0 && stringValue[len(stringValue)-1] != '\n' { + stringValue += "\n" + } + parser := dns.NewZoneParser(strings.NewReader(stringValue), "", "") + parser.SetDefaultTTL(defaultDNSRecordTTL) + record, _ := parser.Next() + return record, parser.Err() +} + func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { record, _, err := dns.UnpackRR(binary, 0) if err != nil { @@ -100,3 +116,10 @@ func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { func (o DNSRecordOptions) Build() dns.RR { return o.RR } + +func (o DNSRecordOptions) Match(record dns.RR) bool { + if o.RR == nil || record == nil { + return false + } + return dns.IsDuplicate(o.RR, record) +} diff --git a/option/dns_record_test.go b/option/dns_record_test.go new file mode 100644 index 0000000000..759ef5fc5a --- /dev/null +++ b/option/dns_record_test.go @@ -0,0 +1,40 @@ +package option + +import ( + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func mustRecordOptions(t *testing.T, record string) DNSRecordOptions { + t.Helper() + var value DNSRecordOptions + require.NoError(t, value.UnmarshalJSON([]byte(`"`+record+`"`))) + return value +} + +func TestDNSRecordOptionsUnmarshalJSONRejectsRelativeNames(t *testing.T) { + t.Parallel() + + for _, record := range []string{ + "@ IN A 1.1.1.1", + "www IN CNAME example.com.", + "example.com. IN CNAME @", + "example.com. IN CNAME www", + } { + var value DNSRecordOptions + err := value.UnmarshalJSON([]byte(`"` + record + `"`)) + require.Error(t, err) + } +} + +func TestDNSRecordOptionsMatchIgnoresTTL(t *testing.T) { + t.Parallel() + + expected := mustRecordOptions(t, "example.com. 600 IN A 1.1.1.1") + record, err := dns.NewRR("example.com. 60 IN A 1.1.1.1") + require.NoError(t, err) + + require.True(t, expected.Match(record)) +} diff --git a/option/dns_test.go b/option/dns_test.go new file mode 100644 index 0000000000..4e7bf9a92b --- /dev/null +++ b/option/dns_test.go @@ -0,0 +1,54 @@ +package option + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + + "github.com/stretchr/testify/require" +) + +type stubDNSTransportOptionsRegistry struct{} + +func (stubDNSTransportOptionsRegistry) CreateOptions(transportType string) (any, bool) { + switch transportType { + case C.DNSTypeUDP: + return new(RemoteDNSServerOptions), true + case C.DNSTypeFakeIP: + return new(FakeIPDNSServerOptions), true + default: + return nil, false + } +} + +func TestDNSOptionsRejectsLegacyFakeIPOptions(t *testing.T) { + t.Parallel() + + ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) + var options DNSOptions + err := json.UnmarshalContext(ctx, []byte(`{ + "fakeip": { + "enabled": true, + "inet4_range": "198.18.0.0/15" + } + }`), &options) + require.EqualError(t, err, legacyDNSFakeIPRemovedMessage) +} + +func TestDNSServerOptionsRejectsLegacyFormats(t *testing.T) { + t.Parallel() + + ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) + testCases := []string{ + `{"address":"1.1.1.1"}`, + `{"type":"legacy","address":"1.1.1.1"}`, + } + for _, content := range testCases { + var options DNSServerOptions + err := json.UnmarshalContext(ctx, []byte(content), &options) + require.EqualError(t, err, legacyDNSServerRemovedMessage) + } +} diff --git a/option/rule.go b/option/rule.go index b792ccf4b2..9fd9437973 100644 --- a/option/rule.go +++ b/option/rule.go @@ -1,6 +1,7 @@ package option import ( + "context" "reflect" C "github.com/sagernet/sing-box/constant" @@ -33,26 +34,24 @@ func (r Rule) MarshalJSON() ([]byte, error) { return badjson.MarshallObjects((_Rule)(r), v) } -func (r *Rule) UnmarshalJSON(bytes []byte) error { - err := json.Unmarshal(bytes, (*_Rule)(r)) +func (r *Rule) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { + err := json.UnmarshalContext(ctx, bytes, (*_Rule)(r)) + if err != nil { + return err + } + payload, err := rulePayloadWithoutType(ctx, bytes) if err != nil { return err } - var v any switch r.Type { case "", C.RuleTypeDefault: r.Type = C.RuleTypeDefault - v = &r.DefaultOptions + return unmarshalDefaultRuleContext(ctx, payload, &r.DefaultOptions) case C.RuleTypeLogical: - v = &r.LogicalOptions + return unmarshalLogicalRuleContext(ctx, payload, &r.LogicalOptions) default: return E.New("unknown rule type: " + r.Type) } - err = badjson.UnmarshallExcluded(bytes, (*_Rule)(r), v) - if err != nil { - return err - } - return nil } func (r Rule) IsValid() bool { @@ -160,6 +159,64 @@ func (r *LogicalRule) UnmarshalJSON(data []byte) error { return badjson.UnmarshallExcluded(data, &r.RawLogicalRule, &r.RuleAction) } +func rulePayloadWithoutType(ctx context.Context, data []byte) ([]byte, error) { + var content badjson.JSONObject + err := content.UnmarshalJSONContext(ctx, data) + if err != nil { + return nil, err + } + content.Remove("type") + return content.MarshalJSONContext(ctx) +} + +func unmarshalDefaultRuleContext(ctx context.Context, data []byte, rule *DefaultRule) error { + rawAction, routeOptions, err := inspectRouteRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedRouteRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(ctx, data, &rule.RawDefaultRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &rule.RawDefaultRule, &rule.RuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (RouteActionOptions{}) { + rule.RuleAction = RuleAction{} + } + return nil +} + +func unmarshalLogicalRuleContext(ctx context.Context, data []byte, rule *LogicalRule) error { + rawAction, routeOptions, err := inspectRouteRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedRouteRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(nestedRuleChildContext(ctx), data, &rule.RawLogicalRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &rule.RawLogicalRule, &rule.RuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (RouteActionOptions{}) { + rule.RuleAction = RuleAction{} + } + return nil +} + func (r *LogicalRule) IsValid() bool { return len(r.Rules) > 0 && common.All(r.Rules, Rule.IsValid) } diff --git a/option/rule_action.go b/option/rule_action.go index 4310825520..212396b7b9 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -115,6 +115,10 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { case C.RuleActionTypeRoute: r.Action = "" v = r.RouteOptions + case C.RuleActionTypeEvaluate: + v = r.RouteOptions + case C.RuleActionTypeRespond: + v = nil case C.RuleActionTypeRouteOptions: v = r.RouteOptionsOptions case C.RuleActionTypeReject: @@ -124,6 +128,9 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { default: return nil, E.New("unknown DNS rule action: " + r.Action) } + if v == nil { + return badjson.MarshallObjects((_DNSRuleAction)(r)) + } return badjson.MarshallObjects((_DNSRuleAction)(r), v) } @@ -137,6 +144,10 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e case "", C.RuleActionTypeRoute: r.Action = C.RuleActionTypeRoute v = &r.RouteOptions + case C.RuleActionTypeEvaluate: + v = &r.RouteOptions + case C.RuleActionTypeRespond: + v = nil case C.RuleActionTypeRouteOptions: v = &r.RouteOptionsOptions case C.RuleActionTypeReject: @@ -146,6 +157,9 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e default: return E.New("unknown DNS rule action: " + r.Action) } + if v == nil { + return json.UnmarshalDisallowUnknownFields(data, &_DNSRuleAction{}) + } return badjson.UnmarshallExcludedContext(ctx, data, (*_DNSRuleAction)(r), v) } diff --git a/option/rule_action_test.go b/option/rule_action_test.go new file mode 100644 index 0000000000..0007cd36ed --- /dev/null +++ b/option/rule_action_test.go @@ -0,0 +1,29 @@ +package option + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestDNSRuleActionRespondUnmarshalJSON(t *testing.T) { + t.Parallel() + + var action DNSRuleAction + err := json.UnmarshalContext(context.Background(), []byte(`{"action":"respond"}`), &action) + require.NoError(t, err) + require.Equal(t, C.RuleActionTypeRespond, action.Action) + require.Equal(t, DNSRouteActionOptions{}, action.RouteOptions) +} + +func TestDNSRuleActionRespondRejectsUnknownFields(t *testing.T) { + t.Parallel() + + var action DNSRuleAction + err := json.UnmarshalContext(context.Background(), []byte(`{"action":"respond","disable_cache":true}`), &action) + require.ErrorContains(t, err, "unknown field") +} diff --git a/option/rule_dns.go b/option/rule_dns.go index 880b96ac54..d1298635b8 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -35,7 +35,7 @@ func (r DNSRule) MarshalJSON() ([]byte, error) { } func (r *DNSRule) UnmarshalJSONContext(ctx context.Context, bytes []byte) error { - err := json.Unmarshal(bytes, (*_DNSRule)(r)) + err := json.UnmarshalContext(ctx, bytes, (*_DNSRule)(r)) if err != nil { return err } @@ -78,12 +78,6 @@ type RawDefaultDNSRule struct { DomainSuffix badoption.Listable[string] `json:"domain_suffix,omitempty"` DomainKeyword badoption.Listable[string] `json:"domain_keyword,omitempty"` DomainRegex badoption.Listable[string] `json:"domain_regex,omitempty"` - Geosite badoption.Listable[string] `json:"geosite,omitempty"` - SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` - GeoIP badoption.Listable[string] `json:"geoip,omitempty"` - IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` - IPIsPrivate bool `json:"ip_is_private,omitempty"` - IPAcceptAny bool `json:"ip_accept_any,omitempty"` SourceIPCIDR badoption.Listable[string] `json:"source_ip_cidr,omitempty"` SourceIPIsPrivate bool `json:"source_ip_is_private,omitempty"` SourcePort badoption.Listable[uint16] `json:"source_port,omitempty"` @@ -110,9 +104,23 @@ type RawDefaultDNSRule struct { SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` - RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` + MatchResponse bool `json:"match_response,omitempty"` + IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` + IPIsPrivate bool `json:"ip_is_private,omitempty"` + ResponseRcode *DNSRCode `json:"response_rcode,omitempty"` + ResponseAnswer badoption.Listable[DNSRecordOptions] `json:"response_answer,omitempty"` + ResponseNs badoption.Listable[DNSRecordOptions] `json:"response_ns,omitempty"` + ResponseExtra badoption.Listable[DNSRecordOptions] `json:"response_extra,omitempty"` Invert bool `json:"invert,omitempty"` + // Deprecated: removed in sing-box 1.12.0 + Geosite badoption.Listable[string] `json:"geosite,omitempty"` + SourceGeoIP badoption.Listable[string] `json:"source_geoip,omitempty"` + GeoIP badoption.Listable[string] `json:"geoip,omitempty"` + // Deprecated: use match_response with response items + IPAcceptAny bool `json:"ip_accept_any,omitempty"` + // Deprecated: removed in sing-box 1.11.0 + RuleSetIPCIDRAcceptEmpty bool `json:"rule_set_ip_cidr_accept_empty,omitempty"` // Deprecated: renamed to rule_set_ip_cidr_match_source Deprecated_RulesetIPCIDRMatchSource bool `json:"rule_set_ipcidr_match_source,omitempty"` } @@ -127,11 +135,27 @@ func (r DefaultDNSRule) MarshalJSON() ([]byte, error) { } func (r *DefaultDNSRule) UnmarshalJSONContext(ctx context.Context, data []byte) error { - err := json.UnmarshalContext(ctx, data, &r.RawDefaultDNSRule) + rawAction, routeOptions, err := inspectDNSRuleAction(ctx, data) + if err != nil { + return err + } + err = rejectNestedDNSRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(ctx, data, &r.RawDefaultDNSRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &r.RawDefaultDNSRule, &r.DNSRuleAction) if err != nil { return err } - return badjson.UnmarshallExcludedContext(ctx, data, &r.RawDefaultDNSRule, &r.DNSRuleAction) + if depth > 0 && rawAction == "" && routeOptions == (DNSRouteActionOptions{}) { + r.DNSRuleAction = DNSRuleAction{} + } + return nil } func (r DefaultDNSRule) IsValid() bool { @@ -156,11 +180,27 @@ func (r LogicalDNSRule) MarshalJSON() ([]byte, error) { } func (r *LogicalDNSRule) UnmarshalJSONContext(ctx context.Context, data []byte) error { - err := json.Unmarshal(data, &r.RawLogicalDNSRule) + rawAction, routeOptions, err := inspectDNSRuleAction(ctx, data) if err != nil { return err } - return badjson.UnmarshallExcludedContext(ctx, data, &r.RawLogicalDNSRule, &r.DNSRuleAction) + err = rejectNestedDNSRuleAction(ctx, data) + if err != nil { + return err + } + depth := nestedRuleDepth(ctx) + err = json.UnmarshalContext(nestedRuleChildContext(ctx), data, &r.RawLogicalDNSRule) + if err != nil { + return err + } + err = badjson.UnmarshallExcludedContext(ctx, data, &r.RawLogicalDNSRule, &r.DNSRuleAction) + if err != nil { + return err + } + if depth > 0 && rawAction == "" && routeOptions == (DNSRouteActionOptions{}) { + r.DNSRuleAction = DNSRuleAction{} + } + return nil } func (r *LogicalDNSRule) IsValid() bool { diff --git a/option/rule_nested.go b/option/rule_nested.go new file mode 100644 index 0000000000..172165729a --- /dev/null +++ b/option/rule_nested.go @@ -0,0 +1,133 @@ +package option + +import ( + "context" + "reflect" + "strings" + + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/common/json/badjson" +) + +type nestedRuleDepthContextKey struct{} + +const ( + RouteRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" + DNSRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" +) + +var ( + routeRuleActionKeys = jsonFieldNames(reflect.TypeFor[_RuleAction](), reflect.TypeFor[RouteActionOptions]()) + dnsRuleActionKeys = jsonFieldNames(reflect.TypeFor[_DNSRuleAction](), reflect.TypeFor[DNSRouteActionOptions]()) +) + +func nestedRuleChildContext(ctx context.Context) context.Context { + return context.WithValue(ctx, nestedRuleDepthContextKey{}, nestedRuleDepth(ctx)+1) +} + +func rejectNestedRouteRuleAction(ctx context.Context, content []byte) error { + return rejectNestedRuleAction(ctx, content, routeRuleActionKeys, RouteRuleActionNestedUnsupportedMessage) +} + +func rejectNestedDNSRuleAction(ctx context.Context, content []byte) error { + return rejectNestedRuleAction(ctx, content, dnsRuleActionKeys, DNSRuleActionNestedUnsupportedMessage) +} + +func nestedRuleDepth(ctx context.Context) int { + depth, _ := ctx.Value(nestedRuleDepthContextKey{}).(int) + return depth +} + +func rejectNestedRuleAction(ctx context.Context, content []byte, keys []string, message string) error { + if nestedRuleDepth(ctx) == 0 { + return nil + } + hasActionKey, err := hasAnyJSONKey(ctx, content, keys...) + if err != nil { + return err + } + if hasActionKey { + return E.New(message) + } + return nil +} + +func hasAnyJSONKey(ctx context.Context, content []byte, keys ...string) (bool, error) { + var object badjson.JSONObject + err := object.UnmarshalJSONContext(ctx, content) + if err != nil { + return false, err + } + for _, key := range keys { + if object.ContainsKey(key) { + return true, nil + } + } + return false, nil +} + +func inspectRouteRuleAction(ctx context.Context, content []byte) (string, RouteActionOptions, error) { + var rawAction _RuleAction + err := json.UnmarshalContext(ctx, content, &rawAction) + if err != nil { + return "", RouteActionOptions{}, err + } + var routeOptions RouteActionOptions + err = json.UnmarshalContext(ctx, content, &routeOptions) + if err != nil { + return "", RouteActionOptions{}, err + } + return rawAction.Action, routeOptions, nil +} + +func inspectDNSRuleAction(ctx context.Context, content []byte) (string, DNSRouteActionOptions, error) { + var rawAction _DNSRuleAction + err := json.UnmarshalContext(ctx, content, &rawAction) + if err != nil { + return "", DNSRouteActionOptions{}, err + } + var routeOptions DNSRouteActionOptions + err = json.UnmarshalContext(ctx, content, &routeOptions) + if err != nil { + return "", DNSRouteActionOptions{}, err + } + return rawAction.Action, routeOptions, nil +} + +func jsonFieldNames(types ...reflect.Type) []string { + fieldMap := make(map[string]struct{}) + for _, fieldType := range types { + appendJSONFieldNames(fieldMap, fieldType) + } + fieldNames := make([]string, 0, len(fieldMap)) + for fieldName := range fieldMap { + fieldNames = append(fieldNames, fieldName) + } + return fieldNames +} + +func appendJSONFieldNames(fieldMap map[string]struct{}, fieldType reflect.Type) { + for fieldType.Kind() == reflect.Pointer { + fieldType = fieldType.Elem() + } + if fieldType.Kind() != reflect.Struct { + return + } + for i := range fieldType.NumField() { + field := fieldType.Field(i) + tagValue := field.Tag.Get("json") + tagName, _, _ := strings.Cut(tagValue, ",") + if tagName == "-" { + continue + } + if field.Anonymous && tagName == "" { + appendJSONFieldNames(fieldMap, field.Type) + continue + } + if tagName == "" { + tagName = field.Name + } + fieldMap[tagName] = struct{}{} + } +} diff --git a/option/rule_nested_test.go b/option/rule_nested_test.go new file mode 100644 index 0000000000..3b2ef2e5f0 --- /dev/null +++ b/option/rule_nested_test.go @@ -0,0 +1,68 @@ +package option + +import ( + "context" + "testing" + + "github.com/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestRuleRejectsNestedDefaultRuleAction(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "outbound": "direct"} + ] + }`), &rule) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) +} + +func TestRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "foo": "bar"} + ] + }`), &rule) + require.ErrorContains(t, err, "unknown field") + require.NotContains(t, err.Error(), RouteRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedDefaultRuleAction(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "server": "default"} + ] + }`), &rule) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "foo": "bar"} + ] + }`), &rule) + require.ErrorContains(t, err, "unknown field") + require.NotContains(t, err.Error(), DNSRuleActionNestedUnsupportedMessage) +} diff --git a/route/router.go b/route/router.go index 2815d5095b..03546b2a7e 100644 --- a/route/router.go +++ b/route/router.go @@ -70,6 +70,10 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) error { for i, options := range rules { + err := R.ValidateNoNestedRuleActions(options) + if err != nil { + return E.Cause(err, "parse rule[", i, "]") + } rule, err := R.NewRule(r.ctx, r.logger, options, false) if err != nil { return E.Cause(err, "parse rule[", i, "]") diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 8a95fa6d2a..d7b844adbb 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -156,7 +156,6 @@ func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundConte return r.invertedFailure(inheritedBase) } if r.invert { - // DNS pre-lookup defers destination address-limit checks until the response phase. if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { return emptyRuleMatchState().withBase(inheritedBase) } diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index cac814e765..2fe6ba98a4 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -132,6 +132,18 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), }, } + case C.RuleActionTypeEvaluate: + return &RuleActionEvaluate{ + Server: action.RouteOptions.Server, + RuleActionDNSRouteOptions: RuleActionDNSRouteOptions{ + Strategy: C.DomainStrategy(action.RouteOptions.Strategy), + DisableCache: action.RouteOptions.DisableCache, + RewriteTTL: action.RouteOptions.RewriteTTL, + ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), + }, + } + case C.RuleActionTypeRespond: + return &RuleActionRespond{} case C.RuleActionTypeRouteOptions: return &RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptionsOptions.Strategy), @@ -230,7 +242,7 @@ func (r *RuleActionRouteOptions) Descriptions() []string { descriptions = append(descriptions, F.ToString("network-type=", strings.Join(common.Map(r.NetworkType, C.InterfaceType.String), ","))) } if r.FallbackNetworkType != nil { - descriptions = append(descriptions, F.ToString("fallback-network-type="+strings.Join(common.Map(r.NetworkType, C.InterfaceType.String), ","))) + descriptions = append(descriptions, F.ToString("fallback-network-type=", strings.Join(common.Map(r.FallbackNetworkType, C.InterfaceType.String), ","))) } if r.FallbackDelay > 0 { descriptions = append(descriptions, F.ToString("fallback-delay=", r.FallbackDelay.String())) @@ -266,18 +278,45 @@ func (r *RuleActionDNSRoute) Type() string { } func (r *RuleActionDNSRoute) String() string { + return formatDNSRouteAction("route", r.Server, r.RuleActionDNSRouteOptions) +} + +type RuleActionEvaluate struct { + Server string + RuleActionDNSRouteOptions +} + +func (r *RuleActionEvaluate) Type() string { + return C.RuleActionTypeEvaluate +} + +func (r *RuleActionEvaluate) String() string { + return formatDNSRouteAction("evaluate", r.Server, r.RuleActionDNSRouteOptions) +} + +type RuleActionRespond struct{} + +func (r *RuleActionRespond) Type() string { + return C.RuleActionTypeRespond +} + +func (r *RuleActionRespond) String() string { + return "respond" +} + +func formatDNSRouteAction(action string, server string, options RuleActionDNSRouteOptions) string { var descriptions []string - descriptions = append(descriptions, r.Server) - if r.DisableCache { + descriptions = append(descriptions, server) + if options.DisableCache { descriptions = append(descriptions, "disable-cache") } - if r.RewriteTTL != nil { - descriptions = append(descriptions, F.ToString("rewrite-ttl=", *r.RewriteTTL)) + if options.RewriteTTL != nil { + descriptions = append(descriptions, F.ToString("rewrite-ttl=", *options.RewriteTTL)) } - if r.ClientSubnet.IsValid() { - descriptions = append(descriptions, F.ToString("client-subnet=", r.ClientSubnet)) + if options.ClientSubnet.IsValid() { + descriptions = append(descriptions, F.ToString("client-subnet=", options.ClientSubnet)) } - return F.ToString("route(", strings.Join(descriptions, ","), ")") + return F.ToString(action, "(", strings.Join(descriptions, ","), ")") } type RuleActionDNSRouteOptions struct { diff --git a/route/rule/rule_default.go b/route/rule/rule_default.go index 5ce1f87d4a..d4de6ff7ae 100644 --- a/route/rule/rule_default.go +++ b/route/rule/rule_default.go @@ -326,6 +326,10 @@ func NewLogicalRule(ctx context.Context, logger log.ContextLogger, options optio return nil, E.New("unknown logical mode: ", options.Mode) } for i, subOptions := range options.Rules { + err = validateNoNestedRuleActions(subOptions, true) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } subRule, err := NewRule(ctx, logger, subOptions, false) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index f33d6096ae..2c6d10b342 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -5,58 +5,84 @@ 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/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/service" + + "github.com/miekg/dns" ) -func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool) (adapter.DNSRule, error) { +func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool, legacyDNSMode bool) (adapter.DNSRule, error) { switch options.Type { case "", C.RuleTypeDefault: if !options.DefaultOptions.IsValid() { return nil, E.New("missing conditions") } + if !checkServer && options.DefaultOptions.Action == C.RuleActionTypeEvaluate { + return nil, E.New(options.DefaultOptions.Action, " is only allowed on top-level DNS rules") + } + err := validateDNSRuleAction(options.DefaultOptions.DNSRuleAction) + if err != nil { + return nil, err + } switch options.DefaultOptions.Action { - case "", C.RuleActionTypeRoute: + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: if options.DefaultOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } } - return NewDefaultDNSRule(ctx, logger, options.DefaultOptions) + return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyDNSMode) case C.RuleTypeLogical: if !options.LogicalOptions.IsValid() { return nil, E.New("missing conditions") } + if !checkServer && options.LogicalOptions.Action == C.RuleActionTypeEvaluate { + return nil, E.New(options.LogicalOptions.Action, " is only allowed on top-level DNS rules") + } + err := validateDNSRuleAction(options.LogicalOptions.DNSRuleAction) + if err != nil { + return nil, err + } switch options.LogicalOptions.Action { - case "", C.RuleActionTypeRoute: + case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: if options.LogicalOptions.RouteOptions.Server == "" && checkServer { return nil, E.New("missing server field") } } - return NewLogicalDNSRule(ctx, logger, options.LogicalOptions) + return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyDNSMode) default: return nil, E.New("unknown rule type: ", options.Type) } } +func validateDNSRuleAction(action option.DNSRuleAction) error { + if action.Action == C.RuleActionTypeReject && action.RejectOptions.Method == C.RuleActionRejectMethodReply { + return E.New("reject method `reply` is not supported for DNS rules") + } + return nil +} + var _ adapter.DNSRule = (*DefaultDNSRule)(nil) type DefaultDNSRule struct { abstractDefaultRule + matchResponse bool } func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { return r.abstractDefaultRule.matchStates(metadata) } -func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule) (*DefaultDNSRule, error) { +func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, + matchResponse: options.MatchResponse, } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) @@ -116,7 +142,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.destinationAddressItems = append(rule.destinationAddressItems, item) rule.allItems = append(rule.allItems, item) } - if len(options.Geosite) > 0 { + if len(options.Geosite) > 0 { //nolint:staticcheck return nil, E.New("geosite database is deprecated in sing-box 1.8.0 and removed in sing-box 1.12.0") } if len(options.SourceGeoIP) > 0 { @@ -151,11 +177,36 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) rule.allItems = append(rule.allItems, item) } - if options.IPAcceptAny { + if options.IPAcceptAny { //nolint:staticcheck + if legacyDNSMode { + deprecated.Report(ctx, deprecated.OptionIPAcceptAny) + } else { + return nil, E.New(deprecated.OptionIPAcceptAny.MessageWithLink()) + } item := NewIPAcceptAnyItem() rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) rule.allItems = append(rule.allItems, item) } + if options.ResponseRcode != nil { + item := NewDNSResponseRCodeItem(int(*options.ResponseRcode)) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseAnswer) > 0 { + item := NewDNSResponseRecordItem("response_answer", options.ResponseAnswer, dnsResponseAnswers) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseNs) > 0 { + item := NewDNSResponseRecordItem("response_ns", options.ResponseNs, dnsResponseNS) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } + if len(options.ResponseExtra) > 0 { + item := NewDNSResponseRecordItem("response_extra", options.ResponseExtra, dnsResponseExtra) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if len(options.SourcePort) > 0 { item := NewPortItem(true, options.SourcePort) rule.sourcePortItems = append(rule.sourcePortItems, item) @@ -284,7 +335,14 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op if options.RuleSetIPCIDRMatchSource { matchSource = true } - item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) + if options.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck + if legacyDNSMode { + deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) + } else { + return nil, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) + } + } + item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) //nolint:staticcheck rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) } @@ -309,15 +367,35 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStatesForMatch(metadata).isEmpty() +} + +func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { + if r.matchResponse { + return false + } metadata.IgnoreDestinationIPCIDRMatch = true - defer func() { - metadata.IgnoreDestinationIPCIDRMatch = false - }() - return !r.matchStates(metadata).isEmpty() + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractDefaultRule.matchStates(metadata).isEmpty() } -func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() +func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + if r.matchResponse { + if metadata.DNSResponse == nil { + return r.abstractDefaultRule.invertedFailure(0) + } + matchMetadata := *metadata + matchMetadata.DestinationAddressMatchFromResponse = true + return r.abstractDefaultRule.matchStates(&matchMetadata) + } + return r.abstractDefaultRule.matchStates(metadata) +} + +func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { + matchMetadata := *metadata + matchMetadata.DNSResponse = response + matchMetadata.DestinationAddressMatchFromResponse = true + return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() } var _ adapter.DNSRule = (*LogicalDNSRule)(nil) @@ -330,7 +408,53 @@ func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatch return r.abstractLogicalRule.matchStates(metadata) } -func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule) (*LogicalDNSRule, error) { +func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { + switch typedRule := rule.(type) { + case *DefaultDNSRule: + return typedRule.matchStatesForMatch(metadata) + case *LogicalDNSRule: + return typedRule.matchStatesForMatch(metadata) + default: + return matchHeadlessRuleStates(typedRule, metadata) + } +} + +func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + var stateSet ruleMatchStateSet + if r.mode == C.LogicalTypeAnd { + stateSet = emptyRuleMatchState() + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) + if nestedStateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState() + } + return 0 + } + stateSet = stateSet.combine(nestedStateSet) + } + } else { + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) + } + if stateSet.isEmpty() { + if r.invert { + return emptyRuleMatchState() + } + return 0 + } + } + if r.invert { + return 0 + } + return stateSet +} + +func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyDNSMode bool) (*LogicalDNSRule, error) { r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ rules: make([]adapter.HeadlessRule, len(options.Rules)), @@ -347,7 +471,11 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op return nil, E.New("unknown logical mode: ", options.Mode) } for i, subRule := range options.Rules { - rule, err := NewDNSRule(ctx, logger, subRule, false) + err := validateNoNestedDNSRuleActions(subRule, true) + if err != nil { + return nil, E.Cause(err, "sub rule[", i, "]") + } + rule, err := NewDNSRule(ctx, logger, subRule, false, legacyDNSMode) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") } @@ -377,13 +505,18 @@ func (r *LogicalDNSRule) WithAddressLimit() bool { } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStatesForMatch(metadata).isEmpty() +} + +func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { metadata.IgnoreDestinationIPCIDRMatch = true - defer func() { - metadata.IgnoreDestinationIPCIDRMatch = false - }() - return !r.matchStates(metadata).isEmpty() + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractLogicalRule.matchStates(metadata).isEmpty() } -func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() +func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { + matchMetadata := *metadata + matchMetadata.DNSResponse = response + matchMetadata.DestinationAddressMatchFromResponse = true + return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() } diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go index c823dcf30a..28f74161f1 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -76,11 +76,26 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { if r.isSource || metadata.IPCIDRMatchSource { return r.ipSet.Contains(metadata.Source.Addr) } + if metadata.DestinationAddressMatchFromResponse { + addresses := metadata.DNSResponseAddressesForMatch() + if len(addresses) == 0 { + // Legacy rule_set_ip_cidr_accept_empty only applies when the DNS response + // does not expose any address answers for matching. + return metadata.IPCIDRAcceptEmpty + } + for _, address := range addresses { + if r.ipSet.Contains(address) { + return true + } + } + return false + } if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) } - if len(metadata.DestinationAddresses) > 0 { - for _, address := range metadata.DestinationAddresses { + addresses := metadata.DestinationAddresses + if len(addresses) > 0 { + for _, address := range addresses { if r.ipSet.Contains(address) { return true } diff --git a/route/rule/rule_item_ip_accept_any.go b/route/rule/rule_item_ip_accept_any.go index 1ca7125735..fceebc1860 100644 --- a/route/rule/rule_item_ip_accept_any.go +++ b/route/rule/rule_item_ip_accept_any.go @@ -13,6 +13,9 @@ func NewIPAcceptAnyItem() *IPAcceptAnyItem { } func (r *IPAcceptAnyItem) Match(metadata *adapter.InboundContext) bool { + if metadata.DestinationAddressMatchFromResponse { + return len(metadata.DNSResponseAddressesForMatch()) > 0 + } return len(metadata.DestinationAddresses) > 0 } diff --git a/route/rule/rule_item_ip_is_private.go b/route/rule/rule_item_ip_is_private.go index e185db1db4..c968877395 100644 --- a/route/rule/rule_item_ip_is_private.go +++ b/route/rule/rule_item_ip_is_private.go @@ -1,8 +1,6 @@ package rule import ( - "net/netip" - "github.com/sagernet/sing-box/adapter" N "github.com/sagernet/sing/common/network" ) @@ -18,21 +16,24 @@ func NewIPIsPrivateItem(isSource bool) *IPIsPrivateItem { } func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { - var destination netip.Addr if r.isSource { - destination = metadata.Source.Addr - } else { - destination = metadata.Destination.Addr - } - if destination.IsValid() { - return !N.IsPublicAddr(destination) + return !N.IsPublicAddr(metadata.Source.Addr) } - if !r.isSource { - for _, destinationAddress := range metadata.DestinationAddresses { + if metadata.DestinationAddressMatchFromResponse { + for _, destinationAddress := range metadata.DNSResponseAddressesForMatch() { if !N.IsPublicAddr(destinationAddress) { return true } } + return false + } + if metadata.Destination.Addr.IsValid() { + return !N.IsPublicAddr(metadata.Destination.Addr) + } + for _, destinationAddress := range metadata.DestinationAddresses { + if !N.IsPublicAddr(destinationAddress) { + return true + } } return false } diff --git a/route/rule/rule_item_response_rcode.go b/route/rule/rule_item_response_rcode.go new file mode 100644 index 0000000000..cac75e8034 --- /dev/null +++ b/route/rule/rule_item_response_rcode.go @@ -0,0 +1,26 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" + + "github.com/miekg/dns" +) + +var _ RuleItem = (*DNSResponseRCodeItem)(nil) + +type DNSResponseRCodeItem struct { + rcode int +} + +func NewDNSResponseRCodeItem(rcode int) *DNSResponseRCodeItem { + return &DNSResponseRCodeItem{rcode: rcode} +} + +func (r *DNSResponseRCodeItem) Match(metadata *adapter.InboundContext) bool { + return metadata.DNSResponse != nil && metadata.DNSResponse.Rcode == r.rcode +} + +func (r *DNSResponseRCodeItem) String() string { + return F.ToString("response_rcode=", dns.RcodeToString[r.rcode]) +} diff --git a/route/rule/rule_item_response_record.go b/route/rule/rule_item_response_record.go new file mode 100644 index 0000000000..3a2c889beb --- /dev/null +++ b/route/rule/rule_item_response_record.go @@ -0,0 +1,63 @@ +package rule + +import ( + "strings" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/option" + + "github.com/miekg/dns" +) + +var _ RuleItem = (*DNSResponseRecordItem)(nil) + +type DNSResponseRecordItem struct { + field string + records []option.DNSRecordOptions + selector func(*dns.Msg) []dns.RR +} + +func NewDNSResponseRecordItem(field string, records []option.DNSRecordOptions, selector func(*dns.Msg) []dns.RR) *DNSResponseRecordItem { + return &DNSResponseRecordItem{ + field: field, + records: records, + selector: selector, + } +} + +func (r *DNSResponseRecordItem) Match(metadata *adapter.InboundContext) bool { + if metadata.DNSResponse == nil { + return false + } + records := r.selector(metadata.DNSResponse) + for _, expected := range r.records { + for _, record := range records { + if expected.Match(record) { + return true + } + } + } + return false +} + +func (r *DNSResponseRecordItem) String() string { + descriptions := make([]string, 0, len(r.records)) + for _, record := range r.records { + if record.RR != nil { + descriptions = append(descriptions, record.RR.String()) + } + } + return r.field + "=[" + strings.Join(descriptions, " ") + "]" +} + +func dnsResponseAnswers(message *dns.Msg) []dns.RR { + return message.Answer +} + +func dnsResponseNS(message *dns.Msg) []dns.RR { + return message.Ns +} + +func dnsResponseExtra(message *dns.Msg) []dns.RR { + return message.Extra +} diff --git a/route/rule/rule_item_rule_set.go b/route/rule/rule_item_rule_set.go index 3467843ba1..0136494353 100644 --- a/route/rule/rule_item_rule_set.go +++ b/route/rule/rule_item_rule_set.go @@ -29,9 +29,11 @@ func NewRuleSetItem(router adapter.Router, tagList []string, ipCIDRMatchSource b } func (r *RuleSetItem) Start() error { + _ = r.Close() for _, tag := range r.tagList { ruleSet, loaded := r.router.RuleSet(tag) if !loaded { + _ = r.Close() return E.New("rule-set not found: ", tag) } ruleSet.IncRef() @@ -40,6 +42,15 @@ func (r *RuleSetItem) Start() error { return nil } +func (r *RuleSetItem) Close() error { + for _, ruleSet := range r.setList { + ruleSet.DecRef() + } + clear(r.setList) + r.setList = nil + return nil +} + func (r *RuleSetItem) Match(metadata *adapter.InboundContext) bool { return !r.matchStates(metadata).isEmpty() } diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go new file mode 100644 index 0000000000..21d2070d9b --- /dev/null +++ b/route/rule/rule_item_rule_set_test.go @@ -0,0 +1,138 @@ +package rule + +import ( + "context" + "net" + "sync/atomic" + "testing" + "time" + + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-tun" + N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/common/x/list" + + "github.com/stretchr/testify/require" + "go4.org/netipx" +) + +type ruleSetItemTestRouter struct { + ruleSets map[string]adapter.RuleSet +} + +func (r *ruleSetItemTestRouter) Start(adapter.StartStage) error { return nil } +func (r *ruleSetItemTestRouter) Close() error { return nil } +func (r *ruleSetItemTestRouter) PreMatch(adapter.InboundContext, tun.DirectRouteContext, time.Duration, bool) (tun.DirectRouteDestination, error) { + return nil, nil +} + +func (r *ruleSetItemTestRouter) RouteConnection(context.Context, net.Conn, adapter.InboundContext) error { + return nil +} + +func (r *ruleSetItemTestRouter) RoutePacketConnection(context.Context, N.PacketConn, adapter.InboundContext) error { + return nil +} + +func (r *ruleSetItemTestRouter) RouteConnectionEx(context.Context, net.Conn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *ruleSetItemTestRouter) RoutePacketConnectionEx(context.Context, N.PacketConn, adapter.InboundContext, N.CloseHandlerFunc) { +} + +func (r *ruleSetItemTestRouter) RuleSet(tag string) (adapter.RuleSet, bool) { + ruleSet, loaded := r.ruleSets[tag] + return ruleSet, loaded +} +func (r *ruleSetItemTestRouter) Rules() []adapter.Rule { return nil } +func (r *ruleSetItemTestRouter) NeedFindProcess() bool { return false } +func (r *ruleSetItemTestRouter) NeedFindNeighbor() bool { return false } +func (r *ruleSetItemTestRouter) NeighborResolver() adapter.NeighborResolver { return nil } +func (r *ruleSetItemTestRouter) AppendTracker(adapter.ConnectionTracker) {} +func (r *ruleSetItemTestRouter) ResetNetwork() {} + +type countingRuleSet struct { + name string + refs atomic.Int32 +} + +func (s *countingRuleSet) Name() string { return s.name } +func (s *countingRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) error { return nil } +func (s *countingRuleSet) PostStart() error { return nil } +func (s *countingRuleSet) Metadata() adapter.RuleSetMetadata { return adapter.RuleSetMetadata{} } +func (s *countingRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *countingRuleSet) IncRef() { s.refs.Add(1) } +func (s *countingRuleSet) DecRef() { + if s.refs.Add(-1) < 0 { + panic("rule-set: negative refs") + } +} +func (s *countingRuleSet) Cleanup() {} +func (s *countingRuleSet) RegisterCallback(adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + return nil +} +func (s *countingRuleSet) UnregisterCallback(*list.Element[adapter.RuleSetUpdateCallback]) {} +func (s *countingRuleSet) Close() error { return nil } +func (s *countingRuleSet) Match(*adapter.InboundContext) bool { return true } +func (s *countingRuleSet) String() string { return s.name } +func (s *countingRuleSet) RefCount() int32 { return s.refs.Load() } + +func TestRuleSetItemCloseReleasesRefs(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + secondSet := &countingRuleSet{name: "second"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + "second": secondSet, + }, + }, []string{"first", "second"}, false, false) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + require.EqualValues(t, 1, secondSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) + require.Zero(t, secondSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) + require.Zero(t, secondSet.RefCount()) +} + +func TestRuleSetItemStartRollbackOnFailure(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + }, + }, []string{"first", "missing"}, false, false) + + err := item.Start() + require.ErrorContains(t, err, "rule-set not found: missing") + require.Zero(t, firstSet.RefCount()) +} + +func TestRuleSetItemRestartKeepsBalancedRefs(t *testing.T) { + t.Parallel() + + firstSet := &countingRuleSet{name: "first"} + item := NewRuleSetItem(&ruleSetItemTestRouter{ + ruleSets: map[string]adapter.RuleSet{ + "first": firstSet, + }, + }, []string{"first"}, false, false) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + + require.NoError(t, item.Start()) + require.EqualValues(t, 1, firstSet.RefCount()) + + require.NoError(t, item.Close()) + require.Zero(t, firstSet.RefCount()) +} diff --git a/route/rule/rule_nested_action.go b/route/rule/rule_nested_action.go new file mode 100644 index 0000000000..44e58839b5 --- /dev/null +++ b/route/rule/rule_nested_action.go @@ -0,0 +1,71 @@ +package rule + +import ( + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +func ValidateNoNestedRuleActions(rule option.Rule) error { + return validateNoNestedRuleActions(rule, false) +} + +func ValidateNoNestedDNSRuleActions(rule option.DNSRule) error { + return validateNoNestedDNSRuleActions(rule, false) +} + +func validateNoNestedRuleActions(rule option.Rule, nested bool) error { + if nested && ruleHasConfiguredAction(rule) { + return E.New(option.RouteRuleActionNestedUnsupportedMessage) + } + if rule.Type != C.RuleTypeLogical { + return nil + } + for i, subRule := range rule.LogicalOptions.Rules { + err := validateNoNestedRuleActions(subRule, true) + if err != nil { + return E.Cause(err, "sub rule[", i, "]") + } + } + return nil +} + +func validateNoNestedDNSRuleActions(rule option.DNSRule, nested bool) error { + if nested && dnsRuleHasConfiguredAction(rule) { + return E.New(option.DNSRuleActionNestedUnsupportedMessage) + } + if rule.Type != C.RuleTypeLogical { + return nil + } + for i, subRule := range rule.LogicalOptions.Rules { + err := validateNoNestedDNSRuleActions(subRule, true) + if err != nil { + return E.Cause(err, "sub rule[", i, "]") + } + } + return nil +} + +func ruleHasConfiguredAction(rule option.Rule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return !reflect.DeepEqual(rule.DefaultOptions.RuleAction, option.RuleAction{}) + case C.RuleTypeLogical: + return !reflect.DeepEqual(rule.LogicalOptions.RuleAction, option.RuleAction{}) + default: + return false + } +} + +func dnsRuleHasConfiguredAction(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return !reflect.DeepEqual(rule.DefaultOptions.DNSRuleAction, option.DNSRuleAction{}) + case C.RuleTypeLogical: + return !reflect.DeepEqual(rule.LogicalOptions.DNSRuleAction, option.DNSRuleAction{}) + default: + return false + } +} diff --git a/route/rule/rule_nested_action_test.go b/route/rule/rule_nested_action_test.go new file mode 100644 index 0000000000..f895b89282 --- /dev/null +++ b/route/rule/rule_nested_action_test.go @@ -0,0 +1,88 @@ +package rule + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/log" + "github.com/sagernet/sing-box/option" + + "github.com/stretchr/testify/require" +) + +func TestNewRuleRejectsNestedRuleAction(t *testing.T) { + t.Parallel() + + _, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), option.Rule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalRule{ + RawLogicalRule: option.RawLogicalRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.Rule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultRule{ + RuleAction: option.RuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.RouteActionOptions{ + Outbound: "direct", + }, + }, + }, + }}, + }, + }, + }, false) + require.ErrorContains(t, err, option.RouteRuleActionNestedUnsupportedMessage) +} + +func TestNewDNSRuleRejectsNestedRuleAction(t *testing.T) { + t.Parallel() + + _, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), option.DNSRule{ + Type: C.RuleTypeLogical, + LogicalOptions: option.LogicalDNSRule{ + RawLogicalDNSRule: option.RawLogicalDNSRule{ + Mode: C.LogicalTypeAnd, + Rules: []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + }, + }, + }, + }}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "default", + }, + }, + }, + }, true, false) + require.ErrorContains(t, err, option.DNSRuleActionNestedUnsupportedMessage) +} + +func TestNewDNSRuleRejectsReplyRejectMethod(t *testing.T) { + t.Parallel() + + _, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), option.DNSRule{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + Domain: []string{"example.com"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeReject, + RejectOptions: option.RejectActionOptions{ + Method: C.RuleActionRejectMethodReply, + }, + }, + }, + }, false, false) + require.ErrorContains(t, err, "reject method `reply` is not supported for DNS rules") +} diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 39068dbf35..d286a7941d 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -9,6 +9,7 @@ import ( "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/service" "go4.org/netipx" ) @@ -69,3 +70,24 @@ func isWIFIHeadlessRule(rule option.DefaultHeadlessRule) bool { func isIPCIDRHeadlessRule(rule option.DefaultHeadlessRule) bool { return len(rule.IPCIDR) > 0 || rule.IPSet != nil } + +func isDNSQueryTypeHeadlessRule(rule option.DefaultHeadlessRule) bool { + return len(rule.QueryType) > 0 +} + +func buildRuleSetMetadata(headlessRules []option.HeadlessRule) adapter.RuleSetMetadata { + return adapter.RuleSetMetadata{ + ContainsProcessRule: HasHeadlessRule(headlessRules, isProcessHeadlessRule), + ContainsWIFIRule: HasHeadlessRule(headlessRules, isWIFIHeadlessRule), + ContainsIPCIDRRule: HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule), + ContainsDNSQueryTypeRule: HasHeadlessRule(headlessRules, isDNSQueryTypeHeadlessRule), + } +} + +func validateRuleSetMetadataUpdate(ctx context.Context, tag string, metadata adapter.RuleSetMetadata) error { + validator := service.FromContext[adapter.DNSRuleSetUpdateValidator](ctx) + if validator == nil { + return nil + } + return validator.ValidateRuleSetMetadataUpdate(tag, metadata) +} diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index ed873d7069..5408615fc0 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -137,10 +137,11 @@ func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error { return E.Cause(err, "parse rule_set.rules.[", i, "]") } } - var metadata adapter.RuleSetMetadata - metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) - metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) - metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) + metadata := buildRuleSetMetadata(headlessRules) + err = validateRuleSetMetadataUpdate(s.ctx, s.tag, metadata) + if err != nil { + return err + } s.access.Lock() s.rules = rules s.metadata = metadata diff --git a/route/rule/rule_set_remote.go b/route/rule/rule_set_remote.go index bda6e23f1e..53d353b3c1 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -189,10 +189,13 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { return E.Cause(err, "parse rule_set.rules.[", i, "]") } } + metadata := buildRuleSetMetadata(plainRuleSet.Rules) + err = validateRuleSetMetadataUpdate(s.ctx, s.options.Tag, metadata) + if err != nil { + return err + } s.access.Lock() - s.metadata.ContainsProcessRule = HasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule) - s.metadata.ContainsWIFIRule = HasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule) - s.metadata.ContainsIPCIDRRule = HasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule) + s.metadata = metadata s.rules = rules callbacks := s.callbacks.Array() s.access.Unlock() diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index a01defe6e6..2fc559d204 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -2,6 +2,7 @@ package rule import ( "context" + "net" "net/netip" "strings" "testing" @@ -14,6 +15,7 @@ import ( M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" + mDNS "github.com/miekg/dns" "github.com/stretchr/testify/require" ) @@ -581,7 +583,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.MatchAddressLimit(&metadata)) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) }) t.Run("dns keeps ruleset or semantics", func(t *testing.T) { t.Parallel() @@ -596,7 +598,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{emptyStateSet, destinationStateSet}}) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.True(t, rule.MatchAddressLimit(&metadata)) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) }) t.Run("ruleset ip cidr flags stay scoped", func(t *testing.T) { t.Parallel() @@ -610,10 +612,384 @@ func TestDNSRuleSetSemantics(t *testing.T) { ipCidrAcceptEmpty: true, }) }) - require.True(t, rule.MatchAddressLimit(&metadata)) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + require.False(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest())) require.False(t, metadata.IPCIDRMatchSource) require.False(t, metadata.IPCIDRAcceptEmpty) }) + t.Run("pre lookup ruleset only deferred fields fail closed", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-prelookup-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + // This is accepted without match_response so mixed rule_set deployments keep + // working; the destination-IP-only branch simply cannot match before a DNS + // response is available. + require.False(t, rule.Match(&metadata)) + }) + t.Run("pre lookup ruleset destination cidr does not fall back to other predicates", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("lookup.example") + ruleSet := newLocalRuleSetForTest("dns-prelookup-network-and-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + require.False(t, rule.Match(&metadata)) + }) + t.Run("pre lookup mixed ruleset still matches non response branch", func(t *testing.T) { + t.Parallel() + metadata := testMetadata("www.example.com") + ruleSet := newLocalRuleSetForTest( + "dns-prelookup-mixed", + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationAddressItem(t, rule, nil, []string{"example.com"}) + }), + ) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + // Destination-IP predicates inside rule_set fail closed before the DNS response, + // but they must not force validation errors or suppress sibling non-response + // branches. + require.True(t, rule.Match(&metadata)) + }) +} + +func TestDNSMatchResponseRuleSetDestinationCIDRUsesDNSResponse(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-response-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + rule.matchResponse = true + + matchedMetadata := testMetadata("lookup.example") + matchedMetadata.DNSResponse = dnsResponseForTest(netip.MustParseAddr("203.0.113.1")) + require.True(t, rule.Match(&matchedMetadata)) + require.Empty(t, matchedMetadata.DestinationAddresses) + + unmatchedMetadata := testMetadata("lookup.example") + unmatchedMetadata.DNSResponse = dnsResponseForTest(netip.MustParseAddr("8.8.8.8")) + require.False(t, rule.Match(&unmatchedMetadata)) +} + +func TestDNSMatchResponseMissingResponseUsesBooleanSemantics(t *testing.T) { + t.Parallel() + + t.Run("plain rule remains false", func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) {}) + rule.matchResponse = true + + metadata := testMetadata("lookup.example") + require.False(t, rule.Match(&metadata)) + }) + + t.Run("invert rule becomes true", func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + }) + rule.matchResponse = true + + metadata := testMetadata("lookup.example") + require.True(t, rule.Match(&metadata)) + }) + + t.Run("logical wrapper respects inverted child", func(t *testing.T) { + t.Parallel() + + nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + }) + nestedRule.matchResponse = true + + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{nestedRule}, + mode: C.LogicalTypeAnd, + }, + } + + metadata := testMetadata("lookup.example") + require.True(t, logicalRule.Match(&metadata)) + }) +} + +func TestDNSAddressLimitIgnoresDestinationAddresses(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedResponse *mDNS.Msg + unmatchedResponse *mDNS.Msg + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("10.0.0.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + testCase.build(t, rule) + }) + + mismatchMetadata := testMetadata("lookup.example") + mismatchMetadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("203.0.113.1")} + require.False(t, rule.MatchAddressLimit(&mismatchMetadata, testCase.unmatchedResponse)) + + matchMetadata := testMetadata("lookup.example") + matchMetadata.DestinationAddresses = []netip.Addr{netip.MustParseAddr("8.8.8.8")} + require.True(t, rule.MatchAddressLimit(&matchMetadata, testCase.matchedResponse)) + }) + } +} + +func TestDNSLegacyAddressLimitPreLookupDefersDirectRules(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedResponse *mDNS.Msg + unmatchedResponse *mDNS.Msg + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("10.0.0.1")), + unmatchedResponse: dnsResponseForTest(netip.MustParseAddr("8.8.8.8")), + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedResponse: dnsResponseForTest(netip.MustParseAddr("203.0.113.1")), + unmatchedResponse: dnsResponseForTest(), + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&matchedMetadata, testCase.matchedResponse)) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&unmatchedMetadata, testCase.unmatchedResponse)) + }) + } +} + +func TestDNSLegacyAddressLimitPreLookupDefersRuleSetDestinationCIDR(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-legacy-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) +} + +func TestDNSLegacyLogicalAddressLimitPreLookupDefersNestedRules(t *testing.T) { + t.Parallel() + + nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationIPIsPrivateItem(rule) + }) + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{nestedRule}, + mode: C.LogicalTypeAnd, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("10.0.0.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.False(t, logicalRule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) +} + +func TestDNSLegacyInvertAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + build func(*testing.T, *abstractDefaultRule) + matchedAddrs []netip.Addr + unmatchedAddrs []netip.Addr + }{ + { + name: "ip_cidr", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_is_private", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPIsPrivateItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("10.0.0.1")}, + unmatchedAddrs: []netip.Addr{netip.MustParseAddr("8.8.8.8")}, + }, + { + name: "ip_accept_any", + build: func(t *testing.T, rule *abstractDefaultRule) { + t.Helper() + addDestinationIPAcceptAnyItem(rule) + }, + matchedAddrs: []netip.Addr{netip.MustParseAddr("203.0.113.1")}, + }, + } + for _, testCase := range testCases { + testCase := testCase + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + testCase.build(t, rule) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(testCase.matchedAddrs...))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(testCase.unmatchedAddrs...))) + }) + } +} + +func TestDNSLegacyInvertLogicalAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + t.Run("inverted deferred child does not suppress branch", func(t *testing.T) { + t.Parallel() + + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{ + dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationIPIsPrivateItem(rule) + }), + }, + mode: C.LogicalTypeAnd, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) + }) +} + +func TestDNSLegacyInvertRuleSetAddressLimitPreLookupRegression(t *testing.T) { + t.Parallel() + + ruleSet := newLocalRuleSetForTest("dns-legacy-invert-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { + rule.invert = true + addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) + })) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) + }) + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) } func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { @@ -665,14 +1041,14 @@ func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { matchedMetadata := testMetadata("lookup.example") matchedMetadata.DestinationAddresses = testCase.matchedAddrs - require.False(t, rule.MatchAddressLimit(&matchedMetadata)) + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(testCase.matchedAddrs...))) unmatchedMetadata := testMetadata("lookup.example") unmatchedMetadata.DestinationAddresses = testCase.unmatchedAddrs - require.True(t, rule.MatchAddressLimit(&unmatchedMetadata)) + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(testCase.unmatchedAddrs...))) }) } - t.Run("mixed resolved and deferred fields keep old pre lookup false", func(t *testing.T) { + t.Run("mixed resolved and deferred fields invert matches pre lookup", func(t *testing.T) { t.Parallel() metadata := testMetadata("lookup.example") rule := dnsRuleForTest(func(rule *abstractDefaultRule) { @@ -680,9 +1056,9 @@ func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { addOtherItem(rule, NewNetworkItem([]string{N.NetworkTCP})) addDestinationIPCIDRItem(t, rule, []string{"203.0.113.0/24"}) }) - require.False(t, rule.Match(&metadata)) + require.True(t, rule.Match(&metadata)) }) - t.Run("ruleset only deferred fields keep old pre lookup false", func(t *testing.T) { + t.Run("ruleset only deferred fields invert matches pre lookup", func(t *testing.T) { t.Parallel() metadata := testMetadata("lookup.example") ruleSet := newLocalRuleSetForTest("dns-ruleset-ipcidr", headlessDefaultRule(t, func(rule *abstractDefaultRule) { @@ -692,7 +1068,7 @@ func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { rule.invert = true addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) }) - require.False(t, rule.Match(&metadata)) + require.True(t, rule.Match(&metadata)) }) } @@ -763,6 +1139,39 @@ func testMetadata(domain string) adapter.InboundContext { } } +func dnsResponseForTest(addresses ...netip.Addr) *mDNS.Msg { + response := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + } + for _, address := range addresses { + if address.Is4() { + response.Answer = append(response.Answer, &mDNS.A{ + Hdr: mDNS.RR_Header{ + Name: mDNS.Fqdn("lookup.example"), + Rrtype: mDNS.TypeA, + Class: mDNS.ClassINET, + Ttl: 60, + }, + A: net.IP(append([]byte(nil), address.AsSlice()...)), + }) + } else { + response.Answer = append(response.Answer, &mDNS.AAAA{ + Hdr: mDNS.RR_Header{ + Name: mDNS.Fqdn("lookup.example"), + Rrtype: mDNS.TypeAAAA, + Class: mDNS.ClassINET, + Ttl: 60, + }, + AAAA: net.IP(append([]byte(nil), address.AsSlice()...)), + }) + } + } + return response +} + func addRuleSetItem(rule *abstractDefaultRule, item *RuleSetItem) { rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) diff --git a/route/rule/rule_set_update_validation_test.go b/route/rule/rule_set_update_validation_test.go new file mode 100644 index 0000000000..2f29e551a6 --- /dev/null +++ b/route/rule/rule_set_update_validation_test.go @@ -0,0 +1,110 @@ +package rule + +import ( + "context" + "sync/atomic" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/x/list" + "github.com/sagernet/sing/service" + "github.com/stretchr/testify/require" +) + +type fakeDNSRuleSetUpdateValidator struct { + validate func(tag string, metadata adapter.RuleSetMetadata) error +} + +func (v *fakeDNSRuleSetUpdateValidator) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.RuleSetMetadata) error { + if v.validate == nil { + return nil + } + return v.validate(tag, metadata) +} + +func TestLocalRuleSetReloadRulesRejectsInvalidUpdateBeforeCommit(t *testing.T) { + t.Parallel() + + var callbackCount atomic.Int32 + ctx := service.ContextWith[adapter.DNSRuleSetUpdateValidator](context.Background(), &fakeDNSRuleSetUpdateValidator{ + validate: func(tag string, metadata adapter.RuleSetMetadata) error { + require.Equal(t, "dynamic-set", tag) + if metadata.ContainsDNSQueryTypeRule { + return E.New("dns conflict") + } + return nil + }, + }) + ruleSet := &LocalRuleSet{ + ctx: ctx, + tag: "dynamic-set", + fileFormat: C.RuleSetFormatSource, + } + _ = ruleSet.callbacks.PushBack(func(adapter.RuleSet) { + callbackCount.Add(1) + }) + + err := ruleSet.reloadRules([]option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + Domain: badoption.Listable[string]{"example.com"}, + }, + }}) + require.NoError(t, err) + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) + + err = ruleSet.reloadRules([]option.HeadlessRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultHeadlessRule{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(1)}, + }, + }}) + require.ErrorContains(t, err, "dns conflict") + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) +} + +func TestRemoteRuleSetLoadBytesRejectsInvalidUpdateBeforeCommit(t *testing.T) { + t.Parallel() + + var callbackCount atomic.Int32 + ctx := service.ContextWith[adapter.DNSRuleSetUpdateValidator](context.Background(), &fakeDNSRuleSetUpdateValidator{ + validate: func(tag string, metadata adapter.RuleSetMetadata) error { + require.Equal(t, "dynamic-set", tag) + if metadata.ContainsDNSQueryTypeRule { + return E.New("dns conflict") + } + return nil + }, + }) + ruleSet := &RemoteRuleSet{ + ctx: ctx, + options: option.RuleSet{ + Tag: "dynamic-set", + Format: C.RuleSetFormatSource, + }, + callbacks: list.List[adapter.RuleSetUpdateCallback]{}, + } + _ = ruleSet.callbacks.PushBack(func(adapter.RuleSet) { + callbackCount.Add(1) + }) + + err := ruleSet.loadBytes([]byte(`{"version":4,"rules":[{"domain":["example.com"]}]}`)) + require.NoError(t, err) + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) + + err = ruleSet.loadBytes([]byte(`{"version":4,"rules":[{"query_type":["A"]}]}`)) + require.ErrorContains(t, err, "dns conflict") + require.Equal(t, int32(1), callbackCount.Load()) + require.False(t, ruleSet.metadata.ContainsDNSQueryTypeRule) + require.True(t, ruleSet.Match(&adapter.InboundContext{Domain: "example.com"})) +}