diff --git a/constant/dns.go b/constant/dns.go index c7cd0d037..d39ed82c8 100644 --- a/constant/dns.go +++ b/constant/dns.go @@ -26,6 +26,7 @@ const ( DNSTypeHosts = "hosts" DNSTypeFakeIP = "fakeip" DNSTypeDHCP = "dhcp" + DNSTypeMDNS = "mdns" DNSTypeTailscale = "tailscale" ) diff --git a/dns/transport/local/local.go b/dns/transport/local/local.go index d13de3fa4..34897e128 100644 --- a/dns/transport/local/local.go +++ b/dns/transport/local/local.go @@ -1,5 +1,3 @@ -//go:build !darwin - package local import ( @@ -9,10 +7,13 @@ import ( C "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" "github.com/sagernet/sing-box/dns/transport/hosts" + "github.com/sagernet/sing-box/dns/transport/mdns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" + "github.com/sagernet/sing/common" E "github.com/sagernet/sing/common/exceptions" "github.com/sagernet/sing/common/logger" + M "github.com/sagernet/sing/common/metadata" N "github.com/sagernet/sing/common/network" "github.com/sagernet/sing/service" @@ -35,11 +36,20 @@ type Transport struct { hosts *hosts.File dialer N.Dialer preferGo bool + fallback bool resolved ResolvedResolver + mdnsTransport adapter.DNSTransport + dhcpTransport dhcpTransport neighborResolver adapter.NeighborResolver neighborSuffixes []string } +type dhcpTransport interface { + adapter.DNSTransport + Fetch() []M.Socksaddr + Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) +} + func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { transportDialer, err := dns.NewLocalDialer(ctx, options) if err != nil { @@ -68,55 +78,77 @@ func (t *Transport) Start(stage adapter.StartStage) error { } else { t.hosts = defaultHosts } - if !t.preferGo { - if isSystemdResolvedManaged() { - resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger) + if !t.preferGo && isSystemdResolvedManaged() { + resolvedResolver, err := NewResolvedResolver(t.ctx, t.logger) + if err == nil { + err = resolvedResolver.Start() if err == nil { - err = resolvedResolver.Start() - if err == nil { - t.resolved = resolvedResolver - } else { - t.logger.Warn(E.Cause(err, "initialize resolved resolver")) - } + t.resolved = resolvedResolver + } else { + t.logger.Warn(E.Cause(err, "initialize resolved resolver")) } } } case adapter.StartStateStart: + if C.IsDarwin { + 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) + } + } else { + t.mdnsTransport = mdns.NewRawTransport(t.TransportAdapter, t.ctx, t.logger) + } router := service.FromContext[adapter.Router](t.ctx) if router != nil { t.neighborResolver = router.NeighborResolver() } + fallthrough + default: + if t.dhcpTransport != nil { + err := t.dhcpTransport.Start(stage) + if err != nil { + return err + } + } + if t.mdnsTransport != nil { + err := t.mdnsTransport.Start(stage) + if err != nil { + return err + } + } } return nil } func (t *Transport) Close() error { - if t.resolved != nil { - return t.resolved.Close() - } - return nil + return common.Close(t.resolved, t.dhcpTransport, t.mdnsTransport) } func (t *Transport) Reset() { + if t.dhcpTransport != nil { + t.dhcpTransport.Reset() + } + if t.mdnsTransport != nil { + t.mdnsTransport.Reset() + } } func (t *Transport) PreferredDomain(domain string) bool { - if t.hosts != nil && t.resolved == nil { + if t.hosts != nil { if len(t.hosts.Lookup(dns.FqdnToDomain(domain))) > 0 { return true } } - return t.hasNeighborHost(domain) + return t.hasNeighborHost(domain) || mdns.IsLocalDomain(domain) } 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] if t.hosts != nil && (question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) @@ -128,5 +160,23 @@ func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, if response != nil { return response, nil } + if mdns.IsLocalDomain(question.Name) { + if C.IsDarwin { + return t.systemExchange(ctx, message) + } + return t.mdnsTransport.Exchange(ctx, message) + } + if t.resolved != nil { + return t.resolved.Exchange(ctx, message) + } + if t.dhcpTransport != nil { + servers := t.dhcpTransport.Fetch() + if len(servers) > 0 { + return t.dhcpTransport.Exchange0(ctx, message, servers) + } + } + if t.fallback { + return t.systemExchange(ctx, message) + } return t.exchange(ctx, message, question.Name) } diff --git a/dns/transport/local/local_darwin.go b/dns/transport/local/local_darwin.go deleted file mode 100644 index d38a0bf7b..000000000 --- a/dns/transport/local/local_darwin.go +++ /dev/null @@ -1,120 +0,0 @@ -//go:build darwin - -package local - -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/dns/transport/hosts" - "github.com/sagernet/sing-box/log" - "github.com/sagernet/sing-box/option" - "github.com/sagernet/sing/common" - "github.com/sagernet/sing/common/logger" - M "github.com/sagernet/sing/common/metadata" - N "github.com/sagernet/sing/common/network" - "github.com/sagernet/sing/service" - - mDNS "github.com/miekg/dns" -) - -func RegisterTransport(registry *dns.TransportRegistry) { - dns.RegisterTransport[option.LocalDNSServerOptions](registry, C.DNSTypeLocal, NewTransport) -} - -var ( - _ adapter.DNSTransport = (*Transport)(nil) - _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) -) - -type Transport struct { - dns.TransportAdapter - ctx context.Context - logger logger.ContextLogger - hosts *hosts.File - dialer N.Dialer - fallback bool - dhcpTransport dhcpTransport - neighborResolver adapter.NeighborResolver - neighborSuffixes []string -} - -type dhcpTransport interface { - adapter.DNSTransport - Fetch() []M.Socksaddr - Exchange0(ctx context.Context, message *mDNS.Msg, servers []M.Socksaddr) (*mDNS.Msg, error) -} - -func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.LocalDNSServerOptions) (adapter.DNSTransport, error) { - transportDialer, err := dns.NewLocalDialer(ctx, options) - 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 { - switch stage { - case adapter.StartStateStart: - 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 - } - } - 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 -} - -func (t *Transport) Close() error { - return common.Close( - t.dhcpTransport, - ) -} - -func (t *Transport) Reset() { - if t.dhcpTransport != nil { - 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_darwin_cgo.go b/dns/transport/local/local_darwin_cgo.go index bfea8dd64..de2ec56a4 100644 --- a/dns/transport/local/local_darwin_cgo.go +++ b/dns/transport/local/local_darwin_cgo.go @@ -31,7 +31,6 @@ import ( "errors" "unsafe" - boxC "github.com/sagernet/sing-box/constant" "github.com/sagernet/sing-box/dns" E "github.com/sagernet/sing/common/exceptions" @@ -78,24 +77,8 @@ func darwinResolverHErrno(name string, hErrno int) error { } } -func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { +func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { question := message.Question[0] - if t.hosts != nil && (question.Qtype == mDNS.TypeA || question.Qtype == mDNS.TypeAAAA) { - addresses := t.hosts.Lookup(dns.FqdnToDomain(question.Name)) - if len(addresses) > 0 { - return dns.FixedResponse(message.Id, question, addresses, boxC.DefaultDNSTTL), 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) - } - } type resolvResult struct { response *mDNS.Msg err error diff --git a/dns/transport/local/local_darwin_dhcp.go b/dns/transport/local/local_dhcp.go similarity index 93% rename from dns/transport/local/local_darwin_dhcp.go rename to dns/transport/local/local_dhcp.go index b228b76a4..bf77ed252 100644 --- a/dns/transport/local/local_darwin_dhcp.go +++ b/dns/transport/local/local_dhcp.go @@ -1,4 +1,4 @@ -//go:build darwin && with_dhcp +//go:build with_dhcp package local diff --git a/dns/transport/local/local_darwin_nodhcp.go b/dns/transport/local/local_nodhcp.go similarity index 90% rename from dns/transport/local/local_darwin_nodhcp.go rename to dns/transport/local/local_nodhcp.go index 5ce84690a..7893d416f 100644 --- a/dns/transport/local/local_darwin_nodhcp.go +++ b/dns/transport/local/local_nodhcp.go @@ -1,4 +1,4 @@ -//go:build darwin && !with_dhcp +//go:build !with_dhcp package local diff --git a/dns/transport/local/local_other.go b/dns/transport/local/local_other.go new file mode 100644 index 000000000..9bb3d7777 --- /dev/null +++ b/dns/transport/local/local_other.go @@ -0,0 +1,14 @@ +//go:build !darwin + +package local + +import ( + "context" + "os" + + mDNS "github.com/miekg/dns" +) + +func (t *Transport) systemExchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + return nil, os.ErrInvalid +} diff --git a/dns/transport/local/local_shared.go b/dns/transport/local/local_shared.go index 64a23a9fc..776354584 100644 --- a/dns/transport/local/local_shared.go +++ b/dns/transport/local/local_shared.go @@ -1,5 +1,3 @@ -//go:build !darwin - package local import ( diff --git a/dns/transport/mdns/mdns.go b/dns/transport/mdns/mdns.go new file mode 100644 index 000000000..2db3390d5 --- /dev/null +++ b/dns/transport/mdns/mdns.go @@ -0,0 +1,456 @@ +package mdns + +import ( + "context" + "net" + "net/netip" + "slices" + "strings" + "time" + + "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" + "github.com/sagernet/sing/common/buf" + "github.com/sagernet/sing/common/control" + E "github.com/sagernet/sing/common/exceptions" + "github.com/sagernet/sing/common/json/badoption" + "github.com/sagernet/sing/common/logger" + "github.com/sagernet/sing/common/task" + "github.com/sagernet/sing/service" + + mDNS "github.com/miekg/dns" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +const ( + mdnsPort = 5353 + mdnsClassTopBit = 1 << 15 + mdnsTimeout = time.Second +) + +var ( + mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251) + mdnsGroupIPv6 = net.ParseIP("ff02::fb") + mdnsLocalZones = []string{ + "local.", + "254.169.in-addr.arpa.", + "8.e.f.ip6.arpa.", + "9.e.f.ip6.arpa.", + "a.e.f.ip6.arpa.", + "b.e.f.ip6.arpa.", + } +) + +func IsLocalDomain(name string) bool { + canonical := mDNS.CanonicalName(name) + return common.Any(mdnsLocalZones, func(zone string) bool { + return canonical == zone || strings.HasSuffix(canonical, "."+zone) + }) +} + +func RegisterTransport(registry *dns.TransportRegistry) { + dns.RegisterTransport[option.MDNSDNSServerOptions](registry, C.DNSTypeMDNS, NewTransport) +} + +var ( + _ adapter.DNSTransport = (*Transport)(nil) + _ adapter.DNSTransportWithPreferredDomain = (*Transport)(nil) +) + +type Transport struct { + dns.TransportAdapter + ctx context.Context + logger logger.ContextLogger + networkManager adapter.NetworkManager + interfaceNames badoption.Listable[string] +} + +func NewTransport(ctx context.Context, logger log.ContextLogger, tag string, options option.MDNSDNSServerOptions) (adapter.DNSTransport, error) { + return &Transport{ + TransportAdapter: dns.NewTransportAdapterWithLocalOptions(C.DNSTypeMDNS, tag, options.LocalDNSServerOptions), + ctx: ctx, + logger: logger, + networkManager: service.FromContext[adapter.NetworkManager](ctx), + interfaceNames: options.Interface, + }, nil +} + +func NewRawTransport(transportAdapter dns.TransportAdapter, ctx context.Context, logger log.ContextLogger) *Transport { + return &Transport{ + TransportAdapter: transportAdapter, + ctx: ctx, + logger: logger, + networkManager: service.FromContext[adapter.NetworkManager](ctx), + } +} + +func (t *Transport) Start(stage adapter.StartStage) error { + return nil +} + +func (t *Transport) Close() error { + return nil +} + +func (t *Transport) Reset() { +} + +func (t *Transport) PreferredDomain(domain string) bool { + return IsLocalDomain(domain) +} + +func (t *Transport) Exchange(ctx context.Context, message *mDNS.Msg) (*mDNS.Msg, error) { + targets, err := t.queryTargets() + if err != nil { + return nil, E.Cause(err, "mdns: prepare interfaces") + } + request := makeQueryMessage(message) + rawMessage, err := request.Pack() + if err != nil { + return nil, E.Cause(err, "mdns: pack request") + } + deadline, loaded := ctx.Deadline() + if !loaded || deadline.IsZero() { + deadline = time.Now().Add(mdnsTimeout) + } + exchangeCtx, cancel := context.WithDeadline(ctx, deadline) + defer cancel() + results := make(chan exchangeResult, len(targets)) + var group task.Group + for _, target := range targets { + group.Append0(func(ctx context.Context) error { + response, err := t.exchangeTarget(ctx, target, rawMessage, message.Question[0], deadline) + if err != nil || response != nil { + results <- exchangeResult{ + response: response, + err: err, + } + } + return nil + }) + } + groupErr := group.Run(exchangeCtx) + close(results) + response := newResponse(message) + seenRecords := make(map[string]bool) + var lastErr error + for result := range results { + if result.err != nil { + lastErr = result.err + t.logger.TraceContext(ctx, result.err) + continue + } + mergeResponse(response, result.response, seenRecords) + } + if len(response.Answer) > 0 || len(response.Ns) > 0 || len(response.Extra) > 0 { + return response, nil + } + if lastErr != nil { + return nil, lastErr + } + if groupErr != nil && ctx.Err() != nil { + return nil, groupErr + } + return nil, E.New("mdns: query timeout") +} + +type exchangeResult struct { + response *mDNS.Msg + err error +} + +type queryTarget struct { + iface control.Interface + family string +} + +func (t *Transport) exchangeTarget(ctx context.Context, target queryTarget, rawMessage []byte, question mDNS.Question, deadline time.Time) (*mDNS.Msg, error) { + packetConn, destination, err := t.listenPacket(ctx, target) + if err != nil { + return nil, err + } + defer packetConn.Close() + + _, err = packetConn.WriteTo(rawMessage, destination) + if err != nil { + return nil, E.Cause(err, "mdns: write request on ", target.iface.Name, " ", target.family) + } + err = packetConn.SetReadDeadline(deadline) + if err != nil { + return nil, E.Cause(err, "mdns: set deadline on ", target.iface.Name, " ", target.family) + } + response := newResponseFromQuestion(question) + seenRecords := make(map[string]bool) + buffer := buf.Get(buf.UDPBufferSize) + defer buf.Put(buffer) + for { + n, source, readErr := packetConn.ReadFrom(buffer) + if readErr != nil { + if E.IsTimeout(readErr) { + if len(response.Answer) > 0 || len(response.Ns) > 0 || len(response.Extra) > 0 { + return response, nil + } + return nil, nil + } + return nil, E.Cause(readErr, "mdns: read response on ", target.iface.Name, " ", target.family) + } + if !validSource(source, target) { + continue + } + var candidate mDNS.Msg + err = candidate.Unpack(buffer[:n]) + if err != nil { + t.logger.TraceContext(ctx, "mdns: unpack response: ", err) + continue + } + if !validResponse(&candidate, question) { + continue + } + normalizeResponse(&candidate, question) + mergeResponse(response, &candidate, seenRecords) + } +} + +func (t *Transport) listenPacket(ctx context.Context, target queryTarget) (net.PacketConn, net.Addr, error) { + var listenConfig net.ListenConfig + listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(t.networkManager.InterfaceFinder(), target.iface.Name, target.iface.Index)) + netInterface := target.iface.NetInterface() + switch target.family { + case "udp4": + packetConn, err := listenConfig.ListenPacket(ctx, "udp4", "0.0.0.0:0") + if err != nil { + return nil, nil, E.Cause(err, "mdns: listen on ", target.iface.Name, " udp4") + } + ipv4Conn := ipv4.NewPacketConn(packetConn) + err = ipv4Conn.SetMulticastInterface(&netInterface) + if err != nil { + packetConn.Close() + return nil, nil, E.Cause(err, "mdns: set multicast interface on ", target.iface.Name, " udp4") + } + _ = ipv4Conn.SetMulticastTTL(255) + return packetConn, &net.UDPAddr{IP: mdnsGroupIPv4, Port: mdnsPort}, nil + case "udp6": + packetConn, err := listenConfig.ListenPacket(ctx, "udp6", "[::]:0") + if err != nil { + return nil, nil, E.Cause(err, "mdns: listen on ", target.iface.Name, " udp6") + } + ipv6Conn := ipv6.NewPacketConn(packetConn) + err = ipv6Conn.SetMulticastInterface(&netInterface) + if err != nil { + packetConn.Close() + return nil, nil, E.Cause(err, "mdns: set multicast interface on ", target.iface.Name, " udp6") + } + _ = ipv6Conn.SetMulticastHopLimit(255) + return packetConn, &net.UDPAddr{IP: mdnsGroupIPv6, Port: mdnsPort, Zone: target.iface.Name}, nil + default: + return nil, nil, E.New("mdns: unknown network: ", target.family) + } +} + +func (t *Transport) queryTargets() ([]queryTarget, error) { + interfaces, err := t.fetchInterfaces() + if err != nil { + return nil, err + } + var targets []queryTarget + for _, iface := range interfaces { + supports4, supports6 := interfaceFamilies(iface) + if supports4 { + targets = append(targets, queryTarget{ + iface: iface, + family: "udp4", + }) + } + if supports6 { + targets = append(targets, queryTarget{ + iface: iface, + family: "udp6", + }) + } + } + if len(targets) == 0 { + return nil, E.New("missing usable mDNS interfaces") + } + return targets, nil +} + +func (t *Transport) fetchInterfaces() ([]control.Interface, error) { + finder := t.networkManager.InterfaceFinder() + var interfaces []control.Interface + if len(t.interfaceNames) > 0 { + for _, interfaceName := range t.interfaceNames { + iface, err := finder.ByName(interfaceName) + if err != nil { + t.logger.Warn("mdns: interface ", interfaceName, " not found") + continue + } + if !isUsableInterface(*iface) { + t.logger.Warn("mdns: interface ", interfaceName, " is not usable") + continue + } + interfaces = append(interfaces, *iface) + } + } else { + interfaces = common.Filter(finder.Interfaces(), isUsableInterface) + } + if len(interfaces) == 0 { + return nil, E.New("mdns: missing usable interface") + } + return interfaces, nil +} + +func isUsableInterface(iface control.Interface) bool { + return iface.Flags&net.FlagUp != 0 && + iface.Flags&net.FlagMulticast != 0 && + iface.Flags&net.FlagLoopback == 0 +} + +func interfaceFamilies(iface control.Interface) (supports4, supports6 bool) { + for _, prefix := range iface.Addresses { + addr := prefix.Addr() + if addr.IsLoopback() { + continue + } + if addr.Is4() { + supports4 = true + } else if addr.Is6() && !addr.Is4In6() { + supports6 = true + } + if supports4 && supports6 { + return + } + } + return +} + +func makeQueryMessage(message *mDNS.Msg) *mDNS.Msg { + request := &mDNS.Msg{ + Question: slices.Clone(message.Question), + } + for i := range request.Question { + stripQuestionClass(&request.Question[i]) + } + return request +} + +func newResponse(message *mDNS.Msg) *mDNS.Msg { + response := newResponseFromQuestion(message.Question[0]) + response.Id = message.Id + return response +} + +func newResponseFromQuestion(question mDNS.Question) *mDNS.Msg { + stripQuestionClass(&question) + return &mDNS.Msg{ + MsgHdr: mDNS.MsgHdr{ + Response: true, + Authoritative: true, + Rcode: mDNS.RcodeSuccess, + }, + Question: []mDNS.Question{question}, + } +} + +func validSource(source net.Addr, target queryTarget) bool { + sourceUDP, isUDP := source.(*net.UDPAddr) + if !isUDP || sourceUDP.Port != mdnsPort { + return false + } + sourceAddr, loaded := netip.AddrFromSlice(sourceUDP.IP) + if !loaded { + return false + } + sourceAddr = sourceAddr.Unmap() + if (target.family == "udp4" && !sourceAddr.Is4()) || (target.family == "udp6" && !sourceAddr.Is6()) { + return false + } + for _, prefix := range target.iface.Addresses { + if prefix.Contains(sourceAddr) { + return true + } + } + return false +} + +func validResponse(response *mDNS.Msg, question mDNS.Question) bool { + if !response.Response || + response.Opcode != mDNS.OpcodeQuery || + response.Rcode != mDNS.RcodeSuccess { + return false + } + for _, responseQuestion := range response.Question { + if questionMatches(responseQuestion, question) { + return true + } + } + return responseHasMatchingRecord(response, question) +} + +func responseHasMatchingRecord(response *mDNS.Msg, question mDNS.Question) bool { + for _, recordList := range [][]mDNS.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + if recordMatchesQuestion(record, question) { + return true + } + } + } + return false +} + +func questionMatches(left mDNS.Question, right mDNS.Question) bool { + stripQuestionClass(&left) + stripQuestionClass(&right) + return left.Qtype == right.Qtype && + left.Qclass == right.Qclass && + strings.EqualFold(left.Name, right.Name) +} + +func recordMatchesQuestion(record mDNS.RR, question mDNS.Question) bool { + header := record.Header() + return strings.EqualFold(header.Name, question.Name) && + (question.Qtype == mDNS.TypeANY || + header.Rrtype == question.Qtype || + header.Rrtype == mDNS.TypeCNAME) +} + +func normalizeResponse(response *mDNS.Msg, question mDNS.Question) { + response.Id = 0 + response.Question = []mDNS.Question{question} + for i := range response.Question { + stripQuestionClass(&response.Question[i]) + } + for _, recordList := range [][]mDNS.RR{response.Answer, response.Ns, response.Extra} { + for _, record := range recordList { + stripRecordClass(record) + } + } +} + +func mergeResponse(destination *mDNS.Msg, source *mDNS.Msg, seenRecords map[string]bool) { + appendRecords := func(destinationRecords *[]mDNS.RR, sourceRecords []mDNS.RR) { + for _, record := range sourceRecords { + key := record.String() + if seenRecords[key] { + continue + } + seenRecords[key] = true + *destinationRecords = append(*destinationRecords, record) + } + } + appendRecords(&destination.Answer, source.Answer) + appendRecords(&destination.Ns, source.Ns) + appendRecords(&destination.Extra, source.Extra) +} + +func stripQuestionClass(question *mDNS.Question) { + question.Qclass &^= mdnsClassTopBit +} + +func stripRecordClass(record mDNS.RR) { + record.Header().Class &^= mdnsClassTopBit +} diff --git a/docs/configuration/dns/rule.md b/docs/configuration/dns/rule.md index b8783d60e..897bb09b8 100644 --- a/docs/configuration/dns/rule.md +++ b/docs/configuration/dns/rule.md @@ -507,11 +507,12 @@ Match source device hostname from DHCP leases. 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 | +| Type | Match | +|-------------|------------------------------------------------------------------------------| +| `hosts` | Match predefined entries and entries in hosts files | +| `local` | Match hosts entries, neighbor-resolved hosts, and mDNS local domains | +| `mdns` | Match mDNS local domains (`*.local.` and IPv4/IPv6 link-local reverse zones) | +| `tailscale` | Match MagicDNS hosts and DNS route suffixes | #### wifi_ssid diff --git a/docs/configuration/dns/rule.zh.md b/docs/configuration/dns/rule.zh.md index 2c2de7d03..55e229805 100644 --- a/docs/configuration/dns/rule.zh.md +++ b/docs/configuration/dns/rule.zh.md @@ -499,11 +499,12 @@ Available values: `wifi`, `cellular`, `ethernet` and `other`. 匹配指定 DNS 服务器的首选域名。 -| 类型 | 匹配 | -|-------------|--------------------------| -| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | -| `local` | 匹配 hosts 中的条目和邻居解析得到的主机名 | -| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | +| 类型 | 匹配 | +|-------------|-------------------------------------------------------------| +| `hosts` | 匹配预定义条目和 hosts 文件中的条目 | +| `local` | 匹配 hosts 中的条目、邻居解析得到的主机名以及 mDNS 本地域名 | +| `mdns` | 匹配 mDNS 本地域名(`*.local.` 以及 IPv4/IPv6 链路本地反向区域) | +| `tailscale` | 匹配 MagicDNS 主机和 DNS 路由后缀 | #### wifi_ssid diff --git a/docs/configuration/dns/server/index.md b/docs/configuration/dns/server/index.md index b610cf5b0..bcb058617 100644 --- a/docs/configuration/dns/server/index.md +++ b/docs/configuration/dns/server/index.md @@ -2,6 +2,10 @@ icon: material/alert-decagram --- +!!! quote "Changes in sing-box 1.14.0" + + :material-plus: [mdns](./mdns/) + !!! quote "Changes in sing-box 1.12.0" :material-plus: [type](#type) @@ -39,6 +43,7 @@ The type of the DNS server. | `https` | [HTTPS](./https/) | | `h3` | [HTTP/3](./http3/) | | `dhcp` | [DHCP](./dhcp/) | +| `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | diff --git a/docs/configuration/dns/server/index.zh.md b/docs/configuration/dns/server/index.zh.md index d1a4dc3c4..54dd97e7f 100644 --- a/docs/configuration/dns/server/index.zh.md +++ b/docs/configuration/dns/server/index.zh.md @@ -2,6 +2,10 @@ icon: material/alert-decagram --- +!!! quote "sing-box 1.14.0 中的更改" + + :material-plus: [mdns](./mdns/) + !!! quote "sing-box 1.12.0 中的更改" :material-plus: [type](#type) @@ -39,6 +43,7 @@ DNS 服务器的类型。 | `https` | [HTTPS](./https/) | | `h3` | [HTTP/3](./http3/) | | `dhcp` | [DHCP](./dhcp/) | +| `mdns` | [mDNS](./mdns/) | | `fakeip` | [Fake IP](./fakeip/) | | `tailscale` | [Tailscale](./tailscale/) | | `resolved` | [Resolved](./resolved/) | diff --git a/docs/configuration/dns/server/mdns.md b/docs/configuration/dns/server/mdns.md new file mode 100644 index 000000000..d42e07152 --- /dev/null +++ b/docs/configuration/dns/server/mdns.md @@ -0,0 +1,42 @@ +--- +icon: material/new-box +--- + +!!! question "Since sing-box 1.14.0" + +# mDNS + +### Structure + +```json +{ + "dns": { + "servers": [ + { + "type": "mdns", + "tag": "", + + "interface": [], + + // Dial Fields + } + ] + } +} +``` + +!!! info "" + + You usually do not need an explicit `mdns` server in addition to a [Local](./local/) server: the local server already routes queries for `*.local.` and IPv4/IPv6 link-local reverse zones via mDNS on non-Apple platforms and via the system resolver on Apple platforms. Add an explicit `mdns` server only when you want to reference it from [`preferred_by`](../rule/#preferred_by) or use it standalone. + +### Fields + +#### interface + +List of network interface names to send mDNS queries on. + +When empty, all interfaces that are up, multicast-capable, and non-loopback are used. + +### Dial Fields + +See [Dial Fields](/configuration/shared/dial/) for details. diff --git a/docs/configuration/dns/server/mdns.zh.md b/docs/configuration/dns/server/mdns.zh.md new file mode 100644 index 000000000..3af3763e5 --- /dev/null +++ b/docs/configuration/dns/server/mdns.zh.md @@ -0,0 +1,42 @@ +--- +icon: material/new-box +--- + +!!! question "自 sing-box 1.14.0 起" + +# mDNS + +### 结构 + +```json +{ + "dns": { + "servers": [ + { + "type": "mdns", + "tag": "", + + "interface": [], + + // 拨号字段 + } + ] + } +} +``` + +!!! info "" + + 通常不需要在 [Local](./local/) 服务器之外再添加显式的 `mdns` 服务器:本地服务器已经会在非 Apple 平台通过 mDNS、在 Apple 平台通过系统解析器来回答 `*.local.` 与 IPv4/IPv6 链路本地反向区域的查询。仅当需要从 [`preferred_by`](../rule/#preferred_by) 引用,或独立使用时,才需要显式添加 `mdns` 服务器。 + +### 字段 + +#### interface + +用于发送 mDNS 查询的网络接口名称列表。 + +留空时,将使用所有处于 up 状态、支持多播且非环回的接口。 + +### 拨号字段 + +参阅 [拨号字段](/zh/configuration/shared/dial/) 了解详情。 diff --git a/include/registry.go b/include/registry.go index 5a1a2f973..91d66bbd5 100644 --- a/include/registry.go +++ b/include/registry.go @@ -16,6 +16,7 @@ import ( "github.com/sagernet/sing-box/dns/transport/fakeip" "github.com/sagernet/sing-box/dns/transport/hosts" "github.com/sagernet/sing-box/dns/transport/local" + "github.com/sagernet/sing-box/dns/transport/mdns" "github.com/sagernet/sing-box/log" "github.com/sagernet/sing-box/option" "github.com/sagernet/sing-box/protocol/anytls" @@ -118,6 +119,7 @@ func DNSTransportRegistry() *dns.TransportRegistry { transport.RegisterHTTPS(registry) hosts.RegisterTransport(registry) local.RegisterTransport(registry) + mdns.RegisterTransport(registry) fakeip.RegisterTransport(registry) resolved.RegisterTransport(registry) diff --git a/mkdocs.yml b/mkdocs.yml index 8a583f15b..4280979e0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -93,6 +93,7 @@ nav: - HTTPS: configuration/dns/server/https.md - HTTP3: configuration/dns/server/http3.md - DHCP: configuration/dns/server/dhcp.md + - mDNS: configuration/dns/server/mdns.md - FakeIP: configuration/dns/server/fakeip.md - Tailscale: configuration/dns/server/tailscale.md - Resolved: configuration/dns/server/resolved.md diff --git a/option/dns.go b/option/dns.go index 53a078bb3..6d3970d88 100644 --- a/option/dns.go +++ b/option/dns.go @@ -184,3 +184,8 @@ type DHCPDNSServerOptions struct { LocalDNSServerOptions Interface string `json:"interface,omitempty"` } + +type MDNSDNSServerOptions struct { + LocalDNSServerOptions + Interface badoption.Listable[string] `json:"interface,omitempty"` +}