Sacrilege and Zeroconf

Tuesday, October 13th, 2009 2:06 am

(Yes, I will get back to CouchDB soon. It’s my blog, I can post what I want. :) )

Recently, I’ve been toying around with Zeroconf, the “look ma, no hands” network service discovery better known as Bonjour on Mac and Avahi on Linux. It actually does a lot of different things, from “headless” network address negotiation to host name resolution, all built on top of (mostly) the same stuff that drives DNS across the web. It’s really cool stuff, and also really late for me to be getting into the game.

Anyway, the stuff I am most interested in is the “service discovery”, or the way two iTunes clients see each other without anyone entering in IP addresses. This is solid user-friendly tech, and this style of thinking should be implemented in more software.

So, when I found pyzeroconf, a pure python implementation of the service discovery stack, I was stoked. Until I tried running it, and all of the tests failed no matter what system I ran it on. I was no longer stoked. Thus I began reading, and this is always a dangerous thing.

Below is just some fun I’ve been having. It is a horrific test implementation of multicast UDP service discovery. About the only thing it shares in common with Bonjour is that it uses the same multicast address. I understand that many educated network programmers will see this and run screaming — don’t worry, this is just to get a feel for the way it all works. Actually, any critiques / advice / links would be most welcome, as anyone who comes stumbling along here via the alleys of Google will also learn from it.

This should run in any default install of Python. There are some caveats, as this is just something I’ve thrown together. First: it doesn’t actually do anything with the service, it just prints out a list of every compatible service it finds (including itself). Second: I only tried it once in Windows, but while my Ubuntu box and Macbook saw the Windows service, Windows wouldn’t see any, even itself, so I have some hacking / learning to do if I want to support the devil. Finally — and this is where it gets really funny — I have had a few times where if I leave them running for too long I can’t hit the outside internet. At all. So, yeah, not a perfect implementation at all yet, but I’m moving along. :)

Eventually, I’ll tack on a graphical user interface and, you know, conform to some actual specification as opposed to throwing strings around.

Without further ado, my Frankenstein:

import socket
from threading import Thread, Lock
import time
import sys

# Multicast Globals
MC_IP = '224.0.0.251' # Oh no he didn't
MC_PORT = 5354 # Oh yes he did

def message(text):
    # Just an stdout.write wrapper
    sys.stdout.write(text)
    sys.stdout.flush()

class Broadcast(Thread):

    def __init__(self, client):
        Thread.__init__(self)
        self.die = False
        self.client = client
        self.lock = Lock()

    def run(self):
        self.emit('UP')
        self.last_emit = time.time()
        while True:
            if self.die:
                self.emit('DOWN')
                break
            # So the emit thread stays alive
            time.sleep(3)

    def emit(self, status='ALIVE'):
        self.lock.acquire()
        if status not in ('UP', 'DOWN', 'ALIVE'):
            raise ValueError('Status "%s" not valid.' % status)
        message = '_woot._tcp.local.|%s|%s|%s' %
                  (self.client.port, self.client.hostname, status)
        sock = socket.socket( socket.AF_INET, socket.SOCK_DGRAM )
        sock.sendto(message, (MC_IP, MC_PORT))
        sock.close()
        self.lock.release()

class Browser(Thread):

    def __init__(self, client):
        Thread.__init__(self)
        self.client = client
        self.die = False

    def run(self):
        while True:
            if self.die:
                break
            sock = socket.socket( socket.AF_INET, socket.SOCK_DGRAM )
            sock.bind((MC_IP, MC_PORT))
            data, addr = sock.recvfrom( 1024 )
            sock.close()
            self.parse(data, addr)

    def parse(self, data, addr):
        """
        DATA STRUCTURE:
        _woot._tcp.local.|port|hostname|STATUS
        STATUS can be UP, DOWN, or ALIVE
        """
        if not data.startswith('_woot._tcp.local.'):
            return
        data_list = data.split('|')
        if len(data_list) < 4:
            return
        ip = addr[0]
        port = int(data_list[1])
        host = data_list[2]
        status = data_list[3]
        if status in ('UP', 'ALIVE'):
            self.client.add_service(ip, port, host, status)
        if status in ('DOWN'):
            self.client.remove_service(host)

class Client(object):

    def __init__(self, port):
        self.services = {}
        self.browser = Browser(self)
        self.broadcast = Broadcast(self)
        self.hostname = socket.gethostname()
        if not self.hostname:
            raise Exception('Could not get hostname.')
        self.port = port
        message('Starting up: %s:%sn' % (self.hostname, self.port))

    def add_service(self, ip, port, host, status):
        if status not in ('UP', 'ALIVE'):
            raise ValueError('Status should be UP or ALIVE.')
        if host not in self.services.keys():
            self.services[host] = {}
            self.broadcast.emit()
        update = { 'ip': ip, 'port': port, 'host': host, 'status': status }
        update['time'] = time.time()
        self.services[host].update(update)
        # Do add stuff here

    def remove_service(self, host):
        # Do removal stuff here
        del self.services[host]

    def run(self):
        self.browser.start()
        self.broadcast.start()
        message('Searching for services.n')
        try:
            while True:
                time.sleep(3)
                self.report()
        except KeyboardInterrupt:
            message('Shutting down...')
            self.browser.die = True
            self.broadcast.die = True
            self.browser.join()
            self.broadcast.join()
            message('done.n')

    def report(self):
        if len(self.services) == 0:
            message('No services found.n')
            return
        message('%s SERVICE(S():n' % len(self.services))
        for service in self.services.values():
            message('* %s - %s:%s | %sn' %
                    (service['host'], service['ip'], service['port'],
                    service['status']))

if __name__ == '__main__':
    port = 1337
    if len(sys.argv) > 1:
        port = int(sys.argv[1])
    client = Client(port)
    client.run()

Save it as something (I like client.py personally) and run it just by:

python client.py

Optionally, you can add a port to the end of it, and that is the port the actual client will run on. The system should be smart enough to tell the other systems about it, but if it doesn’t work, hey, it’s a pre-alpha script file from the internet — give me a break. :) You should get something like the following if it is by itself:

Starting up: josh-laptop-ubuntu:1337
Searching for services.
1 SERVICE(S():
* josh-laptop-ubuntu - 192.168.0.120:1337 | UP

…with the SERVICE(S) list printing out to the terminal every few seconds. DO NOT run two of these on the same computer, I haven’t done it and I’m not at all certain what would happen. If you want to test multiple ones, use different computers or at least different virtual machines with local addresses.

Feel free to shout at me in the comments!

Leave a Reply