2
# -*- mode: python; coding: utf-8 -*-
 
 
4
# Mandos Monitor - Control and monitor the Mandos server
 
 
6
# Copyright © 2009-2011 Teddy Hogeborn
 
 
7
# Copyright © 2009-2011 Björn Påhlsson
 
 
9
# This program is free software: you can redistribute it and/or modify
 
 
10
# it under the terms of the GNU General Public License as published by
 
 
11
# the Free Software Foundation, either version 3 of the License, or
 
 
12
# (at your option) any later version.
 
 
14
#     This program is distributed in the hope that it will be useful,
 
 
15
#     but WITHOUT ANY WARRANTY; without even the implied warranty of
 
 
16
#     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 
 
17
#     GNU General Public License for more details.
 
 
19
# You should have received a copy of the GNU General Public License
 
 
20
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
22
# Contact the authors at <mandos@recompile.se>.
 
 
25
from __future__ import (division, absolute_import, print_function,
 
 
34
import urwid.curses_display
 
 
37
from dbus.mainloop.glib import DBusGMainLoop
 
 
46
locale.setlocale(locale.LC_ALL, '')
 
 
49
logging.getLogger('dbus.proxies').setLevel(logging.CRITICAL)
 
 
51
# Some useful constants
 
 
52
domain = 'se.recompile'
 
 
53
server_interface = domain + '.Mandos'
 
 
54
client_interface = domain + '.Mandos.Client'
 
 
57
# Always run in monochrome mode
 
 
58
urwid.curses_display.curses.has_colors = lambda : False
 
 
60
# Urwid doesn't support blinking, but we want it.  Since we have no
 
 
61
# use for underline on its own, we make underline also always blink.
 
 
62
urwid.curses_display.curses.A_UNDERLINE |= (
 
 
63
    urwid.curses_display.curses.A_BLINK)
 
 
65
def isoformat_to_datetime(iso):
 
 
66
    "Parse an ISO 8601 date string to a datetime.datetime()"
 
 
69
    d, t = iso.split("T", 1)
 
 
70
    year, month, day = d.split("-", 2)
 
 
71
    hour, minute, second = t.split(":", 2)
 
 
72
    second, fraction = divmod(float(second), 1)
 
 
73
    return datetime.datetime(int(year),
 
 
78
                             int(second),           # Whole seconds
 
 
79
                             int(fraction*1000000)) # Microseconds
 
 
81
class MandosClientPropertyCache(object):
 
 
82
    """This wraps a Mandos Client D-Bus proxy object, caches the
 
 
83
    properties and calls a hook function when any of them are
 
 
86
    def __init__(self, proxy_object=None, *args, **kwargs):
 
 
87
        self.proxy = proxy_object # Mandos Client proxy object
 
 
89
        self.properties = dict()
 
 
90
        self.property_changed_match = (
 
 
91
            self.proxy.connect_to_signal("PropertyChanged",
 
 
92
                                         self.property_changed,
 
 
96
        self.properties.update(
 
 
97
            self.proxy.GetAll(client_interface,
 
 
98
                              dbus_interface = dbus.PROPERTIES_IFACE))
 
 
100
        #XXX This breaks good super behaviour
 
 
101
#        super(MandosClientPropertyCache, self).__init__(
 
 
104
    def property_changed(self, property=None, value=None):
 
 
105
        """This is called whenever we get a PropertyChanged signal
 
 
106
        It updates the changed property in the "properties" dict.
 
 
108
        # Update properties dict with new value
 
 
109
        self.properties[property] = value
 
 
111
    def delete(self, *args, **kwargs):
 
 
112
        self.property_changed_match.remove()
 
 
113
        super(MandosClientPropertyCache, self).__init__(
 
 
117
class MandosClientWidget(urwid.FlowWidget, MandosClientPropertyCache):
 
 
118
    """A Mandos Client which is visible on the screen.
 
 
121
    def __init__(self, server_proxy_object=None, update_hook=None,
 
 
122
                 delete_hook=None, logger=None, *args, **kwargs):
 
 
124
        self.update_hook = update_hook
 
 
126
        self.delete_hook = delete_hook
 
 
127
        # Mandos Server proxy object
 
 
128
        self.server_proxy_object = server_proxy_object
 
 
132
        self._update_timer_callback_tag = None
 
 
133
        self._update_timer_callback_lock = 0
 
 
134
        self.last_checker_failed = False
 
 
136
        # The widget shown normally
 
 
137
        self._text_widget = urwid.Text("")
 
 
138
        # The widget shown when we have focus
 
 
139
        self._focus_text_widget = urwid.Text("")
 
 
140
        super(MandosClientWidget, self).__init__(
 
 
141
            update_hook=update_hook, delete_hook=delete_hook,
 
 
146
        last_checked_ok = isoformat_to_datetime(self.properties
 
 
148
        if last_checked_ok is None:
 
 
149
            self.last_checker_failed = True
 
 
151
            self.last_checker_failed = ((datetime.datetime.utcnow()
 
 
158
        if self.last_checker_failed:
 
 
159
            self.using_timer(True)
 
 
161
        if self.need_approval:
 
 
162
            self.using_timer(True)
 
 
164
        self.match_objects = (
 
 
165
            self.proxy.connect_to_signal("CheckerCompleted",
 
 
166
                                         self.checker_completed,
 
 
169
            self.proxy.connect_to_signal("CheckerStarted",
 
 
170
                                         self.checker_started,
 
 
173
            self.proxy.connect_to_signal("GotSecret",
 
 
177
            self.proxy.connect_to_signal("NeedApproval",
 
 
181
            self.proxy.connect_to_signal("Rejected",
 
 
185
        #self.logger('Created client %s' % (self.properties["Name"]))
 
 
187
    def property_changed(self, property=None, value=None):
 
 
188
        super(self, MandosClientWidget).property_changed(property,
 
 
190
        if property == "ApprovalPending":
 
 
191
            using_timer(bool(value))
 
 
193
    def using_timer(self, flag):
 
 
194
        """Call this method with True or False when timer should be
 
 
195
        activated or deactivated.
 
 
197
        old = self._update_timer_callback_lock
 
 
199
            self._update_timer_callback_lock += 1
 
 
201
            self._update_timer_callback_lock -= 1
 
 
202
        if old == 0 and self._update_timer_callback_lock:
 
 
203
            # Will update the shown timer value every second
 
 
204
            self._update_timer_callback_tag = (gobject.timeout_add
 
 
207
        elif old and self._update_timer_callback_lock == 0:
 
 
208
            gobject.source_remove(self._update_timer_callback_tag)
 
 
209
            self._update_timer_callback_tag = None
 
 
211
    def checker_completed(self, exitstatus, condition, command):
 
 
213
            if self.last_checker_failed:
 
 
214
                self.last_checker_failed = False
 
 
215
                self.using_timer(False)
 
 
216
            #self.logger('Checker for client %s (command "%s")'
 
 
218
            #            % (self.properties["Name"], command))
 
 
222
        if not self.last_checker_failed:
 
 
223
            self.last_checker_failed = True
 
 
224
            self.using_timer(True)
 
 
225
        if os.WIFEXITED(condition):
 
 
226
            self.logger('Checker for client %s (command "%s")'
 
 
227
                        ' failed with exit code %s'
 
 
228
                        % (self.properties["Name"], command,
 
 
229
                           os.WEXITSTATUS(condition)))
 
 
230
        elif os.WIFSIGNALED(condition):
 
 
231
            self.logger('Checker for client %s (command "%s")'
 
 
232
                        ' was killed by signal %s'
 
 
233
                        % (self.properties["Name"], command,
 
 
234
                           os.WTERMSIG(condition)))
 
 
235
        elif os.WCOREDUMP(condition):
 
 
236
            self.logger('Checker for client %s (command "%s")'
 
 
238
                        % (self.properties["Name"], command))
 
 
240
            self.logger('Checker for client %s completed'
 
 
244
    def checker_started(self, command):
 
 
245
        """Server signals that a checker started. This could be useful
 
 
246
           to log in the future. """
 
 
247
        #self.logger('Client %s started checker "%s"'
 
 
248
        #            % (self.properties["Name"], unicode(command)))
 
 
251
    def got_secret(self):
 
 
252
        self.last_checker_failed = False
 
 
253
        self.logger('Client %s received its secret'
 
 
254
                    % self.properties["Name"])
 
 
256
    def need_approval(self, timeout, default):
 
 
258
            message = 'Client %s needs approval within %s seconds'
 
 
260
            message = 'Client %s will get its secret in %s seconds'
 
 
262
                    % (self.properties["Name"], timeout/1000))
 
 
263
        self.using_timer(True)
 
 
265
    def rejected(self, reason):
 
 
266
        self.logger('Client %s was rejected; reason: %s'
 
 
267
                    % (self.properties["Name"], reason))
 
 
269
    def selectable(self):
 
 
270
        """Make this a "selectable" widget.
 
 
271
        This overrides the method from urwid.FlowWidget."""
 
 
274
    def rows(self, maxcolrow, focus=False):
 
 
275
        """How many rows this widget will occupy might depend on
 
 
276
        whether we have focus or not.
 
 
277
        This overrides the method from urwid.FlowWidget"""
 
 
278
        return self.current_widget(focus).rows(maxcolrow, focus=focus)
 
 
280
    def current_widget(self, focus=False):
 
 
281
        if focus or self.opened:
 
 
282
            return self._focus_widget
 
 
286
        "Called when what is visible on the screen should be updated."
 
 
287
        # How to add standout mode to a style
 
 
288
        with_standout = { "normal": "standout",
 
 
289
                          "bold": "bold-standout",
 
 
291
                              "underline-blink-standout",
 
 
292
                          "bold-underline-blink":
 
 
293
                              "bold-underline-blink-standout",
 
 
296
        # Rebuild focus and non-focus widgets using current properties
 
 
298
        # Base part of a client. Name!
 
 
300
                      % {"name": self.properties["Name"]})
 
 
301
        if not self.properties["Enabled"]:
 
 
303
        elif self.properties["ApprovalPending"]:
 
 
304
            timeout = datetime.timedelta(milliseconds
 
 
307
            last_approval_request = isoformat_to_datetime(
 
 
308
                self.properties["LastApprovalRequest"])
 
 
309
            if last_approval_request is not None:
 
 
310
                timer = timeout - (datetime.datetime.utcnow()
 
 
311
                                   - last_approval_request)
 
 
313
                timer = datetime.timedelta()
 
 
314
            if self.properties["ApprovedByDefault"]:
 
 
315
                message = "Approval in %s. (d)eny?"
 
 
317
                message = "Denial in %s. (a)pprove?"
 
 
318
            message = message % unicode(timer).rsplit(".", 1)[0]
 
 
319
        elif self.last_checker_failed:
 
 
320
            # When checker has failed, print a timer until client expires
 
 
321
            expires = self.properties["Expires"]
 
 
323
                timer = datetime.timedelta(0)
 
 
325
                expires = datetime.datetime.strptime(expires,
 
 
326
                                                     '%Y-%m-%dT%H:%M:%S.%f')
 
 
327
                timer = expires - datetime.datetime.utcnow()
 
 
328
            message = ('A checker has failed! Time until client'
 
 
330
                           % unicode(timer).rsplit(".", 1)[0])
 
 
333
        self._text = "%s%s" % (base, message)
 
 
335
        if not urwid.supports_unicode():
 
 
336
            self._text = self._text.encode("ascii", "replace")
 
 
337
        textlist = [("normal", self._text)]
 
 
338
        self._text_widget.set_text(textlist)
 
 
339
        self._focus_text_widget.set_text([(with_standout[text[0]],
 
 
341
                                          if isinstance(text, tuple)
 
 
343
                                          for text in textlist])
 
 
344
        self._widget = self._text_widget
 
 
345
        self._focus_widget = urwid.AttrWrap(self._focus_text_widget,
 
 
347
        # Run update hook, if any
 
 
348
        if self.update_hook is not None:
 
 
351
    def update_timer(self):
 
 
352
        """called by gobject. Will indefinitely loop until
 
 
353
        gobject.source_remove() on tag is called"""
 
 
355
        return True             # Keep calling this
 
 
357
    def delete(self, *args, **kwargs):
 
 
358
        if self._update_timer_callback_tag is not None:
 
 
359
            gobject.source_remove(self._update_timer_callback_tag)
 
 
360
            self._update_timer_callback_tag = None
 
 
361
        for match in self.match_objects:
 
 
363
        self.match_objects = ()
 
 
364
        if self.delete_hook is not None:
 
 
365
            self.delete_hook(self)
 
 
366
        return super(MandosClientWidget, self).delete(*args, **kwargs)
 
 
368
    def render(self, maxcolrow, focus=False):
 
 
369
        """Render differently if we have focus.
 
 
370
        This overrides the method from urwid.FlowWidget"""
 
 
371
        return self.current_widget(focus).render(maxcolrow,
 
 
374
    def keypress(self, maxcolrow, key):
 
 
376
        This overrides the method from urwid.FlowWidget"""
 
 
378
            self.proxy.Enable(dbus_interface = client_interface,
 
 
381
            self.proxy.Disable(dbus_interface = client_interface,
 
 
384
            self.proxy.Approve(dbus.Boolean(True, variant_level=1),
 
 
385
                               dbus_interface = client_interface,
 
 
388
            self.proxy.Approve(dbus.Boolean(False, variant_level=1),
 
 
389
                                  dbus_interface = client_interface,
 
 
391
        elif key == "R" or key == "_" or key == "ctrl k":
 
 
392
            self.server_proxy_object.RemoveClient(self.proxy
 
 
396
            self.proxy.StartChecker(dbus_interface = client_interface,
 
 
399
            self.proxy.StopChecker(dbus_interface = client_interface,
 
 
402
            self.proxy.CheckedOK(dbus_interface = client_interface,
 
 
405
#         elif key == "p" or key == "=":
 
 
407
#         elif key == "u" or key == ":":
 
 
408
#             self.proxy.unpause()
 
 
414
    def property_changed(self, property=None, value=None,
 
 
416
        """Call self.update() if old value is not new value.
 
 
417
        This overrides the method from MandosClientPropertyCache"""
 
 
418
        property_name = unicode(property)
 
 
419
        old_value = self.properties.get(property_name)
 
 
420
        super(MandosClientWidget, self).property_changed(
 
 
421
            property=property, value=value, *args, **kwargs)
 
 
422
        if self.properties.get(property_name) != old_value:
 
 
426
class ConstrainedListBox(urwid.ListBox):
 
 
427
    """Like a normal urwid.ListBox, but will consume all "up" or
 
 
428
    "down" key presses, thus not allowing any containing widgets to
 
 
429
    use them as an excuse to shift focus away from this widget.
 
 
431
    def keypress(self, maxcolrow, key):
 
 
432
        ret = super(ConstrainedListBox, self).keypress(maxcolrow, key)
 
 
433
        if ret in ("up", "down"):
 
 
438
class UserInterface(object):
 
 
439
    """This is the entire user interface - the whole screen
 
 
440
    with boxes, lists of client widgets, etc.
 
 
442
    def __init__(self, max_log_length=1000):
 
 
443
        DBusGMainLoop(set_as_default=True)
 
 
445
        self.screen = urwid.curses_display.Screen()
 
 
447
        self.screen.register_palette((
 
 
449
                 "default", "default", None),
 
 
451
                 "default", "default", "bold"),
 
 
453
                 "default", "default", "underline"),
 
 
455
                 "default", "default", "standout"),
 
 
456
                ("bold-underline-blink",
 
 
457
                 "default", "default", ("bold", "underline")),
 
 
459
                 "default", "default", ("bold", "standout")),
 
 
460
                ("underline-blink-standout",
 
 
461
                 "default", "default", ("underline", "standout")),
 
 
462
                ("bold-underline-blink-standout",
 
 
463
                 "default", "default", ("bold", "underline",
 
 
467
        if urwid.supports_unicode():
 
 
468
            self.divider = "─" # \u2500
 
 
469
            #self.divider = "━" # \u2501
 
 
471
            #self.divider = "-" # \u002d
 
 
472
            self.divider = "_" # \u005f
 
 
476
        self.size = self.screen.get_cols_rows()
 
 
478
        self.clients = urwid.SimpleListWalker([])
 
 
479
        self.clients_dict = {}
 
 
481
        # We will add Text widgets to this list
 
 
483
        self.max_log_length = max_log_length
 
 
485
        # We keep a reference to the log widget so we can remove it
 
 
486
        # from the ListWalker without it getting destroyed
 
 
487
        self.logbox = ConstrainedListBox(self.log)
 
 
489
        # This keeps track of whether self.uilist currently has
 
 
490
        # self.logbox in it or not
 
 
491
        self.log_visible = True
 
 
492
        self.log_wrap = "any"
 
 
495
        self.log_message_raw(("bold",
 
 
496
                              "Mandos Monitor version " + version))
 
 
497
        self.log_message_raw(("bold",
 
 
500
        self.busname = domain + '.Mandos'
 
 
501
        self.main_loop = gobject.MainLoop()
 
 
503
    def client_not_found(self, fingerprint, address):
 
 
504
        self.log_message(("Client with address %s and fingerprint %s"
 
 
505
                          " could not be found" % (address,
 
 
509
        """This rebuilds the User Interface.
 
 
510
        Call this when the widget layout needs to change"""
 
 
512
        #self.uilist.append(urwid.ListBox(self.clients))
 
 
513
        self.uilist.append(urwid.Frame(ConstrainedListBox(self.
 
 
515
                                       #header=urwid.Divider(),
 
 
518
                                       urwid.Divider(div_char=
 
 
521
            self.uilist.append(self.logbox)
 
 
522
        self.topwidget = urwid.Pile(self.uilist)
 
 
524
    def log_message(self, message):
 
 
525
        timestamp = datetime.datetime.now().isoformat()
 
 
526
        self.log_message_raw(timestamp + ": " + message)
 
 
528
    def log_message_raw(self, markup):
 
 
529
        """Add a log message to the log buffer."""
 
 
530
        self.log.append(urwid.Text(markup, wrap=self.log_wrap))
 
 
531
        if (self.max_log_length
 
 
532
            and len(self.log) > self.max_log_length):
 
 
533
            del self.log[0:len(self.log)-self.max_log_length-1]
 
 
534
        self.logbox.set_focus(len(self.logbox.body.contents),
 
 
538
    def toggle_log_display(self):
 
 
539
        """Toggle visibility of the log buffer."""
 
 
540
        self.log_visible = not self.log_visible
 
 
542
        #self.log_message("Log visibility changed to: "
 
 
543
        #                 + unicode(self.log_visible))
 
 
545
    def change_log_display(self):
 
 
546
        """Change type of log display.
 
 
547
        Currently, this toggles wrapping of text lines."""
 
 
548
        if self.log_wrap == "clip":
 
 
549
            self.log_wrap = "any"
 
 
551
            self.log_wrap = "clip"
 
 
552
        for textwidget in self.log:
 
 
553
            textwidget.set_wrap_mode(self.log_wrap)
 
 
554
        #self.log_message("Wrap mode: " + self.log_wrap)
 
 
556
    def find_and_remove_client(self, path, name):
 
 
557
        """Find a client by its object path and remove it.
 
 
559
        This is connected to the ClientRemoved signal from the
 
 
560
        Mandos server object."""
 
 
562
            client = self.clients_dict[path]
 
 
565
            self.log_message("Unknown client %r (%r) removed", name,
 
 
570
    def add_new_client(self, path):
 
 
571
        client_proxy_object = self.bus.get_object(self.busname, path)
 
 
572
        self.add_client(MandosClientWidget(server_proxy_object
 
 
575
                                           =client_proxy_object,
 
 
584
    def add_client(self, client, path=None):
 
 
585
        self.clients.append(client)
 
 
587
            path = client.proxy.object_path
 
 
588
        self.clients_dict[path] = client
 
 
589
        self.clients.sort(None, lambda c: c.properties["Name"])
 
 
592
    def remove_client(self, client, path=None):
 
 
593
        self.clients.remove(client)
 
 
595
            path = client.proxy.object_path
 
 
596
        del self.clients_dict[path]
 
 
597
        if not self.clients_dict:
 
 
598
            # Work around bug in Urwid 0.9.8.3 - if a SimpleListWalker
 
 
599
            # is completely emptied, we need to recreate it.
 
 
600
            self.clients = urwid.SimpleListWalker([])
 
 
605
        """Redraw the screen"""
 
 
606
        canvas = self.topwidget.render(self.size, focus=True)
 
 
607
        self.screen.draw_screen(self.size, canvas)
 
 
610
        """Start the main loop and exit when it's done."""
 
 
611
        self.bus = dbus.SystemBus()
 
 
612
        mandos_dbus_objc = self.bus.get_object(
 
 
613
            self.busname, "/", follow_name_owner_changes=True)
 
 
614
        self.mandos_serv = dbus.Interface(mandos_dbus_objc,
 
 
618
            mandos_clients = (self.mandos_serv
 
 
619
                              .GetAllClientsWithProperties())
 
 
620
        except dbus.exceptions.DBusException:
 
 
621
            mandos_clients = dbus.Dictionary()
 
 
624
         .connect_to_signal("ClientRemoved",
 
 
625
                            self.find_and_remove_client,
 
 
626
                            dbus_interface=server_interface,
 
 
629
         .connect_to_signal("ClientAdded",
 
 
631
                            dbus_interface=server_interface,
 
 
634
         .connect_to_signal("ClientNotFound",
 
 
635
                            self.client_not_found,
 
 
636
                            dbus_interface=server_interface,
 
 
638
        for path, client in mandos_clients.iteritems():
 
 
639
            client_proxy_object = self.bus.get_object(self.busname,
 
 
641
            self.add_client(MandosClientWidget(server_proxy_object
 
 
644
                                               =client_proxy_object,
 
 
655
        self._input_callback_tag = (gobject.io_add_watch
 
 
660
        # Main loop has finished, we should close everything now
 
 
661
        gobject.source_remove(self._input_callback_tag)
 
 
665
        self.main_loop.quit()
 
 
667
    def process_input(self, source, condition):
 
 
668
        keys = self.screen.get_input()
 
 
669
        translations = { "ctrl n": "down",      # Emacs
 
 
670
                         "ctrl p": "up",        # Emacs
 
 
671
                         "ctrl v": "page down", # Emacs
 
 
672
                         "meta v": "page up",   # Emacs
 
 
673
                         " ": "page down",      # less
 
 
674
                         "f": "page down",      # less
 
 
675
                         "b": "page up",        # less
 
 
681
                key = translations[key]
 
 
682
            except KeyError:    # :-)
 
 
685
            if key == "q" or key == "Q":
 
 
688
            elif key == "window resize":
 
 
689
                self.size = self.screen.get_cols_rows()
 
 
691
            elif key == "\f":  # Ctrl-L
 
 
693
            elif key == "l" or key == "D":
 
 
694
                self.toggle_log_display()
 
 
696
            elif key == "w" or key == "i":
 
 
697
                self.change_log_display()
 
 
699
            elif key == "?" or key == "f1" or key == "esc":
 
 
700
                if not self.log_visible:
 
 
701
                    self.log_visible = True
 
 
703
                self.log_message_raw(("bold",
 
 
707
                                            "l: Log window toggle",
 
 
708
                                            "TAB: Switch window",
 
 
710
                self.log_message_raw(("bold",
 
 
716
                                             "s: Start new checker",
 
 
723
                if self.topwidget.get_focus() is self.logbox:
 
 
724
                    self.topwidget.set_focus(0)
 
 
726
                    self.topwidget.set_focus(self.logbox)
 
 
728
            #elif (key == "end" or key == "meta >" or key == "G"
 
 
730
            #    pass            # xxx end-of-buffer
 
 
731
            #elif (key == "home" or key == "meta <" or key == "g"
 
 
733
            #    pass            # xxx beginning-of-buffer
 
 
734
            #elif key == "ctrl e" or key == "$":
 
 
735
            #    pass            # xxx move-end-of-line
 
 
736
            #elif key == "ctrl a" or key == "^":
 
 
737
            #    pass            # xxx move-beginning-of-line
 
 
738
            #elif key == "ctrl b" or key == "meta (" or key == "h":
 
 
740
            #elif key == "ctrl f" or key == "meta )" or key == "l":
 
 
743
            #    pass            # scroll up log
 
 
745
            #    pass            # scroll down log
 
 
746
            elif self.topwidget.selectable():
 
 
747
                self.topwidget.keypress(self.size, key)
 
 
754
except KeyboardInterrupt:
 
 
757
    ui.log_message(unicode(e))