dns: Add neighbor-based hostname resolution to local server

This commit is contained in:
世界
2026-04-29 22:26:10 +08:00
parent 2a533b0daa
commit e171852b19
16 changed files with 290 additions and 71 deletions
+1
View File
@@ -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
}
+27 -7
View File
@@ -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)
}
+38 -27
View File
@@ -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
}
+5 -1
View File
@@ -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)
+57
View File
@@ -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 ""
}
+19 -1
View File
@@ -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.
+16
View File
@@ -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/) 了解详情。
+3 -1
View File
@@ -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
+1 -1
View File
@@ -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
+2 -1
View File
@@ -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 {
+6
View File
@@ -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()
+56
View File
@@ -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() != "")
}
+6
View File
@@ -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()
+6
View File
@@ -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()
+31 -32
View File
@@ -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, "]")
+16
View File
@@ -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
}