From fdec2fe051137c31888b8ac1500153f9909179b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Wed, 29 Apr 2026 22:48:02 +0800 Subject: [PATCH] dns: Add preferred_by rule item --- adapter/dns.go | 5 ++ dns/transport/hosts/hosts.go | 17 +++++- dns/transport/local/local.go | 14 ++++- dns/transport/local/local_darwin.go | 14 ++++- dns/transport/local/local_neighbor.go | 11 ++++ docs/configuration/dns/rule.md | 17 ++++++ docs/configuration/dns/rule.zh.md | 17 ++++++ option/rule_dns.go | 1 + protocol/tailscale/dns_transport.go | 16 +++++ route/rule/rule_dns.go | 5 ++ route/rule/rule_item_preferred_by_dns.go | 74 ++++++++++++++++++++++++ service/resolved/transport.go | 21 ++++++- 12 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 route/rule/rule_item_preferred_by_dns.go diff --git a/adapter/dns.go b/adapter/dns.go index eeaf12a44..afee5aa8a 100644 --- a/adapter/dns.go +++ b/adapter/dns.go @@ -86,6 +86,11 @@ type DNSTransport interface { Exchange(ctx context.Context, message *dns.Msg) (*dns.Msg, error) } +type DNSTransportWithPreferredDomain interface { + DNSTransport + PreferredDomain(domain string) bool +} + type DNSTransportRegistry interface { option.DNSTransportOptionsRegistry CreateDNSTransport(ctx context.Context, logger log.ContextLogger, tag string, transportType string, options any) (DNSTransport, error) diff --git a/dns/transport/hosts/hosts.go b/dns/transport/hosts/hosts.go index aeb878179..074c07c95 100644 --- a/dns/transport/hosts/hosts.go +++ b/dns/transport/hosts/hosts.go @@ -19,7 +19,10 @@ func RegisterTransport(registry *dns.TransportRegistry) { dns.RegisterTransport[option.HostsDNSServerOptions](registry, C.DNSTypeHosts, NewTransport) } -var _ adapter.DNSTransport = (*Transport)(nil) +var ( + _ adapter.DNSTransport = (*Transport)(nil) + _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) +) type Transport struct { dns.TransportAdapter @@ -66,6 +69,18 @@ func (t *Transport) Close() error { func (t *Transport) Reset() { } +func (t *Transport) PreferredDomain(domain string) bool { + if _, loaded := t.predefined[domain]; loaded { + return true + } + for _, file := range t.files { + if len(file.Lookup(domain)) > 0 { + return true + } + } + return false +} + func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { question := message.Question[0] domain := mDNS.CanonicalName(question.Name) diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index 7cff674d2..d13de3fa4 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -23,7 +23,10 @@ func RegisterTransport(registry *dns.TransportRegistry) { dns.RegisterTransport[option.LocalDNSServerOptions](registry, C.DNSTypeLocal, NewTransport) } -var _ adapter.DNSTransport = (*Transport)(nil) +var ( + _ adapter.DNSTransport = (*Transport)(nil) + _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) +) type Transport struct { dns.TransportAdapter @@ -97,6 +100,15 @@ func (t *Transport) Close() error { func (t *Transport) Reset() { } +func (t *Transport) PreferredDomain(domain string) bool { + if t.hosts != nil && t.resolved == nil { + if len(t.hosts.Lookup(dns.FqdnToDomain(domain))) > 0 { + return true + } + } + return t.hasNeighborHost(domain) +} + func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if t.resolved != nil { response := t.lookupNeighbor(message) diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go index 85cc16ed0..d38a0bf7b 100644 --- a/dns/transport/local/local_darwin.go +++ b/dns/transport/local/local_darwin.go @@ -24,7 +24,10 @@ func RegisterTransport(registry *dns.TransportRegistry) { dns.RegisterTransport[option.LocalDNSServerOptions](registry, C.DNSTypeLocal, NewTransport) } -var _ adapter.DNSTransport = (*Transport)(nil) +var ( + _ adapter.DNSTransport = (*Transport)(nil) + _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) +) type Transport struct { dns.TransportAdapter @@ -106,3 +109,12 @@ func (t *Transport) Reset() { t.dhcpTransport.Reset() } } + +func (t *Transport) PreferredDomain(domain string) bool { + if t.hosts != nil { + if len(t.hosts.Lookup(dns.FqdnToDomain(domain))) > 0 { + return true + } + } + return t.hasNeighborHost(domain) +} diff --git a/dns/transport/local/local_neighbor.go b/dns/transport/local/local_neighbor.go index e48ba8a85..3dd394a8b 100644 --- a/dns/transport/local/local_neighbor.go +++ b/dns/transport/local/local_neighbor.go @@ -43,6 +43,17 @@ func (t *Transport) lookupNeighbor(message *mDNS.Msg) *mDNS.Msg { return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL) } +func (t *Transport) hasNeighborHost(domain string) bool { + if t.neighborResolver == nil { + return false + } + host := extractNeighborHost(domain, t.neighborSuffixes) + if host == "" { + return false + } + return len(t.neighborResolver.LookupAddresses(host)) > 0 +} + func extractNeighborHost(canonical string, suffixes []string) string { for _, suffix := range suffixes { if !strings.HasSuffix(canonical, suffix) || len(canonical) <= len(suffix) { diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index b0785b778..b8783d60e 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -6,6 +6,7 @@ icon: material/alert-decagram :material-plus: [source_mac_address](#source_mac_address) :material-plus: [source_hostname](#source_hostname) + :material-plus: [preferred_by](#preferred_by) :material-plus: [match_response](#match_response) :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) :material-plus: [response_rcode](#response_rcode) @@ -166,6 +167,10 @@ icon: material/alert-decagram "source_hostname": [ "my-device" ], + "preferred_by": [ + "local", + "ts-dns" + ], "wifi_ssid": [ "My WIFI" ], @@ -496,6 +501,18 @@ Match source device MAC address. Match source device hostname from DHCP leases. +#### preferred_by + +!!! question "Since sing-box 1.14.0" + +Match specified DNS servers' preferred domains. + +| Type | Match | +|-------------|-----------------------------------------------------| +| `hosts` | Match predefined entries and entries in hosts files | +| `local` | Match hosts entries and neighbor-resolved hosts | +| `tailscale` | Match MagicDNS hosts and DNS route suffixes | + #### wifi_ssid !!! quote "" diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index cc0a3037e..2c2de7d03 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -6,6 +6,7 @@ icon: material/alert-decagram :material-plus: [source_mac_address](#source_mac_address) :material-plus: [source_hostname](#source_hostname) + :material-plus: [preferred_by](#preferred_by) :material-plus: [match_response](#match_response) :material-delete-clock: [rule_set_ip_cidr_accept_empty](#rule_set_ip_cidr_accept_empty) :material-plus: [response_rcode](#response_rcode) @@ -166,6 +167,10 @@ icon: material/alert-decagram "source_hostname": [ "my-device" ], + "preferred_by": [ + "local", + "ts-dns" + ], "wifi_ssid": [ "My WIFI" ], @@ -488,6 +493,18 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配源设备从 DHCP 租约获取的主机名。 +#### preferred_by + +!!! question "自 sing-box 1.14.0 起" + +匹配指定 DNS 服务器的首选域名。 + +| 类型 | 匹配 | +|-------------|--------------------------| +| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | +| `local` | 匹配 hosts 中的条目和邻居解析得到的主机名 | +| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | + #### wifi_ssid !!! quote "" diff --git a/option/rule_dns.go b/option/rule_dns.go index 74058a654..ab1ddb24a 100644 --- a/option/rule_dns.go +++ b/option/rule_dns.go @@ -103,6 +103,7 @@ type RawDefaultDNSRule struct { DefaultInterfaceAddress badoption.Listable[*badoption.Prefixable] `json:"default_interface_address,omitempty"` SourceMACAddress badoption.Listable[string] `json:"source_mac_address,omitempty"` SourceHostname badoption.Listable[string] `json:"source_hostname,omitempty"` + PreferredBy badoption.Listable[string] `json:"preferred_by,omitempty"` RuleSet badoption.Listable[string] `json:"rule_set,omitempty"` RuleSetIPCIDRMatchSource bool `json:"rule_set_ip_cidr_match_source,omitempty"` MatchResponse bool `json:"match_response,omitempty"` diff --git a/protocol/tailscale/dns_transport.go b/protocol/tailscale/dns_transport.go index adfe388bf..9b2263712 100644 --- a/protocol/tailscale/dns_transport.go +++ b/protocol/tailscale/dns_transport.go @@ -255,6 +255,22 @@ func (t *DNSTransport) Raw() bool { return true } +func (t *DNSTransport) PreferredDomain(domain string) bool { + t.access.RLock() + hosts := t.hosts + routes := t.routes + t.access.RUnlock() + if _, loaded := hosts[domain]; loaded { + return true + } + for suffix := range routes { + if strings.HasSuffix(domain, suffix) { + return true + } + } + return false +} + func (t *DNSTransport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if len(message.Question) != 1 { return nil, os.ErrInvalid diff --git a/route/rule/rule_dns.go b/route/rule/rule_dns.go index 646f987ed..edc0e06cc 100644 --- a/route/rule/rule_dns.go +++ b/route/rule/rule_dns.go @@ -329,6 +329,11 @@ func NewDefaultDNSRule(ctx context.Context, logger log.ContextLogger, options op rule.items = append(rule.items, item) rule.allItems = append(rule.allItems, item) } + if len(options.PreferredBy) > 0 { + item := NewPreferredByDNSItem(ctx, options.PreferredBy) + rule.items = append(rule.items, item) + rule.allItems = append(rule.allItems, item) + } if options.RuleSetIPCIDRAcceptEmpty { //nolint:staticcheck if legacyDNSMode { deprecated.Report(ctx, deprecated.OptionRuleSetIPCIDRAcceptEmpty) diff --git a/route/rule/rule_item_preferred_by_dns.go b/route/rule/rule_item_preferred_by_dns.go new file mode 100644 index 000000000..d00d780c6 --- /dev/null +++ b/route/rule/rule_item_preferred_by_dns.go @@ -0,0 +1,74 @@ +package rule + +import ( + "context" + "strings" + + "github.com/sagernet/sing-box/adapter" + E "github.com/sagernet/sing/common/exceptions" + F "github.com/sagernet/sing/common/format" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" +) + +var _ RuleItem = (*PreferredByDNSItem)(nil) + +type PreferredByDNSItem struct { + ctx context.Context + transportTags []string + transports []adapter.DNSTransportWithPreferredDomain +} + +func NewPreferredByDNSItem(ctx context.Context, transportTags []string) *PreferredByDNSItem { + return &PreferredByDNSItem{ + ctx: ctx, + transportTags: transportTags, + } +} + +func (r *PreferredByDNSItem) Start() error { + transportManager := service.FromContext[adapter.DNSTransportManager](r.ctx) + for _, transportTag := range r.transportTags { + rawTransport, loaded := transportManager.Transport(transportTag) + if !loaded { + return E.New("DNS server not found: ", transportTag) + } + transportWithPreferredDomain, withPreferredDomain := rawTransport.(adapter.DNSTransportWithPreferredDomain) + if !withPreferredDomain { + return E.New("DNS server type does not support preferred_by: ", rawTransport.Type()) + } + r.transports = append(r.transports, transportWithPreferredDomain) + } + return nil +} + +func (r *PreferredByDNSItem) Match(metadata *adapter.InboundContext) bool { + var domainHost string + if metadata.Domain != "" { + domainHost = metadata.Domain + } else { + domainHost = metadata.Destination.Fqdn + } + if domainHost == "" { + return false + } + canonical := mDNS.CanonicalName(domainHost) + for _, transport := range r.transports { + if transport.PreferredDomain(canonical) { + return true + } + } + return false +} + +func (r *PreferredByDNSItem) String() string { + description := "preferred_by=" + pLen := len(r.transportTags) + if pLen == 1 { + description += F.ToString(r.transportTags[0]) + } else { + description += "[" + strings.Join(F.MapToString(r.transportTags), " ") + "]" + } + return description +} diff --git a/service/resolved/transport.go b/service/resolved/transport.go index ac20663ae..e217904de 100644 --- a/service/resolved/transport.go +++ b/service/resolved/transport.go @@ -32,7 +32,10 @@ func RegisterTransport(registry *dns.TransportRegistry) { dns.RegisterTransport[option.ResolvedDNSServerOptions](registry, C.TypeResolved, NewTransport) } -var _ adapter.DNSTransport = (*Transport)(nil) +var ( + _ adapter.DNSTransport = (*Transport)(nil) + _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) +) type Transport struct { dns.TransportAdapter @@ -191,6 +194,22 @@ func (t *Transport) deleteTransport(link *TransportLink) { delete(t.linkServers, link) } +func (t *Transport) PreferredDomain(domain string) bool { + t.service.linkAccess.RLock() + defer t.service.linkAccess.RUnlock() + for _, link := range t.service.links { + for _, linkDomain := range link.domain { + if linkDomain.Domain == "." { + continue + } + if strings.HasSuffix(domain, linkDomain.Domain) { + return true + } + } + } + return false +} + func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { question := message.Question[0] var selectedLink *TransportLink