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] 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 2a5aaefda..15d71c530 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 dd637a6cc..e7980c1e4 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 8d1a0770b..2227e0d82 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 b49f82d3f..ef5110eed 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 b15fc871e..4fc505891 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 b64f7c02d..6e44fbb9c 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 0ff9d0c08..b7d9b0dbd 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 027e80076..212396b7b 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 000000000..0007cd36e --- /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 194fed39f..2fe6ba98a 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)