├── examples ├── resolv │ ├── .gitignore │ ├── README.md │ └── client.go ├── register │ ├── .gitignore │ └── server.go └── proxyservice │ ├── .gitignore │ └── server.go ├── version.json ├── go.mod ├── .github └── workflows │ ├── stale.yml │ ├── generated-pr.yml │ ├── tagpush.yml │ ├── releaser.yml │ ├── go-check.yml │ ├── release-check.yml │ └── go-test.yml ├── utils.go ├── .gitignore ├── doc.go ├── LICENSE ├── go.sum ├── connection.go ├── service.go ├── README.md ├── service_test.go ├── client.go └── server.go /examples/resolv/.gitignore: -------------------------------------------------------------------------------- 1 | resolv 2 | 3 | -------------------------------------------------------------------------------- /examples/register/.gitignore: -------------------------------------------------------------------------------- 1 | register 2 | 3 | -------------------------------------------------------------------------------- /examples/proxyservice/.gitignore: -------------------------------------------------------------------------------- 1 | proxyservice 2 | -------------------------------------------------------------------------------- /version.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "v2.2.0" 3 | } 4 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/libp2p/zeroconf/v2 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/miekg/dns v1.1.43 7 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 8 | ) 9 | 10 | require golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 // indirect 11 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close Stale Issues 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-stale-issue.yml@v1 15 | -------------------------------------------------------------------------------- /.github/workflows/generated-pr.yml: -------------------------------------------------------------------------------- 1 | name: Close Generated PRs 2 | 3 | on: 4 | schedule: 5 | - cron: '0 0 * * *' 6 | workflow_dispatch: 7 | 8 | permissions: 9 | issues: write 10 | pull-requests: write 11 | 12 | jobs: 13 | stale: 14 | uses: ipdxco/unified-github-workflows/.github/workflows/reusable-generated-pr.yml@v1 15 | -------------------------------------------------------------------------------- /utils.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import "strings" 4 | 5 | func parseSubtypes(service string) (string, []string) { 6 | subtypes := strings.Split(service, ",") 7 | return subtypes[0], subtypes[1:] 8 | } 9 | 10 | // trimDot is used to trim the dots from the start or end of a string 11 | func trimDot(s string) string { 12 | return strings.Trim(s, ".") 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 2 | *.o 3 | *.a 4 | *.so 5 | 6 | # Folders 7 | _obj 8 | _test 9 | 10 | # Architecture specific extensions/prefixes 11 | *.[568vq] 12 | [568vq].out 13 | 14 | *.cgo1.go 15 | *.cgo2.c 16 | _cgo_defun.c 17 | _cgo_gotypes.go 18 | _cgo_export.* 19 | 20 | _testmain.go 21 | 22 | *.exe 23 | *.test 24 | *.prof 25 | -------------------------------------------------------------------------------- /.github/workflows/tagpush.yml: -------------------------------------------------------------------------------- 1 | name: Tag Push Checker 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* 7 | 8 | permissions: 9 | contents: read 10 | issues: write 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | releaser: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/tagpush.yml@v1.0 19 | -------------------------------------------------------------------------------- /.github/workflows/releaser.yml: -------------------------------------------------------------------------------- 1 | name: Releaser 2 | 3 | on: 4 | push: 5 | paths: [ 'version.json' ] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: write 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.sha }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | releaser: 17 | uses: ipdxco/unified-github-workflows/.github/workflows/releaser.yml@v1.0 18 | -------------------------------------------------------------------------------- /.github/workflows/go-check.yml: -------------------------------------------------------------------------------- 1 | name: Go Checks 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["master"] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | go-check: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/go-check.yml@v1.0 19 | -------------------------------------------------------------------------------- /.github/workflows/release-check.yml: -------------------------------------------------------------------------------- 1 | name: Release Checker 2 | 3 | on: 4 | pull_request_target: 5 | paths: [ 'version.json' ] 6 | types: [ opened, synchronize, reopened, labeled, unlabeled ] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | 13 | concurrency: 14 | group: ${{ github.workflow }}-${{ github.ref }} 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | release-check: 19 | uses: ipdxco/unified-github-workflows/.github/workflows/release-check.yml@v1.0 20 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: Go Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: ["master"] 7 | workflow_dispatch: 8 | 9 | permissions: 10 | contents: read 11 | 12 | concurrency: 13 | group: ${{ github.workflow }}-${{ github.event_name }}-${{ github.event_name == 'push' && github.sha || github.ref }} 14 | cancel-in-progress: true 15 | 16 | jobs: 17 | go-test: 18 | uses: ipdxco/unified-github-workflows/.github/workflows/go-test.yml@v1.0 19 | secrets: 20 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 21 | -------------------------------------------------------------------------------- /doc.go: -------------------------------------------------------------------------------- 1 | // Package zeroconf is a pure Golang library that employs Multicast DNS-SD for 2 | // browsing and resolving services in your network and registering own services 3 | // in the local network. 4 | // 5 | // It basically implements aspects of the standards 6 | // RFC 6762 (mDNS) and 7 | // RFC 6763 (DNS-SD). 8 | // Though it does not support all requirements yet, the aim is to provide a 9 | // complient solution in the long-term with the community. 10 | // 11 | // By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and 12 | // Apple's Bonjour (untested). Should work in the most office, home and private 13 | // environments. 14 | package zeroconf 15 | -------------------------------------------------------------------------------- /examples/resolv/README.md: -------------------------------------------------------------------------------- 1 | Browse and Resolve 2 | ================== 3 | Compile: 4 | ```bash 5 | go build -v 6 | ``` 7 | 8 | Browse for available services in your local network: 9 | ```bash 10 | ./resolv 11 | ``` 12 | By default, it shows all working stations in your network running 13 | a mDNS service like Avahi. 14 | The output should look similar to this one: 15 | ``` 16 | 2016/12/04 00:40:23 &{{stefanserver _workstation._tcp local. } stefan.local. 50051 [] 120 [192.168.42.42] [fd00::86a6:c8ff:fe62:4242]} 17 | 2016/12/04 00:40:23 stefanserver 18 | 2016/12/04 00:40:28 No more entries. 19 | ``` 20 | The `-wait` parameter enables to wait for a specific time until 21 | it stops listening for new services. 22 | 23 | For a list of all possible options, just have a look at: 24 | ```bash 25 | ./resolv --help 26 | ``` -------------------------------------------------------------------------------- /examples/resolv/client.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "flag" 6 | "log" 7 | "time" 8 | 9 | "github.com/libp2p/zeroconf/v2" 10 | ) 11 | 12 | var ( 13 | service = flag.String("service", "_workstation._tcp", "Set the service category to look for devices.") 14 | domain = flag.String("domain", "local", "Set the search domain. For local networks, default is fine.") 15 | waitTime = flag.Int("wait", 10, "Duration in [s] to run discovery.") 16 | ) 17 | 18 | func main() { 19 | flag.Parse() 20 | 21 | entries := make(chan *zeroconf.ServiceEntry) 22 | go func(results <-chan *zeroconf.ServiceEntry) { 23 | for entry := range results { 24 | log.Println(entry) 25 | } 26 | log.Println("No more entries.") 27 | }(entries) 28 | 29 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(*waitTime)) 30 | defer cancel() 31 | // Discover all services on the network (e.g. _workstation._tcp) 32 | err := zeroconf.Browse(ctx, *service, *domain, entries) 33 | if err != nil { 34 | log.Fatalln("Failed to browse:", err.Error()) 35 | } 36 | 37 | <-ctx.Done() 38 | // Wait some additional time to see debug messages on go routine shutdown. 39 | time.Sleep(1 * time.Second) 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Stefan Smarzly 4 | Copyright (c) 2014 Oleksandr Lobunets 5 | 6 | Note: Copyright for portions of project zeroconf.sd are held by Oleksandr 7 | Lobunets, 2014, as part of project bonjour. All other copyright for 8 | project zeroconf.sd are held by Stefan Smarzly, 2016. 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | 28 | -------------------------------------------------------------------------------- /examples/register/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "time" 11 | 12 | "github.com/libp2p/zeroconf/v2" 13 | ) 14 | 15 | var ( 16 | name = flag.String("name", "GoZeroconfGo", "The name for the service.") 17 | service = flag.String("service", "_workstation._tcp", "Set the service type of the new service.") 18 | domain = flag.String("domain", "local.", "Set the network domain. Default should be fine.") 19 | port = flag.Int("port", 42424, "Set the port the service is listening to.") 20 | waitTime = flag.Int("wait", 10, "Duration in [s] to publish service for.") 21 | ) 22 | 23 | func main() { 24 | flag.Parse() 25 | 26 | server, err := zeroconf.Register(*name, *service, *domain, *port, []string{"txtv=0", "lo=1", "la=2"}, nil) 27 | if err != nil { 28 | panic(err) 29 | } 30 | defer server.Shutdown() 31 | log.Println("Published service:") 32 | log.Println("- Name:", *name) 33 | log.Println("- Type:", *service) 34 | log.Println("- Domain:", *domain) 35 | log.Println("- Port:", *port) 36 | 37 | // Clean exit. 38 | sig := make(chan os.Signal, 1) 39 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 40 | // Timeout timer. 41 | var tc <-chan time.Time 42 | if *waitTime > 0 { 43 | tc = time.After(time.Second * time.Duration(*waitTime)) 44 | } 45 | 46 | select { 47 | case <-sig: 48 | // Exit by user 49 | case <-tc: 50 | // Exit by timeout 51 | } 52 | 53 | log.Println("Shutting down.") 54 | } 55 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= 2 | github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= 3 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 4 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk= 5 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 6 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 7 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 8 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 9 | golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 10 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 11 | golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 h1:kHSDPqCtsHZOg0nVylfTo20DDhE9gG4Y0jn7hKQ0QAM= 12 | golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 13 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 14 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 15 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 16 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 17 | -------------------------------------------------------------------------------- /examples/proxyservice/server.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | 10 | "time" 11 | 12 | "github.com/libp2p/zeroconf/v2" 13 | ) 14 | 15 | var ( 16 | name = flag.String("name", "GoZeroconfGo", "The name for the service.") 17 | service = flag.String("service", "_workstation._tcp", "Set the service type of the new service.") 18 | domain = flag.String("domain", "local.", "Set the network domain. Default should be fine.") 19 | host = flag.String("host", "pc1", "Set host name for service.") 20 | ip = flag.String("ip", "::1", "Set IP a service should be reachable.") 21 | port = flag.Int("port", 42424, "Set the port the service is listening to.") 22 | waitTime = flag.Int("wait", 10, "Duration in [s] to publish service for.") 23 | ) 24 | 25 | func main() { 26 | flag.Parse() 27 | 28 | server, err := zeroconf.RegisterProxy(*name, *service, *domain, *port, *host, []string{*ip}, []string{"txtv=0", "lo=1", "la=2"}, nil) 29 | if err != nil { 30 | panic(err) 31 | } 32 | defer server.Shutdown() 33 | log.Println("Published proxy service:") 34 | log.Println("- Name:", *name) 35 | log.Println("- Type:", *service) 36 | log.Println("- Domain:", *domain) 37 | log.Println("- Port:", *port) 38 | log.Println("- Host:", *host) 39 | log.Println("- IP:", *ip) 40 | 41 | // Clean exit. 42 | sig := make(chan os.Signal, 1) 43 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 44 | // Timeout timer. 45 | var tc <-chan time.Time 46 | if *waitTime > 0 { 47 | tc = time.After(time.Second * time.Duration(*waitTime)) 48 | } 49 | 50 | select { 51 | case <-sig: 52 | // Exit by user 53 | case <-tc: 54 | // Exit by timeout 55 | } 56 | 57 | log.Println("Shutting down.") 58 | } 59 | -------------------------------------------------------------------------------- /connection.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | 7 | "golang.org/x/net/ipv4" 8 | "golang.org/x/net/ipv6" 9 | ) 10 | 11 | var ( 12 | // Multicast groups used by mDNS 13 | mdnsGroupIPv4 = net.IPv4(224, 0, 0, 251) 14 | mdnsGroupIPv6 = net.ParseIP("ff02::fb") 15 | 16 | // mDNS wildcard addresses 17 | mdnsWildcardAddrIPv4 = &net.UDPAddr{ 18 | IP: net.ParseIP("224.0.0.0"), 19 | Port: 5353, 20 | } 21 | mdnsWildcardAddrIPv6 = &net.UDPAddr{ 22 | IP: net.ParseIP("ff02::"), 23 | // IP: net.ParseIP("fd00::12d3:26e7:48db:e7d"), 24 | Port: 5353, 25 | } 26 | 27 | // mDNS endpoint addresses 28 | ipv4Addr = &net.UDPAddr{ 29 | IP: mdnsGroupIPv4, 30 | Port: 5353, 31 | } 32 | ipv6Addr = &net.UDPAddr{ 33 | IP: mdnsGroupIPv6, 34 | Port: 5353, 35 | } 36 | ) 37 | 38 | func joinUdp6Multicast(interfaces []net.Interface) (*ipv6.PacketConn, error) { 39 | udpConn, err := net.ListenUDP("udp6", mdnsWildcardAddrIPv6) 40 | if err != nil { 41 | return nil, err 42 | } 43 | 44 | // Join multicast groups to receive announcements 45 | pkConn := ipv6.NewPacketConn(udpConn) 46 | pkConn.SetControlMessage(ipv6.FlagInterface, true) 47 | 48 | if len(interfaces) == 0 { 49 | interfaces = listMulticastInterfaces() 50 | } 51 | // log.Println("Using multicast interfaces: ", interfaces) 52 | 53 | var failedJoins int 54 | for _, iface := range interfaces { 55 | if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil { 56 | // log.Println("Udp6 JoinGroup failed for iface ", iface) 57 | failedJoins++ 58 | } 59 | } 60 | if failedJoins == len(interfaces) { 61 | pkConn.Close() 62 | return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces) 63 | } 64 | 65 | _ = pkConn.SetMulticastHopLimit(255) 66 | 67 | return pkConn, nil 68 | } 69 | 70 | func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) { 71 | udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4) 72 | if err != nil { 73 | // log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err) 74 | return nil, err 75 | } 76 | 77 | // Join multicast groups to receive announcements 78 | pkConn := ipv4.NewPacketConn(udpConn) 79 | pkConn.SetControlMessage(ipv4.FlagInterface, true) 80 | 81 | if len(interfaces) == 0 { 82 | interfaces = listMulticastInterfaces() 83 | } 84 | // log.Println("Using multicast interfaces: ", interfaces) 85 | 86 | var failedJoins int 87 | for _, iface := range interfaces { 88 | if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil { 89 | // log.Println("Udp4 JoinGroup failed for iface ", iface) 90 | failedJoins++ 91 | } 92 | } 93 | if failedJoins == len(interfaces) { 94 | pkConn.Close() 95 | return nil, fmt.Errorf("udp4: failed to join any of these interfaces: %v", interfaces) 96 | } 97 | 98 | _ = pkConn.SetMulticastTTL(255) 99 | 100 | return pkConn, nil 101 | } 102 | 103 | func listMulticastInterfaces() []net.Interface { 104 | var interfaces []net.Interface 105 | ifaces, err := net.Interfaces() 106 | if err != nil { 107 | return nil 108 | } 109 | for _, ifi := range ifaces { 110 | if (ifi.Flags & net.FlagUp) == 0 { 111 | continue 112 | } 113 | if (ifi.Flags & net.FlagMulticast) > 0 { 114 | interfaces = append(interfaces, ifi) 115 | } 116 | } 117 | 118 | return interfaces 119 | } 120 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | "time" 8 | ) 9 | 10 | // ServiceRecord contains the basic description of a service, which contains instance name, service type & domain 11 | type ServiceRecord struct { 12 | Instance string `json:"name"` // Instance name (e.g. "My web page") 13 | Service string `json:"type"` // Service name (e.g. _http._tcp.) 14 | Subtypes []string `json:"subtypes"` // Service subtypes 15 | Domain string `json:"domain"` // If blank, assumes "local" 16 | 17 | // private variable populated on ServiceRecord creation 18 | serviceName string 19 | serviceInstanceName string 20 | serviceTypeName string 21 | } 22 | 23 | // ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed 24 | // of a service name (also referred as service type) and a domain. 25 | func (s *ServiceRecord) ServiceName() string { 26 | return s.serviceName 27 | } 28 | 29 | // ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.), 30 | // which is composed from service instance name, service name and a domain. 31 | func (s *ServiceRecord) ServiceInstanceName() string { 32 | return s.serviceInstanceName 33 | } 34 | 35 | // ServiceTypeName returns the complete identifier for a DNS-SD query. 36 | func (s *ServiceRecord) ServiceTypeName() string { 37 | return s.serviceTypeName 38 | } 39 | 40 | // newServiceRecord constructs a ServiceRecord. 41 | func newServiceRecord(instance, service string, domain string) *ServiceRecord { 42 | service, subtypes := parseSubtypes(service) 43 | s := &ServiceRecord{ 44 | Instance: instance, 45 | Service: service, 46 | Domain: domain, 47 | serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)), 48 | } 49 | 50 | for _, subtype := range subtypes { 51 | s.Subtypes = append(s.Subtypes, fmt.Sprintf("%s._sub.%s", trimDot(subtype), s.serviceName)) 52 | } 53 | 54 | // Cache service instance name 55 | if instance != "" { 56 | s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName()) 57 | } 58 | 59 | // Cache service type name domain 60 | typeNameDomain := "local" 61 | if len(s.Domain) > 0 { 62 | typeNameDomain = trimDot(s.Domain) 63 | } 64 | s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain) 65 | 66 | return s 67 | } 68 | 69 | // lookupParams contains configurable properties to create a service discovery request 70 | type lookupParams struct { 71 | ServiceRecord 72 | Entries chan<- *ServiceEntry // Entries Channel 73 | 74 | isBrowsing bool 75 | stopProbing chan struct{} 76 | once sync.Once 77 | } 78 | 79 | // newLookupParams constructs a lookupParams. 80 | func newLookupParams(instance, service, domain string, isBrowsing bool, entries chan<- *ServiceEntry) *lookupParams { 81 | p := &lookupParams{ 82 | ServiceRecord: *newServiceRecord(instance, service, domain), 83 | Entries: entries, 84 | isBrowsing: isBrowsing, 85 | } 86 | if !isBrowsing { 87 | p.stopProbing = make(chan struct{}) 88 | } 89 | return p 90 | } 91 | 92 | // Notify subscriber that no more entries will arrive. Mostly caused 93 | // by an expired context. 94 | func (l *lookupParams) done() { 95 | close(l.Entries) 96 | } 97 | 98 | func (l *lookupParams) disableProbing() { 99 | l.once.Do(func() { close(l.stopProbing) }) 100 | } 101 | 102 | // ServiceEntry represents a browse/lookup result for client API. 103 | // It is also used to configure service registration (server API), which is 104 | // used to answer multicast queries. 105 | type ServiceEntry struct { 106 | ServiceRecord 107 | HostName string `json:"hostname"` // Host machine DNS name 108 | Port int `json:"port"` // Service Port 109 | Text []string `json:"text"` // Service info served as a TXT record 110 | Expiry time.Time `json:"expiry"` // Expiry of the service entry, will be converted to a TTL value 111 | AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address 112 | AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address 113 | } 114 | 115 | // newServiceEntry constructs a ServiceEntry. 116 | func newServiceEntry(instance, service string, domain string) *ServiceEntry { 117 | return &ServiceEntry{ 118 | ServiceRecord: *newServiceRecord(instance, service, domain), 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ZeroConf: Service Discovery with mDNS 2 | ===================================== 3 | ZeroConf is a pure Golang library that employs Multicast DNS-SD for 4 | 5 | * browsing and resolving services in your network 6 | * registering own services 7 | 8 | in the local network. 9 | 10 | It basically implements aspects of the standards 11 | [RFC 6762](https://tools.ietf.org/html/rfc6762) (mDNS) and 12 | [RFC 6763](https://tools.ietf.org/html/rfc6763) (DNS-SD). 13 | Though it does not support all requirements yet, the aim is to provide a compliant solution in the long-term with the community. 14 | 15 | By now, it should be compatible to [Avahi](http://avahi.org/) (tested) and Apple's Bonjour (untested). 16 | Target environments: private LAN/Wifi, small or isolated networks. 17 | 18 | [![GoDoc](https://godoc.org/github.com/libp2p/zeroconf?status.svg)](https://godoc.org/github.com/libp2p/zeroconf) 19 | [![Go Report Card](https://goreportcard.com/badge/github.com/libp2p/zeroconf)](https://goreportcard.com/report/github.com/libp2p/zeroconf) 20 | [![Tests](https://github.com/libp2p/zeroconf/actions/workflows/go-test.yml/badge.svg)](https://github.com/libp2p/zeroconf/actions/workflows/go-test.yml) 21 | 22 | ## Install 23 | Nothing is as easy as that: 24 | ```bash 25 | $ go get -u github.com/libp2p/zeroconf/v2 26 | ``` 27 | 28 | ## Browse for services in your local network 29 | 30 | ```go 31 | entries := make(chan *zeroconf.ServiceEntry) 32 | go func(results <-chan *zeroconf.ServiceEntry) { 33 | for entry := range results { 34 | log.Println(entry) 35 | } 36 | log.Println("No more entries.") 37 | }(entries) 38 | 39 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) 40 | defer cancel() 41 | // Discover all services on the network (e.g. _workstation._tcp) 42 | err = zeroconf.Browse(ctx, "_workstation._tcp", "local.", entries) 43 | if err != nil { 44 | log.Fatalln("Failed to browse:", err.Error()) 45 | } 46 | 47 | <-ctx.Done() 48 | ``` 49 | A subtype may added to service name to narrow the set of results. E.g. to browse `_workstation._tcp` with subtype `_windows`, use`_workstation._tcp,_windows`. 50 | 51 | See https://github.com/libp2p/zeroconf/blob/master/examples/resolv/client.go. 52 | 53 | ## Lookup a specific service instance 54 | 55 | ```go 56 | // Example filled soon. 57 | ``` 58 | 59 | ## Register a service 60 | 61 | ```go 62 | server, err := zeroconf.Register("GoZeroconf", "_workstation._tcp", "local.", 42424, []string{"txtv=0", "lo=1", "la=2"}, nil) 63 | if err != nil { 64 | panic(err) 65 | } 66 | defer server.Shutdown() 67 | 68 | // Clean exit. 69 | sig := make(chan os.Signal, 1) 70 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 71 | select { 72 | case <-sig: 73 | // Exit by user 74 | case <-time.After(time.Second * 120): 75 | // Exit by timeout 76 | } 77 | 78 | log.Println("Shutting down.") 79 | ``` 80 | Multiple subtypes may be added to service name, separated by commas. E.g `_workstation._tcp,_windows` has subtype `_windows`. 81 | 82 | See https://github.com/libp2p/zeroconf/blob/master/examples/register/server.go. 83 | 84 | ## Features and ToDo's 85 | This list gives a quick impression about the state of this library. 86 | See what needs to be done and submit a pull request :) 87 | 88 | * [x] Browse / Lookup / Register services 89 | * [x] Multiple IPv6 / IPv4 addresses support 90 | * [x] Send multiple probes (exp. back-off) if no service answers (*) 91 | * [x] Timestamp entries for TTL checks 92 | * [ ] Compare new multicasts with already received services 93 | 94 | _Notes:_ 95 | 96 | (*) The denoted features might not be perfectly standards compliant, but shouldn't cause any problems. 97 | Some tests showed improvements in overall robustness and performance with the features enabled. 98 | 99 | ## Credits 100 | Great thanks to [hashicorp](https://github.com/hashicorp/mdns) and to [oleksandr](https://github.com/oleksandr/bonjour) and all contributing authors for the code this projects bases upon. 101 | Large parts of the code are still the same. 102 | 103 | However, there are several reasons why I decided to create a fork of the original project: 104 | The previous project seems to be unmaintained. There are several useful pull requests waiting. I merged most of them in this project. 105 | Still, the implementation has some bugs and lacks some other features that make it quite unreliable in real LAN environments when running continously. 106 | Last but not least, the aim for this project is to build a solution that targets standard conformance in the long term with the support of the community. 107 | Though, resiliency should remain a top goal. 108 | -------------------------------------------------------------------------------- /service_test.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | var ( 11 | mdnsName = "test--xxxxxxxxxxxx" 12 | mdnsService = "_test--xxxx._tcp" 13 | mdnsSubtype = "_test--xxxx._tcp,_fancy" 14 | mdnsDomain = "local." 15 | mdnsPort = 8888 16 | ) 17 | 18 | func startMDNS(t *testing.T, port int, name, service, domain string) { 19 | // 5353 is default mdns port 20 | server, err := Register(name, service, domain, port, []string{"txtv=0", "lo=1", "la=2"}, nil) 21 | if err != nil { 22 | t.Fatalf("error while registering mdns service: %s", err) 23 | } 24 | t.Cleanup(server.Shutdown) 25 | log.Printf("Published service: %s, type: %s, domain: %s", name, service, domain) 26 | } 27 | 28 | func TestQuickShutdown(t *testing.T) { 29 | server, err := Register(mdnsName, mdnsService, mdnsDomain, mdnsPort, []string{"txtv=0", "lo=1", "la=2"}, nil) 30 | if err != nil { 31 | t.Fatal(err) 32 | } 33 | 34 | done := make(chan struct{}) 35 | go func() { 36 | defer close(done) 37 | server.Shutdown() 38 | }() 39 | select { 40 | case <-done: 41 | case <-time.After(500 * time.Millisecond): 42 | t.Fatal("shutdown took longer than 500ms") 43 | } 44 | } 45 | 46 | func TestBasic(t *testing.T) { 47 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 48 | defer cancel() 49 | 50 | startMDNS(t, mdnsPort, mdnsName, mdnsService, mdnsDomain) 51 | 52 | time.Sleep(time.Second) 53 | 54 | entries := make(chan *ServiceEntry, 100) 55 | if err := Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 56 | t.Fatalf("Expected browse success, but got %v", err) 57 | } 58 | <-ctx.Done() 59 | 60 | if len(entries) != 1 { 61 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 62 | } 63 | result := <-entries 64 | if result.Domain != mdnsDomain { 65 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 66 | } 67 | if result.Service != mdnsService { 68 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 69 | } 70 | if result.Instance != mdnsName { 71 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 72 | } 73 | if result.Port != mdnsPort { 74 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 75 | } 76 | } 77 | 78 | func TestNoRegister(t *testing.T) { 79 | // before register, mdns resolve shuold not have any entry 80 | entries := make(chan *ServiceEntry) 81 | go func(results <-chan *ServiceEntry) { 82 | s := <-results 83 | if s != nil { 84 | t.Errorf("Expected empty service entries but got %v", *s) 85 | } 86 | }(entries) 87 | 88 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 89 | if err := Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 90 | t.Fatalf("Expected browse success, but got %v", err) 91 | } 92 | <-ctx.Done() 93 | cancel() 94 | } 95 | 96 | func TestSubtype(t *testing.T) { 97 | t.Run("browse with subtype", func(t *testing.T) { 98 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 99 | defer cancel() 100 | 101 | startMDNS(t, mdnsPort, mdnsName, mdnsSubtype, mdnsDomain) 102 | 103 | time.Sleep(time.Second) 104 | 105 | entries := make(chan *ServiceEntry, 100) 106 | if err := Browse(ctx, mdnsSubtype, mdnsDomain, entries); err != nil { 107 | t.Fatalf("Expected browse success, but got %v", err) 108 | } 109 | <-ctx.Done() 110 | 111 | if len(entries) != 1 { 112 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 113 | } 114 | result := <-entries 115 | if result.Domain != mdnsDomain { 116 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 117 | } 118 | if result.Service != mdnsService { 119 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 120 | } 121 | if result.Instance != mdnsName { 122 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 123 | } 124 | if result.Port != mdnsPort { 125 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 126 | } 127 | }) 128 | 129 | t.Run("browse without subtype", func(t *testing.T) { 130 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 131 | defer cancel() 132 | 133 | startMDNS(t, mdnsPort, mdnsName, mdnsSubtype, mdnsDomain) 134 | 135 | time.Sleep(time.Second) 136 | 137 | entries := make(chan *ServiceEntry, 100) 138 | if err := Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 139 | t.Fatalf("Expected browse success, but got %v", err) 140 | } 141 | <-ctx.Done() 142 | 143 | if len(entries) != 1 { 144 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 145 | } 146 | result := <-entries 147 | if result.Domain != mdnsDomain { 148 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 149 | } 150 | if result.Service != mdnsService { 151 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 152 | } 153 | if result.Instance != mdnsName { 154 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 155 | } 156 | if result.Port != mdnsPort { 157 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 158 | } 159 | }) 160 | 161 | t.Run("ttl", func(t *testing.T) { 162 | origTTL := defaultTTL 163 | origCleanupFreq := cleanupFreq 164 | origInitialQueryInterval := initialQueryInterval 165 | t.Cleanup(func() { 166 | defaultTTL = origTTL 167 | cleanupFreq = origCleanupFreq 168 | initialQueryInterval = origInitialQueryInterval 169 | }) 170 | defaultTTL = 1 // 1 second 171 | initialQueryInterval = 100 * time.Millisecond 172 | cleanupFreq = 100 * time.Millisecond 173 | 174 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 175 | defer cancel() 176 | startMDNS(t, mdnsPort, mdnsName, mdnsSubtype, mdnsDomain) 177 | 178 | entries := make(chan *ServiceEntry, 100) 179 | if err := Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 180 | t.Fatalf("Expected browse success, but got %v", err) 181 | } 182 | 183 | <-ctx.Done() 184 | if len(entries) < 2 { 185 | t.Fatalf("Expected to have received at least 2 entries, but got %d", len(entries)) 186 | } 187 | res1 := <-entries 188 | res2 := <-entries 189 | if res1.ServiceInstanceName() != res2.ServiceInstanceName() { 190 | t.Fatalf("expected the two entries to be identical") 191 | } 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net" 9 | "runtime" 10 | "strings" 11 | "time" 12 | 13 | "github.com/miekg/dns" 14 | "golang.org/x/net/ipv4" 15 | "golang.org/x/net/ipv6" 16 | ) 17 | 18 | // IPType specifies the IP traffic the client listens for. 19 | // This does not guarantee that only mDNS entries of this sepcific 20 | // type passes. E.g. typical mDNS packets distributed via IPv4, often contain 21 | // both DNS A and AAAA entries. 22 | type IPType uint8 23 | 24 | // Options for IPType. 25 | const ( 26 | IPv4 IPType = 0x01 27 | IPv6 IPType = 0x02 28 | IPv4AndIPv6 = IPv4 | IPv6 // default option 29 | ) 30 | 31 | var initialQueryInterval = 4 * time.Second 32 | 33 | // Client structure encapsulates both IPv4/IPv6 UDP connections. 34 | type client struct { 35 | ipv4conn *ipv4.PacketConn 36 | ipv6conn *ipv6.PacketConn 37 | ifaces []net.Interface 38 | } 39 | 40 | type clientOpts struct { 41 | listenOn IPType 42 | ifaces []net.Interface 43 | } 44 | 45 | // ClientOption fills the option struct to configure intefaces, etc. 46 | type ClientOption func(*clientOpts) 47 | 48 | // SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this 49 | // instance listens for. 50 | // This does not guarantee that only mDNS entries of this sepcific 51 | // type passes. E.g. typical mDNS packets distributed via IPv4, may contain 52 | // both DNS A and AAAA entries. 53 | func SelectIPTraffic(t IPType) ClientOption { 54 | return func(o *clientOpts) { 55 | o.listenOn = t 56 | } 57 | } 58 | 59 | // SelectIfaces selects the interfaces to query for mDNS records 60 | func SelectIfaces(ifaces []net.Interface) ClientOption { 61 | return func(o *clientOpts) { 62 | o.ifaces = ifaces 63 | } 64 | } 65 | 66 | // Browse for all services of a given type in a given domain. 67 | // Received entries are sent on the entries channel. 68 | // It blocks until the context is canceled (or an error occurs). 69 | func Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry, opts ...ClientOption) error { 70 | cl, err := newClient(applyOpts(opts...)) 71 | if err != nil { 72 | return err 73 | } 74 | params := defaultParams(service) 75 | if domain != "" { 76 | params.Domain = domain 77 | } 78 | params.Entries = entries 79 | params.isBrowsing = true 80 | return cl.run(ctx, params) 81 | } 82 | 83 | // Lookup a specific service by its name and type in a given domain. 84 | // Received entries are sent on the entries channel. 85 | // It blocks until the context is canceled (or an error occurs). 86 | func Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry, opts ...ClientOption) error { 87 | cl, err := newClient(applyOpts(opts...)) 88 | if err != nil { 89 | return err 90 | } 91 | params := defaultParams(service) 92 | params.Instance = instance 93 | if domain != "" { 94 | params.Domain = domain 95 | } 96 | params.Entries = entries 97 | return cl.run(ctx, params) 98 | } 99 | 100 | func applyOpts(options ...ClientOption) clientOpts { 101 | // Apply default configuration and load supplied options. 102 | var conf = clientOpts{ 103 | listenOn: IPv4AndIPv6, 104 | } 105 | for _, o := range options { 106 | if o != nil { 107 | o(&conf) 108 | } 109 | } 110 | return conf 111 | } 112 | 113 | func (c *client) run(ctx context.Context, params *lookupParams) error { 114 | ctx, cancel := context.WithCancel(ctx) 115 | done := make(chan struct{}) 116 | go func() { 117 | defer close(done) 118 | c.mainloop(ctx, params) 119 | }() 120 | 121 | // If previous probe was ok, it should be fine now. In case of an error later on, 122 | // the entries' queue is closed. 123 | err := c.periodicQuery(ctx, params) 124 | cancel() 125 | <-done 126 | return err 127 | } 128 | 129 | // defaultParams returns a default set of QueryParams. 130 | func defaultParams(service string) *lookupParams { 131 | return newLookupParams("", service, "local", false, make(chan *ServiceEntry)) 132 | } 133 | 134 | // Client structure constructor 135 | func newClient(opts clientOpts) (*client, error) { 136 | ifaces := opts.ifaces 137 | if len(ifaces) == 0 { 138 | ifaces = listMulticastInterfaces() 139 | } 140 | // IPv4 interfaces 141 | var ipv4conn *ipv4.PacketConn 142 | if (opts.listenOn & IPv4) > 0 { 143 | var err error 144 | ipv4conn, err = joinUdp4Multicast(ifaces) 145 | if err != nil { 146 | return nil, err 147 | } 148 | } 149 | // IPv6 interfaces 150 | var ipv6conn *ipv6.PacketConn 151 | if (opts.listenOn & IPv6) > 0 { 152 | var err error 153 | ipv6conn, err = joinUdp6Multicast(ifaces) 154 | if err != nil { 155 | return nil, err 156 | } 157 | } 158 | 159 | return &client{ 160 | ipv4conn: ipv4conn, 161 | ipv6conn: ipv6conn, 162 | ifaces: ifaces, 163 | }, nil 164 | } 165 | 166 | var cleanupFreq = 10 * time.Second 167 | 168 | // Start listeners and waits for the shutdown signal from exit channel 169 | func (c *client) mainloop(ctx context.Context, params *lookupParams) { 170 | // start listening for responses 171 | msgCh := make(chan *dns.Msg, 32) 172 | if c.ipv4conn != nil { 173 | go c.recv(ctx, c.ipv4conn, msgCh) 174 | } 175 | if c.ipv6conn != nil { 176 | go c.recv(ctx, c.ipv6conn, msgCh) 177 | } 178 | 179 | // Iterate through channels from listeners goroutines 180 | var entries map[string]*ServiceEntry 181 | sentEntries := make(map[string]*ServiceEntry) 182 | 183 | ticker := time.NewTicker(cleanupFreq) 184 | defer ticker.Stop() 185 | for { 186 | var now time.Time 187 | select { 188 | case <-ctx.Done(): 189 | // Context expired. Notify subscriber that we are done here. 190 | params.done() 191 | c.shutdown() 192 | return 193 | case t := <-ticker.C: 194 | for k, e := range sentEntries { 195 | if t.After(e.Expiry) { 196 | delete(sentEntries, k) 197 | } 198 | } 199 | continue 200 | case msg := <-msgCh: 201 | now = time.Now() 202 | entries = make(map[string]*ServiceEntry) 203 | sections := append(msg.Answer, msg.Ns...) 204 | sections = append(sections, msg.Extra...) 205 | 206 | for _, answer := range sections { 207 | switch rr := answer.(type) { 208 | case *dns.PTR: 209 | if params.ServiceName() != rr.Hdr.Name { 210 | continue 211 | } 212 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr { 213 | continue 214 | } 215 | if _, ok := entries[rr.Ptr]; !ok { 216 | entries[rr.Ptr] = newServiceEntry( 217 | trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)), 218 | params.Service, 219 | params.Domain) 220 | } 221 | entries[rr.Ptr].Expiry = now.Add(time.Duration(rr.Hdr.Ttl) * time.Second) 222 | case *dns.SRV: 223 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { 224 | continue 225 | } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { 226 | continue 227 | } 228 | if _, ok := entries[rr.Hdr.Name]; !ok { 229 | entries[rr.Hdr.Name] = newServiceEntry( 230 | trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), 231 | params.Service, 232 | params.Domain) 233 | } 234 | entries[rr.Hdr.Name].HostName = rr.Target 235 | entries[rr.Hdr.Name].Port = int(rr.Port) 236 | entries[rr.Hdr.Name].Expiry = now.Add(time.Duration(rr.Hdr.Ttl) * time.Second) 237 | case *dns.TXT: 238 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { 239 | continue 240 | } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { 241 | continue 242 | } 243 | if _, ok := entries[rr.Hdr.Name]; !ok { 244 | entries[rr.Hdr.Name] = newServiceEntry( 245 | trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), 246 | params.Service, 247 | params.Domain) 248 | } 249 | entries[rr.Hdr.Name].Text = rr.Txt 250 | entries[rr.Hdr.Name].Expiry = now.Add(time.Duration(rr.Hdr.Ttl) * time.Second) 251 | } 252 | } 253 | // Associate IPs in a second round as other fields should be filled by now. 254 | for _, answer := range sections { 255 | switch rr := answer.(type) { 256 | case *dns.A: 257 | for k, e := range entries { 258 | if e.HostName == rr.Hdr.Name { 259 | entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A) 260 | } 261 | } 262 | case *dns.AAAA: 263 | for k, e := range entries { 264 | if e.HostName == rr.Hdr.Name { 265 | entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA) 266 | } 267 | } 268 | } 269 | } 270 | } 271 | 272 | if len(entries) > 0 { 273 | for k, e := range entries { 274 | if !e.Expiry.After(now) { 275 | delete(entries, k) 276 | delete(sentEntries, k) 277 | continue 278 | } 279 | if _, ok := sentEntries[k]; ok { 280 | continue 281 | } 282 | 283 | // If this is an DNS-SD query do not throw PTR away. 284 | // It is expected to have only PTR for enumeration 285 | if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() { 286 | // Require at least one resolved IP address for ServiceEntry 287 | // TODO: wait some more time as chances are high both will arrive. 288 | if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 { 289 | continue 290 | } 291 | } 292 | // Submit entry to subscriber and cache it. 293 | // This is also a point to possibly stop probing actively for a 294 | // service entry. 295 | params.Entries <- e 296 | sentEntries[k] = e 297 | if !params.isBrowsing { 298 | params.disableProbing() 299 | } 300 | } 301 | } 302 | } 303 | } 304 | 305 | // Shutdown client will close currently open connections and channel implicitly. 306 | func (c *client) shutdown() { 307 | if c.ipv4conn != nil { 308 | c.ipv4conn.Close() 309 | } 310 | if c.ipv6conn != nil { 311 | c.ipv6conn.Close() 312 | } 313 | } 314 | 315 | // Data receiving routine reads from connection, unpacks packets into dns.Msg 316 | // structures and sends them to a given msgCh channel 317 | func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) { 318 | var readFrom func([]byte) (n int, src net.Addr, err error) 319 | 320 | switch pConn := l.(type) { 321 | case *ipv6.PacketConn: 322 | readFrom = func(b []byte) (n int, src net.Addr, err error) { 323 | n, _, src, err = pConn.ReadFrom(b) 324 | return 325 | } 326 | case *ipv4.PacketConn: 327 | readFrom = func(b []byte) (n int, src net.Addr, err error) { 328 | n, _, src, err = pConn.ReadFrom(b) 329 | return 330 | } 331 | 332 | default: 333 | return 334 | } 335 | 336 | buf := make([]byte, 65536) 337 | var fatalErr error 338 | for { 339 | // Handles the following cases: 340 | // - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel 341 | // - ReadFrom aborts otherwise. 342 | // TODO: the context check can be removed. Verify! 343 | if ctx.Err() != nil || fatalErr != nil { 344 | return 345 | } 346 | 347 | n, _, err := readFrom(buf) 348 | if err != nil { 349 | fatalErr = err 350 | continue 351 | } 352 | msg := new(dns.Msg) 353 | if err := msg.Unpack(buf[:n]); err != nil { 354 | // log.Printf("[WARN] mdns: Failed to unpack packet: %v", err) 355 | continue 356 | } 357 | select { 358 | case msgCh <- msg: 359 | // Submit decoded DNS message and continue. 360 | case <-ctx.Done(): 361 | // Abort. 362 | return 363 | } 364 | } 365 | } 366 | 367 | // periodicQuery sens multiple probes until a valid response is received by 368 | // the main processing loop or some timeout/cancel fires. 369 | // TODO: move error reporting to shutdown function as periodicQuery is called from 370 | // go routine context. 371 | func (c *client) periodicQuery(ctx context.Context, params *lookupParams) error { 372 | // Do the first query immediately. 373 | if err := c.query(params); err != nil { 374 | return err 375 | } 376 | 377 | const maxInterval = 60 * time.Second 378 | interval := initialQueryInterval 379 | timer := time.NewTimer(interval) 380 | defer timer.Stop() 381 | for { 382 | select { 383 | case <-timer.C: 384 | // Wait for next iteration. 385 | case <-params.stopProbing: 386 | // Chan is closed (or happened in the past). 387 | // Done here. Received a matching mDNS entry. 388 | return nil 389 | case <-ctx.Done(): 390 | if params.isBrowsing { 391 | return nil 392 | } 393 | return ctx.Err() 394 | } 395 | 396 | if err := c.query(params); err != nil { 397 | return err 398 | } 399 | // Exponential increase of the interval with jitter: 400 | // the new interval will be between 1.5x and 2.5x the old interval, capped at maxInterval. 401 | if interval != maxInterval { 402 | interval += time.Duration(rand.Int63n(interval.Nanoseconds())) + interval/2 403 | if interval > maxInterval { 404 | interval = maxInterval 405 | } 406 | } 407 | timer.Reset(interval) 408 | } 409 | } 410 | 411 | // Performs the actual query by service name (browse) or service instance name (lookup), 412 | // start response listeners goroutines and loops over the entries channel. 413 | func (c *client) query(params *lookupParams) error { 414 | var serviceName, serviceInstanceName string 415 | serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain)) 416 | 417 | // send the query 418 | m := new(dns.Msg) 419 | if params.Instance != "" { // service instance name lookup 420 | serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName) 421 | m.Question = []dns.Question{ 422 | {Name: serviceInstanceName, Qtype: dns.TypeSRV, Qclass: dns.ClassINET}, 423 | {Name: serviceInstanceName, Qtype: dns.TypeTXT, Qclass: dns.ClassINET}, 424 | } 425 | } else if len(params.Subtypes) > 0 { // service subtype browse 426 | m.SetQuestion(params.Subtypes[0], dns.TypePTR) 427 | } else { // service name browse 428 | m.SetQuestion(serviceName, dns.TypePTR) 429 | } 430 | m.RecursionDesired = false 431 | return c.sendQuery(m) 432 | } 433 | 434 | // Pack the dns.Msg and write to available connections (multicast) 435 | func (c *client) sendQuery(msg *dns.Msg) error { 436 | buf, err := msg.Pack() 437 | if err != nil { 438 | return err 439 | } 440 | if c.ipv4conn != nil { 441 | // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG 442 | // As of Golang 1.18.4 443 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 444 | var wcm ipv4.ControlMessage 445 | for ifi := range c.ifaces { 446 | switch runtime.GOOS { 447 | case "darwin", "ios", "linux": 448 | wcm.IfIndex = c.ifaces[ifi].Index 449 | default: 450 | if err := c.ipv4conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { 451 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 452 | } 453 | } 454 | c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 455 | } 456 | } 457 | if c.ipv6conn != nil { 458 | // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG 459 | // As of Golang 1.18.4 460 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 461 | var wcm ipv6.ControlMessage 462 | for ifi := range c.ifaces { 463 | switch runtime.GOOS { 464 | case "darwin", "ios", "linux": 465 | wcm.IfIndex = c.ifaces[ifi].Index 466 | default: 467 | if err := c.ipv6conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { 468 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 469 | } 470 | } 471 | c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 472 | } 473 | } 474 | return nil 475 | } 476 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "math/rand" 7 | "net" 8 | "os" 9 | "runtime" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/miekg/dns" 15 | "golang.org/x/net/ipv4" 16 | "golang.org/x/net/ipv6" 17 | ) 18 | 19 | const ( 20 | // Number of Multicast responses sent for a query message (default: 1 < x < 9) 21 | multicastRepetitions = 2 22 | ) 23 | 24 | var defaultTTL uint32 = 3200 25 | 26 | type serverOpts struct { 27 | ttl uint32 28 | } 29 | 30 | func applyServerOpts(options ...ServerOption) serverOpts { 31 | // Apply default configuration and load supplied options. 32 | var conf = serverOpts{ 33 | ttl: defaultTTL, 34 | } 35 | for _, o := range options { 36 | if o != nil { 37 | o(&conf) 38 | } 39 | } 40 | return conf 41 | } 42 | 43 | // ServerOption fills the option struct. 44 | type ServerOption func(*serverOpts) 45 | 46 | // TTL sets the TTL for DNS replies. 47 | func TTL(ttl uint32) ServerOption { 48 | return func(o *serverOpts) { 49 | o.ttl = ttl 50 | } 51 | } 52 | 53 | // Register a service by given arguments. This call will take the system's hostname 54 | // and lookup IP by that hostname. 55 | func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface, opts ...ServerOption) (*Server, error) { 56 | entry := newServiceEntry(instance, service, domain) 57 | entry.Port = port 58 | entry.Text = text 59 | 60 | if entry.Instance == "" { 61 | return nil, fmt.Errorf("missing service instance name") 62 | } 63 | if entry.Service == "" { 64 | return nil, fmt.Errorf("missing service name") 65 | } 66 | if entry.Domain == "" { 67 | entry.Domain = "local." 68 | } 69 | if entry.Port == 0 { 70 | return nil, fmt.Errorf("missing port") 71 | } 72 | 73 | var err error 74 | if entry.HostName == "" { 75 | entry.HostName, err = os.Hostname() 76 | if err != nil { 77 | return nil, fmt.Errorf("could not determine host") 78 | } 79 | } 80 | 81 | if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { 82 | entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) 83 | } 84 | 85 | if len(ifaces) == 0 { 86 | ifaces = listMulticastInterfaces() 87 | } 88 | 89 | for _, iface := range ifaces { 90 | v4, v6 := addrsForInterface(&iface) 91 | entry.AddrIPv4 = append(entry.AddrIPv4, v4...) 92 | entry.AddrIPv6 = append(entry.AddrIPv6, v6...) 93 | } 94 | 95 | if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil { 96 | return nil, fmt.Errorf("could not determine host IP addresses") 97 | } 98 | 99 | s, err := newServer(ifaces, applyServerOpts(opts...)) 100 | if err != nil { 101 | return nil, err 102 | } 103 | 104 | s.service = entry 105 | s.start() 106 | 107 | return s, nil 108 | } 109 | 110 | // RegisterProxy registers a service proxy. This call will skip the hostname/IP lookup and 111 | // will use the provided values. 112 | func RegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface, opts ...ServerOption) (*Server, error) { 113 | entry := newServiceEntry(instance, service, domain) 114 | entry.Port = port 115 | entry.Text = text 116 | entry.HostName = host 117 | 118 | if entry.Instance == "" { 119 | return nil, fmt.Errorf("missing service instance name") 120 | } 121 | if entry.Service == "" { 122 | return nil, fmt.Errorf("missing service name") 123 | } 124 | if entry.HostName == "" { 125 | return nil, fmt.Errorf("missing host name") 126 | } 127 | if entry.Domain == "" { 128 | entry.Domain = "local" 129 | } 130 | if entry.Port == 0 { 131 | return nil, fmt.Errorf("missing port") 132 | } 133 | 134 | if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { 135 | entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) 136 | } 137 | 138 | for _, ip := range ips { 139 | ipAddr := net.ParseIP(ip) 140 | if ipAddr == nil { 141 | return nil, fmt.Errorf("failed to parse given IP: %v", ip) 142 | } else if ipv4 := ipAddr.To4(); ipv4 != nil { 143 | entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr) 144 | } else if ipv6 := ipAddr.To16(); ipv6 != nil { 145 | entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr) 146 | } else { 147 | return nil, fmt.Errorf("the IP is neither IPv4 nor IPv6: %#v", ipAddr) 148 | } 149 | } 150 | 151 | if len(ifaces) == 0 { 152 | ifaces = listMulticastInterfaces() 153 | } 154 | 155 | s, err := newServer(ifaces, applyServerOpts(opts...)) 156 | if err != nil { 157 | return nil, err 158 | } 159 | 160 | s.service = entry 161 | s.start() 162 | 163 | return s, nil 164 | } 165 | 166 | const ( 167 | qClassCacheFlush uint16 = 1 << 15 168 | ) 169 | 170 | // Server structure encapsulates both IPv4/IPv6 UDP connections 171 | type Server struct { 172 | service *ServiceEntry 173 | ipv4conn *ipv4.PacketConn 174 | ipv6conn *ipv6.PacketConn 175 | ifaces []net.Interface 176 | 177 | shouldShutdown chan struct{} 178 | shutdownLock sync.Mutex 179 | refCount sync.WaitGroup 180 | isShutdown bool 181 | ttl uint32 182 | } 183 | 184 | // Constructs server structure 185 | func newServer(ifaces []net.Interface, opts serverOpts) (*Server, error) { 186 | ipv4conn, err4 := joinUdp4Multicast(ifaces) 187 | if err4 != nil { 188 | log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error()) 189 | } 190 | ipv6conn, err6 := joinUdp6Multicast(ifaces) 191 | if err6 != nil { 192 | log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error()) 193 | } 194 | if err4 != nil && err6 != nil { 195 | // No supported interface left. 196 | return nil, fmt.Errorf("no supported interface") 197 | } 198 | 199 | s := &Server{ 200 | ipv4conn: ipv4conn, 201 | ipv6conn: ipv6conn, 202 | ifaces: ifaces, 203 | ttl: opts.ttl, 204 | shouldShutdown: make(chan struct{}), 205 | } 206 | 207 | return s, nil 208 | } 209 | 210 | func (s *Server) start() { 211 | if s.ipv4conn != nil { 212 | s.refCount.Add(1) 213 | go s.recv4(s.ipv4conn) 214 | } 215 | if s.ipv6conn != nil { 216 | s.refCount.Add(1) 217 | go s.recv6(s.ipv6conn) 218 | } 219 | s.refCount.Add(1) 220 | go s.probe() 221 | } 222 | 223 | // SetText updates and announces the TXT records 224 | func (s *Server) SetText(text []string) { 225 | s.service.Text = text 226 | s.announceText() 227 | } 228 | 229 | // TTL sets the TTL for DNS replies 230 | // 231 | // Deprecated: This method is racy. Use the TTL server option instead. 232 | func (s *Server) TTL(ttl uint32) { 233 | s.ttl = ttl 234 | } 235 | 236 | // Shutdown closes all udp connections and unregisters the service 237 | func (s *Server) Shutdown() { 238 | s.shutdownLock.Lock() 239 | defer s.shutdownLock.Unlock() 240 | if s.isShutdown { 241 | return 242 | } 243 | 244 | if err := s.unregister(); err != nil { 245 | log.Printf("failed to unregister: %s", err) 246 | } 247 | 248 | close(s.shouldShutdown) 249 | 250 | if s.ipv4conn != nil { 251 | s.ipv4conn.Close() 252 | } 253 | if s.ipv6conn != nil { 254 | s.ipv6conn.Close() 255 | } 256 | 257 | // Wait for connection and routines to be closed 258 | s.refCount.Wait() 259 | s.isShutdown = true 260 | } 261 | 262 | // recv4 is a long running routine to receive packets from an interface 263 | func (s *Server) recv4(c *ipv4.PacketConn) { 264 | defer s.refCount.Done() 265 | if c == nil { 266 | return 267 | } 268 | buf := make([]byte, 65536) 269 | for { 270 | select { 271 | case <-s.shouldShutdown: 272 | return 273 | default: 274 | var ifIndex int 275 | n, cm, from, err := c.ReadFrom(buf) 276 | if err != nil { 277 | continue 278 | } 279 | if cm != nil { 280 | ifIndex = cm.IfIndex 281 | } 282 | _ = s.parsePacket(buf[:n], ifIndex, from) 283 | } 284 | } 285 | } 286 | 287 | // recv6 is a long running routine to receive packets from an interface 288 | func (s *Server) recv6(c *ipv6.PacketConn) { 289 | defer s.refCount.Done() 290 | if c == nil { 291 | return 292 | } 293 | buf := make([]byte, 65536) 294 | for { 295 | select { 296 | case <-s.shouldShutdown: 297 | return 298 | default: 299 | var ifIndex int 300 | n, cm, from, err := c.ReadFrom(buf) 301 | if err != nil { 302 | continue 303 | } 304 | if cm != nil { 305 | ifIndex = cm.IfIndex 306 | } 307 | _ = s.parsePacket(buf[:n], ifIndex, from) 308 | } 309 | } 310 | } 311 | 312 | // parsePacket is used to parse an incoming packet 313 | func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error { 314 | var msg dns.Msg 315 | if err := msg.Unpack(packet); err != nil { 316 | // log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err) 317 | return err 318 | } 319 | return s.handleQuery(&msg, ifIndex, from) 320 | } 321 | 322 | // handleQuery is used to handle an incoming query 323 | func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error { 324 | // Ignore questions with authoritative section for now 325 | if len(query.Ns) > 0 { 326 | return nil 327 | } 328 | 329 | // Handle each question 330 | var err error 331 | for _, q := range query.Question { 332 | resp := dns.Msg{} 333 | resp.SetReply(query) 334 | resp.Compress = true 335 | resp.RecursionDesired = false 336 | resp.Authoritative = true 337 | resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions" 338 | resp.Answer = []dns.RR{} 339 | resp.Extra = []dns.RR{} 340 | if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil { 341 | // log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err) 342 | continue 343 | } 344 | // Check if there is an answer 345 | if len(resp.Answer) == 0 { 346 | continue 347 | } 348 | 349 | if isUnicastQuestion(q) { 350 | // Send unicast 351 | if e := s.unicastResponse(&resp, ifIndex, from); e != nil { 352 | err = e 353 | } 354 | } else { 355 | // Send mulicast 356 | if e := s.multicastResponse(&resp, ifIndex); e != nil { 357 | err = e 358 | } 359 | } 360 | } 361 | 362 | return err 363 | } 364 | 365 | // RFC6762 7.1. Known-Answer Suppression 366 | func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool { 367 | if len(resp.Answer) == 0 || len(query.Answer) == 0 { 368 | return false 369 | } 370 | 371 | if resp.Answer[0].Header().Rrtype != dns.TypePTR { 372 | return false 373 | } 374 | answer := resp.Answer[0].(*dns.PTR) 375 | 376 | for _, known := range query.Answer { 377 | hdr := known.Header() 378 | if hdr.Rrtype != answer.Hdr.Rrtype { 379 | continue 380 | } 381 | ptr := known.(*dns.PTR) 382 | if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 { 383 | // log.Printf("skipping known answer: %v", ptr) 384 | return true 385 | } 386 | } 387 | 388 | return false 389 | } 390 | 391 | // handleQuestion is used to handle an incoming question 392 | func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error { 393 | if s.service == nil { 394 | return nil 395 | } 396 | 397 | switch q.Name { 398 | case s.service.ServiceTypeName(): 399 | s.serviceTypeName(resp, s.ttl) 400 | if isKnownAnswer(resp, query) { 401 | resp.Answer = nil 402 | } 403 | 404 | case s.service.ServiceName(): 405 | s.composeBrowsingAnswers(resp, ifIndex) 406 | if isKnownAnswer(resp, query) { 407 | resp.Answer = nil 408 | } 409 | 410 | case s.service.ServiceInstanceName(): 411 | s.composeLookupAnswers(resp, s.ttl, ifIndex, false) 412 | default: 413 | // handle matching subtype query 414 | for _, subtype := range s.service.Subtypes { 415 | subtype = fmt.Sprintf("%s._sub.%s", subtype, s.service.ServiceName()) 416 | if q.Name == subtype { 417 | s.composeBrowsingAnswers(resp, ifIndex) 418 | if isKnownAnswer(resp, query) { 419 | resp.Answer = nil 420 | } 421 | break 422 | } 423 | } 424 | } 425 | 426 | return nil 427 | } 428 | 429 | func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) { 430 | ptr := &dns.PTR{ 431 | Hdr: dns.RR_Header{ 432 | Name: s.service.ServiceName(), 433 | Rrtype: dns.TypePTR, 434 | Class: dns.ClassINET, 435 | Ttl: s.ttl, 436 | }, 437 | Ptr: s.service.ServiceInstanceName(), 438 | } 439 | resp.Answer = append(resp.Answer, ptr) 440 | 441 | txt := &dns.TXT{ 442 | Hdr: dns.RR_Header{ 443 | Name: s.service.ServiceInstanceName(), 444 | Rrtype: dns.TypeTXT, 445 | Class: dns.ClassINET, 446 | Ttl: s.ttl, 447 | }, 448 | Txt: s.service.Text, 449 | } 450 | srv := &dns.SRV{ 451 | Hdr: dns.RR_Header{ 452 | Name: s.service.ServiceInstanceName(), 453 | Rrtype: dns.TypeSRV, 454 | Class: dns.ClassINET, 455 | Ttl: s.ttl, 456 | }, 457 | Priority: 0, 458 | Weight: 0, 459 | Port: uint16(s.service.Port), 460 | Target: s.service.HostName, 461 | } 462 | resp.Extra = append(resp.Extra, srv, txt) 463 | 464 | resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false) 465 | } 466 | 467 | func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) { 468 | // From RFC6762 469 | // The most significant bit of the rrclass for a record in the Answer 470 | // Section of a response message is the Multicast DNS cache-flush bit 471 | // and is discussed in more detail below in Section 10.2, "Announcements 472 | // to Flush Outdated Cache Entries". 473 | ptr := &dns.PTR{ 474 | Hdr: dns.RR_Header{ 475 | Name: s.service.ServiceName(), 476 | Rrtype: dns.TypePTR, 477 | Class: dns.ClassINET, 478 | Ttl: ttl, 479 | }, 480 | Ptr: s.service.ServiceInstanceName(), 481 | } 482 | srv := &dns.SRV{ 483 | Hdr: dns.RR_Header{ 484 | Name: s.service.ServiceInstanceName(), 485 | Rrtype: dns.TypeSRV, 486 | Class: dns.ClassINET | qClassCacheFlush, 487 | Ttl: ttl, 488 | }, 489 | Priority: 0, 490 | Weight: 0, 491 | Port: uint16(s.service.Port), 492 | Target: s.service.HostName, 493 | } 494 | txt := &dns.TXT{ 495 | Hdr: dns.RR_Header{ 496 | Name: s.service.ServiceInstanceName(), 497 | Rrtype: dns.TypeTXT, 498 | Class: dns.ClassINET | qClassCacheFlush, 499 | Ttl: ttl, 500 | }, 501 | Txt: s.service.Text, 502 | } 503 | dnssd := &dns.PTR{ 504 | Hdr: dns.RR_Header{ 505 | Name: s.service.ServiceTypeName(), 506 | Rrtype: dns.TypePTR, 507 | Class: dns.ClassINET, 508 | Ttl: ttl, 509 | }, 510 | Ptr: s.service.ServiceName(), 511 | } 512 | resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd) 513 | 514 | for _, subtype := range s.service.Subtypes { 515 | resp.Answer = append(resp.Answer, 516 | &dns.PTR{ 517 | Hdr: dns.RR_Header{ 518 | Name: subtype, 519 | Rrtype: dns.TypePTR, 520 | Class: dns.ClassINET, 521 | Ttl: ttl, 522 | }, 523 | Ptr: s.service.ServiceInstanceName(), 524 | }) 525 | } 526 | 527 | resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache) 528 | } 529 | 530 | func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) { 531 | // From RFC6762 532 | // 9. Service Type Enumeration 533 | // 534 | // For this purpose, a special meta-query is defined. A DNS query for 535 | // PTR records with the name "_services._dns-sd._udp." yields a 536 | // set of PTR records, where the rdata of each PTR record is the two- 537 | // label name, plus the same domain, e.g., 538 | // "_http._tcp.". 539 | dnssd := &dns.PTR{ 540 | Hdr: dns.RR_Header{ 541 | Name: s.service.ServiceTypeName(), 542 | Rrtype: dns.TypePTR, 543 | Class: dns.ClassINET, 544 | Ttl: ttl, 545 | }, 546 | Ptr: s.service.ServiceName(), 547 | } 548 | resp.Answer = append(resp.Answer, dnssd) 549 | } 550 | 551 | // Perform probing & announcement 552 | // TODO: implement a proper probing & conflict resolution 553 | func (s *Server) probe() { 554 | defer s.refCount.Done() 555 | 556 | q := new(dns.Msg) 557 | q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR) 558 | q.RecursionDesired = false 559 | 560 | srv := &dns.SRV{ 561 | Hdr: dns.RR_Header{ 562 | Name: s.service.ServiceInstanceName(), 563 | Rrtype: dns.TypeSRV, 564 | Class: dns.ClassINET, 565 | Ttl: s.ttl, 566 | }, 567 | Priority: 0, 568 | Weight: 0, 569 | Port: uint16(s.service.Port), 570 | Target: s.service.HostName, 571 | } 572 | txt := &dns.TXT{ 573 | Hdr: dns.RR_Header{ 574 | Name: s.service.ServiceInstanceName(), 575 | Rrtype: dns.TypeTXT, 576 | Class: dns.ClassINET, 577 | Ttl: s.ttl, 578 | }, 579 | Txt: s.service.Text, 580 | } 581 | q.Ns = []dns.RR{srv, txt} 582 | 583 | // Wait for a random duration uniformly distributed between 0 and 250 ms 584 | // before sending the first probe packet. 585 | timer := time.NewTimer(time.Duration(rand.Intn(250)) * time.Millisecond) 586 | defer timer.Stop() 587 | select { 588 | case <-timer.C: 589 | case <-s.shouldShutdown: 590 | return 591 | } 592 | for i := 0; i < 3; i++ { 593 | if err := s.multicastResponse(q, 0); err != nil { 594 | log.Println("[ERR] zeroconf: failed to send probe:", err.Error()) 595 | } 596 | timer.Reset(250 * time.Millisecond) 597 | select { 598 | case <-timer.C: 599 | case <-s.shouldShutdown: 600 | return 601 | } 602 | } 603 | 604 | // From RFC6762 605 | // The Multicast DNS responder MUST send at least two unsolicited 606 | // responses, one second apart. To provide increased robustness against 607 | // packet loss, a responder MAY send up to eight unsolicited responses, 608 | // provided that the interval between unsolicited responses increases by 609 | // at least a factor of two with every response sent. 610 | timeout := time.Second 611 | for i := 0; i < multicastRepetitions; i++ { 612 | for _, intf := range s.ifaces { 613 | resp := new(dns.Msg) 614 | resp.MsgHdr.Response = true 615 | // TODO: make response authoritative if we are the publisher 616 | resp.Compress = true 617 | resp.Answer = []dns.RR{} 618 | resp.Extra = []dns.RR{} 619 | s.composeLookupAnswers(resp, s.ttl, intf.Index, true) 620 | if err := s.multicastResponse(resp, intf.Index); err != nil { 621 | log.Println("[ERR] zeroconf: failed to send announcement:", err.Error()) 622 | } 623 | } 624 | timer.Reset(timeout) 625 | select { 626 | case <-timer.C: 627 | case <-s.shouldShutdown: 628 | return 629 | } 630 | timeout *= 2 631 | } 632 | } 633 | 634 | // announceText sends a Text announcement with cache flush enabled 635 | func (s *Server) announceText() { 636 | resp := new(dns.Msg) 637 | resp.MsgHdr.Response = true 638 | 639 | txt := &dns.TXT{ 640 | Hdr: dns.RR_Header{ 641 | Name: s.service.ServiceInstanceName(), 642 | Rrtype: dns.TypeTXT, 643 | Class: dns.ClassINET | qClassCacheFlush, 644 | Ttl: s.ttl, 645 | }, 646 | Txt: s.service.Text, 647 | } 648 | 649 | resp.Answer = []dns.RR{txt} 650 | s.multicastResponse(resp, 0) 651 | } 652 | 653 | func (s *Server) unregister() error { 654 | resp := new(dns.Msg) 655 | resp.MsgHdr.Response = true 656 | resp.Answer = []dns.RR{} 657 | resp.Extra = []dns.RR{} 658 | s.composeLookupAnswers(resp, 0, 0, true) 659 | return s.multicastResponse(resp, 0) 660 | } 661 | 662 | func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR { 663 | v4 := s.service.AddrIPv4 664 | v6 := s.service.AddrIPv6 665 | if len(v4) == 0 && len(v6) == 0 { 666 | iface, _ := net.InterfaceByIndex(ifIndex) 667 | if iface != nil { 668 | a4, a6 := addrsForInterface(iface) 669 | v4 = append(v4, a4...) 670 | v6 = append(v6, a6...) 671 | } 672 | } 673 | if ttl > 0 { 674 | // RFC6762 Section 10 says A/AAAA records SHOULD 675 | // use TTL of 120s, to account for network interface 676 | // and IP address changes. 677 | ttl = 120 678 | } 679 | var cacheFlushBit uint16 680 | if flushCache { 681 | cacheFlushBit = qClassCacheFlush 682 | } 683 | for _, ipv4 := range v4 { 684 | a := &dns.A{ 685 | Hdr: dns.RR_Header{ 686 | Name: s.service.HostName, 687 | Rrtype: dns.TypeA, 688 | Class: dns.ClassINET | cacheFlushBit, 689 | Ttl: ttl, 690 | }, 691 | A: ipv4, 692 | } 693 | list = append(list, a) 694 | } 695 | for _, ipv6 := range v6 { 696 | aaaa := &dns.AAAA{ 697 | Hdr: dns.RR_Header{ 698 | Name: s.service.HostName, 699 | Rrtype: dns.TypeAAAA, 700 | Class: dns.ClassINET | cacheFlushBit, 701 | Ttl: ttl, 702 | }, 703 | AAAA: ipv6, 704 | } 705 | list = append(list, aaaa) 706 | } 707 | return list 708 | } 709 | 710 | func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) { 711 | var v4, v6, v6local []net.IP 712 | addrs, _ := iface.Addrs() 713 | for _, address := range addrs { 714 | if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 715 | if ipnet.IP.To4() != nil { 716 | v4 = append(v4, ipnet.IP) 717 | } else { 718 | switch ip := ipnet.IP.To16(); ip != nil { 719 | case ip.IsGlobalUnicast(): 720 | v6 = append(v6, ipnet.IP) 721 | case ip.IsLinkLocalUnicast(): 722 | v6local = append(v6local, ipnet.IP) 723 | } 724 | } 725 | } 726 | } 727 | if len(v6) == 0 { 728 | v6 = v6local 729 | } 730 | return v4, v6 731 | } 732 | 733 | // unicastResponse is used to send a unicast response packet 734 | func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error { 735 | buf, err := resp.Pack() 736 | if err != nil { 737 | return err 738 | } 739 | addr := from.(*net.UDPAddr) 740 | if addr.IP.To4() != nil { 741 | if ifIndex != 0 { 742 | var wcm ipv4.ControlMessage 743 | wcm.IfIndex = ifIndex 744 | _, err = s.ipv4conn.WriteTo(buf, &wcm, addr) 745 | } else { 746 | _, err = s.ipv4conn.WriteTo(buf, nil, addr) 747 | } 748 | return err 749 | } else { 750 | if ifIndex != 0 { 751 | var wcm ipv6.ControlMessage 752 | wcm.IfIndex = ifIndex 753 | _, err = s.ipv6conn.WriteTo(buf, &wcm, addr) 754 | } else { 755 | _, err = s.ipv6conn.WriteTo(buf, nil, addr) 756 | } 757 | return err 758 | } 759 | } 760 | 761 | // multicastResponse is used to send a multicast response packet 762 | func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error { 763 | buf, err := msg.Pack() 764 | if err != nil { 765 | return fmt.Errorf("failed to pack msg %v: %w", msg, err) 766 | } 767 | if s.ipv4conn != nil { 768 | // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG 769 | // As of Golang 1.18.4 770 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 771 | var wcm ipv4.ControlMessage 772 | if ifIndex != 0 { 773 | switch runtime.GOOS { 774 | case "darwin", "ios", "linux": 775 | wcm.IfIndex = ifIndex 776 | default: 777 | iface, _ := net.InterfaceByIndex(ifIndex) 778 | if err := s.ipv4conn.SetMulticastInterface(iface); err != nil { 779 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 780 | } 781 | } 782 | s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 783 | } else { 784 | for _, intf := range s.ifaces { 785 | switch runtime.GOOS { 786 | case "darwin", "ios", "linux": 787 | wcm.IfIndex = intf.Index 788 | default: 789 | if err := s.ipv4conn.SetMulticastInterface(&intf); err != nil { 790 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 791 | } 792 | } 793 | s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 794 | } 795 | } 796 | } 797 | 798 | if s.ipv6conn != nil { 799 | // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG 800 | // As of Golang 1.18.4 801 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 802 | var wcm ipv6.ControlMessage 803 | if ifIndex != 0 { 804 | switch runtime.GOOS { 805 | case "darwin", "ios", "linux": 806 | wcm.IfIndex = ifIndex 807 | default: 808 | iface, _ := net.InterfaceByIndex(ifIndex) 809 | if err := s.ipv6conn.SetMulticastInterface(iface); err != nil { 810 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 811 | } 812 | } 813 | s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 814 | } else { 815 | for _, intf := range s.ifaces { 816 | switch runtime.GOOS { 817 | case "darwin", "ios", "linux": 818 | wcm.IfIndex = intf.Index 819 | default: 820 | if err := s.ipv6conn.SetMulticastInterface(&intf); err != nil { 821 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 822 | } 823 | } 824 | s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 825 | } 826 | } 827 | } 828 | return nil 829 | } 830 | 831 | func isUnicastQuestion(q dns.Question) bool { 832 | // From RFC6762 833 | // 18.12. Repurposing of Top Bit of qclass in Question Section 834 | // 835 | // In the Question Section of a Multicast DNS query, the top bit of the 836 | // qclass field is used to indicate that unicast responses are preferred 837 | // for this particular question. (See Section 5.4.) 838 | return q.Qclass&qClassCacheFlush != 0 839 | } 840 | --------------------------------------------------------------------------------