2
# -*- mode: python; coding: utf-8 -*-
 
 
4
# Mandos Monitor - Control and monitor the Mandos server
 
 
6
# Copyright © 2009-2012 Teddy Hogeborn
 
 
7
# Copyright © 2009-2012 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
 
 
21
# <http://www.gnu.org/licenses/>.
 
 
23
# Contact the authors at <mandos@recompile.se>.
 
 
26
from __future__ import (division, absolute_import, print_function,
 
 
29
from future_builtins import *
 
 
37
import urwid.curses_display
 
 
40
from dbus.mainloop.glib import DBusGMainLoop
 
 
49
locale.setlocale(locale.LC_ALL, '')
 
 
52
logging.getLogger('dbus.proxies').setLevel(logging.CRITICAL)
 
 
54
# Some useful constants
 
 
55
domain = 'se.recompile'
 
 
56
server_interface = domain + '.Mandos'
 
 
57
client_interface = domain + '.Mandos.Client'
 
 
60
# Always run in monochrome mode
 
 
61
urwid.curses_display.curses.has_colors = lambda : False
 
 
63
# Urwid doesn't support blinking, but we want it.  Since we have no
 
 
64
# use for underline on its own, we make underline also always blink.
 
 
65
urwid.curses_display.curses.A_UNDERLINE |= (
 
 
66
    urwid.curses_display.curses.A_BLINK)
 
 
68
def isoformat_to_datetime(iso):
 
 
69
    "Parse an ISO 8601 date string to a datetime.datetime()"
 
 
72
    d, t = iso.split("T", 1)
 
 
73
    year, month, day = d.split("-", 2)
 
 
74
    hour, minute, second = t.split(":", 2)
 
 
75
    second, fraction = divmod(float(second), 1)
 
 
76
    return datetime.datetime(int(year),
 
 
81
                             int(second),           # Whole seconds
 
 
82
                             int(fraction*1000000)) # Microseconds
 
 
84
class MandosClientPropertyCache(object):
 
 
85
    """This wraps a Mandos Client D-Bus proxy object, caches the
 
 
86
    properties and calls a hook function when any of them are
 
 
89
    def __init__(self, proxy_object=None, properties=None, **kwargs):
 
 
90
        self.proxy = proxy_object # Mandos Client proxy object
 
 
91
        self.properties = dict() if properties is None else properties
 
 
92
        self.property_changed_match = (
 
 
93
            self.proxy.connect_to_signal("PropertyChanged",
 
 
94
                                         self._property_changed,
 
 
98
        if properties is None:
 
 
99
            self.properties.update(
 
 
100
                self.proxy.GetAll(client_interface,
 
 
102
                                  = dbus.PROPERTIES_IFACE))
 
 
104
        super(MandosClientPropertyCache, self).__init__(**kwargs)
 
 
106
    def _property_changed(self, property, value):
 
 
107
        """Helper which takes positional arguments"""
 
 
108
        return self.property_changed(property=property, value=value)
 
 
110
    def property_changed(self, property=None, value=None):
 
 
111
        """This is called whenever we get a PropertyChanged signal
 
 
112
        It updates the changed property in the "properties" dict.
 
 
114
        # Update properties dict with new value
 
 
115
        self.properties[property] = value
 
 
118
        self.property_changed_match.remove()
 
 
121
class MandosClientWidget(urwid.FlowWidget, MandosClientPropertyCache):
 
 
122
    """A Mandos Client which is visible on the screen.
 
 
125
    def __init__(self, server_proxy_object=None, update_hook=None,
 
 
126
                 delete_hook=None, logger=None, **kwargs):
 
 
128
        self.update_hook = update_hook
 
 
130
        self.delete_hook = delete_hook
 
 
131
        # Mandos Server proxy object
 
 
132
        self.server_proxy_object = server_proxy_object
 
 
136
        self._update_timer_callback_tag = None
 
 
138
        # The widget shown normally
 
 
139
        self._text_widget = urwid.Text("")
 
 
140
        # The widget shown when we have focus
 
 
141
        self._focus_text_widget = urwid.Text("")
 
 
142
        super(MandosClientWidget, self).__init__(**kwargs)
 
 
146
        self.match_objects = (
 
 
147
            self.proxy.connect_to_signal("CheckerCompleted",
 
 
148
                                         self.checker_completed,
 
 
151
            self.proxy.connect_to_signal("CheckerStarted",
 
 
152
                                         self.checker_started,
 
 
155
            self.proxy.connect_to_signal("GotSecret",
 
 
159
            self.proxy.connect_to_signal("NeedApproval",
 
 
163
            self.proxy.connect_to_signal("Rejected",
 
 
167
        #self.logger('Created client {0}'
 
 
168
        #            .format(self.properties["Name"]))
 
 
170
    def using_timer(self, flag):
 
 
171
        """Call this method with True or False when timer should be
 
 
172
        activated or deactivated.
 
 
174
        if flag and self._update_timer_callback_tag is None:
 
 
175
            # Will update the shown timer value every second
 
 
176
            self._update_timer_callback_tag = (gobject.timeout_add
 
 
179
        elif not (flag or self._update_timer_callback_tag is None):
 
 
180
            gobject.source_remove(self._update_timer_callback_tag)
 
 
181
            self._update_timer_callback_tag = None
 
 
183
    def checker_completed(self, exitstatus, condition, command):
 
 
188
        if os.WIFEXITED(condition):
 
 
189
            self.logger('Checker for client {0} (command "{1}")'
 
 
190
                        ' failed with exit code {2}'
 
 
191
                        .format(self.properties["Name"], command,
 
 
192
                                os.WEXITSTATUS(condition)))
 
 
193
        elif os.WIFSIGNALED(condition):
 
 
194
            self.logger('Checker for client {0} (command "{1}") was'
 
 
195
                        ' killed by signal {2}'
 
 
196
                        .format(self.properties["Name"], command,
 
 
197
                                os.WTERMSIG(condition)))
 
 
198
        elif os.WCOREDUMP(condition):
 
 
199
            self.logger('Checker for client {0} (command "{1}")'
 
 
201
                        .format(self.properties["Name"], command))
 
 
203
            self.logger('Checker for client {0} completed'
 
 
205
                        .format(self.properties["Name"]))
 
 
208
    def checker_started(self, command):
 
 
209
        """Server signals that a checker started. This could be useful
 
 
210
           to log in the future. """
 
 
211
        #self.logger('Client {0} started checker "{1}"'
 
 
212
        #            .format(self.properties["Name"],
 
 
216
    def got_secret(self):
 
 
217
        self.logger('Client {0} received its secret'
 
 
218
                    .format(self.properties["Name"]))
 
 
220
    def need_approval(self, timeout, default):
 
 
222
            message = 'Client {0} needs approval within {1} seconds'
 
 
224
            message = 'Client {0} will get its secret in {1} seconds'
 
 
225
        self.logger(message.format(self.properties["Name"],
 
 
228
    def rejected(self, reason):
 
 
229
        self.logger('Client {0} was rejected; reason: {1}'
 
 
230
                    .format(self.properties["Name"], reason))
 
 
232
    def selectable(self):
 
 
233
        """Make this a "selectable" widget.
 
 
234
        This overrides the method from urwid.FlowWidget."""
 
 
237
    def rows(self, maxcolrow, focus=False):
 
 
238
        """How many rows this widget will occupy might depend on
 
 
239
        whether we have focus or not.
 
 
240
        This overrides the method from urwid.FlowWidget"""
 
 
241
        return self.current_widget(focus).rows(maxcolrow, focus=focus)
 
 
243
    def current_widget(self, focus=False):
 
 
244
        if focus or self.opened:
 
 
245
            return self._focus_widget
 
 
249
        "Called when what is visible on the screen should be updated."
 
 
250
        # How to add standout mode to a style
 
 
251
        with_standout = { "normal": "standout",
 
 
252
                          "bold": "bold-standout",
 
 
254
                              "underline-blink-standout",
 
 
255
                          "bold-underline-blink":
 
 
256
                              "bold-underline-blink-standout",
 
 
259
        # Rebuild focus and non-focus widgets using current properties
 
 
261
        # Base part of a client. Name!
 
 
262
        base = '{name}: '.format(name=self.properties["Name"])
 
 
263
        if not self.properties["Enabled"]:
 
 
265
            self.using_timer(False)
 
 
266
        elif self.properties["ApprovalPending"]:
 
 
267
            timeout = datetime.timedelta(milliseconds
 
 
270
            last_approval_request = isoformat_to_datetime(
 
 
271
                self.properties["LastApprovalRequest"])
 
 
272
            if last_approval_request is not None:
 
 
273
                timer = max(timeout - (datetime.datetime.utcnow()
 
 
274
                                       - last_approval_request),
 
 
275
                            datetime.timedelta())
 
 
277
                timer = datetime.timedelta()
 
 
278
            if self.properties["ApprovedByDefault"]:
 
 
279
                message = "Approval in {0}. (d)eny?"
 
 
281
                message = "Denial in {0}. (a)pprove?"
 
 
282
            message = message.format(unicode(timer).rsplit(".", 1)[0])
 
 
283
            self.using_timer(True)
 
 
284
        elif self.properties["LastCheckerStatus"] != 0:
 
 
285
            # When checker has failed, show timer until client expires
 
 
286
            expires = self.properties["Expires"]
 
 
288
                timer = datetime.timedelta(0)
 
 
290
                expires = (datetime.datetime.strptime
 
 
291
                           (expires, '%Y-%m-%dT%H:%M:%S.%f'))
 
 
292
                timer = max(expires - datetime.datetime.utcnow(),
 
 
293
                            datetime.timedelta())
 
 
294
            message = ('A checker has failed! Time until client'
 
 
295
                       ' gets disabled: {0}'
 
 
296
                       .format(unicode(timer).rsplit(".", 1)[0]))
 
 
297
            self.using_timer(True)
 
 
300
            self.using_timer(False)
 
 
301
        self._text = "{0}{1}".format(base, message)
 
 
303
        if not urwid.supports_unicode():
 
 
304
            self._text = self._text.encode("ascii", "replace")
 
 
305
        textlist = [("normal", self._text)]
 
 
306
        self._text_widget.set_text(textlist)
 
 
307
        self._focus_text_widget.set_text([(with_standout[text[0]],
 
 
309
                                          if isinstance(text, tuple)
 
 
311
                                          for text in textlist])
 
 
312
        self._widget = self._text_widget
 
 
313
        self._focus_widget = urwid.AttrWrap(self._focus_text_widget,
 
 
315
        # Run update hook, if any
 
 
316
        if self.update_hook is not None:
 
 
319
    def update_timer(self):
 
 
320
        """called by gobject. Will indefinitely loop until
 
 
321
        gobject.source_remove() on tag is called"""
 
 
323
        return True             # Keep calling this
 
 
325
    def delete(self, **kwargs):
 
 
326
        if self._update_timer_callback_tag is not None:
 
 
327
            gobject.source_remove(self._update_timer_callback_tag)
 
 
328
            self._update_timer_callback_tag = None
 
 
329
        for match in self.match_objects:
 
 
331
        self.match_objects = ()
 
 
332
        if self.delete_hook is not None:
 
 
333
            self.delete_hook(self)
 
 
334
        return super(MandosClientWidget, self).delete(**kwargs)
 
 
336
    def render(self, maxcolrow, focus=False):
 
 
337
        """Render differently if we have focus.
 
 
338
        This overrides the method from urwid.FlowWidget"""
 
 
339
        return self.current_widget(focus).render(maxcolrow,
 
 
342
    def keypress(self, maxcolrow, key):
 
 
344
        This overrides the method from urwid.FlowWidget"""
 
 
346
            self.proxy.Enable(dbus_interface = client_interface,
 
 
349
            self.proxy.Disable(dbus_interface = client_interface,
 
 
352
            self.proxy.Approve(dbus.Boolean(True, variant_level=1),
 
 
353
                               dbus_interface = client_interface,
 
 
356
            self.proxy.Approve(dbus.Boolean(False, variant_level=1),
 
 
357
                                  dbus_interface = client_interface,
 
 
359
        elif key == "R" or key == "_" or key == "ctrl k":
 
 
360
            self.server_proxy_object.RemoveClient(self.proxy
 
 
364
            self.proxy.StartChecker(dbus_interface = client_interface,
 
 
367
            self.proxy.StopChecker(dbus_interface = client_interface,
 
 
370
            self.proxy.CheckedOK(dbus_interface = client_interface,
 
 
373
#         elif key == "p" or key == "=":
 
 
375
#         elif key == "u" or key == ":":
 
 
376
#             self.proxy.unpause()
 
 
382
    def property_changed(self, property=None, **kwargs):
 
 
383
        """Call self.update() if old value is not new value.
 
 
384
        This overrides the method from MandosClientPropertyCache"""
 
 
385
        property_name = unicode(property)
 
 
386
        old_value = self.properties.get(property_name)
 
 
387
        super(MandosClientWidget, self).property_changed(
 
 
388
            property=property, **kwargs)
 
 
389
        if self.properties.get(property_name) != old_value:
 
 
393
class ConstrainedListBox(urwid.ListBox):
 
 
394
    """Like a normal urwid.ListBox, but will consume all "up" or
 
 
395
    "down" key presses, thus not allowing any containing widgets to
 
 
396
    use them as an excuse to shift focus away from this widget.
 
 
398
    def keypress(self, *args, **kwargs):
 
 
399
        ret = super(ConstrainedListBox, self).keypress(*args, **kwargs)
 
 
400
        if ret in ("up", "down"):
 
 
405
class UserInterface(object):
 
 
406
    """This is the entire user interface - the whole screen
 
 
407
    with boxes, lists of client widgets, etc.
 
 
409
    def __init__(self, max_log_length=1000):
 
 
410
        DBusGMainLoop(set_as_default=True)
 
 
412
        self.screen = urwid.curses_display.Screen()
 
 
414
        self.screen.register_palette((
 
 
416
                 "default", "default", None),
 
 
418
                 "default", "default", "bold"),
 
 
420
                 "default", "default", "underline"),
 
 
422
                 "default", "default", "standout"),
 
 
423
                ("bold-underline-blink",
 
 
424
                 "default", "default", ("bold", "underline")),
 
 
426
                 "default", "default", ("bold", "standout")),
 
 
427
                ("underline-blink-standout",
 
 
428
                 "default", "default", ("underline", "standout")),
 
 
429
                ("bold-underline-blink-standout",
 
 
430
                 "default", "default", ("bold", "underline",
 
 
434
        if urwid.supports_unicode():
 
 
435
            self.divider = "─" # \u2500
 
 
436
            #self.divider = "━" # \u2501
 
 
438
            #self.divider = "-" # \u002d
 
 
439
            self.divider = "_" # \u005f
 
 
443
        self.size = self.screen.get_cols_rows()
 
 
445
        self.clients = urwid.SimpleListWalker([])
 
 
446
        self.clients_dict = {}
 
 
448
        # We will add Text widgets to this list
 
 
450
        self.max_log_length = max_log_length
 
 
452
        # We keep a reference to the log widget so we can remove it
 
 
453
        # from the ListWalker without it getting destroyed
 
 
454
        self.logbox = ConstrainedListBox(self.log)
 
 
456
        # This keeps track of whether self.uilist currently has
 
 
457
        # self.logbox in it or not
 
 
458
        self.log_visible = True
 
 
459
        self.log_wrap = "any"
 
 
462
        self.log_message_raw(("bold",
 
 
463
                              "Mandos Monitor version " + version))
 
 
464
        self.log_message_raw(("bold",
 
 
467
        self.busname = domain + '.Mandos'
 
 
468
        self.main_loop = gobject.MainLoop()
 
 
470
    def client_not_found(self, fingerprint, address):
 
 
471
        self.log_message("Client with address {0} and fingerprint"
 
 
472
                         " {1} could not be found"
 
 
473
                         .format(address, fingerprint))
 
 
476
        """This rebuilds the User Interface.
 
 
477
        Call this when the widget layout needs to change"""
 
 
479
        #self.uilist.append(urwid.ListBox(self.clients))
 
 
480
        self.uilist.append(urwid.Frame(ConstrainedListBox(self.
 
 
482
                                       #header=urwid.Divider(),
 
 
485
                                       urwid.Divider(div_char=
 
 
488
            self.uilist.append(self.logbox)
 
 
489
        self.topwidget = urwid.Pile(self.uilist)
 
 
491
    def log_message(self, message):
 
 
492
        """Log message formatted with timestamp"""
 
 
493
        timestamp = datetime.datetime.now().isoformat()
 
 
494
        self.log_message_raw(timestamp + ": " + message)
 
 
496
    def log_message_raw(self, markup):
 
 
497
        """Add a log message to the log buffer."""
 
 
498
        self.log.append(urwid.Text(markup, wrap=self.log_wrap))
 
 
499
        if (self.max_log_length
 
 
500
            and len(self.log) > self.max_log_length):
 
 
501
            del self.log[0:len(self.log)-self.max_log_length-1]
 
 
502
        self.logbox.set_focus(len(self.logbox.body.contents),
 
 
506
    def toggle_log_display(self):
 
 
507
        """Toggle visibility of the log buffer."""
 
 
508
        self.log_visible = not self.log_visible
 
 
510
        #self.log_message("Log visibility changed to: "
 
 
511
        #                 + unicode(self.log_visible))
 
 
513
    def change_log_display(self):
 
 
514
        """Change type of log display.
 
 
515
        Currently, this toggles wrapping of text lines."""
 
 
516
        if self.log_wrap == "clip":
 
 
517
            self.log_wrap = "any"
 
 
519
            self.log_wrap = "clip"
 
 
520
        for textwidget in self.log:
 
 
521
            textwidget.set_wrap_mode(self.log_wrap)
 
 
522
        #self.log_message("Wrap mode: " + self.log_wrap)
 
 
524
    def find_and_remove_client(self, path, name):
 
 
525
        """Find a client by its object path and remove it.
 
 
527
        This is connected to the ClientRemoved signal from the
 
 
528
        Mandos server object."""
 
 
530
            client = self.clients_dict[path]
 
 
533
            self.log_message("Unknown client {0!r} ({1!r}) removed"
 
 
538
    def add_new_client(self, path):
 
 
539
        client_proxy_object = self.bus.get_object(self.busname, path)
 
 
540
        self.add_client(MandosClientWidget(server_proxy_object
 
 
543
                                           =client_proxy_object,
 
 
552
    def add_client(self, client, path=None):
 
 
553
        self.clients.append(client)
 
 
555
            path = client.proxy.object_path
 
 
556
        self.clients_dict[path] = client
 
 
557
        self.clients.sort(None, lambda c: c.properties["Name"])
 
 
560
    def remove_client(self, client, path=None):
 
 
561
        self.clients.remove(client)
 
 
563
            path = client.proxy.object_path
 
 
564
        del self.clients_dict[path]
 
 
565
        if not self.clients_dict:
 
 
566
            # Work around bug in Urwid 0.9.8.3 - if a SimpleListWalker
 
 
567
            # is completely emptied, we need to recreate it.
 
 
568
            self.clients = urwid.SimpleListWalker([])
 
 
573
        """Redraw the screen"""
 
 
574
        canvas = self.topwidget.render(self.size, focus=True)
 
 
575
        self.screen.draw_screen(self.size, canvas)
 
 
578
        """Start the main loop and exit when it's done."""
 
 
579
        self.bus = dbus.SystemBus()
 
 
580
        mandos_dbus_objc = self.bus.get_object(
 
 
581
            self.busname, "/", follow_name_owner_changes=True)
 
 
582
        self.mandos_serv = dbus.Interface(mandos_dbus_objc,
 
 
586
            mandos_clients = (self.mandos_serv
 
 
587
                              .GetAllClientsWithProperties())
 
 
588
            if not mandos_clients:
 
 
589
                self.log_message_raw(("bold", "Note: Server has no clients."))
 
 
590
        except dbus.exceptions.DBusException:
 
 
591
            self.log_message_raw(("bold", "Note: No Mandos servers running."))
 
 
592
            mandos_clients = dbus.Dictionary()
 
 
595
         .connect_to_signal("ClientRemoved",
 
 
596
                            self.find_and_remove_client,
 
 
597
                            dbus_interface=server_interface,
 
 
600
         .connect_to_signal("ClientAdded",
 
 
602
                            dbus_interface=server_interface,
 
 
605
         .connect_to_signal("ClientNotFound",
 
 
606
                            self.client_not_found,
 
 
607
                            dbus_interface=server_interface,
 
 
609
        for path, client in mandos_clients.iteritems():
 
 
610
            client_proxy_object = self.bus.get_object(self.busname,
 
 
612
            self.add_client(MandosClientWidget(server_proxy_object
 
 
615
                                               =client_proxy_object,
 
 
626
        self._input_callback_tag = (gobject.io_add_watch
 
 
631
        # Main loop has finished, we should close everything now
 
 
632
        gobject.source_remove(self._input_callback_tag)
 
 
636
        self.main_loop.quit()
 
 
638
    def process_input(self, source, condition):
 
 
639
        keys = self.screen.get_input()
 
 
640
        translations = { "ctrl n": "down",      # Emacs
 
 
641
                         "ctrl p": "up",        # Emacs
 
 
642
                         "ctrl v": "page down", # Emacs
 
 
643
                         "meta v": "page up",   # Emacs
 
 
644
                         " ": "page down",      # less
 
 
645
                         "f": "page down",      # less
 
 
646
                         "b": "page up",        # less
 
 
652
                key = translations[key]
 
 
653
            except KeyError:    # :-)
 
 
656
            if key == "q" or key == "Q":
 
 
659
            elif key == "window resize":
 
 
660
                self.size = self.screen.get_cols_rows()
 
 
662
            elif key == "\f":  # Ctrl-L
 
 
664
            elif key == "l" or key == "D":
 
 
665
                self.toggle_log_display()
 
 
667
            elif key == "w" or key == "i":
 
 
668
                self.change_log_display()
 
 
670
            elif key == "?" or key == "f1" or key == "esc":
 
 
671
                if not self.log_visible:
 
 
672
                    self.log_visible = True
 
 
674
                self.log_message_raw(("bold",
 
 
678
                                            "l: Log window toggle",
 
 
679
                                            "TAB: Switch window",
 
 
681
                self.log_message_raw(("bold",
 
 
687
                                             "s: Start new checker",
 
 
694
                if self.topwidget.get_focus() is self.logbox:
 
 
695
                    self.topwidget.set_focus(0)
 
 
697
                    self.topwidget.set_focus(self.logbox)
 
 
699
            #elif (key == "end" or key == "meta >" or key == "G"
 
 
701
            #    pass            # xxx end-of-buffer
 
 
702
            #elif (key == "home" or key == "meta <" or key == "g"
 
 
704
            #    pass            # xxx beginning-of-buffer
 
 
705
            #elif key == "ctrl e" or key == "$":
 
 
706
            #    pass            # xxx move-end-of-line
 
 
707
            #elif key == "ctrl a" or key == "^":
 
 
708
            #    pass            # xxx move-beginning-of-line
 
 
709
            #elif key == "ctrl b" or key == "meta (" or key == "h":
 
 
711
            #elif key == "ctrl f" or key == "meta )" or key == "l":
 
 
714
            #    pass            # scroll up log
 
 
716
            #    pass            # scroll down log
 
 
717
            elif self.topwidget.selectable():
 
 
718
                self.topwidget.keypress(self.size, key)
 
 
725
except KeyboardInterrupt:
 
 
728
    ui.log_message(unicode(e))