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