diff --git a/epson_xp630.yml b/epson_xp630.yml new file mode 100644 index 00000000..98ef51f6 --- /dev/null +++ b/epson_xp630.yml @@ -0,0 +1,26 @@ +- name: TEST_NAME + service: _ipps._tcp + domain: local. + port: 6631 + records: + - txtvers=1 + - ty=EPSON XP-630 Series + - usb_MFG=EPSON + - usb_MDL=XP-630 Series + - product=(EPSON XP-630 Series) + - pdl=application/octet-stream,image/pwg-raster,image/urf,image/jpeg + - rp=ipp/print + - qtotal=1 + - Color=T + - Duplex=T + - Scan=T + - Fax=F + - kind=document,envelope,label,photo + - PaperMax=legal-A4 + - URF=CP1,MT1-3-5-8-10-11-12,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7,V1.4 + - mopria-certified=1.2 + - priority=30 + - adminurl=http://EPSON59F5BA.local.:80/PRESENTATION/BONJOUR + - note= + - UUID=cfe92100-67c4-11d4-a45f-44d24459f5ba + - TLS=1.2 \ No newline at end of file diff --git a/external_responder.yml b/external_responder.yml new file mode 100644 index 00000000..b846d53b --- /dev/null +++ b/external_responder.yml @@ -0,0 +1,27 @@ +- name: TEST_NAME + service: _ipps._tcp + domain: local. + port: 6631 + responder: 134.122.95.96 + records: + - txtvers=1 + - ty=EPSON XP-630 Series + - usb_MFG=EPSON + - usb_MDL=XP-630 Series + - product=(EPSON XP-630 Series) + - pdl=application/octet-stream,image/pwg-raster,image/urf,image/jpeg + - rp=ipp/print + - qtotal=1 + - Color=T + - Duplex=T + - Scan=T + - Fax=F + - kind=document,envelope,label,photo + - PaperMax=legal-A4 + - URF=CP1,MT1-3-5-8-10-11-12,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7,V1.4 + - mopria-certified=1.2 + - priority=30 + - adminurl=http://EPSON59F5BA.local.:80/PRESENTATION/BONJOUR + - note= + - UUID=cfe92100-67c4-11d4-a45f-44d24459f5ba + - TLS=1.2 \ No newline at end of file diff --git a/go.mod b/go.mod index cd7dfdef..b1b2dfc3 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/bettercap/nrf24 v0.0.0-20190219153547-aa37e6d0e0eb github.com/bettercap/readline v0.0.0-20210228151553-655e48bcb7bf github.com/bettercap/recording v0.0.0-20190408083647-3ce1dcf032e3 + github.com/cenkalti/backoff v2.2.1+incompatible github.com/dustin/go-humanize v1.0.1 github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 github.com/evilsocket/islazy v1.11.0 @@ -42,7 +43,6 @@ require ( require ( github.com/antchfx/xpath v1.3.1 // indirect - github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/chzyer/logex v1.2.1 // indirect github.com/elazarl/goproxy/ext v0.0.0-20210110162100-a92cc753f88e // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect diff --git a/modules/zerogod/zerogod_advertise.go b/modules/zerogod/zerogod_advertise.go index 2f2100c3..5578b805 100644 --- a/modules/zerogod/zerogod_advertise.go +++ b/modules/zerogod/zerogod_advertise.go @@ -5,63 +5,60 @@ import ( "errors" "fmt" "io/ioutil" + "net" "os" "strings" "time" tls_utils "github.com/bettercap/bettercap/v2/tls" + "github.com/bettercap/bettercap/v2/zeroconf" "github.com/evilsocket/islazy/fs" "github.com/evilsocket/islazy/tui" - "github.com/grandcat/zeroconf" yaml "gopkg.in/yaml.v3" ) type Advertiser struct { - Filename string - Mapping map[string]zeroconf.ServiceEntry - Servers map[string]*zeroconf.Server - Acceptors map[string]*Acceptor + Filename string + + Services []ServiceData + Servers []*zeroconf.Server + Acceptors []*Acceptor } type setupResult struct { err error - key string server *zeroconf.Server } -func (mod *ZeroGod) startAdvertiser(fileName string) error { - if mod.advertiser != nil { - return fmt.Errorf("advertiser already started for %s", mod.advertiser.Filename) - } - +func (mod *ZeroGod) loadTLSConfig() (*tls.Config, error) { var certFile string var keyFile string var err error // read tls configuration if err, certFile = mod.StringParam("zerogod.advertise.certificate"); err != nil { - return err + return nil, err } else if certFile, err = fs.Expand(certFile); err != nil { - return err + return nil, err } if err, keyFile = mod.StringParam("zerogod.advertise.key"); err != nil { - return err + return nil, err } else if keyFile, err = fs.Expand(keyFile); err != nil { - return err + return nil, err } if !fs.Exists(certFile) || !fs.Exists(keyFile) { cfg, err := tls_utils.CertConfigFromModule("zerogod.advertise", mod.SessionModule) if err != nil { - return err + return nil, err } mod.Debug("%+v", cfg) mod.Info("generating server TLS key to %s", keyFile) mod.Info("generating server TLS certificate to %s", certFile) if err := tls_utils.Generate(cfg, certFile, keyFile, false); err != nil { - return err + return nil, err } } else { mod.Info("loading server TLS key from %s", keyFile) @@ -70,22 +67,23 @@ func (mod *ZeroGod) startAdvertiser(fileName string) error { cert, err := tls.LoadX509KeyPair(certFile, keyFile) if err != nil { - return err + return nil, err } - tlsConfig := tls.Config{ + return &tls.Config{ Certificates: []tls.Certificate{cert}, InsecureSkipVerify: true, + }, nil +} + +func (mod *ZeroGod) startAdvertiser(fileName string) error { + if mod.advertiser != nil { + return fmt.Errorf("advertiser already started for %s", mod.advertiser.Filename) } - data, err := ioutil.ReadFile(fileName) + tlsConfig, err := mod.loadTLSConfig() if err != nil { - return fmt.Errorf("could not read %s: %v", fileName, err) - } - - mapping := make(map[string]zeroconf.ServiceEntry) - if err = yaml.Unmarshal(data, &mapping); err != nil { - return fmt.Errorf("could not deserialize %s: %v", fileName, err) + return err } hostName, err := os.Hostname() @@ -96,73 +94,121 @@ func (mod *ZeroGod) startAdvertiser(fileName string) error { hostName += "." } - mod.Info("loaded %d services from %s, advertising with host=%s iface=%s ipv4=%s ipv6=%s", - len(mapping), - fileName, - hostName, - mod.Session.Interface.Name(), - mod.Session.Interface.IpAddress, - mod.Session.Interface.Ip6Address) + data, err := ioutil.ReadFile(fileName) + if err != nil { + return fmt.Errorf("could not read %s: %v", fileName, err) + } + + var services []ServiceData + if err = yaml.Unmarshal(data, &services); err != nil { + return fmt.Errorf("could not deserialize %s: %v", fileName, err) + } + + mod.Info("loaded %d services from %s", len(services), fileName) advertiser := &Advertiser{ Filename: fileName, - Mapping: mapping, - Servers: make(map[string]*zeroconf.Server), - Acceptors: make(map[string]*Acceptor), + Services: services, + Servers: make([]*zeroconf.Server, 0), + Acceptors: make([]*Acceptor, 0), } svcChan := make(chan setupResult) - // TODO: support external responders - // paralleize initialization - for key, svc := range mapping { - go func(key string, svc zeroconf.ServiceEntry) { - mod.Info("unregistering instance %s ...", tui.Yellow(fmt.Sprintf("%s.%s.%s", svc.Instance, svc.Service, svc.Domain))) + for _, svc := range services { + go func(svc ServiceData) { + mod.Info("unregistering instance %s ...", tui.Yellow(svc.FullName())) - // create a first instance just to deregister it from the network - server, err := zeroconf.Register(svc.Instance, svc.Service, svc.Domain, svc.Port, svc.Text, nil) - if err != nil { - svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.Instance, err)} + // deregister the service from the network first + if err := svc.Unregister(); err != nil { + svcChan <- setupResult{err: fmt.Errorf("could not unregister service %s: %v", svc.FullName(), err)} return } - server.Shutdown() // give some time to the network to adjust time.Sleep(time.Duration(1) * time.Second) - // now create it again to actually advertise - if server, err = zeroconf.Register(svc.Instance, svc.Service, svc.Domain, svc.Port, svc.Text, nil); err != nil { - svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.Instance, err)} - return - } + var server *zeroconf.Server - mod.Info("advertising service %s", tui.Yellow(svc.Service)) + // now create it again to actually advertise + if svc.Responder == "" { + // use our own IP + if server, err = zeroconf.Register( + svc.Name, + svc.Service, + svc.Domain, + svc.Port, + svc.Records, + nil); err != nil { + svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.FullName(), err)} + return + } + mod.Info("advertising %s with responder=%s port=%d", + tui.Yellow(svc.FullName()), + tui.Red(svc.Responder), + svc.Port) + } else { + responderHostName := "" + + // try first to do a reverse DNS of the ip + if addr, err := net.LookupAddr(svc.Responder); err == nil && len(addr) > 0 { + responderHostName = addr[0] + } else { + mod.Debug("could not get responder %s reverse dns entry: %v", svc.Responder, err) + } + + // if we don't have a host, create a .nip.io representation + if responderHostName == "" { + responderHostName = fmt.Sprintf("%s.nip.io.", strings.ReplaceAll(svc.Responder, ".", "-")) + } + + // use external responder + if server, err = zeroconf.RegisterExternalResponder( + svc.Name, + svc.Service, + svc.Domain, + svc.Port, + responderHostName, + []string{svc.Responder}, + svc.Records, + nil); err != nil { + svcChan <- setupResult{err: fmt.Errorf("could not create service %s: %v", svc.FullName(), err)} + return + } + + mod.Info("advertising %s with responder=%s hostname=%s port=%d", + tui.Yellow(svc.FullName()), + tui.Red(svc.Responder), + tui.Yellow(responderHostName), + svc.Port) + } svcChan <- setupResult{ - key: key, server: server, } - }(key, svc) + }(svc) } for res := range svcChan { if res.err != nil { return res.err } - advertiser.Servers[res.key] = res.server - if len(advertiser.Servers) == len(mapping) { + advertiser.Servers = append(advertiser.Servers, res.server) + if len(advertiser.Servers) == len(advertiser.Services) { break } } - // now create the tcp acceptors - for key, svc := range mapping { - acceptor := NewAcceptor(mod, key, hostName, uint16(svc.Port), &tlsConfig) - if err := acceptor.Start(); err != nil { - return err + // now create the tcp acceptors for entries without an explicit responder address + for _, svc := range advertiser.Services { + if svc.Responder == "" { + acceptor := NewAcceptor(mod, svc.FullName(), hostName, uint16(svc.Port), tlsConfig) + if err := acceptor.Start(); err != nil { + return err + } + advertiser.Acceptors = append(advertiser.Acceptors, acceptor) } - advertiser.Acceptors[key] = acceptor } mod.advertiser = advertiser @@ -177,7 +223,7 @@ func (mod *ZeroGod) stopAdvertiser() error { return errors.New("advertiser not started") } - mod.Info("stopping %d services ...", len(mod.advertiser.Mapping)) + mod.Info("stopping %d services ...", len(mod.advertiser.Services)) for key, server := range mod.advertiser.Servers { mod.Info("stopping %s ...", key) diff --git a/modules/zerogod/zerogod_discovery.go b/modules/zerogod/zerogod_discovery.go index 669bfe11..12f03b65 100644 --- a/modules/zerogod/zerogod_discovery.go +++ b/modules/zerogod/zerogod_discovery.go @@ -7,9 +7,8 @@ import ( "github.com/bettercap/bettercap/v2/network" "github.com/bettercap/bettercap/v2/session" "github.com/bettercap/bettercap/v2/tls" + "github.com/bettercap/bettercap/v2/zeroconf" "github.com/evilsocket/islazy/tui" - - "github.com/grandcat/zeroconf" ) type ZeroGod struct { diff --git a/modules/zerogod/zerogod_endpoint_update.go b/modules/zerogod/zerogod_endpoint_update.go index 7c1d7087..af64df03 100644 --- a/modules/zerogod/zerogod_endpoint_update.go +++ b/modules/zerogod/zerogod_endpoint_update.go @@ -6,8 +6,8 @@ import ( "github.com/bettercap/bettercap/v2/modules/syn_scan" "github.com/bettercap/bettercap/v2/network" + "github.com/bettercap/bettercap/v2/zeroconf" "github.com/evilsocket/islazy/str" - "github.com/grandcat/zeroconf" ) func (mod *ZeroGod) updateEndpointMeta(address string, endpoint *network.Endpoint, svc *zeroconf.ServiceEntry) { diff --git a/modules/zerogod/zerogod_ipp_handler.go b/modules/zerogod/zerogod_ipp_handler.go index ca0c4446..b60af411 100644 --- a/modules/zerogod/zerogod_ipp_handler.go +++ b/modules/zerogod/zerogod_ipp_handler.go @@ -256,6 +256,8 @@ func ippOnGetPrinterAttributes(mod *ZeroGod, client net.Conn, ipp_req *ipp.Reque } */ + // TODO: allow customization of these attributes. + // rfc2911 section 4.4 ipp_resp.PrinterAttributes = []ipp.Attributes{ { diff --git a/modules/zerogod/zerogod_save.go b/modules/zerogod/zerogod_save.go index 60b1ef42..86bdb021 100644 --- a/modules/zerogod/zerogod_save.go +++ b/modules/zerogod/zerogod_save.go @@ -3,10 +3,60 @@ package zerogod import ( "fmt" "io/ioutil" + "strings" + "github.com/bettercap/bettercap/v2/zeroconf" + "github.com/evilsocket/islazy/str" yaml "gopkg.in/yaml.v3" ) +type ServiceData struct { + Name string `yaml:"name"` // Instance name (e.g. "My web page") + Service string `yaml:"service"` // Service name (e.g. _http._tcp.) + Domain string `yaml:"domain"` // If blank, assumes "local" + Port int `yaml:"port"` // Service port + Records []string `yaml:"records,omitempty"` // Service DNS text records + Responder string `yaml:"responder,omitempty"` // Optional IP to use instead of our tcp acceptor +} + +func (svc ServiceData) FullName() string { + return fmt.Sprintf("%s.%s.%s", + strings.Trim(svc.Name, "."), + strings.Trim(svc.Service, "."), + strings.Trim(svc.Domain, ".")) +} + +func (svc ServiceData) Unregister() error { + if server, err := zeroconf.Register(svc.Name, svc.Service, svc.Domain, svc.Port, svc.Records, nil); err != nil { + return err + } else { + server.Shutdown() + } + return nil +} + +func svcEntriesToData(services map[string]*zeroconf.ServiceEntry) []ServiceData { + data := make([]ServiceData, 0) + for _, svc := range services { + // filter out empty DNS records + records := ([]string)(nil) + for _, txt := range svc.Text { + if txt = str.Trim(txt); len(txt) > 0 { + records = append(records, txt) + } + } + + data = append(data, ServiceData{ + Name: svc.Instance, + Service: svc.Service, + Domain: svc.Domain, + Port: svc.Port, + Records: records, + }) + } + return data +} + func (mod *ZeroGod) save(address, filename string) error { if address == "" { return fmt.Errorf("address cannot be empty") @@ -16,7 +66,8 @@ func (mod *ZeroGod) save(address, filename string) error { } if ipServices, found := mod.mapping[address]; found { - data, err := yaml.Marshal(ipServices) + services := svcEntriesToData(ipServices) + data, err := yaml.Marshal(services) if err != nil { return err } @@ -26,7 +77,7 @@ func (mod *ZeroGod) save(address, filename string) error { return err } - mod.Info("mDNS information saved to %s", filename) + mod.Info("zeroconf information saved to %s", filename) } else { return fmt.Errorf("no mDNS information found for address %s", address) } diff --git a/modules/zerogod/zerogod_show.go b/modules/zerogod/zerogod_show.go index 46f4ea8b..58f6e592 100644 --- a/modules/zerogod/zerogod_show.go +++ b/modules/zerogod/zerogod_show.go @@ -4,9 +4,9 @@ import ( "fmt" "sort" + "github.com/bettercap/bettercap/v2/zeroconf" "github.com/evilsocket/islazy/str" "github.com/evilsocket/islazy/tui" - "github.com/grandcat/zeroconf" ) type entry struct { diff --git a/zeroconf/client.go b/zeroconf/client.go new file mode 100644 index 00000000..3fa67374 --- /dev/null +++ b/zeroconf/client.go @@ -0,0 +1,482 @@ +package zeroconf + +import ( + "context" + "fmt" + "log" + "net" + "runtime" + "strings" + "time" + + "github.com/cenkalti/backoff" + "github.com/miekg/dns" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +// 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.SetDomain(domain) + } + params.Entries = entries + params.isBrowsing = true + 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.SetDomain(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", false, 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 + if !params.isBrowsing { + params.disableProbing() + } + } + } + } +} + +// 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 { + bo := backoff.NewExponentialBackOff() + bo.InitialInterval = 4 * time.Second + bo.MaxInterval = 60 * time.Second + bo.MaxElapsedTime = 0 + bo.Reset() + + var timer *time.Timer + defer func() { + if timer != nil { + timer.Stop() + } + }() + for { + // Backoff and cancel logic. + wait := bo.NextBackOff() + if wait == backoff.Stop { + return fmt.Errorf("periodicQuery: abort due to timeout") + } + if timer == nil { + timer = time.NewTimer(wait) + } else { + timer.Reset(wait) + } + select { + case <-timer.C: + // 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() + } + // Do periodic query. + if err := c.query(params); err != nil { + return 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)) + + // send the query + m := new(dns.Msg) + if params.Instance != "" { // service instance name lookup + serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName) + m.Question = []dns.Question{ + {Name: serviceInstanceName, Qtype: dns.TypeSRV, Qclass: dns.ClassINET}, + {Name: serviceInstanceName, Qtype: dns.TypeTXT, Qclass: dns.ClassINET}, + } + } else if len(params.Subtypes) > 0 { // service subtype browse + m.SetQuestion(params.Subtypes[0], dns.TypePTR) + } else { // service name browse + 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 { + // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG + // As of Golang 1.18.4 + // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. + var wcm ipv4.ControlMessage + for ifi := range c.ifaces { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = c.ifaces[ifi].Index + default: + if err := c.ipv4conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } + } + if c.ipv6conn != nil { + // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG + // As of Golang 1.18.4 + // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. + var wcm ipv6.ControlMessage + for ifi := range c.ifaces { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = c.ifaces[ifi].Index + default: + if err := c.ipv6conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) + } + } + return nil +} diff --git a/zeroconf/connection.go b/zeroconf/connection.go new file mode 100644 index 00000000..daac9c12 --- /dev/null +++ b/zeroconf/connection.go @@ -0,0 +1,117 @@ +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) + _ = pkConn.SetMulticastHopLimit(255) + + 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) + _ = pkConn.SetMulticastTTL(255) + + 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/zeroconf/doc.go b/zeroconf/doc.go new file mode 100644 index 00000000..b3e5d47a --- /dev/null +++ b/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/zeroconf/server.go b/zeroconf/server.go new file mode 100644 index 00000000..03cd05b7 --- /dev/null +++ b/zeroconf/server.go @@ -0,0 +1,796 @@ +package zeroconf + +import ( + "errors" + "fmt" + "log" + "math/rand" + "net" + "os" + "runtime" + "strings" + "sync" + "time" + + "github.com/miekg/dns" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +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 +} + +// RegisterExternalResponder registers a service proxy. This call will skip the hostname/IP lookup and +// will use the provided values. +func RegisterExternalResponder(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() + + 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 err +} + +// 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 + } + _ = s.parsePacket(buf[:n], ifIndex, from) + } + } +} + +// 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 + } + _ = s.parsePacket(buf[:n], ifIndex, from) + } + } +} + +// 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) + default: + // handle matching subtype query + for _, subtype := range s.service.Subtypes { + subtype = fmt.Sprintf("%s._sub.%s", subtype, s.service.ServiceName()) + if q.Name == subtype { + s.composeBrowsingAnswers(resp, ifIndex) + if isKnownAnswer(resp, query) { + resp.Answer = nil + } + break + } + } + } + + 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) + + for _, subtype := range s.service.Subtypes { + resp.Answer = append(resp.Answer, + &dns.PTR{ + Hdr: dns.RR_Header{ + Name: subtype, + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ptr: s.service.ServiceInstanceName(), + }) + } + + 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 fmt.Errorf("error creating multicast response: %v", err) + } + if s.ipv4conn != nil { + // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG + // As of Golang 1.18.4 + // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. + var wcm ipv4.ControlMessage + if ifIndex != 0 { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = ifIndex + default: + iface, _ := net.InterfaceByIndex(ifIndex) + if err := s.ipv4conn.SetMulticastInterface(iface); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } else { + for _, intf := range s.ifaces { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = intf.Index + default: + if err := s.ipv4conn.SetMulticastInterface(&intf); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) + } + } + } + + if s.ipv6conn != nil { + // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG + // As of Golang 1.18.4 + // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. + var wcm ipv6.ControlMessage + if ifIndex != 0 { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = ifIndex + default: + iface, _ := net.InterfaceByIndex(ifIndex) + if err := s.ipv6conn.SetMulticastInterface(iface); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) + } else { + for _, intf := range s.ifaces { + switch runtime.GOOS { + case "darwin", "ios", "linux": + wcm.IfIndex = intf.Index + default: + if err := s.ipv6conn.SetMulticastInterface(&intf); err != nil { + log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) + } + } + 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/zeroconf/service.go b/zeroconf/service.go new file mode 100644 index 00000000..d90589c5 --- /dev/null +++ b/zeroconf/service.go @@ -0,0 +1,125 @@ +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.) + Subtypes []string `json:"subtypes"` // Service subtypes + 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 +} + +func (s *ServiceRecord) SetDomain(domain string) { + s.Domain = domain + + s.serviceName = fmt.Sprintf("%s.%s.", trimDot(s.Service), trimDot(domain)) + if s.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) +} + +// NewServiceRecord constructs a ServiceRecord. +func NewServiceRecord(instance, service string, domain string) *ServiceRecord { + service, subtypes := parseSubtypes(service) + s := &ServiceRecord{ + Instance: instance, + Service: service, + Domain: domain, + serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)), + } + + for _, subtype := range subtypes { + s.Subtypes = append(s.Subtypes, fmt.Sprintf("%s._sub.%s", trimDot(subtype), s.serviceName)) + } + + s.SetDomain(domain) + + return s +} + +// lookupParams contains configurable properties to create a service discovery request +type lookupParams struct { + ServiceRecord + Entries chan<- *ServiceEntry // Entries Channel + + isBrowsing bool + stopProbing chan struct{} + once sync.Once +} + +// newLookupParams constructs a lookupParams. +func newLookupParams(instance, service, domain string, isBrowsing bool, entries chan<- *ServiceEntry) *lookupParams { + p := &lookupParams{ + ServiceRecord: *NewServiceRecord(instance, service, domain), + Entries: entries, + isBrowsing: isBrowsing, + } + if !isBrowsing { + p.stopProbing = make(chan struct{}) + } + return p +} + +// 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 string, domain string) *ServiceEntry { + return &ServiceEntry{ + ServiceRecord: *NewServiceRecord(instance, service, domain), + } +} diff --git a/zeroconf/utils.go b/zeroconf/utils.go new file mode 100644 index 00000000..106fc6e6 --- /dev/null +++ b/zeroconf/utils.go @@ -0,0 +1,13 @@ +package zeroconf + +import "strings" + +func parseSubtypes(service string) (string, []string) { + subtypes := strings.Split(service, ",") + return subtypes[0], subtypes[1:] +} + +// trimDot is used to trim the dots from the start or end of a string +func trimDot(s string) string { + return strings.Trim(s, ".") +}