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