[mdns] Add bin/dev_finder command

This adds device discovery tools to the SDK that use mDNS.

Commands include:
--resolve Looks up a device based on its hostname w/ ".local" on the
end.

--list Looks up all devices that are running the _fuchsia._udp.local
service and lists then.

TEST: Manually with a Fuchsia device using edgerouter

DX-655 #comment

Change-Id: Ibc9411d3dff51e85522a9de6ea9a481defcd5a81
diff --git a/bin/dev_finder/BUILD.gn b/bin/dev_finder/BUILD.gn
new file mode 100644
index 0000000..9ca25f0
--- /dev/null
+++ b/bin/dev_finder/BUILD.gn
@@ -0,0 +1,26 @@
+# Copyright 2018 The Fuchsia Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+import("//build/go/go_library.gni")
+import("//build/go/go_binary.gni")
+
+go_library("dev_finder_lib") {
+  name = "dev_finder"
+
+  deps = [
+    "//tools/mdns:mdns_lib",
+    "//garnet/public/go/third_party:github.com/google/subcommands",
+  ]
+}
+
+go_binary("dev_finder") {
+  output_name = "dev_finder"
+  gopackage = "dev_finder"
+
+  sdk_category = "partner"
+
+  deps = [
+    ":dev_finder_lib",
+  ]
+}
diff --git a/bin/dev_finder/common.go b/bin/dev_finder/common.go
new file mode 100644
index 0000000..cec22c8
--- /dev/null
+++ b/bin/dev_finder/common.go
@@ -0,0 +1,133 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"net"
+	"time"
+
+	"mdns_lib"
+)
+
+type mDNSResponse struct {
+	rxIface  net.Interface
+	devAddr  net.Addr
+	rxPacket mdns.Packet
+}
+
+func (m *mDNSResponse) getReceiveIP() (net.IP, error) {
+	if unicastAddrs, err := m.rxIface.Addrs(); err != nil {
+		return nil, err
+	} else {
+		for _, addr := range unicastAddrs {
+			var ip net.IP
+			switch v := addr.(type) {
+			case *net.IPNet:
+				ip = v.IP
+			case *net.IPAddr:
+				ip = v.IP
+			}
+			if ip == nil || ip.To4() == nil {
+				continue
+			}
+			return ip, nil
+		}
+	}
+	return nil, fmt.Errorf("no IPv4 unicast addresses found on iface %v", m.rxIface)
+}
+
+type mDNSHandler func(mDNSResponse, bool, chan<- *fuchsiaDevice, chan<- error)
+
+// Contains common command information for embedding in other dev_finder commands.
+type devFinderCmd struct {
+	// The mDNS port to connect to.
+	mdnsPort int
+	// The timeout in ms to either give up or to exit the program after finding at least one
+	// device.
+	timeout int
+	// Determines whether to return the address of the address of the interface that
+	// established a connection to the Fuchsia device (rather than the address of the
+	// Fuchsia device on its own).
+	localResolve bool
+	// The limit of devices to discover. If this number of devices has been discovered before
+	// the timeout has been reached the program will exit successfully.
+	deviceLimit int
+
+	mdnsHandler mDNSHandler
+}
+
+type fuchsiaDevice struct {
+	addr   net.IP
+	domain string
+}
+
+func (cmd *devFinderCmd) SetCommonFlags(f *flag.FlagSet) {
+	f.IntVar(&cmd.mdnsPort, "port", 5353, "The port your mDNS servers operate on.")
+	f.IntVar(&cmd.timeout, "timeout", 2000, "The number of milliseconds before declaring a timeout.")
+	f.BoolVar(&cmd.localResolve, "local", false, "Returns the address of the interface to the host when doing service lookup/domain resolution.")
+	f.IntVar(&cmd.deviceLimit, "device_limit", 0, "Exits before the timeout at this many devices per resolution (zero means no limit).")
+}
+
+// Extracts the IP from its argument, returning an error if the type is unsupported.
+func addrToIP(addr net.Addr) (net.IP, error) {
+	switch v := addr.(type) {
+	case *net.IPNet:
+		return v.IP, nil
+	case *net.IPAddr:
+		return v.IP, nil
+	case *net.UDPAddr:
+		return v.IP, nil
+	}
+	return nil, errors.New("unsupported address type")
+}
+
+func (cmd *devFinderCmd) sendMDNSPacket(ctx context.Context, packet mdns.Packet) ([]*fuchsiaDevice, error) {
+	if cmd.mdnsHandler == nil {
+		return nil, fmt.Errorf("packet handler is nil")
+	}
+	if cmd.timeout <= 0 {
+		return nil, fmt.Errorf("invalid timeout value: %v", cmd.timeout)
+	}
+
+	var m mdns.MDNS
+	errChan := make(chan error)
+	devChan := make(chan *fuchsiaDevice)
+	m.AddHandler(func(recv net.Interface, addr net.Addr, rxPacket mdns.Packet) {
+		response := mDNSResponse{recv, addr, rxPacket}
+		cmd.mdnsHandler(response, cmd.localResolve, devChan, errChan)
+	})
+	m.AddErrorHandler(func(err error) {
+		errChan <- err
+	})
+	m.AddWarningHandler(func(addr net.Addr, err error) {
+		fmt.Printf("from: %v warn: %v\n", addr, err)
+	})
+	ctx, cancel := context.WithTimeout(ctx, time.Duration(cmd.timeout)*time.Millisecond)
+	defer cancel()
+	if err := m.Start(ctx, cmd.mdnsPort); err != nil {
+		errChan <- fmt.Errorf("starting mdns: %v", err)
+	}
+	m.Send(packet)
+	devices := make([]*fuchsiaDevice, 0)
+	for {
+		select {
+		case <-ctx.Done():
+			if len(devices) == 0 {
+				return nil, fmt.Errorf("timeout")
+			}
+			return devices, nil
+		case err := <-errChan:
+			return nil, err
+		case device := <-devChan:
+			devices = append(devices, device)
+			if cmd.deviceLimit != 0 && len(devices) == cmd.deviceLimit {
+				return devices, nil
+			}
+		}
+	}
+}
diff --git a/bin/dev_finder/list.go b/bin/dev_finder/list.go
new file mode 100644
index 0000000..6bd8249
--- /dev/null
+++ b/bin/dev_finder/list.go
@@ -0,0 +1,116 @@
+package main
+
+import (
+	"context"
+	"flag"
+	"fmt"
+	"log"
+	"strings"
+
+	"github.com/google/subcommands"
+	"mdns_lib"
+)
+
+const (
+	fuchsiaService = "_fuchsia._udp.local"
+)
+
+type listCmd struct {
+	devFinderCmd
+
+	// Determines whether or not to print the full device info.
+	fullInfo bool
+	// Filters domains that match this string when listing devices.
+	domainFilter string
+}
+
+func (*listCmd) Name() string {
+	return "list"
+}
+
+func (*listCmd) Usage() string {
+	return "list [flags...]\n\nflags:\n"
+}
+
+func (*listCmd) Synopsis() string {
+	return "lists all Fuchsia devices on the network"
+}
+
+func (cmd *listCmd) SetFlags(f *flag.FlagSet) {
+	cmd.SetCommonFlags(f)
+	f.StringVar(&cmd.domainFilter, "domain_filter", "", "When using the \"list\" command, returns only devices that match this domain name.")
+	f.BoolVar(&cmd.fullInfo, "full", false, "Print device address and domain")
+}
+
+func listMDNSHandler(resp mDNSResponse, localResolve bool, devChan chan<- *fuchsiaDevice, errChan chan<- error) {
+	for _, a := range resp.rxPacket.Answers {
+		if a.Class == mdns.IN && a.Type == mdns.PTR {
+			// This is a bit convoluted: the domain param is being used
+			// as a "service", and the Data field actually contains the
+			// domain of the device.
+			fuchsiaDomain := strings.Trim(string(a.Data[:]), "\n\r\f ")
+			if localResolve {
+				recvIP, err := resp.getReceiveIP()
+				if err != nil {
+					errChan <- err
+					return
+				}
+				devChan <- &fuchsiaDevice{recvIP, fuchsiaDomain}
+				continue
+			}
+			if ip, err := addrToIP(resp.devAddr); err != nil {
+				errChan <- fmt.Errorf("could not find addr for %v: %v", resp.devAddr, err)
+			} else {
+				devChan <- &fuchsiaDevice{
+					addr:   ip,
+					domain: fuchsiaDomain,
+				}
+			}
+		}
+	}
+}
+
+func (cmd *listCmd) execute(ctx context.Context) error {
+	listPacket := mdns.Packet{
+		Header: mdns.Header{QDCount: 1},
+		Questions: []mdns.Question{
+			mdns.Question{
+				Domain:  fuchsiaService,
+				Type:    mdns.PTR,
+				Class:   mdns.IN,
+				Unicast: false,
+			},
+		},
+	}
+	devices, err := cmd.sendMDNSPacket(ctx, listPacket)
+	if err != nil {
+		return fmt.Errorf("sending/receiving mdns packets: %v", err)
+	}
+	filteredDevices := make([]*fuchsiaDevice, 0)
+	for _, device := range devices {
+		if strings.Contains(device.domain, cmd.domainFilter) {
+			filteredDevices = append(filteredDevices, device)
+		}
+	}
+	if len(filteredDevices) == 0 {
+		return fmt.Errorf("no devices with domain matching '%v'", cmd.domainFilter)
+	}
+
+	for _, device := range filteredDevices {
+		if cmd.fullInfo {
+			fmt.Printf("%v %v\n", device.addr, device.domain)
+		} else {
+			fmt.Printf("%v\n", device.addr)
+		}
+	}
+	return nil
+}
+
+func (cmd *listCmd) Execute(ctx context.Context, _ *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+	cmd.mdnsHandler = listMDNSHandler
+	if err := cmd.execute(ctx); err != nil {
+		log.Print(err)
+		return subcommands.ExitFailure
+	}
+	return subcommands.ExitSuccess
+}
diff --git a/bin/dev_finder/main.go b/bin/dev_finder/main.go
new file mode 100644
index 0000000..72525e3
--- /dev/null
+++ b/bin/dev_finder/main.go
@@ -0,0 +1,25 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package main
+
+// Uses mDNS for Fuchsia device discovery.
+
+import (
+	"context"
+	"flag"
+	"os"
+
+	"github.com/google/subcommands"
+)
+
+func main() {
+	subcommands.Register(subcommands.HelpCommand(), "")
+	subcommands.Register(subcommands.CommandsCommand(), "")
+	subcommands.Register(subcommands.FlagsCommand(), "")
+	subcommands.Register(&listCmd{}, "")
+	subcommands.Register(&resolveCmd{}, "")
+
+	flag.Parse()
+	os.Exit(int(subcommands.Execute(context.Background())))
+}
diff --git a/bin/dev_finder/resolve.go b/bin/dev_finder/resolve.go
new file mode 100644
index 0000000..ee92350
--- /dev/null
+++ b/bin/dev_finder/resolve.go
@@ -0,0 +1,93 @@
+// Copyright 2018 The Fuchsia Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package main
+
+import (
+	"context"
+	"errors"
+	"flag"
+	"fmt"
+	"log"
+	"net"
+
+	"github.com/google/subcommands"
+	"mdns_lib"
+)
+
+const (
+	ipv4AddrLength = 4
+)
+
+type resolveCmd struct {
+	devFinderCmd
+}
+
+func (*resolveCmd) Name() string {
+	return "resolve"
+}
+
+func (*resolveCmd) Usage() string {
+	return "resolve [flags...] [domains...]\n\nflags:\n"
+}
+
+func (*resolveCmd) Synopsis() string {
+	return "Attempts to resolve all passed Fuchsia domain names on the network"
+}
+
+func (cmd *resolveCmd) SetFlags(f *flag.FlagSet) {
+	cmd.SetCommonFlags(f)
+}
+
+func resolveMDNSHandler(resp mDNSResponse, localResolve bool, devChan chan<- *fuchsiaDevice, errChan chan<- error) {
+	for _, a := range resp.rxPacket.Answers {
+		if a.Class == mdns.IN && a.Type == mdns.A &&
+			len(a.Data) == ipv4AddrLength {
+			if localResolve {
+				recvIP, err := resp.getReceiveIP()
+				if err != nil {
+					errChan <- err
+					return
+				}
+				devChan <- &fuchsiaDevice{recvIP, a.Domain}
+				continue
+			}
+			devChan <- &fuchsiaDevice{net.IP(a.Data), a.Domain}
+		}
+	}
+}
+
+func (cmd *resolveCmd) execute(ctx context.Context, domains ...string) error {
+	if len(domains) == 0 {
+		return errors.New("no domains supplied")
+	}
+	for _, domain := range domains {
+		devices, err := cmd.sendMDNSPacket(ctx, mdns.QuestionPacket(domain))
+		if err != nil {
+			return fmt.Errorf("sending/receiving mdns packets during resolve of domain '%s': %v", domain, err)
+		}
+		filteredDevices := make([]*fuchsiaDevice, 0)
+		for _, device := range devices {
+			if device.domain == domain {
+				filteredDevices = append(filteredDevices, device)
+			}
+		}
+		if len(filteredDevices) == 0 {
+			return fmt.Errorf("no devices with domain %v", domain)
+		}
+
+		for _, device := range filteredDevices {
+			fmt.Printf("%v\n", device.addr)
+		}
+	}
+	return nil
+}
+
+func (cmd *resolveCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
+	cmd.mdnsHandler = resolveMDNSHandler
+	if err := cmd.execute(ctx, f.Args()...); err != nil {
+		log.Print(err)
+		return subcommands.ExitFailure
+	}
+	return subcommands.ExitSuccess
+}
diff --git a/public/sdk/BUILD.gn b/public/sdk/BUILD.gn
index e9cbb85..1edfb2b 100644
--- a/public/sdk/BUILD.gn
+++ b/public/sdk/BUILD.gn
@@ -104,6 +104,7 @@
     "//build/images:images_sdk",
     "//garnet/bin/far:bin_sdk($host_toolchain)",
     "//garnet/bin/zxdb:zxdb_sdk($host_toolchain)",
+    "//garnet/bin/dev_finder:dev_finder_sdk($host_toolchain)",
     "//garnet/go/src/fidl:fidlgen_sdk($host_toolchain)",
     "//garnet/go/src/pm:pm_bin_sdk($host_toolchain)",
     "//garnet/lib/vulkan:vulkan_layers",