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