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.