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
Usage
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.
Caveats
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.
The Code
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[1] == '\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]))[0]
tlen = struct.unpack("!H",str(self.data[6:8]))[0]
dlen = struct.unpack("!H",str(self.data[8:10]))[0]
alen = struct.unpack("!H",str(self.data[10:12]))[0]
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]))[0]
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[0],
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()