diff --git a/adapter/neighbor.go b/adapter/neighbor.go index d917db5b7..3115b2935 100644 --- a/adapter/neighbor.go +++ b/adapter/neighbor.go @@ -14,6 +14,7 @@ type NeighborEntry struct { type NeighborResolver interface { LookupMAC(address netip.Addr) (net.HardwareAddr, bool) LookupHostname(address netip.Addr) (string, bool) + LookupAddresses(hostname string) []netip.Addr Start() error Close() error } diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index 55933510a..7cff674d2 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -14,6 +14,7 @@ import ( E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" N "github.com/sagernet/sing/common/network" + "github.com/sagernet/sing/service" mDNS "github.com/miekg/dns" ) @@ -26,12 +27,14 @@ var _ adapter.DNSTransport = (*Transport)(nil) type Transport struct { dns.TransportAdapter - ctx context.Context - logger logger.ContextLogger - hosts *hosts.File - dialer N.Dialer - preferGo bool - resolved ResolvedResolver + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + preferGo bool + resolved ResolvedResolver + neighborResolver adapter.NeighborResolver + neighborSuffixes []string } func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { @@ -39,13 +42,17 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt if err != nil { return nil, err } - + suffixes, err := buildNeighborMatchers(options.NeighborDomain) + if err != nil { + return nil, err + } return &Transport{ TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeLocal, tag, options), ctx: ctx, logger: logger, dialer: transportDialer, preferGo: options.PreferGo, + neighborSuffixes: suffixes, }, nil } @@ -71,6 +78,11 @@ func (t *Transport) Start(stage adapter.StartStage) error { } } } + case adapter.StartStateStart: + router := service.FromContext[adapter.Router](t.ctx) + if router != nil { + t.neighborResolver = router.NeighborResolver() + } } return nil } @@ -87,6 +99,10 @@ func (t *Transport) Reset() { func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { if t.resolved != nil { + response := t.lookupNeighbor(message) + if response != nil { + return response, nil + } return t.resolved.Exchange(ctx, message) } question := message.Question[0] @@ -96,5 +112,9 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL), nil } } + response := t.lookupNeighbor(message) + if response != nil { + return response, nil + } return t.exchange(ctx, message, question.Name) } diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go index 75fdfd9ab..85cc16ed0 100644 --- a/dns/transport/local/local_darwin.go +++ b/dns/transport/local/local_darwin.go @@ -28,12 +28,14 @@ var _ adapter.DNSTransport = (*Transport)(nil) type Transport struct { dns.TransportAdapter - ctx context.Context - logger logger.ContextLogger - hosts *hosts.File - dialer N.Dialer - fallback bool - dhcpTransport dhcpTransport + ctx context.Context + logger logger.ContextLogger + hosts *hosts.File + dialer N.Dialer + fallback bool + dhcpTransport dhcpTransport + neighborResolver adapter.NeighborResolver + neighborSuffixes []string } type dhcpTransport interface { @@ -47,39 +49,48 @@ func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, opt if err != nil { return nil, err } + suffixes, err := buildNeighborMatchers(options.NeighborDomain) + if err != nil { + return nil, err + } return &Transport{ TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeLocal, tag, options), ctx: ctx, logger: logger, dialer: transportDialer, + neighborSuffixes: suffixes, }, nil } func (t *Transport) Start(stage adapter.StartStage) error { - if stage != adapter.StartStateStart { - return nil - } - defaultHosts, err := hosts.NewDefault() - if err != nil { - t.logger.Warn(err) - } else { - t.hosts = defaultHosts - } - inboundManager := service.FromContext[adapter.InboundManager](t.ctx) - for _, inbound := range inboundManager.Inbounds() { - if inbound.Type() == C.TypeTun { - t.fallback = true - break + switch stage { + case adapter.StartStateStart: + defaultHosts, err := hosts.NewDefault() + if err != nil { + t.logger.Warn(err) + } else { + t.hosts = defaultHosts } - } - if t.fallback { - t.dhcpTransport = newDHCPTransport(t.TransportAdapter, log.ContextWithOverrideLevel(t.ctx, log.LevelDebug), t.dialer, t.logger) - if t.dhcpTransport != nil { - err := t.dhcpTransport.Start(stage) - if err != nil { - return err + inboundManager := service.FromContext[adapter.InboundManager](t.ctx) + for _, inbound := range inboundManager.Inbounds() { + if inbound.Type() == C.TypeTun { + t.fallback = true + break } } + if t.fallback { + t.dhcpTransport = newDHCPTransport(t.TransportAdapter, log.ContextWithOverrideLevel(t.ctx, log.LevelDebug), t.dialer, t.logger) + if t.dhcpTransport != nil { + err = t.dhcpTransport.Start(stage) + if err != nil { + return err + } + } + } + router := service.FromContext[adapter.Router](t.ctx) + if router != nil { + t.neighborResolver = router.NeighborResolver() + } } return nil } diff --git a/dns/transport/local/local_darwin_cgo.go b/dns/transport/local/local_darwin_cgo.go index 6468a31f1..bfea8dd64 100644 --- a/dns/transport/local/local_darwin_cgo.go +++ b/dns/transport/local/local_darwin_cgo.go @@ -86,7 +86,11 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, return dns.FixedResponse(message.Id, question, addresses, boxC.DefaultDNSTTL), nil } } - if t.fallback && t.dhcpTransport != nil { + response := t.lookupNeighbor(message) + if response != nil { + return response, nil + } + if t.dhcpTransport != nil { dhcpServers := t.dhcpTransport.Fetch() if len(dhcpServers) > 0 { return t.dhcpTransport.Exchange0(ctx, message, dhcpServers) diff --git a/dns/transport/local/local_neighbor.go b/dns/transport/local/local_neighbor.go new file mode 100644 index 000000000..e48ba8a85 --- /dev/null +++ b/dns/transport/local/local_neighbor.go @@ -0,0 +1,57 @@ +package local + +import ( + "strings" + + C "github.com/sagernet/sing-box/constant" + "github.com/sagernet/sing-box/dns" + E "github.com/sagernet/sing/common/exceptions" + + mDNS "github.com/miekg/dns" +) + +func buildNeighborMatchers(domains []string) ([]string, error) { + if len(domains) == 0 { + return nil, nil + } + var suffixes []string + for _, domain := range domains { + if !strings.HasPrefix(domain, ".") { + return nil, E.New("neighbor_domain entry must start with '.': ", domain) + } + suffixes = append(suffixes, mDNS.CanonicalName(domain)) + } + return suffixes, nil +} + +func (t *Transport) lookupNeighbor(message *mDNS.Msg) *mDNS.Msg { + if t.neighborResolver == nil { + return nil + } + question := message.Question[0] + if question.Qtype != mDNS.TypeA && question.Qtype != mDNS.TypeAAAA { + return nil + } + host := extractNeighborHost(mDNS.CanonicalName(question.Name), t.neighborSuffixes) + if host == "" { + return nil + } + addresses := t.neighborResolver.LookupAddresses(host) + if len(addresses) == 0 { + return nil + } + return dns.FixedResponse(message.Id, question, addresses, C.DefaultDNSTTL) +} + +func extractNeighborHost(canonical string, suffixes []string) string { + for _, suffix := range suffixes { + if !strings.HasSuffix(canonical, suffix) || len(canonical) <= len(suffix) { + continue + } + host := canonical[:len(canonical)-len(suffix)] + if !strings.ContainsRune(host, '.') { + return host + } + } + return "" +} diff --git a/docs/configuration/dns/server/local.md b/docs/configuration/dns/server/local.md index aa7f095a3..39c22b065 100644 --- a/docs/configuration/dns/server/local.md +++ b/docs/configuration/dns/server/local.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [neighbor_domain](#neighbor_domain) + !!! quote "Changes in sing-box 1.13.0" :material-plus: [prefer_go](#prefer_go) @@ -19,7 +23,8 @@ icon: material/new-box { "type": "local", "tag": "", - "prefer_go": false + "prefer_go": false, + "neighbor_domain": [] // Dial Fields } @@ -56,6 +61,19 @@ On devices running Android versions lower than 10, this interface can only resol 2. On macOS, `local` will try DHCP first in Network Extension, since DHCP respects DIal Fields, it will not be disabled by `prefer_go`. +#### neighbor_domain + +!!! question "Since sing-box 1.14.0" + +A list of domain suffixes for which A/AAAA queries are answered from the +[neighbor resolver](/configuration/shared/neighbor/) instead of the upstream. + +Each entry must start with `.`. Only queries whose host part (the portion +before the suffix) contains no dots are matched; `.` matches any +single-label name such as `nas`. + +Example: `[".", ".lan"]`. + ### Dial Fields See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/local.zh.md b/docs/configuration/dns/server/local.zh.md index 50ac05acd..c19efc1e1 100644 --- a/docs/configuration/dns/server/local.zh.md +++ b/docs/configuration/dns/server/local.zh.md @@ -2,6 +2,10 @@ icon: material/new-box --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [neighbor_domain](#neighbor_domain) + !!! quote "sing-box 1.13.0 中的更改" :material-plus: [prefer_go](#prefer_go) @@ -20,6 +24,7 @@ icon: material/new-box "type": "local", "tag": "", "prefer_go": false, + "neighbor_domain": [], // 拨号字段 } @@ -56,6 +61,17 @@ icon: material/new-box 2. 在 macOS 上,`local` 会在 Network Extension 中首先尝试 DHCP,由于 DHCP 遵循拨号字段, 它不会被 `prefer_go` 禁用。 +#### neighbor_domain + +!!! question "自 sing-box 1.14.0 起" + +用于从[邻居解析器](/zh/configuration/shared/neighbor/)而非上游回答 A/AAAA 查询的域后缀列表。 + +每一项必须以 `.` 开头。仅匹配后缀之前的主机名部分不包含点的查询; +`.` 匹配任意单标签名称,例如 `nas`。 + +示例:`[".", ".lan"]`。 + ### 拨号字段 参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 \ No newline at end of file diff --git a/docs/configuration/shared/neighbor.md b/docs/configuration/shared/neighbor.md index c67d995eb..a0d5f36a7 100644 --- a/docs/configuration/shared/neighbor.md +++ b/docs/configuration/shared/neighbor.md @@ -8,7 +8,9 @@ Match LAN devices by MAC address and hostname using [`source_mac_address`](/configuration/route/rule/#source_mac_address) and [`source_hostname`](/configuration/route/rule/#source_hostname) rule items. -Neighbor resolution is automatically enabled when these rule items exist. +Neighbor resolution is automatically enabled when these rule items exist +or when a [local DNS server](/configuration/dns/server/local/) sets +[neighbor_domain](/configuration/dns/server/local/#neighbor_domain). Use [`route.find_neighbor`](/configuration/route/#find_neighbor) to force enable it for logging without rules. ## Linux diff --git a/docs/configuration/shared/neighbor.zh.md b/docs/configuration/shared/neighbor.zh.md index 96297fcb5..7f4065ecf 100644 --- a/docs/configuration/shared/neighbor.zh.md +++ b/docs/configuration/shared/neighbor.zh.md @@ -8,7 +8,7 @@ icon: material/lan [`source_mac_address`](/configuration/route/rule/#source_mac_address) 和 [`source_hostname`](/configuration/route/rule/#source_hostname) 规则项匹配局域网设备的 MAC 地址和主机名。 -当这些规则项存在时,邻居解析自动启用。 +当这些规则项存在,或 [local DNS 服务器](/zh/configuration/dns/server/local/) 设置了 [neighbor_domain](/zh/configuration/dns/server/local/#neighbor_domain) 时,邻居解析自动启用。 使用 [`route.find_neighbor`](/configuration/route/#find_neighbor) 可在没有规则时强制启用以输出日志。 ## Linux diff --git a/option/dns.go b/option/dns.go index c0d131a30..53a078bb3 100644 --- a/option/dns.go +++ b/option/dns.go @@ -154,7 +154,8 @@ type RawLocalDNSServerOptions struct { type LocalDNSServerOptions struct { RawLocalDNSServerOptions - PreferGo bool `json:"prefer_go,omitempty"` + PreferGo bool `json:"prefer_go,omitempty"` + NeighborDomain badoption.Listable[string] `json:"neighbor_domain,omitempty"` } type RemoteDNSServerOptions struct { diff --git a/route/neighbor_resolver_darwin.go b/route/neighbor_resolver_darwin.go index a8884ae62..cc24ec73e 100644 --- a/route/neighbor_resolver_darwin.go +++ b/route/neighbor_resolver_darwin.go @@ -110,6 +110,12 @@ func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool return nil, false } +func (r *neighborResolver) LookupAddresses(hostname string) []netip.Addr { + r.access.RLock() + defer r.access.RUnlock() + return lookupAddressesByHostname(hostname, r.ipToHostname, r.macToHostname, r.neighborIPToMAC, r.leaseIPToMAC) +} + func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { r.access.RLock() defer r.access.RUnlock() diff --git a/route/neighbor_resolver_hostname.go b/route/neighbor_resolver_hostname.go new file mode 100644 index 000000000..d030bd1cd --- /dev/null +++ b/route/neighbor_resolver_hostname.go @@ -0,0 +1,56 @@ +package route + +import ( + "net" + "net/netip" + "strings" + + "github.com/sagernet/sing-box/dns" +) + +func lookupAddressesByHostname( + hostname string, + ipToHostname map[netip.Addr]string, + macToHostname map[string]string, + ipToMACTables ...map[netip.Addr]net.HardwareAddr, +) []netip.Addr { + hostname = dns.FqdnToDomain(hostname) + if hostname == "" { + return nil + } + resultSet := make(map[netip.Addr]struct{}) + var result []netip.Addr + addAddress := func(address netip.Addr) { + if isScopedIPv6Address(address) { + return + } + if _, exists := resultSet[address]; exists { + return + } + resultSet[address] = struct{}{} + result = append(result, address) + } + for address, entryHostname := range ipToHostname { + if strings.EqualFold(entryHostname, hostname) { + addAddress(address) + } + } + for mac, entryHostname := range macToHostname { + if !strings.EqualFold(entryHostname, hostname) { + continue + } + for _, table := range ipToMACTables { + for address, entryMAC := range table { + if entryMAC.String() == mac { + addAddress(address) + } + } + } + } + return result +} + +func isScopedIPv6Address(address netip.Addr) bool { + // DNS AAAA records cannot carry an interface zone. + return address.Is6() && (address.IsLinkLocalUnicast() || address.Zone() != "") +} diff --git a/route/neighbor_resolver_linux.go b/route/neighbor_resolver_linux.go index b7991b4c8..5c6cdcb72 100644 --- a/route/neighbor_resolver_linux.go +++ b/route/neighbor_resolver_linux.go @@ -114,6 +114,12 @@ func (r *neighborResolver) LookupMAC(address netip.Addr) (net.HardwareAddr, bool return nil, false } +func (r *neighborResolver) LookupAddresses(hostname string) []netip.Addr { + r.access.RLock() + defer r.access.RUnlock() + return lookupAddressesByHostname(hostname, r.ipToHostname, r.macToHostname, r.neighborIPToMAC, r.leaseIPToMAC) +} + func (r *neighborResolver) LookupHostname(address netip.Addr) (string, bool) { r.access.RLock() defer r.access.RUnlock() diff --git a/route/neighbor_resolver_platform.go b/route/neighbor_resolver_platform.go index ddb9a9959..a9930c0e4 100644 --- a/route/neighbor_resolver_platform.go +++ b/route/neighbor_resolver_platform.go @@ -46,6 +46,12 @@ func (r *platformNeighborResolver) LookupMAC(address netip.Addr) (net.HardwareAd return extractMACFromEUI64(address) } +func (r *platformNeighborResolver) LookupAddresses(hostname string) []netip.Addr { + r.access.RLock() + defer r.access.RUnlock() + return lookupAddressesByHostname(hostname, r.ipToHostname, r.macToHostname, r.ipToMAC) +} + func (r *platformNeighborResolver) LookupHostname(address netip.Addr) (string, bool) { r.access.RLock() defer r.access.RUnlock() diff --git a/route/router.go b/route/router.go index 72f549c38..2d50c22e4 100644 --- a/route/router.go +++ b/route/router.go @@ -63,7 +63,7 @@ func NewRouter(ctx context.Context, logFactory log.Factory, options option.Route rules: make([]adapter.Rule, 0, len(options.Rules)), ruleSetMap: make(map[string]adapter.RuleSet), needFindProcess: hasRule(options.Rules, isProcessRule) || hasDNSRule(dnsOptions.Rules, isProcessDNSRule) || options.FindProcess, - needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || options.FindNeighbor, + needFindNeighbor: hasRule(options.Rules, isNeighborRule) || hasDNSRule(dnsOptions.Rules, isNeighborDNSRule) || hasLocalNeighborDNSServer(dnsOptions.Servers) || options.FindNeighbor, leaseFiles: options.DHCPLeaseFiles, pauseManager: service.FromContext[pause.Manager](ctx), platformInterface: service.FromContext[adapter.PlatformInterface](ctx), @@ -99,6 +99,36 @@ func (r *Router) Initialize(rules []option.Rule, ruleSets []option.RuleSet) erro func (r *Router) Start(stage adapter.StartStage) error { monitor := taskmonitor.New(r.logger, C.StartTimeout) switch stage { + case adapter.StartStateInitialize: + if r.needFindNeighbor { + if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { + monitor.Start("initialize neighbor resolver") + resolver := newPlatformNeighborResolver(r.logger, r.platformInterface) + err := resolver.Start() + monitor.Finish() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } else { + monitor.Start("initialize neighbor resolver") + resolver, err := newNeighborResolver(r.logger, r.leaseFiles) + monitor.Finish() + if err != nil { + if err != os.ErrInvalid { + r.logger.Error(E.Cause(err, "create neighbor resolver")) + } + } else { + err = resolver.Start() + if err != nil { + r.logger.Error(E.Cause(err, "start neighbor resolver")) + } else { + r.neighborResolver = resolver + } + } + } + } case adapter.StartStateStart: var startContext *adapter.HTTPStartContext if len(r.ruleSets) > 0 { @@ -128,7 +158,6 @@ func (r *Router) Start(stage adapter.StartStage) error { } r.network.Initialize(r.ruleSets) needFindProcess := r.needFindProcess - needFindNeighbor := r.needFindNeighbor for _, ruleSet := range r.ruleSets { metadata := ruleSet.Metadata() if metadata.ContainsProcessRule { @@ -163,36 +192,6 @@ func (r *Router) Start(stage adapter.StartStage) error { processCache.SetLifetime(200 * time.Millisecond) r.processCache = processCache } - r.needFindNeighbor = needFindNeighbor - if needFindNeighbor { - if r.platformInterface != nil && r.platformInterface.UsePlatformNeighborResolver() { - monitor.Start("initialize neighbor resolver") - resolver := newPlatformNeighborResolver(r.logger, r.platformInterface) - err := resolver.Start() - monitor.Finish() - if err != nil { - r.logger.Error(E.Cause(err, "start neighbor resolver")) - } else { - r.neighborResolver = resolver - } - } else { - monitor.Start("initialize neighbor resolver") - resolver, err := newNeighborResolver(r.logger, r.leaseFiles) - monitor.Finish() - if err != nil { - if err != os.ErrInvalid { - r.logger.Error(E.Cause(err, "create neighbor resolver")) - } - } else { - err = resolver.Start() - if err != nil { - r.logger.Error(E.Cause(err, "start neighbor resolver")) - } else { - r.neighborResolver = resolver - } - } - } - } case adapter.StartStatePostStart: for i, rule := range r.rules { monitor.Start("initialize rule[", i, "]") diff --git a/route/rule_conds.go b/route/rule_conds.go index 2c6290294..716a19b09 100644 --- a/route/rule_conds.go +++ b/route/rule_conds.go @@ -60,3 +60,19 @@ func isWIFIRule(rule option.DefaultRule) bool { func isWIFIDNSRule(rule option.DefaultDNSRule) bool { return len(rule.WIFISSID) > 0 || len(rule.WIFIBSSID) > 0 } + +func hasLocalNeighborDNSServer(servers []option.DNSServerOptions) bool { + for _, server := range servers { + if server.Type != C.DNSTypeLocal { + continue + } + localOptions, isLocal := server.Options.(*option.LocalDNSServerOptions) + if !isLocal { + continue + } + if len(localOptions.NeighborDomain) > 0 { + return true + } + } + return false +}