chiark / gitweb /
Weird. tripe-keys got backdates somewhere.
[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 class WindowSlot (HookClient):
538   """A place to store a window.  If the window is destroyed, remember this;
539   when we come to open the window, raise it if it already exists; otherwise
540   make a new one."""
541   def __init__(me, createfunc):
542     """Constructor: CREATEFUNC must return a new Window which supports the
543     closehook protocol."""
544     HookClient.__init__(me)
545     me.createfunc = createfunc
546     me.window = None
547   def open(me):
548     """Opens the window, creating it if necessary."""
549     if me.window:
550       me.window.window.raise_()
551     else:
552       me.window = me.createfunc()
553       me.hook(me.window.closehook, me.closed)
554   def closed(me):
555     me.unhook(me.window.closehook)
556     me.window = None
557
558 class ValidationError (Exception):
559   """Raised by ValidatingEntry.get_text() if the text isn't valid."""
560   pass
561 class ValidatingEntry (G.Entry):
562   """Like an Entry, but makes the text go red if the contents are invalid.
563   If get_text is called, and the text is invalid, ValidationError is
564   raised."""
565   def __init__(me, valid, text = '', size = -1, *arg, **kw):
566     """Make an Entry.  VALID is a regular expression or a predicate on
567     strings.  TEXT is the default text to insert.  SIZE is the size of the
568     box to set, in characters (ish).  Other arguments are passed to Entry."""
569     G.Entry.__init__(me, *arg, **kw)
570     me.connect("changed", me.check)
571     if callable(valid):
572       me.validate = valid
573     else:
574       me.validate = RX.compile(valid).match
575     me.ensure_style()
576     me.c_ok = me.get_style().text[G.STATE_NORMAL]
577     me.c_bad = c_red
578     if size != -1: me.set_width_chars(size)
579     me.set_activates_default(True)
580     me.set_text(text)
581     me.check()
582   def check(me, *hunoz):
583     if me.validate(G.Entry.get_text(me)):
584       me.validp = True
585       me.modify_text(G.STATE_NORMAL, me.c_ok)
586     else:
587       me.validp = False
588       me.modify_text(G.STATE_NORMAL, me.c_bad)
589   def get_text(me):
590     if not me.validp:
591       raise ValidationError
592     return G.Entry.get_text(me)
593
594 def numericvalidate(min = None, max = None):
595   """Validation function for numbers.  Entry must consist of an optional sign
596   followed by digits, and the resulting integer must be within the given
597   bounds."""
598   return lambda x: (rx_num.match(x) and
599                     (min is None or long(x) >= min) and
600                     (max is None or long(x) <= max))
601
602 #----- Various minor dialog boxen -------------------------------------------
603
604 GPL = """This program is free software; you can redistribute it and/or modify
605 it under the terms of the GNU General Public License as published by
606 the Free Software Foundation; either version 2 of the License, or
607 (at your option) any later version.
608
609 This program is distributed in the hope that it will be useful,
610 but WITHOUT ANY WARRANTY; without even the implied warranty of
611 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
612 GNU General Public License for more details.
613
614 You should have received a copy of the GNU General Public License
615 along with this program; if not, write to the Free Software Foundation,
616 Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA."""
617
618 class AboutBox (G.AboutDialog, MyWindowMixin):
619   """The program `About' box."""
620   def __init__(me):
621     G.AboutDialog.__init__(me)
622     me.mywininit()
623     me.set_name('TrIPEmon')
624     me.set_version(VERSION)
625     me.set_license(GPL)
626     me.set_authors(['Mark Wooding'])
627     me.connect('unmap', invoker(me.close))
628     me.show()
629 aboutbox = WindowSlot(AboutBox)
630
631 def moanbox(msg):
632   """Report an error message in a window."""
633   d = G.Dialog('Error from %s' % quis,
634                flags = G.DIALOG_MODAL,
635                buttons = ((G.STOCK_OK, G.RESPONSE_NONE)))
636   label = G.Label(msg)
637   label.set_padding(20, 20)
638   d.vbox.pack_start(label)
639   label.show()
640   d.run()
641   d.destroy()
642
643 #----- Logging windows ------------------------------------------------------
644
645 class LogModel (G.ListStore):
646   """A simple list of log messages."""
647   def __init__(me, columns):
648     """Call with a list of column names.  All must be strings.  We add a time
649     column to the left."""
650     me.cols = ('Time',) + columns
651     G.ListStore.__init__(me, *((GO.TYPE_STRING,) * len(me.cols)))
652   def add(me, *entries):
653     """Adds a new log message, with a timestamp."""
654     now = T.strftime('%Y-%m-%d %H:%M:%S')
655     me.append((now,) + entries)
656
657 class TraceLogModel (LogModel):
658   """Log model for trace messages."""
659   def __init__(me):
660     LogModel.__init__(me, ('Message',))
661   def notify(me, line):
662     """Call with a new trace message."""
663     me.add(line)
664
665 class WarningLogModel (LogModel):
666   """Log model for warnings.  We split the category out into a separate
667   column."""
668   def __init__(me):
669     LogModel.__init__(me, ('Category', 'Message'))
670   def notify(me, line):
671     """Call with a new warning message."""
672     me.add(*getword(line))
673
674 class LogViewer (MyWindow):
675   """Log viewer window.  Nothing very exciting."""
676   def __init__(me, model):
677     MyWindow.__init__(me)
678     me.model = model
679     scr = G.ScrolledWindow()
680     scr.set_policy(G.POLICY_AUTOMATIC, G.POLICY_AUTOMATIC)
681     me.list = G.TreeView(me.model)
682     me.closehook = HookList()
683     i = 0
684     for c in me.model.cols:
685       me.list.append_column(G.TreeViewColumn(c,
686                                              G.CellRendererText(),
687                                              text = i))
688       i += 1
689     me.set_default_size(440, 256)
690     scr.add(me.list)
691     me.add(scr)
692     me.show_all()
693
694 def makeactiongroup(name, acts):
695   """Creates an ActionGroup called NAME.  ACTS is a list of tuples
696   containing:
697   ACT: an action name
698   LABEL: the label string for the action
699   ACCEL: accelerator string, or None
700   FUNC: thunk to call when the action is invoked"""
701   actgroup = G.ActionGroup(name)
702   for act, label, accel, func in acts:
703     a = G.Action(act, label, None, None)
704     if func: a.connect('activate', invoker(func))
705     actgroup.add_action_with_accel(a, accel)
706   return actgroup
707
708 class TraceOptions (MyDialog):
709   """Tracing options window."""
710   def __init__(me, monitor):
711     MyDialog.__init__(me, title = 'Tracing options',
712                       buttons = [(G.STOCK_CLOSE, me.destroy),
713                                  (G.STOCK_OK, me.ok)])
714     me.mon = monitor
715     me.opts = []
716     for o in me.mon.simplecmd('TRACE'):
717       char = o[0]
718       onp = o[1]
719       text = o[3].upper() + o[4:]
720       if char.isupper(): continue
721       ticky = G.CheckButton(text)
722       ticky.set_active(onp != ' ')
723       me.vbox.pack_start(ticky)
724       me.opts.append((char, ticky))
725     me.show_all()
726   def ok(me):
727     on = []
728     off = []
729     for char, ticky in me.opts:
730       if ticky.get_active():
731         on.append(char)
732       else:
733         off.append(char)
734     setting = ''.join(on) + '-' + ''.join(off)
735     me.mon.simplecmd('TRACE %s' % setting)
736     me.destroy()
737
738 def unimplemented(*hunoz):
739   """Indicator of laziness."""
740   moanbox("I've not written that bit yet.")
741
742 class GridPacker (G.Table):
743   """Like a Table, but with more state: makes filling in the widgets
744   easier."""
745   def __init__(me):
746     G.Table.__init__(me)
747     me.row = 0
748     me.col = 0
749     me.rows = 1
750     me.cols = 1
751     me.set_border_width(4)
752     me.set_col_spacings(4)
753     me.set_row_spacings(4)
754   def pack(me, w, width = 1, newlinep = False,
755            xopt = G.EXPAND | G.FILL | G.SHRINK, yopt = 0,
756            xpad = 0, ypad = 0):
757     """Packs a new widget.  W is the widget to add.  XOPY, YOPT, XPAD and
758     YPAD are as for Table.  WIDTH is how many cells to take up horizontally.
759     NEWLINEP is whether to start a new line for this widget.  Returns W."""
760     if newlinep:
761       me.row += 1
762       me.col = 0
763     bot = me.row + 1
764     right = me.col + width
765     if bot > me.rows or right > me.cols:
766       if bot > me.rows: me.rows = bot
767       if right > me.cols: me.cols = right
768       me.resize(me.rows, me.cols)
769     me.attach(w, me.col, me.col + width, me.row, me.row + 1,
770               xopt, yopt, xpad, ypad)
771     me.col += width
772     return w
773   def labelled(me, lab, w, newlinep = False, **kw):
774     """Packs a labelled widget.  Other arguments are as for pack.  Returns
775     W."""
776     label = G.Label(lab)
777     label.set_alignment(1.0, 0)
778     me.pack(label, newlinep = newlinep, xopt = G.FILL)
779     me.pack(w, **kw)
780     return w
781   def info(me, label, text = None, len = 18, **kw):
782     e = G.Entry()
783     if text is not None: e.set_text(text)
784     e.set_width_chars(len)
785     e.set_editable(False)
786     me.labelled(label, e, **kw)
787     return e
788
789 def xlate_time(t):
790   """Translate a time in tripe's stats format to something a human might
791   actually want to read."""
792   if t == 'NEVER': return '(never)'
793   Y, M, D, h, m, s = map(int, rx_time.match(t).group(1, 2, 3, 4, 5, 6))
794   return '%04d:%02d:%02d %02d:%02d:%02d' % (Y, M, D, h, m, s)
795 def xlate_bytes(b):
796   """Translate a number of bytes into something a human might want to read."""
797   suff = 'B'
798   b = int(b)
799   for s in 'KMG':
800     if b < 4096: break
801     b /= 1024
802     suff = s
803   return '%d %s' % (b, suff)
804
805 ## How to translate peer stats.  Maps the stat name to a translation
806 ## function.
807 statsxlate = \
808   [('start-time', xlate_time),
809    ('last-packet-time', xlate_time),
810    ('last-keyexch-time', xlate_time),
811    ('bytes-in', xlate_bytes),
812    ('bytes-out', xlate_bytes),
813    ('keyexch-bytes-in', xlate_bytes),
814    ('keyexch-bytes-out', xlate_bytes),
815    ('ip-bytes-in', xlate_bytes),
816    ('ip-bytes-out', xlate_bytes)]
817
818 ## How to lay out the stats dialog.  Format is (LABEL, FORMAT): LABEL is
819 ## the label to give the entry box; FORMAT is the format string to write into
820 ## the entry.
821 statslayout = \
822   [('Start time', '%(start-time)s'),
823    ('Last key-exchange', '%(last-keyexch-time)s'),
824    ('Last packet', '%(last-packet-time)s'),
825    ('Packets in/out',
826     '%(packets-in)s (%(bytes-in)s) / %(packets-out)s (%(bytes-out)s)'),
827    ('Key-exchange in/out',
828     '%(keyexch-packets-in)s (%(keyexch-bytes-in)s) / %(keyexch-packets-out)s (%(keyexch-bytes-out)s)'),
829    ('IP in/out',
830     '%(ip-packets-in)s (%(ip-bytes-in)s) / %(ip-packets-in)s (%(ip-bytes-in)s)'),
831    ('Rejected packets', '%(rejected-packets)s')]
832
833 class PeerWindow (MyWindow):
834   """Show information about a peer."""
835   def __init__(me, monitor, peer):
836     MyWindow.__init__(me)
837     me.set_title('TrIPE statistics: %s' % peer.name)
838     me.mon = monitor
839     me.peer = peer
840     table = GridPacker()
841     me.add(table)
842     me.e = {}
843     def add(label, text = None):
844       me.e[label] = table.info(label, text, len = 42, newlinep = True)
845     add('Peer name', peer.name)
846     add('Tunnel', peer.tunnel)
847     add('Interface', peer.ifname)
848     add('Keepalives',
849         (peer.keepalive == '0' and 'never') or '%s s' % peer.keepalive)
850     add('Address', peer.addr)
851     add('Transport pings')
852     add('Encrypted pings')
853     for label, format in statslayout: add(label)
854     me.timeout = None
855     me.hook(me.mon.connecthook, me.tryupdate)
856     me.hook(me.mon.disconnecthook, me.stopupdate)
857     me.hook(me.closehook, me.stopupdate)
858     me.hook(me.peer.deadhook, me.dead)
859     me.hook(me.peer.pinghook, me.ping)
860     me.tryupdate()
861     me.ping()
862     me.show_all()
863   def update(me):
864     if not me.peer.alivep or not me.mon.connectedp: return False
865     stat = parseinfo(me.mon.simplecmd('STATS %s' % me.peer.name))
866     for s, trans in statsxlate:
867       stat[s] = trans(stat[s])
868     for label, format in statslayout:
869       me.e[label].set_text(format % stat)
870     return True
871   def tryupdate(me):
872     if me.timeout is None and me.update():
873       me.timeout = GO.timeout_add(1000, me.update)
874   def stopupdate(me):
875     if me.timeout is not None:
876       GO.source_remove(me.timeout)
877       me.timeout = None
878   def dead(me):
879     me.set_title('TrIPE statistics: %s [defunct]' % me.peer.name)
880     me.e['Peer name'].set_text('%s [defunct]' % me.peer.name)
881     me.stopupdate()
882   def ping(me):
883     for ping in me.peer.ping, me.peer.eping:
884       s = '%d/%d' % (ping.ngood, ping.n)
885       if ping.ngood:
886         s += '; %.2f ms (last %.1f ms)' % (ping.ttot/ping.ngood, ping.tlast);
887       me.e[ping.cmd].set_text(s)
888
889 class AddPeerCommand (SimpleBackgroundCommand):
890   def __init__(me, conn, dlg, name, addr, port,
891                keepalive = None, tunnel = None):
892     me.name = name
893     me.addr = addr
894     me.port = port
895     me.keepalive = keepalive
896     me.tunnel = tunnel
897     cmd = StringIO()
898     cmd.write('ADD %s' % name)
899     cmd.write(' -background %s' % jobid())
900     if keepalive is not None: cmd.write(' -keepalive %s' % keepalive)
901     if tunnel is not None: cmd.write(' -tunnel %s' % tunnel)
902     cmd.write(' INET %s %s' % (addr, port))
903     SimpleBackgroundCommand.__init__(me, conn, cmd.getvalue())
904     me.hook(me.donehook, invoker(dlg.destroy))
905   def fail(me, err):
906     token, msg = getword(str(err))
907     if token in ('resolve-error', 'resolver-timeout'):
908       moanbox("Unable to resolve hostname `%s'" % me.addr)
909     elif token == 'peer-create-fail':
910       moanbox("Couldn't create new peer `%s'" % me.name)
911     elif token == 'peer-exists':
912       moanbox("Peer `%s' already exists" % me.name)
913     else:
914       moanbox("Unexpected error from server command `ADD': %s" % err)
915
916 class AddPeerDialog (MyDialog):
917   def __init__(me, monitor):
918     MyDialog.__init__(me, 'Add peer',
919                       buttons = [(G.STOCK_CANCEL, me.destroy),
920                                  (G.STOCK_OK, me.ok)])
921     me.mon = monitor
922     table = GridPacker()
923     me.vbox.pack_start(table)
924     me.e_name = table.labelled('Name',
925                                ValidatingEntry(r'^[^\s.:]+$', '', 16),
926                                width = 3)
927     me.e_addr = table.labelled('Address',
928                                ValidatingEntry(r'^[a-zA-Z0-9.-]+$', '', 24),
929                                newlinep = True)
930     me.e_port = table.labelled('Port',
931                                ValidatingEntry(numericvalidate(0, 65535),
932                                                '22003',
933                                                5))
934     me.c_keepalive = G.CheckButton('Keepalives')
935     me.l_tunnel = table.labelled('Tunnel',
936                                  G.combo_box_new_text(),
937                                  newlinep = True, width = 3)
938     me.tuns = me.mon.simplecmd('TUNNELS')
939     for t in me.tuns:
940       me.l_tunnel.append_text(t)
941     me.l_tunnel.set_active(0)
942     table.pack(me.c_keepalive, newlinep = True, xopt = G.FILL)
943     me.c_keepalive.connect('toggled',
944                            lambda t: me.e_keepalive.set_sensitive\
945                                       (t.get_active()))
946     me.e_keepalive = ValidatingEntry(r'^\d+[hms]?$', '', 5)
947     me.e_keepalive.set_sensitive(False)
948     table.pack(me.e_keepalive, width = 3)
949     me.show_all()
950   def ok(me):
951     try:
952       if me.c_keepalive.get_active():
953         ka = me.e_keepalive.get_text()
954       else:
955         ka = None
956       t = me.l_tunnel.get_active()
957       if t == 0:
958         tun = None
959       else:
960         tun = me.tuns[t]
961       AddPeerCommand(me.mon, me,
962                      me.e_name.get_text(),
963                      me.e_addr.get_text(),
964                      me.e_port.get_text(),
965                      keepalive = ka,
966                      tunnel = tun)
967     except ValidationError:
968       GDK.beep()
969       return
970
971 class ServInfo (MyWindow):
972   def __init__(me, monitor):
973     MyWindow.__init__(me)
974     me.set_title('TrIPE server info')
975     me.mon = monitor
976     me.table = GridPacker()
977     me.add(me.table)
978     me.e = {}
979     def add(label, tag, text = None, **kw):
980       me.e[tag] = me.table.info(label, text, **kw)
981     add('Implementation', 'implementation')
982     add('Version', 'version', newlinep = True)
983     me.update()
984     me.hook(me.mon.connecthook, me.update)
985     me.show_all()
986   def update(me):
987     info = parseinfo(me.mon.simplecmd('SERVINFO'))
988     for i in me.e:
989       me.e[i].set_text(info[i])
990
991 class PingCommand (SimpleBackgroundCommand):
992   def __init__(me, conn, cmd, peer, func):
993     me.peer = peer
994     me.func = func
995     SimpleBackgroundCommand.__init__ \
996       (me, conn, '%s -background %s %s' % (cmd, jobid(), peer.name))
997   def ok(me):
998     tok, rest = getword(me.info[0])
999     if tok == 'ping-ok':
1000       me.func(me.peer, float(rest))
1001     else:
1002       me.func(me.peer, None)
1003     me.unhookall()
1004   def fail(me, err): me.unhookall()
1005   def lost(me): me.unhookall()
1006
1007 class MonitorWindow (MyWindow):
1008
1009   def __init__(me, monitor):
1010     MyWindow.__init__(me)
1011     me.set_title('TrIPE monitor')
1012     me.mon = monitor
1013     me.hook(me.mon.errorhook, me.report)
1014     me.warnings = WarningLogModel()
1015     me.hook(me.mon.warnhook, me.warnings.notify)
1016     me.trace = TraceLogModel()
1017     me.hook(me.mon.tracehook, me.trace.notify)
1018
1019     me.warnview = WindowSlot(lambda: LogViewer(me.warnings))
1020     me.traceview = WindowSlot(lambda: LogViewer(me.trace))
1021     me.traceopts = WindowSlot(lambda: TraceOptions(me.mon))
1022     me.addpeerwin = WindowSlot(lambda: AddPeerDialog(me.mon))
1023     me.servinfo = WindowSlot(lambda: ServInfo(me.mon))
1024
1025     vbox = G.VBox()
1026     me.add(vbox)
1027
1028     me.ui = G.UIManager()
1029     actgroup = makeactiongroup('monitor',
1030       [('file-menu', '_File', None, None),
1031        ('connect', '_Connect', '<Alt>C', me.mon.connect),
1032        ('disconnect', '_Disconnect', '<Alt>D', me.mon.disconnect),
1033        ('quit', '_Quit', '<Alt>Q', me.close),
1034        ('server-menu', '_Server', None, None),
1035        ('daemon', 'Run in _background', None,
1036           lambda: me.mon.simplecmd('DAEMON')),
1037        ('server-version', 'Server version', None, me.servinfo.open),
1038        ('server-quit', 'Terminate server', None,
1039           lambda: me.mon.simplecmd('QUIT')),
1040        ('logs-menu', '_Logs', None, None),
1041        ('show-warnings', 'Show _warnings', '<Alt>W', me.warnview.open),
1042        ('show-trace', 'Show _trace', '<Alt>T', me.traceview.open),
1043        ('trace-options', 'Trace _options...', None, me.traceopts.open),
1044        ('help-menu', '_Help', None, None),
1045        ('about', '_About tripemon...', None, aboutbox.open),
1046        ('add-peer', '_Add peer...', '<Alt>A', me.addpeerwin.open),
1047        ('kill-peer', '_Kill peer', None, me.killpeer),
1048        ('force-kx', 'Force key e_xchange', None, me.forcekx)])
1049     uidef = '''
1050       <ui>
1051         <menubar>
1052           <menu action="file-menu">
1053             <menuitem action="quit"/>
1054           </menu>
1055           <menu action="server-menu">
1056             <menuitem action="connect"/>
1057             <menuitem action="disconnect"/>
1058             <separator/>
1059             <menuitem action="add-peer"/>
1060             <menuitem action="daemon"/>
1061             <menuitem action="server-version"/>
1062             <separator/>
1063             <menuitem action="server-quit"/>
1064           </menu>
1065           <menu action="logs-menu">
1066             <menuitem action="show-warnings"/>
1067             <menuitem action="show-trace"/>
1068             <menuitem action="trace-options"/>
1069           </menu>
1070           <menu action="help-menu">
1071             <menuitem action="about"/>
1072           </menu>
1073         </menubar>
1074         <popup name="peer-popup">
1075           <menuitem action="add-peer"/>
1076           <menuitem action="kill-peer"/>
1077           <menuitem action="force-kx"/>
1078         </popup>
1079       </ui>
1080       '''
1081     me.ui.insert_action_group(actgroup, 0)
1082     me.ui.add_ui_from_string(uidef)
1083     vbox.pack_start(me.ui.get_widget('/menubar'), expand = False)
1084     me.add_accel_group(me.ui.get_accel_group())
1085     me.status = G.Statusbar()
1086
1087     me.listmodel = G.ListStore(*(GO.TYPE_STRING,) * 6)
1088     me.listmodel.set_sort_column_id(0, G.SORT_ASCENDING)
1089     me.hook(me.mon.addpeerhook, me.addpeer)
1090     me.hook(me.mon.delpeerhook, me.delpeer)
1091
1092     scr = G.ScrolledWindow()
1093     scr.set_policy(G.POLICY_AUTOMATIC, G.POLICY_AUTOMATIC)
1094     me.list = G.TreeView(me.listmodel)
1095     me.list.append_column(G.TreeViewColumn('Peer name',
1096                                            G.CellRendererText(),
1097                                            text = 0))
1098     me.list.append_column(G.TreeViewColumn('Address',
1099                                            G.CellRendererText(),
1100                                            text = 1))
1101     me.list.append_column(G.TreeViewColumn('T-ping',
1102                                            G.CellRendererText(),
1103                                            text = 2,
1104                                            foreground = 3))
1105     me.list.append_column(G.TreeViewColumn('E-ping',
1106                                            G.CellRendererText(),
1107                                            text = 4,
1108                                            foreground = 5))
1109     me.list.get_column(1).set_expand(True)
1110     me.list.connect('row-activated', me.activate)
1111     me.list.connect('button-press-event', me.buttonpress)
1112     me.list.set_reorderable(True)
1113     me.list.get_selection().set_mode(G.SELECTION_NONE)
1114     scr.add(me.list)
1115     vbox.pack_start(scr)
1116
1117     vbox.pack_start(me.status, expand = False)
1118     me.hook(me.mon.connecthook, me.connected)
1119     me.hook(me.mon.disconnecthook, me.disconnected)
1120     me.hook(me.mon.notehook, me.notify)
1121     me.pinger = None
1122     me.set_default_size(420, 180)
1123     me.mon.connect()
1124     me.show_all()
1125
1126   def addpeer(me, peer):
1127     peer.i = me.listmodel.append([peer.name, peer.addr,
1128                                   '???', 'green', '???', 'green'])
1129     peer.win = WindowSlot(lambda: PeerWindow(me.mon, peer))
1130     peer.pinghook = HookList()
1131     peer.ping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1132                           tlast = 0, ttot = 0,
1133                           tcol = 2, ccol = 3, cmd = 'Transport pings')
1134     peer.eping = pingstate(n = 0, ngood = 0, nmiss = 0, nmissrun = 0,
1135                            tlast = 0, ttot = 0,
1136                            tcol = 4, ccol = 5, cmd = 'Encrypted pings')
1137   def delpeer(me, peer):
1138     me.listmodel.remove(peer.i)
1139   def path_peer(me, path):
1140     return me.mon.peers[me.listmodel[path][0]]
1141
1142   def activate(me, l, path, col):
1143     peer = me.path_peer(path)
1144     peer.win.open()
1145   def buttonpress(me, l, ev):
1146     if ev.button == 3:
1147       r = me.list.get_path_at_pos(ev.x, ev.y)
1148       for i in '/peer-popup/kill-peer', '/peer-popup/force-kx':
1149         me.ui.get_widget(i).set_sensitive(me.mon.connectedp and
1150                                           r is not None)
1151       if r:
1152         me.menupeer = me.path_peer(r[0])
1153       else:
1154         me.menupeer = None
1155       me.ui.get_widget('/peer-popup').popup(None, None, None,
1156                                             ev.button, ev.time)
1157
1158   def killpeer(me):
1159     me.mon.simplecmd('KILL %s' % me.menupeer.name)
1160   def forcekx(me):
1161     me.mon.simplecmd('FORCEKX %s' % me.menupeer.name)
1162
1163   def reping(me):
1164     if me.pinger is not None:
1165       GO.source_remove(me.pinger)
1166     me.pinger = GO.timeout_add(10000, me.ping)
1167     me.ping()
1168   def unping(me):
1169     if me.pinger is not None:
1170       GO.source_remove(me.pinger)
1171       me.pinger = None
1172   def ping(me):
1173     for name in me.mon.peers:
1174       p = me.mon.peers[name]
1175       PingCommand(me.mon, 'PING', p, lambda p, t: me.pong(p, p.ping, t))
1176       PingCommand(me.mon, 'EPING', p, lambda p, t: me.pong(p, p.eping, t))
1177     return True
1178   def pong(me, p, ping, t):
1179     ping.n += 1
1180     if t is None:
1181       ping.nmiss += 1
1182       ping.nmissrun += 1
1183       me.listmodel[p.i][ping.tcol] = '(miss %d)' % ping.nmissrun
1184       me.listmodel[p.i][ping.ccol] = 'red'
1185     else:
1186       ping.ngood += 1
1187       ping.nmissrun = 0
1188       ping.tlast = t
1189       ping.ttot += t
1190       me.listmodel[p.i][ping.tcol] = '%.1f ms' % t
1191       me.listmodel[p.i][ping.ccol] = 'black'
1192     p.pinghook.run()
1193   def setstatus(me, status):
1194     me.status.pop(0)
1195     me.status.push(0, status)
1196   def notify(me, note, rest):
1197     if note == 'DAEMON':
1198       me.ui.get_widget('/menubar/server-menu/daemon').set_sensitive(False)
1199   def connected(me):
1200     me.setstatus('Connected (port %s)' % me.mon.simplecmd('PORT')[0])
1201     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(False)
1202     for i in ('/menubar/server-menu/disconnect',
1203               '/menubar/server-menu/server-version',
1204               '/menubar/server-menu/add-peer',
1205               '/menubar/server-menu/server-quit',
1206               '/menubar/logs-menu/trace-options'):
1207       me.ui.get_widget(i).set_sensitive(True)
1208     me.ui.get_widget('/menubar/server-menu/daemon'). \
1209       set_sensitive(parseinfo(me.mon.simplecmd('SERVINFO'))['daemon'] ==
1210                     'nil')
1211     me.reping()
1212   def disconnected(me):
1213     me.setstatus('Disconnected')
1214     me.ui.get_widget('/menubar/server-menu/connect').set_sensitive(True)
1215     for i in ('/menubar/server-menu/disconnect',
1216               '/menubar/server-menu/server-version',
1217               '/menubar/server-menu/add-peer',
1218               '/menubar/server-menu/daemon',
1219               '/menubar/server-menu/server-quit',
1220               '/menubar/logs-menu/trace-options'):
1221       me.ui.get_widget(i).set_sensitive(False)
1222     me.unping()
1223   def destroy(me):
1224     if me.pinger is not None:
1225       GO.source_remove(me.pinger)    
1226   def report(me, msg):
1227     moanbox(msg)
1228     return True
1229
1230 #----- Parse options --------------------------------------------------------
1231
1232 def version(fp = stdout):
1233   """Print the program's version number."""
1234   fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
1235
1236 def usage(fp):
1237   """Print a brief usage message for the program."""
1238   fp.write('Usage: %s [-d DIR] [-a SOCK]\n' % quis)
1239
1240 def main():
1241   global tripedir
1242   if 'TRIPEDIR' in environ:
1243     tripedir = environ['TRIPEDIR']
1244   tripesock = '%s/%s' % (socketdir, 'tripesock')
1245
1246   try:
1247     opts, args = O.getopt(argv[1:],
1248                           'hvud:a:',
1249                           ['help', 'version', 'usage',
1250                            'directory=', 'admin-socket='])
1251   except O.GetoptError, exc:
1252     moan(exc)
1253     usage(stderr)
1254     exit(1)
1255   for o, v in opts:
1256     if o in ('-h', '--help'):
1257       version(stdout)
1258       print
1259       usage(stdout)
1260       print """
1261 Graphical monitor for TrIPE VPN.
1262
1263 Options supported:
1264
1265 -h, --help              Show this help message.
1266 -v, --version           Show the version number.
1267 -u, --usage             Show pointlessly short usage string.
1268
1269 -d, --directory=DIR     Use TrIPE directory DIR.
1270 -a, --admin-socket=FILE Select socket to connect to."""
1271       exit(0)
1272     elif o in ('-v', '--version'):
1273       version(stdout)
1274       exit(0)
1275     elif o in ('-u', '--usage'):
1276       usage(stdout)
1277       exit(0)
1278     elif o in ('-d', '--directory'):
1279       tripedir = v
1280     elif o in ('-a', '--admin-socket'):
1281       tripesock = v
1282     else:
1283       raise "can't happen!"
1284   if len(args) > 0:
1285     usage(stderr)
1286     exit(1)
1287
1288   OS.chdir(tripedir)
1289   mon = Monitor(tripesock)
1290   root = MonitorWindow(mon)
1291   HookClient().hook(root.closehook, exit)
1292   G.main()
1293
1294 if __name__ == '__main__':
1295   main()
1296