diff --git a/adapter/router.go b/adapter/router.go index a8f66ba67..b8564eb0a 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 15761055b..a3a874a81 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 babd6cf83..d28718c23 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 61612f88f..28f74161f 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 03fb64ef3..8c695ecc7 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)