├── .github └── workflows │ ├── go-check.yml │ └── go-test.yml ├── .gitignore ├── LICENSE ├── README.md ├── client.go ├── connection.go ├── doc.go ├── examples ├── proxyservice │ ├── .gitignore │ └── server.go ├── register │ ├── .gitignore │ └── server.go └── resolv │ ├── .gitignore │ ├── README.md │ └── client.go ├── go.mod ├── go.sum ├── server.go ├── service.go ├── service_test.go └── utils.go /.github/workflows/go-check.yml: -------------------------------------------------------------------------------- 1 | name: linting 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | unit: 11 | runs-on: ubuntu-latest 12 | name: All 13 | strategy: 14 | fail-fast: false 15 | steps: 16 | - uses: actions/checkout@v2 17 | with: 18 | submodules: recursive 19 | - uses: actions/setup-go@v2 20 | with: 21 | go-version: "1.16.x" 22 | - name: Install staticcheck 23 | run: go install honnef.co/go/tools/cmd/staticcheck@434f5f3816b358fe468fa83dcba62d794e7fe04b # 2021.1 (v0.2.0) 24 | - name: Check that go.mod is tidy 25 | uses: protocol/multiple-go-modules@v1.0 26 | with: 27 | run: | 28 | go mod tidy 29 | if [[ -n $(git ls-files --other --exclude-standard --directory -- go.sum) ]]; then 30 | echo "go.sum was added by go mod tidy" 31 | exit 1 32 | fi 33 | git diff --exit-code -- go.sum go.mod 34 | - name: gofmt 35 | run: | 36 | out=$(gofmt -s -l .) 37 | if [[ -n "$out" ]]; then 38 | echo $out | awk '{print "::error file=" $0 ",line=0,col=0::File is not gofmt-ed."}' 39 | exit 1 40 | fi 41 | - name: go vet 42 | uses: protocol/multiple-go-modules@v1.0 43 | with: 44 | run: go vet ./... 45 | - name: staticcheck 46 | uses: protocol/multiple-go-modules@v1.0 47 | with: 48 | run: | 49 | set -o pipefail 50 | staticcheck ./... | sed -e 's@\(.*\)\.go@./\1.go@g' 51 | -------------------------------------------------------------------------------- /.github/workflows/go-test.yml: -------------------------------------------------------------------------------- 1 | name: unittest 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - master 8 | 9 | jobs: 10 | unit: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ "ubuntu", "windows", "macos" ] 15 | go: [ "1.15.x", "1.16.x" ] 16 | runs-on: ${{ matrix.os }}-latest 17 | name: Unit tests (${{ matrix.os}}, Go ${{ matrix.go }}) 18 | steps: 19 | - uses: actions/checkout@v2 20 | - uses: actions/setup-go@v2 21 | with: 22 | go-version: ${{ matrix.go }} 23 | - name: Go information 24 | run: | 25 | go version 26 | go env 27 | - name: Run tests 28 | run: go test -v ./... 29 | - name: Run tests (32 bit) 30 | if: ${{ matrix.os != 'macos' }} # can't run 32 bit tests on OSX. 31 | env: 32 | GOARCH: 386 33 | run: go test -v ./... 34 | - name: Run tests with race detector 35 | if: ${{ matrix.os == 'ubuntu' }} # speed things up. Windows and OSX VMs are slow 36 | run: go test -v -race ./... 37 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/grandcat/zeroconf?status.svg)](https://godoc.org/github.com/grandcat/zeroconf) 19 | [![Go Report Card](https://goreportcard.com/badge/github.com/grandcat/zeroconf)](https://goreportcard.com/report/github.com/grandcat/zeroconf) 20 | [![Tests](https://github.com/grandcat/zeroconf/actions/workflows/go-test.yml/badge.svg)](https://github.com/grandcat/zeroconf/actions/workflows/go-test.yml) 21 | 22 | ## Install 23 | Nothing is as easy as that: 24 | ```bash 25 | $ go get -u github.com/grandcat/zeroconf 26 | ``` 27 | This package requires **Go 1.7** (context in std lib) or later. 28 | 29 | ## Browse for services in your local network 30 | 31 | ```go 32 | // Discover all services on the network (e.g. _workstation._tcp) 33 | resolver, err := zeroconf.NewResolver(nil) 34 | if err != nil { 35 | log.Fatalln("Failed to initialize resolver:", err.Error()) 36 | } 37 | 38 | entries := make(chan *zeroconf.ServiceEntry) 39 | go func(results <-chan *zeroconf.ServiceEntry) { 40 | for entry := range results { 41 | log.Println(entry) 42 | } 43 | log.Println("No more entries.") 44 | }(entries) 45 | 46 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*15) 47 | defer cancel() 48 | err = resolver.Browse(ctx, "_workstation._tcp", "local.", entries) 49 | if err != nil { 50 | log.Fatalln("Failed to browse:", err.Error()) 51 | } 52 | 53 | <-ctx.Done() 54 | ``` 55 | 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`. 56 | 57 | See https://github.com/grandcat/zeroconf/blob/master/examples/resolv/client.go. 58 | 59 | ## Lookup a specific service instance 60 | 61 | ```go 62 | // Example filled soon. 63 | ``` 64 | 65 | ## Register a service 66 | 67 | ```go 68 | server, err := zeroconf.Register("GoZeroconf", "_workstation._tcp", "local.", 42424, []string{"txtv=0", "lo=1", "la=2"}, nil) 69 | if err != nil { 70 | panic(err) 71 | } 72 | defer server.Shutdown() 73 | 74 | // Clean exit. 75 | sig := make(chan os.Signal, 1) 76 | signal.Notify(sig, os.Interrupt, syscall.SIGTERM) 77 | select { 78 | case <-sig: 79 | // Exit by user 80 | case <-time.After(time.Second * 120): 81 | // Exit by timeout 82 | } 83 | 84 | log.Println("Shutting down.") 85 | ``` 86 | Multiple subtypes may be added to service name, separated by commas. E.g `_workstation._tcp,_windows` has subtype `_windows`. 87 | 88 | See https://github.com/grandcat/zeroconf/blob/master/examples/register/server.go. 89 | 90 | ## Features and ToDo's 91 | This list gives a quick impression about the state of this library. 92 | See what needs to be done and submit a pull request :) 93 | 94 | * [x] Browse / Lookup / Register services 95 | * [x] Multiple IPv6 / IPv4 addresses support 96 | * [x] Send multiple probes (exp. back-off) if no service answers (*) 97 | * [ ] Timestamp entries for TTL checks 98 | * [ ] Compare new multicasts with already received services 99 | 100 | _Notes:_ 101 | 102 | (*) The denoted features might not be perfectly standards compliant, but shouldn't cause any problems. 103 | Some tests showed improvements in overall robustness and performance with the features enabled. 104 | 105 | ## Credits 106 | 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. 107 | Large parts of the code are still the same. 108 | 109 | However, there are several reasons why I decided to create a fork of the original project: 110 | The previous project seems to be unmaintained. There are several useful pull requests waiting. I merged most of them in this project. 111 | Still, the implementation has some bugs and lacks some other features that make it quite unreliable in real LAN environments when running continously. 112 | 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. 113 | Though, resiliency should remain a top goal. 114 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net" 8 | "runtime" 9 | "strings" 10 | "time" 11 | 12 | "github.com/cenkalti/backoff" 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 = 0x01 27 | IPv6 = 0x02 28 | IPv4AndIPv6 = (IPv4 | IPv6) //< Default option. 29 | ) 30 | 31 | type clientOpts struct { 32 | listenOn IPType 33 | ifaces []net.Interface 34 | } 35 | 36 | // ClientOption fills the option struct to configure intefaces, etc. 37 | type ClientOption func(*clientOpts) 38 | 39 | // SelectIPTraffic selects the type of IP packets (IPv4, IPv6, or both) this 40 | // instance listens for. 41 | // This does not guarantee that only mDNS entries of this sepcific 42 | // type passes. E.g. typical mDNS packets distributed via IPv4, may contain 43 | // both DNS A and AAAA entries. 44 | func SelectIPTraffic(t IPType) ClientOption { 45 | return func(o *clientOpts) { 46 | o.listenOn = t 47 | } 48 | } 49 | 50 | // SelectIfaces selects the interfaces to query for mDNS records 51 | func SelectIfaces(ifaces []net.Interface) ClientOption { 52 | return func(o *clientOpts) { 53 | o.ifaces = ifaces 54 | } 55 | } 56 | 57 | // Resolver acts as entry point for service lookups and to browse the DNS-SD. 58 | type Resolver struct { 59 | c *client 60 | } 61 | 62 | // NewResolver creates a new resolver and joins the UDP multicast groups to 63 | // listen for mDNS messages. 64 | func NewResolver(options ...ClientOption) (*Resolver, error) { 65 | // Apply default configuration and load supplied options. 66 | var conf = clientOpts{ 67 | listenOn: IPv4AndIPv6, 68 | } 69 | for _, o := range options { 70 | if o != nil { 71 | o(&conf) 72 | } 73 | } 74 | 75 | c, err := newClient(conf) 76 | if err != nil { 77 | return nil, err 78 | } 79 | return &Resolver{ 80 | c: c, 81 | }, nil 82 | } 83 | 84 | // Browse for all services of a given type in a given domain. 85 | func (r *Resolver) Browse(ctx context.Context, service, domain string, entries chan<- *ServiceEntry) error { 86 | params := defaultParams(service) 87 | if domain != "" { 88 | params.Domain = domain 89 | } 90 | params.Entries = entries 91 | params.isBrowsing = true 92 | ctx, cancel := context.WithCancel(ctx) 93 | go r.c.mainloop(ctx, params) 94 | 95 | err := r.c.query(params) 96 | if err != nil { 97 | cancel() 98 | return err 99 | } 100 | // If previous probe was ok, it should be fine now. In case of an error later on, 101 | // the entries' queue is closed. 102 | go func() { 103 | if err := r.c.periodicQuery(ctx, params); err != nil { 104 | cancel() 105 | } 106 | }() 107 | 108 | return nil 109 | } 110 | 111 | // Lookup a specific service by its name and type in a given domain. 112 | func (r *Resolver) Lookup(ctx context.Context, instance, service, domain string, entries chan<- *ServiceEntry) error { 113 | params := defaultParams(service) 114 | params.Instance = instance 115 | if domain != "" { 116 | params.Domain = domain 117 | } 118 | params.Entries = entries 119 | ctx, cancel := context.WithCancel(ctx) 120 | go r.c.mainloop(ctx, params) 121 | err := r.c.query(params) 122 | if err != nil { 123 | // cancel mainloop 124 | cancel() 125 | return err 126 | } 127 | // If previous probe was ok, it should be fine now. In case of an error later on, 128 | // the entries' queue is closed. 129 | go func() { 130 | if err := r.c.periodicQuery(ctx, params); err != nil { 131 | cancel() 132 | } 133 | }() 134 | 135 | return nil 136 | } 137 | 138 | // defaultParams returns a default set of QueryParams. 139 | func defaultParams(service string) *lookupParams { 140 | return newLookupParams("", service, "local", false, make(chan *ServiceEntry)) 141 | } 142 | 143 | // Client structure encapsulates both IPv4/IPv6 UDP connections. 144 | type client struct { 145 | ipv4conn *ipv4.PacketConn 146 | ipv6conn *ipv6.PacketConn 147 | ifaces []net.Interface 148 | } 149 | 150 | // Client structure constructor 151 | func newClient(opts clientOpts) (*client, error) { 152 | ifaces := opts.ifaces 153 | if len(ifaces) == 0 { 154 | ifaces = listMulticastInterfaces() 155 | } 156 | // IPv4 interfaces 157 | var ipv4conn *ipv4.PacketConn 158 | if (opts.listenOn & IPv4) > 0 { 159 | var err error 160 | ipv4conn, err = joinUdp4Multicast(ifaces) 161 | if err != nil { 162 | return nil, err 163 | } 164 | } 165 | // IPv6 interfaces 166 | var ipv6conn *ipv6.PacketConn 167 | if (opts.listenOn & IPv6) > 0 { 168 | var err error 169 | ipv6conn, err = joinUdp6Multicast(ifaces) 170 | if err != nil { 171 | return nil, err 172 | } 173 | } 174 | 175 | return &client{ 176 | ipv4conn: ipv4conn, 177 | ipv6conn: ipv6conn, 178 | ifaces: ifaces, 179 | }, nil 180 | } 181 | 182 | // Start listeners and waits for the shutdown signal from exit channel 183 | func (c *client) mainloop(ctx context.Context, params *lookupParams) { 184 | // start listening for responses 185 | msgCh := make(chan *dns.Msg, 32) 186 | if c.ipv4conn != nil { 187 | go c.recv(ctx, c.ipv4conn, msgCh) 188 | } 189 | if c.ipv6conn != nil { 190 | go c.recv(ctx, c.ipv6conn, msgCh) 191 | } 192 | 193 | // Iterate through channels from listeners goroutines 194 | var entries, sentEntries map[string]*ServiceEntry 195 | sentEntries = make(map[string]*ServiceEntry) 196 | for { 197 | select { 198 | case <-ctx.Done(): 199 | // Context expired. Notify subscriber that we are done here. 200 | params.done() 201 | c.shutdown() 202 | return 203 | case msg := <-msgCh: 204 | entries = make(map[string]*ServiceEntry) 205 | sections := append(msg.Answer, msg.Ns...) 206 | sections = append(sections, msg.Extra...) 207 | 208 | for _, answer := range sections { 209 | switch rr := answer.(type) { 210 | case *dns.PTR: 211 | if params.ServiceName() != rr.Hdr.Name { 212 | continue 213 | } 214 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Ptr { 215 | continue 216 | } 217 | if _, ok := entries[rr.Ptr]; !ok { 218 | entries[rr.Ptr] = NewServiceEntry( 219 | trimDot(strings.Replace(rr.Ptr, rr.Hdr.Name, "", -1)), 220 | params.Service, 221 | params.Domain) 222 | } 223 | entries[rr.Ptr].TTL = rr.Hdr.Ttl 224 | case *dns.SRV: 225 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { 226 | continue 227 | } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { 228 | continue 229 | } 230 | if _, ok := entries[rr.Hdr.Name]; !ok { 231 | entries[rr.Hdr.Name] = NewServiceEntry( 232 | trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), 233 | params.Service, 234 | params.Domain) 235 | } 236 | entries[rr.Hdr.Name].HostName = rr.Target 237 | entries[rr.Hdr.Name].Port = int(rr.Port) 238 | entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl 239 | case *dns.TXT: 240 | if params.ServiceInstanceName() != "" && params.ServiceInstanceName() != rr.Hdr.Name { 241 | continue 242 | } else if !strings.HasSuffix(rr.Hdr.Name, params.ServiceName()) { 243 | continue 244 | } 245 | if _, ok := entries[rr.Hdr.Name]; !ok { 246 | entries[rr.Hdr.Name] = NewServiceEntry( 247 | trimDot(strings.Replace(rr.Hdr.Name, params.ServiceName(), "", 1)), 248 | params.Service, 249 | params.Domain) 250 | } 251 | entries[rr.Hdr.Name].Text = rr.Txt 252 | entries[rr.Hdr.Name].TTL = rr.Hdr.Ttl 253 | } 254 | } 255 | // Associate IPs in a second round as other fields should be filled by now. 256 | for _, answer := range sections { 257 | switch rr := answer.(type) { 258 | case *dns.A: 259 | for k, e := range entries { 260 | if e.HostName == rr.Hdr.Name { 261 | entries[k].AddrIPv4 = append(entries[k].AddrIPv4, rr.A) 262 | } 263 | } 264 | case *dns.AAAA: 265 | for k, e := range entries { 266 | if e.HostName == rr.Hdr.Name { 267 | entries[k].AddrIPv6 = append(entries[k].AddrIPv6, rr.AAAA) 268 | } 269 | } 270 | } 271 | } 272 | } 273 | 274 | if len(entries) > 0 { 275 | for k, e := range entries { 276 | if e.TTL == 0 { 277 | delete(entries, k) 278 | delete(sentEntries, k) 279 | continue 280 | } 281 | if _, ok := sentEntries[k]; ok { 282 | continue 283 | } 284 | 285 | // If this is an DNS-SD query do not throw PTR away. 286 | // It is expected to have only PTR for enumeration 287 | if params.ServiceRecord.ServiceTypeName() != params.ServiceRecord.ServiceName() { 288 | // Require at least one resolved IP address for ServiceEntry 289 | // TODO: wait some more time as chances are high both will arrive. 290 | if len(e.AddrIPv4) == 0 && len(e.AddrIPv6) == 0 { 291 | continue 292 | } 293 | } 294 | // Submit entry to subscriber and cache it. 295 | // This is also a point to possibly stop probing actively for a 296 | // service entry. 297 | params.Entries <- e 298 | sentEntries[k] = e 299 | if !params.isBrowsing { 300 | params.disableProbing() 301 | } 302 | } 303 | } 304 | } 305 | } 306 | 307 | // Shutdown client will close currently open connections and channel implicitly. 308 | func (c *client) shutdown() { 309 | if c.ipv4conn != nil { 310 | c.ipv4conn.Close() 311 | } 312 | if c.ipv6conn != nil { 313 | c.ipv6conn.Close() 314 | } 315 | } 316 | 317 | // Data receiving routine reads from connection, unpacks packets into dns.Msg 318 | // structures and sends them to a given msgCh channel 319 | func (c *client) recv(ctx context.Context, l interface{}, msgCh chan *dns.Msg) { 320 | var readFrom func([]byte) (n int, src net.Addr, err error) 321 | 322 | switch pConn := l.(type) { 323 | case *ipv6.PacketConn: 324 | readFrom = func(b []byte) (n int, src net.Addr, err error) { 325 | n, _, src, err = pConn.ReadFrom(b) 326 | return 327 | } 328 | case *ipv4.PacketConn: 329 | readFrom = func(b []byte) (n int, src net.Addr, err error) { 330 | n, _, src, err = pConn.ReadFrom(b) 331 | return 332 | } 333 | 334 | default: 335 | return 336 | } 337 | 338 | buf := make([]byte, 65536) 339 | var fatalErr error 340 | for { 341 | // Handles the following cases: 342 | // - ReadFrom aborts with error due to closed UDP connection -> causes ctx cancel 343 | // - ReadFrom aborts otherwise. 344 | // TODO: the context check can be removed. Verify! 345 | if ctx.Err() != nil || fatalErr != nil { 346 | return 347 | } 348 | 349 | n, _, err := readFrom(buf) 350 | if err != nil { 351 | fatalErr = err 352 | continue 353 | } 354 | msg := new(dns.Msg) 355 | if err := msg.Unpack(buf[:n]); err != nil { 356 | // log.Printf("[WARN] mdns: Failed to unpack packet: %v", err) 357 | continue 358 | } 359 | select { 360 | case msgCh <- msg: 361 | // Submit decoded DNS message and continue. 362 | case <-ctx.Done(): 363 | // Abort. 364 | return 365 | } 366 | } 367 | } 368 | 369 | // periodicQuery sens multiple probes until a valid response is received by 370 | // the main processing loop or some timeout/cancel fires. 371 | // TODO: move error reporting to shutdown function as periodicQuery is called from 372 | // go routine context. 373 | func (c *client) periodicQuery(ctx context.Context, params *lookupParams) error { 374 | bo := backoff.NewExponentialBackOff() 375 | bo.InitialInterval = 4 * time.Second 376 | bo.MaxInterval = 60 * time.Second 377 | bo.MaxElapsedTime = 0 378 | bo.Reset() 379 | 380 | var timer *time.Timer 381 | defer func() { 382 | if timer != nil { 383 | timer.Stop() 384 | } 385 | }() 386 | for { 387 | // Backoff and cancel logic. 388 | wait := bo.NextBackOff() 389 | if wait == backoff.Stop { 390 | return fmt.Errorf("periodicQuery: abort due to timeout") 391 | } 392 | if timer == nil { 393 | timer = time.NewTimer(wait) 394 | } else { 395 | timer.Reset(wait) 396 | } 397 | select { 398 | case <-timer.C: 399 | // Wait for next iteration. 400 | case <-params.stopProbing: 401 | // Chan is closed (or happened in the past). 402 | // Done here. Received a matching mDNS entry. 403 | return nil 404 | case <-ctx.Done(): 405 | return ctx.Err() 406 | } 407 | // Do periodic query. 408 | if err := c.query(params); err != nil { 409 | return err 410 | } 411 | } 412 | } 413 | 414 | // Performs the actual query by service name (browse) or service instance name (lookup), 415 | // start response listeners goroutines and loops over the entries channel. 416 | func (c *client) query(params *lookupParams) error { 417 | var serviceName, serviceInstanceName string 418 | serviceName = fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain)) 419 | 420 | // send the query 421 | m := new(dns.Msg) 422 | if params.Instance != "" { // service instance name lookup 423 | serviceInstanceName = fmt.Sprintf("%s.%s", params.Instance, serviceName) 424 | m.Question = []dns.Question{ 425 | {Name: serviceInstanceName, Qtype: dns.TypeSRV, Qclass: dns.ClassINET}, 426 | {Name: serviceInstanceName, Qtype: dns.TypeTXT, Qclass: dns.ClassINET}, 427 | } 428 | } else if len(params.Subtypes) > 0 { // service subtype browse 429 | m.SetQuestion(params.Subtypes[0], dns.TypePTR) 430 | } else { // service name browse 431 | m.SetQuestion(serviceName, dns.TypePTR) 432 | } 433 | m.RecursionDesired = false 434 | if err := c.sendQuery(m); err != nil { 435 | return err 436 | } 437 | 438 | return nil 439 | } 440 | 441 | // Pack the dns.Msg and write to available connections (multicast) 442 | func (c *client) sendQuery(msg *dns.Msg) error { 443 | buf, err := msg.Pack() 444 | if err != nil { 445 | return err 446 | } 447 | if c.ipv4conn != nil { 448 | // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG 449 | // As of Golang 1.18.4 450 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 451 | var wcm ipv4.ControlMessage 452 | for ifi := range c.ifaces { 453 | switch runtime.GOOS { 454 | case "darwin", "ios", "linux": 455 | wcm.IfIndex = c.ifaces[ifi].Index 456 | default: 457 | if err := c.ipv4conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { 458 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 459 | } 460 | } 461 | c.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 462 | } 463 | } 464 | if c.ipv6conn != nil { 465 | // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG 466 | // As of Golang 1.18.4 467 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 468 | var wcm ipv6.ControlMessage 469 | for ifi := range c.ifaces { 470 | switch runtime.GOOS { 471 | case "darwin", "ios", "linux": 472 | wcm.IfIndex = c.ifaces[ifi].Index 473 | default: 474 | if err := c.ipv6conn.SetMulticastInterface(&c.ifaces[ifi]); err != nil { 475 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 476 | } 477 | } 478 | c.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 479 | } 480 | } 481 | return nil 482 | } 483 | -------------------------------------------------------------------------------- /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 | _ = pkConn.SetMulticastHopLimit(255) 48 | 49 | if len(interfaces) == 0 { 50 | interfaces = listMulticastInterfaces() 51 | } 52 | // log.Println("Using multicast interfaces: ", interfaces) 53 | 54 | var failedJoins int 55 | for _, iface := range interfaces { 56 | if err := pkConn.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil { 57 | // log.Println("Udp6 JoinGroup failed for iface ", iface) 58 | failedJoins++ 59 | } 60 | } 61 | if failedJoins == len(interfaces) { 62 | pkConn.Close() 63 | return nil, fmt.Errorf("udp6: failed to join any of these interfaces: %v", interfaces) 64 | } 65 | 66 | return pkConn, nil 67 | } 68 | 69 | func joinUdp4Multicast(interfaces []net.Interface) (*ipv4.PacketConn, error) { 70 | udpConn, err := net.ListenUDP("udp4", mdnsWildcardAddrIPv4) 71 | if err != nil { 72 | // log.Printf("[ERR] bonjour: Failed to bind to udp4 mutlicast: %v", err) 73 | return nil, err 74 | } 75 | 76 | // Join multicast groups to receive announcements 77 | pkConn := ipv4.NewPacketConn(udpConn) 78 | pkConn.SetControlMessage(ipv4.FlagInterface, true) 79 | _ = pkConn.SetMulticastTTL(255) 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 | return pkConn, nil 99 | } 100 | 101 | func listMulticastInterfaces() []net.Interface { 102 | var interfaces []net.Interface 103 | ifaces, err := net.Interfaces() 104 | if err != nil { 105 | return nil 106 | } 107 | for _, ifi := range ifaces { 108 | if (ifi.Flags & net.FlagUp) == 0 { 109 | continue 110 | } 111 | if (ifi.Flags & net.FlagMulticast) > 0 { 112 | interfaces = append(interfaces, ifi) 113 | } 114 | } 115 | 116 | return interfaces 117 | } 118 | -------------------------------------------------------------------------------- /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/proxyservice/.gitignore: -------------------------------------------------------------------------------- 1 | proxyservice 2 | -------------------------------------------------------------------------------- /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/grandcat/zeroconf" 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 | -------------------------------------------------------------------------------- /examples/register/.gitignore: -------------------------------------------------------------------------------- 1 | register 2 | 3 | -------------------------------------------------------------------------------- /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/grandcat/zeroconf" 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 | -------------------------------------------------------------------------------- /examples/resolv/.gitignore: -------------------------------------------------------------------------------- 1 | resolv 2 | 3 | -------------------------------------------------------------------------------- /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/grandcat/zeroconf" 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 | // Discover all services on the network (e.g. _workstation._tcp) 22 | resolver, err := zeroconf.NewResolver(nil) 23 | if err != nil { 24 | log.Fatalln("Failed to initialize resolver:", err.Error()) 25 | } 26 | 27 | entries := make(chan *zeroconf.ServiceEntry) 28 | go func(results <-chan *zeroconf.ServiceEntry) { 29 | for entry := range results { 30 | log.Println(entry) 31 | } 32 | log.Println("No more entries.") 33 | }(entries) 34 | 35 | ctx, cancel := context.WithTimeout(context.Background(), time.Second*time.Duration(*waitTime)) 36 | defer cancel() 37 | err = resolver.Browse(ctx, *service, *domain, entries) 38 | if err != nil { 39 | log.Fatalln("Failed to browse:", err.Error()) 40 | } 41 | 42 | <-ctx.Done() 43 | // Wait some additional time to see debug messages on go routine shutdown. 44 | time.Sleep(1 * time.Second) 45 | } 46 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grandcat/zeroconf 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/cenkalti/backoff v2.2.1+incompatible 7 | github.com/miekg/dns v1.1.41 8 | github.com/pkg/errors v0.9.1 9 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 10 | golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= 2 | github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= 3 | github.com/miekg/dns v1.1.41 h1:WMszZWJG0XmzbK9FEmzH2TVcqYzFesusSIB41b8KHxY= 4 | github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI= 5 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 6 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 7 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 8 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6 h1:0PC75Fz/kyMGhL0e1QnypqK2kQMqKt9csD1GnMJR+Zk= 9 | golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 10 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 11 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 12 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 13 | golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 14 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 15 | golang.org/x/sys v0.0.0-20210426080607-c94f62235c83 h1:kHSDPqCtsHZOg0nVylfTo20DDhE9gG4Y0jn7hKQ0QAM= 16 | golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 17 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 18 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 19 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 20 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 21 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "log" 7 | "math/rand" 8 | "net" 9 | "os" 10 | "runtime" 11 | "strings" 12 | "sync" 13 | "time" 14 | 15 | "github.com/miekg/dns" 16 | "golang.org/x/net/ipv4" 17 | "golang.org/x/net/ipv6" 18 | ) 19 | 20 | const ( 21 | // Number of Multicast responses sent for a query message (default: 1 < x < 9) 22 | multicastRepetitions = 2 23 | ) 24 | 25 | // Register a service by given arguments. This call will take the system's hostname 26 | // and lookup IP by that hostname. 27 | func Register(instance, service, domain string, port int, text []string, ifaces []net.Interface) (*Server, error) { 28 | entry := NewServiceEntry(instance, service, domain) 29 | entry.Port = port 30 | entry.Text = text 31 | 32 | if entry.Instance == "" { 33 | return nil, fmt.Errorf("missing service instance name") 34 | } 35 | if entry.Service == "" { 36 | return nil, fmt.Errorf("missing service name") 37 | } 38 | if entry.Domain == "" { 39 | entry.Domain = "local." 40 | } 41 | if entry.Port == 0 { 42 | return nil, fmt.Errorf("missing port") 43 | } 44 | 45 | var err error 46 | if entry.HostName == "" { 47 | entry.HostName, err = os.Hostname() 48 | if err != nil { 49 | return nil, fmt.Errorf("could not determine host") 50 | } 51 | } 52 | 53 | if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { 54 | entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) 55 | } 56 | 57 | if len(ifaces) == 0 { 58 | ifaces = listMulticastInterfaces() 59 | } 60 | 61 | for _, iface := range ifaces { 62 | v4, v6 := addrsForInterface(&iface) 63 | entry.AddrIPv4 = append(entry.AddrIPv4, v4...) 64 | entry.AddrIPv6 = append(entry.AddrIPv6, v6...) 65 | } 66 | 67 | if entry.AddrIPv4 == nil && entry.AddrIPv6 == nil { 68 | return nil, fmt.Errorf("could not determine host IP addresses") 69 | } 70 | 71 | s, err := newServer(ifaces) 72 | if err != nil { 73 | return nil, err 74 | } 75 | 76 | s.service = entry 77 | go s.mainloop() 78 | go s.probe() 79 | 80 | return s, nil 81 | } 82 | 83 | // RegisterProxy registers a service proxy. This call will skip the hostname/IP lookup and 84 | // will use the provided values. 85 | func RegisterProxy(instance, service, domain string, port int, host string, ips []string, text []string, ifaces []net.Interface) (*Server, error) { 86 | entry := NewServiceEntry(instance, service, domain) 87 | entry.Port = port 88 | entry.Text = text 89 | entry.HostName = host 90 | 91 | if entry.Instance == "" { 92 | return nil, fmt.Errorf("missing service instance name") 93 | } 94 | if entry.Service == "" { 95 | return nil, fmt.Errorf("missing service name") 96 | } 97 | if entry.HostName == "" { 98 | return nil, fmt.Errorf("missing host name") 99 | } 100 | if entry.Domain == "" { 101 | entry.Domain = "local" 102 | } 103 | if entry.Port == 0 { 104 | return nil, fmt.Errorf("missing port") 105 | } 106 | 107 | if !strings.HasSuffix(trimDot(entry.HostName), entry.Domain) { 108 | entry.HostName = fmt.Sprintf("%s.%s.", trimDot(entry.HostName), trimDot(entry.Domain)) 109 | } 110 | 111 | for _, ip := range ips { 112 | ipAddr := net.ParseIP(ip) 113 | if ipAddr == nil { 114 | return nil, fmt.Errorf("failed to parse given IP: %v", ip) 115 | } else if ipv4 := ipAddr.To4(); ipv4 != nil { 116 | entry.AddrIPv4 = append(entry.AddrIPv4, ipAddr) 117 | } else if ipv6 := ipAddr.To16(); ipv6 != nil { 118 | entry.AddrIPv6 = append(entry.AddrIPv6, ipAddr) 119 | } else { 120 | return nil, fmt.Errorf("the IP is neither IPv4 nor IPv6: %#v", ipAddr) 121 | } 122 | } 123 | 124 | if len(ifaces) == 0 { 125 | ifaces = listMulticastInterfaces() 126 | } 127 | 128 | s, err := newServer(ifaces) 129 | if err != nil { 130 | return nil, err 131 | } 132 | 133 | s.service = entry 134 | go s.mainloop() 135 | go s.probe() 136 | 137 | return s, nil 138 | } 139 | 140 | const ( 141 | qClassCacheFlush uint16 = 1 << 15 142 | ) 143 | 144 | // Server structure encapsulates both IPv4/IPv6 UDP connections 145 | type Server struct { 146 | service *ServiceEntry 147 | ipv4conn *ipv4.PacketConn 148 | ipv6conn *ipv6.PacketConn 149 | ifaces []net.Interface 150 | 151 | shouldShutdown chan struct{} 152 | shutdownLock sync.Mutex 153 | shutdownEnd sync.WaitGroup 154 | isShutdown bool 155 | ttl uint32 156 | } 157 | 158 | // Constructs server structure 159 | func newServer(ifaces []net.Interface) (*Server, error) { 160 | ipv4conn, err4 := joinUdp4Multicast(ifaces) 161 | if err4 != nil { 162 | log.Printf("[zeroconf] no suitable IPv4 interface: %s", err4.Error()) 163 | } 164 | ipv6conn, err6 := joinUdp6Multicast(ifaces) 165 | if err6 != nil { 166 | log.Printf("[zeroconf] no suitable IPv6 interface: %s", err6.Error()) 167 | } 168 | if err4 != nil && err6 != nil { 169 | // No supported interface left. 170 | return nil, fmt.Errorf("no supported interface") 171 | } 172 | 173 | s := &Server{ 174 | ipv4conn: ipv4conn, 175 | ipv6conn: ipv6conn, 176 | ifaces: ifaces, 177 | ttl: 3200, 178 | shouldShutdown: make(chan struct{}), 179 | } 180 | 181 | return s, nil 182 | } 183 | 184 | // Start listeners and waits for the shutdown signal from exit channel 185 | func (s *Server) mainloop() { 186 | if s.ipv4conn != nil { 187 | go s.recv4(s.ipv4conn) 188 | } 189 | if s.ipv6conn != nil { 190 | go s.recv6(s.ipv6conn) 191 | } 192 | } 193 | 194 | // Shutdown closes all udp connections and unregisters the service 195 | func (s *Server) Shutdown() { 196 | s.shutdown() 197 | } 198 | 199 | // SetText updates and announces the TXT records 200 | func (s *Server) SetText(text []string) { 201 | s.service.Text = text 202 | s.announceText() 203 | } 204 | 205 | // TTL sets the TTL for DNS replies 206 | func (s *Server) TTL(ttl uint32) { 207 | s.ttl = ttl 208 | } 209 | 210 | // Shutdown server will close currently open connections & channel 211 | func (s *Server) shutdown() error { 212 | s.shutdownLock.Lock() 213 | defer s.shutdownLock.Unlock() 214 | if s.isShutdown { 215 | return errors.New("server is already shutdown") 216 | } 217 | 218 | err := s.unregister() 219 | 220 | close(s.shouldShutdown) 221 | 222 | if s.ipv4conn != nil { 223 | s.ipv4conn.Close() 224 | } 225 | if s.ipv6conn != nil { 226 | s.ipv6conn.Close() 227 | } 228 | 229 | // Wait for connection and routines to be closed 230 | s.shutdownEnd.Wait() 231 | s.isShutdown = true 232 | 233 | return err 234 | } 235 | 236 | // recv is a long running routine to receive packets from an interface 237 | func (s *Server) recv4(c *ipv4.PacketConn) { 238 | if c == nil { 239 | return 240 | } 241 | buf := make([]byte, 65536) 242 | s.shutdownEnd.Add(1) 243 | defer s.shutdownEnd.Done() 244 | for { 245 | select { 246 | case <-s.shouldShutdown: 247 | return 248 | default: 249 | var ifIndex int 250 | n, cm, from, err := c.ReadFrom(buf) 251 | if err != nil { 252 | continue 253 | } 254 | if cm != nil { 255 | ifIndex = cm.IfIndex 256 | } 257 | _ = s.parsePacket(buf[:n], ifIndex, from) 258 | } 259 | } 260 | } 261 | 262 | // recv is a long running routine to receive packets from an interface 263 | func (s *Server) recv6(c *ipv6.PacketConn) { 264 | if c == nil { 265 | return 266 | } 267 | buf := make([]byte, 65536) 268 | s.shutdownEnd.Add(1) 269 | defer s.shutdownEnd.Done() 270 | for { 271 | select { 272 | case <-s.shouldShutdown: 273 | return 274 | default: 275 | var ifIndex int 276 | n, cm, from, err := c.ReadFrom(buf) 277 | if err != nil { 278 | continue 279 | } 280 | if cm != nil { 281 | ifIndex = cm.IfIndex 282 | } 283 | _ = s.parsePacket(buf[:n], ifIndex, from) 284 | } 285 | } 286 | } 287 | 288 | // parsePacket is used to parse an incoming packet 289 | func (s *Server) parsePacket(packet []byte, ifIndex int, from net.Addr) error { 290 | var msg dns.Msg 291 | if err := msg.Unpack(packet); err != nil { 292 | // log.Printf("[ERR] zeroconf: Failed to unpack packet: %v", err) 293 | return err 294 | } 295 | return s.handleQuery(&msg, ifIndex, from) 296 | } 297 | 298 | // handleQuery is used to handle an incoming query 299 | func (s *Server) handleQuery(query *dns.Msg, ifIndex int, from net.Addr) error { 300 | // Ignore questions with authoritative section for now 301 | if len(query.Ns) > 0 { 302 | return nil 303 | } 304 | 305 | // Handle each question 306 | var err error 307 | for _, q := range query.Question { 308 | resp := dns.Msg{} 309 | resp.SetReply(query) 310 | resp.Compress = true 311 | resp.RecursionDesired = false 312 | resp.Authoritative = true 313 | resp.Question = nil // RFC6762 section 6 "responses MUST NOT contain any questions" 314 | resp.Answer = []dns.RR{} 315 | resp.Extra = []dns.RR{} 316 | if err = s.handleQuestion(q, &resp, query, ifIndex); err != nil { 317 | // log.Printf("[ERR] zeroconf: failed to handle question %v: %v", q, err) 318 | continue 319 | } 320 | // Check if there is an answer 321 | if len(resp.Answer) == 0 { 322 | continue 323 | } 324 | 325 | if isUnicastQuestion(q) { 326 | // Send unicast 327 | if e := s.unicastResponse(&resp, ifIndex, from); e != nil { 328 | err = e 329 | } 330 | } else { 331 | // Send mulicast 332 | if e := s.multicastResponse(&resp, ifIndex); e != nil { 333 | err = e 334 | } 335 | } 336 | } 337 | 338 | return err 339 | } 340 | 341 | // RFC6762 7.1. Known-Answer Suppression 342 | func isKnownAnswer(resp *dns.Msg, query *dns.Msg) bool { 343 | if len(resp.Answer) == 0 || len(query.Answer) == 0 { 344 | return false 345 | } 346 | 347 | if resp.Answer[0].Header().Rrtype != dns.TypePTR { 348 | return false 349 | } 350 | answer := resp.Answer[0].(*dns.PTR) 351 | 352 | for _, known := range query.Answer { 353 | hdr := known.Header() 354 | if hdr.Rrtype != answer.Hdr.Rrtype { 355 | continue 356 | } 357 | ptr := known.(*dns.PTR) 358 | if ptr.Ptr == answer.Ptr && hdr.Ttl >= answer.Hdr.Ttl/2 { 359 | // log.Printf("skipping known answer: %v", ptr) 360 | return true 361 | } 362 | } 363 | 364 | return false 365 | } 366 | 367 | // handleQuestion is used to handle an incoming question 368 | func (s *Server) handleQuestion(q dns.Question, resp *dns.Msg, query *dns.Msg, ifIndex int) error { 369 | if s.service == nil { 370 | return nil 371 | } 372 | 373 | switch q.Name { 374 | case s.service.ServiceTypeName(): 375 | s.serviceTypeName(resp, s.ttl) 376 | if isKnownAnswer(resp, query) { 377 | resp.Answer = nil 378 | } 379 | 380 | case s.service.ServiceName(): 381 | s.composeBrowsingAnswers(resp, ifIndex) 382 | if isKnownAnswer(resp, query) { 383 | resp.Answer = nil 384 | } 385 | 386 | case s.service.ServiceInstanceName(): 387 | s.composeLookupAnswers(resp, s.ttl, ifIndex, false) 388 | default: 389 | // handle matching subtype query 390 | for _, subtype := range s.service.Subtypes { 391 | subtype = fmt.Sprintf("%s._sub.%s", subtype, s.service.ServiceName()) 392 | if q.Name == subtype { 393 | s.composeBrowsingAnswers(resp, ifIndex) 394 | if isKnownAnswer(resp, query) { 395 | resp.Answer = nil 396 | } 397 | break 398 | } 399 | } 400 | } 401 | 402 | return nil 403 | } 404 | 405 | func (s *Server) composeBrowsingAnswers(resp *dns.Msg, ifIndex int) { 406 | ptr := &dns.PTR{ 407 | Hdr: dns.RR_Header{ 408 | Name: s.service.ServiceName(), 409 | Rrtype: dns.TypePTR, 410 | Class: dns.ClassINET, 411 | Ttl: s.ttl, 412 | }, 413 | Ptr: s.service.ServiceInstanceName(), 414 | } 415 | resp.Answer = append(resp.Answer, ptr) 416 | 417 | txt := &dns.TXT{ 418 | Hdr: dns.RR_Header{ 419 | Name: s.service.ServiceInstanceName(), 420 | Rrtype: dns.TypeTXT, 421 | Class: dns.ClassINET, 422 | Ttl: s.ttl, 423 | }, 424 | Txt: s.service.Text, 425 | } 426 | srv := &dns.SRV{ 427 | Hdr: dns.RR_Header{ 428 | Name: s.service.ServiceInstanceName(), 429 | Rrtype: dns.TypeSRV, 430 | Class: dns.ClassINET, 431 | Ttl: s.ttl, 432 | }, 433 | Priority: 0, 434 | Weight: 0, 435 | Port: uint16(s.service.Port), 436 | Target: s.service.HostName, 437 | } 438 | resp.Extra = append(resp.Extra, srv, txt) 439 | 440 | resp.Extra = s.appendAddrs(resp.Extra, s.ttl, ifIndex, false) 441 | } 442 | 443 | func (s *Server) composeLookupAnswers(resp *dns.Msg, ttl uint32, ifIndex int, flushCache bool) { 444 | // From RFC6762 445 | // The most significant bit of the rrclass for a record in the Answer 446 | // Section of a response message is the Multicast DNS cache-flush bit 447 | // and is discussed in more detail below in Section 10.2, "Announcements 448 | // to Flush Outdated Cache Entries". 449 | ptr := &dns.PTR{ 450 | Hdr: dns.RR_Header{ 451 | Name: s.service.ServiceName(), 452 | Rrtype: dns.TypePTR, 453 | Class: dns.ClassINET, 454 | Ttl: ttl, 455 | }, 456 | Ptr: s.service.ServiceInstanceName(), 457 | } 458 | srv := &dns.SRV{ 459 | Hdr: dns.RR_Header{ 460 | Name: s.service.ServiceInstanceName(), 461 | Rrtype: dns.TypeSRV, 462 | Class: dns.ClassINET | qClassCacheFlush, 463 | Ttl: ttl, 464 | }, 465 | Priority: 0, 466 | Weight: 0, 467 | Port: uint16(s.service.Port), 468 | Target: s.service.HostName, 469 | } 470 | txt := &dns.TXT{ 471 | Hdr: dns.RR_Header{ 472 | Name: s.service.ServiceInstanceName(), 473 | Rrtype: dns.TypeTXT, 474 | Class: dns.ClassINET | qClassCacheFlush, 475 | Ttl: ttl, 476 | }, 477 | Txt: s.service.Text, 478 | } 479 | dnssd := &dns.PTR{ 480 | Hdr: dns.RR_Header{ 481 | Name: s.service.ServiceTypeName(), 482 | Rrtype: dns.TypePTR, 483 | Class: dns.ClassINET, 484 | Ttl: ttl, 485 | }, 486 | Ptr: s.service.ServiceName(), 487 | } 488 | resp.Answer = append(resp.Answer, srv, txt, ptr, dnssd) 489 | 490 | for _, subtype := range s.service.Subtypes { 491 | resp.Answer = append(resp.Answer, 492 | &dns.PTR{ 493 | Hdr: dns.RR_Header{ 494 | Name: subtype, 495 | Rrtype: dns.TypePTR, 496 | Class: dns.ClassINET, 497 | Ttl: ttl, 498 | }, 499 | Ptr: s.service.ServiceInstanceName(), 500 | }) 501 | } 502 | 503 | resp.Answer = s.appendAddrs(resp.Answer, ttl, ifIndex, flushCache) 504 | } 505 | 506 | func (s *Server) serviceTypeName(resp *dns.Msg, ttl uint32) { 507 | // From RFC6762 508 | // 9. Service Type Enumeration 509 | // 510 | // For this purpose, a special meta-query is defined. A DNS query for 511 | // PTR records with the name "_services._dns-sd._udp." yields a 512 | // set of PTR records, where the rdata of each PTR record is the two- 513 | // label name, plus the same domain, e.g., 514 | // "_http._tcp.". 515 | dnssd := &dns.PTR{ 516 | Hdr: dns.RR_Header{ 517 | Name: s.service.ServiceTypeName(), 518 | Rrtype: dns.TypePTR, 519 | Class: dns.ClassINET, 520 | Ttl: ttl, 521 | }, 522 | Ptr: s.service.ServiceName(), 523 | } 524 | resp.Answer = append(resp.Answer, dnssd) 525 | } 526 | 527 | // Perform probing & announcement 528 | //TODO: implement a proper probing & conflict resolution 529 | func (s *Server) probe() { 530 | q := new(dns.Msg) 531 | q.SetQuestion(s.service.ServiceInstanceName(), dns.TypePTR) 532 | q.RecursionDesired = false 533 | 534 | srv := &dns.SRV{ 535 | Hdr: dns.RR_Header{ 536 | Name: s.service.ServiceInstanceName(), 537 | Rrtype: dns.TypeSRV, 538 | Class: dns.ClassINET, 539 | Ttl: s.ttl, 540 | }, 541 | Priority: 0, 542 | Weight: 0, 543 | Port: uint16(s.service.Port), 544 | Target: s.service.HostName, 545 | } 546 | txt := &dns.TXT{ 547 | Hdr: dns.RR_Header{ 548 | Name: s.service.ServiceInstanceName(), 549 | Rrtype: dns.TypeTXT, 550 | Class: dns.ClassINET, 551 | Ttl: s.ttl, 552 | }, 553 | Txt: s.service.Text, 554 | } 555 | q.Ns = []dns.RR{srv, txt} 556 | 557 | randomizer := rand.New(rand.NewSource(time.Now().UnixNano())) 558 | 559 | for i := 0; i < multicastRepetitions; i++ { 560 | if err := s.multicastResponse(q, 0); err != nil { 561 | log.Println("[ERR] zeroconf: failed to send probe:", err.Error()) 562 | } 563 | time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond) 564 | } 565 | 566 | // From RFC6762 567 | // The Multicast DNS responder MUST send at least two unsolicited 568 | // responses, one second apart. To provide increased robustness against 569 | // packet loss, a responder MAY send up to eight unsolicited responses, 570 | // provided that the interval between unsolicited responses increases by 571 | // at least a factor of two with every response sent. 572 | timeout := 1 * time.Second 573 | for i := 0; i < multicastRepetitions; i++ { 574 | for _, intf := range s.ifaces { 575 | resp := new(dns.Msg) 576 | resp.MsgHdr.Response = true 577 | // TODO: make response authoritative if we are the publisher 578 | resp.Compress = true 579 | resp.Answer = []dns.RR{} 580 | resp.Extra = []dns.RR{} 581 | s.composeLookupAnswers(resp, s.ttl, intf.Index, true) 582 | if err := s.multicastResponse(resp, intf.Index); err != nil { 583 | log.Println("[ERR] zeroconf: failed to send announcement:", err.Error()) 584 | } 585 | } 586 | time.Sleep(timeout) 587 | timeout *= 2 588 | } 589 | } 590 | 591 | // announceText sends a Text announcement with cache flush enabled 592 | func (s *Server) announceText() { 593 | resp := new(dns.Msg) 594 | resp.MsgHdr.Response = true 595 | 596 | txt := &dns.TXT{ 597 | Hdr: dns.RR_Header{ 598 | Name: s.service.ServiceInstanceName(), 599 | Rrtype: dns.TypeTXT, 600 | Class: dns.ClassINET | qClassCacheFlush, 601 | Ttl: s.ttl, 602 | }, 603 | Txt: s.service.Text, 604 | } 605 | 606 | resp.Answer = []dns.RR{txt} 607 | s.multicastResponse(resp, 0) 608 | } 609 | 610 | func (s *Server) unregister() error { 611 | resp := new(dns.Msg) 612 | resp.MsgHdr.Response = true 613 | resp.Answer = []dns.RR{} 614 | resp.Extra = []dns.RR{} 615 | s.composeLookupAnswers(resp, 0, 0, true) 616 | return s.multicastResponse(resp, 0) 617 | } 618 | 619 | func (s *Server) appendAddrs(list []dns.RR, ttl uint32, ifIndex int, flushCache bool) []dns.RR { 620 | v4 := s.service.AddrIPv4 621 | v6 := s.service.AddrIPv6 622 | if len(v4) == 0 && len(v6) == 0 { 623 | iface, _ := net.InterfaceByIndex(ifIndex) 624 | if iface != nil { 625 | a4, a6 := addrsForInterface(iface) 626 | v4 = append(v4, a4...) 627 | v6 = append(v6, a6...) 628 | } 629 | } 630 | if ttl > 0 { 631 | // RFC6762 Section 10 says A/AAAA records SHOULD 632 | // use TTL of 120s, to account for network interface 633 | // and IP address changes. 634 | ttl = 120 635 | } 636 | var cacheFlushBit uint16 637 | if flushCache { 638 | cacheFlushBit = qClassCacheFlush 639 | } 640 | for _, ipv4 := range v4 { 641 | a := &dns.A{ 642 | Hdr: dns.RR_Header{ 643 | Name: s.service.HostName, 644 | Rrtype: dns.TypeA, 645 | Class: dns.ClassINET | cacheFlushBit, 646 | Ttl: ttl, 647 | }, 648 | A: ipv4, 649 | } 650 | list = append(list, a) 651 | } 652 | for _, ipv6 := range v6 { 653 | aaaa := &dns.AAAA{ 654 | Hdr: dns.RR_Header{ 655 | Name: s.service.HostName, 656 | Rrtype: dns.TypeAAAA, 657 | Class: dns.ClassINET | cacheFlushBit, 658 | Ttl: ttl, 659 | }, 660 | AAAA: ipv6, 661 | } 662 | list = append(list, aaaa) 663 | } 664 | return list 665 | } 666 | 667 | func addrsForInterface(iface *net.Interface) ([]net.IP, []net.IP) { 668 | var v4, v6, v6local []net.IP 669 | addrs, _ := iface.Addrs() 670 | for _, address := range addrs { 671 | if ipnet, ok := address.(*net.IPNet); ok && !ipnet.IP.IsLoopback() { 672 | if ipnet.IP.To4() != nil { 673 | v4 = append(v4, ipnet.IP) 674 | } else { 675 | switch ip := ipnet.IP.To16(); ip != nil { 676 | case ip.IsGlobalUnicast(): 677 | v6 = append(v6, ipnet.IP) 678 | case ip.IsLinkLocalUnicast(): 679 | v6local = append(v6local, ipnet.IP) 680 | } 681 | } 682 | } 683 | } 684 | if len(v6) == 0 { 685 | v6 = v6local 686 | } 687 | return v4, v6 688 | } 689 | 690 | // unicastResponse is used to send a unicast response packet 691 | func (s *Server) unicastResponse(resp *dns.Msg, ifIndex int, from net.Addr) error { 692 | buf, err := resp.Pack() 693 | if err != nil { 694 | return err 695 | } 696 | addr := from.(*net.UDPAddr) 697 | if addr.IP.To4() != nil { 698 | if ifIndex != 0 { 699 | var wcm ipv4.ControlMessage 700 | wcm.IfIndex = ifIndex 701 | _, err = s.ipv4conn.WriteTo(buf, &wcm, addr) 702 | } else { 703 | _, err = s.ipv4conn.WriteTo(buf, nil, addr) 704 | } 705 | return err 706 | } else { 707 | if ifIndex != 0 { 708 | var wcm ipv6.ControlMessage 709 | wcm.IfIndex = ifIndex 710 | _, err = s.ipv6conn.WriteTo(buf, &wcm, addr) 711 | } else { 712 | _, err = s.ipv6conn.WriteTo(buf, nil, addr) 713 | } 714 | return err 715 | } 716 | } 717 | 718 | // multicastResponse us used to send a multicast response packet 719 | func (s *Server) multicastResponse(msg *dns.Msg, ifIndex int) error { 720 | buf, err := msg.Pack() 721 | if err != nil { 722 | return err 723 | } 724 | if s.ipv4conn != nil { 725 | // See https://pkg.go.dev/golang.org/x/net/ipv4#pkg-note-BUG 726 | // As of Golang 1.18.4 727 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 728 | var wcm ipv4.ControlMessage 729 | if ifIndex != 0 { 730 | switch runtime.GOOS { 731 | case "darwin", "ios", "linux": 732 | wcm.IfIndex = ifIndex 733 | default: 734 | iface, _ := net.InterfaceByIndex(ifIndex) 735 | if err := s.ipv4conn.SetMulticastInterface(iface); err != nil { 736 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 737 | } 738 | } 739 | s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 740 | } else { 741 | for _, intf := range s.ifaces { 742 | switch runtime.GOOS { 743 | case "darwin", "ios", "linux": 744 | wcm.IfIndex = intf.Index 745 | default: 746 | if err := s.ipv4conn.SetMulticastInterface(&intf); err != nil { 747 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 748 | } 749 | } 750 | s.ipv4conn.WriteTo(buf, &wcm, ipv4Addr) 751 | } 752 | } 753 | } 754 | 755 | if s.ipv6conn != nil { 756 | // See https://pkg.go.dev/golang.org/x/net/ipv6#pkg-note-BUG 757 | // As of Golang 1.18.4 758 | // On Windows, the ControlMessage for ReadFrom and WriteTo methods of PacketConn is not implemented. 759 | var wcm ipv6.ControlMessage 760 | if ifIndex != 0 { 761 | switch runtime.GOOS { 762 | case "darwin", "ios", "linux": 763 | wcm.IfIndex = ifIndex 764 | default: 765 | iface, _ := net.InterfaceByIndex(ifIndex) 766 | if err := s.ipv6conn.SetMulticastInterface(iface); err != nil { 767 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 768 | } 769 | } 770 | s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 771 | } else { 772 | for _, intf := range s.ifaces { 773 | switch runtime.GOOS { 774 | case "darwin", "ios", "linux": 775 | wcm.IfIndex = intf.Index 776 | default: 777 | if err := s.ipv6conn.SetMulticastInterface(&intf); err != nil { 778 | log.Printf("[WARN] mdns: Failed to set multicast interface: %v", err) 779 | } 780 | } 781 | s.ipv6conn.WriteTo(buf, &wcm, ipv6Addr) 782 | } 783 | } 784 | } 785 | return nil 786 | } 787 | 788 | func isUnicastQuestion(q dns.Question) bool { 789 | // From RFC6762 790 | // 18.12. Repurposing of Top Bit of qclass in Question Section 791 | // 792 | // In the Question Section of a Multicast DNS query, the top bit of the 793 | // qclass field is used to indicate that unicast responses are preferred 794 | // for this particular question. (See Section 5.4.) 795 | return q.Qclass&qClassCacheFlush != 0 796 | } 797 | -------------------------------------------------------------------------------- /service.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "sync" 7 | ) 8 | 9 | // ServiceRecord contains the basic description of a service, which contains instance name, service type & domain 10 | type ServiceRecord struct { 11 | Instance string `json:"name"` // Instance name (e.g. "My web page") 12 | Service string `json:"type"` // Service name (e.g. _http._tcp.) 13 | Subtypes []string `json:"subtypes"` // Service subtypes 14 | Domain string `json:"domain"` // If blank, assumes "local" 15 | 16 | // private variable populated on ServiceRecord creation 17 | serviceName string 18 | serviceInstanceName string 19 | serviceTypeName string 20 | } 21 | 22 | // ServiceName returns a complete service name (e.g. _foobar._tcp.local.), which is composed 23 | // of a service name (also referred as service type) and a domain. 24 | func (s *ServiceRecord) ServiceName() string { 25 | return s.serviceName 26 | } 27 | 28 | // ServiceInstanceName returns a complete service instance name (e.g. MyDemo\ Service._foobar._tcp.local.), 29 | // which is composed from service instance name, service name and a domain. 30 | func (s *ServiceRecord) ServiceInstanceName() string { 31 | return s.serviceInstanceName 32 | } 33 | 34 | // ServiceTypeName returns the complete identifier for a DNS-SD query. 35 | func (s *ServiceRecord) ServiceTypeName() string { 36 | return s.serviceTypeName 37 | } 38 | 39 | // NewServiceRecord constructs a ServiceRecord. 40 | func NewServiceRecord(instance, service string, domain string) *ServiceRecord { 41 | service, subtypes := parseSubtypes(service) 42 | s := &ServiceRecord{ 43 | Instance: instance, 44 | Service: service, 45 | Domain: domain, 46 | serviceName: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)), 47 | } 48 | 49 | for _, subtype := range subtypes { 50 | s.Subtypes = append(s.Subtypes, fmt.Sprintf("%s._sub.%s", trimDot(subtype), s.serviceName)) 51 | } 52 | 53 | // Cache service instance name 54 | if instance != "" { 55 | s.serviceInstanceName = fmt.Sprintf("%s.%s", trimDot(s.Instance), s.ServiceName()) 56 | } 57 | 58 | // Cache service type name domain 59 | typeNameDomain := "local" 60 | if len(s.Domain) > 0 { 61 | typeNameDomain = trimDot(s.Domain) 62 | } 63 | s.serviceTypeName = fmt.Sprintf("_services._dns-sd._udp.%s.", typeNameDomain) 64 | 65 | return s 66 | } 67 | 68 | // lookupParams contains configurable properties to create a service discovery request 69 | type lookupParams struct { 70 | ServiceRecord 71 | Entries chan<- *ServiceEntry // Entries Channel 72 | 73 | isBrowsing bool 74 | stopProbing chan struct{} 75 | once sync.Once 76 | } 77 | 78 | // newLookupParams constructs a lookupParams. 79 | func newLookupParams(instance, service, domain string, isBrowsing bool, entries chan<- *ServiceEntry) *lookupParams { 80 | p := &lookupParams{ 81 | ServiceRecord: *NewServiceRecord(instance, service, domain), 82 | Entries: entries, 83 | isBrowsing: isBrowsing, 84 | } 85 | if !isBrowsing { 86 | p.stopProbing = make(chan struct{}) 87 | } 88 | return p 89 | } 90 | 91 | // Notify subscriber that no more entries will arrive. Mostly caused 92 | // by an expired context. 93 | func (l *lookupParams) done() { 94 | close(l.Entries) 95 | } 96 | 97 | func (l *lookupParams) disableProbing() { 98 | l.once.Do(func() { close(l.stopProbing) }) 99 | } 100 | 101 | // ServiceEntry represents a browse/lookup result for client API. 102 | // It is also used to configure service registration (server API), which is 103 | // used to answer multicast queries. 104 | type ServiceEntry struct { 105 | ServiceRecord 106 | HostName string `json:"hostname"` // Host machine DNS name 107 | Port int `json:"port"` // Service Port 108 | Text []string `json:"text"` // Service info served as a TXT record 109 | TTL uint32 `json:"ttl"` // TTL of the service record 110 | AddrIPv4 []net.IP `json:"-"` // Host machine IPv4 address 111 | AddrIPv6 []net.IP `json:"-"` // Host machine IPv6 address 112 | } 113 | 114 | // NewServiceEntry constructs a ServiceEntry. 115 | func NewServiceEntry(instance, service string, domain string) *ServiceEntry { 116 | return &ServiceEntry{ 117 | ServiceRecord: *NewServiceRecord(instance, service, domain), 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /service_test.go: -------------------------------------------------------------------------------- 1 | package zeroconf 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "testing" 7 | "time" 8 | 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | var ( 13 | mdnsName = "test--xxxxxxxxxxxx" 14 | mdnsService = "_test--xxxx._tcp" 15 | mdnsSubtype = "_test--xxxx._tcp,_fancy" 16 | mdnsDomain = "local." 17 | mdnsPort = 8888 18 | ) 19 | 20 | func startMDNS(ctx context.Context, port int, name, service, domain string) { 21 | // 5353 is default mdns port 22 | server, err := Register(name, service, domain, port, []string{"txtv=0", "lo=1", "la=2"}, nil) 23 | if err != nil { 24 | panic(errors.Wrap(err, "while registering mdns service")) 25 | } 26 | defer server.Shutdown() 27 | log.Printf("Published service: %s, type: %s, domain: %s", name, service, domain) 28 | 29 | <-ctx.Done() 30 | 31 | log.Printf("Shutting down.") 32 | 33 | } 34 | 35 | func TestBasic(t *testing.T) { 36 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 37 | defer cancel() 38 | 39 | go startMDNS(ctx, mdnsPort, mdnsName, mdnsService, mdnsDomain) 40 | 41 | time.Sleep(time.Second) 42 | 43 | resolver, err := NewResolver(nil) 44 | if err != nil { 45 | t.Fatalf("Expected create resolver success, but got %v", err) 46 | } 47 | entries := make(chan *ServiceEntry, 100) 48 | if err := resolver.Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 49 | t.Fatalf("Expected browse success, but got %v", err) 50 | } 51 | <-ctx.Done() 52 | 53 | if len(entries) != 1 { 54 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 55 | } 56 | result := <-entries 57 | if result.Domain != mdnsDomain { 58 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 59 | } 60 | if result.Service != mdnsService { 61 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 62 | } 63 | if result.Instance != mdnsName { 64 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 65 | } 66 | if result.Port != mdnsPort { 67 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 68 | } 69 | } 70 | 71 | func TestNoRegister(t *testing.T) { 72 | resolver, err := NewResolver(nil) 73 | if err != nil { 74 | t.Fatalf("Expected create resolver success, but got %v", err) 75 | } 76 | 77 | // before register, mdns resolve shuold not have any entry 78 | entries := make(chan *ServiceEntry) 79 | go func(results <-chan *ServiceEntry) { 80 | s := <-results 81 | if s != nil { 82 | t.Errorf("Expected empty service entries but got %v", *s) 83 | } 84 | }(entries) 85 | 86 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 87 | if err := resolver.Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 88 | t.Fatalf("Expected browse success, but got %v", err) 89 | } 90 | <-ctx.Done() 91 | cancel() 92 | } 93 | 94 | func TestSubtype(t *testing.T) { 95 | t.Run("browse with subtype", func(t *testing.T) { 96 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 97 | defer cancel() 98 | 99 | go startMDNS(ctx, mdnsPort, mdnsName, mdnsSubtype, mdnsDomain) 100 | 101 | time.Sleep(time.Second) 102 | 103 | resolver, err := NewResolver(nil) 104 | if err != nil { 105 | t.Fatalf("Expected create resolver success, but got %v", err) 106 | } 107 | entries := make(chan *ServiceEntry, 100) 108 | if err := resolver.Browse(ctx, mdnsSubtype, mdnsDomain, entries); err != nil { 109 | t.Fatalf("Expected browse success, but got %v", err) 110 | } 111 | <-ctx.Done() 112 | 113 | if len(entries) != 1 { 114 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 115 | } 116 | result := <-entries 117 | if result.Domain != mdnsDomain { 118 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 119 | } 120 | if result.Service != mdnsService { 121 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 122 | } 123 | if result.Instance != mdnsName { 124 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 125 | } 126 | if result.Port != mdnsPort { 127 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 128 | } 129 | }) 130 | 131 | t.Run("browse without subtype", func(t *testing.T) { 132 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) 133 | defer cancel() 134 | 135 | go startMDNS(ctx, mdnsPort, mdnsName, mdnsSubtype, mdnsDomain) 136 | 137 | time.Sleep(time.Second) 138 | 139 | resolver, err := NewResolver(nil) 140 | if err != nil { 141 | t.Fatalf("Expected create resolver success, but got %v", err) 142 | } 143 | entries := make(chan *ServiceEntry, 100) 144 | if err := resolver.Browse(ctx, mdnsService, mdnsDomain, entries); err != nil { 145 | t.Fatalf("Expected browse success, but got %v", err) 146 | } 147 | <-ctx.Done() 148 | 149 | if len(entries) != 1 { 150 | t.Fatalf("Expected number of service entries is 1, but got %d", len(entries)) 151 | } 152 | result := <-entries 153 | if result.Domain != mdnsDomain { 154 | t.Fatalf("Expected domain is %s, but got %s", mdnsDomain, result.Domain) 155 | } 156 | if result.Service != mdnsService { 157 | t.Fatalf("Expected service is %s, but got %s", mdnsService, result.Service) 158 | } 159 | if result.Instance != mdnsName { 160 | t.Fatalf("Expected instance is %s, but got %s", mdnsName, result.Instance) 161 | } 162 | if result.Port != mdnsPort { 163 | t.Fatalf("Expected port is %d, but got %d", mdnsPort, result.Port) 164 | } 165 | }) 166 | } 167 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------