├── .github ├── CODEOWNERS ├── dependabot.yml └── workflows │ └── ci.yml ├── .gitignore ├── .go-version ├── LICENSE ├── README.md ├── client.go ├── go.mod ├── go.sum ├── server.go ├── server_test.go ├── zone.go └── zone_test.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # Each line is a file pattern followed by one or more owners. 2 | # More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners 3 | 4 | # Default owner 5 | * @hashicorp/team-ip-compliance @hashicorp/consul-core 6 | 7 | # Add override rules below. Each line is a file/folder pattern followed by one or more owners. 8 | # Being an owner means those groups or individuals will be added as reviewers to PRs affecting 9 | # those areas of the code. 10 | # Examples: 11 | # /docs/ @docs-team 12 | # *.js @js-team 13 | # *.go @go-team 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: "gomod" 5 | directory: "/" 6 | schedule: 7 | interval: "monthly" 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | push: 7 | branches: ["main"] 8 | tags: ["*"] 9 | 10 | permissions: 11 | contents: read 12 | 13 | jobs: 14 | lint-and-test: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 18 | 19 | - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 20 | with: 21 | go-version: '1.23' 22 | 23 | - name: Run golangci-lint 24 | uses: golangci/golangci-lint-action@08e2f20817b15149a52b5b3ebe7de50aff2ba8c5 # v3.4.0 25 | with: 26 | version: latest 27 | skip-cache: true 28 | 29 | - name: Run Go tests and generate coverage report 30 | run: go test -v -race ./... -coverprofile=coverage.out 31 | 32 | - name: Upload the coverage report 33 | uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 34 | with: 35 | path: coverage.out 36 | name: Coverage-report 37 | 38 | - name: Display Coverage Report 39 | run: go tool cover -func=coverage.out 40 | 41 | - name: Build Go 42 | run: go build ./... 43 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.go-version: -------------------------------------------------------------------------------- 1 | 1.20 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 HashiCorp, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 7 | the Software, and to permit persons to whom the Software is furnished to do so, 8 | subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # mdns 2 | [![Build Status](https://github.com/hashicorp/mdns/workflows/ci/badge.svg)](https://github.com/hashicorp/mdns/actions) 3 | 4 | Simple mDNS client/server library in Golang. mDNS or Multicast DNS can be 5 | used to discover services on the local network without the use of an authoritative 6 | DNS server. This enables peer-to-peer discovery. It is important to note that many 7 | networks restrict the use of multicasting, which prevents mDNS from functioning. 8 | Notably, multicast cannot be used in any sort of cloud, or shared infrastructure 9 | environment. However it works well in most office, home, or private infrastructure 10 | environments. 11 | 12 | Using the library is very simple, here is an example of publishing a service entry: 13 | ```go 14 | // Setup our service export 15 | host, _ := os.Hostname() 16 | info := []string{"My awesome service"} 17 | service, _ := mdns.NewMDNSService(host, "_foobar._tcp", "", "", 8000, nil, info) 18 | 19 | // Create the mDNS server, defer shutdown 20 | server, _ := mdns.NewServer(&mdns.Config{Zone: service}) 21 | defer server.Shutdown() 22 | ``` 23 | 24 | Doing a lookup for service providers is also very simple: 25 | ```go 26 | // Make a channel for results and start listening 27 | entriesCh := make(chan *mdns.ServiceEntry, 4) 28 | go func() { 29 | for entry := range entriesCh { 30 | fmt.Printf("Got new entry: %v\n", entry) 31 | } 32 | }() 33 | 34 | // Start the lookup 35 | mdns.Lookup("_foobar._tcp", entriesCh) 36 | close(entriesCh) 37 | ``` 38 | -------------------------------------------------------------------------------- /client.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mdns 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "net" 11 | "strings" 12 | "sync/atomic" 13 | "time" 14 | 15 | "github.com/miekg/dns" 16 | "golang.org/x/net/ipv4" 17 | "golang.org/x/net/ipv6" 18 | ) 19 | 20 | // ServiceEntry is returned after we query for a service 21 | type ServiceEntry struct { 22 | Name string 23 | Host string 24 | AddrV4 net.IP 25 | AddrV6 net.IP // @Deprecated 26 | AddrV6IPAddr *net.IPAddr 27 | Port int 28 | Info string 29 | InfoFields []string 30 | 31 | Addr net.IP // @Deprecated 32 | 33 | hasTXT bool 34 | sent bool 35 | } 36 | 37 | // complete is used to check if we have all the info we need 38 | func (s *ServiceEntry) complete() bool { 39 | return (s.AddrV4 != nil || s.AddrV6 != nil || s.Addr != nil) && s.Port != 0 && s.hasTXT 40 | } 41 | 42 | // QueryParam is used to customize how a Lookup is performed 43 | type QueryParam struct { 44 | Service string // Service to lookup 45 | Domain string // Lookup domain, default "local" 46 | Timeout time.Duration // Lookup timeout, default 1 second 47 | Interface *net.Interface // Multicast interface to use 48 | Entries chan<- *ServiceEntry // Entries Channel 49 | WantUnicastResponse bool // Unicast response desired, as per 5.4 in RFC 50 | DisableIPv4 bool // Whether to disable usage of IPv4 for MDNS operations. Does not affect discovered addresses. 51 | DisableIPv6 bool // Whether to disable usage of IPv6 for MDNS operations. Does not affect discovered addresses. 52 | Logger *log.Logger // Optionally provide a *log.Logger to better manage log output. 53 | } 54 | 55 | // DefaultParams is used to return a default set of QueryParam's 56 | func DefaultParams(service string) *QueryParam { 57 | return &QueryParam{ 58 | Service: service, 59 | Domain: "local", 60 | Timeout: time.Second, 61 | Entries: make(chan *ServiceEntry), 62 | WantUnicastResponse: false, // TODO(reddaly): Change this default. 63 | DisableIPv4: false, 64 | DisableIPv6: false, 65 | } 66 | } 67 | 68 | // Query looks up a given service, in a domain, waiting at most 69 | // for a timeout before finishing the query. The results are streamed 70 | // to a channel. Sends will not block, so clients should make sure to 71 | // either read or buffer. 72 | func Query(params *QueryParam) error { 73 | return QueryContext(context.Background(), params) 74 | } 75 | 76 | // QueryContext looks up a given service, in a domain, waiting at most 77 | // for a timeout before finishing the query. The results are streamed 78 | // to a channel. Sends will not block, so clients should make sure to 79 | // either read or buffer. QueryContext will attempt to stop the query 80 | // on cancellation. 81 | func QueryContext(ctx context.Context, params *QueryParam) error { 82 | if params.Logger == nil { 83 | params.Logger = log.Default() 84 | } 85 | // Create a new client 86 | client, err := newClient(!params.DisableIPv4, !params.DisableIPv6, params.Logger) 87 | if err != nil { 88 | return err 89 | } 90 | defer client.Close() 91 | 92 | go func() { 93 | select { 94 | case <-ctx.Done(): 95 | client.Close() 96 | case <-client.closedCh: 97 | return 98 | } 99 | }() 100 | 101 | // Set the multicast interface 102 | if params.Interface != nil { 103 | if err := client.setInterface(params.Interface); err != nil { 104 | return err 105 | } 106 | } 107 | 108 | // Ensure defaults are set 109 | if params.Domain == "" { 110 | params.Domain = "local" 111 | } 112 | if params.Timeout == 0 { 113 | params.Timeout = time.Second 114 | } 115 | 116 | // Run the query 117 | return client.query(params) 118 | } 119 | 120 | // Lookup is the same as Query, however it uses all the default parameters 121 | func Lookup(service string, entries chan<- *ServiceEntry) error { 122 | params := DefaultParams(service) 123 | params.Entries = entries 124 | return Query(params) 125 | } 126 | 127 | // Client provides a query interface that can be used to 128 | // search for service providers using mDNS 129 | type client struct { 130 | use_ipv4 bool 131 | use_ipv6 bool 132 | 133 | ipv4UnicastConn *net.UDPConn 134 | ipv6UnicastConn *net.UDPConn 135 | 136 | ipv4MulticastConn *net.UDPConn 137 | ipv6MulticastConn *net.UDPConn 138 | 139 | closed int32 140 | closedCh chan struct{} // TODO(reddaly): This doesn't appear to be used. 141 | 142 | log *log.Logger 143 | } 144 | 145 | // NewClient creates a new mdns Client that can be used to query 146 | // for records 147 | func newClient(v4 bool, v6 bool, logger *log.Logger) (*client, error) { 148 | if !v4 && !v6 { 149 | return nil, fmt.Errorf("Must enable at least one of IPv4 and IPv6 querying") 150 | } 151 | 152 | // TODO(reddaly): At least attempt to bind to the port required in the spec. 153 | // Create a IPv4 listener 154 | var uconn4 *net.UDPConn 155 | var uconn6 *net.UDPConn 156 | var mconn4 *net.UDPConn 157 | var mconn6 *net.UDPConn 158 | var err error 159 | 160 | // Establish unicast connections 161 | if v4 { 162 | uconn4, err = net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) 163 | if err != nil { 164 | logger.Printf("[ERR] mdns: Failed to bind to udp4 port: %v", err) 165 | } 166 | } 167 | if v6 { 168 | uconn6, err = net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0}) 169 | if err != nil { 170 | logger.Printf("[ERR] mdns: Failed to bind to udp6 port: %v", err) 171 | } 172 | } 173 | if uconn4 == nil && uconn6 == nil { 174 | return nil, fmt.Errorf("failed to bind to any unicast udp port") 175 | } 176 | 177 | // Establish multicast connections 178 | if v4 { 179 | mconn4, err = net.ListenMulticastUDP("udp4", nil, ipv4Addr) 180 | if err != nil { 181 | logger.Printf("[ERR] mdns: Failed to bind to udp4 port: %v", err) 182 | } 183 | } 184 | if v6 { 185 | mconn6, err = net.ListenMulticastUDP("udp6", nil, ipv6Addr) 186 | if err != nil { 187 | logger.Printf("[ERR] mdns: Failed to bind to udp6 port: %v", err) 188 | } 189 | } 190 | if mconn4 == nil && mconn6 == nil { 191 | return nil, fmt.Errorf("failed to bind to any multicast udp port") 192 | } 193 | 194 | // Check that unicast and multicast connections have been made for IPv4 and IPv6 195 | // and disable the respective protocol if not. 196 | if uconn4 == nil || mconn4 == nil { 197 | logger.Printf("[INFO] mdns: Failed to listen to both unicast and multicast on IPv4") 198 | uconn4 = nil 199 | mconn4 = nil 200 | v4 = false 201 | } 202 | if uconn6 == nil || mconn6 == nil { 203 | logger.Printf("[INFO] mdns: Failed to listen to both unicast and multicast on IPv6") 204 | uconn6 = nil 205 | mconn6 = nil 206 | v6 = false 207 | } 208 | if !v4 && !v6 { 209 | return nil, fmt.Errorf("at least one of IPv4 and IPv6 must be enabled for querying") 210 | } 211 | 212 | c := &client{ 213 | use_ipv4: v4, 214 | use_ipv6: v6, 215 | ipv4MulticastConn: mconn4, 216 | ipv6MulticastConn: mconn6, 217 | ipv4UnicastConn: uconn4, 218 | ipv6UnicastConn: uconn6, 219 | closedCh: make(chan struct{}), 220 | log: logger, 221 | } 222 | return c, nil 223 | } 224 | 225 | // Close is used to cleanup the client 226 | func (c *client) Close() error { 227 | if !atomic.CompareAndSwapInt32(&c.closed, 0, 1) { 228 | // something else already closed it 229 | return nil 230 | } 231 | 232 | c.log.Printf("[INFO] mdns: Closing client %v", *c) 233 | close(c.closedCh) 234 | 235 | if c.ipv4UnicastConn != nil { 236 | c.ipv4UnicastConn.Close() 237 | } 238 | if c.ipv6UnicastConn != nil { 239 | c.ipv6UnicastConn.Close() 240 | } 241 | if c.ipv4MulticastConn != nil { 242 | c.ipv4MulticastConn.Close() 243 | } 244 | if c.ipv6MulticastConn != nil { 245 | c.ipv6MulticastConn.Close() 246 | } 247 | 248 | return nil 249 | } 250 | 251 | // setInterface is used to set the query interface, uses system 252 | // default if not provided 253 | func (c *client) setInterface(iface *net.Interface) error { 254 | if c.use_ipv4 { 255 | p := ipv4.NewPacketConn(c.ipv4UnicastConn) 256 | if err := p.SetMulticastInterface(iface); err != nil { 257 | return err 258 | } 259 | p = ipv4.NewPacketConn(c.ipv4MulticastConn) 260 | if err := p.SetMulticastInterface(iface); err != nil { 261 | return err 262 | } 263 | } 264 | if c.use_ipv6 { 265 | p2 := ipv6.NewPacketConn(c.ipv6UnicastConn) 266 | if err := p2.SetMulticastInterface(iface); err != nil { 267 | return err 268 | } 269 | p2 = ipv6.NewPacketConn(c.ipv6MulticastConn) 270 | if err := p2.SetMulticastInterface(iface); err != nil { 271 | return err 272 | } 273 | } 274 | return nil 275 | } 276 | 277 | // msgAddr carries the message and source address from recv to message processing. 278 | type msgAddr struct { 279 | msg *dns.Msg 280 | src *net.UDPAddr 281 | } 282 | 283 | // query is used to perform a lookup and stream results 284 | func (c *client) query(params *QueryParam) error { 285 | // Create the service name 286 | serviceAddr := fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain)) 287 | 288 | // Start listening for response packets 289 | msgCh := make(chan *msgAddr, 32) 290 | if c.use_ipv4 { 291 | go c.recv(c.ipv4UnicastConn, msgCh) 292 | go c.recv(c.ipv4MulticastConn, msgCh) 293 | } 294 | if c.use_ipv6 { 295 | go c.recv(c.ipv6UnicastConn, msgCh) 296 | go c.recv(c.ipv6MulticastConn, msgCh) 297 | } 298 | 299 | // Send the query 300 | m := new(dns.Msg) 301 | m.SetQuestion(serviceAddr, dns.TypePTR) 302 | // RFC 6762, section 18.12. Repurposing of Top Bit of qclass in Question 303 | // Section 304 | // 305 | // In the Question Section of a Multicast DNS query, the top bit of the qclass 306 | // field is used to indicate that unicast responses are preferred for this 307 | // particular question. (See Section 5.4.) 308 | if params.WantUnicastResponse { 309 | m.Question[0].Qclass |= 1 << 15 310 | } 311 | m.RecursionDesired = false 312 | if err := c.sendQuery(m); err != nil { 313 | return err 314 | } 315 | 316 | // Map the in-progress responses 317 | inprogress := make(map[string]*ServiceEntry) 318 | 319 | // Listen until we reach the timeout 320 | finish := time.After(params.Timeout) 321 | for { 322 | select { 323 | case resp := <-msgCh: 324 | var inp *ServiceEntry 325 | for _, answer := range append(resp.msg.Answer, resp.msg.Extra...) { 326 | // TODO(reddaly): Check that response corresponds to serviceAddr? 327 | switch rr := answer.(type) { 328 | case *dns.PTR: 329 | // Create new entry for this 330 | inp = ensureName(inprogress, rr.Ptr) 331 | 332 | case *dns.SRV: 333 | // Check for a target mismatch 334 | if rr.Target != rr.Hdr.Name { 335 | alias(inprogress, rr.Hdr.Name, rr.Target) 336 | } 337 | 338 | // Get the port 339 | inp = ensureName(inprogress, rr.Hdr.Name) 340 | inp.Host = rr.Target 341 | inp.Port = int(rr.Port) 342 | 343 | case *dns.TXT: 344 | // Pull out the txt 345 | inp = ensureName(inprogress, rr.Hdr.Name) 346 | inp.Info = strings.Join(rr.Txt, "|") 347 | inp.InfoFields = rr.Txt 348 | inp.hasTXT = true 349 | 350 | case *dns.A: 351 | // Pull out the IP 352 | inp = ensureName(inprogress, rr.Hdr.Name) 353 | inp.Addr = rr.A // @Deprecated 354 | inp.AddrV4 = rr.A 355 | 356 | case *dns.AAAA: 357 | // Pull out the IP 358 | inp = ensureName(inprogress, rr.Hdr.Name) 359 | inp.Addr = rr.AAAA // @Deprecated 360 | inp.AddrV6 = rr.AAAA // @Deprecated 361 | inp.AddrV6IPAddr = &net.IPAddr{IP: rr.AAAA} 362 | // link-local IPv6 addresses must be qualified with a zone (interface). Zone is 363 | // specific to this machine/network-namespace and so won't be carried in the 364 | // mDNS message itself. We borrow the zone from the source address of the UDP 365 | // packet, as the link-local address should be valid on that interface. 366 | if rr.AAAA.IsLinkLocalUnicast() || rr.AAAA.IsLinkLocalMulticast() { 367 | inp.AddrV6IPAddr.Zone = resp.src.Zone 368 | } 369 | } 370 | } 371 | 372 | if inp == nil { 373 | continue 374 | } 375 | 376 | // Check if this entry is complete 377 | if inp.complete() { 378 | if inp.sent { 379 | continue 380 | } 381 | inp.sent = true 382 | select { 383 | case params.Entries <- inp: 384 | default: 385 | } 386 | } else { 387 | // Fire off a node specific query 388 | m := new(dns.Msg) 389 | m.SetQuestion(inp.Name, dns.TypePTR) 390 | m.RecursionDesired = false 391 | if err := c.sendQuery(m); err != nil { 392 | c.log.Printf("[ERR] mdns: Failed to query instance %s: %v", inp.Name, err) 393 | } 394 | } 395 | case <-finish: 396 | return nil 397 | } 398 | } 399 | } 400 | 401 | // sendQuery is used to multicast a query out 402 | func (c *client) sendQuery(q *dns.Msg) error { 403 | buf, err := q.Pack() 404 | if err != nil { 405 | return err 406 | } 407 | if c.ipv4UnicastConn != nil { 408 | _, err = c.ipv4UnicastConn.WriteToUDP(buf, ipv4Addr) 409 | if err != nil { 410 | return err 411 | } 412 | } 413 | if c.ipv6UnicastConn != nil { 414 | _, err = c.ipv6UnicastConn.WriteToUDP(buf, ipv6Addr) 415 | if err != nil { 416 | return err 417 | } 418 | } 419 | return nil 420 | } 421 | 422 | // recv is used to receive until we get a shutdown 423 | func (c *client) recv(l *net.UDPConn, msgCh chan *msgAddr) { 424 | if l == nil { 425 | return 426 | } 427 | buf := make([]byte, 65536) 428 | for atomic.LoadInt32(&c.closed) == 0 { 429 | n, addr, err := l.ReadFromUDP(buf) 430 | 431 | if atomic.LoadInt32(&c.closed) == 1 { 432 | return 433 | } 434 | 435 | if err != nil { 436 | c.log.Printf("[ERR] mdns: Failed to read packet: %v", err) 437 | continue 438 | } 439 | msg := new(dns.Msg) 440 | if err := msg.Unpack(buf[:n]); err != nil { 441 | c.log.Printf("[ERR] mdns: Failed to unpack packet: %v", err) 442 | continue 443 | } 444 | select { 445 | case msgCh <- &msgAddr{ 446 | msg: msg, 447 | src: addr, 448 | }: 449 | case <-c.closedCh: 450 | return 451 | } 452 | } 453 | } 454 | 455 | // ensureName is used to ensure the named node is in progress 456 | func ensureName(inprogress map[string]*ServiceEntry, name string) *ServiceEntry { 457 | if inp, ok := inprogress[name]; ok { 458 | return inp 459 | } 460 | inp := &ServiceEntry{ 461 | Name: name, 462 | } 463 | inprogress[name] = inp 464 | return inp 465 | } 466 | 467 | // alias is used to setup an alias between two entries 468 | func alias(inprogress map[string]*ServiceEntry, src, dst string) { 469 | srcEntry := ensureName(inprogress, src) 470 | inprogress[dst] = srcEntry 471 | } 472 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/hashicorp/mdns 2 | 3 | go 1.23.0 4 | 5 | require ( 6 | github.com/miekg/dns v1.1.55 7 | golang.org/x/net v0.38.0 8 | ) 9 | 10 | require ( 11 | golang.org/x/mod v0.7.0 // indirect 12 | golang.org/x/sys v0.31.0 // indirect 13 | golang.org/x/tools v0.3.0 // indirect 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 2 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 3 | golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA= 4 | golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 5 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 6 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 7 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 8 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 9 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 10 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 11 | golang.org/x/tools v0.3.0 h1:SrNbZl6ECOS1qFzgTdQfWXZM9XBkiA6tkFrH9YSTPHM= 12 | golang.org/x/tools v0.3.0/go.mod h1:/rWhSS2+zyEVwoJf8YAX6L2f0ntZ7Kn/mGgAWcipA5k= 13 | -------------------------------------------------------------------------------- /server.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mdns 5 | 6 | import ( 7 | "fmt" 8 | "log" 9 | "net" 10 | "strings" 11 | "sync/atomic" 12 | 13 | "github.com/miekg/dns" 14 | ) 15 | 16 | const ( 17 | ipv4mdns = "224.0.0.251" 18 | ipv6mdns = "ff02::fb" 19 | mdnsPort = 5353 20 | forceUnicastResponses = false 21 | ) 22 | 23 | var ( 24 | ipv4Addr = &net.UDPAddr{ 25 | IP: net.ParseIP(ipv4mdns), 26 | Port: mdnsPort, 27 | } 28 | ipv6Addr = &net.UDPAddr{ 29 | IP: net.ParseIP(ipv6mdns), 30 | Port: mdnsPort, 31 | } 32 | ) 33 | 34 | // Config is used to configure the mDNS server 35 | type Config struct { 36 | // Zone must be provided to support responding to queries 37 | Zone Zone 38 | 39 | // Iface if provided binds the multicast listener to the given 40 | // interface. If not provided, the system default multicase interface 41 | // is used. 42 | Iface *net.Interface 43 | 44 | // LogEmptyResponses indicates the server should print an informative message 45 | // when there is an mDNS query for which the server has no response. 46 | LogEmptyResponses bool 47 | 48 | // Logger can optionally be set to use an alternative logger instead of the default. 49 | Logger *log.Logger 50 | } 51 | 52 | // mDNS server is used to listen for mDNS queries and respond if we 53 | // have a matching local record 54 | type Server struct { 55 | config *Config 56 | 57 | ipv4List *net.UDPConn 58 | ipv6List *net.UDPConn 59 | 60 | shutdown int32 61 | shutdownCh chan struct{} 62 | } 63 | 64 | // NewServer is used to create a new mDNS server from a config 65 | func NewServer(config *Config) (*Server, error) { 66 | // Create the listeners 67 | ipv4List, _ := net.ListenMulticastUDP("udp4", config.Iface, ipv4Addr) 68 | ipv6List, _ := net.ListenMulticastUDP("udp6", config.Iface, ipv6Addr) 69 | 70 | // Check if we have any listener 71 | if ipv4List == nil && ipv6List == nil { 72 | return nil, fmt.Errorf("no multicast listeners could be started") 73 | } 74 | 75 | if config.Logger == nil { 76 | config.Logger = log.Default() 77 | } 78 | 79 | s := &Server{ 80 | config: config, 81 | ipv4List: ipv4List, 82 | ipv6List: ipv6List, 83 | shutdownCh: make(chan struct{}), 84 | } 85 | 86 | if ipv4List != nil { 87 | go s.recv(s.ipv4List) 88 | } 89 | 90 | if ipv6List != nil { 91 | go s.recv(s.ipv6List) 92 | } 93 | 94 | return s, nil 95 | } 96 | 97 | // Shutdown is used to shutdown the listener 98 | func (s *Server) Shutdown() error { 99 | if !atomic.CompareAndSwapInt32(&s.shutdown, 0, 1) { 100 | // something else already closed us 101 | return nil 102 | } 103 | 104 | close(s.shutdownCh) 105 | 106 | if s.ipv4List != nil { 107 | s.ipv4List.Close() 108 | } 109 | if s.ipv6List != nil { 110 | s.ipv6List.Close() 111 | } 112 | return nil 113 | } 114 | 115 | // recv is a long running routine to receive packets from an interface 116 | func (s *Server) recv(c *net.UDPConn) { 117 | if c == nil { 118 | return 119 | } 120 | buf := make([]byte, 65536) 121 | for atomic.LoadInt32(&s.shutdown) == 0 { 122 | n, from, err := c.ReadFrom(buf) 123 | 124 | if err != nil { 125 | continue 126 | } 127 | if err := s.parsePacket(buf[:n], from); err != nil { 128 | s.config.Logger.Printf("[ERR] mdns: Failed to handle query: %v", err) 129 | } 130 | } 131 | } 132 | 133 | // parsePacket is used to parse an incoming packet 134 | func (s *Server) parsePacket(packet []byte, from net.Addr) error { 135 | var msg dns.Msg 136 | if err := msg.Unpack(packet); err != nil { 137 | s.config.Logger.Printf("[ERR] mdns: Failed to unpack packet: %v", err) 138 | return err 139 | } 140 | return s.handleQuery(&msg, from) 141 | } 142 | 143 | // handleQuery is used to handle an incoming query 144 | func (s *Server) handleQuery(query *dns.Msg, from net.Addr) error { 145 | if query.Opcode != dns.OpcodeQuery { 146 | // "In both multicast query and multicast response messages, the OPCODE MUST 147 | // be zero on transmission (only standard queries are currently supported 148 | // over multicast). Multicast DNS messages received with an OPCODE other 149 | // than zero MUST be silently ignored." Note: OpcodeQuery == 0 150 | return fmt.Errorf("mdns: received query with non-zero Opcode %v: %v", query.Opcode, *query) 151 | } 152 | if query.Rcode != 0 { 153 | // "In both multicast query and multicast response messages, the Response 154 | // Code MUST be zero on transmission. Multicast DNS messages received with 155 | // non-zero Response Codes MUST be silently ignored." 156 | return fmt.Errorf("mdns: received query with non-zero Rcode %v: %v", query.Rcode, *query) 157 | } 158 | 159 | // TODO(reddaly): Handle "TC (Truncated) Bit": 160 | // In query messages, if the TC bit is set, it means that additional 161 | // Known-Answer records may be following shortly. A responder SHOULD 162 | // record this fact, and wait for those additional Known-Answer records, 163 | // before deciding whether to respond. If the TC bit is clear, it means 164 | // that the querying host has no additional Known Answers. 165 | if query.Truncated { 166 | return fmt.Errorf("[ERR] mdns: support for DNS requests with high truncated bit not implemented: %v", *query) 167 | } 168 | 169 | var unicastAnswer, multicastAnswer []dns.RR 170 | 171 | // Handle each question 172 | for _, q := range query.Question { 173 | mrecs, urecs := s.handleQuestion(q) 174 | multicastAnswer = append(multicastAnswer, mrecs...) 175 | unicastAnswer = append(unicastAnswer, urecs...) 176 | } 177 | 178 | // See section 18 of RFC 6762 for rules about DNS headers. 179 | resp := func(unicast bool) *dns.Msg { 180 | // 18.1: ID (Query Identifier) 181 | // 0 for multicast response, query.Id for unicast response 182 | id := uint16(0) 183 | if unicast { 184 | id = query.Id 185 | } 186 | 187 | var answer []dns.RR 188 | if unicast { 189 | answer = unicastAnswer 190 | } else { 191 | answer = multicastAnswer 192 | } 193 | if len(answer) == 0 { 194 | return nil 195 | } 196 | 197 | return &dns.Msg{ 198 | MsgHdr: dns.MsgHdr{ 199 | Id: id, 200 | 201 | // 18.2: QR (Query/Response) Bit - must be set to 1 in response. 202 | Response: true, 203 | 204 | // 18.3: OPCODE - must be zero in response (OpcodeQuery == 0) 205 | Opcode: dns.OpcodeQuery, 206 | 207 | // 18.4: AA (Authoritative Answer) Bit - must be set to 1 208 | Authoritative: true, 209 | 210 | // The following fields must all be set to 0: 211 | // 18.5: TC (TRUNCATED) Bit 212 | // 18.6: RD (Recursion Desired) Bit 213 | // 18.7: RA (Recursion Available) Bit 214 | // 18.8: Z (Zero) Bit 215 | // 18.9: AD (Authentic Data) Bit 216 | // 18.10: CD (Checking Disabled) Bit 217 | // 18.11: RCODE (Response Code) 218 | }, 219 | // 18.12 pertains to questions (handled by handleQuestion) 220 | // 18.13 pertains to resource records (handled by handleQuestion) 221 | 222 | // 18.14: Name Compression - responses should be compressed (though see 223 | // caveats in the RFC), so set the Compress bit (part of the dns library 224 | // API, not part of the DNS packet) to true. 225 | Compress: true, 226 | 227 | Answer: answer, 228 | } 229 | } 230 | 231 | if s.config.LogEmptyResponses && len(multicastAnswer) == 0 && len(unicastAnswer) == 0 { 232 | questions := make([]string, len(query.Question)) 233 | for i, q := range query.Question { 234 | questions[i] = q.Name 235 | } 236 | s.config.Logger.Printf("no responses for query with questions: %s", strings.Join(questions, ", ")) 237 | } 238 | 239 | if mresp := resp(false); mresp != nil { 240 | if err := s.sendResponse(mresp, from, false); err != nil { 241 | return fmt.Errorf("mdns: error sending multicast response: %v", err) 242 | } 243 | } 244 | if uresp := resp(true); uresp != nil { 245 | if err := s.sendResponse(uresp, from, true); err != nil { 246 | return fmt.Errorf("mdns: error sending unicast response: %v", err) 247 | } 248 | } 249 | return nil 250 | } 251 | 252 | // handleQuestion is used to handle an incoming question 253 | // 254 | // The response to a question may be transmitted over multicast, unicast, or 255 | // both. The return values are DNS records for each transmission type. 256 | func (s *Server) handleQuestion(q dns.Question) (multicastRecs, unicastRecs []dns.RR) { 257 | records := s.config.Zone.Records(q) 258 | 259 | if len(records) == 0 { 260 | return nil, nil 261 | } 262 | 263 | // Handle unicast and multicast responses. 264 | // TODO(reddaly): The decision about sending over unicast vs. multicast is not 265 | // yet fully compliant with RFC 6762. For example, the unicast bit should be 266 | // ignored if the records in question are close to TTL expiration. For now, 267 | // we just use the unicast bit to make the decision, as per the spec: 268 | // RFC 6762, section 18.12. Repurposing of Top Bit of qclass in Question 269 | // Section 270 | // 271 | // In the Question Section of a Multicast DNS query, the top bit of the 272 | // qclass field is used to indicate that unicast responses are preferred 273 | // for this particular question. (See Section 5.4.) 274 | if q.Qclass&(1<<15) != 0 || forceUnicastResponses { 275 | return nil, records 276 | } 277 | return records, nil 278 | } 279 | 280 | // sendResponse is used to send a response packet 281 | func (s *Server) sendResponse(resp *dns.Msg, from net.Addr, unicast bool) error { 282 | // TODO(reddaly): Respect the unicast argument, and allow sending responses 283 | // over multicast. 284 | buf, err := resp.Pack() 285 | if err != nil { 286 | return err 287 | } 288 | 289 | // Determine the socket to send from 290 | addr := from.(*net.UDPAddr) 291 | if addr.IP.To4() != nil { 292 | _, err = s.ipv4List.WriteToUDP(buf, addr) 293 | return err 294 | } else { 295 | _, err = s.ipv6List.WriteToUDP(buf, addr) 296 | return err 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /server_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mdns 5 | 6 | import ( 7 | "fmt" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func TestServer_StartStop(t *testing.T) { 13 | s := makeService(t) 14 | serv, err := NewServer(&Config{Zone: s}) 15 | if err != nil { 16 | t.Fatalf("err: %v", err) 17 | } 18 | 19 | if err := serv.Shutdown(); err != nil { 20 | t.Fatalf("err: %v", err) 21 | } 22 | } 23 | 24 | func TestServer_Lookup(t *testing.T) { 25 | serv, err := NewServer(&Config{Zone: makeServiceWithServiceName(t, "_foobar._tcp")}) 26 | if err != nil { 27 | t.Fatalf("err: %v", err) 28 | } 29 | defer func() { 30 | if err := serv.Shutdown(); err != nil { 31 | t.Fatalf("err: %v", err) 32 | } 33 | }() 34 | 35 | entries := make(chan *ServiceEntry, 1) 36 | errCh := make(chan error, 1) 37 | defer close(errCh) 38 | go func() { 39 | select { 40 | case e := <-entries: 41 | if e.Name != "hostname._foobar._tcp.local." { 42 | errCh <- fmt.Errorf("Entry has the wrong name: %+v", e) 43 | return 44 | } 45 | if e.Port != 80 { 46 | errCh <- fmt.Errorf("Entry has the wrong port: %+v", e) 47 | return 48 | } 49 | if e.Info != "Local web server" { 50 | errCh <- fmt.Errorf("Entry as the wrong Info: %+v", e) 51 | return 52 | } 53 | errCh <- nil 54 | case <-time.After(80 * time.Millisecond): 55 | errCh <- fmt.Errorf("Timed out waiting for response") 56 | } 57 | }() 58 | 59 | params := &QueryParam{ 60 | Service: "_foobar._tcp", 61 | Domain: "local", 62 | Timeout: 50 * time.Millisecond, 63 | Entries: entries, 64 | DisableIPv6: true, 65 | } 66 | err = Query(params) 67 | if err != nil { 68 | t.Fatalf("err: %v", err) 69 | } 70 | 71 | err = <-errCh 72 | if err != nil { 73 | t.Fatalf("err: %v", err) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /zone.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mdns 5 | 6 | import ( 7 | "fmt" 8 | "net" 9 | "os" 10 | "strings" 11 | 12 | "github.com/miekg/dns" 13 | ) 14 | 15 | const ( 16 | // defaultTTL is the default TTL value in returned DNS records in seconds. 17 | defaultTTL = 120 18 | ) 19 | 20 | // Zone is the interface used to integrate with the server and 21 | // to serve records dynamically 22 | type Zone interface { 23 | // Records returns DNS records in response to a DNS question. 24 | Records(q dns.Question) []dns.RR 25 | } 26 | 27 | // MDNSService is used to export a named service by implementing a Zone 28 | type MDNSService struct { 29 | Instance string // Instance name (e.g. "hostService name") 30 | Service string // Service name (e.g. "_http._tcp.") 31 | Domain string // If blank, assumes "local" 32 | HostName string // Host machine DNS name (e.g. "mymachine.net.") 33 | Port int // Service Port 34 | IPs []net.IP // IP addresses for the service's host 35 | TXT []string // Service TXT records 36 | 37 | serviceAddr string // Fully qualified service address 38 | instanceAddr string // Fully qualified instance address 39 | enumAddr string // _services._dns-sd._udp. 40 | } 41 | 42 | // validateFQDN returns an error if the passed string is not a fully qualified 43 | // hdomain name (more specifically, a hostname). 44 | func validateFQDN(s string) error { 45 | if len(s) == 0 { 46 | return fmt.Errorf("FQDN must not be blank") 47 | } 48 | if s[len(s)-1] != '.' { 49 | return fmt.Errorf("FQDN must end in period: %s", s) 50 | } 51 | // TODO(reddaly): Perform full validation. 52 | 53 | return nil 54 | } 55 | 56 | // NewMDNSService returns a new instance of MDNSService. 57 | // 58 | // If domain, hostName, or ips is set to the zero value, then a default value 59 | // will be inferred from the operating system. 60 | // 61 | // TODO(reddaly): This interface may need to change to account for "unique 62 | // record" conflict rules of the mDNS protocol. Upon startup, the server should 63 | // check to ensure that the instance name does not conflict with other instance 64 | // names, and, if required, select a new name. There may also be conflicting 65 | // hostName A/AAAA records. 66 | func NewMDNSService(instance, service, domain, hostName string, port int, ips []net.IP, txt []string) (*MDNSService, error) { 67 | // Sanity check inputs 68 | if instance == "" { 69 | return nil, fmt.Errorf("missing service instance name") 70 | } 71 | if service == "" { 72 | return nil, fmt.Errorf("missing service name") 73 | } 74 | if port == 0 { 75 | return nil, fmt.Errorf("missing service port") 76 | } 77 | 78 | // Set default domain 79 | if domain == "" { 80 | domain = "local." 81 | } 82 | if err := validateFQDN(domain); err != nil { 83 | return nil, fmt.Errorf("domain %q is not a fully-qualified domain name: %v", domain, err) 84 | } 85 | 86 | // Get host information if no host is specified. 87 | if hostName == "" { 88 | var err error 89 | hostName, err = os.Hostname() 90 | if err != nil { 91 | return nil, fmt.Errorf("could not determine host: %v", err) 92 | } 93 | hostName = fmt.Sprintf("%s.", hostName) 94 | } 95 | if err := validateFQDN(hostName); err != nil { 96 | return nil, fmt.Errorf("hostName %q is not a fully-qualified domain name: %v", hostName, err) 97 | } 98 | 99 | if len(ips) == 0 { 100 | var err error 101 | ips, err = net.LookupIP(hostName) 102 | if err != nil { 103 | // Try appending the host domain suffix and lookup again 104 | // (required for Linux-based hosts) 105 | tmpHostName := fmt.Sprintf("%s%s", hostName, domain) 106 | 107 | ips, err = net.LookupIP(tmpHostName) 108 | 109 | if err != nil { 110 | return nil, fmt.Errorf("could not determine host IP addresses for %s", hostName) 111 | } 112 | } 113 | } 114 | for _, ip := range ips { 115 | if ip.To4() == nil && ip.To16() == nil { 116 | return nil, fmt.Errorf("invalid IP address in IPs list: %v", ip) 117 | } 118 | } 119 | 120 | return &MDNSService{ 121 | Instance: instance, 122 | Service: service, 123 | Domain: domain, 124 | HostName: hostName, 125 | Port: port, 126 | IPs: ips, 127 | TXT: txt, 128 | serviceAddr: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)), 129 | instanceAddr: fmt.Sprintf("%s.%s.%s.", instance, trimDot(service), trimDot(domain)), 130 | enumAddr: fmt.Sprintf("_services._dns-sd._udp.%s.", trimDot(domain)), 131 | }, nil 132 | } 133 | 134 | // trimDot is used to trim the dots from the start or end of a string 135 | func trimDot(s string) string { 136 | return strings.Trim(s, ".") 137 | } 138 | 139 | // Records returns DNS records in response to a DNS question. 140 | func (m *MDNSService) Records(q dns.Question) []dns.RR { 141 | switch q.Name { 142 | case m.enumAddr: 143 | return m.serviceEnum(q) 144 | case m.serviceAddr: 145 | return m.serviceRecords(q) 146 | case m.instanceAddr: 147 | return m.instanceRecords(q) 148 | case m.HostName: 149 | if q.Qtype == dns.TypeA || q.Qtype == dns.TypeAAAA { 150 | return m.instanceRecords(q) 151 | } 152 | fallthrough 153 | default: 154 | return nil 155 | } 156 | } 157 | 158 | func (m *MDNSService) serviceEnum(q dns.Question) []dns.RR { 159 | switch q.Qtype { 160 | case dns.TypeANY: 161 | fallthrough 162 | case dns.TypePTR: 163 | rr := &dns.PTR{ 164 | Hdr: dns.RR_Header{ 165 | Name: q.Name, 166 | Rrtype: dns.TypePTR, 167 | Class: dns.ClassINET, 168 | Ttl: defaultTTL, 169 | }, 170 | Ptr: m.serviceAddr, 171 | } 172 | return []dns.RR{rr} 173 | default: 174 | return nil 175 | } 176 | } 177 | 178 | // serviceRecords is called when the query matches the service name 179 | func (m *MDNSService) serviceRecords(q dns.Question) []dns.RR { 180 | switch q.Qtype { 181 | case dns.TypeANY: 182 | fallthrough 183 | case dns.TypePTR: 184 | // Build a PTR response for the service 185 | rr := &dns.PTR{ 186 | Hdr: dns.RR_Header{ 187 | Name: q.Name, 188 | Rrtype: dns.TypePTR, 189 | Class: dns.ClassINET, 190 | Ttl: defaultTTL, 191 | }, 192 | Ptr: m.instanceAddr, 193 | } 194 | servRec := []dns.RR{rr} 195 | 196 | // Get the instance records 197 | instRecs := m.instanceRecords(dns.Question{ 198 | Name: m.instanceAddr, 199 | Qtype: dns.TypeANY, 200 | }) 201 | 202 | // Return the service record with the instance records 203 | return append(servRec, instRecs...) 204 | default: 205 | return nil 206 | } 207 | } 208 | 209 | // serviceRecords is called when the query matches the instance name 210 | func (m *MDNSService) instanceRecords(q dns.Question) []dns.RR { 211 | switch q.Qtype { 212 | case dns.TypeANY: 213 | // Get the SRV, which includes A and AAAA 214 | recs := m.instanceRecords(dns.Question{ 215 | Name: m.instanceAddr, 216 | Qtype: dns.TypeSRV, 217 | }) 218 | 219 | // Add the TXT record 220 | recs = append(recs, m.instanceRecords(dns.Question{ 221 | Name: m.instanceAddr, 222 | Qtype: dns.TypeTXT, 223 | })...) 224 | return recs 225 | 226 | case dns.TypeA: 227 | var rr []dns.RR 228 | for _, ip := range m.IPs { 229 | if ip4 := ip.To4(); ip4 != nil { 230 | rr = append(rr, &dns.A{ 231 | Hdr: dns.RR_Header{ 232 | Name: m.HostName, 233 | Rrtype: dns.TypeA, 234 | Class: dns.ClassINET, 235 | Ttl: defaultTTL, 236 | }, 237 | A: ip4, 238 | }) 239 | } 240 | } 241 | return rr 242 | 243 | case dns.TypeAAAA: 244 | var rr []dns.RR 245 | for _, ip := range m.IPs { 246 | if ip.To4() != nil { 247 | // TODO(reddaly): IPv4 addresses could be encoded in IPv6 format and 248 | // putinto AAAA records, but the current logic puts ipv4-encodable 249 | // addresses into the A records exclusively. Perhaps this should be 250 | // configurable? 251 | continue 252 | } 253 | 254 | if ip16 := ip.To16(); ip16 != nil { 255 | rr = append(rr, &dns.AAAA{ 256 | Hdr: dns.RR_Header{ 257 | Name: m.HostName, 258 | Rrtype: dns.TypeAAAA, 259 | Class: dns.ClassINET, 260 | Ttl: defaultTTL, 261 | }, 262 | AAAA: ip16, 263 | }) 264 | } 265 | } 266 | return rr 267 | 268 | case dns.TypeSRV: 269 | // Create the SRV Record 270 | srv := &dns.SRV{ 271 | Hdr: dns.RR_Header{ 272 | Name: q.Name, 273 | Rrtype: dns.TypeSRV, 274 | Class: dns.ClassINET, 275 | Ttl: defaultTTL, 276 | }, 277 | Priority: 10, 278 | Weight: 1, 279 | Port: uint16(m.Port), 280 | Target: m.HostName, 281 | } 282 | recs := []dns.RR{srv} 283 | 284 | // Add the A record 285 | recs = append(recs, m.instanceRecords(dns.Question{ 286 | Name: m.instanceAddr, 287 | Qtype: dns.TypeA, 288 | })...) 289 | 290 | // Add the AAAA record 291 | recs = append(recs, m.instanceRecords(dns.Question{ 292 | Name: m.instanceAddr, 293 | Qtype: dns.TypeAAAA, 294 | })...) 295 | return recs 296 | 297 | case dns.TypeTXT: 298 | txt := &dns.TXT{ 299 | Hdr: dns.RR_Header{ 300 | Name: q.Name, 301 | Rrtype: dns.TypeTXT, 302 | Class: dns.ClassINET, 303 | Ttl: defaultTTL, 304 | }, 305 | Txt: m.TXT, 306 | } 307 | return []dns.RR{txt} 308 | } 309 | return nil 310 | } 311 | -------------------------------------------------------------------------------- /zone_test.go: -------------------------------------------------------------------------------- 1 | // Copyright (c) HashiCorp, Inc. 2 | // SPDX-License-Identifier: MIT 3 | 4 | package mdns 5 | 6 | import ( 7 | "bytes" 8 | "net" 9 | "reflect" 10 | "testing" 11 | 12 | "github.com/miekg/dns" 13 | ) 14 | 15 | func makeService(t *testing.T) *MDNSService { 16 | return makeServiceWithServiceName(t, "_http._tcp") 17 | } 18 | 19 | func makeServiceWithServiceName(t *testing.T, service string) *MDNSService { 20 | m, err := NewMDNSService( 21 | "hostname", 22 | service, 23 | "local.", 24 | "testhost.", 25 | 80, // port 26 | []net.IP{net.IP([]byte{192, 168, 0, 42}), net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc")}, 27 | []string{"Local web server"}) // TXT 28 | 29 | if err != nil { 30 | t.Fatalf("err: %v", err) 31 | } 32 | 33 | return m 34 | } 35 | 36 | func TestNewMDNSService_BadParams(t *testing.T) { 37 | for _, test := range []struct { 38 | testName string 39 | hostName string 40 | domain string 41 | }{ 42 | { 43 | "NewMDNSService should fail when passed hostName that is not a legal fully-qualified domain name", 44 | "hostname", // not legal FQDN - should be "hostname." or "hostname.local.", etc. 45 | "local.", // legal 46 | }, 47 | { 48 | "NewMDNSService should fail when passed domain that is not a legal fully-qualified domain name", 49 | "hostname.", // legal 50 | "local", // should be "local." 51 | }, 52 | } { 53 | _, err := NewMDNSService( 54 | "instance name", 55 | "_http._tcp", 56 | test.domain, 57 | test.hostName, 58 | 80, // port 59 | []net.IP{net.IP([]byte{192, 168, 0, 42})}, 60 | []string{"Local web server"}) // TXT 61 | if err == nil { 62 | t.Fatalf("%s: error expected, but got none", test.testName) 63 | } 64 | } 65 | } 66 | 67 | func TestMDNSService_BadAddr(t *testing.T) { 68 | s := makeService(t) 69 | q := dns.Question{ 70 | Name: "random", 71 | Qtype: dns.TypeANY, 72 | } 73 | recs := s.Records(q) 74 | if len(recs) != 0 { 75 | t.Fatalf("bad: %v", recs) 76 | } 77 | } 78 | 79 | func TestMDNSService_ServiceAddr(t *testing.T) { 80 | s := makeService(t) 81 | q := dns.Question{ 82 | Name: "_http._tcp.local.", 83 | Qtype: dns.TypeANY, 84 | } 85 | recs := s.Records(q) 86 | if got, want := len(recs), 5; got != want { 87 | t.Fatalf("got %d records, want %d: %v", got, want, recs) 88 | } 89 | 90 | if ptr, ok := recs[0].(*dns.PTR); !ok { 91 | t.Errorf("recs[0] should be PTR record, got: %v, all records: %v", recs[0], recs) 92 | } else if got, want := ptr.Ptr, "hostname._http._tcp.local."; got != want { 93 | t.Fatalf("bad PTR record %v: got %v, want %v", ptr, got, want) 94 | } 95 | 96 | if _, ok := recs[1].(*dns.SRV); !ok { 97 | t.Errorf("recs[1] should be SRV record, got: %v, all reccords: %v", recs[1], recs) 98 | } 99 | if _, ok := recs[2].(*dns.A); !ok { 100 | t.Errorf("recs[2] should be A record, got: %v, all records: %v", recs[2], recs) 101 | } 102 | if _, ok := recs[3].(*dns.AAAA); !ok { 103 | t.Errorf("recs[3] should be AAAA record, got: %v, all records: %v", recs[3], recs) 104 | } 105 | if _, ok := recs[4].(*dns.TXT); !ok { 106 | t.Errorf("recs[4] should be TXT record, got: %v, all records: %v", recs[4], recs) 107 | } 108 | 109 | q.Qtype = dns.TypePTR 110 | if recs2 := s.Records(q); !reflect.DeepEqual(recs, recs2) { 111 | t.Fatalf("PTR question should return same result as ANY question: ANY => %v, PTR => %v", recs, recs2) 112 | } 113 | } 114 | 115 | func TestMDNSService_InstanceAddr_ANY(t *testing.T) { 116 | s := makeService(t) 117 | q := dns.Question{ 118 | Name: "hostname._http._tcp.local.", 119 | Qtype: dns.TypeANY, 120 | } 121 | recs := s.Records(q) 122 | if len(recs) != 4 { 123 | t.Fatalf("bad: %v", recs) 124 | } 125 | if _, ok := recs[0].(*dns.SRV); !ok { 126 | t.Fatalf("bad: %v", recs[0]) 127 | } 128 | if _, ok := recs[1].(*dns.A); !ok { 129 | t.Fatalf("bad: %v", recs[1]) 130 | } 131 | if _, ok := recs[2].(*dns.AAAA); !ok { 132 | t.Fatalf("bad: %v", recs[2]) 133 | } 134 | if _, ok := recs[3].(*dns.TXT); !ok { 135 | t.Fatalf("bad: %v", recs[3]) 136 | } 137 | } 138 | 139 | func TestMDNSService_InstanceAddr_SRV(t *testing.T) { 140 | s := makeService(t) 141 | q := dns.Question{ 142 | Name: "hostname._http._tcp.local.", 143 | Qtype: dns.TypeSRV, 144 | } 145 | recs := s.Records(q) 146 | if len(recs) != 3 { 147 | t.Fatalf("bad: %v", recs) 148 | } 149 | srv, ok := recs[0].(*dns.SRV) 150 | if !ok { 151 | t.Fatalf("bad: %v", recs[0]) 152 | } 153 | if _, ok := recs[1].(*dns.A); !ok { 154 | t.Fatalf("bad: %v", recs[1]) 155 | } 156 | if _, ok := recs[2].(*dns.AAAA); !ok { 157 | t.Fatalf("bad: %v", recs[2]) 158 | } 159 | 160 | if srv.Port != uint16(s.Port) { 161 | t.Fatalf("bad: %v", recs[0]) 162 | } 163 | } 164 | 165 | func TestMDNSService_InstanceAddr_A(t *testing.T) { 166 | s := makeService(t) 167 | q := dns.Question{ 168 | Name: "hostname._http._tcp.local.", 169 | Qtype: dns.TypeA, 170 | } 171 | recs := s.Records(q) 172 | if len(recs) != 1 { 173 | t.Fatalf("bad: %v", recs) 174 | } 175 | a, ok := recs[0].(*dns.A) 176 | if !ok { 177 | t.Fatalf("bad: %v", recs[0]) 178 | } 179 | if !bytes.Equal(a.A, []byte{192, 168, 0, 42}) { 180 | t.Fatalf("bad: %v", recs[0]) 181 | } 182 | } 183 | 184 | func TestMDNSService_InstanceAddr_AAAA(t *testing.T) { 185 | s := makeService(t) 186 | q := dns.Question{ 187 | Name: "hostname._http._tcp.local.", 188 | Qtype: dns.TypeAAAA, 189 | } 190 | recs := s.Records(q) 191 | if len(recs) != 1 { 192 | t.Fatalf("bad: %v", recs) 193 | } 194 | a4, ok := recs[0].(*dns.AAAA) 195 | if !ok { 196 | t.Fatalf("bad: %v", recs[0]) 197 | } 198 | ip6 := net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc") 199 | if got := len(ip6); got != net.IPv6len { 200 | t.Fatalf("test IP failed to parse (len = %d, want %d)", got, net.IPv6len) 201 | } 202 | if !a4.AAAA.Equal(ip6) { 203 | t.Fatalf("bad: %v", recs[0]) 204 | } 205 | } 206 | 207 | func TestMDNSService_InstanceAddr_TXT(t *testing.T) { 208 | s := makeService(t) 209 | q := dns.Question{ 210 | Name: "hostname._http._tcp.local.", 211 | Qtype: dns.TypeTXT, 212 | } 213 | recs := s.Records(q) 214 | if len(recs) != 1 { 215 | t.Fatalf("bad: %v", recs) 216 | } 217 | txt, ok := recs[0].(*dns.TXT) 218 | if !ok { 219 | t.Fatalf("bad: %v", recs[0]) 220 | } 221 | if got, want := txt.Txt, s.TXT; !reflect.DeepEqual(got, want) { 222 | t.Fatalf("TXT record mismatch for %v: got %v, want %v", recs[0], got, want) 223 | } 224 | } 225 | 226 | func TestMDNSService_HostNameQuery(t *testing.T) { 227 | s := makeService(t) 228 | for _, test := range []struct { 229 | q dns.Question 230 | want []dns.RR 231 | }{ 232 | { 233 | dns.Question{Name: "testhost.", Qtype: dns.TypeA}, 234 | []dns.RR{&dns.A{ 235 | Hdr: dns.RR_Header{ 236 | Name: "testhost.", 237 | Rrtype: dns.TypeA, 238 | Class: dns.ClassINET, 239 | Ttl: 120, 240 | }, 241 | A: net.IP([]byte{192, 168, 0, 42}), 242 | }}, 243 | }, 244 | { 245 | dns.Question{Name: "testhost.", Qtype: dns.TypeAAAA}, 246 | []dns.RR{&dns.AAAA{ 247 | Hdr: dns.RR_Header{ 248 | Name: "testhost.", 249 | Rrtype: dns.TypeAAAA, 250 | Class: dns.ClassINET, 251 | Ttl: 120, 252 | }, 253 | AAAA: net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc"), 254 | }}, 255 | }, 256 | } { 257 | if got := s.Records(test.q); !reflect.DeepEqual(got, test.want) { 258 | t.Errorf("hostname query failed: s.Records(%v) = %v, want %v", test.q, got, test.want) 259 | } 260 | } 261 | } 262 | 263 | func TestMDNSService_serviceEnum_PTR(t *testing.T) { 264 | s := makeService(t) 265 | q := dns.Question{ 266 | Name: "_services._dns-sd._udp.local.", 267 | Qtype: dns.TypePTR, 268 | } 269 | recs := s.Records(q) 270 | if len(recs) != 1 { 271 | t.Fatalf("bad: %v", recs) 272 | } 273 | if ptr, ok := recs[0].(*dns.PTR); !ok { 274 | t.Errorf("recs[0] should be PTR record, got: %v, all records: %v", recs[0], recs) 275 | } else if got, want := ptr.Ptr, "_http._tcp.local."; got != want { 276 | t.Fatalf("bad PTR record %v: got %v, want %v", ptr, got, want) 277 | } 278 | } 279 | --------------------------------------------------------------------------------