chiark / gitweb /
Stupid bug.
[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.ngood:
911         s += '; %.2f ms (last %.1f ms)' % (ping.ttot/ping.ngood, ping.tlast);
912       me.e[ping.cmd].set_text(s)
913
914 #----- Add peer -------------------------------------------------------------
915
916 class AddPeerCommand (SimpleBackgroundCommand):
917   def __init__(me, conn, dlg, name, addr, port,
918                keepalive = None, tunnel = None):
919     me.name = name
920     me.addr = addr
921     me.port = port
922     me.keepalive = keepalive
923     me.tunnel = tunnel
924     cmd = StringIO()
925     cmd.write('ADD %s' % name)
926     cmd.write(' -background %s' % jobid())
927     if keepalive is not None: cmd.write(' -keepalive %s' % keepalive)
928     if tunnel is not None: cmd.write(' -tunnel %s' % tunnel)
929     cmd.write(' INET %s %s' % (addr, port))
930     SimpleBackgroundCommand.__init__(me, conn, cmd.getvalue())
931     me.hook(me.donehook, invoker(dlg.destroy))
932   def fail(me, err):
933     token, msg = getword(str(err))
934     if token in ('resolve-error', 'resolver-timeout'):
935       moanbox("Unable to resolve hostname `%s'" % me.addr)
936     elif token == 'peer-create-fail':
937       moanbox("Couldn't create new peer `%s'" % me.name)
938     elif token == 'peer-exists':
939       moanbox("Peer `%s' already exists" % me.name)
940     else:
941       moanbox("Unexpected error from server command `ADD': %s" % err)
942
943 class AddPeerDialog (MyDialog):
944   def __init__(me, monitor):
945     MyDialog.__init__(me, 'Add peer',
946                       buttons = [(G.STOCK_CANCEL, me.destroy),
947                                  (G.STOCK_OK, me.ok)])
948     me.mon = monitor
949     table = GridPacker()
950     me.vbox.pack_start(table)
951     me.e_name = table.labelled('Name',
952                                ValidatingEntry(r'^[^\s.:]+$', '', 16),
953                                width = 3)
954     me.e_addr = table.labelled('Address',
955                                ValidatingEntry(r'^[a-zA-Z0-9.-]+$', '', 24),
956                                newlinep = True)
957     me.e_port = table.labelled('Port',
958                                ValidatingEntry(numericvalidate(0, 65535),
959                                                '22003',
960                                                5))
961     me.c_keepalive = G.CheckButton('Keepalives')
962     me.l_tunnel = table.labelled('Tunnel',
963                                  G.combo_box_new_text(),
964                                  newlinep = True, width = 3)
965     me.tuns = me.mon.simplecmd('TUNNELS')
966     for t in me.tuns:
967       me.l_tunnel.append_text(t)
968     me.l_tunnel.set_active(0)
969     table.pack(me.c_keepalive, newlinep = True, xopt = G.FILL)
970     me.c_keepalive.connect('toggled',
971                            lambda t: me.e_keepalive.set_sensitive\
972                                       (t.get_active()))
973     me.e_keepalive = ValidatingEntry(r'^\d+[hms]?$', '', 5)
974     me.e_keepalive.set_sensitive(False)
975     table.pack(me.e_keepalive, width = 3)
976     me.show_all()
977   def ok(me):
978     try:
979       if me.c_keepalive.get_active():
980         ka = me.e_keepalive.get_text()
981       else:
982         ka = None
983       t = me.l_tunnel.get_active()
984       if t == 0:
985         tun = None
986       else:
987         tun = me.tuns[t]
988       AddPeerCommand(me.mon, me,
989                      me.e_name.get_text(),
990                      me.e_addr.get_text(),
991                      me.e_port.get_text(),
992                      keepalive = ka,
993                      tunnel = tun)
994     except ValidationError:
995       GDK.beep()
996       return
997
998 #----- The server monitor ---------------------------------------------------
999
1000 class PingCommand (SimpleBackgroundCommand):
1001   def __init__(me, conn, cmd, peer, func):
1002     me.peer = peer
1003     me.func = func
1004     SimpleBackgroundCommand.__init__ \
1005       (me, conn, '%s -background %s %s' % (cmd, jobid(), peer.name))
1006   def ok(me):
1007     tok, rest = getword(me.info[0])
1008     if tok == 'ping-ok':
1009       me.func(me.peer, float(rest))
1010     else:
1011       me.func(me.peer, None)
1012     me.unhookall()
1013   def fail(me, err): me.unhookall()
1014   def lost(me): me.unhookall()
1015
1016 class MonitorWindow (MyWindow):
1017
1018   def __init__(me, monitor):
1019     MyWindow.__init__(me)
1020     me.set_title('TrIPE monitor')
1021     me.mon = monitor
1022     me.hook(me.mon.errorhook, me.report)
1023     me.warnings = WarningLogModel()
1024     me.hook(me.mon.warnhook, me.warnings.notify)
1025     me.trace = TraceLogModel()
1026     me.hook(me.mon.tracehook, me.trace.notify)
1027
1028     me.warnview = WindowSlot(lambda: LogViewer(me.warnings))
1029     me.traceview = WindowSlot(lambda: LogViewer(me.trace))
1030     me.traceopts = WindowSlot(lambda: TraceOptions(me.mon))
1031     me.addpeerwin = WindowSlot(lambda: AddPeerDialog(me.mon))
1032     me.servinfo = WindowSlot(lambda: ServInfo(me.mon))
1033
1034     vbox = G.VBox()
1035     me.add(vbox)
1036
1037     me.ui = G.UIManager()
1038     def cmd(c): return lambda: me.mon.simplecmd(c)
1039     actgroup = makeactiongroup('monitor',
1040       [('file-menu', '_File', None, None),
1041        ('connect', '_Connect', '<Alt>C', me.mon.connect),
1042        ('disconnect', '_Disconnect', '<Alt>D', me.mon.disconnect),
1043        ('quit', '_Quit', '<Alt>Q', me.close),
1044        ('server-menu', '_Server', None, None),
1045        ('daemon', 'Run in _background', None, cmd('DAEMON')),
1046        ('server-version', 'Server version', '<Alt>V', me.servinfo.open),
1047        ('reload-keys', 'Reload keys', '<Alt>R', cmd('RELOAD')),
1048        ('server-quit', 'Terminate server', None, cmd('QUIT')),
1049        ('logs-menu', '_Logs', None, None),
1050        ('show-warnings', 'Show _warnings', '<Alt>W', me.warnview.open),
1051        ('show-trace', 'Show _trace', '<Alt>T', me.traceview.open),
1052        ('trace-options', 'Trace _options...', None, me.traceopts.open),
1053        ('help-menu', '_Help', None, None),
1054        ('about', '_About tripemon...', None, aboutbox.open),
1055        ('add-peer', '_Add peer...', '<Alt>A', me.addpeerwin.open),
1056        ('kill-peer', '_Kill peer', None, me.killpeer),
1057        ('force-kx', 'Force key e_xchange', None, me.forcekx)])
1058     uidef = '''
1059       <ui>
1060         <menubar>
1061           <menu action="file-menu">
1062             <menuitem action="quit"/>
1063           </menu>
1064           <menu action="server-menu">
1065              <menuitem action="connect"/>
1066             <menuitem action="disconnect"/>
1067             <separator/>
1068             <menuitem action="server-version"/>
1069             <menuitem action="add-peer"/>
1070             <menuitem action="daemon"/>
1071             <menuitem action="reload-keys"/>
1072             <separator/>
1073             <menuitem action="server-quit"/>
1074           </menu>
1075           <menu action="logs-menu">
1076             <menuitem action="show-warnings"/>
1077             <menuitem action="show-trace"/>
1078             <menuitem action="trace-options"/>
1079           </menu>
1080           <menu action="help-menu">
1081             <menuitem action="about"/>
1082           </menu>
1083         </menubar>
1084         <popup name="peer-popup">
1085           <menuitem action="add-peer"/>
1086           <menuitem action="kill-peer"/>
1087           <menuitem action="force-kx"/>
1088         </popup>
1089       </ui>
1090       '''
1091     me.ui.insert_action_group(actgroup, 0)
1092     me.ui.add_ui_from_string(uidef)
1093     vbox.pack_start(me.ui.get_widget('/menubar'), expand = False)
1094     me.add_accel_group(me.ui.get_accel_group())
1095     me.status = G.Statusbar()
1096
1097     me.listmodel = G.ListStore(*(GO.TYPE_STRING,) * 6)
1098     me.listmodel.set_sort_column_id(0, G.SORT_ASCENDING)
1099     me.hook(me.mon.addpeerhook, me.addpeer)
1100     me.hook(me.mon.delpeerhook, me.delpeer)
1101
1102     scr = G.ScrolledWindow()
1103     scr.set_policy(G.POLICY_AUTOMATIC, G.POLICY_AUTOMATIC)
1104     me.list = G.TreeView(me.listmodel)
1105     me.list.append_column(G.TreeViewColumn('Peer name',
1106                                            G.CellRendererText(),
1107                                            text = 0))
1108     me.list.append_column(G.TreeViewColumn('Address',
1109                                            G.CellRendererText(),
1110                                            text = 1))
1111     me.list.append_column(G.TreeViewColumn('T-ping',
1112                                            G.CellRendererText(),
1113                                            text = 2,
1114                                            foreground = 3))
1115     me.list.append_column(G.TreeViewColumn('E-ping',
1116                                            G.CellRendererText(),
1117                                            text = 4,
1118                                            foreground = 5))
1119     me.list.get_column(1).set_expand(True)
1120     me.list.connect('row-activated', me.activate)
1121     me.list.connect('button-press-event', me.buttonpress)
1122     me.list.set_reorderable(True)
1123     me.list.get_selection().set_mode(G.SELECTION_NONE)
1124     scr.add(me.list)
1125     vbox.pack_start(scr)
1126
1127     vbox.pack_start(me.status, expand = False)
1128     me.hook(me.mon.connecthook, me.connected)
1129     me.hook(me.mon.disconnecthook, me.disconnected)
1130     me.hook(me.mon.notehook, me.notify)
1131     me.pinger = None
1132     me.set_default_size(420, 180)
1133     me.mon.connect()
1134     me.show_all()
1135
1136   def addpeer(me, peer):
1137     peer.i = me.listmodel.append([peer.name, peer.addr,
1138                                   '???', 'green', '???', 'green'])
1139     peer.win = WindowSlot(lambda: PeerWindow(me.mon, peer))
1140     peer.pinghook = HookList()
1141     peer.ping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1142                           tlast = 0, ttot = 0,
1143                           tcol = 2, ccol = 3, cmd = 'Transport pings')
1144     peer.eping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1145                            tlast = 0, ttot = 0,
1146                            tcol = 4, ccol = 5, cmd = 'Encrypted pings')
1147   def delpeer(me, peer):
1148     me.listmodel.remove(peer.i)
1149   def path_peer(me, path):
1150     return me.mon.peers[me.listmodel[path][0]]
1151
1152   def activate(me, l, path, col):
1153     peer = me.path_peer(path)
1154     peer.win.open()
1155   def buttonpress(me, l, ev):
1156     if ev.button == 3:
1157       r = me.list.get_path_at_pos(ev.x, ev.y)
1158       for i in '/peer-popup/kill-peer', '/peer-popup/force-kx':
1159         me.ui.get_widget(i).set_sensitive(me.mon.connectedp and
1160                                           r is not None)
1161       if r:
1162         me.menupeer = me.path_peer(r[0])
1163       else:
1164         me.menupeer = None
1165       me.ui.get_widget('/peer-popup').popup(None, None, None,
1166                                             ev.button, ev.time)
1167
1168   def killpeer(me):
1169     me.mon.simplecmd('KILL %s' % me.menupeer.name)
1170   def forcekx(me):
1171     me.mon.simplecmd('FORCEKX %s' % me.menupeer.name)
1172
1173   def reping(me):
1174     if me.pinger is not None:
1175       GO.source_remove(me.pinger)
1176     me.pinger = GO.timeout_add(10000, me.ping)
1177     me.ping()
1178   def unping(me):
1179     if me.pinger is not None:
1180       GO.source_remove(me.pinger)
1181       me.pinger = None
1182   def ping(me):
1183     for name in me.mon.peers:
1184       p = me.mon.peers[name]
1185       PingCommand(me.mon, 'PING', p, lambda p, t: me.pong(p, p.ping, t))
1186       PingCommand(me.mon, 'EPING', p, lambda p, t: me.pong(p, p.eping, t))
1187     return True
1188   def pong(me, p, ping, t):
1189     ping.n += 1
1190     if t is None:
1191       ping.nmiss += 1
1192       ping.nmissrun += 1
1193       me.listmodel[p.i][ping.tcol] = '(miss %d)' % ping.nmissrun
1194       me.listmodel[p.i][ping.ccol] = 'red'
1195     else:
1196       ping.ngood += 1
1197       ping.nmissrun = 0
1198       ping.tlast = t
1199       ping.ttot += t
1200       me.listmodel[p.i][ping.tcol] = '%.1f ms' % t
1201       me.listmodel[p.i][ping.ccol] = 'black'
1202     p.pinghook.run()
1203   def setstatus(me, status):
1204     me.status.pop(0)
1205     me.status.push(0, status)
1206   def notify(me, note, rest):
1207     if note == 'DAEMON':
1208       me.ui.get_widget('/menubar/server-menu/daemon').set_sensitive(False)
1209   def connected(me):
1210     me.setstatus('Connected (port %s)' % me.mon.simplecmd('PORT')[0])
1211     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(False)
1212     for i in ('/menubar/server-menu/disconnect',
1213               '/menubar/server-menu/server-version',
1214               '/menubar/server-menu/add-peer',
1215               '/menubar/server-menu/server-quit',
1216               '/menubar/logs-menu/trace-options'):
1217       me.ui.get_widget(i).set_sensitive(True)
1218     me.ui.get_widget('/menubar/server-menu/daemon'). \
1219       set_sensitive(parseinfo(me.mon.simplecmd('SERVINFO'))['daemon'] ==
1220                     'nil')
1221     me.reping()
1222   def disconnected(me):
1223     me.setstatus('Disconnected')
1224     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(True)
1225     for i in ('/menubar/server-menu/disconnect',
1226               '/menubar/server-menu/server-version',
1227               '/menubar/server-menu/add-peer',
1228               '/menubar/server-menu/daemon',
1229               '/menubar/server-menu/server-quit',
1230               '/menubar/logs-menu/trace-options'):
1231       me.ui.get_widget(i).set_sensitive(False)
1232     me.unping()
1233   def destroy(me):
1234     if me.pinger is not None:
1235       GO.source_remove(me.pinger)    
1236   def report(me, msg):
1237     moanbox(msg)
1238     return True
1239
1240 #----- Parse options --------------------------------------------------------
1241
1242 def version(fp = stdout):
1243   """Print the program's version number."""
1244   fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
1245
1246 def usage(fp):
1247   """Print a brief usage message for the program."""
1248   fp.write('Usage: %s [-d DIR] [-a SOCK]\n' % quis)
1249
1250 def main():
1251   global tripedir
1252   if 'TRIPEDIR' in environ:
1253     tripedir = environ['TRIPEDIR']
1254   tripesock = '%s/%s' % (socketdir, 'tripesock')
1255
1256   try:
1257     opts, args = O.getopt(argv[1:],
1258                           'hvud:a:',
1259                           ['help', 'version', 'usage',
1260                            'directory=', 'admin-socket='])
1261   except O.GetoptError, exc:
1262     moan(exc)
1263     usage(stderr)
1264     exit(1)
1265   for o, v in opts:
1266     if o in ('-h', '--help'):
1267       version(stdout)
1268       print
1269       usage(stdout)
1270       print """
1271 Graphical monitor for TrIPE VPN.
1272
1273 Options supported:
1274
1275 -h, --help              Show this help message.
1276 -v, --version           Show the version number.
1277 -u, --usage             Show pointlessly short usage string.
1278
1279 -d, --directory=DIR     Use TrIPE directory DIR.
1280 -a, --admin-socket=FILE Select socket to connect to."""
1281       exit(0)
1282     elif o in ('-v', '--version'):
1283       version(stdout)
1284       exit(0)
1285     elif o in ('-u', '--usage'):
1286       usage(stdout)
1287       exit(0)
1288     elif o in ('-d', '--directory'):
1289       tripedir = v
1290     elif o in ('-a', '--admin-socket'):
1291       tripesock = v
1292     else:
1293       raise "can't happen!"
1294   if len(args) > 0:
1295     usage(stderr)
1296     exit(1)
1297
1298   OS.chdir(tripedir)
1299   mon = Monitor(tripesock)
1300   root = MonitorWindow(mon)
1301   HookClient().hook(root.closehook, exit)
1302   G.main()
1303
1304 if __name__ == '__main__':
1305   main()
1306
1307 #----- That's all, folks ----------------------------------------------------