Add DNS respond rule action

This commit is contained in:
世界
2026-04-01 21:59:25 +08:00
parent bbccdbcc92
commit 6daed349b6
10 changed files with 338 additions and 3 deletions

View File

@@ -30,6 +30,7 @@ const (
RuleActionTypeRoute = "route"
RuleActionTypeRouteOptions = "route-options"
RuleActionTypeEvaluate = "evaluate"
RuleActionTypeRespond = "respond"
RuleActionTypeDirect = "direct"
RuleActionTypeBypass = "bypass"
RuleActionTypeReject = "reject"

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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.

View File

@@ -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 响应码。

View File

@@ -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

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")
}

View File

@@ -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)