chiark / gitweb /
Packet loss percentages.
[tripe] / tripemon.in
1 #! @PYTHON@
2 # -*-python-*-
3
4 #----- Dependencies ---------------------------------------------------------
5
6 import socket as S
7 from sys import argv, exit, stdin, stdout, stderr
8 import os as OS
9 from os import environ
10 import sets as SET
11 import getopt as O
12 import time as T
13 import sre as RX
14 from cStringIO import StringIO
15
16 import pygtk
17 pygtk.require('2.0')
18 import gtk as G
19 import gobject as GO
20 import gtk.gdk as GDK
21
22 #----- Configuration --------------------------------------------------------
23
24 tripedir = "@configdir@"
25 socketdir = "@socketdir@"
26 PACKAGE = "@PACKAGE@"
27 VERSION = "@VERSION@"
28
29 debug = False
30
31 #----- Utility functions ----------------------------------------------------
32
33 ## Program name, shorn of extraneous stuff.
34 quis = OS.path.basename(argv[0])
35
36 def moan(msg):
37   """Report a message to standard error."""
38   stderr.write('%s: %s\n' % (quis, msg))
39
40 def die(msg, rc = 1):
41   """Report a message to standard error and exit."""
42   moan(msg)
43   exit(rc)
44
45 rx_space = RX.compile(r'\s+')
46 rx_ordinary = RX.compile(r'[^\\\'\"\s]+')
47 rx_weird = RX.compile(r'([\\\'])')
48 rx_time = RX.compile(r'^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)$')
49 rx_num = RX.compile(r'^[-+]?\d+$')
50
51 c_red = GDK.color_parse('red')
52
53 def getword(s):
54   """Pull a word from the front of S, handling quoting according to the
55   tripe-admin(5) rules.  Returns the word and the rest of S, or (None, None)
56   if there are no more words left."""
57   i = 0
58   m = rx_space.match(s, i)
59   if m: i = m.end()
60   r = ''
61   q = None
62   if i >= len(s):
63     return None, None
64   while i < len(s) and (q or not s[i].isspace()):
65     m = rx_ordinary.match(s, i)
66     if m:
67       r += m.group()
68       i = m.end()
69     elif s[i] == '\\':
70       r += s[i + 1]
71       i += 2
72     elif s[i] == q:
73       q = None
74       i += 1
75     elif not q and s[i] == '`' or s[i] == "'":
76       q = "'"
77       i += 1
78     elif not q and s[i] == '"':
79       q = '"'
80       i += 1
81     else:
82       r += s[i]
83       i += 1
84   if q:
85     raise SyntaxError, 'missing close quote'
86   m = rx_space.match(s, i)
87   if m: i = m.end()
88   return r, s[i:]
89
90 def quotify(s):
91   """Quote S according to the tripe-admin(5) rules."""
92   m = rx_ordinary.match(s)
93   if m and m.end() == len(s):
94     return s
95   else:
96     return "'" + rx_weird.sub(r'\\\1', s) + "'"
97
98 #----- Random bits of infrastructure ----------------------------------------
99
100 class struct (object):
101   """Simple object which stores attributes and has a sensible construction
102   syntax."""
103   def __init__(me, **kw):
104     me.__dict__.update(kw)
105
106 class peerinfo (struct): pass
107 class pingstate (struct): pass
108
109 def invoker(func):
110   """Return a function which throws away its arguments and calls FUNC.  (If
111   for loops worked by binding rather than assignment then we wouldn't need
112   this kludge."""
113   return lambda *hunoz, **hukairz: func()
114
115 class HookList (object):
116   """I maintain a list of functions, and provide the ability to call them
117   when something interesting happens.  The functions are called in the order
118   they were added to the list, with all the arguments.  If a function returns
119   a non-None result, no further functions are called."""
120   def __init__(me):
121     me.list = []
122   def add(me, func, obj):
123     me.list.append((obj, func))
124   def prune(me, obj):
125     new = []
126     for o, f in me.list:
127       if o is not obj:
128         new.append((o, f))
129     me.list = new
130   def run(me, *args, **kw):
131     for o, hook in me.list:
132       rc = hook(*args, **kw)
133       if rc is not None: return rc
134     return None
135
136 class HookClient (object):
137   def __init__(me):
138     me.hooks = SET.Set()
139   def hook(me, hk, func):
140     hk.add(func, me)
141     me.hooks.add(hk)
142   def unhook(me, hk):
143     hk.prune(me)
144     me.hooks.discard(hk)
145   def unhookall(me):
146     for hk in me.hooks:
147       hk.prune(me)
148     me.hooks.clear()
149   ##def __del__(me):
150   ##  print '%s dying' % me
151
152 #----- Connections and commands ---------------------------------------------
153
154 class ConnException (Exception):
155   """Some sort of problem occurred while communicating with the tripe
156   server."""
157   pass
158
159 class Error (ConnException):
160   """A command caused the server to issue a FAIL message."""
161   pass
162
163 class ConnectionFailed (ConnException):
164   """The connection failed while communicating with the server."""
165
166 jobid_seq = 0
167 def jobid():
168   """Return a job tag.  Used for background commands."""
169   global jobid_seq
170   jobid_seq += 1
171   return 'bg-%d' % jobid_seq
172
173 class BackgroundCommand (HookClient):
174   def __init__(me, conn, cmd):
175     HookClient.__init__(me)
176     me.conn = conn
177     me.tag = None
178     me.cmd = cmd
179     me.donehook = HookList()
180     me.losthook = HookList()
181     me.info = []
182     me.submit()
183     me.hook(me.conn.disconnecthook, me.lost)
184   def submit(me):
185     me.conn.bgcommand(me.cmd, me)
186   def lost(me):
187     me.losthook.run()
188     me.unhookall()
189   def fail(me, msg):
190     me.conn.error("Unexpected error from server command `%s': %s" %
191                   (me.cmd % msg))
192     me.unhookall()
193   def ok(me):
194     me.donehook.run(me.info)
195     me.unhookall()
196
197 class SimpleBackgroundCommand (BackgroundCommand):
198   def submit(me):
199     try:
200       BackgroundCommand.submit(me)
201     except ConnectionFailed, err:
202       me.conn.error('Unexpected error communicating with server: %s' % msg)
203       raise
204
205 class Connection (HookClient):
206
207   """I represent a connection to the TrIPE server.  I provide facilities for
208   sending commands and receiving replies.  The connection is notional: the
209   underlying socket connection can come and go under our feet.
210
211   Useful attributes:
212   connectedp: whether the connection is active
213   connecthook: called when we have connected
214   disconnecthook: called if we have disconnected
215   notehook: called with asynchronous notifications
216   errorhook: called if there was a command error"""
217
218   def __init__(me, sockname):
219     """Make a new connection to the server listening to SOCKNAME.  In fact,
220     we're initially disconnected, to allow the caller to get his life in
221     order before opening the floodgates."""
222     HookClient.__init__(me)
223     me.sockname = sockname
224     me.sock = None
225     me.connectedp = False
226     me.connecthook = HookList()
227     me.disconnecthook = HookList()
228     me.errorhook = HookList()
229     me.inbuf = ''
230     me.info = []
231     me.waitingp = False
232     me.bgcmd = None
233     me.bgmap = {}
234   def connect(me):
235     "Connect to the server.  Runs connecthook if it works."""
236     if me.sock: return
237     sock = S.socket(S.AF_UNIX, S.SOCK_STREAM)
238     try:
239       sock.connect(me.sockname)
240     except S.error, err:
241       me.error('error opening connection: %s' % err[1])
242       me.disconnecthook.run()
243       return
244     sock.setblocking(0)
245     me.socketwatch = GO.io_add_watch(sock, GO.IO_IN, me.ready)
246     me.sock = sock
247     me.connectedp = True
248     me.connecthook.run()
249   def disconnect(me):
250     "Disconnects from the server.  Runs disconnecthook."
251     if not me.sock: return
252     GO.source_remove(me.socketwatch)
253     me.sock.close()
254     me.sock = None
255     me.connectedp = False
256     me.disconnecthook.run()
257   def error(me, msg):
258     """Reports an error on the connection."""
259     me.errorhook.run(msg)
260
261   def bgcommand(me, cmd, bg):
262     """Sends a background command and feeds it properly."""
263     try:
264       me.bgcmd = bg
265       err = me.docommand(cmd)
266       if err:
267         bg.fail(err)
268     finally:
269       me.bgcmd = None
270   def command(me, cmd):
271     """Sends a command to the server.  Returns a list of INFO responses.  Do
272     not use this for backgrounded commands: create a BackgroundCommand
273     instead.  Raises apprpopriate exceptions on error, but doesn't send
274     report them to the errorhook."""
275     err = me.docommand(cmd)
276     if err:
277       raise Error, err
278     return me.info
279   def docommand(me, cmd):
280     if not me.sock:
281       raise ConnException, 'not connected'
282     if debug: print ">>> %s" % cmd
283     me.sock.sendall(cmd + '\n')
284     me.waitingp = True
285     me.info = []
286     try:
287       me.sock.setblocking(1)
288       while True:
289         rc, err = me.collect()
290         if rc: break
291     finally:
292       me.waitingp = False
293       me.sock.setblocking(0)
294       if len(me.inbuf) > 0:
295         GO.idle_add(lambda: me.flushbuf() and False)
296     return err
297   def simplecmd(me, cmd):
298     """Like command(), but reports errors via the errorhook as well as
299     raising exceptions."""
300     try:
301       i = me.command(cmd)
302     except Error, msg:
303       me.error("Unexpected error from server command `%s': %s" % (cmd, msg))
304       raise
305     except ConnectionFailed, msg:
306       me.error("Unexpected error communicating with server: %s" % msg);
307       raise
308     return i
309   def ready(me, sock, condition):
310     try:
311       me.collect()
312     except ConnException, msg:
313       me.error("Error watching server connection: %s" % msg)
314       if me.sock:
315         me.disconnect()
316         me.connect()
317     return True
318   def collect(me):
319     data = me.sock.recv(16384)
320     if data == '':
321       me.disconnect()
322       raise ConnectionFailed, 'server disconnected'
323     me.inbuf += data
324     return me.flushbuf()
325   def flushbuf(me):
326     while True:
327       nl = me.inbuf.find('\n')
328       if nl < 0: break
329       line = me.inbuf[:nl]
330       if debug: print "<<< %s" % line
331       me.inbuf = me.inbuf[nl + 1:]
332       tag, line = getword(line)
333       rc, err = me.parseline(tag, line)
334       if rc: return rc, err
335     return False, None
336   def parseline(me, code, line):
337     if code == 'BGDETACH':
338       if not me.bgcmd:
339         raise ConnectionFailed, 'unexpected detach'
340       me.bgcmd.tag = line
341       me.bgmap[line] = me.bgcmd
342       me.waitingp = False
343       me.bgcmd = None
344       return True, None
345     elif code == 'BGINFO':
346       tag, line = getword(line)
347       me.bgmap[tag].info.append(line)
348       return False, None
349     elif code == 'BGFAIL':
350       tag, line = getword(line)
351       me.bgmap[tag].fail(line)
352       del me.bgmap[tag]
353       return False, None
354     elif code == 'BGOK':
355       tag, line = getword(line)
356       me.bgmap[tag].ok()
357       del me.bgmap[tag]
358       return False, None
359     elif code == 'INFO':
360       if not me.waitingp or me.bgcmd:
361         raise ConnectionFailed, 'unexpected INFO response'
362       me.info.append(line)
363       return False, None
364     elif code == 'OK':
365       if not me.waitingp or me.bgcmd:
366         raise ConnectionFailed, 'unexpected OK response'
367       return True, None
368     elif code == 'FAIL':
369       if not me.waitingp:
370         raise ConnectionFailed, 'unexpected FAIL response'
371       return True, line
372     else:
373       raise ConnectionFailed, 'unknown response code `%s' % code
374
375 class Monitor (Connection):
376   """I monitor a TrIPE server, noticing when it changes state and keeping
377   track of its peers.  I also provide facilities for sending the server
378   commands and collecting the answers.
379
380   Useful attributes:
381   addpeerhook: called with a new Peer when the server adds one
382   delpeerhook: called with a Peer when the server kills one
383   tracehook: called with a trace message
384   warnhook: called with a warning message
385   peers: mapping from names to Peer objects"""
386   def __init__(me, sockname):
387     """Initializes the monitor."""
388     Connection.__init__(me, sockname)
389     me.addpeerhook = HookList()
390     me.delpeerhook = HookList()
391     me.tracehook = HookList()
392     me.warnhook = HookList()
393     me.notehook = HookList()
394     me.hook(me.connecthook, me.connected)
395     me.delay = []
396     me.peers = {}
397   def addpeer(me, peer):
398     if peer not in me.peers:
399       p = Peer(me, peer)
400       me.peers[peer] = p
401       me.addpeerhook.run(p)
402   def delpeer(me, peer):
403     if peer in me.peers:
404       p = me.peers[peer]
405       me.delpeerhook.run(p)
406       p.dead()
407       del me.peers[peer]
408   def updatelist(me, peers):
409     newmap = {}
410     for p in peers:
411       newmap[p] = True
412       if p not in me.peers:
413         me.addpeer(p)
414     oldpeers = me.peers.copy()
415     for p in oldpeers:
416       if p not in newmap:
417         me.delpeer(p)
418   def connected(me):
419     try:
420       me.simplecmd('WATCH -A+wnt')
421       me.updatelist([s.strip() for s in me.simplecmd('LIST')])
422     except ConnException:
423       me.disconnect()
424       return
425   def parseline(me, code, line):
426     ## Delay async messages until the current command is done.  Otherwise the
427     ## handler for the async message might send another command before this
428     ## one's complete, and the whole edifice turns to jelly.
429     ##
430     ## No, this isn't the server's fault.  If we rely on the server to delay
431     ## notifications then there's a race between when we send a command and
432     ## when the server gets it.
433     if me.waitingp and code in ('TRACE', 'WARN', 'NOTE'):
434       if len(me.delay) == 0: GO.idle_add(me.flushdelay)
435       me.delay.append((code, line))
436     elif code == 'TRACE':
437       me.tracehook.run(line)
438     elif code == 'WARN':
439       me.warnhook.run(line)
440     elif code == 'NOTE':
441       note, line = getword(line)
442       me.notehook.run(note, line)
443       if note == 'ADD':
444         me.addpeer(getword(line)[0])
445       elif note == 'KILL':
446         me.delpeer(line)
447       else:
448         ## Well, I asked for it.
449         pass
450     else:
451       return Connection.parseline(me, code, line)
452     return False, None
453   def flushdelay(me):
454     delay = me.delay
455     me.delay = []
456     for tag, line in delay:
457       me.parseline(tag, line)
458     return False
459
460 def parseinfo(info):
461   """Parse key=value output into a dictionary."""
462   d = {}
463   for i in info:
464     for w in i.split(' '):
465       q = w.index('=')
466       d[w[:q]] = w[q + 1:]
467   return d
468
469 class Peer (object):
470   """I represent a TrIPE peer.  Useful attributes are:
471
472   name: peer's name
473   addr: human-friendly representation of the peer's address
474   ifname: interface associated with the peer
475   alivep: true if the peer hasn't been killed
476   deadhook: called with no arguments when the peer is killed"""
477   def __init__(me, monitor, name):
478     me.mon = monitor
479     me.name = name
480     addr = me.mon.simplecmd('ADDR %s' % name)[0].split(' ')
481     if addr[0] == 'INET':
482       ipaddr, port = addr[1:]
483       try:
484         name = S.gethostbyaddr(ipaddr)[0]
485         me.addr = 'INET %s:%s [%s]' % (name, port, ipaddr)
486       except S.herror:
487         me.addr = 'INET %s:%s' % (ipaddr, port)
488     else:
489       me.addr = ' '.join(addr)
490     me.ifname = me.mon.simplecmd('IFNAME %s' % me.name)[0]
491     me.__dict__.update(parseinfo(me.mon.simplecmd('PEERINFO %s' % me.name)))
492     me.deadhook = HookList()
493     me.alivep = True
494   def dead(me):
495     me.alivep = False
496     me.deadhook.run()
497
498 #----- Window management cruft ----------------------------------------------
499
500 class MyWindowMixin (G.Window, HookClient):
501   """Mixin for windows which call a closehook when they're destroyed."""
502   def mywininit(me):
503     me.closehook = HookList()
504     HookClient.__init__(me)
505     me.connect('destroy', invoker(me.close))
506   def close(me):
507     me.closehook.run()
508     me.destroy()
509     me.unhookall()
510 class MyWindow (MyWindowMixin):
511   """A window which calls a closehook when it's destroyed."""
512   def __init__(me, kind = G.WINDOW_TOPLEVEL):
513     G.Window.__init__(me, kind)
514     me.mywininit()
515 class MyDialog (G.Dialog, MyWindowMixin, HookClient):
516   """A dialogue box with a closehook and sensible button binding."""
517   def __init__(me, title = None, flags = 0, buttons = []):
518     """The buttons are a list of (STOCKID, THUNK) pairs: call the appropriate
519     THUNK when the button is pressed.  The others are just like GTK's Dialog
520     class."""
521     i = 0
522     br = []
523     me.rmap = []
524     for b, f in buttons:
525       br.append(b)
526       br.append(i)
527       me.rmap.append(f)
528       i += 1
529     G.Dialog.__init__(me, title, None, flags, tuple(br))
530     HookClient.__init__(me)
531     me.mywininit()
532     me.set_default_response(i - 1)
533     me.connect('response', me.respond)
534   def respond(me, hunoz, rid, *hukairz):
535     if rid >= 0: me.rmap[rid]()
536
537 def makeactiongroup(name, acts):
538   """Creates an ActionGroup called NAME.  ACTS is a list of tuples
539   containing:
540   ACT: an action name
541   LABEL: the label string for the action
542   ACCEL: accelerator string, or None
543   FUNC: thunk to call when the action is invoked"""
544   actgroup = G.ActionGroup(name)
545   for act, label, accel, func in acts:
546     a = G.Action(act, label, None, None)
547     if func: a.connect('activate', invoker(func))
548     actgroup.add_action_with_accel(a, accel)
549   return actgroup
550
551 class GridPacker (G.Table):
552   """Like a Table, but with more state: makes filling in the widgets
553   easier."""
554   def __init__(me):
555     G.Table.__init__(me)
556     me.row = 0
557     me.col = 0
558     me.rows = 1
559     me.cols = 1
560     me.set_border_width(4)
561     me.set_col_spacings(4)
562     me.set_row_spacings(4)
563   def pack(me, w, width = 1, newlinep = False,
564            xopt = G.EXPAND | G.FILL | G.SHRINK, yopt = 0,
565            xpad = 0, ypad = 0):
566     """Packs a new widget.  W is the widget to add.  XOPY, YOPT, XPAD and
567     YPAD are as for Table.  WIDTH is how many cells to take up horizontally.
568     NEWLINEP is whether to start a new line for this widget.  Returns W."""
569     if newlinep:
570       me.row += 1
571       me.col = 0
572     bot = me.row + 1
573     right = me.col + width
574     if bot > me.rows or right > me.cols:
575       if bot > me.rows: me.rows = bot
576       if right > me.cols: me.cols = right
577       me.resize(me.rows, me.cols)
578     me.attach(w, me.col, me.col + width, me.row, me.row + 1,
579               xopt, yopt, xpad, ypad)
580     me.col += width
581     return w
582   def labelled(me, lab, w, newlinep = False, **kw):
583     """Packs a labelled widget.  Other arguments are as for pack.  Returns
584     W."""
585     label = G.Label(lab)
586     label.set_alignment(1.0, 0)
587     me.pack(label, newlinep = newlinep, xopt = G.FILL)
588     me.pack(w, **kw)
589     return w
590   def info(me, label, text = None, len = 18, **kw):
591     """Packs an information widget with a label.  LABEL is the label; TEXT is
592     the initial text; LEN is the estimated length in characters.  Returns the
593     entry widget."""
594     e = G.Entry()
595     if text is not None: e.set_text(text)
596     e.set_width_chars(len)
597     e.set_editable(False)
598     me.labelled(label, e, **kw)
599     return e
600
601 class WindowSlot (HookClient):
602   """A place to store a window.  If the window is destroyed, remember this;
603   when we come to open the window, raise it if it already exists; otherwise
604   make a new one."""
605   def __init__(me, createfunc):
606     """Constructor: CREATEFUNC must return a new Window which supports the
607     closehook protocol."""
608     HookClient.__init__(me)
609     me.createfunc = createfunc
610     me.window = None
611   def open(me):
612     """Opens the window, creating it if necessary."""
613     if me.window:
614       me.window.window.raise_()
615     else:
616       me.window = me.createfunc()
617       me.hook(me.window.closehook, me.closed)
618   def closed(me):
619     me.unhook(me.window.closehook)
620     me.window = None
621
622 class ValidationError (Exception):
623   """Raised by ValidatingEntry.get_text() if the text isn't valid."""
624   pass
625 class ValidatingEntry (G.Entry):
626   """Like an Entry, but makes the text go red if the contents are invalid.
627   If get_text is called, and the text is invalid, ValidationError is
628   raised."""
629   def __init__(me, valid, text = '', size = -1, *arg, **kw):
630     """Make an Entry.  VALID is a regular expression or a predicate on
631     strings.  TEXT is the default text to insert.  SIZE is the size of the
632     box to set, in characters (ish).  Other arguments are passed to Entry."""
633     G.Entry.__init__(me, *arg, **kw)
634     me.connect("changed", me.check)
635     if callable(valid):
636       me.validate = valid
637     else:
638       me.validate = RX.compile(valid).match
639     me.ensure_style()
640     me.c_ok = me.get_style().text[G.STATE_NORMAL]
641     me.c_bad = c_red
642     if size != -1: me.set_width_chars(size)
643     me.set_activates_default(True)
644     me.set_text(text)
645     me.check()
646   def check(me, *hunoz):
647     if me.validate(G.Entry.get_text(me)):
648       me.validp = True
649       me.modify_text(G.STATE_NORMAL, me.c_ok)
650     else:
651       me.validp = False
652       me.modify_text(G.STATE_NORMAL, me.c_bad)
653   def get_text(me):
654     if not me.validp:
655       raise ValidationError
656     return G.Entry.get_text(me)
657
658 def numericvalidate(min = None, max = None):
659   """Validation function for numbers.  Entry must consist of an optional sign
660   followed by digits, and the resulting integer must be within the given
661   bounds."""
662   return lambda x: (rx_num.match(x) and
663                     (min is None or long(x) >= min) and
664                     (max is None or long(x) <= max))
665
666 #----- Various minor dialog boxen -------------------------------------------
667
668 GPL = """This program is free software; you can redistribute it and/or modify
669 it under the terms of the GNU General Public License as published by
670 the Free Software Foundation; either version 2 of the License, or
671 (at your option) any later version.
672
673 This program is distributed in the hope that it will be useful,
674 but WITHOUT ANY WARRANTY; without even the implied warranty of
675 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
676 GNU General Public License for more details.
677
678 You should have received a copy of the GNU General Public License
679 along with this program; if not, write to the Free Software Foundation,
680 Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA."""
681
682 class AboutBox (G.AboutDialog, MyWindowMixin):
683   """The program `About' box."""
684   def __init__(me):
685     G.AboutDialog.__init__(me)
686     me.mywininit()
687     me.set_name('TrIPEmon')
688     me.set_version(VERSION)
689     me.set_license(GPL)
690     me.set_authors(['Mark Wooding'])
691     me.connect('unmap', invoker(me.close))
692     me.show()
693 aboutbox = WindowSlot(AboutBox)
694
695 def moanbox(msg):
696   """Report an error message in a window."""
697   d = G.Dialog('Error from %s' % quis,
698                flags = G.DIALOG_MODAL,
699                buttons = ((G.STOCK_OK, G.RESPONSE_NONE)))
700   label = G.Label(msg)
701   label.set_padding(20, 20)
702   d.vbox.pack_start(label)
703   label.show()
704   d.run()
705   d.destroy()
706
707 def unimplemented(*hunoz):
708   """Indicator of laziness."""
709   moanbox("I've not written that bit yet.")
710
711 class ServInfo (MyWindow):
712   def __init__(me, monitor):
713     MyWindow.__init__(me)
714     me.set_title('TrIPE server info')
715     me.mon = monitor
716     me.table = GridPacker()
717     me.add(me.table)
718     me.e = {}
719     def add(label, tag, text = None, **kw):
720       me.e[tag] = me.table.info(label, text, **kw)
721     add('Implementation', 'implementation')
722     add('Version', 'version', newlinep = True)
723     me.update()
724     me.hook(me.mon.connecthook, me.update)
725     me.show_all()
726   def update(me):
727     info = parseinfo(me.mon.simplecmd('SERVINFO'))
728     for i in me.e:
729       me.e[i].set_text(info[i])
730
731 class TraceOptions (MyDialog):
732   """Tracing options window."""
733   def __init__(me, monitor):
734     MyDialog.__init__(me, title = 'Tracing options',
735                       buttons = [(G.STOCK_CLOSE, me.destroy),
736                                  (G.STOCK_OK, me.ok)])
737     me.mon = monitor
738     me.opts = []
739     for o in me.mon.simplecmd('TRACE'):
740       char = o[0]
741       onp = o[1]
742       text = o[3].upper() + o[4:]
743       if char.isupper(): continue
744       ticky = G.CheckButton(text)
745       ticky.set_active(onp != ' ')
746       me.vbox.pack_start(ticky)
747       me.opts.append((char, ticky))
748     me.show_all()
749   def ok(me):
750     on = []
751     off = []
752     for char, ticky in me.opts:
753       if ticky.get_active():
754         on.append(char)
755       else:
756         off.append(char)
757     setting = ''.join(on) + '-' + ''.join(off)
758     me.mon.simplecmd('TRACE %s' % setting)
759     me.destroy()
760
761 #----- Logging windows ------------------------------------------------------
762
763 class LogModel (G.ListStore):
764   """A simple list of log messages."""
765   def __init__(me, columns):
766     """Call with a list of column names.  All must be strings.  We add a time
767     column to the left."""
768     me.cols = ('Time',) + columns
769     G.ListStore.__init__(me, *((GO.TYPE_STRING,) * len(me.cols)))
770   def add(me, *entries):
771     """Adds a new log message, with a timestamp."""
772     now = T.strftime('%Y-%m-%d %H:%M:%S')
773     me.append((now,) + entries)
774
775 class TraceLogModel (LogModel):
776   """Log model for trace messages."""
777   def __init__(me):
778     LogModel.__init__(me, ('Message',))
779   def notify(me, line):
780     """Call with a new trace message."""
781     me.add(line)
782
783 class WarningLogModel (LogModel):
784   """Log model for warnings.  We split the category out into a separate
785   column."""
786   def __init__(me):
787     LogModel.__init__(me, ('Category', 'Message'))
788   def notify(me, line):
789     """Call with a new warning message."""
790     me.add(*getword(line))
791
792 class LogViewer (MyWindow):
793   """Log viewer window.  Nothing very exciting."""
794   def __init__(me, model):
795     MyWindow.__init__(me)
796     me.model = model
797     scr = G.ScrolledWindow()
798     scr.set_policy(G.POLICY_AUTOMATIC, G.POLICY_AUTOMATIC)
799     me.list = G.TreeView(me.model)
800     me.closehook = HookList()
801     i = 0
802     for c in me.model.cols:
803       me.list.append_column(G.TreeViewColumn(c,
804                                              G.CellRendererText(),
805                                              text = i))
806       i += 1
807     me.set_default_size(440, 256)
808     scr.add(me.list)
809     me.add(scr)
810     me.show_all()
811
812 #----- Peer window ----------------------------------------------------------
813
814 def xlate_time(t):
815   """Translate a time in tripe's stats format to something a human might
816   actually want to read."""
817   if t == 'NEVER': return '(never)'
818   Y, M, D, h, m, s = map(int, rx_time.match(t).group(1, 2, 3, 4, 5, 6))
819   return '%04d:%02d:%02d %02d:%02d:%02d' % (Y, M, D, h, m, s)
820 def xlate_bytes(b):
821   """Translate a number of bytes into something a human might want to read."""
822   suff = 'B'
823   b = int(b)
824   for s in 'KMG':
825     if b < 4096: break
826     b /= 1024
827     suff = s
828   return '%d %s' % (b, suff)
829
830 ## How to translate peer stats.  Maps the stat name to a translation
831 ## function.
832 statsxlate = \
833   [('start-time', xlate_time),
834    ('last-packet-time', xlate_time),
835    ('last-keyexch-time', xlate_time),
836    ('bytes-in', xlate_bytes),
837    ('bytes-out', xlate_bytes),
838    ('keyexch-bytes-in', xlate_bytes),
839    ('keyexch-bytes-out', xlate_bytes),
840    ('ip-bytes-in', xlate_bytes),
841    ('ip-bytes-out', xlate_bytes)]
842
843 ## How to lay out the stats dialog.  Format is (LABEL, FORMAT): LABEL is
844 ## the label to give the entry box; FORMAT is the format string to write into
845 ## the entry.
846 statslayout = \
847   [('Start time', '%(start-time)s'),
848    ('Last key-exchange', '%(last-keyexch-time)s'),
849    ('Last packet', '%(last-packet-time)s'),
850    ('Packets in/out',
851     '%(packets-in)s (%(bytes-in)s) / %(packets-out)s (%(bytes-out)s)'),
852    ('Key-exchange in/out',
853     '%(keyexch-packets-in)s (%(keyexch-bytes-in)s) / %(keyexch-packets-out)s (%(keyexch-bytes-out)s)'),
854    ('IP in/out',
855     '%(ip-packets-in)s (%(ip-bytes-in)s) / %(ip-packets-in)s (%(ip-bytes-in)s)'),
856    ('Rejected packets', '%(rejected-packets)s')]
857
858 class PeerWindow (MyWindow):
859   """Show information about a peer."""
860   def __init__(me, monitor, peer):
861     MyWindow.__init__(me)
862     me.set_title('TrIPE statistics: %s' % peer.name)
863     me.mon = monitor
864     me.peer = peer
865     table = GridPacker()
866     me.add(table)
867     me.e = {}
868     def add(label, text = None):
869       me.e[label] = table.info(label, text, len = 42, newlinep = True)
870     add('Peer name', peer.name)
871     add('Tunnel', peer.tunnel)
872     add('Interface', peer.ifname)
873     add('Keepalives',
874         (peer.keepalive == '0' and 'never') or '%s s' % peer.keepalive)
875     add('Address', peer.addr)
876     add('Transport pings')
877     add('Encrypted pings')
878     for label, format in statslayout: add(label)
879     me.timeout = None
880     me.hook(me.mon.connecthook, me.tryupdate)
881     me.hook(me.mon.disconnecthook, me.stopupdate)
882     me.hook(me.closehook, me.stopupdate)
883     me.hook(me.peer.deadhook, me.dead)
884     me.hook(me.peer.pinghook, me.ping)
885     me.tryupdate()
886     me.ping()
887     me.show_all()
888   def update(me):
889     if not me.peer.alivep or not me.mon.connectedp: return False
890     stat = parseinfo(me.mon.simplecmd('STATS %s' % me.peer.name))
891     for s, trans in statsxlate:
892       stat[s] = trans(stat[s])
893     for label, format in statslayout:
894       me.e[label].set_text(format % stat)
895     return True
896   def tryupdate(me):
897     if me.timeout is None and me.update():
898       me.timeout = GO.timeout_add(1000, me.update)
899   def stopupdate(me):
900     if me.timeout is not None:
901       GO.source_remove(me.timeout)
902       me.timeout = None
903   def dead(me):
904     me.set_title('TrIPE statistics: %s [defunct]' % me.peer.name)
905     me.e['Peer name'].set_text('%s [defunct]' % me.peer.name)
906     me.stopupdate()
907   def ping(me):
908     for ping in me.peer.ping, me.peer.eping:
909       s = '%d/%d' % (ping.ngood, ping.n)
910       if ping.n:
911         s += ' (%.1f%%)' % (ping.ngood * 100.0/ping.n)
912       if ping.ngood:
913         s += '; %.2f ms (last %.1f ms)' % (ping.ttot/ping.ngood, ping.tlast);
914       me.e[ping.cmd].set_text(s)
915
916 #----- Add peer -------------------------------------------------------------
917
918 class AddPeerCommand (SimpleBackgroundCommand):
919   def __init__(me, conn, dlg, name, addr, port,
920                keepalive = None, tunnel = None):
921     me.name = name
922     me.addr = addr
923     me.port = port
924     me.keepalive = keepalive
925     me.tunnel = tunnel
926     cmd = StringIO()
927     cmd.write('ADD %s' % name)
928     cmd.write(' -background %s' % jobid())
929     if keepalive is not None: cmd.write(' -keepalive %s' % keepalive)
930     if tunnel is not None: cmd.write(' -tunnel %s' % tunnel)
931     cmd.write(' INET %s %s' % (addr, port))
932     SimpleBackgroundCommand.__init__(me, conn, cmd.getvalue())
933     me.hook(me.donehook, invoker(dlg.destroy))
934   def fail(me, err):
935     token, msg = getword(str(err))
936     if token in ('resolve-error', 'resolver-timeout'):
937       moanbox("Unable to resolve hostname `%s'" % me.addr)
938     elif token == 'peer-create-fail':
939       moanbox("Couldn't create new peer `%s'" % me.name)
940     elif token == 'peer-exists':
941       moanbox("Peer `%s' already exists" % me.name)
942     else:
943       moanbox("Unexpected error from server command `ADD': %s" % err)
944
945 class AddPeerDialog (MyDialog):
946   def __init__(me, monitor):
947     MyDialog.__init__(me, 'Add peer',
948                       buttons = [(G.STOCK_CANCEL, me.destroy),
949                                  (G.STOCK_OK, me.ok)])
950     me.mon = monitor
951     table = GridPacker()
952     me.vbox.pack_start(table)
953     me.e_name = table.labelled('Name',
954                                ValidatingEntry(r'^[^\s.:]+$', '', 16),
955                                width = 3)
956     me.e_addr = table.labelled('Address',
957                                ValidatingEntry(r'^[a-zA-Z0-9.-]+$', '', 24),
958                                newlinep = True)
959     me.e_port = table.labelled('Port',
960                                ValidatingEntry(numericvalidate(0, 65535),
961                                                '22003',
962                                                5))
963     me.c_keepalive = G.CheckButton('Keepalives')
964     me.l_tunnel = table.labelled('Tunnel',
965                                  G.combo_box_new_text(),
966                                  newlinep = True, width = 3)
967     me.tuns = me.mon.simplecmd('TUNNELS')
968     for t in me.tuns:
969       me.l_tunnel.append_text(t)
970     me.l_tunnel.set_active(0)
971     table.pack(me.c_keepalive, newlinep = True, xopt = G.FILL)
972     me.c_keepalive.connect('toggled',
973                            lambda t: me.e_keepalive.set_sensitive\
974                                       (t.get_active()))
975     me.e_keepalive = ValidatingEntry(r'^\d+[hms]?$', '', 5)
976     me.e_keepalive.set_sensitive(False)
977     table.pack(me.e_keepalive, width = 3)
978     me.show_all()
979   def ok(me):
980     try:
981       if me.c_keepalive.get_active():
982         ka = me.e_keepalive.get_text()
983       else:
984         ka = None
985       t = me.l_tunnel.get_active()
986       if t == 0:
987         tun = None
988       else:
989         tun = me.tuns[t]
990       AddPeerCommand(me.mon, me,
991                      me.e_name.get_text(),
992                      me.e_addr.get_text(),
993                      me.e_port.get_text(),
994                      keepalive = ka,
995                      tunnel = tun)
996     except ValidationError:
997       GDK.beep()
998       return
999
1000 #----- The server monitor ---------------------------------------------------
1001
1002 class PingCommand (SimpleBackgroundCommand):
1003   def __init__(me, conn, cmd, peer, func):
1004     me.peer = peer
1005     me.func = func
1006     SimpleBackgroundCommand.__init__ \
1007       (me, conn, '%s -background %s %s' % (cmd, jobid(), peer.name))
1008   def ok(me):
1009     tok, rest = getword(me.info[0])
1010     if tok == 'ping-ok':
1011       me.func(me.peer, float(rest))
1012     else:
1013       me.func(me.peer, None)
1014     me.unhookall()
1015   def fail(me, err): me.unhookall()
1016   def lost(me): me.unhookall()
1017
1018 class MonitorWindow (MyWindow):
1019
1020   def __init__(me, monitor):
1021     MyWindow.__init__(me)
1022     me.set_title('TrIPE monitor')
1023     me.mon = monitor
1024     me.hook(me.mon.errorhook, me.report)
1025     me.warnings = WarningLogModel()
1026     me.hook(me.mon.warnhook, me.warnings.notify)
1027     me.trace = TraceLogModel()
1028     me.hook(me.mon.tracehook, me.trace.notify)
1029
1030     me.warnview = WindowSlot(lambda: LogViewer(me.warnings))
1031     me.traceview = WindowSlot(lambda: LogViewer(me.trace))
1032     me.traceopts = WindowSlot(lambda: TraceOptions(me.mon))
1033     me.addpeerwin = WindowSlot(lambda: AddPeerDialog(me.mon))
1034     me.servinfo = WindowSlot(lambda: ServInfo(me.mon))
1035
1036     vbox = G.VBox()
1037     me.add(vbox)
1038
1039     me.ui = G.UIManager()
1040     def cmd(c): return lambda: me.mon.simplecmd(c)
1041     actgroup = makeactiongroup('monitor',
1042       [('file-menu', '_File', None, None),
1043        ('connect', '_Connect', '<Alt>C', me.mon.connect),
1044        ('disconnect', '_Disconnect', '<Alt>D', me.mon.disconnect),
1045        ('quit', '_Quit', '<Alt>Q', me.close),
1046        ('server-menu', '_Server', None, None),
1047        ('daemon', 'Run in _background', None, cmd('DAEMON')),
1048        ('server-version', 'Server version', '<Alt>V', me.servinfo.open),
1049        ('reload-keys', 'Reload keys', '<Alt>R', cmd('RELOAD')),
1050        ('server-quit', 'Terminate server', None, cmd('QUIT')),
1051        ('logs-menu', '_Logs', None, None),
1052        ('show-warnings', 'Show _warnings', '<Alt>W', me.warnview.open),
1053        ('show-trace', 'Show _trace', '<Alt>T', me.traceview.open),
1054        ('trace-options', 'Trace _options...', None, me.traceopts.open),
1055        ('help-menu', '_Help', None, None),
1056        ('about', '_About tripemon...', None, aboutbox.open),
1057        ('add-peer', '_Add peer...', '<Alt>A', me.addpeerwin.open),
1058        ('kill-peer', '_Kill peer', None, me.killpeer),
1059        ('force-kx', 'Force key e_xchange', None, me.forcekx)])
1060     uidef = '''
1061       <ui>
1062         <menubar>
1063           <menu action="file-menu">
1064             <menuitem action="quit"/>
1065           </menu>
1066           <menu action="server-menu">
1067              <menuitem action="connect"/>
1068             <menuitem action="disconnect"/>
1069             <separator/>
1070             <menuitem action="server-version"/>
1071             <menuitem action="add-peer"/>
1072             <menuitem action="daemon"/>
1073             <menuitem action="reload-keys"/>
1074             <separator/>
1075             <menuitem action="server-quit"/>
1076           </menu>
1077           <menu action="logs-menu">
1078             <menuitem action="show-warnings"/>
1079             <menuitem action="show-trace"/>
1080             <menuitem action="trace-options"/>
1081           </menu>
1082           <menu action="help-menu">
1083             <menuitem action="about"/>
1084           </menu>
1085         </menubar>
1086         <popup name="peer-popup">
1087           <menuitem action="add-peer"/>
1088           <menuitem action="kill-peer"/>
1089           <menuitem action="force-kx"/>
1090         </popup>
1091       </ui>
1092       '''
1093     me.ui.insert_action_group(actgroup, 0)
1094     me.ui.add_ui_from_string(uidef)
1095     vbox.pack_start(me.ui.get_widget('/menubar'), expand = False)
1096     me.add_accel_group(me.ui.get_accel_group())
1097     me.status = G.Statusbar()
1098
1099     me.listmodel = G.ListStore(*(GO.TYPE_STRING,) * 6)
1100     me.listmodel.set_sort_column_id(0, G.SORT_ASCENDING)
1101     me.hook(me.mon.addpeerhook, me.addpeer)
1102     me.hook(me.mon.delpeerhook, me.delpeer)
1103
1104     scr = G.ScrolledWindow()
1105     scr.set_policy(G.POLICY_AUTOMATIC, G.POLICY_AUTOMATIC)
1106     me.list = G.TreeView(me.listmodel)
1107     me.list.append_column(G.TreeViewColumn('Peer name',
1108                                            G.CellRendererText(),
1109                                            text = 0))
1110     me.list.append_column(G.TreeViewColumn('Address',
1111                                            G.CellRendererText(),
1112                                            text = 1))
1113     me.list.append_column(G.TreeViewColumn('T-ping',
1114                                            G.CellRendererText(),
1115                                            text = 2,
1116                                            foreground = 3))
1117     me.list.append_column(G.TreeViewColumn('E-ping',
1118                                            G.CellRendererText(),
1119                                            text = 4,
1120                                            foreground = 5))
1121     me.list.get_column(1).set_expand(True)
1122     me.list.connect('row-activated', me.activate)
1123     me.list.connect('button-press-event', me.buttonpress)
1124     me.list.set_reorderable(True)
1125     me.list.get_selection().set_mode(G.SELECTION_NONE)
1126     scr.add(me.list)
1127     vbox.pack_start(scr)
1128
1129     vbox.pack_start(me.status, expand = False)
1130     me.hook(me.mon.connecthook, me.connected)
1131     me.hook(me.mon.disconnecthook, me.disconnected)
1132     me.hook(me.mon.notehook, me.notify)
1133     me.pinger = None
1134     me.set_default_size(420, 180)
1135     me.mon.connect()
1136     me.show_all()
1137
1138   def addpeer(me, peer):
1139     peer.i = me.listmodel.append([peer.name, peer.addr,
1140                                   '???', 'green', '???', 'green'])
1141     peer.win = WindowSlot(lambda: PeerWindow(me.mon, peer))
1142     peer.pinghook = HookList()
1143     peer.ping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1144                           tlast = 0, ttot = 0,
1145                           tcol = 2, ccol = 3, cmd = 'Transport pings')
1146     peer.eping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1147                            tlast = 0, ttot = 0,
1148                            tcol = 4, ccol = 5, cmd = 'Encrypted pings')
1149   def delpeer(me, peer):
1150     me.listmodel.remove(peer.i)
1151   def path_peer(me, path):
1152     return me.mon.peers[me.listmodel[path][0]]
1153
1154   def activate(me, l, path, col):
1155     peer = me.path_peer(path)
1156     peer.win.open()
1157   def buttonpress(me, l, ev):
1158     if ev.button == 3:
1159       r = me.list.get_path_at_pos(ev.x, ev.y)
1160       for i in '/peer-popup/kill-peer', '/peer-popup/force-kx':
1161         me.ui.get_widget(i).set_sensitive(me.mon.connectedp and
1162                                           r is not None)
1163       if r:
1164         me.menupeer = me.path_peer(r[0])
1165       else:
1166         me.menupeer = None
1167       me.ui.get_widget('/peer-popup').popup(None, None, None,
1168                                             ev.button, ev.time)
1169
1170   def killpeer(me):
1171     me.mon.simplecmd('KILL %s' % me.menupeer.name)
1172   def forcekx(me):
1173     me.mon.simplecmd('FORCEKX %s' % me.menupeer.name)
1174
1175   def reping(me):
1176     if me.pinger is not None:
1177       GO.source_remove(me.pinger)
1178     me.pinger = GO.timeout_add(10000, me.ping)
1179     me.ping()
1180   def unping(me):
1181     if me.pinger is not None:
1182       GO.source_remove(me.pinger)
1183       me.pinger = None
1184   def ping(me):
1185     for name in me.mon.peers:
1186       p = me.mon.peers[name]
1187       PingCommand(me.mon, 'PING', p, lambda p, t: me.pong(p, p.ping, t))
1188       PingCommand(me.mon, 'EPING', p, lambda p, t: me.pong(p, p.eping, t))
1189     return True
1190   def pong(me, p, ping, t):
1191     ping.n += 1
1192     if t is None:
1193       ping.nmiss += 1
1194       ping.nmissrun += 1
1195       me.listmodel[p.i][ping.tcol] = '(miss %d)' % ping.nmissrun
1196       me.listmodel[p.i][ping.ccol] = 'red'
1197     else:
1198       ping.ngood += 1
1199       ping.nmissrun = 0
1200       ping.tlast = t
1201       ping.ttot += t
1202       me.listmodel[p.i][ping.tcol] = '%.1f ms' % t
1203       me.listmodel[p.i][ping.ccol] = 'black'
1204     p.pinghook.run()
1205   def setstatus(me, status):
1206     me.status.pop(0)
1207     me.status.push(0, status)
1208   def notify(me, note, rest):
1209     if note == 'DAEMON':
1210       me.ui.get_widget('/menubar/server-menu/daemon').set_sensitive(False)
1211   def connected(me):
1212     me.setstatus('Connected (port %s)' % me.mon.simplecmd('PORT')[0])
1213     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(False)
1214     for i in ('/menubar/server-menu/disconnect',
1215               '/menubar/server-menu/server-version',
1216               '/menubar/server-menu/add-peer',
1217               '/menubar/server-menu/server-quit',
1218               '/menubar/logs-menu/trace-options'):
1219       me.ui.get_widget(i).set_sensitive(True)
1220     me.ui.get_widget('/menubar/server-menu/daemon'). \
1221       set_sensitive(parseinfo(me.mon.simplecmd('SERVINFO'))['daemon'] ==
1222                     'nil')
1223     me.reping()
1224   def disconnected(me):
1225     me.setstatus('Disconnected')
1226     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(True)
1227     for i in ('/menubar/server-menu/disconnect',
1228               '/menubar/server-menu/server-version',
1229               '/menubar/server-menu/add-peer',
1230               '/menubar/server-menu/daemon',
1231               '/menubar/server-menu/server-quit',
1232               '/menubar/logs-menu/trace-options'):
1233       me.ui.get_widget(i).set_sensitive(False)
1234     me.unping()
1235   def destroy(me):
1236     if me.pinger is not None:
1237       GO.source_remove(me.pinger)    
1238   def report(me, msg):
1239     moanbox(msg)
1240     return True
1241
1242 #----- Parse options --------------------------------------------------------
1243
1244 def version(fp = stdout):
1245   """Print the program's version number."""
1246   fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
1247
1248 def usage(fp):
1249   """Print a brief usage message for the program."""
1250   fp.write('Usage: %s [-d DIR] [-a SOCK]\n' % quis)
1251
1252 def main():
1253   global tripedir
1254   if 'TRIPEDIR' in environ:
1255     tripedir = environ['TRIPEDIR']
1256   tripesock = '%s/%s' % (socketdir, 'tripesock')
1257
1258   try:
1259     opts, args = O.getopt(argv[1:],
1260                           'hvud:a:',
1261                           ['help', 'version', 'usage',
1262                            'directory=', 'admin-socket='])
1263   except O.GetoptError, exc:
1264     moan(exc)
1265     usage(stderr)
1266     exit(1)
1267   for o, v in opts:
1268     if o in ('-h', '--help'):
1269       version(stdout)
1270       print
1271       usage(stdout)
1272       print """
1273 Graphical monitor for TrIPE VPN.
1274
1275 Options supported:
1276
1277 -h, --help              Show this help message.
1278 -v, --version           Show the version number.
1279 -u, --usage             Show pointlessly short usage string.
1280
1281 -d, --directory=DIR     Use TrIPE directory DIR.
1282 -a, --admin-socket=FILE Select socket to connect to."""
1283       exit(0)
1284     elif o in ('-v', '--version'):
1285       version(stdout)
1286       exit(0)
1287     elif o in ('-u', '--usage'):
1288       usage(stdout)
1289       exit(0)
1290     elif o in ('-d', '--directory'):
1291       tripedir = v
1292     elif o in ('-a', '--admin-socket'):
1293       tripesock = v
1294     else:
1295       raise "can't happen!"
1296   if len(args) > 0:
1297     usage(stderr)
1298     exit(1)
1299
1300   OS.chdir(tripedir)
1301   mon = Monitor(tripesock)
1302   root = MonitorWindow(mon)
1303   HookClient().hook(root.closehook, exit)
1304   G.main()
1305
1306 if __name__ == '__main__':
1307   main()
1308
1309 #----- That's all, folks ----------------------------------------------------