chiark / gitweb /
svc: Peer management services.
[tripe] / mon / tripemon.in
CommitLineData
060ca767 1#! @PYTHON@
2# -*-python-*-
3
4#----- Dependencies ---------------------------------------------------------
5
6import socket as S
7from sys import argv, exit, stdin, stdout, stderr
8import os as OS
9from os import environ
37941236 10import math as M
060ca767 11import sets as SET
12import getopt as O
13import time as T
c55f0b7c 14import re as RX
060ca767 15from cStringIO import StringIO
16
17import pygtk
18pygtk.require('2.0')
19import gtk as G
20import gobject as GO
21import gtk.gdk as GDK
22
23#----- Configuration --------------------------------------------------------
24
25tripedir = "@configdir@"
26socketdir = "@socketdir@"
27PACKAGE = "@PACKAGE@"
28VERSION = "@VERSION@"
29
30debug = False
31
32#----- Utility functions ----------------------------------------------------
33
34## Program name, shorn of extraneous stuff.
35quis = OS.path.basename(argv[0])
36
37def moan(msg):
38 """Report a message to standard error."""
39 stderr.write('%s: %s\n' % (quis, msg))
40
41def die(msg, rc = 1):
42 """Report a message to standard error and exit."""
43 moan(msg)
44 exit(rc)
45
46rx_space = RX.compile(r'\s+')
47rx_ordinary = RX.compile(r'[^\\\'\"\s]+')
48rx_weird = RX.compile(r'([\\\'])')
49rx_time = RX.compile(r'^(\d+)-(\d+)-(\d+)T(\d+):(\d+):(\d+)$')
50rx_num = RX.compile(r'^[-+]?\d+$')
51
52c_red = GDK.color_parse('red')
53
54def 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
91def 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
101class 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
107class peerinfo (struct): pass
108class pingstate (struct): pass
109
110def 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
116class 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
137class 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
155class ConnException (Exception):
156 """Some sort of problem occurred while communicating with the tripe
157 server."""
158 pass
159
160class Error (ConnException):
161 """A command caused the server to issue a FAIL message."""
162 pass
163
164class ConnectionFailed (ConnException):
165 """The connection failed while communicating with the server."""
166
167jobid_seq = 0
168def 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
174class 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
198class 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
206class 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
376class 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
461def 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
470class 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
501class 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()
511class 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()
516class 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
ca6eb20c 538def 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
552class 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
060ca767 602class 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
623class ValidationError (Exception):
624 """Raised by ValidatingEntry.get_text() if the text isn't valid."""
625 pass
626class 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
659def 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
669GPL = """This program is free software; you can redistribute it and/or modify
670it under the terms of the GNU General Public License as published by
671the Free Software Foundation; either version 2 of the License, or
672(at your option) any later version.
673
674This program is distributed in the hope that it will be useful,
675but WITHOUT ANY WARRANTY; without even the implied warranty of
676MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
677GNU General Public License for more details.
678
679You should have received a copy of the GNU General Public License
680along with this program; if not, write to the Free Software Foundation,
681Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA."""
682
683class 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()
694aboutbox = WindowSlot(AboutBox)
695
696def 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
ca6eb20c 708def unimplemented(*hunoz):
709 """Indicator of laziness."""
710 moanbox("I've not written that bit yet.")
711
712class 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
732class 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
060ca767 762#----- Logging windows ------------------------------------------------------
763
764class 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
776class 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
784class 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
793class 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
ca6eb20c 813#----- Peer window ----------------------------------------------------------
060ca767 814
815def 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)'
37941236 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)
060ca767 828def 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.
840statsxlate = \
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.
854statslayout = \
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
866class 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)
f87966db 918 if ping.n:
919 s += ' (%.1f%%)' % (ping.ngood * 100.0/ping.n)
060ca767 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
ca6eb20c 924#----- Add peer -------------------------------------------------------------
925
060ca767 926class 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
953class 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),
165efde7 969 '4070',
060ca767 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
ca6eb20c 1008#----- The server monitor ---------------------------------------------------
060ca767 1009
1010class 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
1026class 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()
804c7d44 1048 def cmd(c): return lambda: me.mon.simplecmd(c)
060ca767 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),
ca6eb20c 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')),
060ca767 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">
ca6eb20c 1075 <menuitem action="connect"/>
060ca767 1076 <menuitem action="disconnect"/>
1077 <separator/>
ca6eb20c 1078 <menuitem action="server-version"/>
060ca767 1079 <menuitem action="add-peer"/>
1080 <menuitem action="daemon"/>
ca6eb20c 1081 <menuitem action="reload-keys"/>
060ca767 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:
e04c2d50 1245 GO.source_remove(me.pinger)
060ca767 1246 def report(me, msg):
1247 moanbox(msg)
1248 return True
1249
1250#----- Parse options --------------------------------------------------------
1251
1252def version(fp = stdout):
1253 """Print the program's version number."""
1254 fp.write('%s, %s version %s\n' % (quis, PACKAGE, VERSION))
1255
1256def usage(fp):
1257 """Print a brief usage message for the program."""
1258 fp.write('Usage: %s [-d DIR] [-a SOCK]\n' % quis)
1259
1260def main():
1261 global tripedir
1262 if 'TRIPEDIR' in environ:
1263 tripedir = environ['TRIPEDIR']
179d8129 1264 tripesock = environ.get('TRIPESOCK', '%s/%s' % (socketdir, 'tripesock'))
060ca767 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 """
1281Graphical monitor for TrIPE VPN.
1282
1283Options supported:
1284
e04c2d50
MW
1285-h, --help Show this help message.
1286-v, --version Show the version number.
1287-u, --usage Show pointlessly short usage string.
060ca767 1288
e04c2d50
MW
1289-d, --directory=DIR Use TrIPE directory DIR.
1290-a, --admin-socket=FILE Select socket to connect to."""
060ca767 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
1314if __name__ == '__main__':
1315 main()
1316
ca6eb20c 1317#----- That's all, folks ----------------------------------------------------