浏览代码

Move predefined DNS server to rule action

世界 7 月之前
父节点
当前提交
9615f48327

+ 14 - 15
common/dialer/resolve.go

@@ -44,6 +44,20 @@ type resolveDialer struct {
 }
 
 func NewResolveDialer(ctx context.Context, dialer N.Dialer, parallel bool, server string, queryOptions adapter.DNSQueryOptions, fallbackDelay time.Duration) ResolveDialer {
+	if parallelDialer, isParallel := dialer.(ParallelInterfaceDialer); isParallel {
+		return &resolveParallelNetworkDialer{
+			resolveDialer{
+				transport:     service.FromContext[adapter.DNSTransportManager](ctx),
+				router:        service.FromContext[adapter.DNSRouter](ctx),
+				dialer:        dialer,
+				parallel:      parallel,
+				server:        server,
+				queryOptions:  queryOptions,
+				fallbackDelay: fallbackDelay,
+			},
+			parallelDialer,
+		}
+	}
 	return &resolveDialer{
 		transport:     service.FromContext[adapter.DNSTransportManager](ctx),
 		router:        service.FromContext[adapter.DNSRouter](ctx),
@@ -60,21 +74,6 @@ type resolveParallelNetworkDialer struct {
 	dialer ParallelInterfaceDialer
 }
 
-func NewResolveParallelInterfaceDialer(ctx context.Context, dialer ParallelInterfaceDialer, parallel bool, server string, queryOptions adapter.DNSQueryOptions, fallbackDelay time.Duration) ParallelInterfaceResolveDialer {
-	return &resolveParallelNetworkDialer{
-		resolveDialer{
-			transport:     service.FromContext[adapter.DNSTransportManager](ctx),
-			router:        service.FromContext[adapter.DNSRouter](ctx),
-			dialer:        dialer,
-			parallel:      parallel,
-			server:        server,
-			queryOptions:  queryOptions,
-			fallbackDelay: fallbackDelay,
-		},
-		dialer,
-	}
-}
-
 func (d *resolveDialer) initialize() error {
 	d.initOnce.Do(d.initServer)
 	return d.initErr

+ 13 - 13
constant/dns.go

@@ -15,19 +15,19 @@ const (
 )
 
 const (
-	DNSTypeLegacy     = "legacy"
-	DNSTypeUDP        = "udp"
-	DNSTypeTCP        = "tcp"
-	DNSTypeTLS        = "tls"
-	DNSTypeHTTPS      = "https"
-	DNSTypeQUIC       = "quic"
-	DNSTypeHTTP3      = "h3"
-	DNSTypeHosts      = "hosts"
-	DNSTypeLocal      = "local"
-	DNSTypePreDefined = "predefined"
-	DNSTypeFakeIP     = "fakeip"
-	DNSTypeDHCP       = "dhcp"
-	DNSTypeTailscale  = "tailscale"
+	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"
 )
 
 const (

+ 1 - 0
constant/rule.go

@@ -33,6 +33,7 @@ const (
 	RuleActionTypeHijackDNS    = "hijack-dns"
 	RuleActionTypeSniff        = "sniff"
 	RuleActionTypeResolve      = "resolve"
+	RuleActionTypePredefined   = "predefined"
 )
 
 const (

+ 32 - 0
dns/router.go

@@ -190,6 +190,8 @@ func (r *Router) matchDNS(ctx context.Context, allowFakeIP bool, ruleIndex int,
 				}
 			case *R.RuleActionReject:
 				return nil, currentRule, currentRuleIndex
+			case *R.RuleActionPredefined:
+				return nil, currentRule, currentRuleIndex
 			}
 		}
 	}
@@ -260,6 +262,21 @@ func (r *Router) Exchange(ctx context.Context, message *mDNS.Msg, options adapte
 						case C.RuleActionRejectMethodDrop:
 							return nil, tun.ErrDrop
 						}
+					case *R.RuleActionPredefined:
+						return &mDNS.Msg{
+							MsgHdr: mDNS.MsgHdr{
+								Id:                 message.Id,
+								Response:           true,
+								Authoritative:      true,
+								RecursionDesired:   true,
+								RecursionAvailable: true,
+								Rcode:              action.Rcode,
+							},
+							Question: message.Question,
+							Answer:   action.Answer,
+							Ns:       action.Ns,
+							Extra:    action.Extra,
+						}, nil
 					}
 				}
 				var responseCheck func(responseAddrs []netip.Addr) bool
