From 8bd14cce3bc546fd018580088b8798a4ddd57b6e Mon Sep 17 00:00:00 2001 From: Lukasz Raczylo Date: Fri, 9 Oct 2026 15:21:35 +0100 Subject: [PATCH] fix(mdns): answer hostname A queries after TTL (#96) * fix(mdns): answer hostname A queries after TTL Zeroconf answers only service queries. The A record for .local expired from the OS cache after 120 seconds and lookups then failed. Vendor zeroconf v1.0.0 with one change: it now answers A and AAAA queries for the registered hostname. Fixes #95. * fix(deps): update golang.org/x/net to v0.60.0 govulncheck reports several x/net advisories fixed in v0.60.0. The Security Scan job fails on them. --- go.mod | 4 +- go.sum | 7 +- third_party/zeroconf/.golangci.yml | 11 + third_party/zeroconf/KPORTAL_PATCH.md | 2 + third_party/zeroconf/LICENSE | 27 + third_party/zeroconf/client.go | 456 ++++++++++++++++ third_party/zeroconf/connection.go | 115 ++++ third_party/zeroconf/doc.go | 14 + third_party/zeroconf/go.mod | 10 + third_party/zeroconf/go.sum | 24 + third_party/zeroconf/hostname_test.go | 42 ++ third_party/zeroconf/server.go | 750 ++++++++++++++++++++++++++ third_party/zeroconf/service.go | 109 ++++ third_party/zeroconf/utils.go | 8 + 14 files changed, 1573 insertions(+), 6 deletions(-) create mode 100644 third_party/zeroconf/.golangci.yml create mode 100644 third_party/zeroconf/KPORTAL_PATCH.md create mode 100644 third_party/zeroconf/LICENSE create mode 100644 third_party/zeroconf/client.go create mode 100644 third_party/zeroconf/connection.go create mode 100644 third_party/zeroconf/doc.go create mode 100644 third_party/zeroconf/go.mod create mode 100644 third_party/zeroconf/go.sum create mode 100644 third_party/zeroconf/hostname_test.go create mode 100644 third_party/zeroconf/server.go create mode 100644 third_party/zeroconf/service.go create mode 100644 third_party/zeroconf/utils.go diff --git a/go.mod b/go.mod index 5ac9c23..f13e229 100644 --- a/go.mod +++ b/go.mod @@ -62,7 +62,7 @@ require ( github.com/xo/terminfo v1.2.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/net v0.59.0 // indirect + golang.org/x/net v0.60.0 // indirect golang.org/x/oauth2 v0.37.0 // indirect golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect @@ -80,3 +80,5 @@ require ( sigs.k8s.io/structured-merge-diff/v6 v6.4.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) + +replace github.com/grandcat/zeroconf => ./third_party/zeroconf diff --git a/go.sum b/go.sum index 8dea477..9324e7e 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,6 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grandcat/zeroconf v1.0.0 h1:uHhahLBKqwWBV6WZUDAT71044vwOTL+McW0mBJvo6kE= -github.com/grandcat/zeroconf v1.0.0/go.mod h1:lTKmG1zh86XyCoUeIHSA4FJMBwCJiQmGfcP2PdzytEs= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -110,7 +108,6 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= @@ -142,8 +139,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= -golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= +golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98= golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/third_party/zeroconf/.golangci.yml b/third_party/zeroconf/.golangci.yml new file mode 100644 index 0000000..2aae7e1 --- /dev/null +++ b/third_party/zeroconf/.golangci.yml @@ -0,0 +1,11 @@ +version: "2" + +# Vendored upstream code; only the documented patch is ours. +linters: + default: none + enable: + - govet + settings: + govet: + disable: + - composites diff --git a/third_party/zeroconf/KPORTAL_PATCH.md b/third_party/zeroconf/KPORTAL_PATCH.md new file mode 100644 index 0000000..5f65b4e --- /dev/null +++ b/third_party/zeroconf/KPORTAL_PATCH.md @@ -0,0 +1,2 @@ +Vendored github.com/grandcat/zeroconf v1.0.0 (MIT, see LICENSE). +Patch: server.go handleQuestion answers A/AAAA queries for the registered hostname. diff --git a/third_party/zeroconf/LICENSE b/third_party/zeroconf/LICENSE new file mode 100644 index 0000000..9cd4e23 --- /dev/null +++ b/third_party/zeroconf/LICENSE @@ -0,0 +1,27 @@ +The MIT License (MIT) + +Copyright (c) 2016 Stefan Smarzly +Copyright (c) 2014 Oleksandr Lobunets + +Note: Copyright for portions of project zeroconf.sd are held by Oleksandr + Lobunets, 2014, as part of project bonjour. All other copyright for + project zeroconf.sd are held by Stefan Smarzly, 2016. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/third_party/zeroconf/client.go b/third_party/zeroconf/client.go new file mode 100644 index 0000000..8f09fc7 --- /dev/null +++ b/third_party/zeroconf/client.go @@ -0,0 +1,456 @@ +package zeroconf + +import ( + "context" + "fmt" + "net" + "strings" + + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" + + "time" + + "github.com/cenkalti/backoff" + "github.com/miekg/dns" +) + +// IPType specifies the IP traffic the client listens for. +// This does not guarantee that only mDNS entries of this sepcific +// type passes. E.g. typical mDNS packets distributed via IPv4, often contain +// both DNS A and AAAA entries. +type IPType uint8 + +// Options for IPType. +const ( + IPv4 = 0x01 + IPv6 = 0x02 + IPv4AndIPv6 = (IPv4 | IPv6) //< Default option. +) + +type clientOpts struct { + listenOn IPType + ifaces []net.Interface +} + +// ClientOption fills the option struct to configure intefaces, etc. +type ClientOption func(*clientOpts) + +// SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this +// instance listens for. +// This does not guarantee that only mDNS entries of this sepcific +// type passes. E.g. typical mDNS packets distributed via IPv4, may contain +// both DNS A and AAAA entries. +func SelectIPTraffic(t IPType) ClientOption { + return func(o *clientOpts) { + o.listenOn = t + } +} + +// SelectIfaces selects the interfaces to query for mDNS records +func SelectIfaces(ifaces []net.Interface) ClientOption { + return func(o *clientOpts) { + o.ifaces = ifaces + } +} + +// Resolver acts as entry point for service lookups and to browse the DNS-SD. +type Resolver struct { + c *client +} + +// NewResolver creates a new resolver and joins the UDP multicast groups to +// listen for mDNS messages. +func NewResolver(options ...ClientOption) (*Resolver, error) { + // Apply default configuration and load supplied options. + var conf = clientOpts{ + listenOn: IPv4AndIPv6, + } + for _, o := range options { + if o != nil { + o(&conf) + } + } + + c, err := newClient(conf) + if err != nil { + return nil, err + } + return &Resolver{ + c: c, + }, nil +} + +// Browse for all services of a given type in a given domain. +func (r *Resolver) Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry) error { + params := defaultParams(service) + if domain != "" { + params.Domain = domain + } + params.Entries = entries + ctx, cancel := context.WithCancel(ctx) + go r.c.mainloop(ctx, params) + + err := r.c.query(params) + if err != nil { + cancel() + return err + } + // If previous probe was ok, it should be fine now. In case of an error later on, + // the entries' queue is closed. + go func() { + if err := r.c.periodicQuery(ctx, params); err != nil { + cancel() + } + }() + + return nil +} + +// Lookup a specific service by its name and type in a given domain. +func (r *Resolver) Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry) error { + params := defaultParams(service) + params.Instance = instance + if domain != "" { + params.Domain = domain + } + params.Entries = entries + ctx, cancel := context.WithCancel(ctx) + go r.c.mainloop(ctx, params) + err := r.c.query(params) + if err != nil { + // cancel mainloop + cancel() + return err + } + // If previous probe was ok, it should be fine now. In case of an error later on, + // the entries' queue is closed. + go func() { + if err := r.c.periodicQuery(ctx, params); err != nil { + cancel() + } + }() + + return nil +} + +// defaultParams returns a default set of QueryParams. +func defaultParams(service string) *LookupParams { + return NewLookupParams("", service, "local", make(chan *ServiceEntry)) +} + +// Client structure encapsulates both IPv4/IPv6 UDP connections. +type client struct { + ipv4conn *ipv4.PacketConn + ipv6conn *ipv6.PacketConn + ifaces []net.Interface +} + +// Client structure constructor +func newClient(opts clientOpts) (*client, error) { + ifaces := opts.ifaces + if len(ifaces) == 0 { + ifaces = listMulticastInterfaces() + } + // IPv4 interfaces + var ipv4conn *ipv4.PacketConn + if (opts.listenOn & IPv4) > 0 { + var err error + ipv4conn, err = joinUdp4Multicast(ifaces) + if err != nil { + return nil, err + } + } + // IPv6 interfaces + var ipv6conn *ipv6.PacketConn + if (opts.listenOn & IPv6) > 0 { + var err error + ipv6conn, err = joinUdp6Multicast(ifaces) + if err != nil { + return nil, err + } + } + + return &client{ + ipv4conn: ipv4conn, + ipv6conn: ipv6conn, + ifaces: ifaces, + }, nil +} + +// Start listeners and waits for the shutdown signal from exit channel +func (c *client) mainloop(ctx context.Context, params *LookupParams) { + // start listening for responses + msgCh := make(chan *dns.Msg, 32) + if c.ipv4conn != nil { + go c.recv(ctx, c.ipv4conn, msgCh) + } + if c.ipv6conn != nil { + go c.recv(ctx, c.ipv6conn, msgCh) + } + + // Iterate through channels from listeners goroutines + var entries, sentEntries map[string]*ServiceEntry + sentEntries = make(map[string]*ServiceEntry) + for { + select { + case <-ctx.Done(): + // Context expired. Notify subscriber that we are done here. + params.done() + c.shutdown() + return + case msg := <-msgCh: + entries = make(map[string]*ServiceEntry) + sections := append(msg.Answer, msg.Ns...) + sections = append(sections, msg.Extra...) + + for _, answer := range sections { + switch rr := answer.(type) { + case *dns.PTR: + if params.ServiceName() != rr.Hdr.Name { + continue + } + if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr { + continue + } + if _, ok := entries[rr.Ptr]; !ok { + entries[rr.Ptr] = NewServiceEntry( + trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)), + params.Service, + params.Domain) + } + entries[rr.Ptr].TTL = rr.Hdr.Ttl + case *dns.SRV: + if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { + continue + } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { + continue + } + if _, ok := entries[rr.Hdr.Name]; !ok { + entries[rr.Hdr.Name] = NewServiceEntry( + trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), + params.Service, + params.Domain) + } + entries[rr.Hdr.Name].HostName = rr.Target + entries[rr.Hdr.Name].Port = int(rr.Port) + entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl + case *dns.TXT: + if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { + continue + } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { + continue + } + if _, ok := entries[rr.Hdr.Name]; !ok { + entries[rr.Hdr.Name] = NewServiceEntry( + trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), + params.Service, + params.Domain) + } + entries[rr.Hdr.Name].Text = rr.Txt + entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl + } + } + // Associate IPs in a second round as other fields should be filled by now. + for _, answer := range sections { + switch rr := answer.(type) { + case *dns.A: + for k, e := range entries { + if e.HostName == rr.Hdr.Name { + entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A) + } + } + case *dns.AAAA: + for k, e := range entries { + if e.HostName == rr.Hdr.Name { + entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA) + } + } + } + } + } + + if len(entries) > 0 { + for k, e := range entries { + if e.TTL == 0 { + delete(entries, k) + delete(sentEntries, k) + continue + } + if _, ok := sentEntries[k]; ok { + continue + } + + // If this is an DNS-SD query do not throw PTR away. + // It is expected to have only PTR for enumeration + if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() { + // Require at least one resolved IP address for ServiceEntry + // TODO: wait some more time as chances are high both will arrive. + if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 { + continue + } + } + // Submit entry to subscriber and cache it. + // This is also a point to possibly stop probing actively for a + // service entry. + params.Entries <- e + sentEntries[k] = e + params.disableProbing() + } + // reset entries + entries = make(map[string]*ServiceEntry) + } + } +} + +// Shutdown client will close currently open connections and channel implicitly. +func (c *client) shutdown() { + if c.ipv4conn != nil { + c.ipv4conn.Close() + } + if c.ipv6conn != nil { + c.ipv6conn.Close() + } +} + +// Data receiving routine reads from connection, unpacks packets into dns.Msg +// structures and sends them to a given msgCh channel +func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) { + var readFrom func([]byte) (n int, src net.Addr, err error) + + switch pConn := l.(type) { + case *ipv6.PacketConn: + readFrom = func(b []byte) (n int, src net.Addr, err error) { + n, _, src, err = pConn.ReadFrom(b) + return + } + case *ipv4.PacketConn: + readFrom = func(b []byte) (n int, src net.Addr, err error) { + n, _, src, err = pConn.ReadFrom(b) + return + } + + default: + return + } + + buf := make([]byte, 65536) + var fatalErr error + for { + // Handles the following cases: + // - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel + // - ReadFrom aborts otherwise. + // TODO: the context check can be removed. Verify! + if ctx.Err() != nil || fatalErr != nil { + return + } + + n, _, err := readFrom(buf) + if err != nil { + fatalErr = err + continue + } + msg := new(dns.Msg) + if err := msg.Unpack(buf[:n]); err != nil { + // log.Printf("[WARN] mdns: Failed to unpack packet: %v", err) + continue + } + select { + case msgCh <- msg: + // Submit decoded DNS message and continue. + case <-ctx.Done(): + // Abort. + return + } + } +} + +// periodicQuery sens multiple probes until a valid response is received by +// the main processing loop or some timeout/cancel fires. +// TODO: move error reporting to shutdown function as periodicQuery is called from +// go routine context. +func (c *client) periodicQuery(ctx context.Context, params *LookupParams) error { + if params.stopProbing == nil { + return nil + } + + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 4 * time.Second + bo.MaxInterval = 60 * time.Second + bo.Reset() + + for { + // Do periodic query. + if err := c.query(params); err != nil { + return err + } + // Backoff and cancel logic. + wait := bo.NextBackOff() + if wait == backoff.Stop { + return fmt.Errorf("periodicQuery: abort due to timeout") + } + select { + case <-time.After(wait): + // Wait for next iteration. + case <-params.stopProbing: + // Chan is closed (or happened in the past). + // Done here. Received a matching mDNS entry. + return nil + case <-ctx.Done(): + return ctx.Err() + + } + } + +} + +// Performs the actual query by service name (browse) or service instance name (lookup), +// start response listeners goroutines and loops over the entries channel. +func (c *client) query(params *LookupParams) error { + var serviceName, serviceInstanceName string + serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain)) + if params.Instance != "" { + serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName) + } + + // send the query + m := new(dns.Msg) + if serviceInstanceName != "" { + m.Question = []dns.Question{ + dns.Question{serviceInstanceName, dns.TypeSRV, dns.ClassINET}, + dns.Question{serviceInstanceName, dns.TypeTXT, dns.ClassINET}, + } + m.RecursionDesired = false + } else { + m.SetQuestion(serviceName, dns.TypePTR) + m.RecursionDesired = false + } + if err := c.sendQuery(m); err != nil { + return err + } + + return nil +} + +// Pack the dns.Msg and write to available connections (multicast) +func (c *client) sendQuery(msg *dns.Msg) error { + buf, err := msg.Pack() + if err != nil { + return err + } + if c.ipv4conn != nil { + var wcm ipv4.ControlMessage + for ifi := range c.ifaces { + wcm.IfIndex = c.ifaces[ifi].Index + c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } + } + if c.ipv6conn != nil { + var wcm ipv6.ControlMessage + for ifi := range c.ifaces { + wcm.IfIndex = c.ifaces[ifi].Index + c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) + } + } + return nil +} diff --git a/third_party/zeroconf/connection.go b/third_party/zeroconf/connection.go new file mode 100644 index 0000000..598dbb9 --- /dev/null +++ b/third_party/zeroconf/connection.go @@ -0,0 +1,115 @@ +package zeroconf + +import ( + "fmt" + "net" + + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +var ( + // Multicast groups used by mDNS + mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251) + mdnsGroupIPv6 = net.ParseIP("ff02::fb") + + // mDNS wildcard addresses + mdnsWildcardAddrIPv4 = &net.UDPAddr{ + IP: net.ParseIP("224.0.0.0"), + Port: 5353, + } + mdnsWildcardAddrIPv6 = &net.UDPAddr{ + IP: net.ParseIP("ff02::"), + // IP: net.ParseIP("fd00::12d3:26e7:48db:e7d"), + Port: 5353, + } + + // mDNS endpoint addresses + ipv4Addr = &net.UDPAddr{ + IP: mdnsGroupIPv4, + Port: 5353, + } + ipv6Addr = &net.UDPAddr{ + IP: mdnsGroupIPv6, + Port: 5353, + } +) + +func joinUdp6Multicast(interfaces []net.Interface) (*ipv6.PacketConn, error) { + udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6) + if err != nil { + return nil, err + } + + // Join multicast groups to receive announcements + pkConn := ipv6.NewPacketConn(udpConn) + pkConn.SetControlMessage(ipv6.FlagInterface, true) + + if len(interfaces) == 0 { + interfaces = listMulticastInterfaces() + } + // log.Println("Using multicast interfaces: ", interfaces) + + var failedJoins int + for _, iface := range interfaces { + if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil { + // log.Println("Udp6 JoinGroup failed for iface ", iface) + failedJoins++ + } + } + if failedJoins == len(interfaces) { + pkConn.Close() + return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces) + } + + return pkConn, nil +} + +func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) { + udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4) + if err != nil { + // log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err) + return nil, err + } + + // Join multicast groups to receive announcements + pkConn := ipv4.NewPacketConn(udpConn) + pkConn.SetControlMessage(ipv4.FlagInterface, true) + + if len(interfaces) == 0 { + interfaces = listMulticastInterfaces() + } + // log.Println("Using multicast interfaces: ", interfaces) + + var failedJoins int + for _, iface := range interfaces { + if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil { + // log.Println("Udp4 JoinGroup failed for iface ", iface) + failedJoins++ + } + } + if failedJoins == len(interfaces) { + pkConn.Close() + return nil, fmt.Errorf("udp4: failed to join any of these interfaces: %v", interfaces) + } + + return pkConn, nil +} + +func listMulticastInterfaces() []net.Interface { + var interfaces []net.Interface + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + for _, ifi := range ifaces { + if (ifi.Flags & net.FlagUp) == 0 { + continue + } + if (ifi.Flags & net.FlagMulticast) > 0 { + interfaces = append(interfaces, ifi) + } + } + + return interfaces +} diff --git a/third_party/zeroconf/doc.go b/third_party/zeroconf/doc.go new file mode 100644 index 0000000..b3e5d47 --- /dev/null +++ b/third_party/zeroconf/doc.go @@ -0,0 +1,14 @@ +// Package zeroconf is a pure Golang library that employs Multicast DNS-SD for +// browsing and resolving services in your network and registering own services +// in the local network. +// +// It basically implements aspects of the standards +// RFC 6762 (mDNS) and +// RFC 6763 (DNS-SD). +// Though it does not support all requirements yet, the aim is to provide a +// complient solution in the long-term with the community. +// +// By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and +// Apple's Bonjour (untested). Should work in the most office, home and private +// environments. +package zeroconf diff --git a/third_party/zeroconf/go.mod b/third_party/zeroconf/go.mod new file mode 100644 index 0000000..a6681ff --- /dev/null +++ b/third_party/zeroconf/go.mod @@ -0,0 +1,10 @@ +module github.com/grandcat/zeroconf + +go 1.13 + +require ( + github.com/cenkalti/backoff v2.2.1+incompatible + github.com/miekg/dns v1.1.27 + github.com/pkg/errors v0.9.1 + golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa +) diff --git a/third_party/zeroconf/go.sum b/third_party/zeroconf/go.sum new file mode 100644 index 0000000..25b9647 --- /dev/null +++ b/third_party/zeroconf/go.sum @@ -0,0 +1,24 @@ +github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= +github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= +github.com/miekg/dns v1.1.27 h1:aEH/kqUzUxGJ/UHcEKdJY+ugH6WEzsEBBSPa8zuy1aM= +github.com/miekg/dns v1.1.27/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190923162816-aa69164e4478/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa h1:F+8P+gmewFQYRk6JoLQLwjBCTu3mcIURZfNkVweuRKA= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58 h1:8gQV6CLnAEikrhgkHFbMAEhagSSnXWGV915qUMm9mrU= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe h1:6fAMxZRR6sl1Uq8U61gxU+kPTs2tR8uOySCbBP7BN/M= +golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20191216052735-49a3e744a425/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/third_party/zeroconf/hostname_test.go b/third_party/zeroconf/hostname_test.go new file mode 100644 index 0000000..a83e8bb --- /dev/null +++ b/third_party/zeroconf/hostname_test.go @@ -0,0 +1,42 @@ +package zeroconf + +import ( + "net" + "testing" + + "github.com/miekg/dns" +) + +func TestHandleQuestionAnswersHostnameA(t *testing.T) { + svc := NewServiceEntry("alias", "_kportal._tcp", "local.") + svc.HostName = "alias.local." + svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)} + s := &Server{service: svc, ttl: 3200} + + q := dns.Question{Name: "alias.local.", Qtype: dns.TypeA, Qclass: dns.ClassINET} + resp := new(dns.Msg) + if err := s.handleQuestion(q, resp, new(dns.Msg), 0); err != nil { + t.Fatal(err) + } + if len(resp.Answer) != 1 { + t.Fatalf("want 1 answer, got %d", len(resp.Answer)) + } + a, ok := resp.Answer[0].(*dns.A) + if !ok || !a.A.Equal(net.IPv4(127, 0, 0, 1)) || a.Hdr.Name != "alias.local." { + t.Fatalf("unexpected answer: %v", resp.Answer[0]) + } +} + +func TestHandleQuestionIgnoresOtherTypesForHostname(t *testing.T) { + svc := NewServiceEntry("alias", "_kportal._tcp", "local.") + svc.HostName = "alias.local." + svc.AddrIPv4 = []net.IP{net.IPv4(127, 0, 0, 1)} + s := &Server{service: svc, ttl: 3200} + + q := dns.Question{Name: "alias.local.", Qtype: dns.TypeTXT, Qclass: dns.ClassINET} + resp := new(dns.Msg) + _ = s.handleQuestion(q, resp, new(dns.Msg), 0) + if len(resp.Answer) != 0 { + t.Fatalf("want no answer, got %d", len(resp.Answer)) + } +} diff --git a/third_party/zeroconf/server.go b/third_party/zeroconf/server.go new file mode 100644 index 0000000..3f469fa --- /dev/null +++ b/third_party/zeroconf/server.go @@ -0,0 +1,750 @@ +package zeroconf + +import ( + "fmt" + "log" + "math/rand" + "net" + "os" + "strings" + "sync" + "time" + + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" + + "errors" + + "github.com/miekg/dns" +) + +const ( + // Number of Multicast responses sent for a query message (default: 1 < x < 9) + multicastRepetitions = 2 +) + +// Register a service by given arguments. This call will take the system's hostname +// and lookup IP by that hostname. +func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface) (*Server, error) { + entry := NewServiceEntry(instance, service, domain) + entry.Port = port + entry.Text = text + + if entry.Instance == "" { + return nil, fmt.Errorf("Missing service instance name") + } + if entry.Service == "" { + return nil, fmt.Errorf("Missing service name") + } + if entry.Domain == "" { + entry.Domain = "local." + } + if entry.Port == 0 { + return nil, fmt.Errorf("Missing port") + } + + var err error + if entry.HostName == "" { + entry.HostName, err = os.Hostname() + if err != nil { + return nil, fmt.Errorf("Could not determine host") + } + } + + if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { + entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) + } + + if len(ifaces) == 0 { + ifaces = listMulticastInterfaces() + } + + for _, iface := range ifaces { + v4, v6 := addrsForInterface(&iface) + entry.AddrIPv4 = append(entry.AddrIPv4, v4...) + entry.AddrIPv6 = append(entry.AddrIPv6, v6...) + } + + if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil { + return nil, fmt.Errorf("Could not determine host IP addresses") + } + + s, err := newServer(ifaces) + if err != nil { + return nil, err + } + + s.service = entry + go s.mainloop() + go s.probe() + + return s, nil +} + +// RegisterProxy registers a service proxy. This call will skip the hostname/IP lookup and +// will use the provided values. +func RegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface) (*Server, error) { + entry := NewServiceEntry(instance, service, domain) + entry.Port = port + entry.Text = text + entry.HostName = host + + if entry.Instance == "" { + return nil, fmt.Errorf("Missing service instance name") + } + if entry.Service == "" { + return nil, fmt.Errorf("Missing service name") + } + if entry.HostName == "" { + return nil, fmt.Errorf("Missing host name") + } + if entry.Domain == "" { + entry.Domain = "local" + } + if entry.Port == 0 { + return nil, fmt.Errorf("Missing port") + } + + if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { + entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) + } + + for _, ip := range ips { + ipAddr := net.ParseIP(ip) + if ipAddr == nil { + return nil, fmt.Errorf("Failed to parse given IP: %v", ip) + } else if ipv4 := ipAddr.To4(); ipv4 != nil { + entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr) + } else if ipv6 := ipAddr.To16(); ipv6 != nil { + entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr) + } else { + return nil, fmt.Errorf("The IP is neither IPv4 nor IPv6: %#v", ipAddr) + } + } + + if len(ifaces) == 0 { + ifaces = listMulticastInterfaces() + } + + s, err := newServer(ifaces) + if err != nil { + return nil, err + } + + s.service = entry + go s.mainloop() + go s.probe() + + return s, nil +} + +const ( + qClassCacheFlush uint16 = 1 << 15 +) + +// Server structure encapsulates both IPv4/IPv6 UDP connections +type Server struct { + service *ServiceEntry + ipv4conn *ipv4.PacketConn + ipv6conn *ipv6.PacketConn + ifaces []net.Interface + + shouldShutdown chan struct{} + shutdownLock sync.Mutex + shutdownEnd sync.WaitGroup + isShutdown bool + ttl uint32 +} + +// Constructs server structure +func newServer(ifaces []net.Interface) (*Server, error) { + ipv4conn, err4 := joinUdp4Multicast(ifaces) + if err4 != nil { + log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error()) + } + ipv6conn, err6 := joinUdp6Multicast(ifaces) + if err6 != nil { + log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error()) + } + if err4 != nil && err6 != nil { + // No supported interface left. + return nil, fmt.Errorf("No supported interface") + } + + s := &Server{ + ipv4conn: ipv4conn, + ipv6conn: ipv6conn, + ifaces: ifaces, + ttl: 3200, + shouldShutdown: make(chan struct{}), + } + + return s, nil +} + +// Start listeners and waits for the shutdown signal from exit channel +func (s *Server) mainloop() { + if s.ipv4conn != nil { + go s.recv4(s.ipv4conn) + } + if s.ipv6conn != nil { + go s.recv6(s.ipv6conn) + } +} + +// Shutdown closes all udp connections and unregisters the service +func (s *Server) Shutdown() { + s.shutdown() +} + +// SetText updates and announces the TXT records +func (s *Server) SetText(text []string) { + s.service.Text = text + s.announceText() +} + +// TTL sets the TTL for DNS replies +func (s *Server) TTL(ttl uint32) { + s.ttl = ttl +} + +// Shutdown server will close currently open connections & channel +func (s *Server) shutdown() error { + s.shutdownLock.Lock() + defer s.shutdownLock.Unlock() + if s.isShutdown { + return errors.New("Server is already shutdown") + } + + err := s.unregister() + if err != nil { + return err + } + + close(s.shouldShutdown) + + if s.ipv4conn != nil { + s.ipv4conn.Close() + } + if s.ipv6conn != nil { + s.ipv6conn.Close() + } + + // Wait for connection and routines to be closed + s.shutdownEnd.Wait() + s.isShutdown = true + + return nil +} + +// recv is a long running routine to receive packets from an interface +func (s *Server) recv4(c *ipv4.PacketConn) { + if c == nil { + return + } + buf := make([]byte, 65536) + s.shutdownEnd.Add(1) + defer s.shutdownEnd.Done() + for { + select { + case <-s.shouldShutdown: + return + default: + var ifIndex int + n, cm, from, err := c.ReadFrom(buf) + if err != nil { + continue + } + if cm != nil { + ifIndex = cm.IfIndex + } + if err := s.parsePacket(buf[:n], ifIndex, from); err != nil { + // log.Printf("[ERR] zeroconf: failed to handle query v4: %v", err) + } + } + } +} + +// recv is a long running routine to receive packets from an interface +func (s *Server) recv6(c *ipv6.PacketConn) { + if c == nil { + return + } + buf := make([]byte, 65536) + s.shutdownEnd.Add(1) + defer s.shutdownEnd.Done() + for { + select { + case <-s.shouldShutdown: + return + default: + var ifIndex int + n, cm, from, err := c.ReadFrom(buf) + if err != nil { + continue + } + if cm != nil { + ifIndex = cm.IfIndex + } + if err := s.parsePacket(buf[:n], ifIndex, from); err != nil { + // log.Printf("[ERR] zeroconf: failed to handle query v6: %v", err) + } + } + } +} + +// parsePacket is used to parse an incoming packet +func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error { + var msg dns.Msg + if err := msg.Unpack(packet); err != nil { + // log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err) + return err + } + return s.handleQuery(&msg, ifIndex, from) +} + +// handleQuery is used to handle an incoming query +func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error { + // Ignore questions with authoritative section for now + if len(query.Ns) > 0 { + return nil + } + + // Handle each question + var err error + for _, q := range query.Question { + resp := dns.Msg{} + resp.SetReply(query) + resp.Compress = true + resp.RecursionDesired = false + resp.Authoritative = true + resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions" + resp.Answer = []dns.RR{} + resp.Extra = []dns.RR{} + if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil { + // log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err) + continue + } + // Check if there is an answer + if len(resp.Answer) == 0 { + continue + } + + if isUnicastQuestion(q) { + // Send unicast + if e := s.unicastResponse(&resp, ifIndex, from); e != nil { + err = e + } + } else { + // Send mulicast + if e := s.multicastResponse(&resp, ifIndex); e != nil { + err = e + } + } + } + + return err +} + +// RFC6762 7.1. Known-Answer Suppression +func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool { + if len(resp.Answer) == 0 || len(query.Answer) == 0 { + return false + } + + if resp.Answer[0].Header().Rrtype != dns.TypePTR { + return false + } + answer := resp.Answer[0].(*dns.PTR) + + for _, known := range query.Answer { + hdr := known.Header() + if hdr.Rrtype != answer.Hdr.Rrtype { + continue + } + ptr := known.(*dns.PTR) + if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 { + // log.Printf("skipping known answer: %v", ptr) + return true + } + } + + return false +} + +// handleQuestion is used to handle an incoming question +func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error { + if s.service == nil { + return nil + } + + switch q.Name { + case s.service.ServiceTypeName(): + s.serviceTypeName(resp, s.ttl) + if isKnownAnswer(resp, query) { + resp.Answer = nil + } + + case s.service.ServiceName(): + s.composeBrowsingAnswers(resp, ifIndex) + if isKnownAnswer(resp, query) { + resp.Answer = nil + } + + case s.service.ServiceInstanceName(): + s.composeLookupAnswers(resp, s.ttl, ifIndex, false) + + // kportal patch: answer direct A/AAAA lookups for the registered hostname + // so the address record can be refreshed after its TTL expires. + case s.service.HostName: + if q.Qtype == dns.TypeA || q.Qtype == dns.TypeAAAA || q.Qtype == dns.TypeANY { + resp.Answer = s.appendAddrs(resp.Answer, s.ttl, ifIndex, false) + } + } + + return nil +} + +func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) { + ptr := &dns.PTR{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceName(), + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: s.ttl, + }, + Ptr: s.service.ServiceInstanceName(), + } + resp.Answer = append(resp.Answer, ptr) + + txt := &dns.TXT{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: s.ttl, + }, + Txt: s.service.Text, + } + srv := &dns.SRV{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: s.ttl, + }, + Priority: 0, + Weight: 0, + Port: uint16(s.service.Port), + Target: s.service.HostName, + } + resp.Extra = append(resp.Extra, srv, txt) + + resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false) +} + +func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) { + // From RFC6762 + // The most significant bit of the rrclass for a record in the Answer + // Section of a response message is the Multicast DNS cache-flush bit + // and is discussed in more detail below in Section 10.2, "Announcements + // to Flush Outdated Cache Entries". + ptr := &dns.PTR{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceName(), + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ptr: s.service.ServiceInstanceName(), + } + srv := &dns.SRV{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeSRV, + Class: dns.ClassINET | qClassCacheFlush, + Ttl: ttl, + }, + Priority: 0, + Weight: 0, + Port: uint16(s.service.Port), + Target: s.service.HostName, + } + txt := &dns.TXT{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeTXT, + Class: dns.ClassINET | qClassCacheFlush, + Ttl: ttl, + }, + Txt: s.service.Text, + } + dnssd := &dns.PTR{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceTypeName(), + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ptr: s.service.ServiceName(), + } + resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd) + + resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache) +} + +func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) { + // From RFC6762 + // 9. Service Type Enumeration + // + // For this purpose, a special meta-query is defined. A DNS query for + // PTR records with the name "_services._dns-sd._udp." yields a + // set of PTR records, where the rdata of each PTR record is the two- + // label name, plus the same domain, e.g., + // "_http._tcp.". + dnssd := &dns.PTR{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceTypeName(), + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ptr: s.service.ServiceName(), + } + resp.Answer = append(resp.Answer, dnssd) +} + +// Perform probing & announcement +//TODO: implement a proper probing & conflict resolution +func (s *Server) probe() { + q := new(dns.Msg) + q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR) + q.RecursionDesired = false + + srv := &dns.SRV{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeSRV, + Class: dns.ClassINET, + Ttl: s.ttl, + }, + Priority: 0, + Weight: 0, + Port: uint16(s.service.Port), + Target: s.service.HostName, + } + txt := &dns.TXT{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeTXT, + Class: dns.ClassINET, + Ttl: s.ttl, + }, + Txt: s.service.Text, + } + q.Ns = []dns.RR{srv, txt} + + randomizer := rand.New(rand.NewSource(time.Now().UnixNano())) + + for i := 0; i < multicastRepetitions; i++ { + if err := s.multicastResponse(q, 0); err != nil { + log.Println("[ERR] zeroconf: failed to send probe:", err.Error()) + } + time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond) + } + + // From RFC6762 + // The Multicast DNS responder MUST send at least two unsolicited + // responses, one second apart. To provide increased robustness against + // packet loss, a responder MAY send up to eight unsolicited responses, + // provided that the interval between unsolicited responses increases by + // at least a factor of two with every response sent. + timeout := 1 * time.Second + for i := 0; i < multicastRepetitions; i++ { + for _, intf := range s.ifaces { + resp := new(dns.Msg) + resp.MsgHdr.Response = true + // TODO: make response authoritative if we are the publisher + resp.Compress = true + resp.Answer = []dns.RR{} + resp.Extra = []dns.RR{} + s.composeLookupAnswers(resp, s.ttl, intf.Index, true) + if err := s.multicastResponse(resp, intf.Index); err != nil { + log.Println("[ERR] zeroconf: failed to send announcement:", err.Error()) + } + } + time.Sleep(timeout) + timeout *= 2 + } +} + +// announceText sends a Text announcement with cache flush enabled +func (s *Server) announceText() { + resp := new(dns.Msg) + resp.MsgHdr.Response = true + + txt := &dns.TXT{ + Hdr: dns.RR_Header{ + Name: s.service.ServiceInstanceName(), + Rrtype: dns.TypeTXT, + Class: dns.ClassINET | qClassCacheFlush, + Ttl: s.ttl, + }, + Txt: s.service.Text, + } + + resp.Answer = []dns.RR{txt} + s.multicastResponse(resp, 0) +} + +func (s *Server) unregister() error { + resp := new(dns.Msg) + resp.MsgHdr.Response = true + resp.Answer = []dns.RR{} + resp.Extra = []dns.RR{} + s.composeLookupAnswers(resp, 0, 0, true) + return s.multicastResponse(resp, 0) +} + +func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR { + v4 := s.service.AddrIPv4 + v6 := s.service.AddrIPv6 + if len(v4) == 0 && len(v6) == 0 { + iface, _ := net.InterfaceByIndex(ifIndex) + if iface != nil { + a4, a6 := addrsForInterface(iface) + v4 = append(v4, a4...) + v6 = append(v6, a6...) + } + } + if ttl > 0 { + // RFC6762 Section 10 says A/AAAA records SHOULD + // use TTL of 120s, to account for network interface + // and IP address changes. + ttl = 120 + } + var cacheFlushBit uint16 + if flushCache { + cacheFlushBit = qClassCacheFlush + } + for _, ipv4 := range v4 { + a := &dns.A{ + Hdr: dns.RR_Header{ + Name: s.service.HostName, + Rrtype: dns.TypeA, + Class: dns.ClassINET | cacheFlushBit, + Ttl: ttl, + }, + A: ipv4, + } + list = append(list, a) + } + for _, ipv6 := range v6 { + aaaa := &dns.AAAA{ + Hdr: dns.RR_Header{ + Name: s.service.HostName, + Rrtype: dns.TypeAAAA, + Class: dns.ClassINET | cacheFlushBit, + Ttl: ttl, + }, + AAAA: ipv6, + } + list = append(list, aaaa) + } + return list +} + +func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) { + var v4, v6, v6local []net.IP + addrs, _ := iface.Addrs() + for _, address := range addrs { + if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { + if ipnet.IP.To4() != nil { + v4 = append(v4, ipnet.IP) + } else { + switch ip := ipnet.IP.To16(); ip != nil { + case ip.IsGlobalUnicast(): + v6 = append(v6, ipnet.IP) + case ip.IsLinkLocalUnicast(): + v6local = append(v6local, ipnet.IP) + } + } + } + } + if len(v6) == 0 { + v6 = v6local + } + return v4, v6 +} + +// unicastResponse is used to send a unicast response packet +func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error { + buf, err := resp.Pack() + if err != nil { + return err + } + addr := from.(*net.UDPAddr) + if addr.IP.To4() != nil { + if ifIndex != 0 { + var wcm ipv4.ControlMessage + wcm.IfIndex = ifIndex + _, err = s.ipv4conn.WriteTo(buf, &wcm, addr) + } else { + _, err = s.ipv4conn.WriteTo(buf, nil, addr) + } + return err + } else { + if ifIndex != 0 { + var wcm ipv6.ControlMessage + wcm.IfIndex = ifIndex + _, err = s.ipv6conn.WriteTo(buf, &wcm, addr) + } else { + _, err = s.ipv6conn.WriteTo(buf, nil, addr) + } + return err + } +} + +// multicastResponse us used to send a multicast response packet +func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error { + buf, err := msg.Pack() + if err != nil { + return err + } + if s.ipv4conn != nil { + var wcm ipv4.ControlMessage + if ifIndex != 0 { + wcm.IfIndex = ifIndex + s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } else { + for _, intf := range s.ifaces { + wcm.IfIndex = intf.Index + s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } + } + } + + if s.ipv6conn != nil { + var wcm ipv6.ControlMessage + if ifIndex != 0 { + wcm.IfIndex = ifIndex + s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) + } else { + for _, intf := range s.ifaces { + wcm.IfIndex = intf.Index + s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) + } + } + } + return nil +} + +func isUnicastQuestion(q dns.Question) bool { + // From RFC6762 + // 18.12. Repurposing of Top Bit of qclass in Question Section + // + // In the Question Section of a Multicast DNS query, the top bit of the + // qclass field is used to indicate that unicast responses are preferred + // for this particular question. (See Section 5.4.) + return q.Qclass&qClassCacheFlush != 0 +} diff --git a/third_party/zeroconf/service.go b/third_party/zeroconf/service.go new file mode 100644 index 0000000..b6451f1 --- /dev/null +++ b/third_party/zeroconf/service.go @@ -0,0 +1,109 @@ +package zeroconf + +import ( + "fmt" + "net" + "sync" +) + +// ServiceRecord contains the basic description of a service, which contains instance name, service type & domain +type ServiceRecord struct { + Instance string `json:"name"` // Instance name (e.g. "My web page") + Service string `json:"type"` // Service name (e.g. _http._tcp.) + Domain string `json:"domain"` // If blank, assumes "local" + + // private variable populated on ServiceRecord creation + serviceName string + serviceInstanceName string + serviceTypeName string +} + +// ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed +// of a service name (also referred as service type) and a domain. +func (s *ServiceRecord) ServiceName() string { + return s.serviceName +} + +// ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.), +// which is composed from service instance name, service name and a domain. +func (s *ServiceRecord) ServiceInstanceName() string { + return s.serviceInstanceName +} + +// ServiceTypeName returns the complete identifier for a DNS-SD query. +func (s *ServiceRecord) ServiceTypeName() string { + return s.serviceTypeName +} + +// NewServiceRecord constructs a ServiceRecord. +func NewServiceRecord(instance, service, domain string) *ServiceRecord { + s := &ServiceRecord{ + Instance: instance, + Service: service, + Domain: domain, + serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)), + } + + // Cache service instance name + if instance != "" { + s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName()) + } + + // Cache service type name domain + typeNameDomain := "local" + if len(s.Domain) > 0 { + typeNameDomain = trimDot(s.Domain) + } + s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain) + + return s +} + +// LookupParams contains configurable properties to create a service discovery request +type LookupParams struct { + ServiceRecord + Entries chan<- *ServiceEntry // Entries Channel + + stopProbing chan struct{} + once sync.Once +} + +// NewLookupParams constructs a LookupParams. +func NewLookupParams(instance, service, domain string, entries chan<- *ServiceEntry) *LookupParams { + return &LookupParams{ + ServiceRecord: *NewServiceRecord(instance, service, domain), + Entries: entries, + + stopProbing: make(chan struct{}), + } +} + +// Notify subscriber that no more entries will arrive. Mostly caused +// by an expired context. +func (l *LookupParams) done() { + close(l.Entries) +} + +func (l *LookupParams) disableProbing() { + l.once.Do(func() { close(l.stopProbing) }) +} + +// ServiceEntry represents a browse/lookup result for client API. +// It is also used to configure service registration (server API), which is +// used to answer multicast queries. +type ServiceEntry struct { + ServiceRecord + HostName string `json:"hostname"` // Host machine DNS name + Port int `json:"port"` // Service Port + Text []string `json:"text"` // Service info served as a TXT record + TTL uint32 `json:"ttl"` // TTL of the service record + AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address + AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address +} + +// NewServiceEntry constructs a ServiceEntry. +func NewServiceEntry(instance, service, domain string) *ServiceEntry { + return &ServiceEntry{ + ServiceRecord: *NewServiceRecord(instance, service, domain), + } +} diff --git a/third_party/zeroconf/utils.go b/third_party/zeroconf/utils.go new file mode 100644 index 0000000..39dc158 --- /dev/null +++ b/third_party/zeroconf/utils.go @@ -0,0 +1,8 @@ +package zeroconf + +import "strings" + +// trimDot is used to trim the dots from the start or end of a string +func trimDot(s string) string { + return strings.Trim(s, ".") +}