Service discovery with ZeroConf and Go

Zeroconf is a set of technologies that allow services and devices on the network to be discoverable. It's also known as Bonjour / Avahi / mDNS. For example, Apple uses mDNS to find any connected speakers, Apple TV and many more. Basically any client on the network can find another service or device for a certain protocol and discover it's IP (4/6) address and other useful information without any need to configure anything manually.

You can see here some of the services discovered over the network (Living Room, PI):

Few years ago i created omxremote, a simple media player built for Raspberry PI and powered by omxplayer that specifically ships with PI. Omxremote provides a web ui to view files and start playback along with an internal API that wraps calls to omxplayer. That's pretty much it. One problem though: my raspberry pi unit usually does not have a persistent IP address (aka 192.168.0.x). So to access the web ui i need to know the address beforehand.

While the initial omxremote implementation worked fine for a while, i was interested to see if i could make a react native app for my iphone. The goal was to have the app to figure out the IP/Port of the omxremote API without any manual fiddling. Once the service is discovered the app would make all the API calls just like it would with the hardcoded url.

With zeroconf each service has to register itself with the service type / name / domain. You can find more information about available types here. Omxremote is written in Go so i found a third-party library that provides Zeroconf capabilities. Essentially, when the omxremote starts we register the service for others to discover. Lets look at the example code:

package main

import (
  "fmt"
  "log"
  "net/http"

  "github.com/grandcat/zeroconf"
)

// Our fake service.
// This could be a HTTP/TCP service or whatever you want.
func startService() {
  http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(rw, "Hello world!")
  })

  log.Println("starting http service...")
  if err := http.ListenAndServe(":8080", nil); err != nil {
    log.Fatal(err)
  }
}

func main() {
  // Start out http service
  go startService()

  // Extra information about our service
  meta := []string{
    "version=0.1.0",
    "hello=world",
  }

  service, err := zeroconf.Register(
    "awesome-sauce",   // service instance name
    "_omxremote._tcp", // service type and protocl
    "local.",          // service domain
    8080,              // service port
    meta,              // service metadata
    nil,               // register on all network interfaces
  )

  if err != nil {
    log.Fatal(err)
  }

  defer service.Shutdown()

  // Sleep forever
  select{}
}

Compile the code and start the service. Once its up and running it will advertise itself as _omxremote._tcp over the network. You can use dns-sd tool that ships with OSX to discover our service:

$ dns-sd -B _omxremote._tcp

Browsing for _omxremote._tcp
DATE: ---Thu 07 Sep 2017---
20:27:26.470  ...STARTING...
Timestamp     A/R    Flags  if Domain   Service Type         Instance Name
20:27:26.471  Add        2   4 local.   _omxremote._tcp.     awesome-sauce

Once discovered we want to know which IP/PORT the service is listening on. Let inspect:

$dns-sd -L awesome-sauce  _omxremote._tcp

Lookup awesome-sauce._omxremote._tcp.local
DATE: ---Thu 07 Sep 2017---
20:29:01.440  ...STARTING...
20:29:01.441  awesome-sauce._omxremote._tcp.local. can be reached at Dan-S-Macbook.local.local.:8080 (interface 4)
 version=0.1.0 hello=world

We can also use avahi-browse on linux to discover and resolve the services:

$ avahi-browse -a -r

+  wlan0 IPv4 awesome-sauce                                 _omxremote._tcp      local
=  wlan0 IPv4 awesome-sauce                                 _omxremote._tcp      local
   hostname = [Dan-S-Macbook.local.local]
   address = [192.168.0.104]
   port = [8080]
   txt = ["hello=world" "version=0.1.0"]

So all that info we used to register the service is available. To make this tutorial complete, lets write another Go program that will discover and talk to the service we created earlier.

package main

import (
  "context"
  "fmt"
  "io/ioutil"
  "log"
  "net/http"

  "github.com/grandcat/zeroconf"
)

func serviceCall(ip string, port int) {
  url := fmt.Sprintf("http://%v:%v", ip, port)

  log.Println("Making call to", url)
  resp, err := http.Get(url)
  if err != nil {
    log.Fatal(err)
  }
  defer resp.Body.Close()

  data, _ := ioutil.ReadAll(resp.Body)
  log.Printf("Got response: %s\n", data)
}

func main() {
  resolver, err := zeroconf.NewResolver(nil)
  if err != nil {
    log.Fatal(err)
  }

  // Channel to receive discovered service entries
  entries := make(chan *zeroconf.ServiceEntry)

  go func(results <-chan *zeroconf.ServiceEntry) {
    for entry := range results {
      log.Println("Found service:", entry.ServiceInstanceName(), entry.Text)
      serviceCall(entry.AddrIPv4[0].String(), entry.Port)
    }
  }(entries)

  ctx := context.Background()

  err = resolver.Browse(ctx, "_omxremote._tcp", "local.", entries)
  if err != nil {
    log.Fatalln("Failed to browse:", err.Error())
  }

  <-ctx.Done()
}

Once we start the program it will try to discover all available _omxremote._tcp services and try to make a simple HTTP call:

$ go run zeroconf_discover.go

2017/09/07 20:46:07 Found service: awesome-sauce._omxremote._tcp.local. [version=0.1.0 hello=world]
2017/09/07 20:46:07 Making call to http://192.168.0.104:8080
2017/09/07 20:46:07 Got response: Hello world!

It worked! That's cool, so we cat start building self-discoverable stuff. Zeroconf could useful for IOT devices as long as they support mDNS protocol. Based on the examples above i added zeroconf support to omxremote.

As i've mentioned before i was working on the react native app to get the omxremote service discovered, i found react-native-zeroconf package that just does that. Heads up: if you're using Expo it will not work due to it's lack of support for the native packages. You'll have to roll with bare react native app instead.

Added a basic view to discover all omxremote services:

On the screenshot above i actually have 2 services running, one on my local machine and another one on the raspberry pi. We also get to see service metadata. This metadata could be used for any configuration / setup steps that your service or client might require.

That's it! Hope you enjoyed the overview.