@@ -376,6 +393,20 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ
 					case C.RuleActionRejectMethodDrop:
 						return nil, tun.ErrDrop
 					}
+				case *R.RuleActionPredefined:
+					if action.Rcode != mDNS.RcodeSuccess {
+						err = RcodeError(action.Rcode)
+					} else {
+						for _, answer := range action.Answer {
+							switch record := answer.(type) {
+							case *mDNS.A:
+								responseAddrs = append(responseAddrs, M.AddrFromIP(record.A))
+							case *mDNS.AAAA:
+								responseAddrs = append(responseAddrs, M.AddrFromIP(record.AAAA))
+							}
+						}
+					}
+					goto response
 				}
 			}
 			var responseCheck func(responseAddrs []netip.Addr) bool
@@ -395,6 +426,7 @@ func (r *Router) Lookup(ctx context.Context, domain string, options adapter.DNSQ
 			printResult()
 		}
 	}
+response:
 	printResult()
 	if len(responseAddrs) > 0 {
 		r.logger.InfoContext(ctx, "lookup succeed for ", domain, ": ", strings.Join(F.MapToString(responseAddrs), " "))

+ 0 - 83
dns/transport/predefined.go

@@ -1,83 +0,0 @@
-package transport
-
-import (
-	"context"
-
-	"github.com/sagernet/sing-box/adapter"
-	C "github.com/sagernet/sing-box/constant"
-	"github.com/sagernet/sing-box/dns"
-	"github.com/sagernet/sing-box/log"
-	"github.com/sagernet/sing-box/option"
-	"github.com/sagernet/sing/common"
-	E "github.com/sagernet/sing/common/exceptions"
-
-	mDNS "github.com/miekg/dns"
-)
-
-var _ adapter.DNSTransport = (*PredefinedTransport)(nil)
-
-func RegisterPredefined(registry *dns.TransportRegistry) {
-	dns.RegisterTransport[option.PredefinedDNSServerOptions](registry, C.DNSTypePreDefined, NewPredefined)
-}
-
-type PredefinedTransport struct {
-	dns.TransportAdapter
-	responses []*predefinedResponse
-}
-
-type predefinedResponse struct {
-	questions []mDNS.Question
-	answer    *mDNS.Msg
-}
-
-func NewPredefined(ctx context.Context, logger log.ContextLogger, tag string, options option.PredefinedDNSServerOptions) (adapter.DNSTransport, error) {
-	var responses []*predefinedResponse
-	for _, response := range options.Responses {
-		questions, msg, err := response.Build()
-		if err != nil {
-			return nil, err
-		}
-		responses = append(responses, &predefinedResponse{
-			questions: questions,
-			answer:    msg,
-		})
-	}
-	if len(responses) == 0 {
-		return nil, E.New("empty predefined responses")
-	}
-	return &PredefinedTransport{
-		TransportAdapter: dns.NewTransportAdapter(C.DNSTypePreDefined, tag, nil),
-		responses:        responses,
-	}, nil
-}
-
-func (t *PredefinedTransport) Reset() {
-}
-
-func (t *PredefinedTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) {
-	for _, response := range t.responses {
-		for _, question := range response.questions {
-			if func() bool {
-				if question.Name == "" && question.Qtype == mDNS.TypeNone {
-					return true
-				} else if question.Name == "" {
-					return common.Any(message.Question, func(it mDNS.Question) bool {
-						return it.Qtype == question.Qtype
-					})
-				} else if question.Qtype == mDNS.TypeNone {
-					return common.Any(message.Question, func(it mDNS.Question) bool {
-						return it.Name == question.Name
-					})
-				} else {
-					return common.Contains(message.Question, question)
-				}
-			}() {
-				copyAnswer := *response.answer
-				copyAnswer.Id = message.Id
-				copyAnswer.Question = message.Question
-				return &copyAnswer, nil
-			}
-		}
-	}
-	return nil, dns.RcodeNameError
-}

+ 58 - 2
docs/configuration/dns/rule_action.md

@@ -4,7 +4,8 @@ icon: material/new-box
 
 !!! quote "Changes in sing-box 1.12.0"
 
-    :material-plus: [strategy](#strategy)
+    :material-plus: [strategy](#strategy)  
+    :material-plus: [predefined](#predefined)
 
 !!! question "Since sing-box 1.11.0"
 
@@ -31,6 +32,8 @@ Tag of target server.
 
 #### strategy
 
+!!! question "Since sing-box 1.12.0"
+
 Set domain strategy for this query.
 
 One of `prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`.
@@ -69,7 +72,7 @@ Will overrides `dns.client_subnet`.
 ```json
 {
   "action": "reject",
-  "method": "default", // default
+  "method": "",
   "no_drop": false
 }
 ```
@@ -81,8 +84,61 @@ Will overrides `dns.client_subnet`.
 - `default`: Reply with NXDOMAIN.
 - `drop`: Drop the request.
 
+`default` will be used by default.
+
 #### no_drop
 
 If not enabled, `method` will be temporarily overwritten to `drop` after 50 triggers in 30s.
 
 Not available when `method` is set to drop.
+
+### predefined
+
+!!! question "Since sing-box 1.12.0"
+
+```json
+{
+  "action": "predefined",
+  "rcode": "",
+  "answer": [],
+  "ns": [],
+  "extra": []
+}
+```
+
+`predefined` responds with predefined DNS records.
+
+#### rcode
+
+The response code.
+
+| Value      | Value in the legacy rcode server | Description     |
+|------------|----------------------------------|-----------------|
+| `NOERROR`  | `success`                        | Ok              |
+| `FORMERR`  | `format_error`                   | Bad request     |
+| `SERVFAIL` | `server_failure`                 | Server failure  |
+| `NXDOMAIN` | `name_error`                     | Not found       |
+| `NOTIMP`   | `not_implemented`                | Not implemented |
+| `REFUSED`  | `refused`                        | Refused         |
+
+`NOERROR` will be used by default.
+
+#### answer
+
+List of text DNS record to respond as answers.
+
+Examples:
+
+| Record Type | Example                       |
+|-------------|-------------------------------|
+| `A`         | `localhost. IN A 127.0.0.1`   |
+| `AAAA`      | `localhost. IN AAAA ::1`      |
+| `TXT`       | `localhost. IN TXT \"Hello\"` |
+
+#### ns
+
+List of text DNS record to respond as name servers.
+
+#### extra
+
+List of text DNS record to respond as extra records.

+ 60 - 4
docs/configuration/dns/rule_action.zh.md

@@ -4,7 +4,8 @@ icon: material/new-box
 
 !!! quote "sing-box 1.12.0 中的更改"
 
-    :material-plus: [strategy](#strategy)
+    :material-plus: [strategy](#strategy)  
+    :material-plus: [predefined](#predefined)
 
 !!! question "自 sing-box 1.11.0 起"
 
@@ -12,9 +13,9 @@ icon: material/new-box
 
 ```json
 {
-  "action": "route",  // 默认
+  "action": "route",
+  // 默认
   "server": "",
-
   "strategy": "",
   "disable_cache": false,
   "rewrite_ttl": null,
@@ -32,6 +33,8 @@ icon: material/new-box
 
 #### strategy
 
+!!! question "自 sing-box 1.12.0 起"
+
 为此查询设置域名策略。
 
 可选项:`prefer_ipv4` `prefer_ipv6` `ipv4_only` `ipv6_only`。
@@ -70,7 +73,7 @@ icon: material/new-box
 ```json
 {
   "action": "reject",
-  "method": "default", // default
+  "method": "",
   "no_drop": false
 }
 ```
@@ -82,8 +85,61 @@ icon: material/new-box
 - `default`: 返回 NXDOMAIN。
 - `drop`: 丢弃请求。
 
+默认使用 `defualt`。
+
 #### no_drop
 
 如果未启用,则 30 秒内触发 50 次后,`method` 将被暂时覆盖为 `drop`。
 
 当 `method` 设为 `drop` 时不可用。
+
+### predefined
+
+!!! question "自 sing-box 1.12.0 起"
+
+```json
+{
+  "action": "predefined",
+  "rcode": "",
+  "answer": [],
+  "ns": [],
+  "extra": []
+}
+```
+
+`predefined` 以预定义的 DNS 记录响应。
+
+#### rcode
+
+响应码。
+
+| 值          | 旧 rcode DNS 服务器中的值 | 描述              |
+|------------|--------------------|-----------------|
+| `NOERROR`  | `success`          | Ok              |
+| `FORMERR`  | `format_error`     | Bad request     |
+| `SERVFAIL` | `server_failure`   | Server failure  |
+| `NXDOMAIN` | `name_error`       | Not found       |
+| `NOTIMP`   | `not_implemented`  | Not implemented |
+| `REFUSED`  | `refused`          | Refused         |
+
+默认使用 `NOERROR`。
+
+#### answer
+
+用于作为回答响应的文本 DNS 记录列表。
+
+例子:
+
+| 记录类型   | 例子                            |
+|--------|-------------------------------|
+| `A`    | `localhost. IN A 127.0.0.1`   |
+| `AAAA` | `localhost. IN AAAA ::1`      |
+| `TXT`  | `localhost. IN TXT \"Hello\"` |
+
+#### ns
+
+用于作为名称服务器响应的文本 DNS 记录列表。
+
+#### extra
+
+用于作为额外记录响应的文本 DNS 记录列表。

+ 27 - 1
docs/configuration/dns/server/hosts.md

@@ -67,4 +67,30 @@ Example:
     ]
   }
 }
-```
+```
+
+### Examples
+
+=== "Use hosts if available"
+
+    ```json
+    {
+      "dns": {
+        "servers": [
+          {
+            ...
+          },
+          {
+            "type": "hosts",
+            "tag": "hosts"
+          }
+        ],
+        "rules": [
+          {
+            "ip_accept_any": true,
+            "server": "hosts"
+          }
+        ]
+      }
+    }
+    ```

+ 0 - 93
docs/configuration/dns/server/predefined.md

@@ -1,93 +0,0 @@
----
-icon: material/new-box
----
-
-!!! question "Since sing-box 1.12.0"
-
-# Predefined
-
-### Structure
-
-```json
-{
-  "dns": {
-    "servers": [
-      {
-        "type": "predefined",
-        "tag": "",
-        "responses": []
-      }
-    ]
-  }
-}
-```
-
-### Fields
-
-#### responses
-
-==Required==
-
-List of [Response](#response-structure).
-
-### Response Structure
-
-```json
-{
-  "query": [],
-  "query_type": [],
-  "rcode": "",
-  "answer": [],
-  "ns": [],
-  "extra": []
-}
-```
-
-!!! note ""
-
-    You can ignore the JSON Array [] tag when the content is only one item
-
-### Response Fields
-
-#### query
-
-List of domain name to match.
-
-#### query_type
-
-List of query type to match.
-
-#### rcode
-
-The response code.
-
-| Value      | Value in the legacy rcode server | Description     |
-|------------|----------------------------------|-----------------|
-| `NOERROR`  | `success`                        | Ok              |
-| `FORMERR`  | `format_error`                   | Bad request     |
-| `SERVFAIL` | `server_failure`                 | Server failure  |
-| `NXDOMAIN` | `name_error`                     | Not found       |
-| `NOTIMP`   | `not_implemented`                | Not implemented |
-| `REFUSED`  | `refused`                        | Refused         |
-
-`NOERROR` will be used by default.
-
-#### answer
-
-List of text DNS record to respond as answers.
-
-Examples:
-
-| Record Type | Example                       |
-|-------------|-------------------------------|
-| `A`         | `localhost. IN A 127.0.0.1`   |
-| `AAAA`      | `localhost. IN AAAA ::1`      |
-| `TXT`       | `localhost. IN TXT \"Hello\"` |
-
-#### ns
-
-List of text DNS record to respond as name servers.
-
-#### extra
-
-List of text DNS record to respond as extra records.

+ 0 - 1
include/registry.go

@@ -107,7 +107,6 @@ func DNSTransportRegistry() *dns.TransportRegistry {
 	transport.RegisterUDP(registry)
 	transport.RegisterTLS(registry)
 	transport.RegisterHTTPS(registry)
-	transport.RegisterPredefined(registry)
 	hosts.RegisterTransport(registry)
 	local.RegisterTransport(registry)
 	fakeip.RegisterTransport(registry)

+ 0 - 1
mkdocs.yml

@@ -91,7 +91,6 @@ nav:
               - QUIC: configuration/dns/server/quic.md
               - HTTPS: configuration/dns/server/https.md
               - HTTP3: configuration/dns/server/http3.md
-              - Predefined: configuration/dns/server/predefined.md
               - DHCP: configuration/dns/server/dhcp.md
               - FakeIP: configuration/dns/server/fakeip.md
               - Tailscale: configuration/dns/server/tailscale.md

+ 42 - 9
option/dns.go

@@ -46,7 +46,46 @@ func (o *DNSOptions) UnmarshalJSONContext(ctx context.Context, content []byte) e
 	}
 	legacyOptions := o.LegacyDNSOptions
 	o.LegacyDNSOptions = LegacyDNSOptions{}
-	return badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions)
+	err = badjson.UnmarshallExcludedContext(ctx, content, legacyOptions, &o.RawDNSOptions)
+	if err != nil {
+		return err
+	}
+	rcodeMap := make(map[string]int)
+	o.Servers = common.Filter(o.Servers, func(it NewDNSServerOptions) bool {
+		if it.Type == C.DNSTypeLegacyRcode {
+			rcodeMap[it.Tag] = it.Options.(int)
+			return false
+		}
+		return true
+	})
+	if len(rcodeMap) > 0 {
+		for i := 0; i < len(o.Rules); i++ {
+			rewriteRcode(rcodeMap, &o.Rules[i])
+		}
+	}
+	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
 }
 
 type DNSClientOptions struct {
@@ -243,14 +282,8 @@ func (o *NewDNSServerOptions) Upgrade(ctx context.Context) error {
 		default:
 			return E.New("unknown rcode: ", serverURL.Host)
 		}
-		o.Type = C.DNSTypePreDefined
-		o.Options = &PredefinedDNSServerOptions{
-			Responses: []DNSResponseOptions{
-				{
-					RCode: common.Ptr(DNSRCode(rcode)),
-				},
-			},
-		}
+		o.Type = C.DNSTypeLegacyRcode
+		o.Options = rcode
 	case C.DNSTypeDHCP:
 		o.Type = C.DNSTypeDHCP
 		dhcpOptions := DHCPDNSServerOptions{}

+ 1 - 60
option/dns_record.go

@@ -3,30 +3,14 @@ package option
 import (
 	"encoding/base64"
 
-	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/buf"
 	E "github.com/sagernet/sing/common/exceptions"
 	"github.com/sagernet/sing/common/json"
-	"github.com/sagernet/sing/common/json/badoption"
 	M "github.com/sagernet/sing/common/metadata"
 
 	"github.com/miekg/dns"
 )
 
-type PredefinedDNSServerOptions struct {
-	Responses []DNSResponseOptions `json:"responses,omitempty"`
-}
-
-type DNSResponseOptions struct {
-	Query     badoption.Listable[string]       `json:"query,omitempty"`
-	QueryType badoption.Listable[DNSQueryType] `json:"query_type,omitempty"`
-
-	RCode  *DNSRCode                            `json:"rcode,omitempty"`
-	Answer badoption.Listable[DNSRecordOptions] `json:"answer,omitempty"`
-	Ns     badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"`
-	Extra  badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"`
-}
-
 type DNSRCode int
 
 func (r DNSRCode) MarshalJSON() ([]byte, error) {
@@ -64,49 +48,6 @@ func (r *DNSRCode) Build() int {
 	return int(*r)
 }
 
-func (o DNSResponseOptions) Build() ([]dns.Question, *dns.Msg, error) {
-	var questions []dns.Question
-	if len(o.Query) == 0 && len(o.QueryType) == 0 {
-		questions = []dns.Question{{Qclass: dns.ClassINET}}
-	} else if len(o.Query) == 0 {
-		for _, queryType := range o.QueryType {
-			questions = append(questions, dns.Question{
-				Qtype:  uint16(queryType),
-				Qclass: dns.ClassINET,
-			})
-		}
-	} else if len(o.QueryType) == 0 {
-		for _, domain := range o.Query {
-			questions = append(questions, dns.Question{
-				Name:   dns.Fqdn(domain),
-				Qclass: dns.ClassINET,
-			})
-		}
-	} else {
-		for _, queryType := range o.QueryType {
-			for _, domain := range o.Query {
-				questions = append(questions, dns.Question{
-					Name:   dns.Fqdn(domain),
-					Qtype:  uint16(queryType),
-					Qclass: dns.ClassINET,
-				})
-			}
-		}
-	}
-	return questions, &dns.Msg{
-		MsgHdr: dns.MsgHdr{
-			Response:           true,
-			Rcode:              o.RCode.Build(),
-			Authoritative:      true,
-			RecursionDesired:   true,
-			RecursionAvailable: true,
-		},
-		Answer: common.Map(o.Answer, DNSRecordOptions.build),
-		Ns:     common.Map(o.Ns, DNSRecordOptions.build),
-		Extra:  common.Map(o.Extra, DNSRecordOptions.build),
-	}, nil
-}
-
 type DNSRecordOptions struct {
 	dns.RR
 	fromBase64 bool
@@ -156,6 +97,6 @@ func (o *DNSRecordOptions) unmarshalBase64(binary []byte) error {
 	return nil
 }
 
-func (o DNSRecordOptions) build() dns.RR {
+func (o DNSRecordOptions) Build() dns.RR {
 	return o.RR
 }

+ 12 - 0
option/rule_action.go

@@ -92,6 +92,7 @@ type _DNSRuleAction struct {
 	RouteOptions        DNSRouteActionOptions        `json:"-"`
 	RouteOptionsOptions DNSRouteOptionsActionOptions `json:"-"`
 	RejectOptions       RejectActionOptions          `json:"-"`
+	PredefinedOptions   DNSRouteActionPredefined     `json:"-"`
 }
 
 type DNSRuleAction _DNSRuleAction
@@ -109,6 +110,8 @@ func (r DNSRuleAction) MarshalJSON() ([]byte, error) {
 		v = r.RouteOptionsOptions
 	case C.RuleActionTypeReject:
 		v = r.RejectOptions
+	case C.RuleActionTypePredefined:
+		v = r.PredefinedOptions
 	default:
 		return nil, E.New("unknown DNS rule action: " + r.Action)
 	}
@@ -129,6 +132,8 @@ func (r *DNSRuleAction) UnmarshalJSONContext(ctx context.Context, data []byte) e
 		v = &r.RouteOptionsOptions
 	case C.RuleActionTypeReject:
 		v = &r.RejectOptions
+	case C.RuleActionTypePredefined:
+		v = &r.PredefinedOptions
 	default:
 		return E.New("unknown DNS rule action: " + r.Action)
 	}
@@ -294,3 +299,10 @@ type RouteActionResolve struct {
 	RewriteTTL   *uint32               `json:"rewrite_ttl,omitempty"`
 	ClientSubnet *badoption.Prefixable `json:"client_subnet,omitempty"`
 }
+
+type DNSRouteActionPredefined struct {
+	Rcode  *DNSRCode                            `json:"rcode,omitempty"`
+	Answer badoption.Listable[DNSRecordOptions] `json:"answer,omitempty"`
+	Ns     badoption.Listable[DNSRecordOptions] `json:"ns,omitempty"`
+	Extra  badoption.Listable[DNSRecordOptions] `json:"extra,omitempty"`
+}

+ 29 - 0
route/rule/rule_action.go

@@ -20,6 +20,8 @@ import (
 	"github.com/sagernet/sing/common/logger"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
+
+	"github.com/miekg/dns"
 )
 
 func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action option.RuleAction) (adapter.RuleAction, error) {
@@ -126,6 +128,13 @@ func NewDNSRuleAction(logger logger.ContextLogger, action option.DNSRuleAction)
 			NoDrop: action.RejectOptions.NoDrop,
 			logger: logger,
 		}
+	case C.RuleActionTypePredefined:
+		return &RuleActionPredefined{
+			Rcode:  action.PredefinedOptions.Rcode.Build(),
+			Answer: common.Map(action.PredefinedOptions.Answer, option.DNSRecordOptions.Build),
+			Ns:     common.Map(action.PredefinedOptions.Ns, option.DNSRecordOptions.Build),
+			Extra:  common.Map(action.PredefinedOptions.Extra, option.DNSRecordOptions.Build),
+		}
 	default:
 		panic(F.ToString("unknown rule action: ", action.Action))
 	}
@@ -413,3 +422,23 @@ func (r *RuleActionResolve) String() string {
 		return F.ToString("resolve(", strings.Join(options, ","), ")")
 	}
 }
+
+type RuleActionPredefined struct {
+	Rcode  int
+	Answer []dns.RR
+	Ns     []dns.RR
+	Extra  []dns.RR
+}
+
+func (r *RuleActionPredefined) Type() string {
+	return C.RuleActionTypePredefined
+}
+
+func (r *RuleActionPredefined) String() string {
+	var options []string
+	options = append(options, dns.RcodeToString[r.Rcode])
+	options = append(options, common.Map(r.Answer, dns.RR.String)...)
+	options = append(options, common.Map(r.Ns, dns.RR.String)...)
+	options = append(options, common.Map(r.Extra, dns.RR.String)...)
+	return F.ToString("predefined(", strings.Join(options, ","), ")")
+}