Shortly after coding netgrowl.py and PicoRendezvous.py, I hacked a simple UDP packet relay that sits on my firewall and lets me receive Growl notifications from some of my machines out on the Internet. It did the trick, but was too much of a hack job to last long, so I finally re-implemented it the right way, and with an eye to providing some useful code snippets for people out there.
If you don't want to (or can't) punch holes in your firewall, Eric Windisch has built a very neat solution called stoReGrowl that provides caching of Growl messages and a poller to retrieve those messages and distribute them to local Growl clients. The idea is that the server can exist on one network, the cache on another, and the poller on a third; or any combination thereof. (sic)
What It Does
The code below uses both netgrowl.py and PicoRendezvous.py, and demonstrates a few useful general-purpose techniques, such as:
- Using a background thread for finding any Macs running Growl (by performing a Rendezvous query every two minutes or so)
- Decoding a Growl network packet (at least the most important portions of it)
- The packet relay itself (which is trivial, but always fun to do neatly)
- Cleaning up a threaded application with the KeyboardInterrupt exception
I removed all the argument handling code, for two reasons:
- It made the code too long, and there are only so many ways to use getopt
- You only need to change the password anyway - it figures out the rest automagically.
So, basically, you stick it somewhere that can receive UDP packets on port 9887 from outside your firewall, set the password, and run it.
If you use growlnotify to send an UDP notification to it and the password matches (you are using passwords, aren't you?), it will re-send it to all the Macs running Growl on your LAN, and spit out a log line like this:
192.168.100.42 - - [31/Mar/2005 21:53:07] NOTIFY ('Command-Line Growl Notification', 'Title', 'Description', 'growlnotify') 86 ['192.168.0.42','192.168.0.50']
...where the first IP address is the sender's and the array of IP addresses at the end are the Macs it relayed the message to.
It will also send registration messages, and will print discarded and not relay anything if the password doesn't match.
Obviously, you have to make sure you're using the same password everywhere. But then Growl doesn't need exquisitely complex security measures right now.
Also, this code will only work with the Growl source code currently in their Subversion repository - I'm told a stock 0.6 binary install doesn't have networking support.
With any luck, 0.7 will come out with network support using a similar protocol or a simplfied SIP variant (I'm pushing for SIP, for a zillion reasons I won't go into here). When it does (and when I upgrade), I'll review this code since I use it on a daily basis.
Without further ado, here it is:
#!/usr/bin/env python """Growl 0.6 Network Protocol Relay""" __version__ = "0.6.2" # Initial digits will always match Growl version __author__ = "Rui Carmo (http://the.taoofmac.com)" __copyright__ = "(C) 2004 Rui Carmo. Code under BSD License." from PicoRendezvous import * from netgrowl import * from SocketServer import * import struct, time, md5, threading, pprint class RendezvousWatcher(threading.Thread): """Class to maintain an updated cache of known Growl servers""" def __init__(self): threading.Thread.__init__(self) self.servers =  self.timer = threading.Event() self.interval = 120.0 # no point in checking more often # end def def shutdown(self): self.timer.set() # end def def getServers(self): return self.servers # end def def run(self): """Main loop""" p = PicoRendezvous() while 1: if self.timer.isSet(): return self.servers = p.query('_growl._tcp.local.') self.timer.wait(self.interval) # end def # end class class GrowlPacket: """Performs basic decoding of a Growl UDP Packet.""" def __init__(self, data, inpassword = None, outpassword = None): """Initializes and validates the packet""" self.valid = False self.data = data self.digest = self.data[-16:] self.password = outpassword checksum = md5.new() checksum.update(self.data[:-16]) if inpassword: # inbound password checksum.update(inpassword) if self.digest == checksum.digest(): self.valid = True if outpassword != inpassword: # re-compute hash checksum = md5.new() checksum.update(self.data[:-16]) checksum.update(outpassword) self.data = self.data[:-16] + checksum.digest() # end def def type(self): """Returns the packet type""" if self.data == '\x01': return 'NOTIFY' else: return 'REGISTER' # end def def info(self): """Returns a subset of packet information""" if self.type() == 'NOTIFY': nlen = struct.unpack("!H",str(self.data[4:6])) tlen = struct.unpack("!H",str(self.data[6:8])) dlen = struct.unpack("!H",str(self.data[8:10])) alen = struct.unpack("!H",str(self.data[10:12])) return struct.unpack(("%ds%ds%ds%ds") % (nlen, tlen, dlen, alen), self.data[12:len(self.data)-16]) else: length = struct.unpack("!H",str(self.data[2:4])) return self.data[6:7+length] # end def # end class class GrowlRelay(UDPServer): """Growl Notification Relay""" allow_reuse_address = True def __init__(self, inpassword = None, outpassword = None): """Initializes the relay and launches the resolver thread""" self.inpassword = inpassword self.outpassword = outpassword self.resolver = RendezvousWatcher() self.resolver.start() UDPServer.__init__(self,('localhost', GROWL_UDP_PORT), _RequestHandler) # end def def server_close(self): self.resolver.shutdown() # end def # end class class _RequestHandler(DatagramRequestHandler): """Processes and logs each incoming notification packet""" # Borrowed from BaseHTTPServer for logging monthname = [None, 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'] def log_date_time_string(self): """Return the current time formatted for logging.""" now = time.time() year, month, day, hh, mm, ss, x, y, z = time.localtime(now) s = "%02d/%3s/%04d %02d:%02d:%02d" % ( day, self.monthname[month], year, hh, mm, ss) return s def handle(self): """Handles each request""" p = GrowlPacket(self.rfile.read(), self.server.inpassword, self.server.outpassword) servers = self.server.resolver.getServers() if p.valid: s = socket(AF_INET, SOCK_DGRAM) for server in servers: s.sendto(p.data, (server, GROWL_UDP_PORT)) s.close() else: servers = 'discarded' # Log the request and outcome print "%s - - [%s] %s %s %d %s" % (self.client_address, self.log_date_time_string(), p.type(), p.info(), len(p.data), servers) if __name__== '__main__': r = GrowlRelay('password', 'batatinhas') try: r.serve_forever() except KeyboardInterrupt: r.server_close()