From 034727c3b62d185bc690f9e6be1415a26d290ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 15:23:50 +0800 Subject: [PATCH 01/67] Add evaluate DNS rule action and related rule items --- adapter/inbound.go | 3 + constant/rule.go | 1 + dns/router.go | 361 ++++++++++++++++++++++-- dns/router_test.go | 338 ++++++++++++++++++++++ experimental/deprecated/constants.go | 27 ++ option/dns_record.go | 25 +- option/rule_action.go | 4 + option/rule_dns.go | 5 + route/rule/rule_action.go | 41 ++- route/rule/rule_dns.go | 63 ++++- route/rule/rule_item_response_rcode.go | 24 ++ route/rule/rule_item_response_record.go | 63 +++++ 12 files changed, 913 insertions(+), 42 deletions(-) create mode 100644 dns/router_test.go create mode 100644 route/rule/rule_item_response_rcode.go create mode 100644 route/rule/rule_item_response_record.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 52af336e5b..903ab9f9b4 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 { @@ -80,6 +82,7 @@ type InboundContext struct { FallbackDelay time.Duration DestinationAddresses []netip.Addr + DNSResponse *dns.Msg SourceGeoIPCode string GeoIPCode string ProcessInfo *ConnectionOwner diff --git a/constant/rule.go b/constant/rule.go index 55cad2e137..2a5aaefda8 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -29,6 +29,7 @@ const ( const ( RuleActionTypeRoute = "route" RuleActionTypeRouteOptions = "route-options" + RuleActionTypeEvaluate = "evaluate" RuleActionTypeDirect = "direct" RuleActionTypeBypass = "bypass" RuleActionTypeReject = "reject" diff --git a/dns/router.go b/dns/router.go index 4f18959b7c..8e552bd52a 100644 --- a/dns/router.go +++ b/dns/router.go @@ -10,6 +10,7 @@ import ( "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 +20,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" @@ -29,15 +31,16 @@ import ( var _ adapter.DNSRouter = (*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 + rules []adapter.DNSRule + defaultDomainStrategy C.DomainStrategy + dnsReverseMapping freelru.Cache[netip.Addr, string] + platformInterface adapter.PlatformInterface + legacyAddressFilterMode bool } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -74,8 +77,15 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp } func (r *Router) Initialize(rules []option.DNSRule) error { + r.legacyAddressFilterMode = !hasNonLegacyAddressFilterItems(rules) + if !r.legacyAddressFilterMode { + err := validateNonLegacyAddressFilterRules(rules) + if err != nil { + return err + } + } for i, ruleOptions := range rules { - dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true) + dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, r.legacyAddressFilterMode) if err != nil { return E.Cause(err, "parse dns rule[", i, "]") } @@ -100,6 +110,9 @@ func (r *Router) Start(stage adapter.StartStage) error { return E.Cause(err, "initialize DNS rule[", i, "]") } } + if r.legacyAddressFilterMode && common.Any(r.rules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + } } return nil } @@ -207,6 +220,209 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, return transport, nil, -1 } +func (r *Router) applyTransportDefaults(transport adapter.DNSTransport, options *adapter.DNSQueryOptions) { + if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { + if options.Strategy == C.DomainStrategyAsIS { + options.Strategy = legacyTransport.LegacyStrategy() + } + if !options.ClientSubnet.IsValid() { + options.ClientSubnet = legacyTransport.LegacyClientSubnet() + } + } +} + +func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) { + if routeOptions.Strategy != C.DomainStrategyAsIS { + options.Strategy = routeOptions.Strategy + } + if routeOptions.DisableCache { + options.DisableCache = true + } + if routeOptions.RewriteTTL != nil { + options.RewriteTTL = routeOptions.RewriteTTL + } + if routeOptions.ClientSubnet.IsValid() { + options.ClientSubnet = routeOptions.ClientSubnet + } +} + +func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, bool) { + transport, loaded := r.transport.Transport(action.Server) + if !loaded { + return nil, false + } + isFakeIP := transport.Type() == C.DNSTypeFakeIP + if isFakeIP && !allowFakeIP { + return transport, false + } + r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) + if isFakeIP { + options.DisableCache = true + } + r.applyTransportDefaults(transport, options) + return transport, true +} + +func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { + displayRuleIndex := ruleIndex + if displayRuleIndex != -1 { + displayRuleIndex += displayRuleIndex + 1 + } + if ruleDescription := currentRule.String(); ruleDescription != "" { + r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + } else { + r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] => ", currentRule.Action()) + } +} + +func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, error) { + metadata := adapter.ContextFrom(ctx) + if metadata == nil { + panic("no context") + } + effectiveOptions := options + var savedResponse *mDNS.Msg + for currentRuleIndex, currentRule := range r.rules { + metadata.ResetRuleCache() + metadata.DNSResponse = savedResponse + metadata.DestinationAddresses = MessageToAddresses(savedResponse) + if !currentRule.Match(metadata) { + continue + } + 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.resolveDNSRoute(&R.RuleActionDNSRoute{ + Server: action.Server, + RuleActionDNSRouteOptions: action.RuleActionDNSRouteOptions, + }, allowFakeIP, &queryOptions) + if !loaded { + if transport == nil { + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + } + continue + } + if queryOptions.Strategy == C.DomainStrategyAsIS { + queryOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) + if err != nil { + r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) + savedResponse = nil + continue + } + savedResponse = response + case *R.RuleActionDNSRoute: + queryOptions := effectiveOptions + transport, loaded := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) + if !loaded { + if transport == nil { + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + } + continue + } + if queryOptions.Strategy == C.DomainStrategyAsIS { + queryOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) + return response, transport, err + case *R.RuleActionReject: + switch action.Method { + case C.RuleActionRejectMethodDefault: + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, + }, nil, nil + case C.RuleActionRejectMethodDrop: + return nil, nil, tun.ErrDrop + } + case *R.RuleActionPredefined: + return action.Response(message), nil, nil + } + } + queryOptions := effectiveOptions + transport := r.transport.Default() + r.applyTransportDefaults(transport, &queryOptions) + if queryOptions.Strategy == C.DomainStrategyAsIS { + queryOptions.Strategy = r.defaultDomainStrategy + } + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) + return response, transport, err +} + +func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + var strategy C.DomainStrategy + if options.LookupStrategy != C.DomainStrategyAsIS { + strategy = options.LookupStrategy + } else { + strategy = options.Strategy + } + lookupOptions := options + if options.LookupStrategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy + } + if strategy == C.DomainStrategyIPv4Only { + return r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + } + if strategy == C.DomainStrategyIPv6Only { + return r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + } + var ( + response4 []netip.Addr + response6 []netip.Addr + ) + var group task.Group + group.Append("exchange4", func(ctx context.Context) error { + response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + if err != nil { + return err + } + response4 = response + return nil + }) + group.Append("exchange6", func(ctx context.Context) error { + response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + if err != nil { + return err + } + response6 = response + return nil + }) + 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, domain string, qType uint16, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + request := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + RecursionDesired: true, + }, + Question: []mDNS.Question{{ + Name: mDNS.Fqdn(FqdnToDomain(domain)), + Qtype: qType, + Qclass: mDNS.ClassINET, + }}, + } + response, _, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + if err != nil { + return nil, err + } + if response.Rcode != mDNS.RcodeSuccess { + return nil, RcodeError(response.Rcode) + } + return MessageToAddresses(response), nil +} + func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { if len(message.Question) != 1 { r.logger.WarnContext(ctx, "bad question size: ", len(message.Question)) @@ -239,18 +455,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() - } - } + r.applyTransportDefaults(transport, &options) if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } response, err = r.client.Exchange(ctx, transport, message, options, nil) + } else if !r.legacyAddressFilterMode { + response, transport, err = r.exchangeWithRules(ctx, message, options, true) } else { var ( rule adapter.DNSRule @@ -352,18 +563,13 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ metadata.Domain = FqdnToDomain(domain) 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() - } - } + r.applyTransportDefaults(transport, &options) if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) + } else if !r.legacyAddressFilterMode { + responseAddrs, err = r.lookupWithRules(ctx, domain, options) } else { var ( transport adapter.DNSTransport @@ -458,3 +664,106 @@ func (r *Router) ResetNetwork() { transport.Reset() } } + +func hasNonLegacyAddressFilterItems(rules []option.DNSRule) bool { + return common.Any(rules, hasNonLegacyAddressFilterItemsInRule) +} + +func hasNonLegacyAddressFilterItemsInRule(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return hasNonLegacyAddressFilterItemsInDefaultRule(rule.DefaultOptions) + case C.RuleTypeLogical: + action := rule.LogicalOptions.Action + return action == C.RuleActionTypeEvaluate || common.Any(rule.LogicalOptions.Rules, hasNonLegacyAddressFilterItemsInRule) + default: + return false + } +} + +func hasNonLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { + action := rule.Action + return action == C.RuleActionTypeEvaluate || + rule.MatchResponse || + rule.ResponseRcode != nil || + len(rule.ResponseAnswer) > 0 || + len(rule.ResponseNs) > 0 || + len(rule.ResponseExtra) > 0 +} + +func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { + var seenEvaluate bool + for i, rule := range rules { + consumesResponse, err := validateNonLegacyAddressFilterRuleTree(rule) + if err != nil { + return E.Cause(err, "validate dns rule[", i, "]") + } + action := dnsRuleActionType(rule) + if action == C.RuleActionTypeEvaluate && consumesResponse { + return E.New("dns rule[", i, "]: evaluate rule cannot consume response state") + } + if consumesResponse && !seenEvaluate { + return E.New("dns rule[", i, "]: response matching requires a preceding top-level evaluate rule") + } + if action == C.RuleActionTypeEvaluate { + seenEvaluate = true + } + } + return nil +} + +func validateNonLegacyAddressFilterRuleTree(rule option.DNSRule) (bool, error) { + switch rule.Type { + case "", C.RuleTypeDefault: + return validateNonLegacyAddressFilterDefaultRule(rule.DefaultOptions) + case C.RuleTypeLogical: + var consumesResponse bool + for i, subRule := range rule.LogicalOptions.Rules { + subConsumesResponse, err := validateNonLegacyAddressFilterRuleTree(subRule) + if err != nil { + return false, E.Cause(err, "sub rule[", i, "]") + } + consumesResponse = consumesResponse || subConsumesResponse + } + return consumesResponse, nil + default: + return false, nil + } +} + +func validateNonLegacyAddressFilterDefaultRule(rule option.DefaultDNSRule) (bool, error) { + hasResponseRecords := rule.ResponseRcode != nil || + len(rule.ResponseAnswer) > 0 || + len(rule.ResponseNs) > 0 || + len(rule.ResponseExtra) > 0 + if hasResponseRecords && !rule.MatchResponse { + return false, E.New("response_* items require match_response") + } + if (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { + return false, E.New("ip_cidr and ip_is_private require match_response in DNS evaluate mode") + } + if rule.IPAcceptAny { + return false, E.New("ip_accept_any is removed in DNS evaluate mode, use ip_cidr with match_response") + } + if rule.RuleSetIPCIDRAcceptEmpty { + return false, E.New("rule_set_ip_cidr_accept_empty is removed in DNS evaluate mode") + } + return rule.MatchResponse, nil +} + +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 "" + } +} diff --git a/dns/router_test.go b/dns/router_test.go new file mode 100644 index 0000000000..d7deb848f5 --- /dev/null +++ b/dns/router_test.go @@ -0,0 +1,338 @@ +package dns + +import ( + "context" + "errors" + "net/netip" + "testing" + + "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/json/badoption" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +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, errors.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 errors.New("unsupported") +} + +type fakeDNSClient struct { + exchange func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) +} + +type fakeDeprecatedManager struct { + features []deprecated.Note +} + +func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { + m.features = append(m.features, feature) +} + +func (c *fakeDNSClient) Start() {} + +func (c *fakeDNSClient) Exchange(_ context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func([]netip.Addr) bool) (*mDNS.Msg, error) { + return c.exchange(transport, message) +} + +func (c *fakeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func([]netip.Addr) bool) ([]netip.Addr, error) { + return nil, errors.New("unused client lookup") +} + +func (c *fakeDNSClient) ClearCache() {} + +func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + t.Helper() + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: transportManager, + client: client, + rules: make([]adapter.DNSRule, 0, len(rules)), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + if rules != nil { + err := router.Initialize(rules) + require.NoError(t, err) + } + return router +} + +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 TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { + t.Parallel() + + err := validateNonLegacyAddressFilterRules([]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.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") +} + +func TestExchangeNewModeEvaluateMatchResponseRoute(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, errors.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 TestLookupNewModeAllowsPartialSuccess(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, errors.New("ipv6 failed") + default: + return nil, errors.New("unexpected qtype") + } + }, + }) + router.legacyAddressFilterMode = 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 TestLookupNewModeSkipsFakeIPRule(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.legacyAddressFilterMode = 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 TestLookupNewModeDoesNotUseQueryTypeRule(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{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "only-a"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "only-a": &fakeDNSTransport{tag: "only-a", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "default": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + case "only-a": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + router.legacyAddressFilterMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("3.3.3.3")}, addresses) +} + +func TestOldModeReportsLegacyAddressFilterDeprecation(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{}, + rules: make([]adapter.DNSRule, 0, 1), + 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) +} diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 3526cda831..d4d12cc727 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -111,6 +111,30 @@ 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: "`ip_accept_any` in DNS rules", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", +} + +var OptionRuleSetIPCIDRAcceptEmpty = Note{ + Name: "dns-rule-rule-set-ip-cidr-accept-empty", + Description: "`rule_set_ip_cidr_accept_empty` in DNS rules", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", +} + +var OptionLegacyDNSAddressFilter = Note{ + Name: "legacy-dns-address-filter", + Description: "legacy address filter DNS rule items", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", +} + var Options = []Note{ OptionLegacyDNSTransport, OptionLegacyDNSFakeIPOptions, @@ -118,4 +142,7 @@ var Options = []Note{ OptionMissingDomainResolver, OptionLegacyDomainStrategyOptions, OptionInlineACME, + OptionIPAcceptAny, + OptionRuleSetIPCIDRAcceptEmpty, + OptionLegacyDNSAddressFilter, } diff --git a/option/dns_record.go b/option/dns_record.go index fa72b61b73..c51341f1dd 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" @@ -51,6 +52,7 @@ func (r *DNSRCode) Build() int { type DNSRecordOptions struct { dns.RR fromBase64 bool + hasTTL bool } func (o DNSRecordOptions) MarshalJSON() ([]byte, error) { @@ -76,7 +78,16 @@ func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { if err == nil { return o.unmarshalBase64(binary) } - record, err := dns.NewRR(stringValue) + parser := dns.NewZoneParser(strings.NewReader(stringValue+"\n"), "", "") + record, ok := parser.Next() + if !ok { + err = parser.Err() + if err == nil { + err = E.New("empty DNS record") + } + return err + } + err = parser.Err() if err != nil { return err } @@ -84,6 +95,7 @@ func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { a.A = M.AddrFromIP(a.A).Unmap().AsSlice() } o.RR = record + o.hasTTL = record.Header().Ttl != 0 return nil } @@ -94,9 +106,20 @@ func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { } o.RR = record o.fromBase64 = true + o.hasTTL = true return nil } 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 + } + if o.hasTTL { + return o.RR.String() == record.String() + } + return dns.IsDuplicate(o.RR, record) +} diff --git a/option/rule_action.go b/option/rule_action.go index 4310825520..027e800766 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -115,6 +115,8 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { case C.RuleActionTypeRoute: r.Action = "" v = r.RouteOptions + case C.RuleActionTypeEvaluate: + v = r.RouteOptions case C.RuleActionTypeRouteOptions: v = r.RouteOptionsOptions case C.RuleActionTypeReject: @@ -137,6 +139,8 @@ 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.RuleActionTypeRouteOptions: v = &r.RouteOptionsOptions case C.RuleActionTypeReject: diff --git a/option/rule_dns.go b/option/rule_dns.go index 880b96ac54..bba6a0db61 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -84,6 +84,11 @@ type RawDefaultDNSRule struct { IPCIDR badoption.Listable[string] `json:"ip_cidr,omitempty"` IPIsPrivate bool `json:"ip_is_private,omitempty"` IPAcceptAny bool `json:"ip_accept_any,omitempty"` + MatchResponse bool `json:"match_response,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"` 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"` diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index cac814e765..9f8ef945e5 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -132,6 +132,16 @@ 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.RuleActionTypeRouteOptions: return &RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptionsOptions.Strategy), @@ -266,18 +276,35 @@ 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) +} + +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_dns.go b/route/rule/rule_dns.go index f33d6096ae..1bb42cbaf9 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -5,6 +5,7 @@ import ( "github.com/sagernet/sing-box/adapter" C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/experimental/deprecated" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing/common" @@ -12,30 +13,36 @@ import ( "github.com/sagernet/sing/service" ) -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, legacyAddressFilter 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") + } 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, legacyAddressFilter) 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") + } 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, legacyAddressFilter) default: return nil, E.New("unknown rule type: ", options.Type) } @@ -45,18 +52,20 @@ 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, legacyAddressFilter 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) @@ -152,10 +161,35 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if options.IPAcceptAny { + if legacyAddressFilter { + deprecated.Report(ctx, deprecated.OptionIPAcceptAny) + } else { + return nil, E.New("ip_accept_any is removed in DNS evaluate mode, use ip_cidr with match_response") + } 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,6 +318,13 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op if options.RuleSetIPCIDRMatchSource { matchSource = true } + if options.RuleSetIPCIDRAcceptEmpty { + if legacyAddressFilter { + deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) + } else { + return nil, E.New("rule_set_ip_cidr_accept_empty is removed in DNS evaluate mode") + } + } item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) @@ -309,6 +350,12 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { + if r.matchResponse { + if metadata.DNSResponse == nil { + return false + } + return r.abstractDefaultRule.Match(metadata) + } metadata.IgnoreDestinationIPCIDRMatch = true defer func() { metadata.IgnoreDestinationIPCIDRMatch = false @@ -330,7 +377,7 @@ 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 NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyAddressFilter bool) (*LogicalDNSRule, error) { r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ rules: make([]adapter.HeadlessRule, len(options.Rules)), @@ -347,7 +394,7 @@ 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) + rule, err := NewDNSRule(ctx, logger, subRule, false, legacyAddressFilter) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") } diff --git a/route/rule/rule_item_response_rcode.go b/route/rule/rule_item_response_rcode.go new file mode 100644 index 0000000000..ae2c622481 --- /dev/null +++ b/route/rule/rule_item_response_rcode.go @@ -0,0 +1,24 @@ +package rule + +import ( + "github.com/sagernet/sing-box/adapter" + F "github.com/sagernet/sing/common/format" +) + +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=", 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 +} From 33e4fcc400154e4a323a9da53aee089425fe2a43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 15:40:21 +0800 Subject: [PATCH 02/67] Reorder DNS rule item fields: match_response above address filter and response items, deprecated fields at bottom --- option/rule_dns.go | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/option/rule_dns.go b/option/rule_dns.go index bba6a0db61..5a73d69fd5 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -78,17 +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"` - MatchResponse bool `json:"match_response,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"` 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"` @@ -115,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"` } From 27b60052fe8716b5c9fa8da43af741468f51cbc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 17:36:02 +0800 Subject: [PATCH 03/67] Fix DNS evaluate routing regressions --- dns/router.go | 111 +++++++++++++++++++++++------------ dns/router_test.go | 128 +++++++++++++++++++++++++++++++++++++++++ route/rule/rule_dns.go | 68 ++++++++++++++++++---- 3 files changed, 257 insertions(+), 50 deletions(-) diff --git a/dns/router.go b/dns/router.go index 8e552bd52a..19232f2889 100644 --- a/dns/router.go +++ b/dns/router.go @@ -275,7 +275,7 @@ func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule ad } } -func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, error) { +func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, adapter.DNSQueryOptions, error) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") @@ -328,7 +328,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio queryOptions.Strategy = r.defaultDomainStrategy } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) - return response, transport, err + return response, transport, queryOptions, err case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: @@ -339,12 +339,12 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio Response: true, }, Question: []mDNS.Question{message.Question[0]}, - }, nil, nil + }, nil, effectiveOptions, nil case C.RuleActionRejectMethodDrop: - return nil, nil, tun.ErrDrop + return nil, nil, effectiveOptions, tun.ErrDrop } case *R.RuleActionPredefined: - return action.Response(message), nil, nil + return action.Response(message), nil, effectiveOptions, nil } } queryOptions := effectiveOptions @@ -354,55 +354,83 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio queryOptions.Strategy = r.defaultDomainStrategy } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) - return response, transport, err + return response, transport, queryOptions, err } -func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { - var strategy C.DomainStrategy +type lookupWithRulesResponse struct { + addresses []netip.Addr + strategy C.DomainStrategy +} + +func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions, strategies ...C.DomainStrategy) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { - strategy = options.LookupStrategy - } else { - strategy = options.Strategy + return options.LookupStrategy + } + for _, strategy := range strategies { + if strategy != C.DomainStrategyAsIS { + return strategy + } + } + if options.Strategy != C.DomainStrategyAsIS { + return options.Strategy + } + return r.defaultDomainStrategy +} + +func lookupStrategyAllowsQueryType(strategy C.DomainStrategy, qType uint16) bool { + switch strategy { + case C.DomainStrategyIPv4Only: + return qType == mDNS.TypeA + case C.DomainStrategyIPv6Only: + return qType == mDNS.TypeAAAA + default: + return true } +} + +func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { lookupOptions := options if options.LookupStrategy != C.DomainStrategyAsIS { - lookupOptions.Strategy = strategy + lookupOptions.Strategy = options.LookupStrategy } - if strategy == C.DomainStrategyIPv4Only { - return r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + if options.LookupStrategy == C.DomainStrategyIPv4Only { + response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + return response.addresses, err } - if strategy == C.DomainStrategyIPv6Only { - return r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + if options.LookupStrategy == C.DomainStrategyIPv6Only { + response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + return response.addresses, err } var ( - response4 []netip.Addr - response6 []netip.Addr + response4 lookupWithRulesResponse + response6 lookupWithRulesResponse ) var group task.Group group.Append("exchange4", func(ctx context.Context) error { - response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) - if err != nil { - return err - } - response4 = response - return nil + result, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + response4 = result + return err }) group.Append("exchange6", func(ctx context.Context) error { - response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) - if err != nil { - return err - } - response6 = response - return nil + result, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + response6 = result + return err }) err := group.Run(ctx) - if len(response4) == 0 && len(response6) == 0 { + strategy := r.resolveLookupStrategy(options, response4.strategy, response6.strategy) + if !lookupStrategyAllowsQueryType(strategy, mDNS.TypeA) { + response4.addresses = nil + } + if !lookupStrategyAllowsQueryType(strategy, mDNS.TypeAAAA) { + response6.addresses = nil + } + if len(response4.addresses) == 0 && len(response6.addresses) == 0 { return nil, err } - return sortAddresses(response4, response6, strategy), nil + return sortAddresses(response4.addresses, response6.addresses, strategy), nil } -func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType uint16, options adapter.DNSQueryOptions) ([]netip.Addr, error) { +func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { request := &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ RecursionDesired: true, @@ -413,14 +441,21 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u Qclass: mDNS.ClassINET, }}, } - response, _, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + response, _, queryOptions, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + result := lookupWithRulesResponse{ + strategy: r.resolveLookupStrategy(options, queryOptions.Strategy), + } if err != nil { - return nil, err + return result, err } if response.Rcode != mDNS.RcodeSuccess { - return nil, RcodeError(response.Rcode) + return result, RcodeError(response.Rcode) + } + if !lookupStrategyAllowsQueryType(result.strategy, qType) { + return result, nil } - return MessageToAddresses(response), nil + result.addresses = MessageToAddresses(response) + return result, nil } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { @@ -461,7 +496,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } response, err = r.client.Exchange(ctx, transport, message, options, nil) } else if !r.legacyAddressFilterMode { - response, transport, err = r.exchangeWithRules(ctx, message, options, true) + response, transport, _, err = r.exchangeWithRules(ctx, message, options, true) } else { var ( rule adapter.DNSRule diff --git a/dns/router_test.go b/dns/router_test.go index d7deb848f5..f4bbd9a39c 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -305,6 +305,134 @@ func TestLookupNewModeDoesNotUseQueryTypeRule(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("3.3.3.3")}, addresses) } +func TestLookupNewModeAppliesRouteStrategyAfterEvaluate(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: "default"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "selected", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + if transport.Tag() == "default" { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case mDNS.TypeAAAA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + default: + return nil, errors.New("unexpected qtype") + } + }, + }) + + 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 TestExchangeNewModeLogicalMatchResponseIPCIDRFallsThrough(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, errors.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 TestOldModeReportsLegacyAddressFilterDeprecation(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 1bb42cbaf9..4d6636dc59 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -350,17 +350,19 @@ func (r *DefaultDNSRule) WithAddressLimit() bool { } func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { + return !r.matchStatesForMatch(metadata).isEmpty() +} + +func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { if r.matchResponse { if metadata.DNSResponse == nil { - return false + return 0 } - return r.abstractDefaultRule.Match(metadata) + return r.abstractDefaultRule.matchStates(metadata) } - metadata.IgnoreDestinationIPCIDRMatch = true - defer func() { - metadata.IgnoreDestinationIPCIDRMatch = false - }() - return !r.matchStates(metadata).isEmpty() + matchMetadata := *metadata + matchMetadata.IgnoreDestinationIPCIDRMatch = true + return r.abstractDefaultRule.matchStates(&matchMetadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { @@ -377,6 +379,52 @@ func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatch return r.abstractLogicalRule.matchStates(metadata) } +func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { + switch rule := rule.(type) { + case *DefaultDNSRule: + return rule.matchStatesForMatch(metadata) + case *LogicalDNSRule: + return rule.matchStatesForMatch(metadata) + default: + return matchHeadlessRuleStates(rule, 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, legacyAddressFilter bool) (*LogicalDNSRule, error) { r := &LogicalDNSRule{ abstractLogicalRule: abstractLogicalRule{ @@ -424,11 +472,7 @@ func (r *LogicalDNSRule) WithAddressLimit() bool { } func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { - metadata.IgnoreDestinationIPCIDRMatch = true - defer func() { - metadata.IgnoreDestinationIPCIDRMatch = false - }() - return !r.matchStates(metadata).isEmpty() + return !r.matchStatesForMatch(metadata).isEmpty() } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { From 2380ae8506481f4eb8ee8c60ec37b95c993256b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 18:21:42 +0800 Subject: [PATCH 04/67] Fix DNS record parsing and matching regressions --- dns/_repro_test.go | 121 ++++++++++++++++++++++++++++++++++++++ dns/router_test.go | 59 +++++++++++++++++++ option/dns_record.go | 21 ++----- option/dns_record_test.go | 38 ++++++++++++ 4 files changed, 222 insertions(+), 17 deletions(-) create mode 100644 dns/_repro_test.go create mode 100644 option/dns_record_test.go diff --git a/dns/_repro_test.go b/dns/_repro_test.go new file mode 100644 index 0000000000..467d0cb248 --- /dev/null +++ b/dns/_repro_test.go @@ -0,0 +1,121 @@ +package dns + +import ( + "context" + "errors" + "net/netip" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestReproLookupWithRulesIgnoresRouteStrategy(t *testing.T) { + 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: "default"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected", Strategy: C.DomainStrategyIPv4Only}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + if transport.Tag() == "default" { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case mDNS.TypeAAAA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + default: + return nil, errors.New("unexpected qtype") + } + }, + }) + + addrs, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addrs) +} + +func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { + 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, errors.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_test.go b/dns/router_test.go index f4bbd9a39c..c0def7bf24 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -196,6 +196,65 @@ func TestExchangeNewModeEvaluateMatchResponseRoute(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } +func TestExchangeNewModeEvaluateMatchResponseRouteIgnoresTTL(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")}, 30), nil + case "selected": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil + default: + return nil, errors.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. 60 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 TestLookupNewModeAllowsPartialSuccess(t *testing.T) { t.Parallel() diff --git a/option/dns_record.go b/option/dns_record.go index c51341f1dd..b2d73fa00d 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -2,7 +2,6 @@ package option import ( "encoding/base64" - "strings" "github.com/sagernet/sing/common/buf" E "github.com/sagernet/sing/common/exceptions" @@ -52,7 +51,6 @@ func (r *DNSRCode) Build() int { type DNSRecordOptions struct { dns.RR fromBase64 bool - hasTTL bool } func (o DNSRecordOptions) MarshalJSON() ([]byte, error) { @@ -78,24 +76,17 @@ func (o *DNSRecordOptions) UnmarshalJSON(data []byte) error { if err == nil { return o.unmarshalBase64(binary) } - parser := dns.NewZoneParser(strings.NewReader(stringValue+"\n"), "", "") - record, ok := parser.Next() - if !ok { - err = parser.Err() - if err == nil { - err = E.New("empty DNS record") - } - return err - } - err = parser.Err() + record, err := dns.NewRR(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() } o.RR = record - o.hasTTL = record.Header().Ttl != 0 return nil } @@ -106,7 +97,6 @@ func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { } o.RR = record o.fromBase64 = true - o.hasTTL = true return nil } @@ -118,8 +108,5 @@ func (o DNSRecordOptions) Match(record dns.RR) bool { if o.RR == nil || record == nil { return false } - if o.hasTTL { - return o.RR.String() == record.String() - } 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..f30f6a682f --- /dev/null +++ b/option/dns_record_test.go @@ -0,0 +1,38 @@ +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 TestDNSRecordOptionsUnmarshalJSONAcceptsRelativeOwnerNames(t *testing.T) { + t.Parallel() + + for _, record := range []string{ + "example.com A 1.1.1.1", + "@ IN A 1.1.1.1", + "www IN CNAME @", + } { + value := mustRecordOptions(t, record) + require.NotNil(t, value.RR) + } +} + +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)) +} From 75ddbee71a8525f7cdb2b9049b740974e55cafd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 20:10:49 +0800 Subject: [PATCH 05/67] Fix DNS match_response response address handling --- adapter/inbound.go | 42 +++++-- dns/router.go | 2 +- dns/router_test.go | 152 +++++++++++++++++++++++++- route/rule/rule_dns.go | 4 +- route/rule/rule_item_cidr.go | 13 ++- route/rule/rule_item_ip_is_private.go | 23 ++-- route/rule/rule_set_semantics_test.go | 56 ++++++++++ 7 files changed, 266 insertions(+), 26 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index 903ab9f9b4..c8f9224eec 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -81,15 +81,16 @@ type InboundContext struct { FallbackNetworkType []C.InterfaceType FallbackDelay time.Duration - DestinationAddresses []netip.Addr - DNSResponse *dns.Msg - 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 @@ -118,6 +119,29 @@ func (c *InboundContext) ResetRuleMatchCache() { c.DidMatch = false } +func (c *InboundContext) DestinationAddressesForMatch() []netip.Addr { + if c.DestinationAddressMatchFromResponse { + return DNSResponseAddresses(c.DNSResponse) + } + return c.DestinationAddresses +} + +func DNSResponseAddresses(response *dns.Msg) []netip.Addr { + if response == nil || response.Rcode != dns.RcodeSuccess { + return nil + } + var addresses []netip.Addr + for _, rawRecord := range response.Answer { + switch record := rawRecord.(type) { + case *dns.A: + addresses = append(addresses, M.AddrFromIP(record.A)) + case *dns.AAAA: + addresses = append(addresses, M.AddrFromIP(record.AAAA)) + } + } + return addresses +} + type inboundContextKey struct{} func WithContext(ctx context.Context, inboundContext *InboundContext) context.Context { diff --git a/dns/router.go b/dns/router.go index 19232f2889..f07d897c2f 100644 --- a/dns/router.go +++ b/dns/router.go @@ -285,7 +285,6 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio for currentRuleIndex, currentRule := range r.rules { metadata.ResetRuleCache() metadata.DNSResponse = savedResponse - metadata.DestinationAddresses = MessageToAddresses(savedResponse) if !currentRule.Match(metadata) { continue } @@ -303,6 +302,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio if transport == nil { r.logger.ErrorContext(ctx, "transport not found: ", action.Server) } + savedResponse = nil continue } if queryOptions.Strategy == C.DomainStrategyAsIS { diff --git a/dns/router_test.go b/dns/router_test.go index c0def7bf24..1fbb4c9773 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -62,7 +62,8 @@ func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, str } type fakeDNSClient struct { - exchange func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) + beforeExchange func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg) + exchange func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) } type fakeDeprecatedManager struct { @@ -75,7 +76,10 @@ func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { func (c *fakeDNSClient) Start() {} -func (c *fakeDNSClient) Exchange(_ context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func([]netip.Addr) bool) (*mDNS.Msg, error) { +func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func([]netip.Addr) bool) (*mDNS.Msg, error) { + if c.beforeExchange != nil { + c.beforeExchange(ctx, transport, message) + } return c.exchange(transport, message) } @@ -255,6 +259,150 @@ func TestExchangeNewModeEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } +func TestExchangeNewModeEvaluateDoesNotLeakAddressesToNextQuery(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, errors.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 TestExchangeNewModeEvaluateRouteResolutionFailureClearsResponse(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, errors.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 TestLookupNewModeAllowsPartialSuccess(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 4d6636dc59..f5914f89e0 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -358,7 +358,9 @@ func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r if metadata.DNSResponse == nil { return 0 } - return r.abstractDefaultRule.matchStates(metadata) + matchMetadata := *metadata + matchMetadata.DestinationAddressMatchFromResponse = true + return r.abstractDefaultRule.matchStates(&matchMetadata) } matchMetadata := *metadata matchMetadata.IgnoreDestinationIPCIDRMatch = true diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go index c823dcf30a..321b6e5d26 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -76,11 +76,20 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { if r.isSource || metadata.IPCIDRMatchSource { return r.ipSet.Contains(metadata.Source.Addr) } + if metadata.DestinationAddressMatchFromResponse { + for _, address := range metadata.DestinationAddressesForMatch() { + if r.ipSet.Contains(address) { + return true + } + } + return metadata.IPCIDRAcceptEmpty + } if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) } - if len(metadata.DestinationAddresses) > 0 { - for _, address := range metadata.DestinationAddresses { + addresses := metadata.DestinationAddressesForMatch() + if len(addresses) > 0 { + for _, address := range addresses { if r.ipSet.Contains(address) { return true } diff --git a/route/rule/rule_item_ip_is_private.go b/route/rule/rule_item_ip_is_private.go index e185db1db4..b71255bae8 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.DestinationAddressesForMatch() { if !N.IsPublicAddr(destinationAddress) { return true } } + return false + } + if metadata.Destination.Addr.IsValid() { + return !N.IsPublicAddr(metadata.Destination.Addr) + } + for _, destinationAddress := range metadata.DestinationAddressesForMatch() { + if !N.IsPublicAddr(destinationAddress) { + return true + } } return false } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index a01defe6e6..17a7da9d56 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" ) @@ -616,6 +618,27 @@ func TestDNSRuleSetSemantics(t *testing.T) { }) } +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 TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() testCases := []struct { @@ -763,6 +786,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) From 23efd0c961d48fcb269ab1b4a7951b13aaae2396 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 20:58:28 +0800 Subject: [PATCH 06/67] dns: use response-only address matching --- adapter/dns.go | 4 +- adapter/inbound.go | 14 ++- adapter/rule.go | 4 +- dns/client.go | 33 ++----- dns/router.go | 14 ++- dns/router_test.go | 118 +++++++++++++++++++++++++- route/rule/rule_dns.go | 16 +++- route/rule/rule_item_cidr.go | 4 +- route/rule/rule_item_ip_accept_any.go | 3 + route/rule/rule_item_ip_is_private.go | 4 +- route/rule/rule_set_semantics_test.go | 66 ++++++++++++-- 11 files changed, 229 insertions(+), 51 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index 8f065e2e82..017feb5960 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() } diff --git a/adapter/inbound.go b/adapter/inbound.go index c8f9224eec..048699f6d6 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -4,11 +4,13 @@ import ( "context" "net" "net/netip" + "strings" "time" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" "github.com/miekg/dns" @@ -126,17 +128,27 @@ func (c *InboundContext) DestinationAddressesForMatch() []netip.Addr { return c.DestinationAddresses } +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 } - var addresses []netip.Addr + addresses := make([]netip.Addr, 0, len(response.Answer)) for _, rawRecord := range response.Answer { switch record := rawRecord.(type) { case *dns.A: addresses = append(addresses, M.AddrFromIP(record.A)) case *dns.AAAA: addresses = append(addresses, M.AddrFromIP(record.AAAA)) + case *dns.HTTPS: + for _, value := range record.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 diff --git a/adapter/rule.go b/adapter/rule.go index f8ee797d44..31ed9b4249 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 { @@ -19,7 +21,7 @@ type Rule interface { type DNSRule interface { Rule WithAddressLimit() bool - MatchAddressLimit(metadata *InboundContext) bool + MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool } type RuleAction interface { 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/router.go b/dns/router.go index f07d897c2f..96abc1238f 100644 --- a/dns/router.go +++ b/dns/router.go @@ -145,6 +145,7 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, continue } metadata.ResetRuleCache() + metadata.DestinationAddressMatchFromResponse = false if currentRule.Match(metadata) { displayRuleIndex := currentRuleIndex if displayRuleIndex != -1 { @@ -285,6 +286,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio for currentRuleIndex, currentRule := range r.rules { metadata.ResetRuleCache() metadata.DNSResponse = savedResponse + metadata.DestinationAddressMatchFromResponse = false if !currentRule.Match(metadata) { continue } @@ -481,6 +483,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 @@ -596,6 +600,8 @@ 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 r.applyTransportDefaults(transport, &options) @@ -666,15 +672,15 @@ 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) + checkMetadata.DNSResponse = response + return rule.MatchAddressLimit(&checkMetadata, response) } } diff --git a/dns/router_test.go b/dns/router_test.go index 1fbb4c9773..b83114bfd9 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -3,6 +3,7 @@ package dns import ( "context" "errors" + "net" "net/netip" "testing" @@ -76,14 +77,14 @@ func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { func (c *fakeDNSClient) Start() {} -func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, _ adapter.DNSQueryOptions, _ func([]netip.Addr) bool) (*mDNS.Msg, error) { +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) } return c.exchange(transport, message) } -func (c *fakeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func([]netip.Addr) bool) ([]netip.Addr, error) { +func (c *fakeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func(*mDNS.Msg) bool) ([]netip.Addr, error) { return nil, errors.New("unused client lookup") } @@ -121,6 +122,49 @@ func mustRecord(t *testing.T, record string) option.DNSRecordOptions { return value } +func fixedHTTPSHintResponse(question mDNS.Question, addresses ...netip.Addr) *mDNS.Msg { + response := &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{question}, + Answer: []mDNS.RR{ + &mDNS.HTTPS{ + SVCB: mDNS.SVCB{ + Hdr: mDNS.RR_Header{ + Name: question.Name, + Rrtype: mDNS.TypeHTTPS, + Class: mDNS.ClassINET, + Ttl: 60, + }, + Priority: 1, + Target: ".", + }, + }, + }, + } + https := response.Answer[0].(*mDNS.HTTPS) + var ( + hints4 []net.IP + hints6 []net.IP + ) + for _, address := range addresses { + if address.Is4() { + hints4 = append(hints4, net.IP(append([]byte(nil), address.AsSlice()...))) + } else { + hints6 = append(hints6, net.IP(append([]byte(nil), address.AsSlice()...))) + } + } + if len(hints4) > 0 { + https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv4Hint{Hint: hints4}) + } + if len(hints6) > 0 { + https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv6Hint{Hint: hints6}) + } + return response +} + func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { t.Parallel() @@ -141,6 +185,17 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } +func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T) { + t.Parallel() + + response := fixedHTTPSHintResponse(fixedQuestion("example.com", mDNS.TypeHTTPS), + netip.MustParseAddr("1.1.1.1"), + netip.MustParseAddr("2001:db8::1"), + ) + + require.Equal(t, MessageToAddresses(response), adapter.DNSResponseAddresses(response)) +} + func TestExchangeNewModeEvaluateMatchResponseRoute(t *testing.T) { t.Parallel() @@ -259,6 +314,65 @@ func TestExchangeNewModeEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } +func TestExchangeNewModeEvaluateMatchResponseRouteWithHTTPSHints(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 fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("1.1.1.1")), nil + case "selected": + return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil + default: + return nil, errors.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, + 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.TypeHTTPS)}, + }, adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) +} + func TestExchangeNewModeEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index f5914f89e0..545d88f603 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -11,6 +11,8 @@ import ( "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, legacyAddressFilter bool) (adapter.DNSRule, error) { @@ -367,8 +369,11 @@ func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r return r.abstractDefaultRule.matchStates(&matchMetadata) } -func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext) bool { - return !r.matchStates(metadata).isEmpty() +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) @@ -477,6 +482,9 @@ func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { return !r.matchStatesForMatch(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 321b6e5d26..61612f88f2 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -77,7 +77,7 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { return r.ipSet.Contains(metadata.Source.Addr) } if metadata.DestinationAddressMatchFromResponse { - for _, address := range metadata.DestinationAddressesForMatch() { + for _, address := range metadata.DNSResponseAddressesForMatch() { if r.ipSet.Contains(address) { return true } @@ -87,7 +87,7 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) } - addresses := metadata.DestinationAddressesForMatch() + addresses := metadata.DestinationAddresses if len(addresses) > 0 { for _, address := range addresses { if r.ipSet.Contains(address) { 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 b71255bae8..c968877395 100644 --- a/route/rule/rule_item_ip_is_private.go +++ b/route/rule/rule_item_ip_is_private.go @@ -20,7 +20,7 @@ func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { return !N.IsPublicAddr(metadata.Source.Addr) } if metadata.DestinationAddressMatchFromResponse { - for _, destinationAddress := range metadata.DestinationAddressesForMatch() { + for _, destinationAddress := range metadata.DNSResponseAddressesForMatch() { if !N.IsPublicAddr(destinationAddress) { return true } @@ -30,7 +30,7 @@ func (r *IPIsPrivateItem) Match(metadata *adapter.InboundContext) bool { if metadata.Destination.Addr.IsValid() { return !N.IsPublicAddr(metadata.Destination.Addr) } - for _, destinationAddress := range metadata.DestinationAddressesForMatch() { + for _, destinationAddress := range metadata.DestinationAddresses { if !N.IsPublicAddr(destinationAddress) { return true } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 17a7da9d56..553c2bf3e6 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -583,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() @@ -598,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() @@ -612,7 +612,7 @@ func TestDNSRuleSetSemantics(t *testing.T) { ipCidrAcceptEmpty: true, }) }) - require.True(t, rule.MatchAddressLimit(&metadata)) + require.True(t, rule.MatchAddressLimit(&metadata, dnsResponseForTest())) require.False(t, metadata.IPCIDRMatchSource) require.False(t, metadata.IPCIDRAcceptEmpty) }) @@ -639,6 +639,62 @@ func TestDNSMatchResponseRuleSetDestinationCIDRUsesDNSResponse(t *testing.T) { require.False(t, rule.Match(&unmatchedMetadata)) } +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 TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() testCases := []struct { @@ -688,11 +744,11 @@ 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) { From 04dd0961c444247956d74d7827e752dc8ef2cd68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 21:38:12 +0800 Subject: [PATCH 07/67] Fix DNS evaluate regressions --- dns/router.go | 103 ++++++++++++-------- dns/router_test.go | 233 +++++++++++++++++++++++++++++++++++++++++++++ option/dns.go | 43 ++++++++- option/dns_test.go | 33 +++++++ 4 files changed, 373 insertions(+), 39 deletions(-) create mode 100644 option/dns_test.go diff --git a/dns/router.go b/dns/router.go index 96abc1238f..bc16fad0fe 100644 --- a/dns/router.go +++ b/dns/router.go @@ -232,9 +232,11 @@ func (r *Router) applyTransportDefaults(transport adapter.DNSTransport, options } } -func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) { +func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) bool { + var strategyOverridden bool if routeOptions.Strategy != C.DomainStrategyAsIS { options.Strategy = routeOptions.Strategy + strategyOverridden = true } if routeOptions.DisableCache { options.DisableCache = true @@ -245,23 +247,32 @@ func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOpt if routeOptions.ClientSubnet.IsValid() { options.ClientSubnet = routeOptions.ClientSubnet } + return strategyOverridden } -func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, bool) { +type dnsRouteStatus uint8 + +const ( + dnsRouteStatusMissing dnsRouteStatus = iota + dnsRouteStatusSkipped + dnsRouteStatusResolved +) + +func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus, bool) { transport, loaded := r.transport.Transport(action.Server) if !loaded { - return nil, false + return nil, dnsRouteStatusMissing, false } isFakeIP := transport.Type() == C.DNSTypeFakeIP if isFakeIP && !allowFakeIP { - return transport, false + return transport, dnsRouteStatusSkipped, false } - r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) + strategyOverridden := r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) if isFakeIP { options.DisableCache = true } r.applyTransportDefaults(transport, options) - return transport, true + return transport, dnsRouteStatusResolved, strategyOverridden } func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { @@ -276,12 +287,13 @@ func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule ad } } -func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, adapter.DNSQueryOptions, error) { +func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, adapter.DNSQueryOptions, bool, error) { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") } effectiveOptions := options + effectiveStrategyOverridden := false var savedResponse *mDNS.Msg for currentRuleIndex, currentRule := range r.rules { metadata.ResetRuleCache() @@ -293,24 +305,26 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio r.logRuleMatch(ctx, currentRuleIndex, currentRule) switch action := currentRule.Action().(type) { case *R.RuleActionDNSRouteOptions: - r.applyDNSRouteOptions(&effectiveOptions, *action) + effectiveStrategyOverridden = r.applyDNSRouteOptions(&effectiveOptions, *action) || effectiveStrategyOverridden case *R.RuleActionEvaluate: queryOptions := effectiveOptions - transport, loaded := r.resolveDNSRoute(&R.RuleActionDNSRoute{ + transport, status, _ := r.resolveDNSRoute(&R.RuleActionDNSRoute{ Server: action.Server, RuleActionDNSRouteOptions: action.RuleActionDNSRouteOptions, }, allowFakeIP, &queryOptions) - if !loaded { - if transport == nil { - r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - } + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) savedResponse = nil continue + case dnsRouteStatusSkipped: + continue } - if queryOptions.Strategy == C.DomainStrategyAsIS { - queryOptions.Strategy = r.defaultDomainStrategy + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) + 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()))) savedResponse = nil @@ -319,18 +333,20 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio savedResponse = response case *R.RuleActionDNSRoute: queryOptions := effectiveOptions - transport, loaded := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) - if !loaded { - if transport == nil { - r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - } + transport, status, strategyOverridden := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) + switch status { + case dnsRouteStatusMissing: + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + continue + case dnsRouteStatusSkipped: continue } - if queryOptions.Strategy == C.DomainStrategyAsIS { - queryOptions.Strategy = r.defaultDomainStrategy + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) - return response, transport, queryOptions, err + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return response, transport, queryOptions, effectiveStrategyOverridden || strategyOverridden, err case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: @@ -341,27 +357,29 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio Response: true, }, Question: []mDNS.Question{message.Question[0]}, - }, nil, effectiveOptions, nil + }, nil, effectiveOptions, effectiveStrategyOverridden, nil case C.RuleActionRejectMethodDrop: - return nil, nil, effectiveOptions, tun.ErrDrop + return nil, nil, effectiveOptions, effectiveStrategyOverridden, tun.ErrDrop } case *R.RuleActionPredefined: - return action.Response(message), nil, effectiveOptions, nil + return action.Response(message), nil, effectiveOptions, effectiveStrategyOverridden, nil } } queryOptions := effectiveOptions transport := r.transport.Default() r.applyTransportDefaults(transport, &queryOptions) - if queryOptions.Strategy == C.DomainStrategyAsIS { - queryOptions.Strategy = r.defaultDomainStrategy + exchangeOptions := queryOptions + if exchangeOptions.Strategy == C.DomainStrategyAsIS { + exchangeOptions.Strategy = r.defaultDomainStrategy } - response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, queryOptions, nil) - return response, transport, queryOptions, err + response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) + return response, transport, queryOptions, effectiveStrategyOverridden, err } type lookupWithRulesResponse struct { - addresses []netip.Addr - strategy C.DomainStrategy + addresses []netip.Addr + strategy C.DomainStrategy + explicitStrategy C.DomainStrategy } func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions, strategies ...C.DomainStrategy) C.DomainStrategy { @@ -390,6 +408,13 @@ func lookupStrategyAllowsQueryType(strategy C.DomainStrategy, qType uint16) bool } } +func lookupStrategyOverride(queryOptions adapter.DNSQueryOptions, strategyOverridden bool) C.DomainStrategy { + if !strategyOverridden { + return C.DomainStrategyAsIS + } + return queryOptions.Strategy +} + func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { lookupOptions := options if options.LookupStrategy != C.DomainStrategyAsIS { @@ -419,7 +444,7 @@ func (r *Router) lookupWithRules(ctx context.Context, domain string, options ada return err }) err := group.Run(ctx) - strategy := r.resolveLookupStrategy(options, response4.strategy, response6.strategy) + strategy := r.resolveLookupStrategy(options, response4.explicitStrategy, response6.explicitStrategy) if !lookupStrategyAllowsQueryType(strategy, mDNS.TypeA) { response4.addresses = nil } @@ -443,9 +468,11 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u Qclass: mDNS.ClassINET, }}, } - response, _, queryOptions, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + response, _, queryOptions, strategyOverridden, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + explicitStrategy := lookupStrategyOverride(queryOptions, strategyOverridden) result := lookupWithRulesResponse{ - strategy: r.resolveLookupStrategy(options, queryOptions.Strategy), + strategy: r.resolveLookupStrategy(options, explicitStrategy), + explicitStrategy: explicitStrategy, } if err != nil { return result, err @@ -500,7 +527,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } response, err = r.client.Exchange(ctx, transport, message, options, nil) } else if !r.legacyAddressFilterMode { - response, transport, _, err = r.exchangeWithRules(ctx, message, options, true) + response, transport, _, _, err = r.exchangeWithRules(ctx, message, options, true) } else { var ( rule adapter.DNSRule diff --git a/dns/router_test.go b/dns/router_test.go index b83114bfd9..f5e06cba03 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -583,6 +583,86 @@ func TestLookupNewModeSkipsFakeIPRule(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } +func TestLookupNewModeEvaluateSkipFakeIPPreservesResponse(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.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, + }, + }, + }, + { + 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"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + case "selected": + 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 + case "default": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + router.legacyAddressFilterMode = 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 TestLookupNewModeDoesNotUseQueryTypeRule(t *testing.T) { t.Parallel() @@ -685,6 +765,159 @@ func TestLookupNewModeAppliesRouteStrategyAfterEvaluate(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } +func TestLookupNewModePrefersExplicitBranchStrategyOverDefault(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{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "selected", + Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + case "selected": + 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::2")}, 60), nil + case "default": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::4")}, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + router.defaultDomainStrategy = C.DomainStrategyIPv4Only + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2001:db8::2")}, addresses) +} + +func TestLookupNewModeKeepsExplicitBranchStrategyMatchingInput(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{ + 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: "selected4", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "selected6", + Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected4": &fakeDNSTransport{tag: "selected4", transportType: C.DNSTypeUDP}, + "selected6": &fakeDNSTransport{tag: "selected6", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + case "selected4": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case "selected6": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + router.legacyAddressFilterMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + Strategy: C.DomainStrategyIPv4Only, + }) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) +} + func TestExchangeNewModeLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { t.Parallel() diff --git a/option/dns.go b/option/dns.go index b5ccf20804..27e018837b 100644 --- a/option/dns.go +++ b/option/dns.go @@ -65,9 +65,21 @@ func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) e } if !dontUpgrade { rcodeMap := make(map[string]int) + for _, server := range o.Servers { + if server.Type == C.DNSTypeLegacyRcode { + rcodeMap[server.Tag] = server.Options.(int) + } + } + if len(rcodeMap) > 0 { + for i := 0; i < len(o.Rules); i++ { + err = rejectEvaluateLegacyRcode(rcodeMap, o.Rules[i]) + if err != nil { + return E.Cause(err, "dns rule[", i, "]") + } + } + } 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 @@ -81,6 +93,35 @@ func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) e return nil } +func rejectEvaluateLegacyRcode(rcodeMap map[string]int, rule DNSRule) error { + switch rule.Type { + case C.RuleTypeDefault: + return rejectEvaluateLegacyRcodeAction(rcodeMap, &rule.DefaultOptions.DNSRuleAction) + case C.RuleTypeLogical: + err := rejectEvaluateLegacyRcodeAction(rcodeMap, &rule.LogicalOptions.DNSRuleAction) + if err != nil { + return err + } + for i, subRule := range rule.LogicalOptions.Rules { + err = rejectEvaluateLegacyRcode(rcodeMap, subRule) + if err != nil { + return E.Cause(err, "sub rule[", i, "]") + } + } + } + return nil +} + +func rejectEvaluateLegacyRcodeAction(rcodeMap map[string]int, ruleAction *DNSRuleAction) error { + if ruleAction.Action != C.RuleActionTypeEvaluate { + return nil + } + if _, loaded := rcodeMap[ruleAction.RouteOptions.Server]; loaded { + return E.New("evaluate action cannot reference legacy rcode server: ", ruleAction.RouteOptions.Server) + } + return nil +} + func rewriteRcode(rcodeMap map[string]int, rule *DNSRule) { switch rule.Type { case C.RuleTypeDefault: diff --git a/option/dns_test.go b/option/dns_test.go new file mode 100644 index 0000000000..30df917358 --- /dev/null +++ b/option/dns_test.go @@ -0,0 +1,33 @@ +package option + +import ( + "context" + "testing" + + "github.com/sagernet/sing/common/json" + "github.com/sagernet/sing/service" + "github.com/stretchr/testify/require" +) + +type stubDNSTransportOptionsRegistry struct{} + +func (stubDNSTransportOptionsRegistry) CreateOptions(string) (any, bool) { + return nil, false +} + +func TestDNSOptionsRejectsEvaluateLegacyRcodeServer(t *testing.T) { + t.Parallel() + + ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) + var options DNSOptions + err := json.UnmarshalContext(ctx, []byte(`{ + "servers": [ + {"tag": "legacy-rcode", "address": "rcode://success"}, + {"tag": "default", "address": "1.1.1.1"} + ], + "rules": [ + {"domain": ["example.com"], "action": "evaluate", "server": "legacy-rcode"} + ] + }`), &options) + require.ErrorContains(t, err, "evaluate action cannot reference legacy rcode server: legacy-rcode") +} From 58334cfd3ce8f1a6e1aa8dec5d4ba9f5c03c1f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 24 Mar 2026 22:26:20 +0800 Subject: [PATCH 08/67] Fix DNS pre-match CIDR fail-closed semantics --- adapter/inbound.go | 11 +- dns/_repro_test.go | 121 ---------------------- dns/repro_test.go | 142 ++++++++++++++++++++++++++ route/rule/rule_abstract.go | 8 +- route/rule/rule_dns.go | 1 - route/rule/rule_set_semantics_test.go | 49 ++++++++- 6 files changed, 194 insertions(+), 138 deletions(-) delete mode 100644 dns/_repro_test.go create mode 100644 dns/repro_test.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 048699f6d6..5bc1474368 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -99,12 +99,11 @@ type InboundContext struct { IPCIDRMatchSource bool IPCIDRAcceptEmpty bool - SourceAddressMatch bool - SourcePortMatch bool - DestinationAddressMatch bool - DestinationPortMatch bool - DidMatch bool - IgnoreDestinationIPCIDRMatch bool + SourceAddressMatch bool + SourcePortMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool + DidMatch bool } func (c *InboundContext) ResetRuleCache() { diff --git a/dns/_repro_test.go b/dns/_repro_test.go deleted file mode 100644 index 467d0cb248..0000000000 --- a/dns/_repro_test.go +++ /dev/null @@ -1,121 +0,0 @@ -package dns - -import ( - "context" - "errors" - "net/netip" - "testing" - - "github.com/sagernet/sing-box/adapter" - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common/json/badoption" - mDNS "github.com/miekg/dns" - "github.com/stretchr/testify/require" -) - -func TestReproLookupWithRulesIgnoresRouteStrategy(t *testing.T) { - 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: "default"}, - }, - }, - }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "selected", Strategy: C.DomainStrategyIPv4Only}, - }, - }, - }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - if transport.Tag() == "default" { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - switch message.Question[0].Qtype { - case mDNS.TypeA: - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil - case mDNS.TypeAAAA: - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - default: - return nil, errors.New("unexpected qtype") - } - }, - }) - - addrs, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addrs) -} - -func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { - 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, errors.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/repro_test.go b/dns/repro_test.go new file mode 100644 index 0000000000..2569a58d76 --- /dev/null +++ b/dns/repro_test.go @@ -0,0 +1,142 @@ +package dns + +import ( + "context" + "errors" + "net/netip" + "testing" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common/json/badoption" + + mDNS "github.com/miekg/dns" + "github.com/stretchr/testify/require" +) + +func TestReproLookupWithRulesIgnoresRouteStrategy(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: "default"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "selected", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + if transport.Tag() == "default" { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + switch message.Question[0].Qtype { + case mDNS.TypeA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case mDNS.TypeAAAA: + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + default: + return nil, errors.New("unexpected qtype") + } + }, + }) + + addrs, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addrs) +} + +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, errors.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/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 8a95fa6d2a..8ec57aac32 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -56,11 +56,11 @@ func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool { } func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { @@ -156,10 +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) - } return 0 } return stateSet diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 545d88f603..f535844a7d 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -365,7 +365,6 @@ func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r return r.abstractDefaultRule.matchStates(&matchMetadata) } matchMetadata := *metadata - matchMetadata.IgnoreDestinationIPCIDRMatch = true return r.abstractDefaultRule.matchStates(&matchMetadata) } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 553c2bf3e6..d2a865bb3d 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -616,6 +616,47 @@ func TestDNSRuleSetSemantics(t *testing.T) { 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}}) + }) + 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}}) + }) + require.True(t, rule.Match(&metadata)) + }) } func TestDNSMatchResponseRuleSetDestinationCIDRUsesDNSResponse(t *testing.T) { @@ -751,7 +792,7 @@ func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { 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) { @@ -759,9 +800,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) { @@ -771,7 +812,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)) }) } From 8b75fbf83e42c5215161d29cd520889a50b07d81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 16:59:06 +0800 Subject: [PATCH 09/67] dns: document non-response rule_set address-filter semantics --- dns/router.go | 5 +++++ route/rule/rule_set_semantics_test.go | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/dns/router.go b/dns/router.go index bc16fad0fe..fd68d1cd86 100644 --- a/dns/router.go +++ b/dns/router.go @@ -810,6 +810,11 @@ func validateNonLegacyAddressFilterDefaultRule(rule option.DefaultDNSRule) (bool if (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { return false, E.New("ip_cidr and ip_is_private require match_response in DNS evaluate mode") } + // 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 { return false, E.New("ip_accept_any is removed in DNS evaluate mode, use ip_cidr with match_response") } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index d2a865bb3d..f599adc3b1 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -625,6 +625,9 @@ func TestDNSRuleSetSemantics(t *testing.T) { 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) { @@ -655,6 +658,9 @@ func TestDNSRuleSetSemantics(t *testing.T) { 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)) }) } From 80965071d9409d599aab6528cf55d6380f7410d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 19:09:13 +0800 Subject: [PATCH 10/67] Remove legacy DNS server formats --- adapter/dns.go | 5 - constant/dns.go | 25 +- dns/router.go | 31 -- dns/transport_adapter.go | 23 -- dns/transport_dialer.go | 99 +------ docs/configuration/dns/fakeip.md | 6 +- docs/configuration/dns/fakeip.zh.md | 6 +- docs/configuration/dns/index.md | 2 +- docs/configuration/dns/index.zh.md | 2 +- docs/configuration/dns/server/index.md | 2 +- docs/configuration/dns/server/index.zh.md | 2 +- docs/configuration/dns/server/legacy.md | 6 +- docs/configuration/dns/server/legacy.zh.md | 6 +- docs/deprecated.md | 2 +- docs/deprecated.zh.md | 2 +- experimental/deprecated/constants.go | 20 -- option/dns.go | 313 +-------------------- option/dns_test.go | 56 +++- 18 files changed, 99 insertions(+), 509 deletions(-) diff --git a/adapter/dns.go b/adapter/dns.go index 017feb5960..67b012d9f2 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -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/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/dns/router.go b/dns/router.go index fd68d1cd86..5453812c82 100644 --- a/dns/router.go +++ b/dns/router.go @@ -180,14 +180,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 { @@ -210,28 +202,9 @@ 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() - } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() - } - } return transport, nil, -1 } -func (r *Router) applyTransportDefaults(transport adapter.DNSTransport, options *adapter.DNSQueryOptions) { - if legacyTransport, isLegacy := transport.(adapter.LegacyDNSTransport); isLegacy { - if options.Strategy == C.DomainStrategyAsIS { - options.Strategy = legacyTransport.LegacyStrategy() - } - if !options.ClientSubnet.IsValid() { - options.ClientSubnet = legacyTransport.LegacyClientSubnet() - } - } -} - func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) bool { var strategyOverridden bool if routeOptions.Strategy != C.DomainStrategyAsIS { @@ -271,7 +244,6 @@ func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, if isFakeIP { options.DisableCache = true } - r.applyTransportDefaults(transport, options) return transport, dnsRouteStatusResolved, strategyOverridden } @@ -367,7 +339,6 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio } queryOptions := effectiveOptions transport := r.transport.Default() - r.applyTransportDefaults(transport, &queryOptions) exchangeOptions := queryOptions if exchangeOptions.Strategy == C.DomainStrategyAsIS { exchangeOptions.Strategy = r.defaultDomainStrategy @@ -521,7 +492,6 @@ 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 - r.applyTransportDefaults(transport, &options) if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } @@ -631,7 +601,6 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ metadata.DestinationAddressMatchFromResponse = false if options.Transport != nil { transport := options.Transport - r.applyTransportDefaults(transport, &options) if options.Strategy == C.DomainStrategyAsIS { options.Strategy = r.defaultDomainStrategy } 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/configuration/dns/fakeip.md b/docs/configuration/dns/fakeip.md index f9204d3452..fcd35e12b2 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-servers). ### Structure 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..4b9702a598 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 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/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..82bd63e73d 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 is deprecated in sing-box 1.12.0 and removed in sing-box 1.14.0, check [Migration](/migration/#migrate-to-new-dns-servers). !!! quote "Changes in sing-box 1.9.0" 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/deprecated.md b/docs/deprecated.md index 3faf986e08..1e6d54eecc 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -21,7 +21,7 @@ Old fields will be removed in sing-box 1.16.0. DNS servers are refactored, check [Migration](../migration/#migrate-to-new-dns-servers). -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..459979cb81 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -21,7 +21,7 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, DNS 服务器已重构, 参阅 [迁移指南](/zh/migration/#迁移到新的-dns-服务器格式). -对旧格式的兼容性将在 sing-box 1.14.0 中被移除。 +旧格式已在 sing-box 1.14.0 中被移除。 #### `outbound` DNS 规则项 diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index d4d12cc727..f08491c461 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", @@ -136,8 +118,6 @@ var OptionLegacyDNSAddressFilter = Note{ } var Options = []Note{ - OptionLegacyDNSTransport, - OptionLegacyDNSFakeIPOptions, OptionOutboundDNSRuleItem, OptionMissingDomainResolver, OptionLegacyDomainStrategyOptions, diff --git a/option/dns.go b/option/dns.go index 27e018837b..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,121 +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 len(legacyOptions.FakeIP) != 0 { + return E.New(legacyDNSFakeIPRemovedMessage) } - if !dontUpgrade { - rcodeMap := make(map[string]int) - for _, server := range o.Servers { - if server.Type == C.DNSTypeLegacyRcode { - rcodeMap[server.Tag] = server.Options.(int) - } - } - if len(rcodeMap) > 0 { - for i := 0; i < len(o.Rules); i++ { - err = rejectEvaluateLegacyRcode(rcodeMap, o.Rules[i]) - if err != nil { - return E.Cause(err, "dns rule[", i, "]") - } - } - } - o.Servers = common.Filter(o.Servers, func(it DNSServerOptions) bool { - if it.Type == C.DNSTypeLegacyRcode { - return false - } - return true - }) - if len(rcodeMap) > 0 { - for i := 0; i < len(o.Rules); i++ { - rewriteRcode(rcodeMap, &o.Rules[i]) - } - } - } - return nil -} - -func rejectEvaluateLegacyRcode(rcodeMap map[string]int, rule DNSRule) error { - switch rule.Type { - case C.RuleTypeDefault: - return rejectEvaluateLegacyRcodeAction(rcodeMap, &rule.DefaultOptions.DNSRuleAction) - case C.RuleTypeLogical: - err := rejectEvaluateLegacyRcodeAction(rcodeMap, &rule.LogicalOptions.DNSRuleAction) - if err != nil { - return err - } - for i, subRule := range rule.LogicalOptions.Rules { - err = rejectEvaluateLegacyRcode(rcodeMap, subRule) - if err != nil { - return E.Cause(err, "sub rule[", i, "]") - } - } - } - return nil -} - -func rejectEvaluateLegacyRcodeAction(rcodeMap map[string]int, ruleAction *DNSRuleAction) error { - if ruleAction.Action != C.RuleActionTypeEvaluate { - return nil - } - if _, loaded := rcodeMap[ruleAction.RouteOptions.Server]; loaded { - return E.New("evaluate action cannot reference legacy rcode server: ", ruleAction.RouteOptions.Server) - } - 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 - } - ruleAction.Action = C.RuleActionTypePredefined - ruleAction.PredefinedOptions.Rcode = common.Ptr(DNSRCode(rcode)) + return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions) } type DNSClientOptions struct { @@ -152,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) } @@ -170,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) } @@ -189,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) @@ -204,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 } @@ -391,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"` @@ -408,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 { @@ -422,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_test.go b/option/dns_test.go index 30df917358..7448a9e2b0 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -4,6 +4,7 @@ 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" @@ -11,23 +12,60 @@ import ( type stubDNSTransportOptionsRegistry struct{} -func (stubDNSTransportOptionsRegistry) CreateOptions(string) (any, bool) { - return nil, false +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 TestDNSOptionsRejectsEvaluateLegacyRcodeServer(t *testing.T) { +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) + } +} + +func TestDNSOptionsAcceptsTypedServers(t *testing.T) { t.Parallel() ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) var options DNSOptions err := json.UnmarshalContext(ctx, []byte(`{ "servers": [ - {"tag": "legacy-rcode", "address": "rcode://success"}, - {"tag": "default", "address": "1.1.1.1"} - ], - "rules": [ - {"domain": ["example.com"], "action": "evaluate", "server": "legacy-rcode"} + {"type": "udp", "tag": "default", "server": "1.1.1.1"}, + {"type": "fakeip", "tag": "fake", "inet4_range": "198.18.0.0/15"} ] }`), &options) - require.ErrorContains(t, err, "evaluate action cannot reference legacy rcode server: legacy-rcode") + require.NoError(t, err) + require.Len(t, options.Servers, 2) + require.Equal(t, C.DNSTypeUDP, options.Servers[0].Type) + require.Equal(t, "1.1.1.1", options.Servers[0].Options.(*RemoteDNSServerOptions).Server) + require.Equal(t, C.DNSTypeFakeIP, options.Servers[1].Type) } From a0d9c27b4441425c861b483d6b8edb30022d252f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 20:05:50 +0800 Subject: [PATCH 11/67] dns: preserve legacy address-filter pre-match semantics Legacy DNS address-filter mode still accepts destination-side IP predicates with a deprecation warning, but the recent evaluate/ match_response refactor started evaluating those predicates during pre-response Match(). That broke rules whose transport selection must be deferred until MatchAddressLimit() can inspect the upstream reply. Restore the old defer behavior by reintroducing an internal IgnoreDestinationIPCIDRMatch flag on InboundContext and using it only for legacy pre-response DNS matching. Default and logical DNS rules now carry the legacy mode bit, set the ignore flag on metadata copies while performing pre-response Match(), and explicitly clear it again for match_response and MatchAddressLimit() so response-phase matching still checks the returned addresses. Add regression coverage for direct legacy destination-IP rules, rule_set-backed CIDR rules, logical wrappers, and the legacy Lookup router path, including fallback after a rejected response. This keeps legacy configs working without changing new-mode evaluate semantics. Tests: go test ./route/rule ./dns Tests: make --- adapter/inbound.go | 11 +-- dns/router_test.go | 117 +++++++++++++++++++++++++- route/rule/rule_abstract.go | 2 +- route/rule/rule_dns.go | 22 ++++- route/rule/rule_set_semantics_test.go | 104 +++++++++++++++++++++++ 5 files changed, 244 insertions(+), 12 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index 5bc1474368..048699f6d6 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -99,11 +99,12 @@ type InboundContext struct { IPCIDRMatchSource bool IPCIDRAcceptEmpty bool - SourceAddressMatch bool - SourcePortMatch bool - DestinationAddressMatch bool - DestinationPortMatch bool - DidMatch bool + SourceAddressMatch bool + SourcePortMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool + DidMatch bool + IgnoreDestinationIPCIDRMatch bool } func (c *InboundContext) ResetRuleCache() { diff --git a/dns/router_test.go b/dns/router_test.go index f5e06cba03..8176c96b48 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -65,6 +65,7 @@ func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, str 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) + lookup func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) } type fakeDeprecatedManager struct { @@ -84,8 +85,24 @@ func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTrans return c.exchange(transport, message) } -func (c *fakeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func(*mDNS.Msg) bool) ([]netip.Addr, error) { - return nil, errors.New("unused client lookup") +func (c *fakeDNSClient) Lookup(_ context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { + if c.lookup == nil { + return nil, errors.New("unused client lookup") + } + 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() {} @@ -185,6 +202,102 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } +func TestLookupLegacyModeDefersDirectDestinationIPMatch(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, errors.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.legacyAddressFilterMode) + + 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 TestLookupLegacyModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} + var lookups []string + 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) + lookups = append(lookups, 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, errors.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"}, lookups) +} + func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 8ec57aac32..ca508330bd 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -60,7 +60,7 @@ func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.I } func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index f535844a7d..d643755bb9 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -54,7 +54,8 @@ var _ adapter.DNSRule = (*DefaultDNSRule)(nil) type DefaultDNSRule struct { abstractDefaultRule - matchResponse bool + matchResponse bool + legacyAddressFilter bool } func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { @@ -67,7 +68,8 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, - matchResponse: options.MatchResponse, + matchResponse: options.MatchResponse, + legacyAddressFilter: legacyAddressFilter, } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) @@ -361,16 +363,21 @@ func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r return 0 } matchMetadata := *metadata + matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return r.abstractDefaultRule.matchStates(&matchMetadata) } matchMetadata := *metadata + if r.legacyAddressFilter { + matchMetadata.IgnoreDestinationIPCIDRMatch = true + } return r.abstractDefaultRule.matchStates(&matchMetadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response + matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() } @@ -379,6 +386,7 @@ var _ adapter.DNSRule = (*LogicalDNSRule)(nil) type LogicalDNSRule struct { abstractLogicalRule + legacyAddressFilter bool } func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { @@ -397,11 +405,15 @@ func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *ada } func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + matchMetadata := *metadata + if r.legacyAddressFilter { + matchMetadata.IgnoreDestinationIPCIDRMatch = true + } var stateSet ruleMatchStateSet if r.mode == C.LogicalTypeAnd { stateSet = emptyRuleMatchState() for _, rule := range r.rules { - nestedMetadata := *metadata + nestedMetadata := matchMetadata nestedMetadata.ResetRuleCache() nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) if nestedStateSet.isEmpty() { @@ -414,7 +426,7 @@ func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r } } else { for _, rule := range r.rules { - nestedMetadata := *metadata + nestedMetadata := matchMetadata nestedMetadata.ResetRuleCache() stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) } @@ -438,6 +450,7 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, + legacyAddressFilter: legacyAddressFilter, } switch options.Mode { case C.LogicalTypeAnd: @@ -484,6 +497,7 @@ func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response + matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() } diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index f599adc3b1..3a9d4ca9d9 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -742,6 +742,110 @@ func TestDNSAddressLimitIgnoresDestinationAddresses(t *testing.T) { } } +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) + }) + rule.legacyAddressFilter = true + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.Match(&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}}) + }) + rule.legacyAddressFilter = true + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.Match(&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, + }, + legacyAddressFilter: true, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.Match(&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 TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() testCases := []struct { From abd420be44fbf387ddd598eef1aa0402c1437884 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 20:41:00 +0800 Subject: [PATCH 12/67] dns: isolate legacy pre-match semantics --- adapter/rule.go | 1 + dns/router.go | 2 +- route/rule/rule_dns.go | 33 +- route/rule/rule_dns_legacy.go | 638 ++++++++++++++++++++++++++ route/rule/rule_set_semantics_test.go | 135 +++++- 5 files changed, 785 insertions(+), 24 deletions(-) create mode 100644 route/rule/rule_dns_legacy.go diff --git a/adapter/rule.go b/adapter/rule.go index 31ed9b4249..00470f60ea 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -20,6 +20,7 @@ type Rule interface { type DNSRule interface { Rule + LegacyPreMatch(metadata *InboundContext) bool WithAddressLimit() bool MatchAddressLimit(metadata *InboundContext, response *dns.Msg) bool } diff --git a/dns/router.go b/dns/router.go index 5453812c82..5fbf50b751 100644 --- a/dns/router.go +++ b/dns/router.go @@ -146,7 +146,7 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, } metadata.ResetRuleCache() metadata.DestinationAddressMatchFromResponse = false - if currentRule.Match(metadata) { + if currentRule.LegacyPreMatch(metadata) { displayRuleIndex := currentRuleIndex if displayRuleIndex != -1 { displayRuleIndex += displayRuleIndex + 1 diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index d643755bb9..cceaec8e9b 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -54,8 +54,7 @@ var _ adapter.DNSRule = (*DefaultDNSRule)(nil) type DefaultDNSRule struct { abstractDefaultRule - matchResponse bool - legacyAddressFilter bool + matchResponse bool } func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { @@ -68,8 +67,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, - matchResponse: options.MatchResponse, - legacyAddressFilter: legacyAddressFilter, + matchResponse: options.MatchResponse, } if len(options.Inbound) > 0 { item := NewInboundRule(options.Inbound) @@ -357,6 +355,13 @@ 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 !r.matchStatesForMatch(metadata).isEmpty() + } + return !r.abstractDefaultRule.legacyMatchStates(metadata).isEmpty() +} + func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { if r.matchResponse { if metadata.DNSResponse == nil { @@ -367,11 +372,7 @@ func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r matchMetadata.DestinationAddressMatchFromResponse = true return r.abstractDefaultRule.matchStates(&matchMetadata) } - matchMetadata := *metadata - if r.legacyAddressFilter { - matchMetadata.IgnoreDestinationIPCIDRMatch = true - } - return r.abstractDefaultRule.matchStates(&matchMetadata) + return r.abstractDefaultRule.matchStates(metadata) } func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { @@ -386,7 +387,6 @@ var _ adapter.DNSRule = (*LogicalDNSRule)(nil) type LogicalDNSRule struct { abstractLogicalRule - legacyAddressFilter bool } func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatchStateSet { @@ -405,15 +405,11 @@ func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *ada } func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - matchMetadata := *metadata - if r.legacyAddressFilter { - matchMetadata.IgnoreDestinationIPCIDRMatch = true - } var stateSet ruleMatchStateSet if r.mode == C.LogicalTypeAnd { stateSet = emptyRuleMatchState() for _, rule := range r.rules { - nestedMetadata := matchMetadata + nestedMetadata := *metadata nestedMetadata.ResetRuleCache() nestedStateSet := matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata) if nestedStateSet.isEmpty() { @@ -426,7 +422,7 @@ func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r } } else { for _, rule := range r.rules { - nestedMetadata := matchMetadata + nestedMetadata := *metadata nestedMetadata.ResetRuleCache() stateSet = stateSet.merge(matchDNSHeadlessRuleStatesForMatch(rule, &nestedMetadata)) } @@ -450,7 +446,6 @@ func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options op invert: options.Invert, action: NewDNSRuleAction(logger, options.DNSRuleAction), }, - legacyAddressFilter: legacyAddressFilter, } switch options.Mode { case C.LogicalTypeAnd: @@ -494,6 +489,10 @@ func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { return !r.matchStatesForMatch(metadata).isEmpty() } +func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { + return !r.abstractLogicalRule.legacyMatchStates(metadata).isEmpty() +} + func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response diff --git a/route/rule/rule_dns_legacy.go b/route/rule/rule_dns_legacy.go new file mode 100644 index 0000000000..23816d7e6a --- /dev/null +++ b/route/rule/rule_dns_legacy.go @@ -0,0 +1,638 @@ +package rule + +import ( + "net/netip" + + "github.com/sagernet/sing-box/adapter" + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing/common" + + "go4.org/netipx" +) + +type legacyResponseLiteralKind uint8 + +const ( + legacyLiteralRequireEmpty legacyResponseLiteralKind = iota + legacyLiteralRequireNonEmpty + legacyLiteralRequireSet + legacyLiteralForbidSet +) + +type legacyResponseLiteral struct { + kind legacyResponseLiteralKind + ipSet *netipx.IPSet +} + +type legacyResponseTerm []legacyResponseLiteral + +type legacyResponseFormula []legacyResponseTerm + +type legacyRuleMatchStateSet [16]legacyResponseFormula + +var ( + legacyAllIPSet = func() *netipx.IPSet { + var builder netipx.IPSetBuilder + builder.Complement() + return common.Must1(builder.IPSet()) + }() + legacyNonPublicIPSet = func() *netipx.IPSet { + var builder netipx.IPSetBuilder + for _, prefix := range []string{ + "0.0.0.0/32", + "10.0.0.0/8", + "127.0.0.0/8", + "169.254.0.0/16", + "172.16.0.0/12", + "192.168.0.0/16", + "224.0.0.0/4", + "::/128", + "::1/128", + "fc00::/7", + "fe80::/10", + "ff00::/8", + } { + builder.AddPrefix(netip.MustParsePrefix(prefix)) + } + return common.Must1(builder.IPSet()) + }() +) + +func legacyFalseFormula() legacyResponseFormula { + return nil +} + +func legacyTrueFormula() legacyResponseFormula { + return legacyResponseFormula{legacyResponseTerm{}} +} + +func legacyLiteralFormula(literal legacyResponseLiteral) legacyResponseFormula { + return legacyResponseFormula{legacyResponseTerm{literal}} +} + +func (f legacyResponseFormula) isFalse() bool { + return len(f) == 0 +} + +func (f legacyResponseFormula) isTrue() bool { + return len(f) == 1 && len(f[0]) == 0 +} + +func (f legacyResponseFormula) or(other legacyResponseFormula) legacyResponseFormula { + if f.isFalse() { + return other + } + if other.isFalse() { + return f + } + result := make(legacyResponseFormula, 0, len(f)+len(other)) + result = append(result, f...) + result = append(result, other...) + return result +} + +func (f legacyResponseFormula) and(other legacyResponseFormula) legacyResponseFormula { + if f.isFalse() || other.isFalse() { + return legacyFalseFormula() + } + if f.isTrue() { + return other + } + if other.isTrue() { + return f + } + var result legacyResponseFormula + for _, leftTerm := range f { + for _, rightTerm := range other { + combined, valid := legacyCombineResponseTerms(leftTerm, rightTerm) + if valid { + result = append(result, combined) + } + } + } + return result +} + +func (f legacyResponseFormula) not() legacyResponseFormula { + if f.isFalse() { + return legacyTrueFormula() + } + result := legacyTrueFormula() + for _, term := range f { + result = result.and(legacyNegateResponseTerm(term)) + if result.isFalse() { + return result + } + } + return result +} + +func legacyNegateResponseTerm(term legacyResponseTerm) legacyResponseFormula { + if len(term) == 0 { + return legacyFalseFormula() + } + result := make(legacyResponseFormula, 0, len(term)) + for _, literal := range term { + result = append(result, legacyResponseTerm{legacyNegateResponseLiteral(literal)}) + } + return result +} + +func legacyNegateResponseLiteral(literal legacyResponseLiteral) legacyResponseLiteral { + switch literal.kind { + case legacyLiteralRequireEmpty: + return legacyResponseLiteral{kind: legacyLiteralRequireNonEmpty} + case legacyLiteralRequireNonEmpty: + return legacyResponseLiteral{kind: legacyLiteralRequireEmpty} + case legacyLiteralRequireSet: + return legacyResponseLiteral{kind: legacyLiteralForbidSet, ipSet: literal.ipSet} + case legacyLiteralForbidSet: + return legacyResponseLiteral{kind: legacyLiteralRequireSet, ipSet: literal.ipSet} + default: + panic("unknown legacy response literal kind") + } +} + +func legacyCombineResponseTerms(left legacyResponseTerm, right legacyResponseTerm) (legacyResponseTerm, bool) { + combined := make(legacyResponseTerm, 0, len(left)+len(right)) + combined = append(combined, left...) + combined = append(combined, right...) + if !legacyResponseTermSatisfiable(combined) { + return nil, false + } + return combined, true +} + +func legacyResponseTermSatisfiable(term legacyResponseTerm) bool { + var ( + requireEmpty bool + requireNonEmpty bool + requiredSets []*netipx.IPSet + forbiddenBuild netipx.IPSetBuilder + hasForbidden bool + ) + for _, literal := range term { + switch literal.kind { + case legacyLiteralRequireEmpty: + requireEmpty = true + case legacyLiteralRequireNonEmpty: + requireNonEmpty = true + case legacyLiteralRequireSet: + requiredSets = append(requiredSets, literal.ipSet) + case legacyLiteralForbidSet: + if literal.ipSet != nil { + forbiddenBuild.AddSet(literal.ipSet) + hasForbidden = true + } + default: + panic("unknown legacy response literal kind") + } + } + if requireEmpty && (requireNonEmpty || len(requiredSets) > 0) { + return false + } + if requireEmpty { + return true + } + var forbidden *netipx.IPSet + if hasForbidden { + forbidden = common.Must1(forbiddenBuild.IPSet()) + } + for _, required := range requiredSets { + if !legacyIPSetHasAllowedIP(required, forbidden) { + return false + } + } + if requireNonEmpty && len(requiredSets) == 0 { + return legacyIPSetHasAllowedIP(legacyAllIPSet, forbidden) + } + return true +} + +func legacyIPSetHasAllowedIP(required *netipx.IPSet, forbidden *netipx.IPSet) bool { + if required == nil { + required = legacyAllIPSet + } + if forbidden == nil { + return len(required.Ranges()) > 0 + } + builder := netipx.IPSetBuilder{} + builder.AddSet(required) + builder.RemoveSet(forbidden) + remaining := common.Must1(builder.IPSet()) + return len(remaining.Ranges()) > 0 +} + +func legacySingleRuleMatchState(state ruleMatchState) legacyRuleMatchStateSet { + return legacySingleRuleMatchStateWithFormula(state, legacyTrueFormula()) +} + +func legacySingleRuleMatchStateWithFormula(state ruleMatchState, formula legacyResponseFormula) legacyRuleMatchStateSet { + var stateSet legacyRuleMatchStateSet + if !formula.isFalse() { + stateSet[state] = formula + } + return stateSet +} + +func (s legacyRuleMatchStateSet) isEmpty() bool { + for _, formula := range s { + if !formula.isFalse() { + return false + } + } + return true +} + +func (s legacyRuleMatchStateSet) merge(other legacyRuleMatchStateSet) legacyRuleMatchStateSet { + var merged legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + merged[state] = s[state].or(other[state]) + } + return merged +} + +func (s legacyRuleMatchStateSet) combine(other legacyRuleMatchStateSet) legacyRuleMatchStateSet { + if s.isEmpty() || other.isEmpty() { + return legacyRuleMatchStateSet{} + } + var combined legacyRuleMatchStateSet + for left := ruleMatchState(0); left < 16; left++ { + if s[left].isFalse() { + continue + } + for right := ruleMatchState(0); right < 16; right++ { + if other[right].isFalse() { + continue + } + combined[left|right] = combined[left|right].or(s[left].and(other[right])) + } + } + return combined +} + +func (s legacyRuleMatchStateSet) withBase(base ruleMatchState) legacyRuleMatchStateSet { + if s.isEmpty() { + return legacyRuleMatchStateSet{} + } + var withBase legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + if s[state].isFalse() { + continue + } + withBase[state|base] = withBase[state|base].or(s[state]) + } + return withBase +} + +func (s legacyRuleMatchStateSet) filter(allowed func(ruleMatchState) bool) legacyRuleMatchStateSet { + var filtered legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + if s[state].isFalse() { + continue + } + if allowed(state) { + filtered[state] = s[state] + } + } + return filtered +} + +func (s legacyRuleMatchStateSet) addBit(bit ruleMatchState) legacyRuleMatchStateSet { + var withBit legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + if s[state].isFalse() { + continue + } + withBit[state|bit] = withBit[state|bit].or(s[state]) + } + return withBit +} + +func (s legacyRuleMatchStateSet) branchOnBit(bit ruleMatchState, condition legacyResponseFormula) legacyRuleMatchStateSet { + if condition.isFalse() { + return s + } + if condition.isTrue() { + return s.addBit(bit) + } + var branched legacyRuleMatchStateSet + conditionFalse := condition.not() + for state := ruleMatchState(0); state < 16; state++ { + if s[state].isFalse() { + continue + } + if state.has(bit) { + branched[state] = branched[state].or(s[state]) + continue + } + branched[state] = branched[state].or(s[state].and(conditionFalse)) + branched[state|bit] = branched[state|bit].or(s[state].and(condition)) + } + return branched +} + +func (s legacyRuleMatchStateSet) andFormula(formula legacyResponseFormula) legacyRuleMatchStateSet { + if formula.isFalse() || s.isEmpty() { + return legacyRuleMatchStateSet{} + } + if formula.isTrue() { + return s + } + var result legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + if s[state].isFalse() { + continue + } + result[state] = s[state].and(formula) + } + return result +} + +func (s legacyRuleMatchStateSet) anyFormula() legacyResponseFormula { + var formula legacyResponseFormula + for _, stateFormula := range s { + formula = formula.or(stateFormula) + } + return formula +} + +type legacyRuleStateMatcher interface { + legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet +} + +type legacyRuleStateMatcherWithBase interface { + legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet +} + +func legacyMatchHeadlessRuleStates(rule adapter.HeadlessRule, metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return legacyMatchHeadlessRuleStatesWithBase(rule, metadata, 0) +} + +func legacyMatchHeadlessRuleStatesWithBase(rule adapter.HeadlessRule, metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + if matcher, loaded := rule.(legacyRuleStateMatcherWithBase); loaded { + return matcher.legacyMatchStatesWithBase(metadata, base) + } + if matcher, loaded := rule.(legacyRuleStateMatcher); loaded { + return matcher.legacyMatchStates(metadata).withBase(base) + } + if rule.Match(metadata) { + return legacySingleRuleMatchState(base) + } + return legacyRuleMatchStateSet{} +} + +func legacyMatchRuleItemStatesWithBase(item RuleItem, metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + if matcher, loaded := item.(legacyRuleStateMatcherWithBase); loaded { + return matcher.legacyMatchStatesWithBase(metadata, base) + } + if matcher, loaded := item.(legacyRuleStateMatcher); loaded { + return matcher.legacyMatchStates(metadata).withBase(base) + } + if item.Match(metadata) { + return legacySingleRuleMatchState(base) + } + return legacyRuleMatchStateSet{} +} + +func (r *DefaultHeadlessRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return r.abstractDefaultRule.legacyMatchStates(metadata) +} + +func (r *LogicalHeadlessRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return r.abstractLogicalRule.legacyMatchStates(metadata) +} + +func (r *RuleSetItem) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return r.legacyMatchStatesWithBase(metadata, 0) +} + +func (r *RuleSetItem) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + var stateSet legacyRuleMatchStateSet + for _, ruleSet := range r.setList { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource + nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty + stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) + } + return stateSet +} + +func (s *LocalRuleSet) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return s.legacyMatchStatesWithBase(metadata, 0) +} + +func (s *LocalRuleSet) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + var stateSet legacyRuleMatchStateSet + for _, rule := range s.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) + } + return stateSet +} + +func (s *RemoteRuleSet) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return s.legacyMatchStatesWithBase(metadata, 0) +} + +func (s *RemoteRuleSet) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + var stateSet legacyRuleMatchStateSet + for _, rule := range s.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleMatchCache() + stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) + } + return stateSet +} + +func (r *abstractDefaultRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return r.legacyMatchStatesWithBase(metadata, 0) +} + +func (r *abstractDefaultRule) legacyMatchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) legacyRuleMatchStateSet { + if len(r.allItems) == 0 { + return legacySingleRuleMatchState(inheritedBase) + } + evaluationBase := inheritedBase + if r.invert { + evaluationBase = 0 + } + stateSet := legacySingleRuleMatchState(evaluationBase) + if len(r.sourceAddressItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.sourceAddressItems, metadata) { + stateSet = stateSet.addBit(ruleMatchSourceAddress) + } + } + if r.destinationIPCIDRMatchesSource(metadata) { + metadata.DidMatch = true + stateSet = stateSet.branchOnBit(ruleMatchSourceAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) + } + if len(r.sourcePortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.sourcePortItems, metadata) { + stateSet = stateSet.addBit(ruleMatchSourcePort) + } + } + if len(r.destinationAddressItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.destinationAddressItems, metadata) { + stateSet = stateSet.addBit(ruleMatchDestinationAddress) + } + } + if r.legacyDestinationIPCIDRMatchesDestination(metadata) { + metadata.DidMatch = true + stateSet = stateSet.branchOnBit(ruleMatchDestinationAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) + } + if len(r.destinationPortItems) > 0 { + metadata.DidMatch = true + if matchAnyItem(r.destinationPortItems, metadata) { + stateSet = stateSet.addBit(ruleMatchDestinationPort) + } + } + for _, item := range r.items { + metadata.DidMatch = true + if !item.Match(metadata) { + if r.invert { + return legacySingleRuleMatchState(inheritedBase) + } + return legacyRuleMatchStateSet{} + } + } + if r.ruleSetItem != nil { + metadata.DidMatch = true + var merged legacyRuleMatchStateSet + for state := ruleMatchState(0); state < 16; state++ { + if stateSet[state].isFalse() { + continue + } + nestedStateSet := legacyMatchRuleItemStatesWithBase(r.ruleSetItem, metadata, state) + merged = merged.merge(nestedStateSet.andFormula(stateSet[state])) + } + stateSet = merged + } + stateSet = stateSet.filter(func(state ruleMatchState) bool { + if r.legacyRequiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { + return false + } + if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { + return false + } + if r.legacyRequiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { + return false + } + if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { + return false + } + return true + }) + if r.invert { + return legacySingleRuleMatchStateWithFormula(inheritedBase, stateSet.anyFormula().not()) + } + return stateSet +} + +func (r *abstractDefaultRule) legacyRequiresSourceAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) +} + +func (r *abstractDefaultRule) legacyDestinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { + return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 +} + +func (r *abstractDefaultRule) legacyRequiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { + return len(r.destinationAddressItems) > 0 || r.legacyDestinationIPCIDRMatchesDestination(metadata) +} + +func (r *abstractLogicalRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { + return r.legacyMatchStatesWithBase(metadata, 0) +} + +func (r *abstractLogicalRule) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { + evaluationBase := base + if r.invert { + evaluationBase = 0 + } + var stateSet legacyRuleMatchStateSet + if r.mode == C.LogicalTypeAnd { + stateSet = legacySingleRuleMatchState(evaluationBase) + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + stateSet = stateSet.combine(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) + if stateSet.isEmpty() && !r.invert { + return legacyRuleMatchStateSet{} + } + } + } else { + for _, rule := range r.rules { + nestedMetadata := *metadata + nestedMetadata.ResetRuleCache() + stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) + } + } + if r.invert { + return legacySingleRuleMatchStateWithFormula(base, stateSet.anyFormula().not()) + } + return stateSet +} + +func legacyDestinationIPFormula(items []RuleItem, metadata *adapter.InboundContext) legacyResponseFormula { + if legacyDestinationIPResolved(metadata) { + if matchAnyItem(items, metadata) { + return legacyTrueFormula() + } + return legacyFalseFormula() + } + var formula legacyResponseFormula + for _, rawItem := range items { + switch item := rawItem.(type) { + case *IPCIDRItem: + if item.isSource || metadata.IPCIDRMatchSource { + if item.Match(metadata) { + return legacyTrueFormula() + } + continue + } + formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ + kind: legacyLiteralRequireSet, + ipSet: item.ipSet, + })) + if metadata.IPCIDRAcceptEmpty { + formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ + kind: legacyLiteralRequireEmpty, + })) + } + case *IPIsPrivateItem: + if item.isSource { + if item.Match(metadata) { + return legacyTrueFormula() + } + continue + } + formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ + kind: legacyLiteralRequireSet, + ipSet: legacyNonPublicIPSet, + })) + case *IPAcceptAnyItem: + formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ + kind: legacyLiteralRequireNonEmpty, + })) + default: + if rawItem.Match(metadata) { + return legacyTrueFormula() + } + } + } + return formula +} + +func legacyDestinationIPResolved(metadata *adapter.InboundContext) bool { + return metadata.IPCIDRMatchSource || + metadata.DestinationAddressMatchFromResponse || + metadata.DNSResponse != nil || + metadata.Destination.IsIP() || + len(metadata.DestinationAddresses) > 0 +} diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 3a9d4ca9d9..66e2646fc3 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -787,10 +787,9 @@ func TestDNSLegacyAddressLimitPreLookupDefersDirectRules(t *testing.T) { rule := dnsRuleForTest(func(rule *abstractDefaultRule) { testCase.build(t, rule) }) - rule.legacyAddressFilter = true preLookupMetadata := testMetadata("lookup.example") - require.True(t, rule.Match(&preLookupMetadata)) + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) matchedMetadata := testMetadata("lookup.example") require.True(t, rule.MatchAddressLimit(&matchedMetadata, testCase.matchedResponse)) @@ -810,10 +809,9 @@ func TestDNSLegacyAddressLimitPreLookupDefersRuleSetDestinationCIDR(t *testing.T rule := dnsRuleForTest(func(rule *abstractDefaultRule) { addRuleSetItem(rule, &RuleSetItem{setList: []adapter.RuleSet{ruleSet}}) }) - rule.legacyAddressFilter = true preLookupMetadata := testMetadata("lookup.example") - require.True(t, rule.Match(&preLookupMetadata)) + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) matchedMetadata := testMetadata("lookup.example") require.True(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("203.0.113.1")))) @@ -833,11 +831,10 @@ func TestDNSLegacyLogicalAddressLimitPreLookupDefersNestedRules(t *testing.T) { rules: []adapter.HeadlessRule{nestedRule}, mode: C.LogicalTypeAnd, }, - legacyAddressFilter: true, } preLookupMetadata := testMetadata("lookup.example") - require.True(t, logicalRule.Match(&preLookupMetadata)) + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) matchedMetadata := testMetadata("lookup.example") require.True(t, logicalRule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("10.0.0.1")))) @@ -846,6 +843,132 @@ func TestDNSLegacyLogicalAddressLimitPreLookupDefersNestedRules(t *testing.T) { 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("wrapper invert keeps nested deferred rule matchable", func(t *testing.T) { + t.Parallel() + + nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationIPIsPrivateItem(rule) + }) + logicalRule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: []adapter.HeadlessRule{nestedRule}, + mode: C.LogicalTypeAnd, + invert: true, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, logicalRule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("10.0.0.1")))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, logicalRule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) + }) + + 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) { t.Parallel() testCases := []struct { From 5604488924f1e5e7067dcf39d3235fc6c8bc0036 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 21:56:22 +0800 Subject: [PATCH 13/67] Fix legacy DNS negation expansion --- route/rule/rule_dns_legacy.go | 294 +++++++++++++++++--------- route/rule/rule_set_semantics_test.go | 94 ++++++++ 2 files changed, 293 insertions(+), 95 deletions(-) diff --git a/route/rule/rule_dns_legacy.go b/route/rule/rule_dns_legacy.go index 23816d7e6a..8cdad83f3f 100644 --- a/route/rule/rule_dns_legacy.go +++ b/route/rule/rule_dns_legacy.go @@ -24,9 +24,28 @@ type legacyResponseLiteral struct { ipSet *netipx.IPSet } -type legacyResponseTerm []legacyResponseLiteral +type legacyResponseFormulaKind uint8 -type legacyResponseFormula []legacyResponseTerm +const ( + legacyFormulaFalse legacyResponseFormulaKind = iota + legacyFormulaTrue + legacyFormulaLiteral + legacyFormulaAnd + legacyFormulaOr +) + +type legacyResponseFormula struct { + kind legacyResponseFormulaKind + literal legacyResponseLiteral + children []legacyResponseFormula +} + +type legacyResponseConstraint struct { + requireEmpty bool + requireNonEmpty bool + requiredSets []*netipx.IPSet + forbiddenSet *netipx.IPSet +} type legacyRuleMatchStateSet [16]legacyResponseFormula @@ -59,156 +78,241 @@ var ( ) func legacyFalseFormula() legacyResponseFormula { - return nil + return legacyResponseFormula{} } func legacyTrueFormula() legacyResponseFormula { - return legacyResponseFormula{legacyResponseTerm{}} + return legacyResponseFormula{kind: legacyFormulaTrue} } func legacyLiteralFormula(literal legacyResponseLiteral) legacyResponseFormula { - return legacyResponseFormula{legacyResponseTerm{literal}} + return legacyResponseFormula{ + kind: legacyFormulaLiteral, + literal: literal, + } } func (f legacyResponseFormula) isFalse() bool { - return len(f) == 0 + return f.kind == legacyFormulaFalse } func (f legacyResponseFormula) isTrue() bool { - return len(f) == 1 && len(f[0]) == 0 + return f.kind == legacyFormulaTrue } func (f legacyResponseFormula) or(other legacyResponseFormula) legacyResponseFormula { - if f.isFalse() { - return other - } - if other.isFalse() { - return f - } - result := make(legacyResponseFormula, 0, len(f)+len(other)) - result = append(result, f...) - result = append(result, other...) - return result + return legacyOrFormulas(f, other) } func (f legacyResponseFormula) and(other legacyResponseFormula) legacyResponseFormula { - if f.isFalse() || other.isFalse() { + return legacyAndFormulas(f, other) +} + +func (f legacyResponseFormula) not() legacyResponseFormula { + switch f.kind { + case legacyFormulaFalse: + return legacyTrueFormula() + case legacyFormulaTrue: return legacyFalseFormula() + case legacyFormulaLiteral: + return legacyLiteralFormula(legacyNegateResponseLiteral(f.literal)) + case legacyFormulaAnd: + negated := make([]legacyResponseFormula, 0, len(f.children)) + for _, child := range f.children { + negated = append(negated, child.not()) + } + return legacyOrFormulas(negated...) + case legacyFormulaOr: + negated := make([]legacyResponseFormula, 0, len(f.children)) + for _, child := range f.children { + negated = append(negated, child.not()) + } + return legacyAndFormulas(negated...) + default: + panic("unknown legacy response formula kind") } - if f.isTrue() { - return other +} + +func legacyNegateResponseLiteral(literal legacyResponseLiteral) legacyResponseLiteral { + switch literal.kind { + case legacyLiteralRequireEmpty: + return legacyResponseLiteral{kind: legacyLiteralRequireNonEmpty} + case legacyLiteralRequireNonEmpty: + return legacyResponseLiteral{kind: legacyLiteralRequireEmpty} + case legacyLiteralRequireSet: + return legacyResponseLiteral{kind: legacyLiteralForbidSet, ipSet: literal.ipSet} + case legacyLiteralForbidSet: + return legacyResponseLiteral{kind: legacyLiteralRequireSet, ipSet: literal.ipSet} + default: + panic("unknown legacy response literal kind") } - if other.isTrue() { - return f +} + +func legacyOrFormulas(formulas ...legacyResponseFormula) legacyResponseFormula { + children := make([]legacyResponseFormula, 0, len(formulas)) + for _, formula := range formulas { + if formula.isFalse() { + continue + } + if formula.isTrue() { + return legacyTrueFormula() + } + if formula.kind == legacyFormulaOr { + children = append(children, formula.children...) + continue + } + children = append(children, formula) } - var result legacyResponseFormula - for _, leftTerm := range f { - for _, rightTerm := range other { - combined, valid := legacyCombineResponseTerms(leftTerm, rightTerm) - if valid { - result = append(result, combined) - } + switch len(children) { + case 0: + return legacyFalseFormula() + case 1: + return children[0] + default: + return legacyResponseFormula{ + kind: legacyFormulaOr, + children: children, } } - return result } -func (f legacyResponseFormula) not() legacyResponseFormula { - if f.isFalse() { +func legacyAndFormulas(formulas ...legacyResponseFormula) legacyResponseFormula { + children := make([]legacyResponseFormula, 0, len(formulas)) + for _, formula := range formulas { + if formula.isFalse() { + return legacyFalseFormula() + } + if formula.isTrue() { + continue + } + if formula.kind == legacyFormulaAnd { + children = append(children, formula.children...) + continue + } + children = append(children, formula) + } + switch len(children) { + case 0: return legacyTrueFormula() + case 1: + return children[0] } - result := legacyTrueFormula() - for _, term := range f { - result = result.and(legacyNegateResponseTerm(term)) - if result.isFalse() { - return result - } + result := legacyResponseFormula{ + kind: legacyFormulaAnd, + children: children, + } + if !result.satisfiable() { + return legacyFalseFormula() } return result } -func legacyNegateResponseTerm(term legacyResponseTerm) legacyResponseFormula { - if len(term) == 0 { - return legacyFalseFormula() +func (f legacyResponseFormula) satisfiable() bool { + return legacyResponseFormulasSatisfiable(legacyResponseConstraint{}, []legacyResponseFormula{f}) +} + +func legacyResponseFormulasSatisfiable(constraint legacyResponseConstraint, formulas []legacyResponseFormula) bool { + stack := append(make([]legacyResponseFormula, 0, len(formulas)), formulas...) + var disjunctions []legacyResponseFormula + for len(stack) > 0 { + formula := stack[len(stack)-1] + stack = stack[:len(stack)-1] + switch formula.kind { + case legacyFormulaFalse: + return false + case legacyFormulaTrue: + continue + case legacyFormulaLiteral: + var ok bool + constraint, ok = constraint.withLiteral(formula.literal) + if !ok { + return false + } + case legacyFormulaAnd: + stack = append(stack, formula.children...) + case legacyFormulaOr: + if len(formula.children) == 0 { + return false + } + disjunctions = append(disjunctions, formula) + default: + panic("unknown legacy response formula kind") + } } - result := make(legacyResponseFormula, 0, len(term)) - for _, literal := range term { - result = append(result, legacyResponseTerm{legacyNegateResponseLiteral(literal)}) + if len(disjunctions) == 0 { + return true } - return result + bestIndex := 0 + for i := 1; i < len(disjunctions); i++ { + if len(disjunctions[i].children) < len(disjunctions[bestIndex].children) { + bestIndex = i + } + } + selected := disjunctions[bestIndex] + remaining := make([]legacyResponseFormula, 0, len(disjunctions)-1) + remaining = append(remaining, disjunctions[:bestIndex]...) + remaining = append(remaining, disjunctions[bestIndex+1:]...) + for _, child := range selected.children { + nextFormulas := make([]legacyResponseFormula, 0, len(remaining)+1) + nextFormulas = append(nextFormulas, remaining...) + nextFormulas = append(nextFormulas, child) + if legacyResponseFormulasSatisfiable(constraint, nextFormulas) { + return true + } + } + return false } -func legacyNegateResponseLiteral(literal legacyResponseLiteral) legacyResponseLiteral { +func (c legacyResponseConstraint) withLiteral(literal legacyResponseLiteral) (legacyResponseConstraint, bool) { switch literal.kind { case legacyLiteralRequireEmpty: - return legacyResponseLiteral{kind: legacyLiteralRequireNonEmpty} + c.requireEmpty = true case legacyLiteralRequireNonEmpty: - return legacyResponseLiteral{kind: legacyLiteralRequireEmpty} + c.requireNonEmpty = true case legacyLiteralRequireSet: - return legacyResponseLiteral{kind: legacyLiteralForbidSet, ipSet: literal.ipSet} + requiredSets := make([]*netipx.IPSet, len(c.requiredSets)+1) + copy(requiredSets, c.requiredSets) + requiredSets[len(c.requiredSets)] = literal.ipSet + c.requiredSets = requiredSets case legacyLiteralForbidSet: - return legacyResponseLiteral{kind: legacyLiteralRequireSet, ipSet: literal.ipSet} + c.forbiddenSet = legacyUnionIPSets(c.forbiddenSet, literal.ipSet) default: panic("unknown legacy response literal kind") } + return c, c.satisfiable() } -func legacyCombineResponseTerms(left legacyResponseTerm, right legacyResponseTerm) (legacyResponseTerm, bool) { - combined := make(legacyResponseTerm, 0, len(left)+len(right)) - combined = append(combined, left...) - combined = append(combined, right...) - if !legacyResponseTermSatisfiable(combined) { - return nil, false - } - return combined, true -} - -func legacyResponseTermSatisfiable(term legacyResponseTerm) bool { - var ( - requireEmpty bool - requireNonEmpty bool - requiredSets []*netipx.IPSet - forbiddenBuild netipx.IPSetBuilder - hasForbidden bool - ) - for _, literal := range term { - switch literal.kind { - case legacyLiteralRequireEmpty: - requireEmpty = true - case legacyLiteralRequireNonEmpty: - requireNonEmpty = true - case legacyLiteralRequireSet: - requiredSets = append(requiredSets, literal.ipSet) - case legacyLiteralForbidSet: - if literal.ipSet != nil { - forbiddenBuild.AddSet(literal.ipSet) - hasForbidden = true - } - default: - panic("unknown legacy response literal kind") - } - } - if requireEmpty && (requireNonEmpty || len(requiredSets) > 0) { +func (c legacyResponseConstraint) satisfiable() bool { + if c.requireEmpty && (c.requireNonEmpty || len(c.requiredSets) > 0) { return false } - if requireEmpty { + if c.requireEmpty { return true } - var forbidden *netipx.IPSet - if hasForbidden { - forbidden = common.Must1(forbiddenBuild.IPSet()) - } - for _, required := range requiredSets { - if !legacyIPSetHasAllowedIP(required, forbidden) { + for _, required := range c.requiredSets { + if !legacyIPSetHasAllowedIP(required, c.forbiddenSet) { return false } } - if requireNonEmpty && len(requiredSets) == 0 { - return legacyIPSetHasAllowedIP(legacyAllIPSet, forbidden) + if c.requireNonEmpty && len(c.requiredSets) == 0 { + return legacyIPSetHasAllowedIP(legacyAllIPSet, c.forbiddenSet) } return true } +func legacyUnionIPSets(left *netipx.IPSet, right *netipx.IPSet) *netipx.IPSet { + if left == nil { + return right + } + if right == nil { + return left + } + var builder netipx.IPSetBuilder + builder.AddSet(left) + builder.AddSet(right) + return common.Must1(builder.IPSet()) +} + func legacyIPSetHasAllowedIP(required *netipx.IPSet, forbidden *netipx.IPSet) bool { if required == nil { required = legacyAllIPSet diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 66e2646fc3..03fb64ef30 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -969,6 +969,94 @@ func TestDNSLegacyInvertRuleSetAddressLimitPreLookupRegression(t *testing.T) { require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) } +func TestDNSLegacyInvertNegationStressRegression(t *testing.T) { + t.Parallel() + + const branchCount = 20 + unmatchedResponse := dnsResponseForTest(netip.MustParseAddr("203.0.113.250")) + + t.Run("logical wrapper", func(t *testing.T) { + t.Parallel() + + branches := make([]adapter.HeadlessRule, 0, branchCount) + var matchedAddrs []netip.Addr + for i := 0; i < branchCount; i++ { + firstCIDR, secondCIDR, branchAddrs := legacyNegationBranchCIDRs(i) + if matchedAddrs == nil { + matchedAddrs = branchAddrs + } + branches = append(branches, &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + mode: C.LogicalTypeAnd, + rules: []adapter.HeadlessRule{ + dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{firstCIDR}) + }), + dnsRuleForTest(func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{secondCIDR}) + }), + }, + }, + }) + } + + rule := &LogicalDNSRule{ + abstractLogicalRule: abstractLogicalRule{ + rules: branches, + mode: C.LogicalTypeOr, + invert: true, + }, + } + + preLookupMetadata := testMetadata("lookup.example") + require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) + + matchedMetadata := testMetadata("lookup.example") + require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(matchedAddrs...))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, unmatchedResponse)) + }) + + t.Run("ruleset wrapper", func(t *testing.T) { + t.Parallel() + + branches := make([]adapter.HeadlessRule, 0, branchCount) + var matchedAddrs []netip.Addr + for i := 0; i < branchCount; i++ { + firstCIDR, secondCIDR, branchAddrs := legacyNegationBranchCIDRs(i) + if matchedAddrs == nil { + matchedAddrs = branchAddrs + } + branches = append(branches, headlessLogicalRule( + C.LogicalTypeAnd, + false, + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{firstCIDR}) + }), + headlessDefaultRule(t, func(rule *abstractDefaultRule) { + addDestinationIPCIDRItem(t, rule, []string{secondCIDR}) + }), + )) + } + + ruleSet := newLocalRuleSetForTest("dns-legacy-negation-stress", branches...) + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + 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(matchedAddrs...))) + + unmatchedMetadata := testMetadata("lookup.example") + require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, unmatchedResponse)) + }) +} + func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() testCases := []struct { @@ -1149,6 +1237,12 @@ func dnsResponseForTest(addresses ...netip.Addr) *mDNS.Msg { return response } +func legacyNegationBranchCIDRs(index int) (string, string, []netip.Addr) { + first := netip.AddrFrom4([4]byte{198, 18, 0, byte(index*2 + 1)}) + second := netip.AddrFrom4([4]byte{198, 18, 0, byte(index*2 + 2)}) + return first.String() + "/32", second.String() + "/32", []netip.Addr{first, second} +} + func addRuleSetItem(rule *abstractDefaultRule, item *RuleSetItem) { rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) From 259e67fca36997ec9cf91f9cee1abc22e43b9828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 25 Mar 2026 23:40:53 +0800 Subject: [PATCH 14/67] dns: complete lookup rule execution in new mode --- dns/router.go | 103 ++++++++++++++++++++++++++++------- dns/router_test.go | 133 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 23 deletions(-) diff --git a/dns/router.go b/dns/router.go index 5fbf50b751..3518a84601 100644 --- a/dns/router.go +++ b/dns/router.go @@ -77,7 +77,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp } func (r *Router) Initialize(rules []option.DNSRule) error { - r.legacyAddressFilterMode = !hasNonLegacyAddressFilterItems(rules) + r.legacyAddressFilterMode = hasLegacyAddressFilterItems(rules) if !r.legacyAddressFilterMode { err := validateNonLegacyAddressFilterRules(rules) if err != nil { @@ -353,6 +353,13 @@ type lookupWithRulesResponse struct { explicitStrategy C.DomainStrategy } +func lookupInputStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { + if options.LookupStrategy != C.DomainStrategyAsIS { + return options.LookupStrategy + } + return options.Strategy +} + func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions, strategies ...C.DomainStrategy) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { return options.LookupStrategy @@ -386,6 +393,66 @@ func lookupStrategyOverride(queryOptions adapter.DNSQueryOptions, strategyOverri return queryOptions.Strategy } +func isSingleFamilyLookupStrategy(strategy C.DomainStrategy) bool { + return strategy == C.DomainStrategyIPv4Only || strategy == C.DomainStrategyIPv6Only +} + +func resolveExplicitLookupStrategy(strategies ...C.DomainStrategy) (C.DomainStrategy, bool) { + var resolvedStrategy C.DomainStrategy + for _, strategy := range strategies { + if strategy == C.DomainStrategyAsIS { + continue + } + if resolvedStrategy == C.DomainStrategyAsIS { + resolvedStrategy = strategy + continue + } + if resolvedStrategy != strategy { + return C.DomainStrategyAsIS, true + } + } + return resolvedStrategy, false +} + +func (r *Router) resolveLookupOutputStrategies(options adapter.DNSQueryOptions, explicitStrategies ...C.DomainStrategy) (C.DomainStrategy, C.DomainStrategy) { + inputStrategy := lookupInputStrategy(options) + if inputStrategy != C.DomainStrategyAsIS { + return inputStrategy, inputStrategy + } + explicitStrategy, explicitConflict := resolveExplicitLookupStrategy(explicitStrategies...) + sortStrategy := r.defaultDomainStrategy + if !explicitConflict && explicitStrategy != C.DomainStrategyAsIS { + sortStrategy = explicitStrategy + } + filterStrategy := C.DomainStrategyAsIS + if explicitConflict { + return sortStrategy, filterStrategy + } + if explicitStrategy != C.DomainStrategyAsIS { + if isSingleFamilyLookupStrategy(explicitStrategy) { + filterStrategy = explicitStrategy + } + return sortStrategy, filterStrategy + } + if isSingleFamilyLookupStrategy(sortStrategy) { + filterStrategy = sortStrategy + } + return sortStrategy, filterStrategy +} + +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 (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { lookupOptions := options if options.LookupStrategy != C.DomainStrategyAsIS { @@ -415,17 +482,17 @@ func (r *Router) lookupWithRules(ctx context.Context, domain string, options ada return err }) err := group.Run(ctx) - strategy := r.resolveLookupStrategy(options, response4.explicitStrategy, response6.explicitStrategy) - if !lookupStrategyAllowsQueryType(strategy, mDNS.TypeA) { + sortStrategy, filterStrategy := r.resolveLookupOutputStrategies(options, response4.explicitStrategy, response6.explicitStrategy) + if !lookupStrategyAllowsQueryType(filterStrategy, mDNS.TypeA) { response4.addresses = nil } - if !lookupStrategyAllowsQueryType(strategy, mDNS.TypeAAAA) { + if !lookupStrategyAllowsQueryType(filterStrategy, mDNS.TypeAAAA) { response6.addresses = nil } if len(response4.addresses) == 0 && len(response6.addresses) == 0 { return nil, err } - return sortAddresses(response4.addresses, response6.addresses, strategy), nil + return sortAddresses(response4.addresses, response6.addresses, sortStrategy), nil } func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { @@ -439,7 +506,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u Qclass: mDNS.ClassINET, }}, } - response, _, queryOptions, strategyOverridden, err := r.exchangeWithRules(adapter.OverrideContext(ctx), request, options, false) + response, _, queryOptions, strategyOverridden, err := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), request, options, false) explicitStrategy := lookupStrategyOverride(queryOptions, strategyOverridden) result := lookupWithRulesResponse{ strategy: r.resolveLookupStrategy(options, explicitStrategy), @@ -702,30 +769,26 @@ func (r *Router) ResetNetwork() { } } -func hasNonLegacyAddressFilterItems(rules []option.DNSRule) bool { - return common.Any(rules, hasNonLegacyAddressFilterItemsInRule) +func hasLegacyAddressFilterItems(rules []option.DNSRule) bool { + return common.Any(rules, hasLegacyAddressFilterItemsInRule) } -func hasNonLegacyAddressFilterItemsInRule(rule option.DNSRule) bool { +func hasLegacyAddressFilterItemsInRule(rule option.DNSRule) bool { switch rule.Type { case "", C.RuleTypeDefault: - return hasNonLegacyAddressFilterItemsInDefaultRule(rule.DefaultOptions) + return hasLegacyAddressFilterItemsInDefaultRule(rule.DefaultOptions) case C.RuleTypeLogical: - action := rule.LogicalOptions.Action - return action == C.RuleActionTypeEvaluate || common.Any(rule.LogicalOptions.Rules, hasNonLegacyAddressFilterItemsInRule) + return common.Any(rule.LogicalOptions.Rules, hasLegacyAddressFilterItemsInRule) default: return false } } -func hasNonLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { - action := rule.Action - return action == C.RuleActionTypeEvaluate || - rule.MatchResponse || - rule.ResponseRcode != nil || - len(rule.ResponseAnswer) > 0 || - len(rule.ResponseNs) > 0 || - len(rule.ResponseExtra) > 0 +func hasLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { + if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { + return true + } + return !rule.MatchResponse && (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) } func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { diff --git a/dns/router_test.go b/dns/router_test.go index 8176c96b48..7012789fcd 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -776,7 +776,7 @@ func TestLookupNewModeEvaluateSkipFakeIPPreservesResponse(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } -func TestLookupNewModeDoesNotUseQueryTypeRule(t *testing.T) { +func TestLookupNewModeUsesQueryTypeRule(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -812,11 +812,57 @@ func TestLookupNewModeDoesNotUseQueryTypeRule(t *testing.T) { } }, }) - router.legacyAddressFilterMode = false + require.False(t, router.legacyAddressFilterMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("3.3.3.3")}, addresses) + require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) +} + +func TestLookupNewModeUsesIPVersionRule(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{ + IPVersion: 6, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "only-v6"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "only-v6": &fakeDNSTransport{tag: "only-v6", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "default": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + case "only-v6": + if message.Question[0].Qtype == mDNS.TypeAAAA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::9")}, 60), nil + } + return FixedResponse(0, message.Question[0], nil, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + require.False(t, router.legacyAddressFilterMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2001:db8::9")}, addresses) } func TestLookupNewModeAppliesRouteStrategyAfterEvaluate(t *testing.T) { @@ -1031,6 +1077,87 @@ func TestLookupNewModeKeepsExplicitBranchStrategyMatchingInput(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } +func TestLookupNewModeKeepsConflictingExplicitBranchStrategies(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{ + 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: "selected4", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), + }, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{ + Server: "selected6", + Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), + }, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, + "selected4": &fakeDNSTransport{tag: "selected4", transportType: C.DNSTypeUDP}, + "selected6": &fakeDNSTransport{tag: "selected6", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "upstream": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil + case "selected4": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + case "selected6": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + router.legacyAddressFilterMode = 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"), netip.MustParseAddr("2001:db8::2")}, addresses) +} + func TestExchangeNewModeLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { t.Parallel() From 40b9c64a0d790bc4e1c5e4c6e70a39b4d023e6b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 26 Mar 2026 11:30:17 +0800 Subject: [PATCH 15/67] dns: make rule path selection rule-set aware --- adapter/router.go | 7 +- box.go | 2 +- dns/router.go | 295 +++++++++++++++++++++++++++++----- dns/router_test.go | 259 ++++++++++++++++++++++++++++- route/rule/rule_dns_legacy.go | 36 +++-- route/rule/rule_set.go | 4 + route/rule/rule_set_local.go | 1 + route/rule/rule_set_remote.go | 1 + 8 files changed, 550 insertions(+), 55 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index 82e6881a60..a8f66ba675 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -67,9 +67,10 @@ type RuleSet interface { type RuleSetUpdateCallback func(it RuleSet) 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/box.go b/box.go index a765e21d8f..82403a29cd 100644 --- a/box.go +++ b/box.go @@ -486,7 +486,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/dns/router.go b/dns/router.go index 3518a84601..f5b80a1200 100644 --- a/dns/router.go +++ b/dns/router.go @@ -5,6 +5,7 @@ import ( "errors" "net/netip" "strings" + "sync" "time" "github.com/sagernet/sing-box/adapter" @@ -21,6 +22,7 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/contrab/freelru" "github.com/sagernet/sing/contrab/maphash" "github.com/sagernet/sing/service" @@ -30,17 +32,27 @@ import ( var _ adapter.DNSRouter = (*Router)(nil) +type dnsRuleSetCallback struct { + ruleSet adapter.RuleSet + element *list.Element[adapter.RuleSetUpdateCallback] +} + type Router struct { 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 legacyAddressFilterMode bool + rulesAccess sync.RWMutex + ruleSetCallbacks []dnsRuleSetCallback + runtimeRuleError error + deprecatedReported bool } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -49,6 +61,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), } @@ -77,20 +90,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp } func (r *Router) Initialize(rules []option.DNSRule) error { - r.legacyAddressFilterMode = hasLegacyAddressFilterItems(rules) - if !r.legacyAddressFilterMode { - err := validateNonLegacyAddressFilterRules(rules) - if err != nil { - return err - } - } - for i, ruleOptions := range rules { - dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, r.legacyAddressFilterMode) - if err != nil { - return E.Cause(err, "parse dns rule[", i, "]") - } - r.rules = append(r.rules, dnsRule) - } + r.rawRules = append(r.rawRules[:0], rules...) return nil } @@ -102,16 +102,17 @@ 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") + err := r.rebuildRules(true) + monitor.Finish() + if err != nil { + return err } - if r.legacyAddressFilterMode && common.Any(r.rules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) { - deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + monitor.Start("register DNS rule-set callbacks") + err = r.registerRuleSetCallbacks() + monitor.Finish() + if err != nil { + return err } } return nil @@ -119,8 +120,18 @@ func (r *Router) Start(stage adapter.StartStage) error { func (r *Router) Close() error { monitor := taskmonitor.New(r.logger, C.StopTimeout) + r.rulesAccess.Lock() + callbacks := r.ruleSetCallbacks + r.ruleSetCallbacks = nil + runtimeRules := r.rules + r.rules = nil + r.runtimeRuleError = nil + r.rulesAccess.Unlock() + for _, callback := range callbacks { + callback.ruleSet.UnregisterCallback(callback.element) + } var err error - for i, rule := range r.rules { + for i, rule := range runtimeRules { monitor.Start("close dns rule[", i, "]") err = E.Append(err, rule.Close(), func(err error) error { return E.Cause(err, "close dns rule[", i, "]") @@ -130,6 +141,111 @@ func (r *Router) Close() error { return err } +func (r *Router) rebuildRules(startRules bool) error { + router := service.FromContext[adapter.Router](r.ctx) + legacyAddressFilterMode, err := resolveLegacyAddressFilterMode(router, r.rawRules) + if err != nil { + return err + } + if !legacyAddressFilterMode { + err = validateNonLegacyAddressFilterRules(r.rawRules) + if err != nil { + return err + } + } + newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) + for i, ruleOptions := range r.rawRules { + dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyAddressFilterMode) + if err != nil { + closeRules(newRules) + return 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 E.Cause(err, "initialize DNS rule[", i, "]") + } + } + } + r.rulesAccess.Lock() + oldRules := r.rules + r.rules = newRules + r.legacyAddressFilterMode = legacyAddressFilterMode + r.runtimeRuleError = nil + shouldReportDeprecated := legacyAddressFilterMode && + !r.deprecatedReported && + common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) + if shouldReportDeprecated { + r.deprecatedReported = true + } + r.rulesAccess.Unlock() + closeRules(oldRules) + if shouldReportDeprecated { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + } + return nil +} + +func closeRules(rules []adapter.DNSRule) { + for _, rule := range rules { + _ = rule.Close() + } +} + +func (r *Router) registerRuleSetCallbacks() error { + tags := referencedDNSRuleSetTags(r.rawRules) + if len(tags) == 0 { + return nil + } + r.rulesAccess.RLock() + if len(r.ruleSetCallbacks) > 0 { + r.rulesAccess.RUnlock() + return nil + } + r.rulesAccess.RUnlock() + router := service.FromContext[adapter.Router](r.ctx) + if router == nil { + return E.New("router service not found") + } + callbacks := make([]dnsRuleSetCallback, 0, len(tags)) + for _, tag := range tags { + ruleSet, loaded := router.RuleSet(tag) + if !loaded { + for _, callback := range callbacks { + callback.ruleSet.UnregisterCallback(callback.element) + } + return E.New("rule-set not found: ", tag) + } + element := ruleSet.RegisterCallback(func(adapter.RuleSet) { + err := r.rebuildRules(true) + if err != nil { + r.rulesAccess.Lock() + r.runtimeRuleError = err + r.rulesAccess.Unlock() + r.logger.Error(E.Cause(err, "rebuild DNS rules after rule-set update")) + } + }) + callbacks = append(callbacks, dnsRuleSetCallback{ + ruleSet: ruleSet, + element: element, + }) + } + r.rulesAccess.Lock() + if len(r.ruleSetCallbacks) == 0 { + r.ruleSetCallbacks = callbacks + callbacks = nil + } + r.rulesAccess.Unlock() + for _, callback := range callbacks { + callback.ruleSet.UnregisterCallback(callback.element) + } + return nil +} + func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { metadata := adapter.ContextFrom(ctx) if metadata == nil { @@ -538,6 +654,11 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } return &responseMessage, nil } + r.rulesAccess.RLock() + defer r.rulesAccess.RUnlock() + if r.runtimeRuleError != nil { + return nil, r.runtimeRuleError + } r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) var ( response *mDNS.Msg @@ -639,6 +760,11 @@ 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.runtimeRuleError != nil { + return nil, r.runtimeRuleError + } var ( responseAddrs []netip.Addr err error @@ -769,26 +895,122 @@ func (r *Router) ResetNetwork() { } } -func hasLegacyAddressFilterItems(rules []option.DNSRule) bool { - return common.Any(rules, hasLegacyAddressFilterItemsInRule) +func hasDirectLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { + if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { + return true + } + return !rule.MatchResponse && (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) } -func hasLegacyAddressFilterItemsInRule(rule option.DNSRule) bool { +func hasResponseMatchFields(rule option.DefaultDNSRule) bool { + return rule.ResponseRcode != nil || + len(rule.ResponseAnswer) > 0 || + len(rule.ResponseNs) > 0 || + len(rule.ResponseExtra) > 0 +} + +func defaultRuleForcesNewDNSPath(rule option.DefaultDNSRule) bool { + return rule.MatchResponse || + hasResponseMatchFields(rule) || + rule.Action == C.RuleActionTypeEvaluate || + rule.IPVersion > 0 || + len(rule.QueryType) > 0 +} + +func resolveLegacyAddressFilterMode(router adapter.Router, rules []option.DNSRule) (bool, error) { + forceNew, needsLegacy, err := dnsRuleModeRequirements(router, rules) + if err != nil { + return false, err + } + if forceNew { + return false, nil + } + return needsLegacy, nil +} + +func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (bool, bool, error) { + var forceNew bool + var needsLegacy bool + for i, rule := range rules { + ruleForceNew, ruleNeedsLegacy, err := dnsRuleModeRequirementsInRule(router, rule) + if err != nil { + return false, false, E.Cause(err, "dns rule[", i, "]") + } + forceNew = forceNew || ruleForceNew + needsLegacy = needsLegacy || ruleNeedsLegacy + } + return forceNew, needsLegacy, nil +} + +func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (bool, bool, error) { switch rule.Type { case "", C.RuleTypeDefault: - return hasLegacyAddressFilterItemsInDefaultRule(rule.DefaultOptions) + return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions) case C.RuleTypeLogical: - return common.Any(rule.LogicalOptions.Rules, hasLegacyAddressFilterItemsInRule) + forceNew := dnsRuleActionType(rule) == C.RuleActionTypeEvaluate + var needsLegacy bool + for i, subRule := range rule.LogicalOptions.Rules { + subForceNew, subNeedsLegacy, err := dnsRuleModeRequirementsInRule(router, subRule) + if err != nil { + return false, false, E.Cause(err, "sub rule[", i, "]") + } + forceNew = forceNew || subForceNew + needsLegacy = needsLegacy || subNeedsLegacy + } + return forceNew, needsLegacy, nil default: - return false + return false, false, nil } } -func hasLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { - if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { - return true +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (bool, bool, error) { + forceNew := defaultRuleForcesNewDNSPath(rule) + needsLegacy := hasDirectLegacyAddressFilterItemsInDefaultRule(rule) + if len(rule.RuleSet) == 0 { + return forceNew, needsLegacy, nil + } + if router == nil { + return false, false, E.New("router service not found") + } + for _, tag := range rule.RuleSet { + ruleSet, loaded := router.RuleSet(tag) + if !loaded { + return false, false, E.New("rule-set not found: ", tag) + } + metadata := ruleSet.Metadata() + forceNew = forceNew || metadata.ContainsDNSQueryTypeRule + if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { + needsLegacy = true + } } - return !rule.MatchResponse && (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) + return forceNew, needsLegacy, 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 validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { @@ -832,10 +1054,7 @@ func validateNonLegacyAddressFilterRuleTree(rule option.DNSRule) (bool, error) { } func validateNonLegacyAddressFilterDefaultRule(rule option.DefaultDNSRule) (bool, error) { - hasResponseRecords := rule.ResponseRcode != nil || - len(rule.ResponseAnswer) > 0 || - len(rule.ResponseNs) > 0 || - len(rule.ResponseExtra) > 0 + hasResponseRecords := hasResponseMatchFields(rule) if hasResponseRecords && !rule.MatchResponse { return false, E.New("response_* items require match_response") } diff --git a/dns/router_test.go b/dns/router_test.go index 7012789fcd..2c967c3faf 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -6,17 +6,23 @@ import ( "net" "net/netip" "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" "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 { @@ -72,6 +78,69 @@ type fakeDeprecatedManager struct { features []deprecated.Note } +type fakeRouter struct { + 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) { + ruleSet, loaded := r.ruleSets[tag] + return ruleSet, loaded +} +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 { + metadata adapter.RuleSetMetadata + callbacks []adapter.RuleSetUpdateCallback +} + +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 { return s.metadata } +func (s *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *fakeRuleSet) IncRef() {} +func (s *fakeRuleSet) DecRef() {} +func (s *fakeRuleSet) Cleanup() {} +func (s *fakeRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { + s.callbacks = append(s.callbacks, callback) + return nil +} +func (s *fakeRuleSet) UnregisterCallback(*list.Element[adapter.RuleSetUpdateCallback]) {} +func (s *fakeRuleSet) Close() error { return nil } +func (s *fakeRuleSet) Match(*adapter.InboundContext) bool { return true } +func (s *fakeRuleSet) String() string { return "fake-rule-set" } +func (s *fakeRuleSet) updateMetadata(metadata adapter.RuleSetMetadata) { + s.metadata = metadata + for _, callback := range s.callbacks { + callback(s) + } +} + func (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { m.features = append(m.features, feature) } @@ -108,18 +177,25 @@ func (c *fakeDNSClient) Lookup(_ context.Context, transport adapter.DNSTransport func (c *fakeDNSClient) ClearCache() {} func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { + return newTestRouterWithContext(t, context.Background(), rules, transportManager, client) +} + +func newTestRouterWithContext(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { t.Helper() router := &Router{ - ctx: context.Background(), + ctx: ctx, logger: log.NewNOPFactory().NewLogger("dns"), 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 } @@ -202,6 +278,187 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } +func TestStartNewModeRejectsDirectLegacyRuleWhenRuleSetForcesNew(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), + 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]{"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.NoError(t, err) + + err = router.Start(adapter.StartStateStart) + require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") +} + +func TestLookupLegacyModeDefersRuleSetDestinationIPMatch(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.legacyAddressFilterMode) + + 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 TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(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"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + 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.legacyAddressFilterMode) + + fakeSet.updateMetadata(adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + + _, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") +} + func TestLookupLegacyModeDefersDirectDestinationIPMatch(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_dns_legacy.go b/route/rule/rule_dns_legacy.go index 8cdad83f3f..25088cacc8 100644 --- a/route/rule/rule_dns_legacy.go +++ b/route/rule/rule_dns_legacy.go @@ -47,7 +47,12 @@ type legacyResponseConstraint struct { forbiddenSet *netipx.IPSet } -type legacyRuleMatchStateSet [16]legacyResponseFormula +const ( + legacyRuleMatchDeferredDestinationAddress ruleMatchState = 1 << 4 + legacyRuleMatchStateCount = 32 +) + +type legacyRuleMatchStateSet [legacyRuleMatchStateCount]legacyResponseFormula var ( legacyAllIPSet = func() *netipx.IPSet { @@ -350,7 +355,7 @@ func (s legacyRuleMatchStateSet) isEmpty() bool { func (s legacyRuleMatchStateSet) merge(other legacyRuleMatchStateSet) legacyRuleMatchStateSet { var merged legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { merged[state] = s[state].or(other[state]) } return merged @@ -361,11 +366,11 @@ func (s legacyRuleMatchStateSet) combine(other legacyRuleMatchStateSet) legacyRu return legacyRuleMatchStateSet{} } var combined legacyRuleMatchStateSet - for left := ruleMatchState(0); left < 16; left++ { + for left := ruleMatchState(0); left < legacyRuleMatchStateCount; left++ { if s[left].isFalse() { continue } - for right := ruleMatchState(0); right < 16; right++ { + for right := ruleMatchState(0); right < legacyRuleMatchStateCount; right++ { if other[right].isFalse() { continue } @@ -380,7 +385,7 @@ func (s legacyRuleMatchStateSet) withBase(base ruleMatchState) legacyRuleMatchSt return legacyRuleMatchStateSet{} } var withBase legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if s[state].isFalse() { continue } @@ -391,7 +396,7 @@ func (s legacyRuleMatchStateSet) withBase(base ruleMatchState) legacyRuleMatchSt func (s legacyRuleMatchStateSet) filter(allowed func(ruleMatchState) bool) legacyRuleMatchStateSet { var filtered legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if s[state].isFalse() { continue } @@ -404,7 +409,7 @@ func (s legacyRuleMatchStateSet) filter(allowed func(ruleMatchState) bool) legac func (s legacyRuleMatchStateSet) addBit(bit ruleMatchState) legacyRuleMatchStateSet { var withBit legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if s[state].isFalse() { continue } @@ -422,7 +427,7 @@ func (s legacyRuleMatchStateSet) branchOnBit(bit ruleMatchState, condition legac } var branched legacyRuleMatchStateSet conditionFalse := condition.not() - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if s[state].isFalse() { continue } @@ -444,7 +449,7 @@ func (s legacyRuleMatchStateSet) andFormula(formula legacyResponseFormula) legac return s } var result legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if s[state].isFalse() { continue } @@ -588,7 +593,7 @@ func (r *abstractDefaultRule) legacyMatchStatesWithBase(metadata *adapter.Inboun } if r.legacyDestinationIPCIDRMatchesDestination(metadata) { metadata.DidMatch = true - stateSet = stateSet.branchOnBit(ruleMatchDestinationAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) + stateSet = stateSet.branchOnBit(legacyRuleMatchDeferredDestinationAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) } if len(r.destinationPortItems) > 0 { metadata.DidMatch = true @@ -608,7 +613,7 @@ func (r *abstractDefaultRule) legacyMatchStatesWithBase(metadata *adapter.Inboun if r.ruleSetItem != nil { metadata.DidMatch = true var merged legacyRuleMatchStateSet - for state := ruleMatchState(0); state < 16; state++ { + for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { if stateSet[state].isFalse() { continue } @@ -627,6 +632,9 @@ func (r *abstractDefaultRule) legacyMatchStatesWithBase(metadata *adapter.Inboun if r.legacyRequiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { return false } + if r.legacyRequiresDeferredDestinationAddressMatch(metadata) && !state.has(legacyRuleMatchDeferredDestinationAddress) { + return false + } if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { return false } @@ -647,7 +655,11 @@ func (r *abstractDefaultRule) legacyDestinationIPCIDRMatchesDestination(metadata } func (r *abstractDefaultRule) legacyRequiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.destinationAddressItems) > 0 || r.legacyDestinationIPCIDRMatchesDestination(metadata) + return len(r.destinationAddressItems) > 0 +} + +func (r *abstractDefaultRule) legacyRequiresDeferredDestinationAddressMatch(metadata *adapter.InboundContext) bool { + return r.legacyDestinationIPCIDRMatchesDestination(metadata) } func (r *abstractLogicalRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 39068dbf35..9bffa8fcb9 100644 --- a/route/rule/rule_set.go +++ b/route/rule/rule_set.go @@ -69,3 +69,7 @@ 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 +} diff --git a/route/rule/rule_set_local.go b/route/rule/rule_set_local.go index ed873d7069..51e8f27235 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -141,6 +141,7 @@ func (s *LocalRuleSet) reloadRules(headlessRules []option.HeadlessRule) error { metadata.ContainsProcessRule = HasHeadlessRule(headlessRules, isProcessHeadlessRule) metadata.ContainsWIFIRule = HasHeadlessRule(headlessRules, isWIFIHeadlessRule) metadata.ContainsIPCIDRRule = HasHeadlessRule(headlessRules, isIPCIDRHeadlessRule) + metadata.ContainsDNSQueryTypeRule = HasHeadlessRule(headlessRules, isDNSQueryTypeHeadlessRule) 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..4d2691450e 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -193,6 +193,7 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error { s.metadata.ContainsProcessRule = HasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule) s.metadata.ContainsWIFIRule = HasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule) s.metadata.ContainsIPCIDRRule = HasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule) + s.metadata.ContainsDNSQueryTypeRule = HasHeadlessRule(plainRuleSet.Rules, isDNSQueryTypeHeadlessRule) s.rules = rules callbacks := s.callbacks.Array() s.access.Unlock() From e09a6d32060b64f2353e87de2dc0befae9e21794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 26 Mar 2026 12:27:22 +0800 Subject: [PATCH 16/67] dns: restore init validation and fix rule-set query type --- dns/router.go | 56 +++++++++++++++---------- dns/router_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 132 insertions(+), 25 deletions(-) diff --git a/dns/router.go b/dns/router.go index f5b80a1200..fe87837663 100644 --- a/dns/router.go +++ b/dns/router.go @@ -91,6 +91,11 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp func (r *Router) Initialize(rules []option.DNSRule) error { r.rawRules = append(r.rawRules[:0], rules...) + newRules, _, err := r.buildRules(false) + if err != nil { + return err + } + closeRules(newRules) return nil } @@ -142,15 +147,40 @@ func (r *Router) Close() error { } func (r *Router) rebuildRules(startRules bool) error { + newRules, legacyAddressFilterMode, err := r.buildRules(startRules) + if err != nil { + return err + } + shouldReportDeprecated := startRules && + legacyAddressFilterMode && + !r.deprecatedReported && + common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) + r.rulesAccess.Lock() + oldRules := r.rules + r.rules = newRules + r.legacyAddressFilterMode = legacyAddressFilterMode + r.runtimeRuleError = nil + if shouldReportDeprecated { + r.deprecatedReported = true + } + r.rulesAccess.Unlock() + closeRules(oldRules) + if shouldReportDeprecated { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) + } + return nil +} + +func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { router := service.FromContext[adapter.Router](r.ctx) legacyAddressFilterMode, err := resolveLegacyAddressFilterMode(router, r.rawRules) if err != nil { - return err + return nil, false, err } if !legacyAddressFilterMode { err = validateNonLegacyAddressFilterRules(r.rawRules) if err != nil { - return err + return nil, false, err } } newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) @@ -158,7 +188,7 @@ func (r *Router) rebuildRules(startRules bool) error { dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyAddressFilterMode) if err != nil { closeRules(newRules) - return E.Cause(err, "parse dns rule[", i, "]") + return nil, false, E.Cause(err, "parse dns rule[", i, "]") } newRules = append(newRules, dnsRule) } @@ -167,27 +197,11 @@ func (r *Router) rebuildRules(startRules bool) error { err := rule.Start() if err != nil { closeRules(newRules) - return E.Cause(err, "initialize DNS rule[", i, "]") + return nil, false, E.Cause(err, "initialize DNS rule[", i, "]") } } } - r.rulesAccess.Lock() - oldRules := r.rules - r.rules = newRules - r.legacyAddressFilterMode = legacyAddressFilterMode - r.runtimeRuleError = nil - shouldReportDeprecated := legacyAddressFilterMode && - !r.deprecatedReported && - common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) - if shouldReportDeprecated { - r.deprecatedReported = true - } - r.rulesAccess.Unlock() - closeRules(oldRules) - if shouldReportDeprecated { - deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) - } - return nil + return newRules, legacyAddressFilterMode, nil } func closeRules(rules []adapter.DNSRule) { diff --git a/dns/router_test.go b/dns/router_test.go index 2c967c3faf..1f66f17572 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -278,7 +278,34 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } -func TestStartNewModeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { +func TestInitializeRejectsInvalidDNSRuleParseError(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), + rules: make([]adapter.DNSRule, 0, 1), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + err := router.Initialize([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + DomainRegex: badoption.Listable[string]{"("}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }}) + require.ErrorContains(t, err, "domain_regex") +} + +func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { t.Parallel() ctx := context.Background() @@ -336,9 +363,6 @@ func TestStartNewModeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { }, }, }) - require.NoError(t, err) - - err = router.Start(adapter.StartStateStart) require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } @@ -1076,6 +1100,75 @@ func TestLookupNewModeUsesQueryTypeRule(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) } +func TestLookupNewModeUsesRuleSetQueryTypeRule(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, + }, + }) + + 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]{"query-set"}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "only-a"}, + }, + }, + }}, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "only-a": &fakeDNSTransport{tag: "only-a", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "default": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::4")}, 60), nil + case "only-a": + if message.Question[0].Qtype == mDNS.TypeA { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil + } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::9")}, 60), nil + default: + return nil, errors.New("unexpected transport") + } + }, + }) + require.False(t, router.legacyAddressFilterMode) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{ + netip.MustParseAddr("9.9.9.9"), + netip.MustParseAddr("2001:db8::4"), + }, addresses) +} + func TestLookupNewModeUsesIPVersionRule(t *testing.T) { t.Parallel() From a491c9afefd429477956df2c16c3a53252853f21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 26 Mar 2026 13:01:13 +0800 Subject: [PATCH 17/67] Fix DNS record parsing and shutdown race --- dns/router.go | 21 ++++++++- dns/router_test.go | 98 +++++++++++++++++++++++++++++++++++---- option/dns_record.go | 16 ++++++- option/dns_record_test.go | 22 +++++++-- 4 files changed, 143 insertions(+), 14 deletions(-) diff --git a/dns/router.go b/dns/router.go index fe87837663..870498a843 100644 --- a/dns/router.go +++ b/dns/router.go @@ -50,6 +50,7 @@ type Router struct { platformInterface adapter.PlatformInterface legacyAddressFilterMode bool rulesAccess sync.RWMutex + closing bool ruleSetCallbacks []dnsRuleSetCallback runtimeRuleError error deprecatedReported bool @@ -126,15 +127,16 @@ func (r *Router) Start(stage adapter.StartStage) error { func (r *Router) Close() error { monitor := taskmonitor.New(r.logger, C.StopTimeout) r.rulesAccess.Lock() + r.closing = true callbacks := r.ruleSetCallbacks r.ruleSetCallbacks = nil runtimeRules := r.rules r.rules = nil r.runtimeRuleError = nil - r.rulesAccess.Unlock() for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } + r.rulesAccess.Unlock() var err error for i, rule := range runtimeRules { monitor.Start("close dns rule[", i, "]") @@ -147,8 +149,14 @@ func (r *Router) Close() error { } func (r *Router) rebuildRules(startRules bool) error { + if r.isClosing() { + return nil + } newRules, legacyAddressFilterMode, err := r.buildRules(startRules) if err != nil { + if r.isClosing() { + return nil + } return err } shouldReportDeprecated := startRules && @@ -156,6 +164,11 @@ func (r *Router) rebuildRules(startRules bool) error { !r.deprecatedReported && common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) r.rulesAccess.Lock() + if r.closing { + r.rulesAccess.Unlock() + closeRules(newRules) + return nil + } oldRules := r.rules r.rules = newRules r.legacyAddressFilterMode = legacyAddressFilterMode @@ -171,6 +184,12 @@ func (r *Router) rebuildRules(startRules bool) error { return nil } +func (r *Router) isClosing() bool { + r.rulesAccess.RLock() + defer r.rulesAccess.RUnlock() + return r.closing +} + func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { router := service.FromContext[adapter.Router](r.ctx) legacyAddressFilterMode, err := resolveLegacyAddressFilterMode(router, r.rawRules) diff --git a/dns/router_test.go b/dns/router_test.go index 1f66f17572..ccc377ea85 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "net/netip" + "sync" "testing" "time" @@ -114,8 +115,9 @@ func (r *fakeRouter) AppendTracker(adapter.ConnectionTracker) {} func (r *fakeRouter) ResetNetwork() {} type fakeRuleSet struct { + access sync.Mutex metadata adapter.RuleSetMetadata - callbacks []adapter.RuleSetUpdateCallback + callbacks list.List[adapter.RuleSetUpdateCallback] } func (s *fakeRuleSet) Name() string { return "fake-rule-set" } @@ -127,20 +129,34 @@ func (s *fakeRuleSet) IncRef() func (s *fakeRuleSet) DecRef() {} func (s *fakeRuleSet) Cleanup() {} func (s *fakeRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] { - s.callbacks = append(s.callbacks, callback) - return nil + 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) UnregisterCallback(*list.Element[adapter.RuleSetUpdateCallback]) {} -func (s *fakeRuleSet) Close() error { return nil } -func (s *fakeRuleSet) Match(*adapter.InboundContext) bool { return true } -func (s *fakeRuleSet) String() string { return "fake-rule-set" } +func (s *fakeRuleSet) Close() error { return nil } +func (s *fakeRuleSet) Match(*adapter.InboundContext) bool { return true } +func (s *fakeRuleSet) String() string { return "fake-rule-set" } func (s *fakeRuleSet) updateMetadata(metadata adapter.RuleSetMetadata) { + s.access.Lock() s.metadata = metadata - for _, callback := range s.callbacks { + 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 (m *fakeDeprecatedManager) ReportDeprecated(feature deprecated.Note) { m.features = append(m.features, feature) } @@ -483,6 +499,72 @@ func TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } +func TestCloseIgnoresSnapshottedRuleSetCallback(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"}, + }, + }, + }, + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + }, + }, &fakeDNSClient{ + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + }, + }) + + callbacks := fakeSet.snapshotCallbacks() + require.Len(t, callbacks, 1) + + require.NoError(t, router.Close()) + require.Empty(t, fakeSet.snapshotCallbacks()) + + fakeSet.metadata = adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + } + callbacks[0](fakeSet) + + router.rulesAccess.RLock() + defer router.rulesAccess.RUnlock() + require.True(t, router.closing) + require.Nil(t, router.rules) + require.Empty(t, router.ruleSetCallbacks) + require.NoError(t, router.runtimeRuleError) +} + func TestLookupLegacyModeDefersDirectDestinationIPMatch(t *testing.T) { t.Parallel() diff --git a/option/dns_record.go b/option/dns_record.go index b2d73fa00d..2d4fb78883 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,7 +79,7 @@ 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 } @@ -90,6 +93,17 @@ 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) + parser.SetIncludeAllowed(true) + record, _ := parser.Next() + return record, parser.Err() +} + func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error { record, _, err := dns.UnpackRR(binary, 0) if err != nil { diff --git a/option/dns_record_test.go b/option/dns_record_test.go index f30f6a682f..cb26f9b018 100644 --- a/option/dns_record_test.go +++ b/option/dns_record_test.go @@ -14,19 +14,33 @@ func mustRecordOptions(t *testing.T, record string) DNSRecordOptions { return value } -func TestDNSRecordOptionsUnmarshalJSONAcceptsRelativeOwnerNames(t *testing.T) { +func TestDNSRecordOptionsUnmarshalJSONAcceptsFullyQualifiedNames(t *testing.T) { t.Parallel() for _, record := range []string{ - "example.com A 1.1.1.1", - "@ IN A 1.1.1.1", - "www IN CNAME @", + "example.com. A 1.1.1.1", + "www.example.com. IN CNAME example.com.", } { value := mustRecordOptions(t, record) require.NotNil(t, value.RR) } } +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() From 5cc484f34de519a76972eac751874ebb43ab6252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 26 Mar 2026 18:58:53 +0800 Subject: [PATCH 18/67] Fix DNS record parser file inclusion and rule match log index Remove SetIncludeAllowed(true) from the DNS record zone parser. The $INCLUDE directive allows opening arbitrary files via os.Open, which is unnecessary and dangerous when parsing a single record string from configuration (especially remote profiles). Fix displayRuleIndex arithmetic in dns/router.go that computed 2*index+1 instead of the correct 0-based index. This was a reintroduction of a bug previously fixed in be8ee370a. Both matchDNS and logRuleMatch now use the index directly, matching the pattern in route/route.go. --- dns/router.go | 19 +++++-------------- option/dns_record.go | 1 - 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/dns/router.go b/dns/router.go index 870498a843..778ad84c0e 100644 --- a/dns/router.go +++ b/dns/router.go @@ -296,15 +296,10 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, metadata.ResetRuleCache() metadata.DestinationAddressMatchFromResponse = false if currentRule.LegacyPreMatch(metadata) { - displayRuleIndex := currentRuleIndex - if displayRuleIndex != -1 { - displayRuleIndex += displayRuleIndex + 1 - } - ruleDescription := currentRule.String() - if ruleDescription != "" { - r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + 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: @@ -397,14 +392,10 @@ func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, } func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { - displayRuleIndex := ruleIndex - if displayRuleIndex != -1 { - displayRuleIndex += displayRuleIndex + 1 - } if ruleDescription := currentRule.String(); ruleDescription != "" { - r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] ", currentRule, " => ", currentRule.Action()) + r.logger.DebugContext(ctx, "match[", ruleIndex, "] ", currentRule, " => ", currentRule.Action()) } else { - r.logger.DebugContext(ctx, "match[", displayRuleIndex, "] => ", currentRule.Action()) + r.logger.DebugContext(ctx, "match[", ruleIndex, "] => ", currentRule.Action()) } } diff --git a/option/dns_record.go b/option/dns_record.go index 2d4fb78883..f10e03d9b6 100644 --- a/option/dns_record.go +++ b/option/dns_record.go @@ -99,7 +99,6 @@ func parseDNSRecord(stringValue string) (dns.RR, error) { } parser := dns.NewZoneParser(strings.NewReader(stringValue), "", "") parser.SetDefaultTTL(defaultDNSRecordTTL) - parser.SetIncludeAllowed(true) record, _ := parser.Next() return record, parser.Err() } From 5dbabe4b37e6aff5b718fb5b6991bf465540b10f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Mar 2026 13:33:13 +0800 Subject: [PATCH 19/67] dns: restore lookup reject semantics --- dns/router.go | 96 +++++++++++++++++++++++++++++++--------- dns/router_test.go | 106 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 20 deletions(-) diff --git a/dns/router.go b/dns/router.go index 778ad84c0e..15761055b5 100644 --- a/dns/router.go +++ b/dns/router.go @@ -399,7 +399,16 @@ func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule ad } } -func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) (*mDNS.Msg, adapter.DNSTransport, adapter.DNSQueryOptions, bool, error) { +type exchangeWithRulesResult struct { + response *mDNS.Msg + transport adapter.DNSTransport + queryOptions adapter.DNSQueryOptions + strategyOverridden bool + rejectAction *R.RuleActionReject + err error +} + +func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") @@ -458,23 +467,43 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio exchangeOptions.Strategy = r.defaultDomainStrategy } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - return response, transport, queryOptions, effectiveStrategyOverridden || strategyOverridden, err + return exchangeWithRulesResult{ + response: response, + transport: transport, + queryOptions: queryOptions, + strategyOverridden: effectiveStrategyOverridden || strategyOverridden, + err: err, + } case *R.RuleActionReject: switch action.Method { case C.RuleActionRejectMethodDefault: - return &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Id: message.Id, - Rcode: mDNS.RcodeRefused, - Response: true, + return exchangeWithRulesResult{ + response: &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Id: message.Id, + Rcode: mDNS.RcodeRefused, + Response: true, + }, + Question: []mDNS.Question{message.Question[0]}, }, - Question: []mDNS.Question{message.Question[0]}, - }, nil, effectiveOptions, effectiveStrategyOverridden, nil + queryOptions: effectiveOptions, + strategyOverridden: effectiveStrategyOverridden, + rejectAction: action, + } case C.RuleActionRejectMethodDrop: - return nil, nil, effectiveOptions, effectiveStrategyOverridden, tun.ErrDrop + return exchangeWithRulesResult{ + queryOptions: effectiveOptions, + strategyOverridden: effectiveStrategyOverridden, + rejectAction: action, + err: tun.ErrDrop, + } } case *R.RuleActionPredefined: - return action.Response(message), nil, effectiveOptions, effectiveStrategyOverridden, nil + return exchangeWithRulesResult{ + response: action.Response(message), + queryOptions: effectiveOptions, + strategyOverridden: effectiveStrategyOverridden, + } } } queryOptions := effectiveOptions @@ -484,7 +513,13 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio exchangeOptions.Strategy = r.defaultDomainStrategy } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) - return response, transport, queryOptions, effectiveStrategyOverridden, err + return exchangeWithRulesResult{ + response: response, + transport: transport, + queryOptions: queryOptions, + strategyOverridden: effectiveStrategyOverridden, + err: err, + } } type lookupWithRulesResponse struct { @@ -593,6 +628,21 @@ func withLookupQueryMetadata(ctx context.Context, qType uint16) context.Context 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, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { lookupOptions := options if options.LookupStrategy != C.DomainStrategyAsIS { @@ -646,22 +696,25 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u Qclass: mDNS.ClassINET, }}, } - response, _, queryOptions, strategyOverridden, err := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), request, options, false) - explicitStrategy := lookupStrategyOverride(queryOptions, strategyOverridden) + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), request, options, false) + explicitStrategy := lookupStrategyOverride(exchangeResult.queryOptions, exchangeResult.strategyOverridden) result := lookupWithRulesResponse{ strategy: r.resolveLookupStrategy(options, explicitStrategy), explicitStrategy: explicitStrategy, } - if err != nil { - return result, err + if exchangeResult.rejectAction != nil { + return result, exchangeResult.rejectAction.Error(ctx) + } + if exchangeResult.err != nil { + return result, exchangeResult.err } - if response.Rcode != mDNS.RcodeSuccess { - return result, RcodeError(response.Rcode) + if exchangeResult.response.Rcode != mDNS.RcodeSuccess { + return result, RcodeError(exchangeResult.response.Rcode) } if !lookupStrategyAllowsQueryType(result.strategy, qType) { return result, nil } - result.addresses = MessageToAddresses(response) + result.addresses = filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType) return result, nil } @@ -709,7 +762,8 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } response, err = r.client.Exchange(ctx, transport, message, options, nil) } else if !r.legacyAddressFilterMode { - response, transport, _, _, err = r.exchangeWithRules(ctx, message, options, true) + exchangeResult := r.exchangeWithRules(ctx, message, options, true) + response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { var ( rule adapter.DNSRule @@ -802,6 +856,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)) } diff --git a/dns/router_test.go b/dns/router_test.go index ccc377ea85..babd6cf83d 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1356,6 +1356,112 @@ func TestLookupNewModeAppliesRouteStrategyAfterEvaluate(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } +func TestLookupNewModeReturnsRejectedErrorForRejectAction(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.legacyAddressFilterMode) + + 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 TestExchangeNewModeReturnsRefusedResponseForRejectAction(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.legacyAddressFilterMode) + + 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 TestLookupNewModeFiltersPerQueryTypeAddressesBeforeMerging(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.legacyAddressFilterMode) + + 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 TestLookupNewModePrefersExplicitBranchStrategyOverDefault(t *testing.T) { t.Parallel() From ae2c869310d9498de4cba527a2b6764f681ae200 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Mar 2026 15:24:09 +0800 Subject: [PATCH 20/67] Fix legacy DNS rule_set accept_empty matching --- adapter/router.go | 3 + dns/router.go | 3 + dns/router_test.go | 82 +++++++++++++++++++++++++++ route/rule/rule_item_cidr.go | 10 +++- route/rule/rule_set_semantics_test.go | 2 + 5 files changed, 98 insertions(+), 2 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index a8f66ba675..b8564eb0ad 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -66,6 +66,9 @@ type RuleSet interface { type RuleSetUpdateCallback func(it RuleSet) +// Rule-set metadata only exposes headless-rule capabilities that outer routers +// need before evaluating nested matches. Headless rules do not support +// ip_version, so there is intentionally no ContainsIPVersionRule flag here. type RuleSetMetadata struct { ContainsProcessRule bool ContainsWIFIRule bool diff --git a/dns/router.go b/dns/router.go index 15761055b5..a3a874a812 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1058,6 +1058,9 @@ func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.Def return false, false, E.New("rule-set not found: ", tag) } metadata := ruleSet.Metadata() + // Rule sets are built from headless rules, so query_type is the only + // per-query DNS predicate they can contribute here. ip_version is not a + // headless-rule item and is therefore intentionally absent from metadata. forceNew = forceNew || metadata.ContainsDNSQueryTypeRule if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { needsLegacy = true diff --git a/dns/router_test.go b/dns/router_test.go index babd6cf83d..d28718c23e 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -661,6 +661,88 @@ func TestLookupLegacyModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T require.Equal(t, []string{"private", "default"}, lookups) } +func TestLookupLegacyModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(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 lookups []string + 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) + lookups = append(lookups, 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, errors.New("unexpected transport") + }, + }) + + require.True(t, router.legacyAddressFilterMode) + + 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"}, lookups) +} + func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_item_cidr.go b/route/rule/rule_item_cidr.go index 61612f88f2..28f74161f1 100644 --- a/route/rule/rule_item_cidr.go +++ b/route/rule/rule_item_cidr.go @@ -77,12 +77,18 @@ func (r *IPCIDRItem) Match(metadata *adapter.InboundContext) bool { return r.ipSet.Contains(metadata.Source.Addr) } if metadata.DestinationAddressMatchFromResponse { - for _, address := range metadata.DNSResponseAddressesForMatch() { + 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 metadata.IPCIDRAcceptEmpty + return false } if metadata.Destination.IsIP() { return r.ipSet.Contains(metadata.Destination.Addr) diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 03fb64ef30..8c695ecc75 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -612,6 +612,8 @@ func TestDNSRuleSetSemantics(t *testing.T) { ipCidrAcceptEmpty: true, }) }) + 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) From 876c8eb283d4050ffce945a78cd5e58d7f0512fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 27 Mar 2026 16:08:57 +0800 Subject: [PATCH 21/67] Fix DNS rule-set ref handling --- dns/router.go | 7 -- dns/router_test.go | 87 ++++++++++++++++- route/rule/rule_item_rule_set.go | 11 +++ route/rule/rule_item_rule_set_test.go | 134 ++++++++++++++++++++++++++ 4 files changed, 227 insertions(+), 12 deletions(-) create mode 100644 route/rule/rule_item_rule_set_test.go diff --git a/dns/router.go b/dns/router.go index a3a874a812..ba29281f84 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1097,7 +1097,6 @@ func referencedDNSRuleSetTags(rules []option.DNSRule) []string { } func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { - var seenEvaluate bool for i, rule := range rules { consumesResponse, err := validateNonLegacyAddressFilterRuleTree(rule) if err != nil { @@ -1107,12 +1106,6 @@ func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { if action == C.RuleActionTypeEvaluate && consumesResponse { return E.New("dns rule[", i, "]: evaluate rule cannot consume response state") } - if consumesResponse && !seenEvaluate { - return E.New("dns rule[", i, "]: response matching requires a preceding top-level evaluate rule") - } - if action == C.RuleActionTypeEvaluate { - seenEvaluate = true - } } return nil } diff --git a/dns/router_test.go b/dns/router_test.go index d28718c23e..fb6dad0e49 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -118,16 +118,32 @@ type fakeRuleSet struct { access sync.Mutex metadata adapter.RuleSetMetadata callbacks list.List[adapter.RuleSetUpdateCallback] + refs int } 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 { return s.metadata } -func (s *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } -func (s *fakeRuleSet) IncRef() {} -func (s *fakeRuleSet) DecRef() {} -func (s *fakeRuleSet) Cleanup() {} +func (s *fakeRuleSet) Metadata() adapter.RuleSetMetadata { + s.access.Lock() + defer s.access.Unlock() + return s.metadata +} +func (s *fakeRuleSet) ExtractIPSet() []*netipx.IPSet { return nil } +func (s *fakeRuleSet) IncRef() { + s.access.Lock() + defer s.access.Unlock() + s.refs++ +} +func (s *fakeRuleSet) DecRef() { + 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() @@ -157,6 +173,12 @@ func (s *fakeRuleSet) snapshotCallbacks() []adapter.RuleSetUpdateCallback { 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) } @@ -294,6 +316,21 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } +func TestValidateNewDNSRules_AllowMatchResponseWithoutEvaluate(t *testing.T) { + t.Parallel() + + err := validateNonLegacyAddressFilterRules([]option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + MatchResponse: true, + IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + }, + }, + }}) + require.NoError(t, err) +} + func TestInitializeRejectsInvalidDNSRuleParseError(t *testing.T) { t.Parallel() @@ -442,6 +479,46 @@ func TestLookupLegacyModeDefersRuleSetDestinationIPMatch(t *testing.T) { 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 TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { t.Parallel() 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..3d5959a392 --- /dev/null +++ b/route/rule/rule_item_rule_set_test.go @@ -0,0 +1,134 @@ +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()) + require.Empty(t, item.setList) +} + +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()) +} From 036ef04da1c524c4efa0481d4471d970df42a074 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sat, 28 Mar 2026 22:42:23 +0800 Subject: [PATCH 22/67] Make DNS match_response fail as a normal condition --- dns/router_test.go | 83 +++++++++++++++++++++++++++ route/rule/rule_dns.go | 13 ++++- route/rule/rule_set_semantics_test.go | 57 ++++++++++++++++++ 3 files changed, 152 insertions(+), 1 deletion(-) diff --git a/dns/router_test.go b/dns/router_test.go index fb6dad0e49..cfd8dea3df 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1152,6 +1152,89 @@ func TestExchangeNewModeEvaluateRouteResolutionFailureClearsResponse(t *testing. require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) } +func TestExchangeNewModeEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(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 { + testCase := testCase + 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, errors.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, errors.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 TestLookupNewModeAllowsPartialSuccess(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index cceaec8e9b..6a84a4e77e 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -357,14 +357,25 @@ func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { - return !r.matchStatesForMatch(metadata).isEmpty() + return !r.legacyMatchStatesForMatch(metadata).isEmpty() } return !r.abstractDefaultRule.legacyMatchStates(metadata).isEmpty() } func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesForMatchWithMissingResponse(metadata, true) +} + +func (r *DefaultDNSRule) legacyMatchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { + return r.matchStatesForMatchWithMissingResponse(metadata, false) +} + +func (r *DefaultDNSRule) matchStatesForMatchWithMissingResponse(metadata *adapter.InboundContext, ordinaryFailure bool) ruleMatchStateSet { if r.matchResponse { if metadata.DNSResponse == nil { + if ordinaryFailure { + return r.abstractDefaultRule.invertedFailure(0) + } return 0 } matchMetadata := *metadata diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index 8c695ecc75..bb26cc1693 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -688,6 +688,63 @@ func TestDNSMatchResponseRuleSetDestinationCIDRUsesDNSResponse(t *testing.T) { 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 TestDNSLegacyMatchResponseMissingResponseStillFailsClosed(t *testing.T) { + t.Parallel() + + rule := dnsRuleForTest(func(rule *abstractDefaultRule) { + rule.invert = true + }) + rule.matchResponse = true + + metadata := testMetadata("lookup.example") + require.False(t, rule.LegacyPreMatch(&metadata)) +} + func TestDNSAddressLimitIgnoresDestinationAddresses(t *testing.T) { t.Parallel() From 4df38c324461089913c9fd08bbd0946fc75c0097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 29 Mar 2026 00:27:59 +0800 Subject: [PATCH 23/67] dns: make rule strategy legacy-only --- dns/repro_test.go | 53 +-- dns/router.go | 360 ++++++++---------- dns/router_test.go | 442 +++++++++-------------- docs/configuration/dns/rule_action.md | 6 +- docs/configuration/dns/rule_action.zh.md | 6 +- docs/deprecated.md | 7 + experimental/deprecated/constants.go | 9 + route/rule/rule_dns.go | 20 +- 8 files changed, 375 insertions(+), 528 deletions(-) diff --git a/dns/repro_test.go b/dns/repro_test.go index 2569a58d76..8455b088c8 100644 --- a/dns/repro_test.go +++ b/dns/repro_test.go @@ -15,62 +15,31 @@ import ( "github.com/stretchr/testify/require" ) -func TestReproLookupWithRulesIgnoresRouteStrategy(t *testing.T) { +func TestReproLookupWithRulesUsesRequestStrategy(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: "default"}, - }, - }, - }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{ - Server: "selected", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), - }, - }, - }, - }, - }, &fakeDNSTransportManager{ + var qTypes []uint16 + router := newTestRouter(t, nil, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": defaultTransport, }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - if transport.Tag() == "default" { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - switch message.Question[0].Qtype { - case mDNS.TypeA: + 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 - case mDNS.TypeAAAA: - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - default: - return nil, errors.New("unexpected qtype") } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil }, }) - addrs, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + addrs, 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")}, addrs) } diff --git a/dns/router.go b/dns/router.go index ba29281f84..13ffd0a0a4 100644 --- a/dns/router.go +++ b/dns/router.go @@ -38,22 +38,23 @@ type dnsRuleSetCallback struct { } type Router struct { - 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 - legacyAddressFilterMode bool - rulesAccess sync.RWMutex - closing bool - ruleSetCallbacks []dnsRuleSetCallback - runtimeRuleError error - deprecatedReported bool + 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 + closing bool + ruleSetCallbacks []dnsRuleSetCallback + runtimeRuleError error + addressFilterDeprecatedReported bool + ruleStrategyDeprecatedReported bool } func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOptions) *Router { @@ -152,17 +153,21 @@ func (r *Router) rebuildRules(startRules bool) error { if r.isClosing() { return nil } - newRules, legacyAddressFilterMode, err := r.buildRules(startRules) + newRules, legacyDNSMode, err := r.buildRules(startRules) if err != nil { if r.isClosing() { return nil } return err } - shouldReportDeprecated := startRules && - legacyAddressFilterMode && - !r.deprecatedReported && + shouldReportAddressFilterDeprecated := startRules && + legacyDNSMode && + !r.addressFilterDeprecatedReported && common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) + shouldReportRuleStrategyDeprecated := startRules && + legacyDNSMode && + !r.ruleStrategyDeprecatedReported && + hasDNSRuleActionStrategy(r.rawRules) r.rulesAccess.Lock() if r.closing { r.rulesAccess.Unlock() @@ -171,16 +176,22 @@ func (r *Router) rebuildRules(startRules bool) error { } oldRules := r.rules r.rules = newRules - r.legacyAddressFilterMode = legacyAddressFilterMode + r.legacyDNSMode = legacyDNSMode r.runtimeRuleError = nil - if shouldReportDeprecated { - r.deprecatedReported = true + if shouldReportAddressFilterDeprecated { + r.addressFilterDeprecatedReported = true + } + if shouldReportRuleStrategyDeprecated { + r.ruleStrategyDeprecatedReported = true } r.rulesAccess.Unlock() closeRules(oldRules) - if shouldReportDeprecated { + if shouldReportAddressFilterDeprecated { deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) } + if shouldReportRuleStrategyDeprecated { + deprecated.Report(r.ctx, deprecated.OptionLegacyDNSRuleStrategy) + } return nil } @@ -192,19 +203,19 @@ func (r *Router) isClosing() bool { func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { router := service.FromContext[adapter.Router](r.ctx) - legacyAddressFilterMode, err := resolveLegacyAddressFilterMode(router, r.rawRules) + legacyDNSMode, err := resolveLegacyDNSMode(router, r.rawRules) if err != nil { return nil, false, err } - if !legacyAddressFilterMode { - err = validateNonLegacyAddressFilterRules(r.rawRules) + if !legacyDNSMode { + err = validateLegacyDNSModeDisabledRules(r.rawRules) if err != nil { return nil, false, err } } newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) for i, ruleOptions := range r.rawRules { - dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyAddressFilterMode) + dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) if err != nil { closeRules(newRules) return nil, false, E.Cause(err, "parse dns rule[", i, "]") @@ -220,7 +231,7 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { } } } - return newRules, legacyAddressFilterMode, nil + return newRules, legacyDNSMode, nil } func closeRules(rules []adapter.DNSRule) { @@ -349,12 +360,7 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, return transport, nil, -1 } -func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) bool { - var strategyOverridden bool - if routeOptions.Strategy != C.DomainStrategyAsIS { - options.Strategy = routeOptions.Strategy - strategyOverridden = true - } +func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOptions R.RuleActionDNSRouteOptions) { if routeOptions.DisableCache { options.DisableCache = true } @@ -364,7 +370,6 @@ func (r *Router) applyDNSRouteOptions(options *adapter.DNSQueryOptions, routeOpt if routeOptions.ClientSubnet.IsValid() { options.ClientSubnet = routeOptions.ClientSubnet } - return strategyOverridden } type dnsRouteStatus uint8 @@ -375,20 +380,20 @@ const ( dnsRouteStatusResolved ) -func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus, bool) { +func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus) { transport, loaded := r.transport.Transport(action.Server) if !loaded { - return nil, dnsRouteStatusMissing, false + return nil, dnsRouteStatusMissing } isFakeIP := transport.Type() == C.DNSTypeFakeIP if isFakeIP && !allowFakeIP { - return transport, dnsRouteStatusSkipped, false + return transport, dnsRouteStatusSkipped } - strategyOverridden := r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) + r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) if isFakeIP { options.DisableCache = true } - return transport, dnsRouteStatusResolved, strategyOverridden + return transport, dnsRouteStatusResolved } func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule adapter.DNSRule) { @@ -400,12 +405,10 @@ func (r *Router) logRuleMatch(ctx context.Context, ruleIndex int, currentRule ad } type exchangeWithRulesResult struct { - response *mDNS.Msg - transport adapter.DNSTransport - queryOptions adapter.DNSQueryOptions - strategyOverridden bool - rejectAction *R.RuleActionReject - err error + response *mDNS.Msg + transport adapter.DNSTransport + rejectAction *R.RuleActionReject + err error } func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { @@ -414,7 +417,6 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio panic("no context") } effectiveOptions := options - effectiveStrategyOverridden := false var savedResponse *mDNS.Msg for currentRuleIndex, currentRule := range r.rules { metadata.ResetRuleCache() @@ -426,10 +428,10 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio r.logRuleMatch(ctx, currentRuleIndex, currentRule) switch action := currentRule.Action().(type) { case *R.RuleActionDNSRouteOptions: - effectiveStrategyOverridden = r.applyDNSRouteOptions(&effectiveOptions, *action) || effectiveStrategyOverridden + r.applyDNSRouteOptions(&effectiveOptions, *action) case *R.RuleActionEvaluate: queryOptions := effectiveOptions - transport, status, _ := r.resolveDNSRoute(&R.RuleActionDNSRoute{ + transport, status := r.resolveDNSRoute(&R.RuleActionDNSRoute{ Server: action.Server, RuleActionDNSRouteOptions: action.RuleActionDNSRouteOptions, }, allowFakeIP, &queryOptions) @@ -454,7 +456,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio savedResponse = response case *R.RuleActionDNSRoute: queryOptions := effectiveOptions - transport, status, strategyOverridden := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) + transport, status := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: r.logger.ErrorContext(ctx, "transport not found: ", action.Server) @@ -468,11 +470,9 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) return exchangeWithRulesResult{ - response: response, - transport: transport, - queryOptions: queryOptions, - strategyOverridden: effectiveStrategyOverridden || strategyOverridden, - err: err, + response: response, + transport: transport, + err: err, } case *R.RuleActionReject: switch action.Method { @@ -486,23 +486,17 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio }, Question: []mDNS.Question{message.Question[0]}, }, - queryOptions: effectiveOptions, - strategyOverridden: effectiveStrategyOverridden, - rejectAction: action, + rejectAction: action, } case C.RuleActionRejectMethodDrop: return exchangeWithRulesResult{ - queryOptions: effectiveOptions, - strategyOverridden: effectiveStrategyOverridden, - rejectAction: action, - err: tun.ErrDrop, + rejectAction: action, + err: tun.ErrDrop, } } case *R.RuleActionPredefined: return exchangeWithRulesResult{ - response: action.Response(message), - queryOptions: effectiveOptions, - strategyOverridden: effectiveStrategyOverridden, + response: action.Response(message), } } } @@ -514,36 +508,20 @@ func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, optio } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) return exchangeWithRulesResult{ - response: response, - transport: transport, - queryOptions: queryOptions, - strategyOverridden: effectiveStrategyOverridden, - err: err, + response: response, + transport: transport, + err: err, } } type lookupWithRulesResponse struct { - addresses []netip.Addr - strategy C.DomainStrategy - explicitStrategy C.DomainStrategy -} - -func lookupInputStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { - if options.LookupStrategy != C.DomainStrategyAsIS { - return options.LookupStrategy - } - return options.Strategy + addresses []netip.Addr } -func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions, strategies ...C.DomainStrategy) C.DomainStrategy { +func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { return options.LookupStrategy } - for _, strategy := range strategies { - if strategy != C.DomainStrategyAsIS { - return strategy - } - } if options.Strategy != C.DomainStrategyAsIS { return options.Strategy } @@ -561,60 +539,6 @@ func lookupStrategyAllowsQueryType(strategy C.DomainStrategy, qType uint16) bool } } -func lookupStrategyOverride(queryOptions adapter.DNSQueryOptions, strategyOverridden bool) C.DomainStrategy { - if !strategyOverridden { - return C.DomainStrategyAsIS - } - return queryOptions.Strategy -} - -func isSingleFamilyLookupStrategy(strategy C.DomainStrategy) bool { - return strategy == C.DomainStrategyIPv4Only || strategy == C.DomainStrategyIPv6Only -} - -func resolveExplicitLookupStrategy(strategies ...C.DomainStrategy) (C.DomainStrategy, bool) { - var resolvedStrategy C.DomainStrategy - for _, strategy := range strategies { - if strategy == C.DomainStrategyAsIS { - continue - } - if resolvedStrategy == C.DomainStrategyAsIS { - resolvedStrategy = strategy - continue - } - if resolvedStrategy != strategy { - return C.DomainStrategyAsIS, true - } - } - return resolvedStrategy, false -} - -func (r *Router) resolveLookupOutputStrategies(options adapter.DNSQueryOptions, explicitStrategies ...C.DomainStrategy) (C.DomainStrategy, C.DomainStrategy) { - inputStrategy := lookupInputStrategy(options) - if inputStrategy != C.DomainStrategyAsIS { - return inputStrategy, inputStrategy - } - explicitStrategy, explicitConflict := resolveExplicitLookupStrategy(explicitStrategies...) - sortStrategy := r.defaultDomainStrategy - if !explicitConflict && explicitStrategy != C.DomainStrategyAsIS { - sortStrategy = explicitStrategy - } - filterStrategy := C.DomainStrategyAsIS - if explicitConflict { - return sortStrategy, filterStrategy - } - if explicitStrategy != C.DomainStrategyAsIS { - if isSingleFamilyLookupStrategy(explicitStrategy) { - filterStrategy = explicitStrategy - } - return sortStrategy, filterStrategy - } - if isSingleFamilyLookupStrategy(sortStrategy) { - filterStrategy = sortStrategy - } - return sortStrategy, filterStrategy -} - func withLookupQueryMetadata(ctx context.Context, qType uint16) context.Context { ctx, metadata := adapter.ExtendContext(ctx) metadata.QueryType = qType @@ -644,15 +568,16 @@ func filterAddressesByQueryType(addresses []netip.Addr, qType uint16) []netip.Ad } func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { + strategy := r.resolveLookupStrategy(options) lookupOptions := options - if options.LookupStrategy != C.DomainStrategyAsIS { - lookupOptions.Strategy = options.LookupStrategy + if strategy != C.DomainStrategyAsIS { + lookupOptions.Strategy = strategy } - if options.LookupStrategy == C.DomainStrategyIPv4Only { + if strategy == C.DomainStrategyIPv4Only { response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) return response.addresses, err } - if options.LookupStrategy == C.DomainStrategyIPv6Only { + if strategy == C.DomainStrategyIPv6Only { response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) return response.addresses, err } @@ -672,17 +597,10 @@ func (r *Router) lookupWithRules(ctx context.Context, domain string, options ada return err }) err := group.Run(ctx) - sortStrategy, filterStrategy := r.resolveLookupOutputStrategies(options, response4.explicitStrategy, response6.explicitStrategy) - if !lookupStrategyAllowsQueryType(filterStrategy, mDNS.TypeA) { - response4.addresses = nil - } - if !lookupStrategyAllowsQueryType(filterStrategy, mDNS.TypeAAAA) { - response6.addresses = nil - } if len(response4.addresses) == 0 && len(response6.addresses) == 0 { return nil, err } - return sortAddresses(response4.addresses, response6.addresses, sortStrategy), nil + return sortAddresses(response4.addresses, response6.addresses, strategy), nil } func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { @@ -697,11 +615,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u }}, } exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), request, options, false) - explicitStrategy := lookupStrategyOverride(exchangeResult.queryOptions, exchangeResult.strategyOverridden) - result := lookupWithRulesResponse{ - strategy: r.resolveLookupStrategy(options, explicitStrategy), - explicitStrategy: explicitStrategy, - } + result := lookupWithRulesResponse{} if exchangeResult.rejectAction != nil { return result, exchangeResult.rejectAction.Error(ctx) } @@ -711,7 +625,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u if exchangeResult.response.Rcode != mDNS.RcodeSuccess { return result, RcodeError(exchangeResult.response.Rcode) } - if !lookupStrategyAllowsQueryType(result.strategy, qType) { + if !lookupStrategyAllowsQueryType(r.resolveLookupStrategy(options), qType) { return result, nil } result.addresses = filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType) @@ -761,7 +675,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte options.Strategy = r.defaultDomainStrategy } response, err = r.client.Exchange(ctx, transport, message, options, nil) - } else if !r.legacyAddressFilterMode { + } else if !r.legacyDNSMode { exchangeResult := r.exchangeWithRules(ctx, message, options, true) response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { @@ -878,7 +792,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ options.Strategy = r.defaultDomainStrategy } responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) - } else if !r.legacyAddressFilterMode { + } else if !r.legacyDNSMode { responseAddrs, err = r.lookupWithRules(ctx, domain, options) } else { var ( @@ -975,7 +889,7 @@ func (r *Router) ResetNetwork() { } } -func hasDirectLegacyAddressFilterItemsInDefaultRule(rule option.DefaultDNSRule) bool { +func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) bool { if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { return true } @@ -989,7 +903,7 @@ func hasResponseMatchFields(rule option.DefaultDNSRule) bool { len(rule.ResponseExtra) > 0 } -func defaultRuleForcesNewDNSPath(rule option.DefaultDNSRule) bool { +func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { return rule.MatchResponse || hasResponseMatchFields(rule) || rule.Action == C.RuleActionTypeEvaluate || @@ -997,76 +911,84 @@ func defaultRuleForcesNewDNSPath(rule option.DefaultDNSRule) bool { len(rule.QueryType) > 0 } -func resolveLegacyAddressFilterMode(router adapter.Router, rules []option.DNSRule) (bool, error) { - forceNew, needsLegacy, err := dnsRuleModeRequirements(router, rules) +func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule) (bool, error) { + legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirements(router, rules) if err != nil { return false, err } - if forceNew { + if legacyDNSModeDisabled && needsLegacyDNSModeFromStrategy { + return false, E.New("DNS rule action strategy is only supported in legacyDNSMode") + } + if legacyDNSModeDisabled { return false, nil } - return needsLegacy, nil + return needsLegacyDNSMode, nil } -func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (bool, bool, error) { - var forceNew bool - var needsLegacy bool +func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (bool, bool, bool, error) { + var legacyDNSModeDisabled bool + var needsLegacyDNSMode bool + var needsLegacyDNSModeFromStrategy bool for i, rule := range rules { - ruleForceNew, ruleNeedsLegacy, err := dnsRuleModeRequirementsInRule(router, rule) + ruleLegacyDNSModeDisabled, ruleNeedsLegacyDNSMode, ruleNeedsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirementsInRule(router, rule) if err != nil { - return false, false, E.Cause(err, "dns rule[", i, "]") + return false, false, false, E.Cause(err, "dns rule[", i, "]") } - forceNew = forceNew || ruleForceNew - needsLegacy = needsLegacy || ruleNeedsLegacy + legacyDNSModeDisabled = legacyDNSModeDisabled || ruleLegacyDNSModeDisabled + needsLegacyDNSMode = needsLegacyDNSMode || ruleNeedsLegacyDNSMode + needsLegacyDNSModeFromStrategy = needsLegacyDNSModeFromStrategy || ruleNeedsLegacyDNSModeFromStrategy } - return forceNew, needsLegacy, nil + return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil } -func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (bool, bool, error) { +func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (bool, bool, bool, error) { switch rule.Type { case "", C.RuleTypeDefault: return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions) case C.RuleTypeLogical: - forceNew := dnsRuleActionType(rule) == C.RuleActionTypeEvaluate - var needsLegacy bool + legacyDNSModeDisabled := dnsRuleActionType(rule) == C.RuleActionTypeEvaluate + needsLegacyDNSModeFromStrategy := dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction) + needsLegacyDNSMode := needsLegacyDNSModeFromStrategy for i, subRule := range rule.LogicalOptions.Rules { - subForceNew, subNeedsLegacy, err := dnsRuleModeRequirementsInRule(router, subRule) + subLegacyDNSModeDisabled, subNeedsLegacyDNSMode, subNeedsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirementsInRule(router, subRule) if err != nil { - return false, false, E.Cause(err, "sub rule[", i, "]") + return false, false, false, E.Cause(err, "sub rule[", i, "]") } - forceNew = forceNew || subForceNew - needsLegacy = needsLegacy || subNeedsLegacy + legacyDNSModeDisabled = legacyDNSModeDisabled || subLegacyDNSModeDisabled + needsLegacyDNSMode = needsLegacyDNSMode || subNeedsLegacyDNSMode + needsLegacyDNSModeFromStrategy = needsLegacyDNSModeFromStrategy || subNeedsLegacyDNSModeFromStrategy } - return forceNew, needsLegacy, nil + return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil default: - return false, false, nil + return false, false, false, nil } } -func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (bool, bool, error) { - forceNew := defaultRuleForcesNewDNSPath(rule) - needsLegacy := hasDirectLegacyAddressFilterItemsInDefaultRule(rule) +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (bool, bool, bool, error) { + legacyDNSModeDisabled := defaultRuleDisablesLegacyDNSMode(rule) + needsLegacyDNSModeFromStrategy := dnsRuleActionHasStrategy(rule.DNSRuleAction) + needsLegacyDNSMode := defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule) || needsLegacyDNSModeFromStrategy if len(rule.RuleSet) == 0 { - return forceNew, needsLegacy, nil + return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil } if router == nil { - return false, false, E.New("router service not found") + return false, false, false, E.New("router service not found") } for _, tag := range rule.RuleSet { ruleSet, loaded := router.RuleSet(tag) if !loaded { - return false, false, E.New("rule-set not found: ", tag) + return false, false, false, E.New("rule-set not found: ", tag) } metadata := ruleSet.Metadata() // Rule sets are built from headless rules, so query_type is the only // per-query DNS predicate they can contribute here. ip_version is not a // headless-rule item and is therefore intentionally absent from metadata. - forceNew = forceNew || metadata.ContainsDNSQueryTypeRule + legacyDNSModeDisabled = legacyDNSModeDisabled || metadata.ContainsDNSQueryTypeRule if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { - needsLegacy = true + needsLegacyDNSMode = true } } - return forceNew, needsLegacy, nil + return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil } func referencedDNSRuleSetTags(rules []option.DNSRule) []string { @@ -1096,9 +1018,9 @@ func referencedDNSRuleSetTags(rules []option.DNSRule) []string { return tags } -func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { +func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { for i, rule := range rules { - consumesResponse, err := validateNonLegacyAddressFilterRuleTree(rule) + consumesResponse, err := validateLegacyDNSModeDisabledRuleTree(rule) if err != nil { return E.Cause(err, "validate dns rule[", i, "]") } @@ -1110,14 +1032,14 @@ func validateNonLegacyAddressFilterRules(rules []option.DNSRule) error { return nil } -func validateNonLegacyAddressFilterRuleTree(rule option.DNSRule) (bool, error) { +func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { switch rule.Type { case "", C.RuleTypeDefault: - return validateNonLegacyAddressFilterDefaultRule(rule.DefaultOptions) + return validateLegacyDNSModeDisabledDefaultRule(rule.DefaultOptions) case C.RuleTypeLogical: var consumesResponse bool for i, subRule := range rule.LogicalOptions.Rules { - subConsumesResponse, err := validateNonLegacyAddressFilterRuleTree(subRule) + subConsumesResponse, err := validateLegacyDNSModeDisabledRuleTree(subRule) if err != nil { return false, E.Cause(err, "sub rule[", i, "]") } @@ -1129,13 +1051,13 @@ func validateNonLegacyAddressFilterRuleTree(rule option.DNSRule) (bool, error) { } } -func validateNonLegacyAddressFilterDefaultRule(rule option.DefaultDNSRule) (bool, error) { +func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, error) { hasResponseRecords := hasResponseMatchFields(rule) if hasResponseRecords && !rule.MatchResponse { return false, E.New("response_* items require match_response") } if (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { - return false, E.New("ip_cidr and ip_is_private require match_response in DNS evaluate mode") + return false, E.New("ip_cidr and ip_is_private require match_response when legacyDNSMode is disabled") } // Intentionally do not reject rule_set here. A referenced rule set may mix // destination-IP predicates with pre-response predicates such as domain items. @@ -1143,14 +1065,48 @@ func validateNonLegacyAddressFilterDefaultRule(rule option.DefaultDNSRule) (bool // pre-response evaluation instead of consuming DNS response state, while sibling // non-response branches remain matchable. if rule.IPAcceptAny { - return false, E.New("ip_accept_any is removed in DNS evaluate mode, use ip_cidr with match_response") + return false, E.New("ip_accept_any is removed when legacyDNSMode is disabled, use ip_cidr with match_response") } if rule.RuleSetIPCIDRAcceptEmpty { - return false, E.New("rule_set_ip_cidr_accept_empty is removed in DNS evaluate mode") + return false, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") } return rule.MatchResponse, nil } +func hasDNSRuleActionStrategy(rules []option.DNSRule) bool { + for _, rule := range rules { + if dnsRuleHasActionStrategy(rule) { + return true + } + } + return false +} + +func dnsRuleHasActionStrategy(rule option.DNSRule) bool { + switch rule.Type { + case "", C.RuleTypeDefault: + return dnsRuleActionHasStrategy(rule.DefaultOptions.DNSRuleAction) + case C.RuleTypeLogical: + if dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction) { + return true + } + return hasDNSRuleActionStrategy(rule.LogicalOptions.Rules) + default: + return false + } +} + +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: diff --git a/dns/router_test.go b/dns/router_test.go index cfd8dea3df..71cbfc960b 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -296,10 +296,10 @@ func fixedHTTPSHintResponse(question mDNS.Question, addresses ...netip.Addr) *mD return response } -func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { +func TestValidateLegacyDNSModeDisabledRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { t.Parallel() - err := validateNonLegacyAddressFilterRules([]option.DNSRule{{ + err := validateLegacyDNSModeDisabledRules([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ @@ -316,10 +316,10 @@ func TestValidateNewDNSRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } -func TestValidateNewDNSRules_AllowMatchResponseWithoutEvaluate(t *testing.T) { +func TestValidateLegacyDNSModeDisabledRules_AllowMatchResponseWithoutEvaluate(t *testing.T) { t.Parallel() - err := validateNonLegacyAddressFilterRules([]option.DNSRule{{ + err := validateLegacyDNSModeDisabledRules([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ @@ -419,7 +419,7 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") } -func TestLookupLegacyModeDefersRuleSetDestinationIPMatch(t *testing.T) { +func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { t.Parallel() ctx := context.Background() @@ -470,7 +470,7 @@ func TestLookupLegacyModeDefersRuleSetDestinationIPMatch(t *testing.T) { }, }) - require.True(t, router.legacyAddressFilterMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -566,7 +566,7 @@ func TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { }, }) - require.True(t, router.legacyAddressFilterMode) + require.True(t, router.legacyDNSMode) fakeSet.updateMetadata(adapter.RuleSetMetadata{ ContainsDNSQueryTypeRule: true, @@ -642,7 +642,7 @@ func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { require.NoError(t, router.runtimeRuleError) } -func TestLookupLegacyModeDefersDirectDestinationIPMatch(t *testing.T) { +func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -680,7 +680,7 @@ func TestLookupLegacyModeDefersDirectDestinationIPMatch(t *testing.T) { }, }, client) - require.True(t, router.legacyAddressFilterMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -689,7 +689,7 @@ func TestLookupLegacyModeDefersDirectDestinationIPMatch(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) } -func TestLookupLegacyModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T) { +func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -738,7 +738,7 @@ func TestLookupLegacyModeFallsBackAfterRejectedAddressLimitResponse(t *testing.T require.Equal(t, []string{"private", "default"}, lookups) } -func TestLookupLegacyModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testing.T) { +func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testing.T) { t.Parallel() ctx := context.Background() @@ -810,7 +810,7 @@ func TestLookupLegacyModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testin }, }) - require.True(t, router.legacyAddressFilterMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -831,7 +831,7 @@ func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T require.Equal(t, MessageToAddresses(response), adapter.DNSResponseAddresses(response)) } -func TestExchangeNewModeEvaluateMatchResponseRoute(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -890,7 +890,7 @@ func TestExchangeNewModeEvaluateMatchResponseRoute(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } -func TestExchangeNewModeEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -949,7 +949,7 @@ func TestExchangeNewModeEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } -func TestExchangeNewModeEvaluateMatchResponseRouteWithHTTPSHints(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -1008,7 +1008,7 @@ func TestExchangeNewModeEvaluateMatchResponseRouteWithHTTPSHints(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } -func TestExchangeNewModeEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -1079,7 +1079,7 @@ func TestExchangeNewModeEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } -func TestExchangeNewModeEvaluateRouteResolutionFailureClearsResponse(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsResponse(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -1152,7 +1152,7 @@ func TestExchangeNewModeEvaluateRouteResolutionFailureClearsResponse(t *testing. require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) } -func TestExchangeNewModeEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(t *testing.T) { t.Parallel() testCases := []struct { @@ -1235,7 +1235,7 @@ func TestExchangeNewModeEvaluateExchangeFailureUsesMatchResponseBooleanSemantics } } -func TestLookupNewModeAllowsPartialSuccess(t *testing.T) { +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1257,14 +1257,14 @@ func TestLookupNewModeAllowsPartialSuccess(t *testing.T) { } }, }) - router.legacyAddressFilterMode = false + 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 TestLookupNewModeSkipsFakeIPRule(t *testing.T) { +func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1294,14 +1294,14 @@ func TestLookupNewModeSkipsFakeIPRule(t *testing.T) { return FixedResponse(0, message.Question[0], nil, 60), nil }, }) - router.legacyAddressFilterMode = false + 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 TestLookupNewModeEvaluateSkipFakeIPPreservesResponse(t *testing.T) { +func TestLookupLegacyDNSModeDisabledEvaluateSkipFakeIPPreservesResponse(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1374,14 +1374,14 @@ func TestLookupNewModeEvaluateSkipFakeIPPreservesResponse(t *testing.T) { } }, }) - router.legacyAddressFilterMode = false + 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 TestLookupNewModeUsesQueryTypeRule(t *testing.T) { +func TestLookupLegacyDNSModeDisabledUsesQueryTypeRule(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1417,14 +1417,14 @@ func TestLookupNewModeUsesQueryTypeRule(t *testing.T) { } }, }) - require.False(t, router.legacyAddressFilterMode) + 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("9.9.9.9")}, addresses) } -func TestLookupNewModeUsesRuleSetQueryTypeRule(t *testing.T) { +func TestLookupLegacyDNSModeDisabledUsesRuleSetQueryTypeRule(t *testing.T) { t.Parallel() ctx := context.Background() @@ -1483,7 +1483,7 @@ func TestLookupNewModeUsesRuleSetQueryTypeRule(t *testing.T) { } }, }) - require.False(t, router.legacyAddressFilterMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -1493,7 +1493,7 @@ func TestLookupNewModeUsesRuleSetQueryTypeRule(t *testing.T) { }, addresses) } -func TestLookupNewModeUsesIPVersionRule(t *testing.T) { +func TestLookupLegacyDNSModeDisabledUsesIPVersionRule(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1532,73 +1532,113 @@ func TestLookupNewModeUsesIPVersionRule(t *testing.T) { } }, }) - require.False(t, router.legacyAddressFilterMode) + 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("3.3.3.3"), netip.MustParseAddr("2001:db8::9")}, addresses) } -func TestLookupNewModeAppliesRouteStrategyAfterEvaluate(t *testing.T) { +func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(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: "default"}, + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: &fakeDNSTransportManager{}, + client: &fakeDNSClient{}, + rawRules: make([]option.DNSRule, 0, 1), + rules: make([]adapter.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), }, }, }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, + }}) + require.ErrorContains(t, err, "legacyDNSMode") +} + +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), + rules: make([]adapter.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), }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{ - Server: "selected", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), - }, + }, + }, + }}) + require.ErrorContains(t, err, "legacyDNSMode") +} + +func TestLookupLegacyDNSModeUsesRouteStrategy(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + selectedTransport := &fakeDNSTransport{tag: "selected", 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: "selected", + Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), }, }, }, - }, &fakeDNSTransportManager{ + }}, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ "default": defaultTransport, - "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "selected": selectedTransport, }, }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - if transport.Tag() == "default" { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - switch message.Question[0].Qtype { - case mDNS.TypeA: - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil - case mDNS.TypeAAAA: - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - default: - return nil, errors.New("unexpected qtype") - } + lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + require.Equal(t, "selected", transport.Tag()) + require.Equal(t, C.DomainStrategyIPv4Only, options.Strategy) + return []netip.Addr{netip.MustParseAddr("2.2.2.2")}, nil, nil }, }) + require.True(t, router.legacyDNSMode) + 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 TestLookupNewModeReturnsRejectedErrorForRejectAction(t *testing.T) { +func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1623,7 +1663,7 @@ func TestLookupNewModeReturnsRejectedErrorForRejectAction(t *testing.T) { "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyAddressFilterMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.Nil(t, addresses) @@ -1631,7 +1671,7 @@ func TestLookupNewModeReturnsRejectedErrorForRejectAction(t *testing.T) { require.True(t, rulepkg.IsRejected(err)) } -func TestExchangeNewModeReturnsRefusedResponseForRejectAction(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1656,7 +1696,7 @@ func TestExchangeNewModeReturnsRefusedResponseForRejectAction(t *testing.T) { "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyAddressFilterMode) + require.False(t, router.legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -1666,7 +1706,7 @@ func TestExchangeNewModeReturnsRefusedResponseForRejectAction(t *testing.T) { require.Equal(t, []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, response.Question) } -func TestLookupNewModeFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { +func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1694,7 +1734,7 @@ func TestLookupNewModeFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyAddressFilterMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -1704,241 +1744,64 @@ func TestLookupNewModeFiltersPerQueryTypeAddressesBeforeMerging(t *testing.T) { }, addresses) } -func TestLookupNewModePrefersExplicitBranchStrategyOverDefault(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{ - MatchResponse: true, - ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{ - Server: "selected", - Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), - }, - }, - }, - }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, - "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "upstream": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - case "selected": - 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::2")}, 60), nil - case "default": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::4")}, 60), nil - default: - return nil, errors.New("unexpected transport") - } - }, - }) - router.defaultDomainStrategy = C.DomainStrategyIPv4Only - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("2001:db8::2")}, addresses) -} - -func TestLookupNewModeKeepsExplicitBranchStrategyMatchingInput(t *testing.T) { +func TestLookupLegacyDNSModeDisabledUsesInputStrategy(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{ - 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: "selected4", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), - }, - }, - }, - }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{ - Server: "selected6", - Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), - }, - }, - }, - }, - }, &fakeDNSTransportManager{ + var qTypes []uint16 + router := newTestRouter(t, nil, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, - "selected4": &fakeDNSTransport{tag: "selected4", transportType: C.DNSTypeUDP}, - "selected6": &fakeDNSTransport{tag: "selected6", transportType: C.DNSTypeUDP}, + "default": defaultTransport, }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "upstream": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - case "selected4": + 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 - case "selected6": - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil - default: - return nil, errors.New("unexpected transport") } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil }, }) - router.legacyAddressFilterMode = false + router.legacyDNSMode = false 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 TestLookupNewModeKeepsConflictingExplicitBranchStrategies(t *testing.T) { +func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(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{ - 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: "selected4", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), - }, - }, - }, - }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN AAAA 2001:db8::1")}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{ - Server: "selected6", - Strategy: option.DomainStrategy(C.DomainStrategyIPv6Only), - }, - }, - }, - }, - }, &fakeDNSTransportManager{ + var qTypes []uint16 + router := newTestRouter(t, nil, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, - "selected4": &fakeDNSTransport{tag: "selected4", transportType: C.DNSTypeUDP}, - "selected6": &fakeDNSTransport{tag: "selected6", transportType: C.DNSTypeUDP}, + "default": defaultTransport, }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "upstream": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::1")}, 60), nil - case "selected4": + 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 - case "selected6": - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil - default: - return nil, errors.New("unexpected transport") } + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil }, }) - router.legacyAddressFilterMode = false + router.defaultDomainStrategy = C.DomainStrategyIPv4Only + 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"), netip.MustParseAddr("2001:db8::2")}, addresses) + require.Equal(t, []uint16{mDNS.TypeA}, qTypes) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } -func TestExchangeNewModeLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -2007,7 +1870,7 @@ func TestExchangeNewModeLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) } -func TestOldModeReportsLegacyAddressFilterDeprecation(t *testing.T) { +func TestLegacyDNSModeReportsLegacyAddressFilterDeprecation(t *testing.T) { t.Parallel() manager := &fakeDeprecatedManager{} @@ -2038,3 +1901,38 @@ func TestOldModeReportsLegacyAddressFilterDeprecation(t *testing.T) { 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{}, + rules: make([]adapter.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.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/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index db9033f8b7..1c291445a0 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -34,7 +34,11 @@ Tag of target server. !!! question "Since sing-box 1.12.0" -Set domain strategy for this query. +!!! warning + + `strategy` is deprecated and only supported in `legacyDNSMode`. + +Set domain strategy for this query in `legacyDNSMode`. One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 9e59c6bd2b..f26691b14c 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -34,7 +34,11 @@ icon: material/new-box !!! question "自 sing-box 1.12.0 起" -为此查询设置域名策略。 +!!! warning + + `strategy` 已废弃,且仅在 `legacyDNSMode` 中可用。 + +在 `legacyDNSMode` 中为此查询设置域名策略。 可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 diff --git a/docs/deprecated.md b/docs/deprecated.md index 1e6d54eecc..0d2e78d0e4 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -14,6 +14,13 @@ check [Migration](../migration/#migrate-inline-acme-to-certificate-provider). Old fields will be removed in sing-box 1.16.0. +#### `strategy` in DNS rule actions + +`strategy` in DNS rule actions is deprecated +and only supported in `legacyDNSMode`. + +Old fields will be removed in sing-box 1.16.0. + ## 1.12.0 #### Legacy DNS server formats diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index f08491c461..81ed14e04e 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -117,6 +117,14 @@ var OptionLegacyDNSAddressFilter = Note{ MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", } +var OptionLegacyDNSRuleStrategy = Note{ + Name: "legacy-dns-rule-strategy", + Description: "`strategy` in DNS rule actions", + DeprecatedVersion: "1.14.0", + ScheduledVersion: "1.16.0", + MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule_action/", +} + var Options = []Note{ OptionOutboundDNSRuleItem, OptionMissingDomainResolver, @@ -125,4 +133,5 @@ var Options = []Note{ OptionIPAcceptAny, OptionRuleSetIPCIDRAcceptEmpty, OptionLegacyDNSAddressFilter, + OptionLegacyDNSRuleStrategy, } diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 6a84a4e77e..0cff492211 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -15,7 +15,7 @@ import ( "github.com/miekg/dns" ) -func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DNSRule, checkServer bool, legacyAddressFilter 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() { @@ -30,7 +30,7 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN return nil, E.New("missing server field") } } - return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyAddressFilter) + return NewDefaultDNSRule(ctx, logger, options.DefaultOptions, legacyDNSMode) case C.RuleTypeLogical: if !options.LogicalOptions.IsValid() { return nil, E.New("missing conditions") @@ -44,7 +44,7 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN return nil, E.New("missing server field") } } - return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyAddressFilter) + return NewLogicalDNSRule(ctx, logger, options.LogicalOptions, legacyDNSMode) default: return nil, E.New("unknown rule type: ", options.Type) } @@ -61,7 +61,7 @@ func (r *DefaultDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatch return r.abstractDefaultRule.matchStates(metadata) } -func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyAddressFilter bool) (*DefaultDNSRule, error) { +func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options option.DefaultDNSRule, legacyDNSMode bool) (*DefaultDNSRule, error) { rule := &DefaultDNSRule{ abstractDefaultRule: abstractDefaultRule{ invert: options.Invert, @@ -163,10 +163,10 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.allItems = append(rule.allItems, item) } if options.IPAcceptAny { - if legacyAddressFilter { + if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionIPAcceptAny) } else { - return nil, E.New("ip_accept_any is removed in DNS evaluate mode, use ip_cidr with match_response") + return nil, E.New("ip_accept_any is removed when legacyDNSMode is disabled, use ip_cidr with match_response") } item := NewIPAcceptAnyItem() rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) @@ -321,10 +321,10 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op matchSource = true } if options.RuleSetIPCIDRAcceptEmpty { - if legacyAddressFilter { + if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) } else { - return nil, E.New("rule_set_ip_cidr_accept_empty is removed in DNS evaluate mode") + return nil, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") } } item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) @@ -450,7 +450,7 @@ func (r *LogicalDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) r return stateSet } -func NewLogicalDNSRule(ctx context.Context, logger log.ContextLogger, options option.LogicalDNSRule, legacyAddressFilter bool) (*LogicalDNSRule, error) { +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)), @@ -467,7 +467,7 @@ 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, legacyAddressFilter) + rule, err := NewDNSRule(ctx, logger, subRule, false, legacyDNSMode) if err != nil { return nil, E.Cause(err, "sub rule[", i, "]") } From f02b50796a42e63413292bff8d3dcb17c9c4e590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 29 Mar 2026 01:34:27 +0800 Subject: [PATCH 24/67] option: reject nested rule actions --- dns/router.go | 6 + dns/router_test.go | 2 + option/dns_test.go | 1 + option/rule.go | 77 +++++++- option/rule_dns.go | 42 +++- option/rule_nested.go | 133 +++++++++++++ option/rule_nested_test.go | 271 ++++++++++++++++++++++++++ route/router.go | 4 + route/rule/nested_action.go | 76 ++++++++ route/rule/nested_action_test.go | 137 +++++++++++++ route/rule/rule_default.go | 4 + route/rule/rule_dns.go | 4 + route/rule/rule_item_rule_set_test.go | 5 + 13 files changed, 747 insertions(+), 15 deletions(-) create mode 100644 option/rule_nested.go create mode 100644 option/rule_nested_test.go create mode 100644 route/rule/nested_action.go create mode 100644 route/rule/nested_action_test.go diff --git a/dns/router.go b/dns/router.go index 13ffd0a0a4..ae8aed9874 100644 --- a/dns/router.go +++ b/dns/router.go @@ -202,6 +202,12 @@ func (r *Router) isClosing() bool { } func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { + for i, ruleOptions := range r.rawRules { + err := R.ValidateNoNestedDNSRuleActions(ruleOptions) + if err != nil { + return nil, false, E.Cause(err, "parse dns rule[", i, "]") + } + } router := service.FromContext[adapter.Router](r.ctx) legacyDNSMode, err := resolveLegacyDNSMode(router, r.rawRules) if err != nil { diff --git a/dns/router_test.go b/dns/router_test.go index 71cbfc960b..7c3c4b5fbd 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -135,6 +135,7 @@ func (s *fakeRuleSet) IncRef() { defer s.access.Unlock() s.refs++ } + func (s *fakeRuleSet) DecRef() { s.access.Lock() defer s.access.Unlock() @@ -149,6 +150,7 @@ func (s *fakeRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) * 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() diff --git a/option/dns_test.go b/option/dns_test.go index 7448a9e2b0..12ee0bca3b 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -7,6 +7,7 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/json" "github.com/sagernet/sing/service" + "github.com/stretchr/testify/require" ) 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_dns.go b/option/rule_dns.go index 5a73d69fd5..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 } @@ -135,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 } - return badjson.UnmarshallExcludedContext(ctx, data, &r.RawDefaultDNSRule, &r.DNSRuleAction) + 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 + } + if depth > 0 && rawAction == "" && routeOptions == (DNSRouteActionOptions{}) { + r.DNSRuleAction = DNSRuleAction{} + } + return nil } func (r DefaultDNSRule) IsValid() bool { @@ -164,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..c6038aad2f --- /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..ca0ff32dfd --- /dev/null +++ b/option/rule_nested_test.go @@ -0,0 +1,271 @@ +package option + +import ( + "context" + "testing" + + C "github.com/sagernet/sing-box/constant" + "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 TestRuleRejectsNestedLogicalRuleAction(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "action": "route", + "outbound": "direct", + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) +} + +func TestRuleRejectsNestedDefaultRuleZeroValueOutbound(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "outbound": ""} + ] + }`), &rule) + require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) +} + +func TestRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "udp_connect": false} + ] + }`), &rule) + require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) +} + +func TestRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "action": "", + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) +} + +func TestRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "override_port": 0, + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) +} + +func TestRuleAllowsTopLevelLogicalAction(t *testing.T) { + t.Parallel() + + var rule Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "outbound": "direct", + "rules": [{"domain": "example.com"}] + }`), &rule) + require.NoError(t, err) + require.Equal(t, C.RuleActionTypeRoute, rule.LogicalOptions.Action) + require.Equal(t, "direct", rule.LogicalOptions.RouteOptions.Outbound) +} + +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 TestDNSRuleRejectsNestedLogicalRuleAction(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "action": "route", + "server": "default", + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedDefaultRuleZeroValueServer(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "server": ""} + ] + }`), &rule) + require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com", "disable_cache": false} + ] + }`), &rule) + require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "action": "", + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + { + "type": "logical", + "mode": "or", + "disable_cache": false, + "rules": [{"domain": "example.com"}] + } + ] + }`), &rule) + require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) +} + +func TestDNSRuleAllowsTopLevelLogicalAction(t *testing.T) { + t.Parallel() + + var rule DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "server": "default", + "rules": [{"domain": "example.com"}] + }`), &rule) + require.NoError(t, err) + require.Equal(t, C.RuleActionTypeRoute, rule.LogicalOptions.Action) + require.Equal(t, "default", rule.LogicalOptions.RouteOptions.Server) +} + +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/nested_action.go b/route/rule/nested_action.go new file mode 100644 index 0000000000..95bb572155 --- /dev/null +++ b/route/rule/nested_action.go @@ -0,0 +1,76 @@ +package rule + +import ( + "reflect" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/option" + E "github.com/sagernet/sing/common/exceptions" +) + +const ( + routeRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" + dnsRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" +) + +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(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(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/nested_action_test.go b/route/rule/nested_action_test.go new file mode 100644 index 0000000000..b21828c04a --- /dev/null +++ b/route/rule/nested_action_test.go @@ -0,0 +1,137 @@ +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/sagernet/sing/common/json" + + "github.com/stretchr/testify/require" +) + +func TestNewRulePreservesImplicitTopLevelDefaultAction(t *testing.T) { + t.Parallel() + + var options option.Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "domain": "example.com" + }`), &options) + require.NoError(t, err) + + rule, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), options, false) + require.NoError(t, err) + require.NotNil(t, rule.Action()) + require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) +} + +func TestNewRuleAllowsNestedRuleWithoutAction(t *testing.T) { + t.Parallel() + + var options option.Rule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com"} + ] + }`), &options) + require.NoError(t, err) + + rule, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), options, false) + require.NoError(t, err) + require.NotNil(t, rule.Action()) + require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) +} + +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, routeRuleActionNestedUnsupportedMessage) +} + +func TestNewDNSRulePreservesImplicitTopLevelDefaultAction(t *testing.T) { + t.Parallel() + + var options option.DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "domain": "example.com" + }`), &options) + require.NoError(t, err) + + rule, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), options, false, false) + require.NoError(t, err) + require.NotNil(t, rule.Action()) + require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) +} + +func TestNewDNSRuleAllowsNestedRuleWithoutAction(t *testing.T) { + t.Parallel() + + var options option.DNSRule + err := json.UnmarshalContext(context.Background(), []byte(`{ + "type": "logical", + "mode": "and", + "rules": [ + {"domain": "example.com"} + ] + }`), &options) + require.NoError(t, err) + + rule, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), options, false, false) + require.NoError(t, err) + require.NotNil(t, rule.Action()) + require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) +} + +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, dnsRuleActionNestedUnsupportedMessage) +} 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 0cff492211..ebc556c0e3 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -467,6 +467,10 @@ 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 { + 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, "]") diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index 3d5959a392..a73ed91f59 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -25,16 +25,21 @@ 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 From ccad6d4595ce5cbc8556aa1d44ce30ab7eb2fb32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 29 Mar 2026 12:58:46 +0800 Subject: [PATCH 25/67] Use typed SVCB hint structs instead of string parsing --- adapter/inbound.go | 13 ++++--- adapter/inbound_test.go | 45 +++++++++++++++++++++++ dns/router_test.go | 79 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 adapter/inbound_test.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 048699f6d6..d13adb5cce 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -4,13 +4,11 @@ import ( "context" "net" "net/netip" - "strings" "time" C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" M "github.com/sagernet/sing/common/metadata" "github.com/miekg/dns" @@ -145,8 +143,15 @@ func DNSResponseAddresses(response *dns.Msg) []netip.Addr { addresses = append(addresses, M.AddrFromIP(record.AAAA)) case *dns.HTTPS: for _, value := range record.SVCB.Value { - if value.Key() == dns.SVCB_IPV4HINT || value.Key() == dns.SVCB_IPV6HINT { - addresses = append(addresses, common.Map(strings.Split(value.String(), ","), M.ParseAddr)...) + switch hint := value.(type) { + case *dns.SVCBIPv4Hint: + for _, ip := range hint.Hint { + addresses = append(addresses, M.AddrFromIP(ip).Unmap()) + } + case *dns.SVCBIPv6Hint: + for _, ip := range hint.Hint { + addresses = append(addresses, M.AddrFromIP(ip)) + } } } } 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/dns/router_test.go b/dns/router_test.go index 7c3c4b5fbd..46ddcd028c 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -298,6 +298,26 @@ func fixedHTTPSHintResponse(question mDNS.Question, addresses ...netip.Addr) *mD return response } +func fixedHTTPSHintResponseWithRawHints(question mDNS.Question, ipv4Hints []net.IP, ipv6Hints []net.IP) *mDNS.Msg { + response := fixedHTTPSHintResponse(question) + https := response.Answer[0].(*mDNS.HTTPS) + if len(ipv4Hints) > 0 { + hints := make([]net.IP, 0, len(ipv4Hints)) + for _, ip := range ipv4Hints { + hints = append(hints, net.IP(append([]byte(nil), ip...))) + } + https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv4Hint{Hint: hints}) + } + if len(ipv6Hints) > 0 { + hints := make([]net.IP, 0, len(ipv6Hints)) + for _, ip := range ipv6Hints { + hints = append(hints, net.IP(append([]byte(nil), ip...))) + } + https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv6Hint{Hint: hints}) + } + return response +} + func TestValidateLegacyDNSModeDisabledRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { t.Parallel() @@ -1010,6 +1030,65 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } +func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithMappedHTTPSIPv4Hints(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 fixedHTTPSHintResponseWithRawHints(message.Question[0], []net.IP{net.ParseIP("1.1.1.1")}, nil), nil + case "selected": + return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil + default: + return nil, errors.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, + 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.TypeHTTPS)}, + }, 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() From 0a85afef25e37f3bc83a3d07e6ec2e1c6edb6cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 29 Mar 2026 14:15:47 +0800 Subject: [PATCH 26/67] docs: add evaluate action, response matching fields, and deprecation notices --- docs/configuration/dns/rule.md | 86 +++++++++++++++++++++--- docs/configuration/dns/rule.zh.md | 83 ++++++++++++++++++++--- docs/configuration/dns/rule_action.md | 47 +++++++++++++ docs/configuration/dns/rule_action.zh.md | 45 +++++++++++++ docs/deprecated.md | 23 +++++++ docs/deprecated.zh.md | 30 +++++++++ 6 files changed, 297 insertions(+), 17 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 0b3e56da69..4ceecf9270 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -4,8 +4,15 @@ icon: material/alert-decagram !!! quote "Changes in sing-box 1.14.0" + :material-plus: [match_response](#match_response) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) + :material-plus: [source_hostname](#source_hostname) + :material-delete-clock: [ip_accept_any](#ip_accept_any) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) !!! 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,17 @@ 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. + +Required for `response_rcode`, `response_answer`, `response_ns`, `response_extra` fields. +Also required for `ip_cidr` and `ip_is_private` when `legacyDNSMode` is disabled. + #### invert Invert match result. @@ -547,24 +570,69 @@ Match GeoIP with query response. Match IP CIDR with query response. +When `legacyDNSMode` is disabled, `match_response` must be set to `true`. + #### ip_is_private !!! question "Since sing-box 1.9.0" Match private IP with query response. +When `legacyDNSMode` is disabled, `match_response` must be set to `true`. + #### 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. + Only supported in `legacyDNSMode`. + 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. + Only supported in `legacyDNSMode`. Use `match_response` with response items instead. + Match any IP with query response. +### Response 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. + +#### 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..b47e599587 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -4,8 +4,15 @@ icon: material/alert-decagram !!! quote "sing-box 1.14.0 中的更改" + :material-plus: [match_response](#match_response) + :material-plus: [response_rcode](#response_rcode) + :material-plus: [response_answer](#response_answer) + :material-plus: [response_ns](#response_ns) + :material-plus: [response_extra](#response_extra) :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) + :material-plus: [source_hostname](#source_hostname) + :material-delete-clock: [ip_accept_any](#ip_accept_any) + :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) !!! 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,15 @@ 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) 动作设置),而不仅是匹配原始查询。 + +`response_rcode`、`response_answer`、`response_ns`、`response_extra` 字段需要此选项。 +当 `legacyDNSMode` 未启用时,`ip_cidr` 和 `ip_is_private` 也需要此选项。 + #### invert 反选匹配结果。 @@ -547,24 +569,69 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配 IP CIDR。 +当 `legacyDNSMode` 未启用时,`match_response` 必须设为 `true`。 + #### ip_is_private !!! question "自 sing-box 1.9.0 起" 与查询响应匹配非公开 IP。 +当 `legacyDNSMode` 未启用时,`match_response` 必须设为 `true`。 + #### ip_accept_any !!! question "自 sing-box 1.12.0 起" +!!! failure "已在 sing-box 1.14.0 废弃" + + `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除。 + 仅在 `legacyDNSMode` 中可用。请使用 `match_response` 和响应项替代。 + 匹配任意 IP。 #### 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 中被移除。 + 仅在 `legacyDNSMode` 中可用。 + 使规则集中的 `ip_cidr` 规则接受空查询响应。 +### 响应字段 + +!!! question "自 sing-box 1.14.0 起" + +DNS 响应数据的匹配字段。需要将 `match_response` 设为 `true`, +且需要前序规则使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作来填充响应。 + +#### 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) 中相同。 + ### 逻辑字段 #### type diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 1c291445a0..7f59c6b123 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [evaluate](#evaluate) + :material-delete-clock: [strategy](#strategy) + !!! quote "Changes in sing-box 1.12.0" :material-plus: [strategy](#strategy) @@ -58,6 +63,48 @@ If value is an IP address instead of prefix, `/32` or `/128` will be appended au Will overrides `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 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). + +#### 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 overrides `dns.client_subnet`. + ### route-options ```json diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index f26691b14c..539e1aedf9 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -2,6 +2,11 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [evaluate](#evaluate) + :material-delete-clock: [strategy](#strategy) + !!! quote "sing-box 1.12.0 中的更改" :material-plus: [strategy](#strategy) @@ -58,6 +63,46 @@ 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 规则中使用(不可在逻辑子规则内部使用)。 + +#### server + +==必填== + +目标 DNS 服务器的标签。 + +#### disable_cache + +在此查询中禁用缓存。 + +#### rewrite_ttl + +重写 DNS 回应中的 TTL。 + +#### client_subnet + +默认情况下,将带有指定 IP 前缀的 `edns0-subnet` OPT 附加记录附加到每个查询。 + +如果值是 IP 地址而不是前缀,则会自动附加 `/32` 或 `/128`。 + +将覆盖 `dns.client_subnet`. + ### route-options ```json diff --git a/docs/deprecated.md b/docs/deprecated.md index 0d2e78d0e4..2e48c5c01d 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -21,6 +21,29 @@ and only supported in `legacyDNSMode`. Old fields will be removed in sing-box 1.16.0. +#### `ip_accept_any` in DNS rules + +`ip_accept_any` in DNS rules is deprecated +and only supported in `legacyDNSMode`. +Use `match_response` with response items instead. + +Old fields will be removed in sing-box 1.16.0. + +#### `rule_set_ip_cidr_accept_empty` in DNS rules + +`rule_set_ip_cidr_accept_empty` in DNS rules is deprecated +and only supported in `legacyDNSMode`. + +Old fields will be removed in sing-box 1.16.0. + +#### Legacy address filter DNS rule items + +Legacy address filter DNS rule items (`ip_cidr`, `ip_is_private` without `match_response`) +are deprecated and only supported in `legacyDNSMode`. +Use `match_response` with the `evaluate` action instead. + +Old behavior will be removed in sing-box 1.16.0. + ## 1.12.0 #### Legacy DNS server formats diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 459979cb81..43806920c6 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -14,6 +14,36 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, 旧字段将在 sing-box 1.16.0 中被移除。 +#### DNS 规则动作中的 `strategy` + +DNS 规则动作中的 `strategy` 已废弃, +且仅在 `legacyDNSMode` 中可用。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### DNS 规则中的 `ip_accept_any` + +DNS 规则中的 `ip_accept_any` 已废弃, +且仅在 `legacyDNSMode` 中可用。 +请使用 `match_response` 和响应项替代。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### DNS 规则中的 `rule_set_ip_cidr_accept_empty` + +DNS 规则中的 `rule_set_ip_cidr_accept_empty` 已废弃, +且仅在 `legacyDNSMode` 中可用。 + +旧字段将在 sing-box 1.16.0 中被移除。 + +#### 旧的地址筛选 DNS 规则项 + +旧的地址筛选 DNS 规则项(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +且仅在 `legacyDNSMode` 中可用。 +请使用 `match_response` 和 `evaluate` 动作替代。 + +旧行为将在 sing-box 1.16.0 中被移除。 + ## 1.12.0 #### 旧的 DNS 服务器格式 From 805f073ffb6012c66cd47ad8fcbfafdbe2275c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 29 Mar 2026 14:41:47 +0800 Subject: [PATCH 27/67] Suppress SA1019 lint warnings for intentional deprecated field usage --- dns/router.go | 6 +++--- route/rule/rule_dns.go | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/dns/router.go b/dns/router.go index ae8aed9874..b30f5a0c8c 100644 --- a/dns/router.go +++ b/dns/router.go @@ -896,7 +896,7 @@ func (r *Router) ResetNetwork() { } func defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule option.DefaultDNSRule) bool { - if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { + if rule.IPAcceptAny || rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck return true } return !rule.MatchResponse && (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) @@ -1070,10 +1070,10 @@ func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, // 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 { + if rule.IPAcceptAny { //nolint:staticcheck return false, E.New("ip_accept_any is removed when legacyDNSMode is disabled, use ip_cidr with match_response") } - if rule.RuleSetIPCIDRAcceptEmpty { + if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck return false, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") } return rule.MatchResponse, nil diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index ebc556c0e3..f95fa66998 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -127,7 +127,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 { @@ -162,7 +162,7 @@ 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 { @@ -320,14 +320,14 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op if options.RuleSetIPCIDRMatchSource { matchSource = true } - if options.RuleSetIPCIDRAcceptEmpty { + if options.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) } else { return nil, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") } } - item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) + item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) //nolint:staticcheck rule.ruleSetItem = item rule.allItems = append(rule.allItems, item) } From b44cf247458c148a78cfedac0e52b15021df8f74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 10:24:40 +0800 Subject: [PATCH 28/67] docs: fix grammar errors and typos --- docs/configuration/dns/index.md | 2 +- docs/configuration/dns/rule_action.md | 4 ++-- docs/configuration/dns/rule_action.zh.md | 2 +- docs/configuration/dns/server/legacy.md | 6 +++--- docs/configuration/route/index.md | 4 ++-- docs/configuration/route/rule_action.md | 2 +- 6 files changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/configuration/dns/index.md b/docs/configuration/dns/index.md index 4b9702a598..cbb58906f1 100644 --- a/docs/configuration/dns/index.md +++ b/docs/configuration/dns/index.md @@ -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/rule_action.md b/docs/configuration/dns/rule_action.md index 7f59c6b123..7cc6aba6d4 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -61,7 +61,7 @@ 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 @@ -103,7 +103,7 @@ 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`. ### route-options diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 539e1aedf9..ab0c2e1bdd 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -133,7 +133,7 @@ icon: material/new-box - `default`: 返回 REFUSED。 - `drop`: 丢弃请求。 -默认使用 `defualt`。 +默认使用 `default`。 #### no_drop diff --git a/docs/configuration/dns/server/legacy.md b/docs/configuration/dns/server/legacy.md index 82bd63e73d..fb498a8e6b 100644 --- a/docs/configuration/dns/server/legacy.md +++ b/docs/configuration/dns/server/legacy.md @@ -4,7 +4,7 @@ icon: material/note-remove !!! failure "Removed in sing-box 1.14.0" - Legacy DNS servers is deprecated in sing-box 1.12.0 and 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-servers). !!! 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/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`. From bd222fe9dfdc6ac8e5cc4780e9493b5c87900a54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 13:15:25 +0800 Subject: [PATCH 29/67] dns: serialize rebuilds and keep last good rules on failure --- dns/router.go | 37 ++- dns/router_test.go | 601 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 561 insertions(+), 77 deletions(-) diff --git a/dns/router.go b/dns/router.go index b30f5a0c8c..8e2424b9fd 100644 --- a/dns/router.go +++ b/dns/router.go @@ -50,9 +50,9 @@ type Router struct { platformInterface adapter.PlatformInterface legacyDNSMode bool rulesAccess sync.RWMutex + rebuildAccess sync.Mutex closing bool ruleSetCallbacks []dnsRuleSetCallback - runtimeRuleError error addressFilterDeprecatedReported bool ruleStrategyDeprecatedReported bool } @@ -116,11 +116,19 @@ func (r *Router) Start(stage adapter.StartStage) error { return err } monitor.Start("register DNS rule-set callbacks") - err = r.registerRuleSetCallbacks() + needsRulesRefresh, err := r.registerRuleSetCallbacks() monitor.Finish() if err != nil { return err } + if needsRulesRefresh { + monitor.Start("refresh DNS rules after callback registration") + err = r.rebuildRules(true) + monitor.Finish() + if err != nil { + r.logger.Error(E.Cause(err, "refresh DNS rules after callback registration")) + } + } } return nil } @@ -133,7 +141,6 @@ func (r *Router) Close() error { r.ruleSetCallbacks = nil runtimeRules := r.rules r.rules = nil - r.runtimeRuleError = nil for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } @@ -150,6 +157,8 @@ func (r *Router) Close() error { } func (r *Router) rebuildRules(startRules bool) error { + r.rebuildAccess.Lock() + defer r.rebuildAccess.Unlock() if r.isClosing() { return nil } @@ -177,7 +186,6 @@ func (r *Router) rebuildRules(startRules bool) error { oldRules := r.rules r.rules = newRules r.legacyDNSMode = legacyDNSMode - r.runtimeRuleError = nil if shouldReportAddressFilterDeprecated { r.addressFilterDeprecatedReported = true } @@ -246,20 +254,20 @@ func closeRules(rules []adapter.DNSRule) { } } -func (r *Router) registerRuleSetCallbacks() error { +func (r *Router) registerRuleSetCallbacks() (bool, error) { tags := referencedDNSRuleSetTags(r.rawRules) if len(tags) == 0 { - return nil + return false, nil } r.rulesAccess.RLock() if len(r.ruleSetCallbacks) > 0 { r.rulesAccess.RUnlock() - return nil + return true, nil } r.rulesAccess.RUnlock() router := service.FromContext[adapter.Router](r.ctx) if router == nil { - return E.New("router service not found") + return false, E.New("router service not found") } callbacks := make([]dnsRuleSetCallback, 0, len(tags)) for _, tag := range tags { @@ -268,14 +276,11 @@ func (r *Router) registerRuleSetCallbacks() error { for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } - return E.New("rule-set not found: ", tag) + return false, E.New("rule-set not found: ", tag) } element := ruleSet.RegisterCallback(func(adapter.RuleSet) { err := r.rebuildRules(true) if err != nil { - r.rulesAccess.Lock() - r.runtimeRuleError = err - r.rulesAccess.Unlock() r.logger.Error(E.Cause(err, "rebuild DNS rules after rule-set update")) } }) @@ -293,7 +298,7 @@ func (r *Router) registerRuleSetCallbacks() error { for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } - return nil + return true, nil } func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { @@ -653,9 +658,6 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } r.rulesAccess.RLock() defer r.rulesAccess.RUnlock() - if r.runtimeRuleError != nil { - return nil, r.runtimeRuleError - } r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) var ( response *mDNS.Msg @@ -760,9 +762,6 @@ 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.runtimeRuleError != nil { - return nil, r.runtimeRuleError - } var ( responseAddrs []netip.Addr err error diff --git a/dns/router_test.go b/dns/router_test.go index 46ddcd028c..37ebcbc02a 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -2,9 +2,10 @@ package dns import ( "context" - "errors" + "io" "net" "net/netip" + "strings" "sync" "testing" "time" @@ -16,6 +17,8 @@ import ( "github.com/sagernet/sing-box/option" rulepkg "github.com/sagernet/sing-box/route/rule" "github.com/sagernet/sing-tun" + "github.com/sagernet/sing/common" + 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" @@ -38,7 +41,7 @@ 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, errors.New("unused transport exchange") + return nil, E.New("unused transport exchange") } type fakeDNSTransportManager struct { @@ -66,7 +69,7 @@ func (m *fakeDNSTransportManager) FakeIP() adapter.FakeIPTransport { } func (m *fakeDNSTransportManager) Remove(string) error { return nil } func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, string, string, any) error { - return errors.New("unsupported") + return E.New("unsupported") } type fakeDNSClient struct { @@ -80,6 +83,7 @@ type fakeDeprecatedManager struct { } type fakeRouter struct { + access sync.RWMutex ruleSets map[string]adapter.RuleSet } @@ -104,9 +108,19 @@ func (r *fakeRouter) RoutePacketConnectionEx(context.Context, N.PacketConn, adap } 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 } @@ -115,10 +129,14 @@ func (r *fakeRouter) AppendTracker(adapter.ConnectionTracker) {} func (r *fakeRouter) ResetNetwork() {} type fakeRuleSet struct { - access sync.Mutex - metadata adapter.RuleSetMetadata - callbacks list.List[adapter.RuleSetUpdateCallback] - refs int + 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" } @@ -126,17 +144,32 @@ func (s *fakeRuleSet) StartContext(context.Context, *adapter.HTTPStartContext) e func (s *fakeRuleSet) PostStart() error { return nil } func (s *fakeRuleSet) Metadata() adapter.RuleSetMetadata { s.access.Lock() - defer s.access.Unlock() - return s.metadata + 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() - defer s.access.Unlock() 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-- @@ -156,9 +189,17 @@ func (s *fakeRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUp defer s.access.Unlock() s.callbacks.Remove(element) } -func (s *fakeRuleSet) Close() error { return nil } -func (s *fakeRuleSet) Match(*adapter.InboundContext) bool { return true } -func (s *fakeRuleSet) String() string { return "fake-rule-set" } +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 @@ -196,7 +237,7 @@ func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTrans func (c *fakeDNSClient) Lookup(_ context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { if c.lookup == nil { - return nil, errors.New("unused client lookup") + return nil, E.New("unused client lookup") } addresses, response, err := c.lookup(transport, domain, options) if err != nil { @@ -221,10 +262,14 @@ func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeD } 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: log.NewNOPFactory().NewLogger("dns"), + logger: dnsLogger, transport: transportManager, client: client, rawRules: make([]option.DNSRule, 0, len(rules)), @@ -240,6 +285,26 @@ func newTestRouterWithContext(t *testing.T, ctx context.Context, rules []option. 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), @@ -541,17 +606,45 @@ func TestRuleSetUpdateReleasesOldRuleSetRefs(t *testing.T) { require.Zero(t, fakeSet.refCount()) } -func TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { +func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t *testing.T) { t.Parallel() - fakeSet := &fakeRuleSet{} - ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + callbackRuleSet := &fakeRuleSet{ + match: func(*adapter.InboundContext) bool { + return false + }, + } + routerService := &fakeRouter{ ruleSets: map[string]adapter.RuleSet{ - "dynamic-set": fakeSet, + "dynamic-set": callbackRuleSet, }, - }) + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + preservedTransport := &fakeDNSTransport{tag: "preserved", transportType: C.DNSTypeUDP} + wouldBeNewTransport := &fakeDNSTransport{tag: "would-be-new", transportType: C.DNSTypeUDP} + loggerFactory := log.NewDefaultFactory( + context.Background(), + log.Formatter{ + BaseTime: time.Now(), + DisableColors: true, + DisableTimestamp: true, + }, + io.Discard, + "", + nil, + true, + ) + loggerFactory.SetLevel(log.LevelError) + logEntries, logDone, err := loggerFactory.Subscribe() + require.NoError(t, err) + t.Cleanup(func() { + loggerFactory.UnSubscribe(logEntries) + closeErr := loggerFactory.Close() + require.NoError(t, closeErr) + }) + var lastUsedTransport common.TypedValue[string] + router := newTestRouterWithContextAndLogger(t, ctx, []option.DNSRule{ { Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -560,7 +653,19 @@ func TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + RouteOptions: option.DNSRouteActionOptions{Server: "would-be-new"}, + }, + }, + }, + { + 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: "preserved"}, }, }, }, @@ -572,30 +677,367 @@ func TestRuleSetUpdateSetsRuntimeErrorWhenRebuildFails(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, + RouteOptions: option.DNSRouteActionOptions{Server: "preserved"}, }, }, }, }, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, + "default": defaultTransport, + "preserved": preservedTransport, + "would-be-new": wouldBeNewTransport, }, }, &fakeDNSClient{ lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + lastUsedTransport.Store(transport.Tag()) response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) return MessageToAddresses(response), response, nil }, + }, loggerFactory.NewLogger("dns")) + t.Cleanup(func() { + closeErr := router.Close() + require.NoError(t, closeErr) }) require.True(t, router.legacyDNSMode) + require.Equal(t, 1, callbackRuleSet.refCount()) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Equal(t, "preserved", lastUsedTransport.Load()) - fakeSet.updateMetadata(adapter.RuleSetMetadata{ + rebuildTargetRuleSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }, + match: func(*adapter.InboundContext) bool { + return true + }, + } + routerService.setRuleSet("dynamic-set", rebuildTargetRuleSet) + + callbackRuleSet.updateMetadata(adapter.RuleSetMetadata{ ContainsDNSQueryTypeRule: true, }) + rebuildErrorEntry := waitForLogMessageContaining(t, logEntries, logDone, "rebuild DNS rules after rule-set update") + require.Contains(t, rebuildErrorEntry.Message, "ip_cidr and ip_is_private require match_response") + require.True(t, router.legacyDNSMode) + require.Equal(t, 1, callbackRuleSet.refCount()) + require.Zero(t, rebuildTargetRuleSet.refCount()) - _, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") + lastUsedTransport.Store("") + addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Equal(t, "preserved", lastUsedTransport.Load()) + require.NotEqual(t, "would-be-new", lastUsedTransport.Load()) +} + +func TestRuleSetUpdateSerializesConcurrentRebuilds(t *testing.T) { + t.Parallel() + + callbackRuleSet := &fakeRuleSet{ + match: func(*adapter.InboundContext) bool { + return false + }, + } + routerService := &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": callbackRuleSet, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + firstTransport := &fakeDNSTransport{tag: "first", transportType: C.DNSTypeUDP} + secondTransport := &fakeDNSTransport{tag: "second", transportType: C.DNSTypeUDP} + var lastUsedTransport common.TypedValue[string] + 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: "first"}, + }, + }, + }, + { + 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: "second"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "first": firstTransport, + "second": secondTransport, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + lastUsedTransport.Store(transport.Tag()) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60), nil + }, + }) + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Equal(t, "second", lastUsedTransport.Load()) + + callbacks := callbackRuleSet.snapshotCallbacks() + require.Len(t, callbacks, 1) + + firstMetadataEntered := make(chan struct{}) + releaseFirstMetadata := make(chan struct{}) + firstRuleSetStarted := make(chan struct{}) + releaseFirstRuleSetStart := make(chan struct{}) + secondMetadataEntered := make(chan struct{}) + releaseSecondMetadata := make(chan struct{}) + + var metadataAccess sync.Mutex + var metadataCallCount int + var concurrentMetadataCalls int + var maximumConcurrentMetadataCalls int + + recordMetadataEntry := func() func() { + metadataAccess.Lock() + metadataCallCount++ + concurrentMetadataCalls++ + if concurrentMetadataCalls > maximumConcurrentMetadataCalls { + maximumConcurrentMetadataCalls = concurrentMetadataCalls + } + metadataAccess.Unlock() + return func() { + metadataAccess.Lock() + concurrentMetadataCalls-- + metadataAccess.Unlock() + } + } + + firstBuildRuleSet := &fakeRuleSet{ + match: func(*adapter.InboundContext) bool { + return true + }, + metadataRead: func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { + metadataDone := recordMetadataEntry() + close(firstMetadataEntered) + <-releaseFirstMetadata + metadataDone() + return metadata + }, + afterIncrementReference: func() { + close(firstRuleSetStarted) + <-releaseFirstRuleSetStart + }, + } + secondBuildRuleSet := &fakeRuleSet{ + match: func(*adapter.InboundContext) bool { + return false + }, + metadataRead: func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { + metadataDone := recordMetadataEntry() + close(secondMetadataEntered) + <-releaseSecondMetadata + metadataDone() + return metadata + }, + } + + routerService.setRuleSet("dynamic-set", firstBuildRuleSet) + + firstCallbackFinished := make(chan struct{}) + go func() { + callbacks[0](callbackRuleSet) + close(firstCallbackFinished) + }() + + select { + case <-firstMetadataEntered: + case <-time.After(time.Second): + t.Fatal("first rebuild did not reach rule-set metadata") + } + + close(releaseFirstMetadata) + + select { + case <-firstRuleSetStarted: + case <-time.After(time.Second): + t.Fatal("first rebuild did not reach rule-set start") + } + + routerService.setRuleSet("dynamic-set", secondBuildRuleSet) + + secondCallbackStarted := make(chan struct{}) + secondCallbackFinished := make(chan struct{}) + go func() { + close(secondCallbackStarted) + callbacks[0](callbackRuleSet) + close(secondCallbackFinished) + }() + + select { + case <-secondCallbackStarted: + case <-time.After(time.Second): + t.Fatal("second rebuild did not start") + } + + select { + case <-secondMetadataEntered: + t.Fatal("second rebuild entered rule-set metadata before the first rebuild completed") + default: + } + + close(releaseFirstRuleSetStart) + + select { + case <-firstCallbackFinished: + case <-time.After(time.Second): + t.Fatal("first rebuild callback did not finish") + } + + select { + case <-secondMetadataEntered: + case <-time.After(time.Second): + t.Fatal("second rebuild did not enter rule-set metadata after the first rebuild finished") + } + + addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Equal(t, "first", lastUsedTransport.Load()) + + close(releaseSecondMetadata) + + select { + case <-secondCallbackFinished: + case <-time.After(time.Second): + t.Fatal("second rebuild callback did not finish") + } + + metadataAccess.Lock() + require.Equal(t, 2, metadataCallCount) + require.Equal(t, 1, maximumConcurrentMetadataCalls) + metadataAccess.Unlock() + require.Zero(t, callbackRuleSet.refCount()) + require.Zero(t, firstBuildRuleSet.refCount()) + require.Equal(t, 1, secondBuildRuleSet.refCount()) + + lastUsedTransport.Store("") + addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Equal(t, "second", lastUsedTransport.Load()) + + err = router.Close() + require.NoError(t, err) + require.Zero(t, callbackRuleSet.refCount()) + require.Zero(t, firstBuildRuleSet.refCount()) + require.Zero(t, secondBuildRuleSet.refCount()) +} + +func TestCloseDuringRebuildDiscardsResult(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + 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: "installed"}, + }, + }, + }, + }, &fakeDNSTransportManager{ + defaultTransport: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "discarded": &fakeDNSTransport{tag: "discarded", transportType: C.DNSTypeUDP}, + "installed": &fakeDNSTransport{tag: "installed", transportType: C.DNSTypeUDP}, + }, + }, &fakeDNSClient{ + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { + switch transport.Tag() { + case "discarded", "installed", "default": + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60), nil + default: + return nil, E.New("unexpected transport: ", transport.Tag()) + } + }, + }) + require.True(t, router.legacyDNSMode) + require.Equal(t, 1, fakeSet.refCount()) + + callbacks := fakeSet.snapshotCallbacks() + require.Len(t, callbacks, 1) + + firstMetadataEntered := make(chan struct{}) + releaseFirstMetadata := make(chan struct{}) + callbackFinished := make(chan struct{}) + fakeSet.metadataRead = func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { + router.rawRules[0].DefaultOptions.RouteOptions.Server = "discarded" + close(firstMetadataEntered) + <-releaseFirstMetadata + return adapter.RuleSetMetadata{} + } + + go func() { + callbacks[0](fakeSet) + close(callbackFinished) + }() + + select { + case <-firstMetadataEntered: + case <-time.After(time.Second): + t.Fatal("rebuild did not reach rule-set metadata") + } + + err := router.Close() + require.NoError(t, err) + close(releaseFirstMetadata) + + select { + case <-callbackFinished: + case <-time.After(time.Second): + t.Fatal("rebuild callback did not finish after close") + } + + fakeSet.metadataRead = nil + + router.rulesAccess.RLock() + require.True(t, router.closing) + require.Nil(t, router.rules) + require.Empty(t, router.ruleSetCallbacks) + router.rulesAccess.RUnlock() + require.True(t, router.legacyDNSMode) + require.Zero(t, fakeSet.refCount()) } func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { @@ -661,7 +1103,6 @@ func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { require.True(t, router.closing) require.Nil(t, router.rules) require.Empty(t, router.ruleSetCallbacks) - require.NoError(t, router.runtimeRuleError) } func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { @@ -680,7 +1121,7 @@ func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { case "default": t.Fatal("default transport should not be used when legacy rule matches after response") } - return nil, nil, errors.New("unexpected transport") + return nil, nil, E.New("unexpected transport") }, } router := newTestRouter(t, []option.DNSRule{{ @@ -716,12 +1157,23 @@ func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testin defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} - var lookups []string + 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) - lookups = append(lookups, transport.Tag()) + recordLookup(transport.Tag()) switch transport.Tag() { case "private": response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) @@ -730,7 +1182,7 @@ func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testin response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) return MessageToAddresses(response), response, nil } - return nil, nil, errors.New("unexpected transport") + return nil, nil, E.New("unexpected transport") }, } router := newTestRouter(t, []option.DNSRule{{ @@ -757,7 +1209,7 @@ func TestLookupLegacyDNSModeFallsBackAfterRejectedAddressLimitResponse(t *testin }) require.NoError(t, err) require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) - require.Equal(t, []string{"private", "default"}, lookups) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) } func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *testing.T) { @@ -785,7 +1237,18 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} privateTransport := &fakeDNSTransport{tag: "private", transportType: C.DNSTypeUDP} - var lookups []string + 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, @@ -819,7 +1282,7 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes 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) - lookups = append(lookups, transport.Tag()) + recordLookup(transport.Tag()) switch transport.Tag() { case "private": response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60) @@ -828,7 +1291,7 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60) return MessageToAddresses(response), response, nil } - return nil, nil, errors.New("unexpected transport") + return nil, nil, E.New("unexpected transport") }, }) @@ -839,7 +1302,7 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes }) require.NoError(t, err) require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) - require.Equal(t, []string{"private", "default"}, lookups) + require.Equal(t, []string{"private", "default"}, currentLookupTags()) } func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T) { @@ -872,7 +1335,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { case "selected": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -931,7 +1394,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *te case "selected": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -990,7 +1453,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t case "selected": return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -1049,7 +1512,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithMappedHTTPSI case "selected": return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -1119,7 +1582,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t case "selected": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -1181,7 +1644,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsRespon case "default": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -1268,13 +1731,13 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { switch transport.Tag() { case "upstream": - return nil, errors.New("upstream exchange failed") + 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, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } @@ -1332,9 +1795,9 @@ func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { case mDNS.TypeA: return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil case mDNS.TypeAAAA: - return nil, errors.New("ipv6 failed") + return nil, E.New("ipv6 failed") default: - return nil, errors.New("unexpected qtype") + return nil, E.New("unexpected qtype") } }, }) @@ -1451,7 +1914,7 @@ func TestLookupLegacyDNSModeDisabledEvaluateSkipFakeIPPreservesResponse(t *testi } return FixedResponse(0, message.Question[0], nil, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, }) @@ -1494,7 +1957,7 @@ func TestLookupLegacyDNSModeDisabledUsesQueryTypeRule(t *testing.T) { case "only-a": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, }) @@ -1560,7 +2023,7 @@ func TestLookupLegacyDNSModeDisabledUsesRuleSetQueryTypeRule(t *testing.T) { } return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::9")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, }) @@ -1609,7 +2072,7 @@ func TestLookupLegacyDNSModeDisabledUsesIPVersionRule(t *testing.T) { } return FixedResponse(0, message.Question[0], nil, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, }) @@ -1829,7 +2292,18 @@ func TestLookupLegacyDNSModeDisabledUsesInputStrategy(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - var qTypes []uint16 + var queryTypeAccess sync.Mutex + var queryTypes []uint16 + recordQueryType := func(queryType uint16) { + queryTypeAccess.Lock() + queryTypes = append(queryTypes, queryType) + queryTypeAccess.Unlock() + } + currentQueryTypes := func() []uint16 { + queryTypeAccess.Lock() + defer queryTypeAccess.Unlock() + return append([]uint16(nil), queryTypes...) + } router := newTestRouter(t, nil, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ @@ -1837,7 +2311,7 @@ func TestLookupLegacyDNSModeDisabledUsesInputStrategy(t *testing.T) { }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - qTypes = append(qTypes, message.Question[0].Qtype) + recordQueryType(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 } @@ -1850,7 +2324,7 @@ func TestLookupLegacyDNSModeDisabledUsesInputStrategy(t *testing.T) { Strategy: C.DomainStrategyIPv4Only, }) require.NoError(t, err) - require.Equal(t, []uint16{mDNS.TypeA}, qTypes) + require.Equal(t, []uint16{mDNS.TypeA}, currentQueryTypes()) require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } @@ -1858,7 +2332,18 @@ func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - var qTypes []uint16 + var queryTypeAccess sync.Mutex + var queryTypes []uint16 + recordQueryType := func(queryType uint16) { + queryTypeAccess.Lock() + queryTypes = append(queryTypes, queryType) + queryTypeAccess.Unlock() + } + currentQueryTypes := func() []uint16 { + queryTypeAccess.Lock() + defer queryTypeAccess.Unlock() + return append([]uint16(nil), queryTypes...) + } router := newTestRouter(t, nil, &fakeDNSTransportManager{ defaultTransport: defaultTransport, transports: map[string]adapter.DNSTransport{ @@ -1866,7 +2351,7 @@ func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(t *testing.T) { }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - qTypes = append(qTypes, message.Question[0].Qtype) + recordQueryType(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 } @@ -1878,7 +2363,7 @@ func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(t *testing.T) { addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) - require.Equal(t, []uint16{mDNS.TypeA}, qTypes) + require.Equal(t, []uint16{mDNS.TypeA}, currentQueryTypes()) require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } @@ -1903,7 +2388,7 @@ func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t * case "default": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } From 866731344fb987ed38ed0b68d3db30a5b0a09cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:29:16 +0800 Subject: [PATCH 30/67] dns: use refcounted snapshot to narrow rule lock scope Exchange and Lookup held rulesAccess.RLock across all DNS network I/O, blocking rebuildRules from swapping in new rules until every in-flight query finished. Replace the RWMutex with an atomic pointer to a refcounted rulesSnapshot so queries only hold a snapshot reference during execution, allowing concurrent rule rebuilds. --- dns/router.go | 164 ++++++++++++++++++--------- dns/router_test.go | 273 +++++++++++++++++++++++++++++++++++++++------ 2 files changed, 349 insertions(+), 88 deletions(-) diff --git a/dns/router.go b/dns/router.go index 8e2424b9fd..ae986d7120 100644 --- a/dns/router.go +++ b/dns/router.go @@ -6,6 +6,7 @@ import ( "net/netip" "strings" "sync" + "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -37,6 +38,42 @@ type dnsRuleSetCallback struct { element *list.Element[adapter.RuleSetUpdateCallback] } +type rulesSnapshot struct { + rules []adapter.DNSRule + legacyDNSMode bool + references atomic.Int64 +} + +func newRulesSnapshot(rules []adapter.DNSRule, legacyDNSMode bool) *rulesSnapshot { + snapshot := &rulesSnapshot{ + rules: rules, + legacyDNSMode: legacyDNSMode, + } + snapshot.references.Store(1) + return snapshot +} + +func (s *rulesSnapshot) retain() { + if s == nil { + return + } + s.references.Add(1) +} + +func (s *rulesSnapshot) release() { + if s == nil { + return + } + references := s.references.Add(-1) + switch { + case references > 0: + case references == 0: + closeRules(s.rules) + default: + panic("dns: negative rules snapshot references") + } +} + type Router struct { ctx context.Context logger logger.ContextLogger @@ -44,13 +81,12 @@ type Router struct { outbound adapter.OutboundManager client adapter.DNSClient rawRules []option.DNSRule - rules []adapter.DNSRule + currentRules atomic.Pointer[rulesSnapshot] defaultDomainStrategy C.DomainStrategy dnsReverseMapping freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface - legacyDNSMode bool - rulesAccess sync.RWMutex rebuildAccess sync.Mutex + stateAccess sync.Mutex closing bool ruleSetCallbacks []dnsRuleSetCallback addressFilterDeprecatedReported bool @@ -64,9 +100,9 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp 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), } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(options.Rules)), false)) router.client = NewClient(ClientOptions{ DisableCache: options.DNSClientOptions.DisableCache, DisableExpire: options.DNSClientOptions.DisableExpire, @@ -134,26 +170,21 @@ func (r *Router) Start(stage adapter.StartStage) error { } func (r *Router) Close() error { - monitor := taskmonitor.New(r.logger, C.StopTimeout) - r.rulesAccess.Lock() + r.stateAccess.Lock() + if r.closing { + r.stateAccess.Unlock() + return nil + } r.closing = true callbacks := r.ruleSetCallbacks r.ruleSetCallbacks = nil - runtimeRules := r.rules - r.rules = nil + oldSnapshot := r.currentRules.Swap(nil) for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } - r.rulesAccess.Unlock() - var err error - for i, rule := range runtimeRules { - 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() - } - return err + r.stateAccess.Unlock() + oldSnapshot.release() + return nil } func (r *Router) rebuildRules(startRules bool) error { @@ -177,23 +208,22 @@ func (r *Router) rebuildRules(startRules bool) error { legacyDNSMode && !r.ruleStrategyDeprecatedReported && hasDNSRuleActionStrategy(r.rawRules) - r.rulesAccess.Lock() + newSnapshot := newRulesSnapshot(newRules, legacyDNSMode) + r.stateAccess.Lock() if r.closing { - r.rulesAccess.Unlock() - closeRules(newRules) + r.stateAccess.Unlock() + newSnapshot.release() return nil } - oldRules := r.rules - r.rules = newRules - r.legacyDNSMode = legacyDNSMode if shouldReportAddressFilterDeprecated { r.addressFilterDeprecatedReported = true } if shouldReportRuleStrategyDeprecated { r.ruleStrategyDeprecatedReported = true } - r.rulesAccess.Unlock() - closeRules(oldRules) + oldSnapshot := r.currentRules.Swap(newSnapshot) + r.stateAccess.Unlock() + oldSnapshot.release() if shouldReportAddressFilterDeprecated { deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) } @@ -204,11 +234,19 @@ func (r *Router) rebuildRules(startRules bool) error { } func (r *Router) isClosing() bool { - r.rulesAccess.RLock() - defer r.rulesAccess.RUnlock() + r.stateAccess.Lock() + defer r.stateAccess.Unlock() return r.closing } +func (r *Router) acquireRulesSnapshot() *rulesSnapshot { + r.stateAccess.Lock() + defer r.stateAccess.Unlock() + snapshot := r.currentRules.Load() + snapshot.retain() + return snapshot +} + func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { for i, ruleOptions := range r.rawRules { err := R.ValidateNoNestedDNSRuleActions(ruleOptions) @@ -259,12 +297,12 @@ func (r *Router) registerRuleSetCallbacks() (bool, error) { if len(tags) == 0 { return false, nil } - r.rulesAccess.RLock() + r.stateAccess.Lock() if len(r.ruleSetCallbacks) > 0 { - r.rulesAccess.RUnlock() + r.stateAccess.Unlock() return true, nil } - r.rulesAccess.RUnlock() + r.stateAccess.Unlock() router := service.FromContext[adapter.Router](r.ctx) if router == nil { return false, E.New("router service not found") @@ -289,19 +327,19 @@ func (r *Router) registerRuleSetCallbacks() (bool, error) { element: element, }) } - r.rulesAccess.Lock() + r.stateAccess.Lock() if len(r.ruleSetCallbacks) == 0 { r.ruleSetCallbacks = callbacks callbacks = nil } - r.rulesAccess.Unlock() + r.stateAccess.Unlock() for _, callback := range callbacks { callback.ruleSet.UnregisterCallback(callback.element) } return true, 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") @@ -310,8 +348,8 @@ 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 } @@ -422,14 +460,14 @@ type exchangeWithRulesResult struct { err error } -func (r *Router) exchangeWithRules(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool) exchangeWithRulesResult { +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 savedResponse *mDNS.Msg - for currentRuleIndex, currentRule := range r.rules { + for currentRuleIndex, currentRule := range rules { metadata.ResetRuleCache() metadata.DNSResponse = savedResponse metadata.DestinationAddressMatchFromResponse = false @@ -578,18 +616,18 @@ func filterAddressesByQueryType(addresses []netip.Addr, qType uint16) []netip.Ad } } -func (r *Router) lookupWithRules(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { +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 { - response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + response, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) return response.addresses, err } if strategy == C.DomainStrategyIPv6Only { - response, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeAAAA, lookupOptions) + response, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) return response.addresses, err } var ( @@ -598,12 +636,12 @@ func (r *Router) lookupWithRules(ctx context.Context, domain string, options ada ) var group task.Group group.Append("exchange4", func(ctx context.Context) error { - result, err := r.lookupWithRulesType(ctx, domain, mDNS.TypeA, lookupOptions) + 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, domain, mDNS.TypeAAAA, lookupOptions) + result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) response6 = result return err }) @@ -614,7 +652,7 @@ func (r *Router) lookupWithRules(ctx context.Context, domain string, options ada return sortAddresses(response4.addresses, response6.addresses, strategy), nil } -func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { +func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRule, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { request := &mDNS.Msg{ MsgHdr: mDNS.MsgHdr{ RecursionDesired: true, @@ -625,7 +663,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, domain string, qType u Qclass: mDNS.ClassINET, }}, } - exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), request, options, false) + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) result := lookupWithRulesResponse{} if exchangeResult.rejectAction != nil { return result, exchangeResult.rejectAction.Error(ctx) @@ -656,8 +694,16 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } return &responseMessage, nil } - r.rulesAccess.RLock() - defer r.rulesAccess.RUnlock() + snapshot := r.acquireRulesSnapshot() + defer snapshot.release() + var ( + rules []adapter.DNSRule + legacyDNSMode bool + ) + if snapshot != nil { + rules = snapshot.rules + legacyDNSMode = snapshot.legacyDNSMode + } r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) var ( response *mDNS.Msg @@ -683,8 +729,8 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte options.Strategy = r.defaultDomainStrategy } response, err = r.client.Exchange(ctx, transport, message, options, nil) - } else if !r.legacyDNSMode { - exchangeResult := r.exchangeWithRules(ctx, message, options, true) + } else if !legacyDNSMode { + exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { var ( @@ -695,7 +741,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: @@ -760,8 +806,16 @@ 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() + snapshot := r.acquireRulesSnapshot() + defer snapshot.release() + var ( + rules []adapter.DNSRule + legacyDNSMode bool + ) + if snapshot != nil { + rules = snapshot.rules + legacyDNSMode = snapshot.legacyDNSMode + } var ( responseAddrs []netip.Addr err error @@ -797,8 +851,8 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ options.Strategy = r.defaultDomainStrategy } responseAddrs, err = r.client.Lookup(ctx, transport, domain, options, nil) - } else if !r.legacyDNSMode { - responseAddrs, err = r.lookupWithRules(ctx, domain, options) + } else if !legacyDNSMode { + responseAddrs, err = r.lookupWithRules(ctx, rules, domain, options) } else { var ( transport adapter.DNSTransport @@ -809,7 +863,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: diff --git a/dns/router_test.go b/dns/router_test.go index 37ebcbc02a..81ab358b2e 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -113,6 +113,7 @@ func (r *fakeRouter) RuleSet(tag string) (adapter.RuleSet, bool) { ruleSet, loaded := r.ruleSets[tag] return ruleSet, loaded } + func (r *fakeRouter) setRuleSet(tag string, ruleSet adapter.RuleSet) { r.access.Lock() defer r.access.Unlock() @@ -135,7 +136,7 @@ type fakeRuleSet struct { match func(*adapter.InboundContext) bool callbacks list.List[adapter.RuleSetUpdateCallback] refs int - afterIncrementReference func() + afterIncrementReference func() beforeDecrementReference func() } @@ -273,9 +274,9 @@ func newTestRouterWithContextAndLogger(t *testing.T, ctx context.Context, rules transport: transportManager, client: client, rawRules: make([]option.DNSRule, 0, len(rules)), - rules: make([]adapter.DNSRule, 0, len(rules)), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(rules)), false)) if rules != nil { err := router.Initialize(rules) require.NoError(t, err) @@ -427,9 +428,9 @@ func TestInitializeRejectsInvalidDNSRuleParseError(t *testing.T) { transport: &fakeDNSTransportManager{}, client: &fakeDNSClient{}, rawRules: make([]option.DNSRule, 0, 1), - rules: make([]adapter.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -474,9 +475,9 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { transport: &fakeDNSTransportManager{}, client: &fakeDNSClient{}, rawRules: make([]option.DNSRule, 0, 2), - rules: make([]adapter.DNSRule, 0, 2), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 2), false)) err = router.Initialize([]option.DNSRule{ { Type: C.RuleTypeDefault, @@ -557,7 +558,7 @@ func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { }, }) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -700,7 +701,7 @@ func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t * require.NoError(t, closeErr) }) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) require.Equal(t, 1, callbackRuleSet.refCount()) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) @@ -723,7 +724,7 @@ func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t * }) rebuildErrorEntry := waitForLogMessageContaining(t, logEntries, logDone, "rebuild DNS rules after rule-set update") require.Contains(t, rebuildErrorEntry.Message, "ip_cidr and ip_is_private require match_response") - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) require.Equal(t, 1, callbackRuleSet.refCount()) require.Zero(t, rebuildTargetRuleSet.refCount()) @@ -992,7 +993,7 @@ func TestCloseDuringRebuildDiscardsResult(t *testing.T) { } }, }) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) require.Equal(t, 1, fakeSet.refCount()) callbacks := fakeSet.snapshotCallbacks() @@ -1031,12 +1032,11 @@ func TestCloseDuringRebuildDiscardsResult(t *testing.T) { fakeSet.metadataRead = nil - router.rulesAccess.RLock() + router.stateAccess.Lock() require.True(t, router.closing) - require.Nil(t, router.rules) require.Empty(t, router.ruleSetCallbacks) - router.rulesAccess.RUnlock() - require.True(t, router.legacyDNSMode) + router.stateAccess.Unlock() + require.Nil(t, router.currentRules.Load()) require.Zero(t, fakeSet.refCount()) } @@ -1098,11 +1098,218 @@ func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { } callbacks[0](fakeSet) - router.rulesAccess.RLock() - defer router.rulesAccess.RUnlock() + router.stateAccess.Lock() require.True(t, router.closing) - require.Nil(t, router.rules) require.Empty(t, router.ruleSetCallbacks) + router.stateAccess.Unlock() + require.Nil(t, router.currentRules.Load()) +} + +func TestRuleSetUpdateDoesNotBlockOnInFlightLookup(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + }) + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} + lookupStarted := make(chan struct{}) + releaseLookup := make(chan struct{}) + 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: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": selectedTransport, + }, + }, &fakeDNSClient{ + lookup: func(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) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + close(lookupStarted) + <-releaseLookup + response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) + return MessageToAddresses(response), response, nil + }, + }) + t.Cleanup(func() { + closeErr := router.Close() + require.NoError(t, closeErr) + }) + + require.True(t, router.currentRules.Load().legacyDNSMode) + require.Equal(t, 1, fakeSet.refCount()) + + var ( + addresses []netip.Addr + err error + ) + lookupDone := make(chan struct{}) + go func() { + addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + close(lookupDone) + }() + + select { + case <-lookupStarted: + case <-time.After(time.Second): + t.Fatal("lookup did not reach DNS client") + } + + rebuildDone := make(chan struct{}) + go func() { + fakeSet.updateMetadata(adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + close(rebuildDone) + }() + + select { + case <-rebuildDone: + case <-time.After(time.Second): + t.Fatal("rebuild blocked on in-flight lookup") + } + + require.Equal(t, 2, fakeSet.refCount()) + + select { + case <-lookupDone: + t.Fatal("lookup finished before release") + default: + } + + close(releaseLookup) + + select { + case <-lookupDone: + case <-time.After(time.Second): + t.Fatal("lookup did not finish after release") + } + + require.NoError(t, err) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Eventually(t, func() bool { + return fakeSet.refCount() == 1 + }, time.Second, 10*time.Millisecond) +} + +func TestCloseReleasesSnapshottedRulesAfterInFlightLookup(t *testing.T) { + t.Parallel() + + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }, + } + ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + ruleSets: map[string]adapter.RuleSet{ + "dynamic-set": fakeSet, + }, + }) + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} + lookupStarted := make(chan struct{}) + releaseLookup := make(chan struct{}) + 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: defaultTransport, + transports: map[string]adapter.DNSTransport{ + "default": defaultTransport, + "selected": selectedTransport, + }, + }, &fakeDNSClient{ + lookup: func(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) + require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) + close(lookupStarted) + <-releaseLookup + 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.currentRules.Load().legacyDNSMode) + require.Equal(t, 1, fakeSet.refCount()) + + var ( + addresses []netip.Addr + lookupErr error + closeErr error + ) + lookupDone := make(chan struct{}) + go func() { + addresses, lookupErr = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + LookupStrategy: C.DomainStrategyIPv4Only, + }) + 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) + }() + + require.Eventually(t, func() bool { + return router.currentRules.Load() == nil && fakeSet.refCount() == 1 + }, time.Second, 10*time.Millisecond) + + close(releaseLookup) + + select { + case <-lookupDone: + case <-time.After(time.Second): + t.Fatal("lookup did not finish after release") + } + select { + case <-closeDone: + case <-time.After(time.Second): + t.Fatal("close did not finish") + } + + require.NoError(t, lookupErr) + require.NoError(t, closeErr) + require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) + require.Eventually(t, func() bool { + return fakeSet.refCount() == 0 + }, time.Second, 10*time.Millisecond) } func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { @@ -1143,7 +1350,7 @@ func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { }, }, client) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -1295,7 +1502,7 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes }, }) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -1801,7 +2008,7 @@ func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { } }, }) - router.legacyDNSMode = false + router.currentRules.Load().legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -1838,7 +2045,7 @@ func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { return FixedResponse(0, message.Question[0], nil, 60), nil }, }) - router.legacyDNSMode = false + router.currentRules.Load().legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -1918,7 +2125,7 @@ func TestLookupLegacyDNSModeDisabledEvaluateSkipFakeIPPreservesResponse(t *testi } }, }) - router.legacyDNSMode = false + router.currentRules.Load().legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -1961,7 +2168,7 @@ func TestLookupLegacyDNSModeDisabledUsesQueryTypeRule(t *testing.T) { } }, }) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2027,7 +2234,7 @@ func TestLookupLegacyDNSModeDisabledUsesRuleSetQueryTypeRule(t *testing.T) { } }, }) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2076,7 +2283,7 @@ func TestLookupLegacyDNSModeDisabledUsesIPVersionRule(t *testing.T) { } }, }) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2092,9 +2299,9 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t transport: &fakeDNSTransportManager{}, client: &fakeDNSClient{}, rawRules: make([]option.DNSRule, 0, 1), - rules: make([]adapter.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2122,9 +2329,9 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo transport: &fakeDNSTransportManager{}, client: &fakeDNSClient{}, rawRules: make([]option.DNSRule, 0, 1), - rules: make([]adapter.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2175,7 +2382,7 @@ func TestLookupLegacyDNSModeUsesRouteStrategy(t *testing.T) { }, }) - require.True(t, router.legacyDNSMode) + require.True(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2207,7 +2414,7 @@ func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testi "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.Nil(t, addresses) @@ -2240,7 +2447,7 @@ func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *t "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -2278,7 +2485,7 @@ func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.legacyDNSMode) + require.False(t, router.currentRules.Load().legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2318,7 +2525,7 @@ func TestLookupLegacyDNSModeDisabledUsesInputStrategy(t *testing.T) { return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::2")}, 60), nil }, }) - router.legacyDNSMode = false + router.currentRules.Load().legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ Strategy: C.DomainStrategyIPv4Only, @@ -2359,7 +2566,7 @@ func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(t *testing.T) { }, }) router.defaultDomainStrategy = C.DomainStrategyIPv4Only - router.legacyDNSMode = false + router.currentRules.Load().legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2445,9 +2652,9 @@ func TestLegacyDNSModeReportsLegacyAddressFilterDeprecation(t *testing.T) { ctx: ctx, logger: log.NewNOPFactory().NewLogger("dns"), client: &fakeDNSClient{}, - rules: make([]adapter.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2477,9 +2684,9 @@ func TestLegacyDNSModeReportsDNSRuleStrategyDeprecation(t *testing.T) { ctx: ctx, logger: log.NewNOPFactory().NewLogger("dns"), client: &fakeDNSClient{}, - rules: make([]adapter.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ From be4e6966328539ebd07347ab0dab374614a74fcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:29:50 +0800 Subject: [PATCH 31/67] route/rule: remove dead IgnoreDestinationIPCIDRMatch field The field was never set to true after the legacy pre-match refactor in 3549c02b8. Remove the declaration, guard check, and redundant false assignments. --- adapter/inbound.go | 7 +++---- route/rule/rule_abstract.go | 2 +- route/rule/rule_dns.go | 3 --- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index d13adb5cce..5f6804acaa 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -99,10 +99,9 @@ type InboundContext struct { SourceAddressMatch bool SourcePortMatch bool - DestinationAddressMatch bool - DestinationPortMatch bool - DidMatch bool - IgnoreDestinationIPCIDRMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool + DidMatch bool } func (c *InboundContext) ResetRuleCache() { diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index ca508330bd..8ec57aac32 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -60,7 +60,7 @@ func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.I } func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index f95fa66998..764deabf9f 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -379,7 +379,6 @@ func (r *DefaultDNSRule) matchStatesForMatchWithMissingResponse(metadata *adapte return 0 } matchMetadata := *metadata - matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return r.abstractDefaultRule.matchStates(&matchMetadata) } @@ -389,7 +388,6 @@ func (r *DefaultDNSRule) matchStatesForMatchWithMissingResponse(metadata *adapte func (r *DefaultDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response - matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return !r.abstractDefaultRule.matchStates(&matchMetadata).isEmpty() } @@ -511,7 +509,6 @@ func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { matchMetadata := *metadata matchMetadata.DNSResponse = response - matchMetadata.IgnoreDestinationIPCIDRMatch = false matchMetadata.DestinationAddressMatchFromResponse = true return !r.abstractLogicalRule.matchStates(&matchMetadata).isEmpty() } From 19b2e48f6efe284ba5da7db29685b488ca30de56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:37:10 +0800 Subject: [PATCH 32/67] dns: populate reverse mapping for legacy predefined responses The legacy path returned predefined responses early, bypassing the reverse mapping cache. Use goto to reach the shared post-exchange block so both legacy and new paths record predefined A/AAAA answers. --- dns/router.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dns/router.go b/dns/router.go index ae986d7120..0d0c9519df 100644 --- a/dns/router.go +++ b/dns/router.go @@ -759,7 +759,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) @@ -787,6 +789,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte break } } +done: if err != nil { return nil, err } From 1897e512ac7401fa9ddf97bb3531319aaf0ffecb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:43:04 +0800 Subject: [PATCH 33/67] docs: fix strategy deprecation format, explain legacyDNSMode, unify CN/EN order - Use standard !!! failure block for strategy deprecation notice - Add Legacy DNS Mode section explaining automatic mode detection - Reorder ip_accept_any/rule_set_ip_cidr_accept_empty in Chinese docs to match English --- docs/configuration/dns/rule.md | 9 ++++++++ docs/configuration/dns/rule.zh.md | 28 +++++++++++++++--------- docs/configuration/dns/rule_action.md | 6 ++--- docs/configuration/dns/rule_action.zh.md | 6 ++--- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 4ceecf9270..19006be07a 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -544,6 +544,15 @@ See [DNS Rule Actions](../rule_action/) for details. Moved to [DNS Rule Action](../rule_action#route). +### Legacy DNS Mode + +`legacyDNSMode` is an internal compatibility mode that is automatically detected from your DNS rule +configuration. It is disabled when any rule uses features introduced in sing-box 1.14.0 such as +`evaluate`, `match_response`, response fields (`response_rcode`, `response_answer`, etc.), +`query_type`, or `ip_version`. When disabled, `ip_cidr` and `ip_is_private` require `match_response` +to be set, and deprecated fields like `strategy`, `ip_accept_any`, and `rule_set_ip_cidr_accept_empty` +are no longer accepted. + ### Address Filter Fields 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. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index b47e599587..d35474cf0c 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -542,6 +542,14 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 已移动到 [DNS 规则动作](../rule_action#route). +### Legacy DNS Mode + +`legacyDNSMode` 是一种内部兼容模式,会根据 DNS 规则配置自动检测。 +当任何规则使用了 sing-box 1.14.0 引入的特性(如 `evaluate`、`match_response`、 +响应字段(`response_rcode`、`response_answer` 等)、`query_type` 或 `ip_version`)时, +该模式将被自动禁用。禁用后,`ip_cidr` 和 `ip_is_private` 需要设置 `match_response`, +且已废弃的字段(如 `strategy`、`ip_accept_any`、`rule_set_ip_cidr_accept_empty`)将不再被接受。 + ### 地址筛选字段 仅对地址请求 (A/AAAA/HTTPS) 生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 @@ -579,27 +587,27 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 当 `legacyDNSMode` 未启用时,`match_response` 必须设为 `true`。 -#### ip_accept_any +#### rule_set_ip_cidr_accept_empty -!!! question "自 sing-box 1.12.0 起" +!!! question "自 sing-box 1.10.0 起" !!! failure "已在 sing-box 1.14.0 废弃" - `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除。 - 仅在 `legacyDNSMode` 中可用。请使用 `match_response` 和响应项替代。 + `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除。 + 仅在 `legacyDNSMode` 中可用。 -匹配任意 IP。 +使规则集中的 `ip_cidr` 规则接受空查询响应。 -#### rule_set_ip_cidr_accept_empty +#### ip_accept_any -!!! question "自 sing-box 1.10.0 起" +!!! question "自 sing-box 1.12.0 起" !!! failure "已在 sing-box 1.14.0 废弃" - `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除。 - 仅在 `legacyDNSMode` 中可用。 + `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除。 + 仅在 `legacyDNSMode` 中可用。请使用 `match_response` 和响应项替代。 -使规则集中的 `ip_cidr` 规则接受空查询响应。 +匹配任意 IP。 ### 响应字段 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 7cc6aba6d4..2a7517528f 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -39,11 +39,11 @@ Tag of target server. !!! question "Since sing-box 1.12.0" -!!! warning +!!! failure "Deprecated in sing-box 1.14.0" - `strategy` is deprecated and only supported in `legacyDNSMode`. + `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 in `legacyDNSMode`. +Set domain strategy for this query. Only supported when `legacyDNSMode` is active. One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`. diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index ab0c2e1bdd..f0fa3bf2a7 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -39,11 +39,11 @@ icon: material/new-box !!! question "自 sing-box 1.12.0 起" -!!! warning +!!! failure "已在 sing-box 1.14.0 废弃" - `strategy` 已废弃,且仅在 `legacyDNSMode` 中可用。 + `strategy` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除。 -在 `legacyDNSMode` 中为此查询设置域名策略。 +为此查询设置域名策略。仅在 `legacyDNSMode` 启用时可用。 可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 From e6377f7ec958e07a33ffbc3881e223ec4b3b4b30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:47:29 +0800 Subject: [PATCH 34/67] fix: add missing EnvName, document Strategy invariant, improve rcode display - Add EnvName to four new deprecation constants so users can suppress warnings via ENABLE_DEPRECATED_* environment variables - Add comment explaining why applyDNSRouteOptions skips Strategy - Use dns.RcodeToString in DNSResponseRCodeItem.String() for readability - Remove redundant Fqdn(FqdnToDomain(domain)) round-trip --- dns/router.go | 5 ++++- experimental/deprecated/constants.go | 4 ++++ route/rule/rule_item_response_rcode.go | 3 ++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dns/router.go b/dns/router.go index 0d0c9519df..e8f1e0f42b 100644 --- a/dns/router.go +++ b/dns/router.go @@ -410,6 +410,9 @@ func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFak } 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 } @@ -658,7 +661,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul RecursionDesired: true, }, Question: []mDNS.Question{{ - Name: mDNS.Fqdn(FqdnToDomain(domain)), + Name: mDNS.Fqdn(domain), Qtype: qType, Qclass: mDNS.ClassINET, }}, diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 81ed14e04e..c9f507387b 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -98,6 +98,7 @@ var OptionIPAcceptAny = Note{ Description: "`ip_accept_any` in DNS rules", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", + EnvName: "DNS_RULE_IP_ACCEPT_ANY", MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", } @@ -106,6 +107,7 @@ var OptionRuleSetIPCIDRAcceptEmpty = Note{ Description: "`rule_set_ip_cidr_accept_empty` in DNS rules", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", + EnvName: "DNS_RULE_RULE_SET_IP_CIDR_ACCEPT_EMPTY", MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", } @@ -114,6 +116,7 @@ var OptionLegacyDNSAddressFilter = Note{ Description: "legacy address filter DNS rule items", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_ADDRESS_FILTER", MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", } @@ -122,6 +125,7 @@ var OptionLegacyDNSRuleStrategy = Note{ Description: "`strategy` in DNS rule actions", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", + EnvName: "LEGACY_DNS_RULE_STRATEGY", MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule_action/", } diff --git a/route/rule/rule_item_response_rcode.go b/route/rule/rule_item_response_rcode.go index ae2c622481..e416c1d775 100644 --- a/route/rule/rule_item_response_rcode.go +++ b/route/rule/rule_item_response_rcode.go @@ -1,6 +1,7 @@ package rule import ( + "github.com/miekg/dns" "github.com/sagernet/sing-box/adapter" F "github.com/sagernet/sing/common/format" ) @@ -20,5 +21,5 @@ func (r *DNSResponseRCodeItem) Match(metadata *adapter.InboundContext) bool { } func (r *DNSResponseRCodeItem) String() string { - return F.ToString("response_rcode=", r.rcode) + return F.ToString("response_rcode=", dns.RcodeToString[r.rcode]) } From 6a4b0db24577825d10d104db6d3b2a1937d44a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:53:30 +0800 Subject: [PATCH 35/67] dns: fix test style issues in repro_test.go - Rename addrs to addresses per naming conventions - Replace errors.New with E.New per error-handling rules --- dns/repro_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/dns/repro_test.go b/dns/repro_test.go index 8455b088c8..113f7c49b9 100644 --- a/dns/repro_test.go +++ b/dns/repro_test.go @@ -2,13 +2,13 @@ package dns import ( "context" - "errors" "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" @@ -35,12 +35,12 @@ func TestReproLookupWithRulesUsesRequestStrategy(t *testing.T) { }, }) - addrs, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ + 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")}, addrs) + require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { @@ -62,7 +62,7 @@ func TestReproLogicalMatchResponseIPCIDR(t *testing.T) { case "selected": return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("8.8.8.8")}, 60), nil default: - return nil, errors.New("unexpected transport") + return nil, E.New("unexpected transport") } }, } From a83f2e977d402c5da28ee01900478b826282ac4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 15:53:38 +0800 Subject: [PATCH 36/67] dns: improve test coverage and cleanup - Add t.Cleanup(router.Close) in newTestRouter for automatic cleanup - Remove unnecessary testCase loop variable capture (Go 1.22+) - Add tests for reject drop action, route_options effect, and chained evaluate response overwrite --- dns/router_test.go | 240 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 238 insertions(+), 2 deletions(-) diff --git a/dns/router_test.go b/dns/router_test.go index 81ab358b2e..566cef4554 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -78,6 +78,11 @@ type fakeDNSClient struct { lookup func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) } +type recordingExchangeDNSClient struct { + beforeExchange func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) + exchange func(transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) +} + type fakeDeprecatedManager struct { features []deprecated.Note } @@ -256,10 +261,54 @@ func (c *fakeDNSClient) Lookup(_ context.Context, transport adapter.DNSTransport return MessageToAddresses(response), nil } +func (c *recordingExchangeDNSClient) Start() {} + +func (c *recordingExchangeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions, _ func(*mDNS.Msg) bool) (*mDNS.Msg, error) { + if c.beforeExchange != nil { + c.beforeExchange(ctx, transport, message, options) + } + if c.exchange == nil { + return nil, E.New("unused client exchange") + } + return c.exchange(transport, message, options) +} + +func (c *recordingExchangeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func(*mDNS.Msg) bool) ([]netip.Addr, error) { + return nil, E.New("unused client lookup") +} + func (c *fakeDNSClient) ClearCache() {} +func (c *recordingExchangeDNSClient) ClearCache() {} + func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { - return newTestRouterWithContext(t, context.Background(), rules, transportManager, client) + router := newTestRouterWithContext(t, context.Background(), rules, transportManager, client) + t.Cleanup(func() { + router.Close() + }) + return router +} + +func newTestRouterWithDNSClient(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client adapter.DNSClient) *Router { + router := &Router{ + ctx: context.Background(), + logger: log.NewNOPFactory().NewLogger("dns"), + transport: transportManager, + client: client, + rawRules: make([]option.DNSRule, 0, len(rules)), + defaultDomainStrategy: C.DomainStrategyAsIS, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(rules)), false)) + if rules != nil { + err := router.Initialize(rules) + require.NoError(t, err) + err = router.Start(adapter.StartStateStart) + require.NoError(t, err) + } + t.Cleanup(func() { + router.Close() + }) + return router } func newTestRouterWithContext(t *testing.T, ctx context.Context, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client *fakeDNSClient) *Router { @@ -1903,6 +1952,98 @@ func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsRespon 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() @@ -1922,7 +2063,6 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo }, } for _, testCase := range testCases { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() @@ -2349,6 +2489,68 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo require.ErrorContains(t, err, "legacyDNSMode") } +func TestExchangeLegacyDNSModeDisabledRouteOptionsApplyQueryOptions(t *testing.T) { + t.Parallel() + + defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} + rewriteTTL := uint32(30) + var capturedOptions adapter.DNSQueryOptions + router := newTestRouterWithDNSClient(t, []option.DNSRule{ + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRouteOptions, + RouteOptionsOptions: option.DNSRouteOptionsActionOptions{ + DisableCache: true, + RewriteTTL: &rewriteTTL, + }, + }, + }, + }, + { + 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": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + }, + }, &recordingExchangeDNSClient{ + beforeExchange: func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) { + require.Equal(t, "selected", transport.Tag()) + require.Equal(t, []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, message.Question) + capturedOptions = options + }, + exchange: func(transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil + }, + }) + require.False(t, router.currentRules.Load().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, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, MessageToAddresses(response)) + require.True(t, capturedOptions.DisableCache) + require.NotNil(t, capturedOptions.RewriteTTL) + require.Equal(t, rewriteTTL, *capturedOptions.RewriteTTL) +} + func TestLookupLegacyDNSModeUsesRouteStrategy(t *testing.T) { t.Parallel() @@ -2457,6 +2659,40 @@ func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *t 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.currentRules.Load().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() From ef99a87de72053a18f0cd5e193b226dc2df0c540 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 16:53:57 +0800 Subject: [PATCH 37/67] dns: reject method `reply` is not supported for DNS rules Add config-time validation in NewDNSRule that rejects RejectMethodReply for both default and logical DNS rules, matching the existing TCP/UDP validation in route/route.go. --- route/rule/nested_action_test.go | 20 ++++++++++++++++++++ route/rule/rule_dns.go | 15 +++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/route/rule/nested_action_test.go b/route/rule/nested_action_test.go index b21828c04a..77ff29c7b5 100644 --- a/route/rule/nested_action_test.go +++ b/route/rule/nested_action_test.go @@ -135,3 +135,23 @@ func TestNewDNSRuleRejectsNestedRuleAction(t *testing.T) { }, true, false) require.ErrorContains(t, err, 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_dns.go b/route/rule/rule_dns.go index 764deabf9f..7dcc4711ad 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -24,6 +24,10 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN 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, C.RuleActionTypeEvaluate: if options.DefaultOptions.RouteOptions.Server == "" && checkServer { @@ -38,6 +42,10 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN 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, C.RuleActionTypeEvaluate: if options.LogicalOptions.RouteOptions.Server == "" && checkServer { @@ -50,6 +58,13 @@ func NewDNSRule(ctx context.Context, logger log.ContextLogger, options option.DN } } +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 { From 509da1ca6b090fc05567ce8b19e9a53bda1077d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:08:52 +0800 Subject: [PATCH 38/67] dns: return immediately on context cancellation in evaluate exchange --- dns/router.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/dns/router.go b/dns/router.go index e8f1e0f42b..96897c036f 100644 --- a/dns/router.go +++ b/dns/router.go @@ -501,6 +501,9 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, } response, err := r.client.Exchange(adapter.OverrideContext(ctx), transport, message, exchangeOptions, nil) if err != nil { + if E.IsClosedOrCanceled(err) { + return exchangeWithRulesResult{err: err} + } r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) savedResponse = nil continue From 47b3ca1d713493838c7d0489af29a9fb8602bee0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:13:18 +0800 Subject: [PATCH 39/67] dns: fix err shadowing in buildRules Reuse the outer err variable in the rule-construction and rule-startup loops instead of redeclaring it with :=, and declare dnsRule separately. --- dns/router.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/dns/router.go b/dns/router.go index 96897c036f..49c166c298 100644 --- a/dns/router.go +++ b/dns/router.go @@ -267,7 +267,8 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { } newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) for i, ruleOptions := range r.rawRules { - dnsRule, err := R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) + var dnsRule adapter.DNSRule + dnsRule, err = R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) if err != nil { closeRules(newRules) return nil, false, E.Cause(err, "parse dns rule[", i, "]") @@ -276,7 +277,7 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { } if startRules { for i, rule := range newRules { - err := rule.Start() + err = rule.Start() if err != nil { closeRules(newRules) return nil, false, E.Cause(err, "initialize DNS rule[", i, "]") From faf786ce7005f29dc8ea7c22135eee8717d35927 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:17:26 +0800 Subject: [PATCH 40/67] dns: fix variable shadowing in matchDNSHeadlessRuleStatesForMatch --- route/rule/rule_dns.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 7dcc4711ad..1163adb510 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -418,13 +418,13 @@ func (r *LogicalDNSRule) matchStates(metadata *adapter.InboundContext) ruleMatch } func matchDNSHeadlessRuleStatesForMatch(rule adapter.HeadlessRule, metadata *adapter.InboundContext) ruleMatchStateSet { - switch rule := rule.(type) { + switch typedRule := rule.(type) { case *DefaultDNSRule: - return rule.matchStatesForMatch(metadata) + return typedRule.matchStatesForMatch(metadata) case *LogicalDNSRule: - return rule.matchStatesForMatch(metadata) + return typedRule.matchStatesForMatch(metadata) default: - return matchHeadlessRuleStates(rule, metadata) + return matchHeadlessRuleStates(typedRule, metadata) } } From d3a5e4747ac39eba2147294d9ccc9d1215d22dcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:21:06 +0800 Subject: [PATCH 41/67] adapter: remove unused DestinationAddressesForMatch --- adapter/inbound.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index 5f6804acaa..28147e9176 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -118,13 +118,6 @@ func (c *InboundContext) ResetRuleMatchCache() { c.DidMatch = false } -func (c *InboundContext) DestinationAddressesForMatch() []netip.Addr { - if c.DestinationAddressMatchFromResponse { - return DNSResponseAddresses(c.DNSResponse) - } - return c.DestinationAddresses -} - func (c *InboundContext) DNSResponseAddressesForMatch() []netip.Addr { return DNSResponseAddresses(c.DNSResponse) } From 1579aee80a6110093b5adb251e0cffb0c2a69790 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:24:03 +0800 Subject: [PATCH 42/67] dns: remove dead lookup strategy guard in lookupWithRulesType --- dns/router.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/dns/router.go b/dns/router.go index 49c166c298..4f10ffb7af 100644 --- a/dns/router.go +++ b/dns/router.go @@ -681,9 +681,6 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul if exchangeResult.response.Rcode != mDNS.RcodeSuccess { return result, RcodeError(exchangeResult.response.Rcode) } - if !lookupStrategyAllowsQueryType(r.resolveLookupStrategy(options), qType) { - return result, nil - } result.addresses = filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType) return result, nil } From 3b1ed3ceb5271a8480a981e5381a1af68ba451e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:28:52 +0800 Subject: [PATCH 43/67] dns: remove dead lookupStrategyAllowsQueryType helper --- dns/router.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/dns/router.go b/dns/router.go index 4f10ffb7af..583374766a 100644 --- a/dns/router.go +++ b/dns/router.go @@ -584,17 +584,6 @@ func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.Domain return r.defaultDomainStrategy } -func lookupStrategyAllowsQueryType(strategy C.DomainStrategy, qType uint16) bool { - switch strategy { - case C.DomainStrategyIPv4Only: - return qType == mDNS.TypeA - case C.DomainStrategyIPv6Only: - return qType == mDNS.TypeAAAA - default: - return true - } -} - func withLookupQueryMetadata(ctx context.Context, qType uint16) context.Context { ctx, metadata := adapter.ExtendContext(ctx) metadata.QueryType = qType From 8860214ad6133f621288a4088f9feec476b190c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:31:09 +0800 Subject: [PATCH 44/67] dns: remove redundant queryOptions variable --- dns/router.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/dns/router.go b/dns/router.go index 583374766a..407f14f9ce 100644 --- a/dns/router.go +++ b/dns/router.go @@ -556,9 +556,8 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, } } } - queryOptions := effectiveOptions transport := r.transport.Default() - exchangeOptions := queryOptions + exchangeOptions := effectiveOptions if exchangeOptions.Strategy == C.DomainStrategyAsIS { exchangeOptions.Strategy = r.defaultDomainStrategy } From 35ac4dca2defb2a1b650ae38ef15eedda1382890 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:39:51 +0800 Subject: [PATCH 45/67] dns: remove redundant DNSResponse assignment in addressLimitResponseCheck MatchAddressLimit internally copies metadata and sets DNSResponse, making the prior assignment in the closure unnecessary. --- dns/router.go | 1 - 1 file changed, 1 deletion(-) diff --git a/dns/router.go b/dns/router.go index 407f14f9ce..55433fc8c2 100644 --- a/dns/router.go +++ b/dns/router.go @@ -916,7 +916,6 @@ func addressLimitResponseCheck(rule adapter.DNSRule, metadata *adapter.InboundCo responseMetadata := *metadata return func(response *mDNS.Msg) bool { checkMetadata := responseMetadata - checkMetadata.DNSResponse = response return rule.MatchAddressLimit(&checkMetadata, response) } } From 8502f14f5f2ecf880fcb2647967c9c9529982f38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:45:35 +0800 Subject: [PATCH 46/67] dns: add evaluate integration tests for response_rcode, response_ns, response_extra --- dns/router_test.go | 200 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 200 insertions(+) diff --git a/dns/router_test.go b/dns/router_test.go index 566cef4554..5f7814430d 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1631,6 +1631,206 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { 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 TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { t.Parallel() From 58dfd2e96fdd84380f078e6be0f91b36d564e498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Tue, 31 Mar 2026 17:52:54 +0800 Subject: [PATCH 47/67] option: add round-trip test for DNSRuleAction with evaluate action --- option/dns_test.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/option/dns_test.go b/option/dns_test.go index 12ee0bca3b..1f26dc77d4 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -70,3 +70,22 @@ func TestDNSOptionsAcceptsTypedServers(t *testing.T) { require.Equal(t, "1.1.1.1", options.Servers[0].Options.(*RemoteDNSServerOptions).Server) require.Equal(t, C.DNSTypeFakeIP, options.Servers[1].Type) } + +func TestDNSRuleActionEvaluateRoundTrip(t *testing.T) { + t.Parallel() + + action := DNSRuleAction{ + Action: C.RuleActionTypeEvaluate, + RouteOptions: DNSRouteActionOptions{ + Server: "default", + }, + } + + content, err := json.Marshal(action) + require.NoError(t, err) + + var decoded DNSRuleAction + err = json.UnmarshalContext(context.Background(), content, &decoded) + require.NoError(t, err) + require.Equal(t, action, decoded) +} From c1ff6a08f041326d6b72bf359f429a565e3b0676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 13:07:29 +0800 Subject: [PATCH 48/67] Format code --- adapter/inbound.go | 4 ++-- route/rule/rule_item_response_rcode.go | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index 28147e9176..505acab0e3 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -97,8 +97,8 @@ type InboundContext struct { IPCIDRMatchSource bool IPCIDRAcceptEmpty bool - SourceAddressMatch bool - SourcePortMatch bool + SourceAddressMatch bool + SourcePortMatch bool DestinationAddressMatch bool DestinationPortMatch bool DidMatch bool diff --git a/route/rule/rule_item_response_rcode.go b/route/rule/rule_item_response_rcode.go index e416c1d775..cac75e8034 100644 --- a/route/rule/rule_item_response_rcode.go +++ b/route/rule/rule_item_response_rcode.go @@ -1,9 +1,10 @@ package rule import ( - "github.com/miekg/dns" "github.com/sagernet/sing-box/adapter" F "github.com/sagernet/sing/common/format" + + "github.com/miekg/dns" ) var _ RuleItem = (*DNSResponseRCodeItem)(nil) From c17a5a3260b761240c74af2c479952def3b0b7ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 13:22:54 +0800 Subject: [PATCH 49/67] Simplify nested action validation and fix FallbackNetworkType bug - Rename nested_action.go to rule_nested_action.go for naming consistency - Export error message constants from option package to deduplicate - Fix RuleActionRouteOptions.Descriptions using wrong field for fallback-network-type --- option/rule_nested.go | 8 +++--- option/rule_nested_test.go | 28 +++++++++---------- route/rule/rule_action.go | 2 +- ...nested_action.go => rule_nested_action.go} | 9 ++---- ...ion_test.go => rule_nested_action_test.go} | 4 +-- 5 files changed, 23 insertions(+), 28 deletions(-) rename route/rule/{nested_action.go => rule_nested_action.go} (85%) rename route/rule/{nested_action_test.go => rule_nested_action_test.go} (96%) diff --git a/option/rule_nested.go b/option/rule_nested.go index c6038aad2f..172165729a 100644 --- a/option/rule_nested.go +++ b/option/rule_nested.go @@ -13,8 +13,8 @@ import ( type nestedRuleDepthContextKey struct{} const ( - routeRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" - dnsRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" + RouteRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" + DNSRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" ) var ( @@ -27,11 +27,11 @@ func nestedRuleChildContext(ctx context.Context) context.Context { } func rejectNestedRouteRuleAction(ctx context.Context, content []byte) error { - return rejectNestedRuleAction(ctx, content, routeRuleActionKeys, routeRuleActionNestedUnsupportedMessage) + return rejectNestedRuleAction(ctx, content, routeRuleActionKeys, RouteRuleActionNestedUnsupportedMessage) } func rejectNestedDNSRuleAction(ctx context.Context, content []byte) error { - return rejectNestedRuleAction(ctx, content, dnsRuleActionKeys, dnsRuleActionNestedUnsupportedMessage) + return rejectNestedRuleAction(ctx, content, dnsRuleActionKeys, DNSRuleActionNestedUnsupportedMessage) } func nestedRuleDepth(ctx context.Context) int { diff --git a/option/rule_nested_test.go b/option/rule_nested_test.go index ca0ff32dfd..d5035891b6 100644 --- a/option/rule_nested_test.go +++ b/option/rule_nested_test.go @@ -21,7 +21,7 @@ func TestRuleRejectsNestedDefaultRuleAction(t *testing.T) { {"domain": "example.com", "outbound": "direct"} ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleRejectsNestedLogicalRuleAction(t *testing.T) { @@ -41,7 +41,7 @@ func TestRuleRejectsNestedLogicalRuleAction(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleRejectsNestedDefaultRuleZeroValueOutbound(t *testing.T) { @@ -55,7 +55,7 @@ func TestRuleRejectsNestedDefaultRuleZeroValueOutbound(t *testing.T) { {"domain": "example.com", "outbound": ""} ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { @@ -69,7 +69,7 @@ func TestRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { {"domain": "example.com", "udp_connect": false} ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { @@ -88,7 +88,7 @@ func TestRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { @@ -107,7 +107,7 @@ func TestRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } func TestRuleAllowsTopLevelLogicalAction(t *testing.T) { @@ -137,7 +137,7 @@ func TestRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { ] }`), &rule) require.ErrorContains(t, err, "unknown field") - require.NotContains(t, err.Error(), routeRuleActionNestedUnsupportedMessage) + require.NotContains(t, err.Error(), RouteRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedDefaultRuleAction(t *testing.T) { @@ -151,7 +151,7 @@ func TestDNSRuleRejectsNestedDefaultRuleAction(t *testing.T) { {"domain": "example.com", "server": "default"} ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedLogicalRuleAction(t *testing.T) { @@ -171,7 +171,7 @@ func TestDNSRuleRejectsNestedLogicalRuleAction(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedDefaultRuleZeroValueServer(t *testing.T) { @@ -185,7 +185,7 @@ func TestDNSRuleRejectsNestedDefaultRuleZeroValueServer(t *testing.T) { {"domain": "example.com", "server": ""} ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { @@ -199,7 +199,7 @@ func TestDNSRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { {"domain": "example.com", "disable_cache": false} ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { @@ -218,7 +218,7 @@ func TestDNSRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { @@ -237,7 +237,7 @@ func TestDNSRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { } ] }`), &rule) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } func TestDNSRuleAllowsTopLevelLogicalAction(t *testing.T) { @@ -267,5 +267,5 @@ func TestDNSRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { ] }`), &rule) require.ErrorContains(t, err, "unknown field") - require.NotContains(t, err.Error(), dnsRuleActionNestedUnsupportedMessage) + require.NotContains(t, err.Error(), DNSRuleActionNestedUnsupportedMessage) } diff --git a/route/rule/rule_action.go b/route/rule/rule_action.go index 9f8ef945e5..194fed39f0 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -240,7 +240,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())) diff --git a/route/rule/nested_action.go b/route/rule/rule_nested_action.go similarity index 85% rename from route/rule/nested_action.go rename to route/rule/rule_nested_action.go index 95bb572155..44e58839b5 100644 --- a/route/rule/nested_action.go +++ b/route/rule/rule_nested_action.go @@ -8,11 +8,6 @@ import ( E "github.com/sagernet/sing/common/exceptions" ) -const ( - routeRuleActionNestedUnsupportedMessage = "rule action is not supported in nested rules" - dnsRuleActionNestedUnsupportedMessage = "DNS rule action is not supported in nested rules" -) - func ValidateNoNestedRuleActions(rule option.Rule) error { return validateNoNestedRuleActions(rule, false) } @@ -23,7 +18,7 @@ func ValidateNoNestedDNSRuleActions(rule option.DNSRule) error { func validateNoNestedRuleActions(rule option.Rule, nested bool) error { if nested && ruleHasConfiguredAction(rule) { - return E.New(routeRuleActionNestedUnsupportedMessage) + return E.New(option.RouteRuleActionNestedUnsupportedMessage) } if rule.Type != C.RuleTypeLogical { return nil @@ -39,7 +34,7 @@ func validateNoNestedRuleActions(rule option.Rule, nested bool) error { func validateNoNestedDNSRuleActions(rule option.DNSRule, nested bool) error { if nested && dnsRuleHasConfiguredAction(rule) { - return E.New(dnsRuleActionNestedUnsupportedMessage) + return E.New(option.DNSRuleActionNestedUnsupportedMessage) } if rule.Type != C.RuleTypeLogical { return nil diff --git a/route/rule/nested_action_test.go b/route/rule/rule_nested_action_test.go similarity index 96% rename from route/rule/nested_action_test.go rename to route/rule/rule_nested_action_test.go index 77ff29c7b5..6923b3510e 100644 --- a/route/rule/nested_action_test.go +++ b/route/rule/rule_nested_action_test.go @@ -68,7 +68,7 @@ func TestNewRuleRejectsNestedRuleAction(t *testing.T) { }, }, }, false) - require.ErrorContains(t, err, routeRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, option.RouteRuleActionNestedUnsupportedMessage) } func TestNewDNSRulePreservesImplicitTopLevelDefaultAction(t *testing.T) { @@ -133,7 +133,7 @@ func TestNewDNSRuleRejectsNestedRuleAction(t *testing.T) { }, }, }, true, false) - require.ErrorContains(t, err, dnsRuleActionNestedUnsupportedMessage) + require.ErrorContains(t, err, option.DNSRuleActionNestedUnsupportedMessage) } func TestNewDNSRuleRejectsReplyRejectMethod(t *testing.T) { From 7757a786f55d299d54e0b052e545884cabfdf2c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 14:49:33 +0800 Subject: [PATCH 50/67] Fix minor robustness issues found during code review - dns/router: add r.closing guard in registerRuleSetCallbacks to prevent callback leak when Close() races with Start() in daemon path - adapter/inbound: validate addr.IsValid() in DNSResponseAddresses before appending to guard against zero-RDATA DNS records - adapter/rule: add evaluate to non-final actions in IsFinalAction --- adapter/inbound.go | 20 ++++++++++++++++---- adapter/rule.go | 2 +- dns/router.go | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/adapter/inbound.go b/adapter/inbound.go index 505acab0e3..9cd7606ca7 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -130,19 +130,31 @@ func DNSResponseAddresses(response *dns.Msg) []netip.Addr { for _, rawRecord := range response.Answer { switch record := rawRecord.(type) { case *dns.A: - addresses = append(addresses, M.AddrFromIP(record.A)) + addr := M.AddrFromIP(record.A) + if addr.IsValid() { + addresses = append(addresses, addr) + } case *dns.AAAA: - addresses = append(addresses, M.AddrFromIP(record.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 { - addresses = append(addresses, M.AddrFromIP(ip).Unmap()) + addr := M.AddrFromIP(ip).Unmap() + if addr.IsValid() { + addresses = append(addresses, addr) + } } case *dns.SVCBIPv6Hint: for _, ip := range hint.Hint { - addresses = append(addresses, M.AddrFromIP(ip)) + addr := M.AddrFromIP(ip) + if addr.IsValid() { + addresses = append(addresses, addr) + } } } } diff --git a/adapter/rule.go b/adapter/rule.go index 00470f60ea..2117ba45a6 100644 --- a/adapter/rule.go +++ b/adapter/rule.go @@ -32,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/dns/router.go b/dns/router.go index 55433fc8c2..52a87ae6d8 100644 --- a/dns/router.go +++ b/dns/router.go @@ -329,7 +329,7 @@ func (r *Router) registerRuleSetCallbacks() (bool, error) { }) } r.stateAccess.Lock() - if len(r.ruleSetCallbacks) == 0 { + if !r.closing && len(r.ruleSetCallbacks) == 0 { r.ruleSetCallbacks = callbacks callbacks = nil } From 8916a241ed1d79a8940d8006fa90dad985a9642e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 16:39:12 +0800 Subject: [PATCH 51/67] test: remove low-value DNS WHAT tests --- dns/router_test.go | 849 +++----------------------- option/dns_record_test.go | 12 - option/dns_test.go | 37 -- option/rule_nested_test.go | 203 ------ route/rule/rule_nested_action_test.go | 69 --- 5 files changed, 76 insertions(+), 1094 deletions(-) diff --git a/dns/router_test.go b/dns/router_test.go index 5f7814430d..55d7311df3 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -78,11 +78,6 @@ type fakeDNSClient struct { lookup func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) } -type recordingExchangeDNSClient struct { - beforeExchange func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) - exchange func(transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) -} - type fakeDeprecatedManager struct { features []deprecated.Note } @@ -261,26 +256,8 @@ func (c *fakeDNSClient) Lookup(_ context.Context, transport adapter.DNSTransport return MessageToAddresses(response), nil } -func (c *recordingExchangeDNSClient) Start() {} - -func (c *recordingExchangeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions, _ func(*mDNS.Msg) bool) (*mDNS.Msg, error) { - if c.beforeExchange != nil { - c.beforeExchange(ctx, transport, message, options) - } - if c.exchange == nil { - return nil, E.New("unused client exchange") - } - return c.exchange(transport, message, options) -} - -func (c *recordingExchangeDNSClient) Lookup(context.Context, adapter.DNSTransport, string, adapter.DNSQueryOptions, func(*mDNS.Msg) bool) ([]netip.Addr, error) { - return nil, E.New("unused client lookup") -} - func (c *fakeDNSClient) ClearCache() {} -func (c *recordingExchangeDNSClient) 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() { @@ -289,28 +266,6 @@ func newTestRouter(t *testing.T, rules []option.DNSRule, transportManager *fakeD return router } -func newTestRouterWithDNSClient(t *testing.T, rules []option.DNSRule, transportManager *fakeDNSTransportManager, client adapter.DNSClient) *Router { - router := &Router{ - ctx: context.Background(), - logger: log.NewNOPFactory().NewLogger("dns"), - transport: transportManager, - client: client, - rawRules: make([]option.DNSRule, 0, len(rules)), - defaultDomainStrategy: C.DomainStrategyAsIS, - } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(rules)), false)) - if rules != nil { - err := router.Initialize(rules) - require.NoError(t, err) - err = router.Start(adapter.StartStateStart) - require.NoError(t, err) - } - 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")) } @@ -370,131 +325,6 @@ func mustRecord(t *testing.T, record string) option.DNSRecordOptions { return value } -func fixedHTTPSHintResponse(question mDNS.Question, addresses ...netip.Addr) *mDNS.Msg { - response := &mDNS.Msg{ - MsgHdr: mDNS.MsgHdr{ - Response: true, - Rcode: mDNS.RcodeSuccess, - }, - Question: []mDNS.Question{question}, - Answer: []mDNS.RR{ - &mDNS.HTTPS{ - SVCB: mDNS.SVCB{ - Hdr: mDNS.RR_Header{ - Name: question.Name, - Rrtype: mDNS.TypeHTTPS, - Class: mDNS.ClassINET, - Ttl: 60, - }, - Priority: 1, - Target: ".", - }, - }, - }, - } - https := response.Answer[0].(*mDNS.HTTPS) - var ( - hints4 []net.IP - hints6 []net.IP - ) - for _, address := range addresses { - if address.Is4() { - hints4 = append(hints4, net.IP(append([]byte(nil), address.AsSlice()...))) - } else { - hints6 = append(hints6, net.IP(append([]byte(nil), address.AsSlice()...))) - } - } - if len(hints4) > 0 { - https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv4Hint{Hint: hints4}) - } - if len(hints6) > 0 { - https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv6Hint{Hint: hints6}) - } - return response -} - -func fixedHTTPSHintResponseWithRawHints(question mDNS.Question, ipv4Hints []net.IP, ipv6Hints []net.IP) *mDNS.Msg { - response := fixedHTTPSHintResponse(question) - https := response.Answer[0].(*mDNS.HTTPS) - if len(ipv4Hints) > 0 { - hints := make([]net.IP, 0, len(ipv4Hints)) - for _, ip := range ipv4Hints { - hints = append(hints, net.IP(append([]byte(nil), ip...))) - } - https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv4Hint{Hint: hints}) - } - if len(ipv6Hints) > 0 { - hints := make([]net.IP, 0, len(ipv6Hints)) - for _, ip := range ipv6Hints { - hints = append(hints, net.IP(append([]byte(nil), ip...))) - } - https.SVCB.Value = append(https.SVCB.Value, &mDNS.SVCBIPv6Hint{Hint: hints}) - } - return response -} - -func TestValidateLegacyDNSModeDisabledRules_RequireMatchResponseForDirectIPCIDR(t *testing.T) { - t.Parallel() - - err := validateLegacyDNSModeDisabledRules([]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.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") -} - -func TestValidateLegacyDNSModeDisabledRules_AllowMatchResponseWithoutEvaluate(t *testing.T) { - t.Parallel() - - err := validateLegacyDNSModeDisabledRules([]option.DNSRule{{ - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, - }, - }, - }}) - require.NoError(t, err) -} - -func TestInitializeRejectsInvalidDNSRuleParseError(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, - } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) - err := router.Initialize([]option.DNSRule{{ - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - DomainRegex: badoption.Listable[string]{"("}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, - }, - }, - }}) - require.ErrorContains(t, err, "domain_regex") -} - func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { t.Parallel() @@ -1561,17 +1391,6 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes require.Equal(t, []string{"private", "default"}, currentLookupTags()) } -func TestDNSResponseAddressesMatchesMessageToAddressesForHTTPSHints(t *testing.T) { - t.Parallel() - - response := fixedHTTPSHintResponse(fixedQuestion("example.com", mDNS.TypeHTTPS), - netip.MustParseAddr("1.1.1.1"), - netip.MustParseAddr("2001:db8::1"), - ) - - require.Equal(t, MessageToAddresses(response), adapter.DNSResponseAddresses(response)) -} - func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRoute(t *testing.T) { t.Parallel() @@ -1831,7 +1650,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseExtraRoute(t *testing require.Equal(t, []netip.Addr{netip.MustParseAddr("8.8.8.8")}, MessageToAddresses(response)) } -func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -1842,11 +1661,22 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *te "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")}, 30), nil + 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: @@ -1872,7 +1702,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *te DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ MatchResponse: true, - ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. 60 IN A 1.1.1.1")}, + ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, @@ -1887,10 +1717,11 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteIgnoresTTL(t *te 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 TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledEvaluateRouteResolutionFailureClearsResponse(t *testing.T) { t.Parallel() transportManager := &fakeDNSTransportManager{ @@ -1905,9 +1736,11 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { switch transport.Tag() { case "upstream": - return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("1.1.1.1")), nil + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil case "selected": - return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil + 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") } @@ -1930,8 +1763,20 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + 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, @@ -1943,30 +1788,38 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithHTTPSHints(t router := newTestRouter(t, rules, transportManager, client) response, err := router.Exchange(context.Background(), &mDNS.Msg{ - Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeHTTPS)}, + 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)) + require.Equal(t, []netip.Addr{netip.MustParseAddr("4.4.4.4")}, MessageToAddresses(response)) } -func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithMappedHTTPSIPv4Hints(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledSecondEvaluateOverwritesFirstResponse(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}, + "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 "upstream": - return fixedHTTPSHintResponseWithRawHints(message.Question[0], []net.IP{net.ParseIP("1.1.1.1")}, nil), nil - case "selected": - return fixedHTTPSHintResponse(message.Question[0], netip.MustParseAddr("8.8.8.8")), nil + 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") } @@ -1981,7 +1834,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithMappedHTTPSI }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "upstream"}, + RouteOptions: option.DNSRouteActionOptions{Server: "first-upstream"}, }, }, }, @@ -1989,69 +1842,24 @@ func TestExchangeLegacyDNSModeDisabledEvaluateMatchResponseRouteWithMappedHTTPSI Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - MatchResponse: true, - IPCIDR: badoption.Listable[string]{"1.1.1.0/24"}, + Domain: badoption.Listable[string]{"example.com"}, }, DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + Action: C.RuleActionTypeEvaluate, + RouteOptions: option.DNSRouteActionOptions{Server: "second-upstream"}, }, }, }, - } - router := newTestRouter(t, rules, transportManager, client) - - response, err := router.Exchange(context.Background(), &mDNS.Msg{ - Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeHTTPS)}, - }, 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"}, + 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: "upstream"}, + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "first-match"}, }, }, }, @@ -2060,11 +1868,11 @@ func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ MatchResponse: true, - ResponseAnswer: badoption.Listable[option.DNSRecordOptions]{mustRecord(t, "example.com. IN A 1.1.1.1")}, + 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: "selected"}, + RouteOptions: option.DNSRouteActionOptions{Server: "second-match"}, }, }, }, @@ -2075,191 +1883,25 @@ func TestExchangeLegacyDNSModeDisabledEvaluateDoesNotLeakAddressesToNextQuery(t 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) { +func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBooleanSemantics(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"), + 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 { @@ -2472,164 +2114,6 @@ func TestLookupLegacyDNSModeDisabledEvaluateSkipFakeIPPreservesResponse(t *testi require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } -func TestLookupLegacyDNSModeDisabledUsesQueryTypeRule(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{ - QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "only-a"}, - }, - }, - }}, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "only-a": &fakeDNSTransport{tag: "only-a", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "default": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil - } - return FixedResponse(0, message.Question[0], nil, 60), nil - case "only-a": - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil - default: - return nil, E.New("unexpected transport") - } - }, - }) - require.False(t, router.currentRules.Load().legacyDNSMode) - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("9.9.9.9")}, addresses) -} - -func TestLookupLegacyDNSModeDisabledUsesRuleSetQueryTypeRule(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, - }, - }) - - 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]{"query-set"}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "only-a"}, - }, - }, - }}, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "only-a": &fakeDNSTransport{tag: "only-a", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "default": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::4")}, 60), nil - case "only-a": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("9.9.9.9")}, 60), nil - } - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::9")}, 60), nil - default: - return nil, E.New("unexpected transport") - } - }, - }) - require.False(t, router.currentRules.Load().legacyDNSMode) - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{ - netip.MustParseAddr("9.9.9.9"), - netip.MustParseAddr("2001:db8::4"), - }, addresses) -} - -func TestLookupLegacyDNSModeDisabledUsesIPVersionRule(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{ - IPVersion: 6, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "only-v6"}, - }, - }, - }}, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "only-v6": &fakeDNSTransport{tag: "only-v6", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "default": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("3.3.3.3")}, 60), nil - } - return FixedResponse(0, message.Question[0], nil, 60), nil - case "only-v6": - if message.Question[0].Qtype == mDNS.TypeAAAA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2001:db8::9")}, 60), nil - } - return FixedResponse(0, message.Question[0], nil, 60), nil - default: - return nil, E.New("unexpected transport") - } - }, - }) - require.False(t, router.currentRules.Load().legacyDNSMode) - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("3.3.3.3"), netip.MustParseAddr("2001:db8::9")}, addresses) -} - func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t *testing.T) { t.Parallel() @@ -2689,108 +2173,6 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo require.ErrorContains(t, err, "legacyDNSMode") } -func TestExchangeLegacyDNSModeDisabledRouteOptionsApplyQueryOptions(t *testing.T) { - t.Parallel() - - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - rewriteTTL := uint32(30) - var capturedOptions adapter.DNSQueryOptions - router := newTestRouterWithDNSClient(t, []option.DNSRule{ - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeA)}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRouteOptions, - RouteOptionsOptions: option.DNSRouteOptionsActionOptions{ - DisableCache: true, - RewriteTTL: &rewriteTTL, - }, - }, - }, - }, - { - 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": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, - }, - }, &recordingExchangeDNSClient{ - beforeExchange: func(ctx context.Context, transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) { - require.Equal(t, "selected", transport.Tag()) - require.Equal(t, []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, message.Question) - capturedOptions = options - }, - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("2.2.2.2")}, 60), nil - }, - }) - require.False(t, router.currentRules.Load().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, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, MessageToAddresses(response)) - require.True(t, capturedOptions.DisableCache) - require.NotNil(t, capturedOptions.RewriteTTL) - require.Equal(t, rewriteTTL, *capturedOptions.RewriteTTL) -} - -func TestLookupLegacyDNSModeUsesRouteStrategy(t *testing.T) { - t.Parallel() - - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - selectedTransport := &fakeDNSTransport{tag: "selected", 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: "selected", - Strategy: option.DomainStrategy(C.DomainStrategyIPv4Only), - }, - }, - }, - }}, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "selected": selectedTransport, - }, - }, &fakeDNSClient{ - lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { - require.Equal(t, "selected", transport.Tag()) - require.Equal(t, C.DomainStrategyIPv4Only, options.Strategy) - return []netip.Addr{netip.MustParseAddr("2.2.2.2")}, nil, nil - }, - }) - - require.True(t, router.currentRules.Load().legacyDNSMode) - - 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 TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testing.T) { t.Parallel() @@ -2931,85 +2313,6 @@ func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t }, addresses) } -func TestLookupLegacyDNSModeDisabledUsesInputStrategy(t *testing.T) { - t.Parallel() - - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - var queryTypeAccess sync.Mutex - var queryTypes []uint16 - recordQueryType := func(queryType uint16) { - queryTypeAccess.Lock() - queryTypes = append(queryTypes, queryType) - queryTypeAccess.Unlock() - } - currentQueryTypes := func() []uint16 { - queryTypeAccess.Lock() - defer queryTypeAccess.Unlock() - return append([]uint16(nil), queryTypes...) - } - 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) { - recordQueryType(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::2")}, 60), nil - }, - }) - router.currentRules.Load().legacyDNSMode = false - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ - Strategy: C.DomainStrategyIPv4Only, - }) - require.NoError(t, err) - require.Equal(t, []uint16{mDNS.TypeA}, currentQueryTypes()) - require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) -} - -func TestLookupLegacyDNSModeDisabledUsesDefaultStrategy(t *testing.T) { - t.Parallel() - - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - var queryTypeAccess sync.Mutex - var queryTypes []uint16 - recordQueryType := func(queryType uint16) { - queryTypeAccess.Lock() - queryTypes = append(queryTypes, queryType) - queryTypeAccess.Unlock() - } - currentQueryTypes := func() []uint16 { - queryTypeAccess.Lock() - defer queryTypeAccess.Unlock() - return append([]uint16(nil), queryTypes...) - } - 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) { - recordQueryType(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::2")}, 60), nil - }, - }) - router.defaultDomainStrategy = C.DomainStrategyIPv4Only - router.currentRules.Load().legacyDNSMode = false - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []uint16{mDNS.TypeA}, currentQueryTypes()) - require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) -} - func TestExchangeLegacyDNSModeDisabledLogicalMatchResponseIPCIDRFallsThrough(t *testing.T) { t.Parallel() diff --git a/option/dns_record_test.go b/option/dns_record_test.go index cb26f9b018..759ef5fc5a 100644 --- a/option/dns_record_test.go +++ b/option/dns_record_test.go @@ -14,18 +14,6 @@ func mustRecordOptions(t *testing.T, record string) DNSRecordOptions { return value } -func TestDNSRecordOptionsUnmarshalJSONAcceptsFullyQualifiedNames(t *testing.T) { - t.Parallel() - - for _, record := range []string{ - "example.com. A 1.1.1.1", - "www.example.com. IN CNAME example.com.", - } { - value := mustRecordOptions(t, record) - require.NotNil(t, value.RR) - } -} - func TestDNSRecordOptionsUnmarshalJSONRejectsRelativeNames(t *testing.T) { t.Parallel() diff --git a/option/dns_test.go b/option/dns_test.go index 1f26dc77d4..4e7bf9a92b 100644 --- a/option/dns_test.go +++ b/option/dns_test.go @@ -52,40 +52,3 @@ func TestDNSServerOptionsRejectsLegacyFormats(t *testing.T) { require.EqualError(t, err, legacyDNSServerRemovedMessage) } } - -func TestDNSOptionsAcceptsTypedServers(t *testing.T) { - t.Parallel() - - ctx := service.ContextWith[DNSTransportOptionsRegistry](context.Background(), stubDNSTransportOptionsRegistry{}) - var options DNSOptions - err := json.UnmarshalContext(ctx, []byte(`{ - "servers": [ - {"type": "udp", "tag": "default", "server": "1.1.1.1"}, - {"type": "fakeip", "tag": "fake", "inet4_range": "198.18.0.0/15"} - ] - }`), &options) - require.NoError(t, err) - require.Len(t, options.Servers, 2) - require.Equal(t, C.DNSTypeUDP, options.Servers[0].Type) - require.Equal(t, "1.1.1.1", options.Servers[0].Options.(*RemoteDNSServerOptions).Server) - require.Equal(t, C.DNSTypeFakeIP, options.Servers[1].Type) -} - -func TestDNSRuleActionEvaluateRoundTrip(t *testing.T) { - t.Parallel() - - action := DNSRuleAction{ - Action: C.RuleActionTypeEvaluate, - RouteOptions: DNSRouteActionOptions{ - Server: "default", - }, - } - - content, err := json.Marshal(action) - require.NoError(t, err) - - var decoded DNSRuleAction - err = json.UnmarshalContext(context.Background(), content, &decoded) - require.NoError(t, err) - require.Equal(t, action, decoded) -} diff --git a/option/rule_nested_test.go b/option/rule_nested_test.go index d5035891b6..3b2ef2e5f0 100644 --- a/option/rule_nested_test.go +++ b/option/rule_nested_test.go @@ -4,7 +4,6 @@ import ( "context" "testing" - C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing/common/json" "github.com/stretchr/testify/require" @@ -24,107 +23,6 @@ func TestRuleRejectsNestedDefaultRuleAction(t *testing.T) { require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) } -func TestRuleRejectsNestedLogicalRuleAction(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "action": "route", - "outbound": "direct", - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) -} - -func TestRuleRejectsNestedDefaultRuleZeroValueOutbound(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com", "outbound": ""} - ] - }`), &rule) - require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) -} - -func TestRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com", "udp_connect": false} - ] - }`), &rule) - require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) -} - -func TestRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "action": "", - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) -} - -func TestRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "override_port": 0, - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, RouteRuleActionNestedUnsupportedMessage) -} - -func TestRuleAllowsTopLevelLogicalAction(t *testing.T) { - t.Parallel() - - var rule Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "outbound": "direct", - "rules": [{"domain": "example.com"}] - }`), &rule) - require.NoError(t, err) - require.Equal(t, C.RuleActionTypeRoute, rule.LogicalOptions.Action) - require.Equal(t, "direct", rule.LogicalOptions.RouteOptions.Outbound) -} - func TestRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { t.Parallel() @@ -154,107 +52,6 @@ func TestDNSRuleRejectsNestedDefaultRuleAction(t *testing.T) { require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) } -func TestDNSRuleRejectsNestedLogicalRuleAction(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "action": "route", - "server": "default", - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) -} - -func TestDNSRuleRejectsNestedDefaultRuleZeroValueServer(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com", "server": ""} - ] - }`), &rule) - require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) -} - -func TestDNSRuleRejectsNestedDefaultRuleZeroValueRouteOption(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com", "disable_cache": false} - ] - }`), &rule) - require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) -} - -func TestDNSRuleRejectsNestedLogicalRuleZeroValueAction(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "action": "", - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) -} - -func TestDNSRuleRejectsNestedLogicalRuleZeroValueRouteOption(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - { - "type": "logical", - "mode": "or", - "disable_cache": false, - "rules": [{"domain": "example.com"}] - } - ] - }`), &rule) - require.ErrorContains(t, err, DNSRuleActionNestedUnsupportedMessage) -} - -func TestDNSRuleAllowsTopLevelLogicalAction(t *testing.T) { - t.Parallel() - - var rule DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "server": "default", - "rules": [{"domain": "example.com"}] - }`), &rule) - require.NoError(t, err) - require.Equal(t, C.RuleActionTypeRoute, rule.LogicalOptions.Action) - require.Equal(t, "default", rule.LogicalOptions.RouteOptions.Server) -} - func TestDNSRuleLeavesUnknownNestedKeysToNormalValidation(t *testing.T) { t.Parallel() diff --git a/route/rule/rule_nested_action_test.go b/route/rule/rule_nested_action_test.go index 6923b3510e..f895b89282 100644 --- a/route/rule/rule_nested_action_test.go +++ b/route/rule/rule_nested_action_test.go @@ -7,45 +7,10 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common/json" "github.com/stretchr/testify/require" ) -func TestNewRulePreservesImplicitTopLevelDefaultAction(t *testing.T) { - t.Parallel() - - var options option.Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "domain": "example.com" - }`), &options) - require.NoError(t, err) - - rule, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), options, false) - require.NoError(t, err) - require.NotNil(t, rule.Action()) - require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) -} - -func TestNewRuleAllowsNestedRuleWithoutAction(t *testing.T) { - t.Parallel() - - var options option.Rule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com"} - ] - }`), &options) - require.NoError(t, err) - - rule, err := NewRule(context.Background(), log.NewNOPFactory().NewLogger("router"), options, false) - require.NoError(t, err) - require.NotNil(t, rule.Action()) - require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) -} - func TestNewRuleRejectsNestedRuleAction(t *testing.T) { t.Parallel() @@ -71,40 +36,6 @@ func TestNewRuleRejectsNestedRuleAction(t *testing.T) { require.ErrorContains(t, err, option.RouteRuleActionNestedUnsupportedMessage) } -func TestNewDNSRulePreservesImplicitTopLevelDefaultAction(t *testing.T) { - t.Parallel() - - var options option.DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "domain": "example.com" - }`), &options) - require.NoError(t, err) - - rule, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), options, false, false) - require.NoError(t, err) - require.NotNil(t, rule.Action()) - require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) -} - -func TestNewDNSRuleAllowsNestedRuleWithoutAction(t *testing.T) { - t.Parallel() - - var options option.DNSRule - err := json.UnmarshalContext(context.Background(), []byte(`{ - "type": "logical", - "mode": "and", - "rules": [ - {"domain": "example.com"} - ] - }`), &options) - require.NoError(t, err) - - rule, err := NewDNSRule(context.Background(), log.NewNOPFactory().NewLogger("dns"), options, false, false) - require.NoError(t, err) - require.NotNil(t, rule.Action()) - require.Equal(t, C.RuleActionTypeRoute, rule.Action().Type()) -} - func TestNewDNSRuleRejectsNestedRuleAction(t *testing.T) { t.Parallel() From 99b363c87873b65837d6a78b322516c3b63a3712 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 16:51:03 +0800 Subject: [PATCH 52/67] test: remove internal-state assertions that test through unexported fields --- dns/router_test.go | 13 ------------- route/rule/rule_item_rule_set_test.go | 1 - 2 files changed, 14 deletions(-) diff --git a/dns/router_test.go b/dns/router_test.go index 55d7311df3..f9e60ab8db 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -811,9 +811,6 @@ func TestRuleSetUpdateSerializesConcurrentRebuilds(t *testing.T) { require.Equal(t, 2, metadataCallCount) require.Equal(t, 1, maximumConcurrentMetadataCalls) metadataAccess.Unlock() - require.Zero(t, callbackRuleSet.refCount()) - require.Zero(t, firstBuildRuleSet.refCount()) - require.Equal(t, 1, secondBuildRuleSet.refCount()) lastUsedTransport.Store("") addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) @@ -911,10 +908,6 @@ func TestCloseDuringRebuildDiscardsResult(t *testing.T) { fakeSet.metadataRead = nil - router.stateAccess.Lock() - require.True(t, router.closing) - require.Empty(t, router.ruleSetCallbacks) - router.stateAccess.Unlock() require.Nil(t, router.currentRules.Load()) require.Zero(t, fakeSet.refCount()) } @@ -976,12 +969,6 @@ func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { ContainsDNSQueryTypeRule: true, } callbacks[0](fakeSet) - - router.stateAccess.Lock() - require.True(t, router.closing) - require.Empty(t, router.ruleSetCallbacks) - router.stateAccess.Unlock() - require.Nil(t, router.currentRules.Load()) } func TestRuleSetUpdateDoesNotBlockOnInFlightLookup(t *testing.T) { diff --git a/route/rule/rule_item_rule_set_test.go b/route/rule/rule_item_rule_set_test.go index a73ed91f59..21d2070d9b 100644 --- a/route/rule/rule_item_rule_set_test.go +++ b/route/rule/rule_item_rule_set_test.go @@ -115,7 +115,6 @@ func TestRuleSetItemStartRollbackOnFailure(t *testing.T) { err := item.Start() require.ErrorContains(t, err, "rule-set not found: missing") require.Zero(t, firstSet.RefCount()) - require.Empty(t, item.setList) } func TestRuleSetItemRestartKeepsBalancedRefs(t *testing.T) { From 91f942c8bc0ed4a8a1ba3a1adf10c2edd92ae7f6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 17:20:29 +0800 Subject: [PATCH 53/67] Simplify DNS router internals - Replace dnsRuleModeRequirements 4-tuple return with dnsRuleModeFlags struct - Eliminate redundant hasDNSRuleActionStrategy tree walk by reusing mode flags from buildRules - Remove single-field lookupWithRulesResponse wrapper - Accept fields directly in resolveDNSRoute instead of *RuleActionDNSRoute - Extract rulesAndMode() helper to deduplicate snapshot unpacking - Trim verbose RuleSetMetadata comment --- adapter/router.go | 4 +- dns/router.go | 205 +++++++++++++++++++--------------------------- 2 files changed, 87 insertions(+), 122 deletions(-) diff --git a/adapter/router.go b/adapter/router.go index b8564eb0ad..550aa66295 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -66,9 +66,7 @@ type RuleSet interface { type RuleSetUpdateCallback func(it RuleSet) -// Rule-set metadata only exposes headless-rule capabilities that outer routers -// need before evaluating nested matches. Headless rules do not support -// ip_version, so there is intentionally no ContainsIPVersionRule flag here. +// ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. type RuleSetMetadata struct { ContainsProcessRule bool ContainsWIFIRule bool diff --git a/dns/router.go b/dns/router.go index 52a87ae6d8..59d7163bd0 100644 --- a/dns/router.go +++ b/dns/router.go @@ -60,6 +60,13 @@ func (s *rulesSnapshot) retain() { s.references.Add(1) } +func (s *rulesSnapshot) rulesAndMode() ([]adapter.DNSRule, bool) { + if s == nil { + return nil, false + } + return s.rules, s.legacyDNSMode +} + func (s *rulesSnapshot) release() { if s == nil { return @@ -129,7 +136,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp func (r *Router) Initialize(rules []option.DNSRule) error { r.rawRules = append(r.rawRules[:0], rules...) - newRules, _, err := r.buildRules(false) + newRules, _, _, err := r.buildRules(false) if err != nil { return err } @@ -193,7 +200,7 @@ func (r *Router) rebuildRules(startRules bool) error { if r.isClosing() { return nil } - newRules, legacyDNSMode, err := r.buildRules(startRules) + newRules, legacyDNSMode, modeFlags, err := r.buildRules(startRules) if err != nil { if r.isClosing() { return nil @@ -207,7 +214,7 @@ func (r *Router) rebuildRules(startRules bool) error { shouldReportRuleStrategyDeprecated := startRules && legacyDNSMode && !r.ruleStrategyDeprecatedReported && - hasDNSRuleActionStrategy(r.rawRules) + modeFlags.neededFromStrategy newSnapshot := newRulesSnapshot(newRules, legacyDNSMode) r.stateAccess.Lock() if r.closing { @@ -247,22 +254,22 @@ func (r *Router) acquireRulesSnapshot() *rulesSnapshot { return snapshot } -func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { +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, E.Cause(err, "parse dns rule[", i, "]") + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") } } router := service.FromContext[adapter.Router](r.ctx) - legacyDNSMode, err := resolveLegacyDNSMode(router, r.rawRules) + legacyDNSMode, modeFlags, err := resolveLegacyDNSMode(router, r.rawRules) if err != nil { - return nil, false, err + return nil, false, dnsRuleModeFlags{}, err } if !legacyDNSMode { err = validateLegacyDNSModeDisabledRules(r.rawRules) if err != nil { - return nil, false, err + return nil, false, dnsRuleModeFlags{}, err } } newRules := make([]adapter.DNSRule, 0, len(r.rawRules)) @@ -271,7 +278,7 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { dnsRule, err = R.NewDNSRule(r.ctx, r.logger, ruleOptions, true, legacyDNSMode) if err != nil { closeRules(newRules) - return nil, false, E.Cause(err, "parse dns rule[", i, "]") + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "parse dns rule[", i, "]") } newRules = append(newRules, dnsRule) } @@ -280,11 +287,11 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, error) { err = rule.Start() if err != nil { closeRules(newRules) - return nil, false, E.Cause(err, "initialize DNS rule[", i, "]") + return nil, false, dnsRuleModeFlags{}, E.Cause(err, "initialize DNS rule[", i, "]") } } } - return newRules, legacyDNSMode, nil + return newRules, legacyDNSMode, modeFlags, nil } func closeRules(rules []adapter.DNSRule) { @@ -433,8 +440,8 @@ const ( dnsRouteStatusResolved ) -func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, dnsRouteStatus) { - transport, loaded := r.transport.Transport(action.Server) +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 } @@ -442,7 +449,7 @@ func (r *Router) resolveDNSRoute(action *R.RuleActionDNSRoute, allowFakeIP bool, if isFakeIP && !allowFakeIP { return transport, dnsRouteStatusSkipped } - r.applyDNSRouteOptions(options, action.RuleActionDNSRouteOptions) + r.applyDNSRouteOptions(options, routeOptions) if isFakeIP { options.DisableCache = true } @@ -484,10 +491,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, r.applyDNSRouteOptions(&effectiveOptions, *action) case *R.RuleActionEvaluate: queryOptions := effectiveOptions - transport, status := r.resolveDNSRoute(&R.RuleActionDNSRoute{ - Server: action.Server, - RuleActionDNSRouteOptions: action.RuleActionDNSRouteOptions, - }, allowFakeIP, &queryOptions) + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: r.logger.ErrorContext(ctx, "transport not found: ", action.Server) @@ -512,7 +516,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, savedResponse = response case *R.RuleActionDNSRoute: queryOptions := effectiveOptions - transport, status := r.resolveDNSRoute(action, allowFakeIP, &queryOptions) + transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: r.logger.ErrorContext(ctx, "transport not found: ", action.Server) @@ -569,10 +573,6 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, } } -type lookupWithRulesResponse struct { - addresses []netip.Addr -} - func (r *Router) resolveLookupStrategy(options adapter.DNSQueryOptions) C.DomainStrategy { if options.LookupStrategy != C.DomainStrategyAsIS { return options.LookupStrategy @@ -618,16 +618,14 @@ func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, d lookupOptions.Strategy = strategy } if strategy == C.DomainStrategyIPv4Only { - response, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) - return response.addresses, err + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeA, lookupOptions) } if strategy == C.DomainStrategyIPv6Only { - response, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) - return response.addresses, err + return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) } var ( - response4 lookupWithRulesResponse - response6 lookupWithRulesResponse + response4 []netip.Addr + response6 []netip.Addr ) var group task.Group group.Append("exchange4", func(ctx context.Context) error { @@ -641,13 +639,13 @@ func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, d return err }) err := group.Run(ctx) - if len(response4.addresses) == 0 && len(response6.addresses) == 0 { + if len(response4) == 0 && len(response6) == 0 { return nil, err } - return sortAddresses(response4.addresses, response6.addresses, strategy), nil + return sortAddresses(response4, response6, strategy), nil } -func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRule, domain string, qType uint16, options adapter.DNSQueryOptions) (lookupWithRulesResponse, error) { +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, @@ -659,18 +657,16 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul }}, } exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) - result := lookupWithRulesResponse{} if exchangeResult.rejectAction != nil { - return result, exchangeResult.rejectAction.Error(ctx) + return nil, exchangeResult.rejectAction.Error(ctx) } if exchangeResult.err != nil { - return result, exchangeResult.err + return nil, exchangeResult.err } if exchangeResult.response.Rcode != mDNS.RcodeSuccess { - return result, RcodeError(exchangeResult.response.Rcode) + return nil, RcodeError(exchangeResult.response.Rcode) } - result.addresses = filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType) - return result, nil + return filterAddressesByQueryType(MessageToAddresses(exchangeResult.response), qType), nil } func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapter.DNSQueryOptions) (*mDNS.Msg, error) { @@ -688,14 +684,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } snapshot := r.acquireRulesSnapshot() defer snapshot.release() - var ( - rules []adapter.DNSRule - legacyDNSMode bool - ) - if snapshot != nil { - rules = snapshot.rules - legacyDNSMode = snapshot.legacyDNSMode - } + rules, legacyDNSMode := snapshot.rulesAndMode() r.logger.DebugContext(ctx, "exchange ", FormatQuestion(message.Question[0].String())) var ( response *mDNS.Msg @@ -803,14 +792,7 @@ done: func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { snapshot := r.acquireRulesSnapshot() defer snapshot.release() - var ( - rules []adapter.DNSRule - legacyDNSMode bool - ) - if snapshot != nil { - rules = snapshot.rules - legacyDNSMode = snapshot.legacyDNSMode - } + rules, legacyDNSMode := snapshot.rulesAndMode() var ( responseAddrs []netip.Addr err error @@ -964,84 +946,92 @@ func defaultRuleDisablesLegacyDNSMode(rule option.DefaultDNSRule) bool { len(rule.QueryType) > 0 } -func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule) (bool, error) { - legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirements(router, rules) +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) (bool, dnsRuleModeFlags, error) { + flags, err := dnsRuleModeRequirements(router, rules) if err != nil { - return false, err + return false, flags, err } - if legacyDNSModeDisabled && needsLegacyDNSModeFromStrategy { - return false, E.New("DNS rule action strategy is only supported in legacyDNSMode") + if flags.disabled && flags.neededFromStrategy { + return false, flags, E.New("DNS rule action strategy is only supported in legacyDNSMode") } - if legacyDNSModeDisabled { - return false, nil + if flags.disabled { + return false, flags, nil } - return needsLegacyDNSMode, nil + return flags.needed, flags, nil } -func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (bool, bool, bool, error) { - var legacyDNSModeDisabled bool - var needsLegacyDNSMode bool - var needsLegacyDNSModeFromStrategy bool +func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (dnsRuleModeFlags, error) { + var flags dnsRuleModeFlags for i, rule := range rules { - ruleLegacyDNSModeDisabled, ruleNeedsLegacyDNSMode, ruleNeedsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirementsInRule(router, rule) + ruleFlags, err := dnsRuleModeRequirementsInRule(router, rule) if err != nil { - return false, false, false, E.Cause(err, "dns rule[", i, "]") + return dnsRuleModeFlags{}, E.Cause(err, "dns rule[", i, "]") } - legacyDNSModeDisabled = legacyDNSModeDisabled || ruleLegacyDNSModeDisabled - needsLegacyDNSMode = needsLegacyDNSMode || ruleNeedsLegacyDNSMode - needsLegacyDNSModeFromStrategy = needsLegacyDNSModeFromStrategy || ruleNeedsLegacyDNSModeFromStrategy + flags.merge(ruleFlags) } - return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil + return flags, nil } -func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (bool, bool, bool, error) { +func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (dnsRuleModeFlags, error) { switch rule.Type { case "", C.RuleTypeDefault: return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions) case C.RuleTypeLogical: - legacyDNSModeDisabled := dnsRuleActionType(rule) == C.RuleActionTypeEvaluate - needsLegacyDNSModeFromStrategy := dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction) - needsLegacyDNSMode := needsLegacyDNSModeFromStrategy + flags := dnsRuleModeFlags{ + disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate, + neededFromStrategy: dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction), + } + flags.needed = flags.neededFromStrategy for i, subRule := range rule.LogicalOptions.Rules { - subLegacyDNSModeDisabled, subNeedsLegacyDNSMode, subNeedsLegacyDNSModeFromStrategy, err := dnsRuleModeRequirementsInRule(router, subRule) + subFlags, err := dnsRuleModeRequirementsInRule(router, subRule) if err != nil { - return false, false, false, E.Cause(err, "sub rule[", i, "]") + return dnsRuleModeFlags{}, E.Cause(err, "sub rule[", i, "]") } - legacyDNSModeDisabled = legacyDNSModeDisabled || subLegacyDNSModeDisabled - needsLegacyDNSMode = needsLegacyDNSMode || subNeedsLegacyDNSMode - needsLegacyDNSModeFromStrategy = needsLegacyDNSModeFromStrategy || subNeedsLegacyDNSModeFromStrategy + flags.merge(subFlags) } - return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil + return flags, nil default: - return false, false, false, nil + return dnsRuleModeFlags{}, nil } } -func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (bool, bool, bool, error) { - legacyDNSModeDisabled := defaultRuleDisablesLegacyDNSMode(rule) - needsLegacyDNSModeFromStrategy := dnsRuleActionHasStrategy(rule.DNSRuleAction) - needsLegacyDNSMode := defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule) || needsLegacyDNSModeFromStrategy +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (dnsRuleModeFlags, error) { + flags := dnsRuleModeFlags{ + disabled: defaultRuleDisablesLegacyDNSMode(rule), + neededFromStrategy: dnsRuleActionHasStrategy(rule.DNSRuleAction), + } + flags.needed = defaultRuleNeedsLegacyDNSModeFromAddressFilter(rule) || flags.neededFromStrategy if len(rule.RuleSet) == 0 { - return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil + return flags, nil } if router == nil { - return false, false, false, E.New("router service not found") + return dnsRuleModeFlags{}, E.New("router service not found") } for _, tag := range rule.RuleSet { ruleSet, loaded := router.RuleSet(tag) if !loaded { - return false, false, false, E.New("rule-set not found: ", tag) + return dnsRuleModeFlags{}, E.New("rule-set not found: ", tag) } metadata := ruleSet.Metadata() - // Rule sets are built from headless rules, so query_type is the only - // per-query DNS predicate they can contribute here. ip_version is not a - // headless-rule item and is therefore intentionally absent from metadata. - legacyDNSModeDisabled = legacyDNSModeDisabled || metadata.ContainsDNSQueryTypeRule + // ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. + flags.disabled = flags.disabled || metadata.ContainsDNSQueryTypeRule if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { - needsLegacyDNSMode = true + flags.needed = true } } - return legacyDNSModeDisabled, needsLegacyDNSMode, needsLegacyDNSModeFromStrategy, nil + return flags, nil } func referencedDNSRuleSetTags(rules []option.DNSRule) []string { @@ -1126,29 +1116,6 @@ func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, return rule.MatchResponse, nil } -func hasDNSRuleActionStrategy(rules []option.DNSRule) bool { - for _, rule := range rules { - if dnsRuleHasActionStrategy(rule) { - return true - } - } - return false -} - -func dnsRuleHasActionStrategy(rule option.DNSRule) bool { - switch rule.Type { - case "", C.RuleTypeDefault: - return dnsRuleActionHasStrategy(rule.DefaultOptions.DNSRuleAction) - case C.RuleTypeLogical: - if dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction) { - return true - } - return hasDNSRuleActionStrategy(rule.LogicalOptions.Rules) - default: - return false - } -} - func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { switch action.Action { case "", C.RuleActionTypeRoute, C.RuleActionTypeEvaluate: From 78348540a958ee353f430a750699dd6e489addc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 18:27:14 +0800 Subject: [PATCH 54/67] Replace internal terminology in docs and error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all user-facing references to the internal `legacyDNSMode` variable name. Error messages now reuse the deprecated.Note.MessageWithLink() method to provide consistent deprecation text with migration URLs. Other internal jargon ("consume response state", "response_*") is replaced with user-friendly descriptions. Add two migration guide entries under 1.14.0: one for strategy → rule items, one for address filter fields → evaluate with match_response. --- dns/router.go | 12 +-- dns/router_test.go | 11 ++- docs/configuration/dns/rule.md | 30 +++---- docs/configuration/dns/rule.zh.md | 29 +++---- docs/configuration/dns/rule_action.md | 4 +- docs/configuration/dns/rule_action.zh.md | 4 +- docs/deprecated.md | 21 +++-- docs/deprecated.zh.md | 14 ++- docs/migration.md | 105 +++++++++++++++++++++++ docs/migration.zh.md | 105 +++++++++++++++++++++++ experimental/deprecated/constants.go | 10 +-- route/rule/rule_dns.go | 4 +- 12 files changed, 280 insertions(+), 69 deletions(-) diff --git a/dns/router.go b/dns/router.go index 59d7163bd0..f3167cfab9 100644 --- a/dns/router.go +++ b/dns/router.go @@ -964,7 +964,7 @@ func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule) (bool, return false, flags, err } if flags.disabled && flags.neededFromStrategy { - return false, flags, E.New("DNS rule action strategy is only supported in legacyDNSMode") + return false, flags, E.New(deprecated.OptionLegacyDNSRuleStrategy.MessageWithLink()) } if flags.disabled { return false, flags, nil @@ -1069,7 +1069,7 @@ func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { } action := dnsRuleActionType(rule) if action == C.RuleActionTypeEvaluate && consumesResponse { - return E.New("dns rule[", i, "]: evaluate rule cannot consume response state") + return E.New("dns rule[", i, "]: evaluate action cannot be used with match_response in the same rule") } } return nil @@ -1097,10 +1097,10 @@ func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, error) { hasResponseRecords := hasResponseMatchFields(rule) if hasResponseRecords && !rule.MatchResponse { - return false, E.New("response_* items require match_response") + return false, E.New("Response Match Fields (response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") } if (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { - return false, E.New("ip_cidr and ip_is_private require match_response when legacyDNSMode is disabled") + return false, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } // Intentionally do not reject rule_set here. A referenced rule set may mix // destination-IP predicates with pre-response predicates such as domain items. @@ -1108,10 +1108,10 @@ func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, // 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("ip_accept_any is removed when legacyDNSMode is disabled, use ip_cidr with match_response") + return false, E.New(deprecated.OptionIPAcceptAny.MessageWithLink()) } if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck - return false, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") + return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) } return rule.MatchResponse, nil } diff --git a/dns/router_test.go b/dns/router_test.go index f9e60ab8db..8d14ef1380 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -383,7 +383,8 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { }, }, }) - require.ErrorContains(t, err, "ip_cidr and ip_is_private require match_response") + require.ErrorContains(t, err, "Address Filter Fields") + require.ErrorContains(t, err, "deprecated") } func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { @@ -602,7 +603,7 @@ func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t * ContainsDNSQueryTypeRule: true, }) rebuildErrorEntry := waitForLogMessageContaining(t, logEntries, logDone, "rebuild DNS rules after rule-set update") - require.Contains(t, rebuildErrorEntry.Message, "ip_cidr and ip_is_private require match_response") + require.Contains(t, rebuildErrorEntry.Message, "Address Filter Fields") require.True(t, router.currentRules.Load().legacyDNSMode) require.Equal(t, 1, callbackRuleSet.refCount()) require.Zero(t, rebuildTargetRuleSet.refCount()) @@ -2128,7 +2129,8 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t }, }, }}) - require.ErrorContains(t, err, "legacyDNSMode") + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") } func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchResponse(t *testing.T) { @@ -2157,7 +2159,8 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo }, }, }}) - require.ErrorContains(t, err, "legacyDNSMode") + require.ErrorContains(t, err, "strategy") + require.ErrorContains(t, err, "deprecated") } func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testing.T) { diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index 19006be07a..b49f82d3f8 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -497,8 +497,8 @@ Enable response-based matching. When enabled, this rule matches against DNS resp (set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) instead of only matching the original query. -Required for `response_rcode`, `response_answer`, `response_ns`, `response_extra` fields. -Also required for `ip_cidr` and `ip_is_private` when `legacyDNSMode` is disabled. +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 @@ -544,16 +544,12 @@ See [DNS Rule Actions](../rule_action/) for details. Moved to [DNS Rule Action](../rule_action#route). -### Legacy DNS Mode +### Address Filter Fields -`legacyDNSMode` is an internal compatibility mode that is automatically detected from your DNS rule -configuration. It is disabled when any rule uses features introduced in sing-box 1.14.0 such as -`evaluate`, `match_response`, response fields (`response_rcode`, `response_answer`, etc.), -`query_type`, or `ip_version`. When disabled, `ip_cidr` and `ip_is_private` require `match_response` -to be set, and deprecated fields like `strategy`, `ip_accept_any`, and `rule_set_ip_cidr_accept_empty` -are no longer accepted. +!!! failure "Deprecated in sing-box 1.14.0" -### Address Filter Fields + 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. @@ -579,7 +575,8 @@ Match GeoIP with query response. Match IP CIDR with query response. -When `legacyDNSMode` is disabled, `match_response` must be set to `true`. +As an Address Filter Field, deprecated. Use with `match_response` instead, +check [Migration](/migration/#migrate-address-filter-fields-to-response-matching). #### ip_is_private @@ -587,7 +584,8 @@ When `legacyDNSMode` is disabled, `match_response` must be set to `true`. Match private IP with query response. -When `legacyDNSMode` is disabled, `match_response` must be set to `true`. +As an 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 @@ -595,8 +593,8 @@ When `legacyDNSMode` is disabled, `match_response` must be set to `true`. !!! 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. - Only supported in `legacyDNSMode`. + `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. @@ -606,8 +604,8 @@ Make `ip_cidr` rules in rule-sets accept empty query response. !!! failure "Deprecated in sing-box 1.14.0" - `ip_accept_any` is deprecated and will be removed in sing-box 1.16.0. - Only supported in `legacyDNSMode`. Use `match_response` with response items instead. + `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. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index d35474cf0c..b15fc871e2 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -495,8 +495,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 启用响应匹配。启用后,此规则将匹配 DNS 响应数据(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 -`response_rcode`、`response_answer`、`response_ns`、`response_extra` 字段需要此选项。 -当 `legacyDNSMode` 未启用时,`ip_cidr` 和 `ip_is_private` 也需要此选项。 +响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 +当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr` 和 `ip_is_private` 也需要此选项。 #### invert @@ -542,15 +542,12 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 已移动到 [DNS 规则动作](../rule_action#route). -### Legacy DNS Mode +### 地址筛选字段 -`legacyDNSMode` 是一种内部兼容模式,会根据 DNS 规则配置自动检测。 -当任何规则使用了 sing-box 1.14.0 引入的特性(如 `evaluate`、`match_response`、 -响应字段(`response_rcode`、`response_answer` 等)、`query_type` 或 `ip_version`)时, -该模式将被自动禁用。禁用后,`ip_cidr` 和 `ip_is_private` 需要设置 `match_response`, -且已废弃的字段(如 `strategy`、`ip_accept_any`、`rule_set_ip_cidr_accept_empty`)将不再被接受。 +!!! failure "已在 sing-box 1.14.0 废弃" -### 地址筛选字段 + 地址筛选字段已废弃,且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 仅对地址请求 (A/AAAA/HTTPS) 生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 @@ -577,7 +574,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配 IP CIDR。 -当 `legacyDNSMode` 未启用时,`match_response` 必须设为 `true`。 +作为地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 #### ip_is_private @@ -585,7 +583,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配非公开 IP。 -当 `legacyDNSMode` 未启用时,`match_response` 必须设为 `true`。 +作为地址筛选字段已废弃。请改为配合 `match_response` 使用, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 #### rule_set_ip_cidr_accept_empty @@ -593,8 +592,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! failure "已在 sing-box 1.14.0 废弃" - `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除。 - 仅在 `legacyDNSMode` 中可用。 + `rule_set_ip_cidr_accept_empty` 已废弃且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 使规则集中的 `ip_cidr` 规则接受空查询响应。 @@ -604,8 +603,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. !!! failure "已在 sing-box 1.14.0 废弃" - `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除。 - 仅在 `legacyDNSMode` 中可用。请使用 `match_response` 和响应项替代。 + `ip_accept_any` 已废弃且将在 sing-box 1.16.0 中被移除, + 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 匹配任意 IP。 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 2a7517528f..20471d3ee1 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -43,7 +43,7 @@ Tag of target server. `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. Only supported when `legacyDNSMode` is active. +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`. @@ -82,6 +82,8 @@ to match against using [`match_response`](/configuration/dns/rule/#match_respons Unlike `route`, it does **not** terminate rule evaluation. Only allowed on top-level DNS rules (not inside logical sub-rules). +The rule itself must not use `match_response` or contain sub-rules with Response Match Fields, +since `evaluate` populates the response for subsequent rules to consume. #### server diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index f0fa3bf2a7..8e0f8eff86 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -43,7 +43,7 @@ icon: material/new-box `strategy` 已在 sing-box 1.14.0 废弃,且将在 sing-box 1.16.0 中被移除。 -为此查询设置域名策略。仅在 `legacyDNSMode` 启用时可用。 +为此查询设置域名策略。已废弃,参阅[迁移指南](/zh/migration/#迁移-dns-规则动作-strategy-到规则项)。 可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。 @@ -80,6 +80,8 @@ icon: material/new-box `evaluate` 向指定服务器发送 DNS 查询并保存响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 +该规则本身不可使用 `match_response` 或包含带有响应匹配字段的子规则, +因为 `evaluate` 是为后续规则填充响应数据。 #### server diff --git a/docs/deprecated.md b/docs/deprecated.md index 2e48c5c01d..f1a0b91c90 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -16,31 +16,30 @@ Old fields will be removed in sing-box 1.16.0. #### `strategy` in DNS rule actions -`strategy` in DNS rule actions is deprecated -and only supported in `legacyDNSMode`. +`strategy` in DNS rule actions is deprecated, +check [Migration](../migration/#migrate-dns-rule-action-strategy-to-rule-items). Old fields will be removed in sing-box 1.16.0. #### `ip_accept_any` in DNS rules -`ip_accept_any` in DNS rules is deprecated -and only supported in `legacyDNSMode`. -Use `match_response` with response items instead. +`ip_accept_any` in DNS rules is deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). Old fields will be removed in sing-box 1.16.0. #### `rule_set_ip_cidr_accept_empty` in DNS rules -`rule_set_ip_cidr_accept_empty` in DNS rules is deprecated -and only supported in `legacyDNSMode`. +`rule_set_ip_cidr_accept_empty` in DNS rules 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 DNS rule items +#### Address Filter Fields in DNS rules -Legacy address filter DNS rule items (`ip_cidr`, `ip_is_private` without `match_response`) -are deprecated and only supported in `legacyDNSMode`. -Use `match_response` with the `evaluate` action instead. +Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) +are deprecated, +check [Migration](../migration/#migrate-address-filter-fields-to-response-matching). Old behavior will be removed in sing-box 1.16.0. diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 43806920c6..47ce260eed 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -17,30 +17,28 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, #### DNS 规则动作中的 `strategy` DNS 规则动作中的 `strategy` 已废弃, -且仅在 `legacyDNSMode` 中可用。 +参阅[迁移指南](/zh/migration/#迁移-dns-规则动作-strategy-到规则项)。 旧字段将在 sing-box 1.16.0 中被移除。 #### DNS 规则中的 `ip_accept_any` DNS 规则中的 `ip_accept_any` 已废弃, -且仅在 `legacyDNSMode` 中可用。 -请使用 `match_response` 和响应项替代。 +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧字段将在 sing-box 1.16.0 中被移除。 #### DNS 规则中的 `rule_set_ip_cidr_accept_empty` DNS 规则中的 `rule_set_ip_cidr_accept_empty` 已废弃, -且仅在 `legacyDNSMode` 中可用。 +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧字段将在 sing-box 1.16.0 中被移除。 -#### 旧的地址筛选 DNS 规则项 +#### 地址筛选 DNS 规则项 -旧的地址筛选 DNS 规则项(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, -且仅在 `legacyDNSMode` 中可用。 -请使用 `match_response` 和 `evaluate` 动作替代。 +地址筛选 DNS 规则项(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧行为将在 sing-box 1.16.0 中被移除。 diff --git a/docs/migration.md b/docs/migration.md index 810bae190a..956668dc87 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 + +`strategy` in DNS rule actions 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 + +Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) are deprecated, +along with `ip_accept_any` and `rule_set_ip_cidr_accept_empty`. + +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..121bd06796 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` 也已废弃。 + +在 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 c9f507387b..16e487ff6b 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -99,7 +99,7 @@ var OptionIPAcceptAny = Note{ DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "DNS_RULE_IP_ACCEPT_ANY", - MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", } var OptionRuleSetIPCIDRAcceptEmpty = Note{ @@ -108,16 +108,16 @@ var OptionRuleSetIPCIDRAcceptEmpty = Note{ DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "DNS_RULE_RULE_SET_IP_CIDR_ACCEPT_EMPTY", - MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule/", + 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 DNS rule items", + Description: "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/configuration/dns/rule/", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-address-filter-fields-to-response-matching", } var OptionLegacyDNSRuleStrategy = Note{ @@ -126,7 +126,7 @@ var OptionLegacyDNSRuleStrategy = Note{ DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "LEGACY_DNS_RULE_STRATEGY", - MigrationLink: "https://sing-box.sagernet.org/configuration/dns/rule_action/", + MigrationLink: "https://sing-box.sagernet.org/migration/#migrate-dns-rule-action-strategy-to-rule-items", } var Options = []Note{ diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 1163adb510..30388b3b2e 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -181,7 +181,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionIPAcceptAny) } else { - return nil, E.New("ip_accept_any is removed when legacyDNSMode is disabled, use ip_cidr with match_response") + return nil, E.New(deprecated.OptionIPAcceptAny.MessageWithLink()) } item := NewIPAcceptAnyItem() rule.destinationIPCIDRItems = append(rule.destinationIPCIDRItems, item) @@ -339,7 +339,7 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) } else { - return nil, E.New("rule_set_ip_cidr_accept_empty is removed when legacyDNSMode is disabled") + return nil, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) } } item := NewRuleSetItem(router, options.RuleSet, matchSource, options.RuleSetIPCIDRAcceptEmpty) //nolint:staticcheck From 40e40ea7a6959a4efefa0799a2cbb2f0e10b5d23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 18:50:19 +0800 Subject: [PATCH 55/67] Fix evaluate response-match validation --- dns/router.go | 19 ++-- dns/router_test.go | 134 +++++++++++++++++++++++ docs/configuration/dns/rule_action.md | 5 +- docs/configuration/dns/rule_action.zh.md | 5 +- 4 files changed, 151 insertions(+), 12 deletions(-) diff --git a/dns/router.go b/dns/router.go index f3167cfab9..fd86319ab7 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1062,14 +1062,17 @@ func referencedDNSRuleSetTags(rules []option.DNSRule) []string { } func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { + var seenEvaluate bool for i, rule := range rules { - consumesResponse, err := validateLegacyDNSModeDisabledRuleTree(rule) + requiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(rule) if err != nil { return E.Cause(err, "validate dns rule[", i, "]") } - action := dnsRuleActionType(rule) - if action == C.RuleActionTypeEvaluate && consumesResponse { - return E.New("dns rule[", i, "]: evaluate action cannot be used with match_response in the same rule") + 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 @@ -1080,15 +1083,15 @@ func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { case "", C.RuleTypeDefault: return validateLegacyDNSModeDisabledDefaultRule(rule.DefaultOptions) case C.RuleTypeLogical: - var consumesResponse bool + var requiresPriorEvaluate bool for i, subRule := range rule.LogicalOptions.Rules { - subConsumesResponse, err := validateLegacyDNSModeDisabledRuleTree(subRule) + subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(subRule) if err != nil { return false, E.Cause(err, "sub rule[", i, "]") } - consumesResponse = consumesResponse || subConsumesResponse + requiresPriorEvaluate = requiresPriorEvaluate || subRequiresPriorEvaluate } - return consumesResponse, nil + return requiresPriorEvaluate, nil default: return false, nil } diff --git a/dns/router_test.go b/dns/router_test.go index 8d14ef1380..e436db7f4d 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -2163,6 +2163,140 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 2), false)) + 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() diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 20471d3ee1..b64f7c02d9 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -82,8 +82,9 @@ to match against using [`match_response`](/configuration/dns/rule/#match_respons Unlike `route`, it does **not** terminate rule evaluation. Only allowed on top-level DNS rules (not inside logical sub-rules). -The rule itself must not use `match_response` or contain sub-rules with Response Match Fields, -since `evaluate` populates the response for subsequent rules to consume. +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 diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 8e0f8eff86..0ff9d0c08c 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -80,8 +80,9 @@ icon: material/new-box `evaluate` 向指定服务器发送 DNS 查询并保存响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 -该规则本身不可使用 `match_response` 或包含带有响应匹配字段的子规则, -因为 `evaluate` 是为后续规则填充响应数据。 +使用 [`match_response`](/zh/configuration/dns/rule/#match_response) 或响应匹配字段的规则, +需要位于更早的顶层 `evaluate` 规则之后。规则自身的 `evaluate` 动作不能满足这个条件, +因为匹配发生在动作执行之前。 #### server From bbccdbcc92fd0d6ac356634996283b08efbf5f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 21:28:34 +0800 Subject: [PATCH 56/67] dns: reject evaluate fakeip servers --- dns/router.go | 36 +++++++++++ dns/router_test.go | 149 +++++++++++++++++++++++++-------------------- 2 files changed, 120 insertions(+), 65 deletions(-) diff --git a/dns/router.go b/dns/router.go index fd86319ab7..dd637a6cce 100644 --- a/dns/router.go +++ b/dns/router.go @@ -272,6 +272,10 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleMo 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 @@ -1078,6 +1082,27 @@ func validateLegacyDNSModeDisabledRules(rules []option.DNSRule) error { 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: @@ -1146,3 +1171,14 @@ func dnsRuleActionType(rule option.DNSRule) string { 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 index e436db7f4d..8d1a0770b8 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -2022,84 +2022,39 @@ func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("2.2.2.2")}, addresses) } -func TestLookupLegacyDNSModeDisabledEvaluateSkipFakeIPPreservesResponse(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledAllowsRouteFakeIPRule(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.RuleActionTypeEvaluate, - RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, - }, + 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"}, }, - }, - { - 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"}, - }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "fake"}, }, }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "upstream": &fakeDNSTransport{tag: "upstream", transportType: C.DNSTypeUDP}, - "fake": &fakeDNSTransport{tag: "fake", transportType: C.DNSTypeFakeIP}, - "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "fake": fakeTransport, }, }, &fakeDNSClient{ exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "upstream": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil - } - return FixedResponse(0, message.Question[0], nil, 60), nil - case "selected": - 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 - case "default": - if message.Question[0].Qtype == mDNS.TypeA { - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("4.4.4.4")}, 60), nil - } - return FixedResponse(0, message.Question[0], nil, 60), nil - default: - return nil, E.New("unexpected transport") - } + require.Same(t, fakeTransport, transport) + return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("198.18.0.1")}, 60), nil }, }) - router.currentRules.Load().legacyDNSMode = false - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + 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("2.2.2.2")}, addresses) + require.Equal(t, []netip.Addr{netip.MustParseAddr("198.18.0.1")}, MessageToAddresses(response)) } func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t *testing.T) { @@ -2133,6 +2088,70 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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() From 6daed349b6bacea43515712581e65761578290f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 21:59:25 +0800 Subject: [PATCH 57/67] Add DNS respond rule action --- constant/rule.go | 1 + dns/router.go | 23 ++- dns/router_test.go | 218 +++++++++++++++++++++++ docs/configuration/dns/rule.md | 4 + docs/configuration/dns/rule.zh.md | 4 + docs/configuration/dns/rule_action.md | 20 +++ docs/configuration/dns/rule_action.zh.md | 20 +++ option/rule_action.go | 10 ++ option/rule_action_test.go | 29 +++ route/rule/rule_action.go | 12 ++ 10 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 option/rule_action_test.go diff --git a/constant/rule.go b/constant/rule.go index 2a5aaefda8..15d71c5301 100644 --- a/constant/rule.go +++ b/constant/rule.go @@ -30,6 +30,7 @@ const ( RuleActionTypeRoute = "route" RuleActionTypeRouteOptions = "route-options" RuleActionTypeEvaluate = "evaluate" + RuleActionTypeRespond = "respond" RuleActionTypeDirect = "direct" RuleActionTypeBypass = "bypass" RuleActionTypeReject = "reject" diff --git a/dns/router.go b/dns/router.go index dd637a6cce..e7980c1e4b 100644 --- a/dns/router.go +++ b/dns/router.go @@ -475,6 +475,8 @@ type exchangeWithRulesResult struct { err error } +const dnsRespondMissingResponseMessage = "respond action requires a saved DNS 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 { @@ -482,6 +484,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, } effectiveOptions := options var savedResponse *mDNS.Msg + var savedTransport adapter.DNSTransport for currentRuleIndex, currentRule := range rules { metadata.ResetRuleCache() metadata.DNSResponse = savedResponse @@ -500,6 +503,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, case dnsRouteStatusMissing: r.logger.ErrorContext(ctx, "transport not found: ", action.Server) savedResponse = nil + savedTransport = nil continue case dnsRouteStatusSkipped: continue @@ -515,9 +519,21 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, } r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) savedResponse = nil + savedTransport = nil continue } savedResponse = response + savedTransport = transport + case *R.RuleActionRespond: + if savedResponse == nil { + return exchangeWithRulesResult{ + err: E.New(dnsRespondMissingResponseMessage), + } + } + return exchangeWithRulesResult{ + response: savedResponse, + transport: savedTransport, + } case *R.RuleActionDNSRoute: queryOptions := effectiveOptions transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) @@ -946,6 +962,7 @@ 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 } @@ -994,7 +1011,7 @@ func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) ( return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions) case C.RuleTypeLogical: flags := dnsRuleModeFlags{ - disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate, + disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate || dnsRuleActionType(rule) == C.RuleActionTypeRespond, neededFromStrategy: dnsRuleActionHasStrategy(rule.LogicalOptions.DNSRuleAction), } flags.needed = flags.neededFromStrategy @@ -1108,7 +1125,7 @@ func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { case "", C.RuleTypeDefault: return validateLegacyDNSModeDisabledDefaultRule(rule.DefaultOptions) case C.RuleTypeLogical: - var requiresPriorEvaluate bool + requiresPriorEvaluate := dnsRuleActionType(rule) == C.RuleActionTypeRespond for i, subRule := range rule.LogicalOptions.Rules { subRequiresPriorEvaluate, err := validateLegacyDNSModeDisabledRuleTree(subRule) if err != nil { @@ -1141,7 +1158,7 @@ func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, if rule.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck return false, E.New(deprecated.OptionRuleSetIPCIDRAcceptEmpty.MessageWithLink()) } - return rule.MatchResponse, nil + return rule.MatchResponse || rule.Action == C.RuleActionTypeRespond, nil } func dnsRuleActionHasStrategy(action option.DNSRuleAction) bool { diff --git a/dns/router_test.go b/dns/router_test.go index 8d1a0770b8..2227e0d82b 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1956,6 +1956,164 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo } } +func TestExchangeLegacyDNSModeDisabledRespondReturnsSavedResponse(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.currentRules.Load().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 TestLookupLegacyDNSModeDisabledRespondReturnsSavedResponse(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.currentRules.Load().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 TestExchangeLegacyDNSModeDisabledRespondWithoutSavedResponseReturnsError(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.currentRules.Load().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 TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { t.Parallel() @@ -2210,6 +2368,66 @@ func TestInitializeRejectsDNSMatchResponseWithoutPrecedingEvaluate(t *testing.T) 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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, + } + router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) + 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() diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index b49f82d3f8..ef5110eed6 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -497,6 +497,8 @@ Enable response-based matching. When enabled, this rule matches against DNS resp (set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) instead of only matching the original query. +The saved 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. @@ -616,6 +618,8 @@ Match any IP with query response. 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 saved response may also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. + #### response_rcode Match DNS response code. diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index b15fc871e2..4fc505891a 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -495,6 +495,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 启用响应匹配。启用后,此规则将匹配 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` 也需要此选项。 @@ -615,6 +617,8 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. DNS 响应数据的匹配字段。需要将 `match_response` 设为 `true`, 且需要前序规则使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作来填充响应。 +该已保存的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 + #### response_rcode 匹配 DNS 响应码。 diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index b64f7c02d9..6e44fbb9c9 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [respond](#respond) + !!! quote "Changes in sing-box 1.14.0" :material-plus: [evaluate](#evaluate) @@ -108,6 +112,22 @@ If value is an IP address instead of prefix, `/32` or `/128` will be appended au 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 a saved response at runtime, the request fails with an error instead of falling through to later rules. + ### route-options ```json diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index 0ff9d0c08c..b7d9b0dbd5 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [respond](#respond) + !!! quote "sing-box 1.14.0 中的更改" :material-plus: [evaluate](#evaluate) @@ -106,6 +110,22 @@ icon: material/new-box 将覆盖 `dns.client_subnet`. +### respond + +!!! question "自 sing-box 1.14.0 起" + +```json +{ + "action": "respond" +} +``` + +`respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的 DNS 响应。 + +此动作不会发起新的 DNS 查询,也没有额外选项。 + +只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已保存的响应,则请求会直接返回错误,而不是继续匹配后续规则。 + ### route-options ```json diff --git a/option/rule_action.go b/option/rule_action.go index 027e800766..212396b7b9 100644 --- a/option/rule_action.go +++ b/option/rule_action.go @@ -117,6 +117,8 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) { v = r.RouteOptions case C.RuleActionTypeEvaluate: v = r.RouteOptions + case C.RuleActionTypeRespond: + v = nil case C.RuleActionTypeRouteOptions: v = r.RouteOptionsOptions case C.RuleActionTypeReject: @@ -126,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) } @@ -141,6 +146,8 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e v = &r.RouteOptions case C.RuleActionTypeEvaluate: v = &r.RouteOptions + case C.RuleActionTypeRespond: + v = nil case C.RuleActionTypeRouteOptions: v = &r.RouteOptionsOptions case C.RuleActionTypeReject: @@ -150,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/route/rule/rule_action.go b/route/rule/rule_action.go index 194fed39f0..2fe6ba98a4 100644 --- a/route/rule/rule_action.go +++ b/route/rule/rule_action.go @@ -142,6 +142,8 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction) ClientSubnet: netip.Prefix(common.PtrValueOrDefault(action.RouteOptions.ClientSubnet)), }, } + case C.RuleActionTypeRespond: + return &RuleActionRespond{} case C.RuleActionTypeRouteOptions: return &RuleActionDNSRouteOptions{ Strategy: C.DomainStrategy(action.RouteOptionsOptions.Strategy), @@ -292,6 +294,16 @@ 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, server) From 3c7bc5a84c0b6161c75226c2cf196bd65ed232b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 22:10:30 +0800 Subject: [PATCH 58/67] Unify evaluate-produced DNS message terminology to "evaluated response" --- dns/router.go | 26 ++++++++++++------------ dns/router_test.go | 6 +++--- docs/configuration/dns/rule.md | 4 ++-- docs/configuration/dns/rule.zh.md | 4 ++-- docs/configuration/dns/rule_action.md | 4 ++-- docs/configuration/dns/rule_action.zh.md | 6 +++--- 6 files changed, 25 insertions(+), 25 deletions(-) diff --git a/dns/router.go b/dns/router.go index e7980c1e4b..0ad1b0c303 100644 --- a/dns/router.go +++ b/dns/router.go @@ -475,7 +475,7 @@ type exchangeWithRulesResult struct { err error } -const dnsRespondMissingResponseMessage = "respond action requires a saved DNS response from a preceding evaluate action" +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) @@ -483,11 +483,11 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, panic("no context") } effectiveOptions := options - var savedResponse *mDNS.Msg - var savedTransport adapter.DNSTransport + var evaluatedResponse *mDNS.Msg + var evaluatedTransport adapter.DNSTransport for currentRuleIndex, currentRule := range rules { metadata.ResetRuleCache() - metadata.DNSResponse = savedResponse + metadata.DNSResponse = evaluatedResponse metadata.DestinationAddressMatchFromResponse = false if !currentRule.Match(metadata) { continue @@ -502,8 +502,8 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, switch status { case dnsRouteStatusMissing: r.logger.ErrorContext(ctx, "transport not found: ", action.Server) - savedResponse = nil - savedTransport = nil + evaluatedResponse = nil + evaluatedTransport = nil continue case dnsRouteStatusSkipped: continue @@ -518,21 +518,21 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, return exchangeWithRulesResult{err: err} } r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) - savedResponse = nil - savedTransport = nil + evaluatedResponse = nil + evaluatedTransport = nil continue } - savedResponse = response - savedTransport = transport + evaluatedResponse = response + evaluatedTransport = transport case *R.RuleActionRespond: - if savedResponse == nil { + if evaluatedResponse == nil { return exchangeWithRulesResult{ err: E.New(dnsRespondMissingResponseMessage), } } return exchangeWithRulesResult{ - response: savedResponse, - transport: savedTransport, + response: evaluatedResponse, + transport: evaluatedTransport, } case *R.RuleActionDNSRoute: queryOptions := effectiveOptions diff --git a/dns/router_test.go b/dns/router_test.go index 2227e0d82b..aff6a318db 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1956,7 +1956,7 @@ func TestExchangeLegacyDNSModeDisabledEvaluateExchangeFailureUsesMatchResponseBo } } -func TestExchangeLegacyDNSModeDisabledRespondReturnsSavedResponse(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { t.Parallel() var exchanges []string @@ -2008,7 +2008,7 @@ func TestExchangeLegacyDNSModeDisabledRespondReturnsSavedResponse(t *testing.T) require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, MessageToAddresses(response)) } -func TestLookupLegacyDNSModeDisabledRespondReturnsSavedResponse(t *testing.T) { +func TestLookupLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -2065,7 +2065,7 @@ func TestLookupLegacyDNSModeDisabledRespondReturnsSavedResponse(t *testing.T) { }, addresses) } -func TestExchangeLegacyDNSModeDisabledRespondWithoutSavedResponseReturnsError(t *testing.T) { +func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsError(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index ef5110eed6..190192df4e 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -497,7 +497,7 @@ Enable response-based matching. When enabled, this rule matches against DNS resp (set by a preceding [`evaluate`](/configuration/dns/rule_action/#evaluate) action) instead of only matching the original query. -The saved response can also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. +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. @@ -618,7 +618,7 @@ Match any IP with query response. 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 saved response may also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. +That evaluated response may also be returned directly by a later [`respond`](/configuration/dns/rule_action/#respond) action. #### response_rcode diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 4fc505891a..fb215b0c04 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -495,7 +495,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 启用响应匹配。启用后,此规则将匹配 DNS 响应数据(由前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作设置),而不仅是匹配原始查询。 -该已保存的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 响应匹配字段(`response_rcode`、`response_answer`、`response_ns`、`response_extra`)需要此选项。 当与 `evaluate` 或响应匹配字段一起使用时,`ip_cidr` 和 `ip_is_private` 也需要此选项。 @@ -617,7 +617,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. DNS 响应数据的匹配字段。需要将 `match_response` 设为 `true`, 且需要前序规则使用 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作来填充响应。 -该已保存的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 +该已评估的响应也可以被后续的 [`respond`](/zh/configuration/dns/rule_action/#respond) 动作直接返回。 #### response_rcode diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 6e44fbb9c9..678053e610 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -81,7 +81,7 @@ Will override `dns.client_subnet`. } ``` -`evaluate` sends a DNS query to the specified server and saves the response for subsequent rules +`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. @@ -126,7 +126,7 @@ Will override `dns.client_subnet`. 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 a saved response at runtime, the request fails with an error instead of falling through to later rules. +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 b7d9b0dbd5..a9db918206 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -81,7 +81,7 @@ icon: material/new-box } ``` -`evaluate` 向指定服务器发送 DNS 查询并保存响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 +`evaluate` 向指定服务器发送 DNS 查询并保存已评估的响应,供后续规则通过 [`match_response`](/zh/configuration/dns/rule/#match_response) 和响应字段进行匹配。与 `route` 不同,它**不会**终止规则评估。 仅允许在顶层 DNS 规则中使用(不可在逻辑子规则内部使用)。 使用 [`match_response`](/zh/configuration/dns/rule/#match_response) 或响应匹配字段的规则, @@ -120,11 +120,11 @@ icon: material/new-box } ``` -`respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的 DNS 响应。 +`respond` 会终止规则评估,并直接返回前序 [`evaluate`](/zh/configuration/dns/rule_action/#evaluate) 动作保存的已评估的响应。 此动作不会发起新的 DNS 查询,也没有额外选项。 -只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已保存的响应,则请求会直接返回错误,而不是继续匹配后续规则。 +只能用于前面已有顶层 `evaluate` 规则的场景。如果运行时命中该动作时没有已评估的响应,则请求会直接返回错误,而不是继续匹配后续规则。 ### route-options From 8e10b22463dee75168af34d47eaa5e69ddb05651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 1 Apr 2026 22:49:22 +0800 Subject: [PATCH 59/67] Standardize legacy DNS feature terminology in docs and error messages Use concrete feature names per design spec: "Legacy Address Filter Fields", "Legacy `strategy` DNS rule action option", "Legacy `ip_accept_any` DNS rule item", "Legacy `rule_set_ip_cidr_accept_empty` DNS rule item" for deprecated features, and "Response Match Fields" for the new response matching section. --- docs/changelog.md | 8 ++++---- docs/configuration/dns/rule.md | 10 +++++----- docs/configuration/dns/rule.zh.md | 10 +++++----- docs/configuration/experimental/cache-file.md | 2 +- .../experimental/cache-file.zh.md | 2 +- docs/deprecated.md | 18 +++++++++--------- docs/deprecated.zh.md | 16 ++++++++-------- docs/migration.md | 6 +++--- docs/migration.zh.md | 6 +++--- experimental/deprecated/constants.go | 8 ++++---- 10 files changed, 43 insertions(+), 43 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 3ef3ca02ae..342fbce7d0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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/rule.md b/docs/configuration/dns/rule.md index 190192df4e..64bfc77119 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -546,11 +546,11 @@ 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" - Address Filter Fields are deprecated and will be removed in sing-box 1.16.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. @@ -577,7 +577,7 @@ Match GeoIP with query response. Match IP CIDR with query response. -As an Address Filter Field, deprecated. Use with `match_response` instead, +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 @@ -586,7 +586,7 @@ check [Migration](/migration/#migrate-address-filter-fields-to-response-matching Match private IP with query response. -As an Address Filter Field, deprecated. Use with `match_response` instead, +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 @@ -611,7 +611,7 @@ Make `ip_cidr` rules in rule-sets accept empty query response. Match any IP with query response. -### Response Fields +### Response Match Fields !!! question "Since sing-box 1.14.0" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index fb215b0c04..02e6131456 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -544,11 +544,11 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 已移动到 [DNS 规则动作](../rule_action#route). -### 地址筛选字段 +### 旧版地址筛选字段 !!! failure "已在 sing-box 1.14.0 废弃" - 地址筛选字段已废弃,且将在 sing-box 1.16.0 中被移除, + 旧版地址筛选字段已废弃,且将在 sing-box 1.16.0 中被移除, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 仅对地址请求 (A/AAAA/HTTPS) 生效。 当查询结果与地址筛选规则项不匹配时,将跳过当前规则。 @@ -576,7 +576,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配 IP CIDR。 -作为地址筛选字段已废弃。请改为配合 `match_response` 使用, +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 #### ip_is_private @@ -585,7 +585,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 与查询响应匹配非公开 IP。 -作为地址筛选字段已废弃。请改为配合 `match_response` 使用, +作为旧版地址筛选字段已废弃。请改为配合 `match_response` 使用, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 #### rule_set_ip_cidr_accept_empty @@ -610,7 +610,7 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配任意 IP。 -### 响应字段 +### 响应匹配字段 !!! question "自 sing-box 1.14.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/deprecated.md b/docs/deprecated.md index f1a0b91c90..7a2c5f4e74 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -14,31 +14,31 @@ check [Migration](../migration/#migrate-inline-acme-to-certificate-provider). Old fields will be removed in sing-box 1.16.0. -#### `strategy` in DNS rule actions +#### Legacy `strategy` DNS rule action option -`strategy` in DNS rule actions is deprecated, +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. -#### `ip_accept_any` in DNS rules +#### Legacy `ip_accept_any` DNS rule item -`ip_accept_any` in DNS rules is deprecated, +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. -#### `rule_set_ip_cidr_accept_empty` in DNS rules +#### Legacy `rule_set_ip_cidr_accept_empty` DNS rule item -`rule_set_ip_cidr_accept_empty` in DNS rules is deprecated, +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. -#### Address Filter Fields in DNS rules +#### Legacy Address Filter Fields in DNS rules -Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) -are deprecated, +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. diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md index 47ce260eed..f98b0c010a 100644 --- a/docs/deprecated.zh.md +++ b/docs/deprecated.zh.md @@ -14,30 +14,30 @@ TLS 中的内联 ACME 选项(`tls.acme`)已废弃, 旧字段将在 sing-box 1.16.0 中被移除。 -#### DNS 规则动作中的 `strategy` +#### 旧版 DNS 规则动作 `strategy` 选项 -DNS 规则动作中的 `strategy` 已废弃, +旧版 DNS 规则动作 `strategy` 选项已废弃, 参阅[迁移指南](/zh/migration/#迁移-dns-规则动作-strategy-到规则项)。 旧字段将在 sing-box 1.16.0 中被移除。 -#### DNS 规则中的 `ip_accept_any` +#### 旧版 `ip_accept_any` DNS 规则项 -DNS 规则中的 `ip_accept_any` 已废弃, +旧版 `ip_accept_any` DNS 规则项已废弃, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧字段将在 sing-box 1.16.0 中被移除。 -#### DNS 规则中的 `rule_set_ip_cidr_accept_empty` +#### 旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项 -DNS 规则中的 `rule_set_ip_cidr_accept_empty` 已废弃, +旧版 `rule_set_ip_cidr_accept_empty` DNS 规则项已废弃, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧字段将在 sing-box 1.16.0 中被移除。 -#### 地址筛选 DNS 规则项 +#### 旧版地址筛选字段 (DNS 规则) -地址筛选 DNS 规则项(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, +旧版地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, 参阅[迁移指南](/zh/migration/#迁移地址筛选字段到响应匹配)。 旧行为将在 sing-box 1.16.0 中被移除。 diff --git a/docs/migration.md b/docs/migration.md index 956668dc87..91e771babd 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -81,7 +81,7 @@ See [ACME](/configuration/shared/certificate-provider/acme/) for fields newly ad ### Migrate DNS rule action strategy to rule items -`strategy` in DNS rule actions is deprecated. +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. @@ -128,8 +128,8 @@ Use `ip_version` or `query_type` rule items to control which query types a rule ### Migrate address filter fields to response matching -Address Filter Fields (`ip_cidr`, `ip_is_private` without `match_response`) are deprecated, -along with `ip_accept_any` and `rule_set_ip_cidr_accept_empty`. +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`. diff --git a/docs/migration.zh.md b/docs/migration.zh.md index 121bd06796..3f12740553 100644 --- a/docs/migration.zh.md +++ b/docs/migration.zh.md @@ -81,7 +81,7 @@ sing-box 1.14.0 新增字段参阅 [ACME](/zh/configuration/shared/certificate-p ### 迁移 DNS 规则动作 strategy 到规则项 -DNS 规则动作中的 `strategy` 已废弃。 +旧版 DNS 规则动作 `strategy` 选项已废弃。 在 sing-box 1.14.0 中,内部域名解析(Lookup)现在在规则层拆分 A 和 AAAA 查询, 每种查询类型独立通过完整的规则链评估。 @@ -128,8 +128,8 @@ DNS 规则动作中的 `strategy` 已废弃。 ### 迁移地址筛选字段到响应匹配 -地址筛选字段(不使用 `match_response` 的 `ip_cidr`、`ip_is_private`)已废弃, -`ip_accept_any` 和 `rule_set_ip_cidr_accept_empty` 也已废弃。 +旧版地址筛选字段(不使用 `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` 显式匹配。 diff --git a/experimental/deprecated/constants.go b/experimental/deprecated/constants.go index 16e487ff6b..543a10bb6c 100644 --- a/experimental/deprecated/constants.go +++ b/experimental/deprecated/constants.go @@ -95,7 +95,7 @@ var OptionInlineACME = Note{ var OptionIPAcceptAny = Note{ Name: "dns-rule-ip-accept-any", - Description: "`ip_accept_any` in DNS rules", + Description: "Legacy `ip_accept_any` DNS rule item", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "DNS_RULE_IP_ACCEPT_ANY", @@ -104,7 +104,7 @@ var OptionIPAcceptAny = Note{ var OptionRuleSetIPCIDRAcceptEmpty = Note{ Name: "dns-rule-rule-set-ip-cidr-accept-empty", - Description: "`rule_set_ip_cidr_accept_empty` in DNS rules", + 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", @@ -113,7 +113,7 @@ var OptionRuleSetIPCIDRAcceptEmpty = Note{ var OptionLegacyDNSAddressFilter = Note{ Name: "legacy-dns-address-filter", - Description: "Address Filter Fields in DNS rules", + Description: "Legacy Address Filter Fields in DNS rules", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "LEGACY_DNS_ADDRESS_FILTER", @@ -122,7 +122,7 @@ var OptionLegacyDNSAddressFilter = Note{ var OptionLegacyDNSRuleStrategy = Note{ Name: "legacy-dns-rule-strategy", - Description: "`strategy` in DNS rule actions", + Description: "Legacy `strategy` DNS rule action option", DeprecatedVersion: "1.14.0", ScheduledVersion: "1.16.0", EnvName: "LEGACY_DNS_RULE_STRATEGY", From c3fdf5219eafcb74c434b2dadc270b54cb9181a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 00:24:16 +0800 Subject: [PATCH 60/67] dns: validate rule-set updates before commit --- adapter/router.go | 4 + box.go | 1 + dns/router.go | 311 +++----- dns/router_test.go | 708 +++++------------- route/rule/rule_set.go | 18 + route/rule/rule_set_local.go | 10 +- route/rule/rule_set_remote.go | 10 +- route/rule/rule_set_update_validation_test.go | 110 +++ 8 files changed, 440 insertions(+), 732 deletions(-) create mode 100644 route/rule/rule_set_update_validation_test.go diff --git a/adapter/router.go b/adapter/router.go index 550aa66295..f1e3da9a0c 100644 --- a/adapter/router.go +++ b/adapter/router.go @@ -66,6 +66,10 @@ 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 diff --git a/box.go b/box.go index 82403a29cd..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") diff --git a/dns/router.go b/dns/router.go index 0ad1b0c303..a485e599c1 100644 --- a/dns/router.go +++ b/dns/router.go @@ -6,7 +6,6 @@ import ( "net/netip" "strings" "sync" - "sync/atomic" "time" "github.com/sagernet/sing-box/adapter" @@ -23,7 +22,6 @@ import ( "github.com/sagernet/sing/common/logger" M "github.com/sagernet/sing/common/metadata" "github.com/sagernet/sing/common/task" - "github.com/sagernet/sing/common/x/list" "github.com/sagernet/sing/contrab/freelru" "github.com/sagernet/sing/contrab/maphash" "github.com/sagernet/sing/service" @@ -32,54 +30,7 @@ import ( ) var _ adapter.DNSRouter = (*Router)(nil) - -type dnsRuleSetCallback struct { - ruleSet adapter.RuleSet - element *list.Element[adapter.RuleSetUpdateCallback] -} - -type rulesSnapshot struct { - rules []adapter.DNSRule - legacyDNSMode bool - references atomic.Int64 -} - -func newRulesSnapshot(rules []adapter.DNSRule, legacyDNSMode bool) *rulesSnapshot { - snapshot := &rulesSnapshot{ - rules: rules, - legacyDNSMode: legacyDNSMode, - } - snapshot.references.Store(1) - return snapshot -} - -func (s *rulesSnapshot) retain() { - if s == nil { - return - } - s.references.Add(1) -} - -func (s *rulesSnapshot) rulesAndMode() ([]adapter.DNSRule, bool) { - if s == nil { - return nil, false - } - return s.rules, s.legacyDNSMode -} - -func (s *rulesSnapshot) release() { - if s == nil { - return - } - references := s.references.Add(-1) - switch { - case references > 0: - case references == 0: - closeRules(s.rules) - default: - panic("dns: negative rules snapshot references") - } -} +var _ adapter.DNSRuleSetUpdateValidator = (*Router)(nil) type Router struct { ctx context.Context @@ -88,14 +39,14 @@ type Router struct { outbound adapter.OutboundManager client adapter.DNSClient rawRules []option.DNSRule - currentRules atomic.Pointer[rulesSnapshot] + rules []adapter.DNSRule defaultDomainStrategy C.DomainStrategy dnsReverseMapping freelru.Cache[netip.Addr, string] platformInterface adapter.PlatformInterface - rebuildAccess sync.Mutex - stateAccess sync.Mutex + legacyDNSMode bool + rulesAccess sync.RWMutex + started bool closing bool - ruleSetCallbacks []dnsRuleSetCallback addressFilterDeprecatedReported bool ruleStrategyDeprecatedReported bool } @@ -107,9 +58,9 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.DNSOp 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), } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(options.Rules)), false)) router.client = NewClient(ClientOptions{ DisableCache: options.DNSClientOptions.DisableCache, DisableExpire: options.DNSClientOptions.DisableExpire, @@ -153,107 +104,57 @@ func (r *Router) Start(stage adapter.StartStage) error { monitor.Finish() monitor.Start("initialize DNS rules") - err := r.rebuildRules(true) + newRules, legacyDNSMode, modeFlags, err := r.buildRules(true) monitor.Finish() if err != nil { return err } - monitor.Start("register DNS rule-set callbacks") - needsRulesRefresh, err := r.registerRuleSetCallbacks() - 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 } - if needsRulesRefresh { - monitor.Start("refresh DNS rules after callback registration") - err = r.rebuildRules(true) - monitor.Finish() - if err != nil { - r.logger.Error(E.Cause(err, "refresh DNS rules after callback registration")) - } + 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 { - r.stateAccess.Lock() + r.rulesAccess.Lock() if r.closing { - r.stateAccess.Unlock() + r.rulesAccess.Unlock() return nil } r.closing = true - callbacks := r.ruleSetCallbacks - r.ruleSetCallbacks = nil - oldSnapshot := r.currentRules.Swap(nil) - for _, callback := range callbacks { - callback.ruleSet.UnregisterCallback(callback.element) - } - r.stateAccess.Unlock() - oldSnapshot.release() + runtimeRules := r.rules + r.rules = nil + r.rulesAccess.Unlock() + closeRules(runtimeRules) return nil } -func (r *Router) rebuildRules(startRules bool) error { - r.rebuildAccess.Lock() - defer r.rebuildAccess.Unlock() - if r.isClosing() { - return nil - } - newRules, legacyDNSMode, modeFlags, err := r.buildRules(startRules) - if err != nil { - if r.isClosing() { - return nil - } - return err - } - shouldReportAddressFilterDeprecated := startRules && - legacyDNSMode && - !r.addressFilterDeprecatedReported && - common.Any(newRules, func(rule adapter.DNSRule) bool { return rule.WithAddressLimit() }) - shouldReportRuleStrategyDeprecated := startRules && - legacyDNSMode && - !r.ruleStrategyDeprecatedReported && - modeFlags.neededFromStrategy - newSnapshot := newRulesSnapshot(newRules, legacyDNSMode) - r.stateAccess.Lock() - if r.closing { - r.stateAccess.Unlock() - newSnapshot.release() - return nil - } - if shouldReportAddressFilterDeprecated { - r.addressFilterDeprecatedReported = true - } - if shouldReportRuleStrategyDeprecated { - r.ruleStrategyDeprecatedReported = true - } - oldSnapshot := r.currentRules.Swap(newSnapshot) - r.stateAccess.Unlock() - oldSnapshot.release() - if shouldReportAddressFilterDeprecated { - deprecated.Report(r.ctx, deprecated.OptionLegacyDNSAddressFilter) - } - if shouldReportRuleStrategyDeprecated { - deprecated.Report(r.ctx, deprecated.OptionLegacyDNSRuleStrategy) - } - return nil -} - -func (r *Router) isClosing() bool { - r.stateAccess.Lock() - defer r.stateAccess.Unlock() - return r.closing -} - -func (r *Router) acquireRulesSnapshot() *rulesSnapshot { - r.stateAccess.Lock() - defer r.stateAccess.Unlock() - snapshot := r.currentRules.Load() - snapshot.retain() - return snapshot -} - func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleModeFlags, error) { for i, ruleOptions := range r.rawRules { err := R.ValidateNoNestedDNSRuleActions(ruleOptions) @@ -262,7 +163,7 @@ func (r *Router) buildRules(startRules bool) ([]adapter.DNSRule, bool, dnsRuleMo } } router := service.FromContext[adapter.Router](r.ctx) - legacyDNSMode, modeFlags, err := resolveLegacyDNSMode(router, r.rawRules) + legacyDNSMode, modeFlags, err := resolveLegacyDNSMode(router, r.rawRules, nil) if err != nil { return nil, false, dnsRuleModeFlags{}, err } @@ -304,51 +205,53 @@ func closeRules(rules []adapter.DNSRule) { } } -func (r *Router) registerRuleSetCallbacks() (bool, error) { - tags := referencedDNSRuleSetTags(r.rawRules) - if len(tags) == 0 { - return false, nil - } - r.stateAccess.Lock() - if len(r.ruleSetCallbacks) > 0 { - r.stateAccess.Unlock() - return true, nil +func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.RuleSetMetadata) error { + if len(r.rawRules) == 0 { + return nil } - r.stateAccess.Unlock() router := service.FromContext[adapter.Router](r.ctx) if router == nil { - return false, E.New("router service not found") - } - callbacks := make([]dnsRuleSetCallback, 0, len(tags)) - for _, tag := range tags { - ruleSet, loaded := router.RuleSet(tag) - if !loaded { - for _, callback := range callbacks { - callback.ruleSet.UnregisterCallback(callback.element) - } - return false, E.New("rule-set not found: ", tag) + 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) } - element := ruleSet.RegisterCallback(func(adapter.RuleSet) { - err := r.rebuildRules(true) + return nil + } + _, flags, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + if err != nil { + return err + } + if legacyDNSMode { + if flags.disabled { + err := validateLegacyDNSModeDisabledRules(r.rawRules) if err != nil { - r.logger.Error(E.Cause(err, "rebuild DNS rules after rule-set update")) + return err } - }) - callbacks = append(callbacks, dnsRuleSetCallback{ - ruleSet: ruleSet, - element: element, - }) - } - r.stateAccess.Lock() - if !r.closing && len(r.ruleSetCallbacks) == 0 { - r.ruleSetCallbacks = callbacks - callbacks = nil + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + } + return nil } - r.stateAccess.Unlock() - for _, callback := range callbacks { - callback.ruleSet.UnregisterCallback(callback.element) + if flags.needed { + return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } - return true, nil + return nil } func (r *Router) matchDNS(ctx context.Context, rules []adapter.DNSRule, allowFakeIP bool, ruleIndex int, isAddressQuery bool, options *adapter.DNSQueryOptions) (adapter.DNSTransport, adapter.DNSRule, int) { @@ -702,9 +605,13 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } return &responseMessage, nil } - snapshot := r.acquireRulesSnapshot() - defer snapshot.release() - rules, legacyDNSMode := snapshot.rulesAndMode() + 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 @@ -810,9 +717,13 @@ done: } func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, error) { - snapshot := r.acquireRulesSnapshot() - defer snapshot.release() - rules, legacyDNSMode := snapshot.rulesAndMode() + 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 @@ -979,8 +890,8 @@ func (f *dnsRuleModeFlags) merge(other dnsRuleModeFlags) { f.neededFromStrategy = f.neededFromStrategy || other.neededFromStrategy } -func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule) (bool, dnsRuleModeFlags, error) { - flags, err := dnsRuleModeRequirements(router, rules) +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 } @@ -993,10 +904,10 @@ func resolveLegacyDNSMode(router adapter.Router, rules []option.DNSRule) (bool, return flags.needed, flags, nil } -func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (dnsRuleModeFlags, error) { +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) + ruleFlags, err := dnsRuleModeRequirementsInRule(router, rule, metadataOverrides) if err != nil { return dnsRuleModeFlags{}, E.Cause(err, "dns rule[", i, "]") } @@ -1005,10 +916,10 @@ func dnsRuleModeRequirements(router adapter.Router, rules []option.DNSRule) (dns return flags, nil } -func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) (dnsRuleModeFlags, error) { +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) + return dnsRuleModeRequirementsInDefaultRule(router, rule.DefaultOptions, metadataOverrides) case C.RuleTypeLogical: flags := dnsRuleModeFlags{ disabled: dnsRuleActionType(rule) == C.RuleActionTypeEvaluate || dnsRuleActionType(rule) == C.RuleActionTypeRespond, @@ -1016,7 +927,7 @@ func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) ( } flags.needed = flags.neededFromStrategy for i, subRule := range rule.LogicalOptions.Rules { - subFlags, err := dnsRuleModeRequirementsInRule(router, subRule) + subFlags, err := dnsRuleModeRequirementsInRule(router, subRule, metadataOverrides) if err != nil { return dnsRuleModeFlags{}, E.Cause(err, "sub rule[", i, "]") } @@ -1028,7 +939,7 @@ func dnsRuleModeRequirementsInRule(router adapter.Router, rule option.DNSRule) ( } } -func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule) (dnsRuleModeFlags, error) { +func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.DefaultDNSRule, metadataOverrides map[string]adapter.RuleSetMetadata) (dnsRuleModeFlags, error) { flags := dnsRuleModeFlags{ disabled: defaultRuleDisablesLegacyDNSMode(rule), neededFromStrategy: dnsRuleActionHasStrategy(rule.DNSRuleAction), @@ -1041,11 +952,10 @@ func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.Def return dnsRuleModeFlags{}, E.New("router service not found") } for _, tag := range rule.RuleSet { - ruleSet, loaded := router.RuleSet(tag) - if !loaded { - return dnsRuleModeFlags{}, E.New("rule-set not found: ", tag) + metadata, err := lookupDNSRuleSetMetadata(router, tag, metadataOverrides) + if err != nil { + return dnsRuleModeFlags{}, err } - metadata := ruleSet.Metadata() // ip_version is not a headless-rule item, so ContainsIPVersionRule is intentionally absent. flags.disabled = flags.disabled || metadata.ContainsDNSQueryTypeRule if !rule.RuleSetIPCIDRMatchSource && metadata.ContainsIPCIDRRule { @@ -1055,6 +965,19 @@ func dnsRuleModeRequirementsInDefaultRule(router adapter.Router, rule option.Def 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) diff --git a/dns/router_test.go b/dns/router_test.go index aff6a318db..a6f71d877f 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -2,7 +2,6 @@ package dns import ( "context" - "io" "net" "net/netip" "strings" @@ -17,7 +16,6 @@ import ( "github.com/sagernet/sing-box/option" rulepkg "github.com/sagernet/sing-box/route/rule" "github.com/sagernet/sing-tun" - "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/json/badoption" N "github.com/sagernet/sing/common/network" @@ -75,6 +73,7 @@ func (m *fakeDNSTransportManager) Create(context.Context, log.ContextLogger, str 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) } @@ -233,14 +232,47 @@ func (c *fakeDNSClient) Exchange(ctx context.Context, transport adapter.DNSTrans 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(_ context.Context, transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions, responseChecker func(*mDNS.Msg) bool) ([]netip.Addr, error) { - if c.lookup == nil { +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") } - addresses, response, err := c.lookup(transport, domain, options) + 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 } @@ -278,9 +310,9 @@ func newTestRouterWithContextAndLogger(t *testing.T, ctx context.Context, rules transport: transportManager, client: client, rawRules: make([]option.DNSRule, 0, len(rules)), + rules: make([]adapter.DNSRule, 0, len(rules)), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, len(rules)), false)) if rules != nil { err := router.Initialize(rules) require.NoError(t, err) @@ -356,7 +388,6 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { rawRules: make([]option.DNSRule, 0, 2), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 2), false)) err = router.Initialize([]option.DNSRule{ { Type: C.RuleTypeDefault, @@ -438,7 +469,7 @@ func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { }, }) - require.True(t, router.currentRules.Load().legacyDNSMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -487,45 +518,21 @@ func TestRuleSetUpdateReleasesOldRuleSetRefs(t *testing.T) { require.Zero(t, fakeSet.refCount()) } -func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t *testing.T) { +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldDisableLegacyDNSMode(t *testing.T) { t.Parallel() - callbackRuleSet := &fakeRuleSet{ - match: func(*adapter.InboundContext) bool { - return false + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, }, } routerService := &fakeRouter{ ruleSets: map[string]adapter.RuleSet{ - "dynamic-set": callbackRuleSet, + "dynamic-set": fakeSet, }, } ctx := service.ContextWith[adapter.Router](context.Background(), routerService) - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - preservedTransport := &fakeDNSTransport{tag: "preserved", transportType: C.DNSTypeUDP} - wouldBeNewTransport := &fakeDNSTransport{tag: "would-be-new", transportType: C.DNSTypeUDP} - loggerFactory := log.NewDefaultFactory( - context.Background(), - log.Formatter{ - BaseTime: time.Now(), - DisableColors: true, - DisableTimestamp: true, - }, - io.Discard, - "", - nil, - true, - ) - loggerFactory.SetLevel(log.LevelError) - logEntries, logDone, err := loggerFactory.Subscribe() - require.NoError(t, err) - t.Cleanup(func() { - loggerFactory.UnSubscribe(logEntries) - closeErr := loggerFactory.Close() - require.NoError(t, closeErr) - }) - var lastUsedTransport common.TypedValue[string] - router := newTestRouterWithContextAndLogger(t, ctx, []option.DNSRule{ + router := newTestRouterWithContext(t, ctx, []option.DNSRule{ { Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -534,19 +541,7 @@ func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "would-be-new"}, - }, - }, - }, - { - 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: "preserved"}, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, }, }, }, @@ -558,289 +553,94 @@ func TestRuleSetUpdateKeepsLastSuccessfullyCompiledRuleGraphWhenRebuildFails(t * }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "preserved"}, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, }, }, }, }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "preserved": preservedTransport, - "would-be-new": wouldBeNewTransport, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, }, }, &fakeDNSClient{ - lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { - lastUsedTransport.Store(transport.Tag()) - response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) - return MessageToAddresses(response), response, nil + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil }, - }, loggerFactory.NewLogger("dns")) - t.Cleanup(func() { - closeErr := router.Close() - require.NoError(t, closeErr) }) + require.True(t, router.legacyDNSMode) - require.True(t, router.currentRules.Load().legacyDNSMode) - require.Equal(t, 1, callbackRuleSet.refCount()) - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Equal(t, "preserved", lastUsedTransport.Load()) - - rebuildTargetRuleSet := &fakeRuleSet{ - metadata: adapter.RuleSetMetadata{ - ContainsDNSQueryTypeRule: true, - }, - match: func(*adapter.InboundContext) bool { - return true - }, - } - routerService.setRuleSet("dynamic-set", rebuildTargetRuleSet) - - callbackRuleSet.updateMetadata(adapter.RuleSetMetadata{ + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ ContainsDNSQueryTypeRule: true, }) - rebuildErrorEntry := waitForLogMessageContaining(t, logEntries, logDone, "rebuild DNS rules after rule-set update") - require.Contains(t, rebuildErrorEntry.Message, "Address Filter Fields") - require.True(t, router.currentRules.Load().legacyDNSMode) - require.Equal(t, 1, callbackRuleSet.refCount()) - require.Zero(t, rebuildTargetRuleSet.refCount()) - - lastUsedTransport.Store("") - addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Equal(t, "preserved", lastUsedTransport.Load()) - require.NotEqual(t, "would-be-new", lastUsedTransport.Load()) + require.ErrorContains(t, err, "Address Filter Fields") } -func TestRuleSetUpdateSerializesConcurrentRebuilds(t *testing.T) { +func TestValidateRuleSetMetadataUpdateRejectsRuleSetOnlyLegacyModeSwitchToNew(t *testing.T) { t.Parallel() - callbackRuleSet := &fakeRuleSet{ - match: func(*adapter.InboundContext) bool { - return false + fakeSet := &fakeRuleSet{ + metadata: adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, }, } routerService := &fakeRouter{ ruleSets: map[string]adapter.RuleSet{ - "dynamic-set": callbackRuleSet, + "dynamic-set": fakeSet, }, } ctx := service.ContextWith[adapter.Router](context.Background(), routerService) - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - firstTransport := &fakeDNSTransport{tag: "first", transportType: C.DNSTypeUDP} - secondTransport := &fakeDNSTransport{tag: "second", transportType: C.DNSTypeUDP} - var lastUsedTransport common.TypedValue[string] - 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: "first"}, - }, + router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + RuleSet: badoption.Listable[string]{"dynamic-set"}, }, - }, - { - 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: "second"}, - }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, }, }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "first": firstTransport, - "second": secondTransport, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, }, }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - lastUsedTransport.Store(transport.Tag()) - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60), nil + 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) - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Equal(t, "second", lastUsedTransport.Load()) - - callbacks := callbackRuleSet.snapshotCallbacks() - require.Len(t, callbacks, 1) - - firstMetadataEntered := make(chan struct{}) - releaseFirstMetadata := make(chan struct{}) - firstRuleSetStarted := make(chan struct{}) - releaseFirstRuleSetStart := make(chan struct{}) - secondMetadataEntered := make(chan struct{}) - releaseSecondMetadata := make(chan struct{}) - - var metadataAccess sync.Mutex - var metadataCallCount int - var concurrentMetadataCalls int - var maximumConcurrentMetadataCalls int - - recordMetadataEntry := func() func() { - metadataAccess.Lock() - metadataCallCount++ - concurrentMetadataCalls++ - if concurrentMetadataCalls > maximumConcurrentMetadataCalls { - maximumConcurrentMetadataCalls = concurrentMetadataCalls - } - metadataAccess.Unlock() - return func() { - metadataAccess.Lock() - concurrentMetadataCalls-- - metadataAccess.Unlock() - } - } - - firstBuildRuleSet := &fakeRuleSet{ - match: func(*adapter.InboundContext) bool { - return true - }, - metadataRead: func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { - metadataDone := recordMetadataEntry() - close(firstMetadataEntered) - <-releaseFirstMetadata - metadataDone() - return metadata - }, - afterIncrementReference: func() { - close(firstRuleSetStarted) - <-releaseFirstRuleSetStart - }, - } - secondBuildRuleSet := &fakeRuleSet{ - match: func(*adapter.InboundContext) bool { - return false - }, - metadataRead: func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { - metadataDone := recordMetadataEntry() - close(secondMetadataEntered) - <-releaseSecondMetadata - metadataDone() - return metadata - }, - } - - routerService.setRuleSet("dynamic-set", firstBuildRuleSet) - - firstCallbackFinished := make(chan struct{}) - go func() { - callbacks[0](callbackRuleSet) - close(firstCallbackFinished) - }() - - select { - case <-firstMetadataEntered: - case <-time.After(time.Second): - t.Fatal("first rebuild did not reach rule-set metadata") - } - - close(releaseFirstMetadata) - - select { - case <-firstRuleSetStarted: - case <-time.After(time.Second): - t.Fatal("first rebuild did not reach rule-set start") - } - - routerService.setRuleSet("dynamic-set", secondBuildRuleSet) - - secondCallbackStarted := make(chan struct{}) - secondCallbackFinished := make(chan struct{}) - go func() { - close(secondCallbackStarted) - callbacks[0](callbackRuleSet) - close(secondCallbackFinished) - }() - - select { - case <-secondCallbackStarted: - case <-time.After(time.Second): - t.Fatal("second rebuild did not start") - } - - select { - case <-secondMetadataEntered: - t.Fatal("second rebuild entered rule-set metadata before the first rebuild completed") - default: - } - - close(releaseFirstRuleSetStart) - - select { - case <-firstCallbackFinished: - case <-time.After(time.Second): - t.Fatal("first rebuild callback did not finish") - } - - select { - case <-secondMetadataEntered: - case <-time.After(time.Second): - t.Fatal("second rebuild did not enter rule-set metadata after the first rebuild finished") - } - - addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Equal(t, "first", lastUsedTransport.Load()) - - close(releaseSecondMetadata) - - select { - case <-secondCallbackFinished: - case <-time.After(time.Second): - t.Fatal("second rebuild callback did not finish") - } - - metadataAccess.Lock() - require.Equal(t, 2, metadataCallCount) - require.Equal(t, 1, maximumConcurrentMetadataCalls) - metadataAccess.Unlock() - - lastUsedTransport.Store("") - addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Equal(t, "second", lastUsedTransport.Load()) - - err = router.Close() - require.NoError(t, err) - require.Zero(t, callbackRuleSet.refCount()) - require.Zero(t, firstBuildRuleSet.refCount()) - require.Zero(t, secondBuildRuleSet.refCount()) + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") } -func TestCloseDuringRebuildDiscardsResult(t *testing.T) { +func TestValidateRuleSetMetadataUpdateBeforeStartUsesStartupValidation(t *testing.T) { t.Parallel() - fakeSet := &fakeRuleSet{ - metadata: adapter.RuleSetMetadata{ - ContainsIPCIDRRule: true, - }, - } - ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + fakeSet := &fakeRuleSet{} + routerService := &fakeRouter{ ruleSets: map[string]adapter.RuleSet{ "dynamic-set": fakeSet, }, - }) - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - router := newTestRouterWithContext(t, ctx, []option.DNSRule{ + } + 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{ @@ -849,130 +649,73 @@ func TestCloseDuringRebuildDiscardsResult(t *testing.T) { }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "installed"}, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, }, }, }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, - transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "discarded": &fakeDNSTransport{tag: "discarded", transportType: C.DNSTypeUDP}, - "installed": &fakeDNSTransport{tag: "installed", transportType: C.DNSTypeUDP}, - }, - }, &fakeDNSClient{ - exchange: func(transport adapter.DNSTransport, message *mDNS.Msg) (*mDNS.Msg, error) { - switch transport.Tag() { - case "discarded", "installed", "default": - return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60), nil - default: - return nil, E.New("unexpected transport: ", transport.Tag()) - } + { + Type: C.RuleTypeDefault, + DefaultOptions: option.DefaultDNSRule{ + RawDefaultDNSRule: option.RawDefaultDNSRule{ + IPIsPrivate: true, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, + }, + }, }, }) - require.True(t, router.currentRules.Load().legacyDNSMode) - require.Equal(t, 1, fakeSet.refCount()) - - callbacks := fakeSet.snapshotCallbacks() - require.Len(t, callbacks, 1) - - firstMetadataEntered := make(chan struct{}) - releaseFirstMetadata := make(chan struct{}) - callbackFinished := make(chan struct{}) - fakeSet.metadataRead = func(metadata adapter.RuleSetMetadata) adapter.RuleSetMetadata { - router.rawRules[0].DefaultOptions.RouteOptions.Server = "discarded" - close(firstMetadataEntered) - <-releaseFirstMetadata - return adapter.RuleSetMetadata{} - } - - go func() { - callbacks[0](fakeSet) - close(callbackFinished) - }() - - select { - case <-firstMetadataEntered: - case <-time.After(time.Second): - t.Fatal("rebuild did not reach rule-set metadata") - } - - err := router.Close() require.NoError(t, err) - close(releaseFirstMetadata) + require.False(t, router.started) - select { - case <-callbackFinished: - case <-time.After(time.Second): - t.Fatal("rebuild callback did not finish after close") - } - - fakeSet.metadataRead = nil - - require.Nil(t, router.currentRules.Load()) - require.Zero(t, fakeSet.refCount()) + err = router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsDNSQueryTypeRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") } -func TestCloseIgnoresSnapshottedRuleSetCallback(t *testing.T) { +func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMode(t *testing.T) { t.Parallel() fakeSet := &fakeRuleSet{} - ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + routerService := &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"}, - }, + } + 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"}, }, - }, - { - Type: C.RuleTypeDefault, - DefaultOptions: option.DefaultDNSRule{ - RawDefaultDNSRule: option.RawDefaultDNSRule{ - IPIsPrivate: true, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "default"}, - }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "selected"}, }, }, - }, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, + }}, &fakeDNSTransportManager{ + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, }, }, &fakeDNSClient{ - lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { - response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) - return MessageToAddresses(response), response, nil + 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) - callbacks := fakeSet.snapshotCallbacks() - require.Len(t, callbacks, 1) - - require.NoError(t, router.Close()) - require.Empty(t, fakeSet.snapshotCallbacks()) - - fakeSet.metadata = adapter.RuleSetMetadata{ - ContainsDNSQueryTypeRule: true, - } - callbacks[0](fakeSet) + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ + ContainsIPCIDRRule: true, + }) + require.ErrorContains(t, err, "Address Filter Fields") } -func TestRuleSetUpdateDoesNotBlockOnInFlightLookup(t *testing.T) { +func TestValidateRuleSetMetadataUpdateAllowsRelaxingLegacyRequirement(t *testing.T) { t.Parallel() fakeSet := &fakeRuleSet{ @@ -980,15 +723,12 @@ func TestRuleSetUpdateDoesNotBlockOnInFlightLookup(t *testing.T) { ContainsIPCIDRRule: true, }, } - ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ + routerService := &fakeRouter{ ruleSets: map[string]adapter.RuleSet{ "dynamic-set": fakeSet, }, - }) - defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} - selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} - lookupStarted := make(chan struct{}) - releaseLookup := make(chan struct{}) + } + ctx := service.ContextWith[adapter.Router](context.Background(), routerService) router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -1001,107 +741,34 @@ func TestRuleSetUpdateDoesNotBlockOnInFlightLookup(t *testing.T) { }, }, }}, &fakeDNSTransportManager{ - defaultTransport: defaultTransport, + defaultTransport: &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, transports: map[string]adapter.DNSTransport{ - "default": defaultTransport, - "selected": selectedTransport, + "default": &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP}, + "selected": &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP}, }, }, &fakeDNSClient{ - lookup: func(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) - require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) - close(lookupStarted) - <-releaseLookup - response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) - return MessageToAddresses(response), response, nil + lookup: func(adapter.DNSTransport, string, adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + return []netip.Addr{netip.MustParseAddr("10.0.0.1")}, nil, nil }, }) - t.Cleanup(func() { - closeErr := router.Close() - require.NoError(t, closeErr) - }) - - require.True(t, router.currentRules.Load().legacyDNSMode) - require.Equal(t, 1, fakeSet.refCount()) - - var ( - addresses []netip.Addr - err error - ) - lookupDone := make(chan struct{}) - go func() { - addresses, err = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ - LookupStrategy: C.DomainStrategyIPv4Only, - }) - close(lookupDone) - }() - - select { - case <-lookupStarted: - case <-time.After(time.Second): - t.Fatal("lookup did not reach DNS client") - } - - rebuildDone := make(chan struct{}) - go func() { - fakeSet.updateMetadata(adapter.RuleSetMetadata{ - ContainsIPCIDRRule: true, - }) - close(rebuildDone) - }() - - select { - case <-rebuildDone: - case <-time.After(time.Second): - t.Fatal("rebuild blocked on in-flight lookup") - } - - require.Equal(t, 2, fakeSet.refCount()) - - select { - case <-lookupDone: - t.Fatal("lookup finished before release") - default: - } - - close(releaseLookup) - - select { - case <-lookupDone: - case <-time.After(time.Second): - t.Fatal("lookup did not finish after release") - } + require.True(t, router.legacyDNSMode) + err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{}) require.NoError(t, err) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Eventually(t, func() bool { - return fakeSet.refCount() == 1 - }, time.Second, 10*time.Millisecond) } -func TestCloseReleasesSnapshottedRulesAfterInFlightLookup(t *testing.T) { +func TestCloseWaitsForInFlightLookupUntilContextCancellation(t *testing.T) { t.Parallel() - fakeSet := &fakeRuleSet{ - metadata: adapter.RuleSetMetadata{ - ContainsIPCIDRRule: true, - }, - } - ctx := service.ContextWith[adapter.Router](context.Background(), &fakeRouter{ - ruleSets: map[string]adapter.RuleSet{ - "dynamic-set": fakeSet, - }, - }) defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} selectedTransport := &fakeDNSTransport{tag: "selected", transportType: C.DNSTypeUDP} lookupStarted := make(chan struct{}) - releaseLookup := make(chan struct{}) - router := newTestRouterWithContext(t, ctx, []option.DNSRule{{ + var lookupStartedOnce sync.Once + router := newTestRouter(t, []option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ RawDefaultDNSRule: option.RawDefaultDNSRule{ - RuleSet: badoption.Listable[string]{"dynamic-set"}, + Domain: badoption.Listable[string]{"example.com"}, }, DNSRuleAction: option.DNSRuleAction{ Action: C.RuleActionTypeRoute, @@ -1115,30 +782,26 @@ func TestCloseReleasesSnapshottedRulesAfterInFlightLookup(t *testing.T) { "selected": selectedTransport, }, }, &fakeDNSClient{ - lookup: func(transport adapter.DNSTransport, domain string, options adapter.DNSQueryOptions) ([]netip.Addr, *mDNS.Msg, error) { + 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) - require.Equal(t, C.DomainStrategyIPv4Only, options.LookupStrategy) - close(lookupStarted) - <-releaseLookup - response := FixedResponse(0, fixedQuestion(domain, mDNS.TypeA), []netip.Addr{netip.MustParseAddr("10.0.0.1")}, 60) - return MessageToAddresses(response), response, nil + lookupStartedOnce.Do(func() { + close(lookupStarted) + }) + <-ctx.Done() + return nil, nil, ctx.Err() }, }) - require.True(t, router.currentRules.Load().legacyDNSMode) - require.Equal(t, 1, fakeSet.refCount()) - + lookupCtx, cancelLookup := context.WithCancel(context.Background()) + defer cancelLookup() var ( - addresses []netip.Addr lookupErr error closeErr error ) lookupDone := make(chan struct{}) go func() { - addresses, lookupErr = router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ - LookupStrategy: C.DomainStrategyIPv4Only, - }) + _, lookupErr = router.Lookup(lookupCtx, "example.com", adapter.DNSQueryOptions{}) close(lookupDone) }() @@ -1154,29 +817,27 @@ func TestCloseReleasesSnapshottedRulesAfterInFlightLookup(t *testing.T) { close(closeDone) }() - require.Eventually(t, func() bool { - return router.currentRules.Load() == nil && fakeSet.refCount() == 1 - }, time.Second, 10*time.Millisecond) + select { + case <-closeDone: + t.Fatal("close finished before lookup context cancellation") + default: + } - close(releaseLookup) + cancelLookup() select { case <-lookupDone: case <-time.After(time.Second): - t.Fatal("lookup did not finish after release") + t.Fatal("lookup did not finish after cancellation") } select { case <-closeDone: case <-time.After(time.Second): - t.Fatal("close did not finish") + t.Fatal("close did not finish after lookup cancellation") } - require.NoError(t, lookupErr) + require.ErrorIs(t, lookupErr, context.Canceled) require.NoError(t, closeErr) - require.Equal(t, []netip.Addr{netip.MustParseAddr("10.0.0.1")}, addresses) - require.Eventually(t, func() bool { - return fakeSet.refCount() == 0 - }, time.Second, 10*time.Millisecond) } func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { @@ -1217,7 +878,7 @@ func TestLookupLegacyDNSModeDefersDirectDestinationIPMatch(t *testing.T) { }, }, client) - require.True(t, router.currentRules.Load().legacyDNSMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -1369,7 +1030,7 @@ func TestLookupLegacyDNSModeRuleSetAcceptEmptyDoesNotTreatMismatchAsEmpty(t *tes }, }) - require.True(t, router.currentRules.Load().legacyDNSMode) + require.True(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{ LookupStrategy: C.DomainStrategyIPv4Only, @@ -1998,7 +1659,7 @@ func TestExchangeLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing return FixedResponse(0, message.Question[0], []netip.Addr{netip.MustParseAddr("1.1.1.1")}, 60), nil }, }) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -2055,7 +1716,7 @@ func TestLookupLegacyDNSModeDisabledRespondReturnsEvaluatedResponse(t *testing.T } }, }) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2105,7 +1766,7 @@ func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsErro return nil, E.New("upstream exchange failed") }, }) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -2136,7 +1797,7 @@ func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { } }, }) - router.currentRules.Load().legacyDNSMode = false + router.legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2173,7 +1834,7 @@ func TestLookupLegacyDNSModeDisabledSkipsFakeIPRule(t *testing.T) { return FixedResponse(0, message.Question[0], nil, 60), nil }, }) - router.currentRules.Load().legacyDNSMode = false + router.legacyDNSMode = false addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2226,7 +1887,6 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByEvaluate(t rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2257,7 +1917,6 @@ func TestInitializeRejectsEvaluateFakeIPServerInDefaultRule(t *testing.T) { rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2285,7 +1944,6 @@ func TestInitializeRejectsEvaluateFakeIPServerInLogicalRule(t *testing.T) { rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeLogical, LogicalOptions: option.LogicalDNSRule{ @@ -2321,7 +1979,6 @@ func TestInitializeRejectsDNSRuleStrategyWhenLegacyDNSModeIsDisabledByMatchRespo rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2351,7 +2008,6 @@ func TestInitializeRejectsDNSMatchResponseWithoutPrecedingEvaluate(t *testing.T) rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2379,7 +2035,6 @@ func TestInitializeRejectsDNSRespondWithoutPrecedingEvaluate(t *testing.T) { rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2405,7 +2060,6 @@ func TestInitializeRejectsLogicalDNSRespondWithoutPrecedingEvaluate(t *testing.T rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeLogical, LogicalOptions: option.LogicalDNSRule{ @@ -2439,7 +2093,6 @@ func TestInitializeRejectsEvaluateRuleWithResponseMatchWithoutPrecedingEvaluate( rawRules: make([]option.DNSRule, 0, 1), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeLogical, LogicalOptions: option.LogicalDNSRule{ @@ -2485,7 +2138,6 @@ func TestInitializeAllowsEvaluateRuleWithResponseMatchAfterPrecedingEvaluate(t * rawRules: make([]option.DNSRule, 0, 2), defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 2), false)) err := router.Initialize([]option.DNSRule{ { Type: C.RuleTypeDefault, @@ -2559,7 +2211,7 @@ func TestLookupLegacyDNSModeDisabledReturnsRejectedErrorForRejectAction(t *testi "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.Nil(t, addresses) @@ -2592,7 +2244,7 @@ func TestExchangeLegacyDNSModeDisabledReturnsRefusedResponseForRejectAction(t *t "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -2627,7 +2279,7 @@ func TestExchangeLegacyDNSModeDisabledReturnsDropErrorForRejectDropAction(t *tes "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) response, err := router.Exchange(context.Background(), &mDNS.Msg{ Question: []mDNS.Question{fixedQuestion("example.com", mDNS.TypeA)}, @@ -2664,7 +2316,7 @@ func TestLookupLegacyDNSModeDisabledFiltersPerQueryTypeAddressesBeforeMerging(t "default": defaultTransport, }, }, &fakeDNSClient{}) - require.False(t, router.currentRules.Load().legacyDNSMode) + require.False(t, router.legacyDNSMode) addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) require.NoError(t, err) @@ -2754,7 +2406,6 @@ func TestLegacyDNSModeReportsLegacyAddressFilterDeprecation(t *testing.T) { client: &fakeDNSClient{}, defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ @@ -2786,7 +2437,6 @@ func TestLegacyDNSModeReportsDNSRuleStrategyDeprecation(t *testing.T) { client: &fakeDNSClient{}, defaultDomainStrategy: C.DomainStrategyAsIS, } - router.currentRules.Store(newRulesSnapshot(make([]adapter.DNSRule, 0, 1), false)) err := router.Initialize([]option.DNSRule{{ Type: C.RuleTypeDefault, DefaultOptions: option.DefaultDNSRule{ diff --git a/route/rule/rule_set.go b/route/rule/rule_set.go index 9bffa8fcb9..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" ) @@ -73,3 +74,20 @@ func isIPCIDRHeadlessRule(rule option.DefaultHeadlessRule) bool { 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 51e8f27235..5408615fc0 100644 --- a/route/rule/rule_set_local.go +++ b/route/rule/rule_set_local.go @@ -137,11 +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.ContainsDNSQueryTypeRule = HasHeadlessRule(headlessRules, isDNSQueryTypeHeadlessRule) + 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 4d2691450e..53d353b3c1 100644 --- a/route/rule/rule_set_remote.go +++ b/route/rule/rule_set_remote.go @@ -189,11 +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.ContainsDNSQueryTypeRule = HasHeadlessRule(plainRuleSet.Rules, isDNSQueryTypeHeadlessRule) + s.metadata = metadata s.rules = rules callbacks := s.callbacks.Array() s.access.Unlock() 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"})) +} From 81430c4558ed43dc9347e9f531daca3f318f9883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 01:05:40 +0800 Subject: [PATCH 61/67] docs: fix broken anchors, change block ordering, and fakeip field name --- docs/changelog.md | 4 ++-- docs/configuration/dns/fakeip.md | 4 ++-- docs/configuration/dns/rule.md | 10 +++++----- docs/configuration/dns/rule.zh.md | 10 +++++----- docs/configuration/dns/rule_action.md | 7 ++----- docs/configuration/dns/rule_action.zh.md | 7 ++----- docs/configuration/dns/server/legacy.md | 2 +- docs/deprecated.md | 2 +- 8 files changed, 20 insertions(+), 26 deletions(-) diff --git a/docs/changelog.md b/docs/changelog.md index 342fbce7d0..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. diff --git a/docs/configuration/dns/fakeip.md b/docs/configuration/dns/fakeip.md index fcd35e12b2..a0524dc8b0 100644 --- a/docs/configuration/dns/fakeip.md +++ b/docs/configuration/dns/fakeip.md @@ -4,7 +4,7 @@ icon: material/note-remove !!! failure "Removed in sing-box 1.14.0" - 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-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/rule.md b/docs/configuration/dns/rule.md index 64bfc77119..bc28e92685 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -4,15 +4,15 @@ 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: [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) - :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) - :material-delete-clock: [ip_accept_any](#ip_accept_any) - :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [response_extra](#response_extra) !!! quote "Changes in sing-box 1.13.0" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 02e6131456..cf98b7a8cf 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -4,15 +4,15 @@ 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: [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) - :material-plus: [source_mac_address](#source_mac_address) - :material-plus: [source_hostname](#source_hostname) - :material-delete-clock: [ip_accept_any](#ip_accept_any) - :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) + :material-plus: [response_extra](#response_extra) !!! quote "sing-box 1.13.0 中的更改" diff --git a/docs/configuration/dns/rule_action.md b/docs/configuration/dns/rule_action.md index 678053e610..00a8e1aa88 100644 --- a/docs/configuration/dns/rule_action.md +++ b/docs/configuration/dns/rule_action.md @@ -4,12 +4,9 @@ icon: material/new-box !!! quote "Changes in sing-box 1.14.0" - :material-plus: [respond](#respond) - -!!! quote "Changes in sing-box 1.14.0" - + :material-delete-clock: [strategy](#strategy) :material-plus: [evaluate](#evaluate) - :material-delete-clock: [strategy](#strategy) + :material-plus: [respond](#respond) !!! quote "Changes in sing-box 1.12.0" diff --git a/docs/configuration/dns/rule_action.zh.md b/docs/configuration/dns/rule_action.zh.md index a9db918206..f11bb58920 100644 --- a/docs/configuration/dns/rule_action.zh.md +++ b/docs/configuration/dns/rule_action.zh.md @@ -4,12 +4,9 @@ icon: material/new-box !!! quote "sing-box 1.14.0 中的更改" - :material-plus: [respond](#respond) - -!!! quote "sing-box 1.14.0 中的更改" - + :material-delete-clock: [strategy](#strategy) :material-plus: [evaluate](#evaluate) - :material-delete-clock: [strategy](#strategy) + :material-plus: [respond](#respond) !!! quote "sing-box 1.12.0 中的更改" diff --git a/docs/configuration/dns/server/legacy.md b/docs/configuration/dns/server/legacy.md index fb498a8e6b..e27b19cbfd 100644 --- a/docs/configuration/dns/server/legacy.md +++ b/docs/configuration/dns/server/legacy.md @@ -4,7 +4,7 @@ icon: material/note-remove !!! failure "Removed in sing-box 1.14.0" - 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-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" diff --git a/docs/deprecated.md b/docs/deprecated.md index 7a2c5f4e74..70084b6df9 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -48,7 +48,7 @@ Old behavior will be removed in sing-box 1.16.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). Old formats were removed in sing-box 1.14.0. From 0926405b944a88080726a246c96ab06cf665f63e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 01:43:31 +0800 Subject: [PATCH 62/67] dns: hard-fail lookup split rule misuse --- dns/router.go | 88 ++++++++++++++++++++++++++---- dns/router_test.go | 132 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 209 insertions(+), 11 deletions(-) diff --git a/dns/router.go b/dns/router.go index a485e599c1..d5c80f67d2 100644 --- a/dns/router.go +++ b/dns/router.go @@ -380,7 +380,31 @@ type exchangeWithRulesResult struct { 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 { +type lookupSplitHardError struct { + cause error +} + +func (e *lookupSplitHardError) Error() string { + return e.cause.Error() +} + +func (e *lookupSplitHardError) Unwrap() error { + return e.cause +} + +func newLookupSplitHardError(err error) error { + if err == nil { + return nil + } + return &lookupSplitHardError{cause: err} +} + +func isLookupSplitHardError(err error) bool { + var target *lookupSplitHardError + return errors.As(err, &target) +} + +func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool, hardFailMissingTransport bool) exchangeWithRulesResult { metadata := adapter.ContextFrom(ctx) if metadata == nil { panic("no context") @@ -404,7 +428,11 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: - r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + err := E.New("transport not found: ", action.Server) + if hardFailMissingTransport { + return exchangeWithRulesResult{err: newLookupSplitHardError(err)} + } + r.logger.ErrorContext(ctx, err) evaluatedResponse = nil evaluatedTransport = nil continue @@ -430,7 +458,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, case *R.RuleActionRespond: if evaluatedResponse == nil { return exchangeWithRulesResult{ - err: E.New(dnsRespondMissingResponseMessage), + err: newLookupSplitHardError(E.New(dnsRespondMissingResponseMessage)), } } return exchangeWithRulesResult{ @@ -442,7 +470,11 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: - r.logger.ErrorContext(ctx, "transport not found: ", action.Server) + err := E.New("transport not found: ", action.Server) + if hardFailMissingTransport { + return exchangeWithRulesResult{err: newLookupSplitHardError(err)} + } + r.logger.ErrorContext(ctx, err) continue case dnsRouteStatusSkipped: continue @@ -547,22 +579,58 @@ func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, d return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) } var ( - response4 []netip.Addr - response6 []netip.Addr + response4 []netip.Addr + response6 []netip.Addr + ordinaryErr4 error + ordinaryErr6 error + hardErr4 error + hardErr6 error ) 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 + if err == nil { + return nil + } + if E.IsClosedOrCanceled(err) { + return err + } + if isLookupSplitHardError(err) { + hardErr4 = err + return nil + } + ordinaryErr4 = err + return nil }) group.Append("exchange6", func(ctx context.Context) error { result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) response6 = result - return err + if err == nil { + return nil + } + if E.IsClosedOrCanceled(err) { + return err + } + if isLookupSplitHardError(err) { + hardErr6 = err + return nil + } + ordinaryErr6 = err + return nil }) err := group.Run(ctx) + if err != nil { + return nil, err + } + err = E.Errors(hardErr4, hardErr6) if len(response4) == 0 && len(response6) == 0 { + if err != nil { + return nil, err + } + return nil, E.Errors(ordinaryErr4, ordinaryErr6) + } + if err != nil { return nil, err } return sortAddresses(response4, response6, strategy), nil @@ -579,7 +647,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul Qclass: mDNS.ClassINET, }}, } - exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false, true) if exchangeResult.rejectAction != nil { return nil, exchangeResult.rejectAction.Error(ctx) } @@ -638,7 +706,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } response, err = r.client.Exchange(ctx, transport, message, options, nil) } else if !legacyDNSMode { - exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) + exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true, false) response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { var ( diff --git a/dns/router_test.go b/dns/router_test.go index a6f71d877f..c5f47a8445 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1775,7 +1775,7 @@ func TestExchangeLegacyDNSModeDisabledRespondWithoutEvaluatedResponseReturnsErro require.ErrorContains(t, err, dnsRespondMissingResponseMessage) } -func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { +func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForExchangeFailure(t *testing.T) { t.Parallel() defaultTransport := &fakeDNSTransport{tag: "default", transportType: C.DNSTypeUDP} @@ -1804,6 +1804,136 @@ func TestLookupLegacyDNSModeDisabledAllowsPartialSuccess(t *testing.T) { require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) } +func TestLookupLegacyDNSModeDisabledRespondWithoutEvaluatedResponseIsHardError(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 nil, E.New("upstream exchange failed") + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.Nil(t, addresses) + require.ErrorContains(t, err, dnsRespondMissingResponseMessage) +} + +func TestLookupLegacyDNSModeDisabledTransportNotFoundIsHardError(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"}, + QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeAAAA)}, + }, + DNSRuleAction: option.DNSRuleAction{ + Action: C.RuleActionTypeRoute, + RouteOptions: option.DNSRouteActionOptions{Server: "missing"}, + }, + }, + }}, &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 FixedResponse(0, message.Question[0], nil, 60), nil + default: + return nil, E.New("unexpected qtype") + } + }, + }) + router.legacyDNSMode = false + + addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) + require.Nil(t, addresses) + require.ErrorContains(t, err, "transport not found: missing") +} + +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() From 250bddfc8e773182231c1fac9abd465470e021ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 02:12:12 +0800 Subject: [PATCH 63/67] dns: allow rule-set updates that keep new mode --- dns/router.go | 6 +++--- dns/router_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/dns/router.go b/dns/router.go index d5c80f67d2..88ff5ca1f6 100644 --- a/dns/router.go +++ b/dns/router.go @@ -234,12 +234,12 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule } return nil } - _, flags, err := resolveLegacyDNSMode(router, r.rawRules, overrides) + candidateLegacyDNSMode, flags, err := resolveLegacyDNSMode(router, r.rawRules, overrides) if err != nil { return err } if legacyDNSMode { - if flags.disabled { + if !candidateLegacyDNSMode && flags.disabled { err := validateLegacyDNSModeDisabledRules(r.rawRules) if err != nil { return err @@ -248,7 +248,7 @@ func (r *Router) ValidateRuleSetMetadataUpdate(tag string, metadata adapter.Rule } return nil } - if flags.needed { + if candidateLegacyDNSMode { return E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) } return nil diff --git a/dns/router_test.go b/dns/router_test.go index c5f47a8445..7e070ba1d1 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -715,6 +715,57 @@ func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMod require.ErrorContains(t, err, "Address Filter Fields") } +func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNewMode(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() From dfa460372fae950f9c05f709f2fbde99f04cad7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 20:18:36 +0800 Subject: [PATCH 64/67] dns: ignore split lookup errors on partial success --- dns/router.go | 88 +++++------------------------------------- dns/router_test.go | 95 ---------------------------------------------- 2 files changed, 10 insertions(+), 173 deletions(-) diff --git a/dns/router.go b/dns/router.go index 88ff5ca1f6..5555f72ef7 100644 --- a/dns/router.go +++ b/dns/router.go @@ -380,31 +380,7 @@ type exchangeWithRulesResult struct { const dnsRespondMissingResponseMessage = "respond action requires an evaluated response from a preceding evaluate action" -type lookupSplitHardError struct { - cause error -} - -func (e *lookupSplitHardError) Error() string { - return e.cause.Error() -} - -func (e *lookupSplitHardError) Unwrap() error { - return e.cause -} - -func newLookupSplitHardError(err error) error { - if err == nil { - return nil - } - return &lookupSplitHardError{cause: err} -} - -func isLookupSplitHardError(err error) bool { - var target *lookupSplitHardError - return errors.As(err, &target) -} - -func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, message *mDNS.Msg, options adapter.DNSQueryOptions, allowFakeIP bool, hardFailMissingTransport bool) exchangeWithRulesResult { +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") @@ -428,11 +404,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: - err := E.New("transport not found: ", action.Server) - if hardFailMissingTransport { - return exchangeWithRulesResult{err: newLookupSplitHardError(err)} - } - r.logger.ErrorContext(ctx, err) + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) evaluatedResponse = nil evaluatedTransport = nil continue @@ -458,7 +430,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, case *R.RuleActionRespond: if evaluatedResponse == nil { return exchangeWithRulesResult{ - err: newLookupSplitHardError(E.New(dnsRespondMissingResponseMessage)), + err: E.New(dnsRespondMissingResponseMessage), } } return exchangeWithRulesResult{ @@ -470,11 +442,7 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) switch status { case dnsRouteStatusMissing: - err := E.New("transport not found: ", action.Server) - if hardFailMissingTransport { - return exchangeWithRulesResult{err: newLookupSplitHardError(err)} - } - r.logger.ErrorContext(ctx, err) + r.logger.ErrorContext(ctx, "transport not found: ", action.Server) continue case dnsRouteStatusSkipped: continue @@ -579,58 +547,22 @@ func (r *Router) lookupWithRules(ctx context.Context, rules []adapter.DNSRule, d return r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) } var ( - response4 []netip.Addr - response6 []netip.Addr - ordinaryErr4 error - ordinaryErr6 error - hardErr4 error - hardErr6 error + 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 - if err == nil { - return nil - } - if E.IsClosedOrCanceled(err) { - return err - } - if isLookupSplitHardError(err) { - hardErr4 = err - return nil - } - ordinaryErr4 = err - return nil + return err }) group.Append("exchange6", func(ctx context.Context) error { result, err := r.lookupWithRulesType(ctx, rules, domain, mDNS.TypeAAAA, lookupOptions) response6 = result - if err == nil { - return nil - } - if E.IsClosedOrCanceled(err) { - return err - } - if isLookupSplitHardError(err) { - hardErr6 = err - return nil - } - ordinaryErr6 = err - return nil + return err }) err := group.Run(ctx) - if err != nil { - return nil, err - } - err = E.Errors(hardErr4, hardErr6) if len(response4) == 0 && len(response6) == 0 { - if err != nil { - return nil, err - } - return nil, E.Errors(ordinaryErr4, ordinaryErr6) - } - if err != nil { return nil, err } return sortAddresses(response4, response6, strategy), nil @@ -647,7 +579,7 @@ func (r *Router) lookupWithRulesType(ctx context.Context, rules []adapter.DNSRul Qclass: mDNS.ClassINET, }}, } - exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false, true) + exchangeResult := r.exchangeWithRules(withLookupQueryMetadata(ctx, qType), rules, request, options, false) if exchangeResult.rejectAction != nil { return nil, exchangeResult.rejectAction.Error(ctx) } @@ -706,7 +638,7 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte } response, err = r.client.Exchange(ctx, transport, message, options, nil) } else if !legacyDNSMode { - exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true, false) + exchangeResult := r.exchangeWithRules(ctx, rules, message, options, true) response, transport, err = exchangeResult.response, exchangeResult.transport, exchangeResult.err } else { var ( diff --git a/dns/router_test.go b/dns/router_test.go index 7e070ba1d1..63cb9c1fe4 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -1855,101 +1855,6 @@ func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForExchangeFailure(t *te require.Equal(t, []netip.Addr{netip.MustParseAddr("1.1.1.1")}, addresses) } -func TestLookupLegacyDNSModeDisabledRespondWithoutEvaluatedResponseIsHardError(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 nil, E.New("upstream exchange failed") - default: - return nil, E.New("unexpected qtype") - } - }, - }) - router.legacyDNSMode = false - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.Nil(t, addresses) - require.ErrorContains(t, err, dnsRespondMissingResponseMessage) -} - -func TestLookupLegacyDNSModeDisabledTransportNotFoundIsHardError(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"}, - QueryType: badoption.Listable[option.DNSQueryType]{option.DNSQueryType(mDNS.TypeAAAA)}, - }, - DNSRuleAction: option.DNSRuleAction{ - Action: C.RuleActionTypeRoute, - RouteOptions: option.DNSRouteActionOptions{Server: "missing"}, - }, - }, - }}, &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 FixedResponse(0, message.Question[0], nil, 60), nil - default: - return nil, E.New("unexpected qtype") - } - }, - }) - router.legacyDNSMode = false - - addresses, err := router.Lookup(context.Background(), "example.com", adapter.DNSQueryOptions{}) - require.Nil(t, addresses) - require.ErrorContains(t, err, "transport not found: missing") -} - func TestLookupLegacyDNSModeDisabledAllowsPartialSuccessForRcodeError(t *testing.T) { t.Parallel() From 74688751b07cdaec032e9f816d7d163fa46c732a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 22:16:39 +0800 Subject: [PATCH 65/67] dns: simplify evaluate action transport resolution Remove resolveDNSRoute indirection from evaluate action since evaluate+fakeip is already rejected at build time, making the dnsRouteStatusSkipped branch dead code. Inline transport lookup directly instead. Also remove the context-cancellation early return that was not part of the design spec, and fix test naming to avoid newMode. --- dns/router.go | 11 +++-------- dns/router_test.go | 2 +- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/dns/router.go b/dns/router.go index 5555f72ef7..616914c884 100644 --- a/dns/router.go +++ b/dns/router.go @@ -401,25 +401,20 @@ func (r *Router) exchangeWithRules(ctx context.Context, rules []adapter.DNSRule, r.applyDNSRouteOptions(&effectiveOptions, *action) case *R.RuleActionEvaluate: queryOptions := effectiveOptions - transport, status := r.resolveDNSRoute(action.Server, action.RuleActionDNSRouteOptions, allowFakeIP, &queryOptions) - switch status { - case dnsRouteStatusMissing: + transport, loaded := r.transport.Transport(action.Server) + if !loaded { r.logger.ErrorContext(ctx, "transport not found: ", action.Server) evaluatedResponse = nil evaluatedTransport = nil continue - case dnsRouteStatusSkipped: - 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 { - if E.IsClosedOrCanceled(err) { - return exchangeWithRulesResult{err: err} - } r.logger.ErrorContext(ctx, E.Cause(err, "exchange failed for ", FormatQuestion(message.Question[0].String()))) evaluatedResponse = nil evaluatedTransport = nil diff --git a/dns/router_test.go b/dns/router_test.go index 63cb9c1fe4..bd9df1311b 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -715,7 +715,7 @@ func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMod require.ErrorContains(t, err, "Address Filter Fields") } -func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNewMode(t *testing.T) { +func TestValidateRuleSetMetadataUpdateAllowsRuleSetThatKeepsNonLegacyDNSMode(t *testing.T) { t.Parallel() fakeSet := &fakeRuleSet{} From 646ed69c0b1f38da5e820d8cd29a6a0d96961aaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 22:44:41 +0800 Subject: [PATCH 66/67] dns: unify match_response gate error for all Response Match Fields ip_cidr and ip_is_private are Response Match Fields in new mode, same as response_rcode/answer/ns/extra. Use a single consistent error message when any of them appear without match_response. --- dns/router.go | 7 ++----- dns/router_test.go | 4 ++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/dns/router.go b/dns/router.go index 616914c884..79ff81a573 100644 --- a/dns/router.go +++ b/dns/router.go @@ -1059,11 +1059,8 @@ func validateLegacyDNSModeDisabledRuleTree(rule option.DNSRule) (bool, error) { func validateLegacyDNSModeDisabledDefaultRule(rule option.DefaultDNSRule) (bool, error) { hasResponseRecords := hasResponseMatchFields(rule) - if hasResponseRecords && !rule.MatchResponse { - return false, E.New("Response Match Fields (response_rcode, response_answer, response_ns, response_extra) require match_response to be enabled") - } - if (len(rule.IPCIDR) > 0 || rule.IPIsPrivate) && !rule.MatchResponse { - return false, E.New(deprecated.OptionLegacyDNSAddressFilter.MessageWithLink()) + 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. diff --git a/dns/router_test.go b/dns/router_test.go index bd9df1311b..e4fcabe2ee 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -414,8 +414,8 @@ func TestInitializeRejectsDirectLegacyRuleWhenRuleSetForcesNew(t *testing.T) { }, }, }) - require.ErrorContains(t, err, "Address Filter Fields") - require.ErrorContains(t, err, "deprecated") + require.ErrorContains(t, err, "Response Match Fields") + require.ErrorContains(t, err, "require match_response") } func TestLookupLegacyDNSModeDefersRuleSetDestinationIPMatch(t *testing.T) { From a1a0d8356c4f92ec240361a07e9a1e6ec4b5b858 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 2 Apr 2026 23:50:39 +0800 Subject: [PATCH 67/67] dns: revert legacy pre-match to simple flag-based approach Remove the 754-line boolean satisfiability formula system (rule_dns_legacy.go) and restore the original IgnoreDestinationIPCIDRMatch flag for legacy address filter pre-matching. Adding complexity to optimize a legacy compatibility path is not worthwhile. --- adapter/inbound.go | 11 +- dns/router_test.go | 4 +- route/rule/rule_abstract.go | 7 +- route/rule/rule_dns.go | 23 +- route/rule/rule_dns_legacy.go | 754 -------------------------- route/rule/rule_set_semantics_test.go | 130 ----- 6 files changed, 21 insertions(+), 908 deletions(-) delete mode 100644 route/rule/rule_dns_legacy.go diff --git a/adapter/inbound.go b/adapter/inbound.go index 9cd7606ca7..6f53b1222e 100644 --- a/adapter/inbound.go +++ b/adapter/inbound.go @@ -97,11 +97,12 @@ type InboundContext struct { IPCIDRMatchSource bool IPCIDRAcceptEmpty bool - SourceAddressMatch bool - SourcePortMatch bool - DestinationAddressMatch bool - DestinationPortMatch bool - DidMatch bool + SourceAddressMatch bool + SourcePortMatch bool + DestinationAddressMatch bool + DestinationPortMatch bool + DidMatch bool + IgnoreDestinationIPCIDRMatch bool } func (c *InboundContext) ResetRuleCache() { diff --git a/dns/router_test.go b/dns/router_test.go index e4fcabe2ee..54213b23c3 100644 --- a/dns/router_test.go +++ b/dns/router_test.go @@ -573,7 +573,7 @@ func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldDisableLegacyDNSMod err := router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ ContainsDNSQueryTypeRule: true, }) - require.ErrorContains(t, err, "Address Filter Fields") + require.ErrorContains(t, err, "require match_response") } func TestValidateRuleSetMetadataUpdateRejectsRuleSetOnlyLegacyModeSwitchToNew(t *testing.T) { @@ -672,7 +672,7 @@ func TestValidateRuleSetMetadataUpdateBeforeStartUsesStartupValidation(t *testin err = router.ValidateRuleSetMetadataUpdate("dynamic-set", adapter.RuleSetMetadata{ ContainsDNSQueryTypeRule: true, }) - require.ErrorContains(t, err, "Address Filter Fields") + require.ErrorContains(t, err, "require match_response") } func TestValidateRuleSetMetadataUpdateRejectsRuleSetThatWouldRequireLegacyDNSMode(t *testing.T) { diff --git a/route/rule/rule_abstract.go b/route/rule/rule_abstract.go index 8ec57aac32..d7b844adbb 100644 --- a/route/rule/rule_abstract.go +++ b/route/rule/rule_abstract.go @@ -56,11 +56,11 @@ func (r *abstractDefaultRule) Match(metadata *adapter.InboundContext) bool { } func (r *abstractDefaultRule) destinationIPCIDRMatchesSource(metadata *adapter.InboundContext) bool { - return metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return !metadata.IgnoreDestinationIPCIDRMatch && metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) destinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 + return !metadata.IgnoreDestinationIPCIDRMatch && !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 } func (r *abstractDefaultRule) requiresSourceAddressMatch(metadata *adapter.InboundContext) bool { @@ -156,6 +156,9 @@ func (r *abstractDefaultRule) matchStatesWithBase(metadata *adapter.InboundConte return r.invertedFailure(inheritedBase) } if r.invert { + if metadata.IgnoreDestinationIPCIDRMatch && stateSet == emptyRuleMatchState() && !metadata.DidMatch && len(r.destinationIPCIDRItems) > 0 { + return emptyRuleMatchState().withBase(inheritedBase) + } return 0 } return stateSet diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 30388b3b2e..2c6d10b342 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -372,26 +372,17 @@ func (r *DefaultDNSRule) Match(metadata *adapter.InboundContext) bool { func (r *DefaultDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { if r.matchResponse { - return !r.legacyMatchStatesForMatch(metadata).isEmpty() + return false } - return !r.abstractDefaultRule.legacyMatchStates(metadata).isEmpty() + metadata.IgnoreDestinationIPCIDRMatch = true + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractDefaultRule.matchStates(metadata).isEmpty() } func (r *DefaultDNSRule) matchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesForMatchWithMissingResponse(metadata, true) -} - -func (r *DefaultDNSRule) legacyMatchStatesForMatch(metadata *adapter.InboundContext) ruleMatchStateSet { - return r.matchStatesForMatchWithMissingResponse(metadata, false) -} - -func (r *DefaultDNSRule) matchStatesForMatchWithMissingResponse(metadata *adapter.InboundContext, ordinaryFailure bool) ruleMatchStateSet { if r.matchResponse { if metadata.DNSResponse == nil { - if ordinaryFailure { - return r.abstractDefaultRule.invertedFailure(0) - } - return 0 + return r.abstractDefaultRule.invertedFailure(0) } matchMetadata := *metadata matchMetadata.DestinationAddressMatchFromResponse = true @@ -518,7 +509,9 @@ func (r *LogicalDNSRule) Match(metadata *adapter.InboundContext) bool { } func (r *LogicalDNSRule) LegacyPreMatch(metadata *adapter.InboundContext) bool { - return !r.abstractLogicalRule.legacyMatchStates(metadata).isEmpty() + metadata.IgnoreDestinationIPCIDRMatch = true + defer func() { metadata.IgnoreDestinationIPCIDRMatch = false }() + return !r.abstractLogicalRule.matchStates(metadata).isEmpty() } func (r *LogicalDNSRule) MatchAddressLimit(metadata *adapter.InboundContext, response *dns.Msg) bool { diff --git a/route/rule/rule_dns_legacy.go b/route/rule/rule_dns_legacy.go deleted file mode 100644 index 25088cacc8..0000000000 --- a/route/rule/rule_dns_legacy.go +++ /dev/null @@ -1,754 +0,0 @@ -package rule - -import ( - "net/netip" - - "github.com/sagernet/sing-box/adapter" - C "github.com/sagernet/sing-box/constant" - "github.com/sagernet/sing/common" - - "go4.org/netipx" -) - -type legacyResponseLiteralKind uint8 - -const ( - legacyLiteralRequireEmpty legacyResponseLiteralKind = iota - legacyLiteralRequireNonEmpty - legacyLiteralRequireSet - legacyLiteralForbidSet -) - -type legacyResponseLiteral struct { - kind legacyResponseLiteralKind - ipSet *netipx.IPSet -} - -type legacyResponseFormulaKind uint8 - -const ( - legacyFormulaFalse legacyResponseFormulaKind = iota - legacyFormulaTrue - legacyFormulaLiteral - legacyFormulaAnd - legacyFormulaOr -) - -type legacyResponseFormula struct { - kind legacyResponseFormulaKind - literal legacyResponseLiteral - children []legacyResponseFormula -} - -type legacyResponseConstraint struct { - requireEmpty bool - requireNonEmpty bool - requiredSets []*netipx.IPSet - forbiddenSet *netipx.IPSet -} - -const ( - legacyRuleMatchDeferredDestinationAddress ruleMatchState = 1 << 4 - legacyRuleMatchStateCount = 32 -) - -type legacyRuleMatchStateSet [legacyRuleMatchStateCount]legacyResponseFormula - -var ( - legacyAllIPSet = func() *netipx.IPSet { - var builder netipx.IPSetBuilder - builder.Complement() - return common.Must1(builder.IPSet()) - }() - legacyNonPublicIPSet = func() *netipx.IPSet { - var builder netipx.IPSetBuilder - for _, prefix := range []string{ - "0.0.0.0/32", - "10.0.0.0/8", - "127.0.0.0/8", - "169.254.0.0/16", - "172.16.0.0/12", - "192.168.0.0/16", - "224.0.0.0/4", - "::/128", - "::1/128", - "fc00::/7", - "fe80::/10", - "ff00::/8", - } { - builder.AddPrefix(netip.MustParsePrefix(prefix)) - } - return common.Must1(builder.IPSet()) - }() -) - -func legacyFalseFormula() legacyResponseFormula { - return legacyResponseFormula{} -} - -func legacyTrueFormula() legacyResponseFormula { - return legacyResponseFormula{kind: legacyFormulaTrue} -} - -func legacyLiteralFormula(literal legacyResponseLiteral) legacyResponseFormula { - return legacyResponseFormula{ - kind: legacyFormulaLiteral, - literal: literal, - } -} - -func (f legacyResponseFormula) isFalse() bool { - return f.kind == legacyFormulaFalse -} - -func (f legacyResponseFormula) isTrue() bool { - return f.kind == legacyFormulaTrue -} - -func (f legacyResponseFormula) or(other legacyResponseFormula) legacyResponseFormula { - return legacyOrFormulas(f, other) -} - -func (f legacyResponseFormula) and(other legacyResponseFormula) legacyResponseFormula { - return legacyAndFormulas(f, other) -} - -func (f legacyResponseFormula) not() legacyResponseFormula { - switch f.kind { - case legacyFormulaFalse: - return legacyTrueFormula() - case legacyFormulaTrue: - return legacyFalseFormula() - case legacyFormulaLiteral: - return legacyLiteralFormula(legacyNegateResponseLiteral(f.literal)) - case legacyFormulaAnd: - negated := make([]legacyResponseFormula, 0, len(f.children)) - for _, child := range f.children { - negated = append(negated, child.not()) - } - return legacyOrFormulas(negated...) - case legacyFormulaOr: - negated := make([]legacyResponseFormula, 0, len(f.children)) - for _, child := range f.children { - negated = append(negated, child.not()) - } - return legacyAndFormulas(negated...) - default: - panic("unknown legacy response formula kind") - } -} - -func legacyNegateResponseLiteral(literal legacyResponseLiteral) legacyResponseLiteral { - switch literal.kind { - case legacyLiteralRequireEmpty: - return legacyResponseLiteral{kind: legacyLiteralRequireNonEmpty} - case legacyLiteralRequireNonEmpty: - return legacyResponseLiteral{kind: legacyLiteralRequireEmpty} - case legacyLiteralRequireSet: - return legacyResponseLiteral{kind: legacyLiteralForbidSet, ipSet: literal.ipSet} - case legacyLiteralForbidSet: - return legacyResponseLiteral{kind: legacyLiteralRequireSet, ipSet: literal.ipSet} - default: - panic("unknown legacy response literal kind") - } -} - -func legacyOrFormulas(formulas ...legacyResponseFormula) legacyResponseFormula { - children := make([]legacyResponseFormula, 0, len(formulas)) - for _, formula := range formulas { - if formula.isFalse() { - continue - } - if formula.isTrue() { - return legacyTrueFormula() - } - if formula.kind == legacyFormulaOr { - children = append(children, formula.children...) - continue - } - children = append(children, formula) - } - switch len(children) { - case 0: - return legacyFalseFormula() - case 1: - return children[0] - default: - return legacyResponseFormula{ - kind: legacyFormulaOr, - children: children, - } - } -} - -func legacyAndFormulas(formulas ...legacyResponseFormula) legacyResponseFormula { - children := make([]legacyResponseFormula, 0, len(formulas)) - for _, formula := range formulas { - if formula.isFalse() { - return legacyFalseFormula() - } - if formula.isTrue() { - continue - } - if formula.kind == legacyFormulaAnd { - children = append(children, formula.children...) - continue - } - children = append(children, formula) - } - switch len(children) { - case 0: - return legacyTrueFormula() - case 1: - return children[0] - } - result := legacyResponseFormula{ - kind: legacyFormulaAnd, - children: children, - } - if !result.satisfiable() { - return legacyFalseFormula() - } - return result -} - -func (f legacyResponseFormula) satisfiable() bool { - return legacyResponseFormulasSatisfiable(legacyResponseConstraint{}, []legacyResponseFormula{f}) -} - -func legacyResponseFormulasSatisfiable(constraint legacyResponseConstraint, formulas []legacyResponseFormula) bool { - stack := append(make([]legacyResponseFormula, 0, len(formulas)), formulas...) - var disjunctions []legacyResponseFormula - for len(stack) > 0 { - formula := stack[len(stack)-1] - stack = stack[:len(stack)-1] - switch formula.kind { - case legacyFormulaFalse: - return false - case legacyFormulaTrue: - continue - case legacyFormulaLiteral: - var ok bool - constraint, ok = constraint.withLiteral(formula.literal) - if !ok { - return false - } - case legacyFormulaAnd: - stack = append(stack, formula.children...) - case legacyFormulaOr: - if len(formula.children) == 0 { - return false - } - disjunctions = append(disjunctions, formula) - default: - panic("unknown legacy response formula kind") - } - } - if len(disjunctions) == 0 { - return true - } - bestIndex := 0 - for i := 1; i < len(disjunctions); i++ { - if len(disjunctions[i].children) < len(disjunctions[bestIndex].children) { - bestIndex = i - } - } - selected := disjunctions[bestIndex] - remaining := make([]legacyResponseFormula, 0, len(disjunctions)-1) - remaining = append(remaining, disjunctions[:bestIndex]...) - remaining = append(remaining, disjunctions[bestIndex+1:]...) - for _, child := range selected.children { - nextFormulas := make([]legacyResponseFormula, 0, len(remaining)+1) - nextFormulas = append(nextFormulas, remaining...) - nextFormulas = append(nextFormulas, child) - if legacyResponseFormulasSatisfiable(constraint, nextFormulas) { - return true - } - } - return false -} - -func (c legacyResponseConstraint) withLiteral(literal legacyResponseLiteral) (legacyResponseConstraint, bool) { - switch literal.kind { - case legacyLiteralRequireEmpty: - c.requireEmpty = true - case legacyLiteralRequireNonEmpty: - c.requireNonEmpty = true - case legacyLiteralRequireSet: - requiredSets := make([]*netipx.IPSet, len(c.requiredSets)+1) - copy(requiredSets, c.requiredSets) - requiredSets[len(c.requiredSets)] = literal.ipSet - c.requiredSets = requiredSets - case legacyLiteralForbidSet: - c.forbiddenSet = legacyUnionIPSets(c.forbiddenSet, literal.ipSet) - default: - panic("unknown legacy response literal kind") - } - return c, c.satisfiable() -} - -func (c legacyResponseConstraint) satisfiable() bool { - if c.requireEmpty && (c.requireNonEmpty || len(c.requiredSets) > 0) { - return false - } - if c.requireEmpty { - return true - } - for _, required := range c.requiredSets { - if !legacyIPSetHasAllowedIP(required, c.forbiddenSet) { - return false - } - } - if c.requireNonEmpty && len(c.requiredSets) == 0 { - return legacyIPSetHasAllowedIP(legacyAllIPSet, c.forbiddenSet) - } - return true -} - -func legacyUnionIPSets(left *netipx.IPSet, right *netipx.IPSet) *netipx.IPSet { - if left == nil { - return right - } - if right == nil { - return left - } - var builder netipx.IPSetBuilder - builder.AddSet(left) - builder.AddSet(right) - return common.Must1(builder.IPSet()) -} - -func legacyIPSetHasAllowedIP(required *netipx.IPSet, forbidden *netipx.IPSet) bool { - if required == nil { - required = legacyAllIPSet - } - if forbidden == nil { - return len(required.Ranges()) > 0 - } - builder := netipx.IPSetBuilder{} - builder.AddSet(required) - builder.RemoveSet(forbidden) - remaining := common.Must1(builder.IPSet()) - return len(remaining.Ranges()) > 0 -} - -func legacySingleRuleMatchState(state ruleMatchState) legacyRuleMatchStateSet { - return legacySingleRuleMatchStateWithFormula(state, legacyTrueFormula()) -} - -func legacySingleRuleMatchStateWithFormula(state ruleMatchState, formula legacyResponseFormula) legacyRuleMatchStateSet { - var stateSet legacyRuleMatchStateSet - if !formula.isFalse() { - stateSet[state] = formula - } - return stateSet -} - -func (s legacyRuleMatchStateSet) isEmpty() bool { - for _, formula := range s { - if !formula.isFalse() { - return false - } - } - return true -} - -func (s legacyRuleMatchStateSet) merge(other legacyRuleMatchStateSet) legacyRuleMatchStateSet { - var merged legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - merged[state] = s[state].or(other[state]) - } - return merged -} - -func (s legacyRuleMatchStateSet) combine(other legacyRuleMatchStateSet) legacyRuleMatchStateSet { - if s.isEmpty() || other.isEmpty() { - return legacyRuleMatchStateSet{} - } - var combined legacyRuleMatchStateSet - for left := ruleMatchState(0); left < legacyRuleMatchStateCount; left++ { - if s[left].isFalse() { - continue - } - for right := ruleMatchState(0); right < legacyRuleMatchStateCount; right++ { - if other[right].isFalse() { - continue - } - combined[left|right] = combined[left|right].or(s[left].and(other[right])) - } - } - return combined -} - -func (s legacyRuleMatchStateSet) withBase(base ruleMatchState) legacyRuleMatchStateSet { - if s.isEmpty() { - return legacyRuleMatchStateSet{} - } - var withBase legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if s[state].isFalse() { - continue - } - withBase[state|base] = withBase[state|base].or(s[state]) - } - return withBase -} - -func (s legacyRuleMatchStateSet) filter(allowed func(ruleMatchState) bool) legacyRuleMatchStateSet { - var filtered legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if s[state].isFalse() { - continue - } - if allowed(state) { - filtered[state] = s[state] - } - } - return filtered -} - -func (s legacyRuleMatchStateSet) addBit(bit ruleMatchState) legacyRuleMatchStateSet { - var withBit legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if s[state].isFalse() { - continue - } - withBit[state|bit] = withBit[state|bit].or(s[state]) - } - return withBit -} - -func (s legacyRuleMatchStateSet) branchOnBit(bit ruleMatchState, condition legacyResponseFormula) legacyRuleMatchStateSet { - if condition.isFalse() { - return s - } - if condition.isTrue() { - return s.addBit(bit) - } - var branched legacyRuleMatchStateSet - conditionFalse := condition.not() - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if s[state].isFalse() { - continue - } - if state.has(bit) { - branched[state] = branched[state].or(s[state]) - continue - } - branched[state] = branched[state].or(s[state].and(conditionFalse)) - branched[state|bit] = branched[state|bit].or(s[state].and(condition)) - } - return branched -} - -func (s legacyRuleMatchStateSet) andFormula(formula legacyResponseFormula) legacyRuleMatchStateSet { - if formula.isFalse() || s.isEmpty() { - return legacyRuleMatchStateSet{} - } - if formula.isTrue() { - return s - } - var result legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if s[state].isFalse() { - continue - } - result[state] = s[state].and(formula) - } - return result -} - -func (s legacyRuleMatchStateSet) anyFormula() legacyResponseFormula { - var formula legacyResponseFormula - for _, stateFormula := range s { - formula = formula.or(stateFormula) - } - return formula -} - -type legacyRuleStateMatcher interface { - legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet -} - -type legacyRuleStateMatcherWithBase interface { - legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet -} - -func legacyMatchHeadlessRuleStates(rule adapter.HeadlessRule, metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return legacyMatchHeadlessRuleStatesWithBase(rule, metadata, 0) -} - -func legacyMatchHeadlessRuleStatesWithBase(rule adapter.HeadlessRule, metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - if matcher, loaded := rule.(legacyRuleStateMatcherWithBase); loaded { - return matcher.legacyMatchStatesWithBase(metadata, base) - } - if matcher, loaded := rule.(legacyRuleStateMatcher); loaded { - return matcher.legacyMatchStates(metadata).withBase(base) - } - if rule.Match(metadata) { - return legacySingleRuleMatchState(base) - } - return legacyRuleMatchStateSet{} -} - -func legacyMatchRuleItemStatesWithBase(item RuleItem, metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - if matcher, loaded := item.(legacyRuleStateMatcherWithBase); loaded { - return matcher.legacyMatchStatesWithBase(metadata, base) - } - if matcher, loaded := item.(legacyRuleStateMatcher); loaded { - return matcher.legacyMatchStates(metadata).withBase(base) - } - if item.Match(metadata) { - return legacySingleRuleMatchState(base) - } - return legacyRuleMatchStateSet{} -} - -func (r *DefaultHeadlessRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return r.abstractDefaultRule.legacyMatchStates(metadata) -} - -func (r *LogicalHeadlessRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return r.abstractLogicalRule.legacyMatchStates(metadata) -} - -func (r *RuleSetItem) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return r.legacyMatchStatesWithBase(metadata, 0) -} - -func (r *RuleSetItem) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - var stateSet legacyRuleMatchStateSet - for _, ruleSet := range r.setList { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - nestedMetadata.IPCIDRMatchSource = r.ipCidrMatchSource - nestedMetadata.IPCIDRAcceptEmpty = r.ipCidrAcceptEmpty - stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(ruleSet, &nestedMetadata, base)) - } - return stateSet -} - -func (s *LocalRuleSet) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return s.legacyMatchStatesWithBase(metadata, 0) -} - -func (s *LocalRuleSet) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - var stateSet legacyRuleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet -} - -func (s *RemoteRuleSet) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return s.legacyMatchStatesWithBase(metadata, 0) -} - -func (s *RemoteRuleSet) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - var stateSet legacyRuleMatchStateSet - for _, rule := range s.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleMatchCache() - stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, base)) - } - return stateSet -} - -func (r *abstractDefaultRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return r.legacyMatchStatesWithBase(metadata, 0) -} - -func (r *abstractDefaultRule) legacyMatchStatesWithBase(metadata *adapter.InboundContext, inheritedBase ruleMatchState) legacyRuleMatchStateSet { - if len(r.allItems) == 0 { - return legacySingleRuleMatchState(inheritedBase) - } - evaluationBase := inheritedBase - if r.invert { - evaluationBase = 0 - } - stateSet := legacySingleRuleMatchState(evaluationBase) - if len(r.sourceAddressItems) > 0 { - metadata.DidMatch = true - if matchAnyItem(r.sourceAddressItems, metadata) { - stateSet = stateSet.addBit(ruleMatchSourceAddress) - } - } - if r.destinationIPCIDRMatchesSource(metadata) { - metadata.DidMatch = true - stateSet = stateSet.branchOnBit(ruleMatchSourceAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) - } - if len(r.sourcePortItems) > 0 { - metadata.DidMatch = true - if matchAnyItem(r.sourcePortItems, metadata) { - stateSet = stateSet.addBit(ruleMatchSourcePort) - } - } - if len(r.destinationAddressItems) > 0 { - metadata.DidMatch = true - if matchAnyItem(r.destinationAddressItems, metadata) { - stateSet = stateSet.addBit(ruleMatchDestinationAddress) - } - } - if r.legacyDestinationIPCIDRMatchesDestination(metadata) { - metadata.DidMatch = true - stateSet = stateSet.branchOnBit(legacyRuleMatchDeferredDestinationAddress, legacyDestinationIPFormula(r.destinationIPCIDRItems, metadata)) - } - if len(r.destinationPortItems) > 0 { - metadata.DidMatch = true - if matchAnyItem(r.destinationPortItems, metadata) { - stateSet = stateSet.addBit(ruleMatchDestinationPort) - } - } - for _, item := range r.items { - metadata.DidMatch = true - if !item.Match(metadata) { - if r.invert { - return legacySingleRuleMatchState(inheritedBase) - } - return legacyRuleMatchStateSet{} - } - } - if r.ruleSetItem != nil { - metadata.DidMatch = true - var merged legacyRuleMatchStateSet - for state := ruleMatchState(0); state < legacyRuleMatchStateCount; state++ { - if stateSet[state].isFalse() { - continue - } - nestedStateSet := legacyMatchRuleItemStatesWithBase(r.ruleSetItem, metadata, state) - merged = merged.merge(nestedStateSet.andFormula(stateSet[state])) - } - stateSet = merged - } - stateSet = stateSet.filter(func(state ruleMatchState) bool { - if r.legacyRequiresSourceAddressMatch(metadata) && !state.has(ruleMatchSourceAddress) { - return false - } - if len(r.sourcePortItems) > 0 && !state.has(ruleMatchSourcePort) { - return false - } - if r.legacyRequiresDestinationAddressMatch(metadata) && !state.has(ruleMatchDestinationAddress) { - return false - } - if r.legacyRequiresDeferredDestinationAddressMatch(metadata) && !state.has(legacyRuleMatchDeferredDestinationAddress) { - return false - } - if len(r.destinationPortItems) > 0 && !state.has(ruleMatchDestinationPort) { - return false - } - return true - }) - if r.invert { - return legacySingleRuleMatchStateWithFormula(inheritedBase, stateSet.anyFormula().not()) - } - return stateSet -} - -func (r *abstractDefaultRule) legacyRequiresSourceAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.sourceAddressItems) > 0 || r.destinationIPCIDRMatchesSource(metadata) -} - -func (r *abstractDefaultRule) legacyDestinationIPCIDRMatchesDestination(metadata *adapter.InboundContext) bool { - return !metadata.IPCIDRMatchSource && len(r.destinationIPCIDRItems) > 0 -} - -func (r *abstractDefaultRule) legacyRequiresDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return len(r.destinationAddressItems) > 0 -} - -func (r *abstractDefaultRule) legacyRequiresDeferredDestinationAddressMatch(metadata *adapter.InboundContext) bool { - return r.legacyDestinationIPCIDRMatchesDestination(metadata) -} - -func (r *abstractLogicalRule) legacyMatchStates(metadata *adapter.InboundContext) legacyRuleMatchStateSet { - return r.legacyMatchStatesWithBase(metadata, 0) -} - -func (r *abstractLogicalRule) legacyMatchStatesWithBase(metadata *adapter.InboundContext, base ruleMatchState) legacyRuleMatchStateSet { - evaluationBase := base - if r.invert { - evaluationBase = 0 - } - var stateSet legacyRuleMatchStateSet - if r.mode == C.LogicalTypeAnd { - stateSet = legacySingleRuleMatchState(evaluationBase) - for _, rule := range r.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleCache() - stateSet = stateSet.combine(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) - if stateSet.isEmpty() && !r.invert { - return legacyRuleMatchStateSet{} - } - } - } else { - for _, rule := range r.rules { - nestedMetadata := *metadata - nestedMetadata.ResetRuleCache() - stateSet = stateSet.merge(legacyMatchHeadlessRuleStatesWithBase(rule, &nestedMetadata, evaluationBase)) - } - } - if r.invert { - return legacySingleRuleMatchStateWithFormula(base, stateSet.anyFormula().not()) - } - return stateSet -} - -func legacyDestinationIPFormula(items []RuleItem, metadata *adapter.InboundContext) legacyResponseFormula { - if legacyDestinationIPResolved(metadata) { - if matchAnyItem(items, metadata) { - return legacyTrueFormula() - } - return legacyFalseFormula() - } - var formula legacyResponseFormula - for _, rawItem := range items { - switch item := rawItem.(type) { - case *IPCIDRItem: - if item.isSource || metadata.IPCIDRMatchSource { - if item.Match(metadata) { - return legacyTrueFormula() - } - continue - } - formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ - kind: legacyLiteralRequireSet, - ipSet: item.ipSet, - })) - if metadata.IPCIDRAcceptEmpty { - formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ - kind: legacyLiteralRequireEmpty, - })) - } - case *IPIsPrivateItem: - if item.isSource { - if item.Match(metadata) { - return legacyTrueFormula() - } - continue - } - formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ - kind: legacyLiteralRequireSet, - ipSet: legacyNonPublicIPSet, - })) - case *IPAcceptAnyItem: - formula = formula.or(legacyLiteralFormula(legacyResponseLiteral{ - kind: legacyLiteralRequireNonEmpty, - })) - default: - if rawItem.Match(metadata) { - return legacyTrueFormula() - } - } - } - return formula -} - -func legacyDestinationIPResolved(metadata *adapter.InboundContext) bool { - return metadata.IPCIDRMatchSource || - metadata.DestinationAddressMatchFromResponse || - metadata.DNSResponse != nil || - metadata.Destination.IsIP() || - len(metadata.DestinationAddresses) > 0 -} diff --git a/route/rule/rule_set_semantics_test.go b/route/rule/rule_set_semantics_test.go index bb26cc1693..2fc559d204 100644 --- a/route/rule/rule_set_semantics_test.go +++ b/route/rule/rule_set_semantics_test.go @@ -733,18 +733,6 @@ func TestDNSMatchResponseMissingResponseUsesBooleanSemantics(t *testing.T) { }) } -func TestDNSLegacyMatchResponseMissingResponseStillFailsClosed(t *testing.T) { - t.Parallel() - - rule := dnsRuleForTest(func(rule *abstractDefaultRule) { - rule.invert = true - }) - rule.matchResponse = true - - metadata := testMetadata("lookup.example") - require.False(t, rule.LegacyPreMatch(&metadata)) -} - func TestDNSAddressLimitIgnoresDestinationAddresses(t *testing.T) { t.Parallel() @@ -963,30 +951,6 @@ func TestDNSLegacyInvertAddressLimitPreLookupRegression(t *testing.T) { func TestDNSLegacyInvertLogicalAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() - t.Run("wrapper invert keeps nested deferred rule matchable", func(t *testing.T) { - t.Parallel() - - nestedRule := dnsRuleForTest(func(rule *abstractDefaultRule) { - addDestinationIPIsPrivateItem(rule) - }) - logicalRule := &LogicalDNSRule{ - abstractLogicalRule: abstractLogicalRule{ - rules: []adapter.HeadlessRule{nestedRule}, - mode: C.LogicalTypeAnd, - invert: true, - }, - } - - preLookupMetadata := testMetadata("lookup.example") - require.True(t, logicalRule.LegacyPreMatch(&preLookupMetadata)) - - matchedMetadata := testMetadata("lookup.example") - require.False(t, logicalRule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(netip.MustParseAddr("10.0.0.1")))) - - unmatchedMetadata := testMetadata("lookup.example") - require.True(t, logicalRule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) - }) - t.Run("inverted deferred child does not suppress branch", func(t *testing.T) { t.Parallel() @@ -1028,94 +992,6 @@ func TestDNSLegacyInvertRuleSetAddressLimitPreLookupRegression(t *testing.T) { require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, dnsResponseForTest(netip.MustParseAddr("8.8.8.8")))) } -func TestDNSLegacyInvertNegationStressRegression(t *testing.T) { - t.Parallel() - - const branchCount = 20 - unmatchedResponse := dnsResponseForTest(netip.MustParseAddr("203.0.113.250")) - - t.Run("logical wrapper", func(t *testing.T) { - t.Parallel() - - branches := make([]adapter.HeadlessRule, 0, branchCount) - var matchedAddrs []netip.Addr - for i := 0; i < branchCount; i++ { - firstCIDR, secondCIDR, branchAddrs := legacyNegationBranchCIDRs(i) - if matchedAddrs == nil { - matchedAddrs = branchAddrs - } - branches = append(branches, &LogicalDNSRule{ - abstractLogicalRule: abstractLogicalRule{ - mode: C.LogicalTypeAnd, - rules: []adapter.HeadlessRule{ - dnsRuleForTest(func(rule *abstractDefaultRule) { - addDestinationIPCIDRItem(t, rule, []string{firstCIDR}) - }), - dnsRuleForTest(func(rule *abstractDefaultRule) { - addDestinationIPCIDRItem(t, rule, []string{secondCIDR}) - }), - }, - }, - }) - } - - rule := &LogicalDNSRule{ - abstractLogicalRule: abstractLogicalRule{ - rules: branches, - mode: C.LogicalTypeOr, - invert: true, - }, - } - - preLookupMetadata := testMetadata("lookup.example") - require.True(t, rule.LegacyPreMatch(&preLookupMetadata)) - - matchedMetadata := testMetadata("lookup.example") - require.False(t, rule.MatchAddressLimit(&matchedMetadata, dnsResponseForTest(matchedAddrs...))) - - unmatchedMetadata := testMetadata("lookup.example") - require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, unmatchedResponse)) - }) - - t.Run("ruleset wrapper", func(t *testing.T) { - t.Parallel() - - branches := make([]adapter.HeadlessRule, 0, branchCount) - var matchedAddrs []netip.Addr - for i := 0; i < branchCount; i++ { - firstCIDR, secondCIDR, branchAddrs := legacyNegationBranchCIDRs(i) - if matchedAddrs == nil { - matchedAddrs = branchAddrs - } - branches = append(branches, headlessLogicalRule( - C.LogicalTypeAnd, - false, - headlessDefaultRule(t, func(rule *abstractDefaultRule) { - addDestinationIPCIDRItem(t, rule, []string{firstCIDR}) - }), - headlessDefaultRule(t, func(rule *abstractDefaultRule) { - addDestinationIPCIDRItem(t, rule, []string{secondCIDR}) - }), - )) - } - - ruleSet := newLocalRuleSetForTest("dns-legacy-negation-stress", branches...) - rule := dnsRuleForTest(func(rule *abstractDefaultRule) { - rule.invert = true - 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(matchedAddrs...))) - - unmatchedMetadata := testMetadata("lookup.example") - require.True(t, rule.MatchAddressLimit(&unmatchedMetadata, unmatchedResponse)) - }) -} - func TestDNSInvertAddressLimitPreLookupRegression(t *testing.T) { t.Parallel() testCases := []struct { @@ -1296,12 +1172,6 @@ func dnsResponseForTest(addresses ...netip.Addr) *mDNS.Msg { return response } -func legacyNegationBranchCIDRs(index int) (string, string, []netip.Addr) { - first := netip.AddrFrom4([4]byte{198, 18, 0, byte(index*2 + 1)}) - second := netip.AddrFrom4([4]byte{198, 18, 0, byte(index*2 + 2)}) - return first.String() + "/32", second.String() + "/32", []netip.Addr{first, second} -} - func addRuleSetItem(rule *abstractDefaultRule, item *RuleSetItem) { rule.ruleSetItem = item rule.allItems = append(rule.allItems, item)