diff --git a/dns/router.go b/dns/router.go index 96abc1238..bc16fad0f 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 b83114bfd..f5e06cba0 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 b5ccf2080..27e018837 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 000000000..30df91735 --- /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") +}