1
#!/usr/bin/python3 -bbI
2
2
# -*- mode: python; coding: utf-8 -*-
4
4
# Mandos Monitor - Control and monitor the Mandos server
6
# Copyright © 2009-2015 Teddy Hogeborn
7
# Copyright © 2009-2015 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
6
# Copyright © 2009-2019 Teddy Hogeborn
7
# Copyright © 2009-2019 Björn Påhlsson
9
# This file is part of Mandos.
11
# Mandos is free software: you can redistribute it and/or modify it
12
# under the terms of the GNU General Public License as published by
11
13
# the Free Software Foundation, either version 3 of the License, or
12
14
# (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
# Mandos is distributed in the hope that it will be useful, but
17
# WITHOUT ANY WARRANTY; without even the implied warranty of
16
18
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
19
# GNU General Public License for more details.
19
21
# 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/>.
22
# along with Mandos. If not, see <http://www.gnu.org/licenses/>.
23
24
# Contact the authors at <mandos@recompile.se>.
26
27
from __future__ import (division, absolute_import, print_function,
38
41
import urwid.curses_display
41
44
from dbus.mainloop.glib import DBusGMainLoop
45
from gi.repository import GObject as gobject
45
from gi.repository import GLib
51
49
if sys.version_info.major == 2:
54
locale.setlocale(locale.LC_ALL, '')
57
logging.getLogger('dbus.proxies').setLevel(logging.CRITICAL)
53
log = logging.getLogger(os.path.basename(sys.argv[0]))
54
logging.basicConfig(level="NOTSET", # Show all messages
55
format="%(message)s") # Show basic log messages
57
logging.captureWarnings(True) # Show warnings via the logging system
59
locale.setlocale(locale.LC_ALL, "")
61
logging.getLogger("dbus.proxies").setLevel(logging.CRITICAL)
59
63
# Some useful constants
60
domain = 'se.recompile'
61
server_interface = domain + '.Mandos'
62
client_interface = domain + '.Mandos.Client'
64
domain = "se.recompile"
65
server_interface = domain + ".Mandos"
66
client_interface = domain + ".Mandos.Client"
66
70
dbus.OBJECT_MANAGER_IFACE
67
71
except AttributeError:
68
72
dbus.OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
70
75
def isoformat_to_datetime(iso):
71
76
"Parse an ISO 8601 date string to a datetime.datetime()"
83
int(second), # Whole seconds
84
int(fraction*1000000)) # Microseconds
86
class MandosClientPropertyCache(object):
88
int(second), # Whole seconds
89
int(fraction*1000000)) # Microseconds
92
class MandosClientPropertyCache:
87
93
"""This wraps a Mandos Client D-Bus proxy object, caches the
88
94
properties and calls a hook function when any of them are
91
97
def __init__(self, proxy_object=None, properties=None, **kwargs):
92
self.proxy = proxy_object # Mandos Client proxy object
98
self.proxy = proxy_object # Mandos Client proxy object
93
99
self.properties = dict() if properties is None else properties
94
100
self.property_changed_match = (
95
101
self.proxy.connect_to_signal("PropertiesChanged",
96
102
self.properties_changed,
97
103
dbus.PROPERTIES_IFACE,
98
104
byte_arrays=True))
100
106
if properties is None:
101
self.properties.update(
102
self.proxy.GetAll(client_interface,
104
= dbus.PROPERTIES_IFACE))
107
self.properties.update(self.proxy.GetAll(
109
dbus_interface=dbus.PROPERTIES_IFACE))
106
111
super(MandosClientPropertyCache, self).__init__(**kwargs)
108
113
def properties_changed(self, interface, properties, invalidated):
109
114
"""This is called whenever we get a PropertiesChanged signal
110
115
It updates the changed properties in the "properties" dict.
164
167
client_interface,
165
168
byte_arrays=True))
166
self.logger('Created client {}'
167
.format(self.properties["Name"]), level=0)
169
log.debug("Created client %s", self.properties["Name"])
169
171
def using_timer(self, flag):
170
172
"""Call this method with True or False when timer should be
171
173
activated or deactivated.
173
175
if flag and self._update_timer_callback_tag is None:
174
176
# Will update the shown timer value every second
175
self._update_timer_callback_tag = (gobject.timeout_add
177
self._update_timer_callback_tag = (
178
GLib.timeout_add(1000,
179
glib_safely(self.update_timer)))
178
180
elif not (flag or self._update_timer_callback_tag is None):
179
gobject.source_remove(self._update_timer_callback_tag)
181
GLib.source_remove(self._update_timer_callback_tag)
180
182
self._update_timer_callback_tag = None
182
184
def checker_completed(self, exitstatus, condition, command):
183
185
if exitstatus == 0:
184
self.logger('Checker for client {} (command "{}")'
185
' succeeded'.format(self.properties["Name"],
186
log.debug('Checker for client %s (command "%s")'
187
" succeeded", self.properties["Name"], command)
190
191
if os.WIFEXITED(condition):
191
self.logger('Checker for client {} (command "{}") failed'
193
.format(self.properties["Name"], command,
194
os.WEXITSTATUS(condition)))
192
log.info('Checker for client %s (command "%s") failed'
193
" with exit code %d", self.properties["Name"],
194
command, os.WEXITSTATUS(condition))
195
195
elif os.WIFSIGNALED(condition):
196
self.logger('Checker for client {} (command "{}") was'
197
' killed by signal {}'
198
.format(self.properties["Name"], command,
199
os.WTERMSIG(condition)))
196
log.info('Checker for client %s (command "%s") was'
197
" killed by signal %d", self.properties["Name"],
198
command, os.WTERMSIG(condition))
202
201
def checker_started(self, command):
203
202
"""Server signals that a checker started."""
204
self.logger('Client {} started checker "{}"'
205
.format(self.properties["Name"],
203
log.debug('Client %s started checker "%s"',
204
self.properties["Name"], command)
208
206
def got_secret(self):
209
self.logger('Client {} received its secret'
210
.format(self.properties["Name"]))
207
log.info("Client %s received its secret",
208
self.properties["Name"])
212
210
def need_approval(self, timeout, default):
214
message = 'Client {} needs approval within {} seconds'
212
message = "Client %s needs approval within %f seconds"
216
message = 'Client {} will get its secret in {} seconds'
217
self.logger(message.format(self.properties["Name"],
214
message = "Client %s will get its secret in %f seconds"
215
log.info(message, self.properties["Name"], timeout/1000)
220
217
def rejected(self, reason):
221
self.logger('Client {} was rejected; reason: {}'
222
.format(self.properties["Name"], reason))
218
log.info("Client %s was rejected; reason: %s",
219
self.properties["Name"], reason)
224
221
def selectable(self):
225
222
"""Make this a "selectable" widget.
226
223
This overrides the method from urwid.FlowWidget."""
229
226
def rows(self, maxcolrow, focus=False):
230
227
"""How many rows this widget will occupy might depend on
231
228
whether we have focus or not.
232
229
This overrides the method from urwid.FlowWidget"""
233
230
return self.current_widget(focus).rows(maxcolrow, focus=focus)
235
232
def current_widget(self, focus=False):
236
233
if focus or self.opened:
237
234
return self._focus_widget
238
235
return self._widget
240
237
def update(self):
241
238
"Called when what is visible on the screen should be updated."
242
239
# How to add standout mode to a style
243
with_standout = { "normal": "standout",
244
"bold": "bold-standout",
246
"underline-blink-standout",
247
"bold-underline-blink":
248
"bold-underline-blink-standout",
240
with_standout = {"normal": "standout",
241
"bold": "bold-standout",
243
"underline-blink-standout",
244
"bold-underline-blink":
245
"bold-underline-blink-standout",
251
248
# Rebuild focus and non-focus widgets using current properties
253
250
# Base part of a client. Name!
254
base = '{name}: '.format(name=self.properties["Name"])
251
base = "{name}: ".format(name=self.properties["Name"])
255
252
if not self.properties["Enabled"]:
256
253
message = "DISABLED"
257
254
self.using_timer(False)
258
255
elif self.properties["ApprovalPending"]:
259
timeout = datetime.timedelta(milliseconds
256
timeout = datetime.timedelta(
257
milliseconds=self.properties["ApprovalDelay"])
262
258
last_approval_request = isoformat_to_datetime(
263
259
self.properties["LastApprovalRequest"])
264
260
if last_approval_request is not None:
324
321
if self.delete_hook is not None:
325
322
self.delete_hook(self)
326
323
return super(MandosClientWidget, self).delete(**kwargs)
328
325
def render(self, maxcolrow, focus=False):
329
326
"""Render differently if we have focus.
330
327
This overrides the method from urwid.FlowWidget"""
331
328
return self.current_widget(focus).render(maxcolrow,
334
331
def keypress(self, maxcolrow, key):
336
333
This overrides the method from urwid.FlowWidget"""
338
335
self.proxy.Set(client_interface, "Enabled",
339
dbus.Boolean(True), ignore_reply = True,
340
dbus_interface = dbus.PROPERTIES_IFACE)
336
dbus.Boolean(True), ignore_reply=True,
337
dbus_interface=dbus.PROPERTIES_IFACE)
342
339
self.proxy.Set(client_interface, "Enabled", False,
344
dbus_interface = dbus.PROPERTIES_IFACE)
341
dbus_interface=dbus.PROPERTIES_IFACE)
346
343
self.proxy.Approve(dbus.Boolean(True, variant_level=1),
347
dbus_interface = client_interface,
344
dbus_interface=client_interface,
348
345
ignore_reply=True)
350
347
self.proxy.Approve(dbus.Boolean(False, variant_level=1),
351
dbus_interface = client_interface,
348
dbus_interface=client_interface,
352
349
ignore_reply=True)
353
350
elif key == "R" or key == "_" or key == "ctrl k":
354
351
self.server_proxy_object.RemoveClient(self.proxy
356
353
ignore_reply=True)
358
355
self.proxy.Set(client_interface, "CheckerRunning",
359
dbus.Boolean(True), ignore_reply = True,
360
dbus_interface = dbus.PROPERTIES_IFACE)
356
dbus.Boolean(True), ignore_reply=True,
357
dbus_interface=dbus.PROPERTIES_IFACE)
362
359
self.proxy.Set(client_interface, "CheckerRunning",
363
dbus.Boolean(False), ignore_reply = True,
364
dbus_interface = dbus.PROPERTIES_IFACE)
360
dbus.Boolean(False), ignore_reply=True,
361
dbus_interface=dbus.PROPERTIES_IFACE)
366
self.proxy.CheckedOK(dbus_interface = client_interface,
363
self.proxy.CheckedOK(dbus_interface=client_interface,
367
364
ignore_reply=True)
369
366
# elif key == "p" or key == "=":
387
def glib_safely(func, retval=True):
388
def safe_func(*args, **kwargs):
390
return func(*args, **kwargs)
390
397
class ConstrainedListBox(urwid.ListBox):
391
398
"""Like a normal urwid.ListBox, but will consume all "up" or
392
399
"down" key presses, thus not allowing any containing widgets to
393
400
use them as an excuse to shift focus away from this widget.
395
402
def keypress(self, *args, **kwargs):
396
ret = super(ConstrainedListBox, self).keypress(*args, **kwargs)
403
ret = (super(ConstrainedListBox, self)
404
.keypress(*args, **kwargs))
397
405
if ret in ("up", "down"):
402
class UserInterface(object):
403
411
"""This is the entire user interface - the whole screen
404
412
with boxes, lists of client widgets, etc.
406
def __init__(self, max_log_length=1000, log_level=1):
414
def __init__(self, max_log_length=1000):
407
415
DBusGMainLoop(set_as_default=True)
409
417
self.screen = urwid.curses_display.Screen()
411
419
self.screen.register_palette((
413
421
"default", "default", None),
428
437
"bold,underline,blink,standout", "default",
429
438
"bold,underline,blink,standout"),
432
441
if urwid.supports_unicode():
433
self.divider = "─" # \u2500
434
#self.divider = "━" # \u2501
442
self.divider = "─" # \u2500
436
#self.divider = "-" # \u002d
437
self.divider = "_" # \u005f
444
self.divider = "_" # \u005f
439
446
self.screen.start()
441
448
self.size = self.screen.get_cols_rows()
443
450
self.clients = urwid.SimpleListWalker([])
444
451
self.clients_dict = {}
446
453
# We will add Text widgets to this list
454
self.log = urwid.SimpleListWalker([])
448
455
self.max_log_length = max_log_length
450
self.log_level = log_level
452
457
# We keep a reference to the log widget so we can remove it
453
458
# from the ListWalker without it getting destroyed
454
459
self.logbox = ConstrainedListBox(self.log)
456
461
# This keeps track of whether self.uilist currently has
457
462
# self.logbox in it or not
458
463
self.log_visible = True
459
464
self.log_wrap = "any"
466
self.loghandler = UILogHandler(self)
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 {} and fingerprint {}"
472
" could not be found"
473
.format(address, fingerprint))
469
self.add_log_line(("bold",
470
"Mandos Monitor version " + version))
471
self.add_log_line(("bold", "q: Quit ?: Help"))
473
self.busname = domain + ".Mandos"
474
self.main_loop = GLib.MainLoop()
476
def client_not_found(self, key_id, address):
477
log.info("Client with address %s and key ID %s could"
478
" not be found", address, key_id)
475
480
def rebuild(self):
476
481
"""This rebuilds the User Interface.
477
482
Call this when the widget layout needs to change"""
479
#self.uilist.append(urwid.ListBox(self.clients))
484
# self.uilist.append(urwid.ListBox(self.clients))
480
485
self.uilist.append(urwid.Frame(ConstrainedListBox(self.
482
#header=urwid.Divider(),
487
# header=urwid.Divider(),
485
urwid.Divider(div_char=
489
footer=urwid.Divider(
490
div_char=self.divider)))
487
491
if self.log_visible:
488
492
self.uilist.append(self.logbox)
489
493
self.topwidget = urwid.Pile(self.uilist)
491
def log_message(self, message, level=1):
492
"""Log message formatted with timestamp"""
493
if level < self.log_level:
495
timestamp = datetime.datetime.now().isoformat()
496
self.log_message_raw("{}: {}".format(timestamp, message),
499
def log_message_raw(self, markup, level=1):
500
"""Add a log message to the log buffer."""
501
if level < self.log_level:
495
def add_log_line(self, markup):
503
496
self.log.append(urwid.Text(markup, wrap=self.log_wrap))
504
if (self.max_log_length
505
and len(self.log) > self.max_log_length):
506
del self.log[0:len(self.log)-self.max_log_length-1]
507
self.logbox.set_focus(len(self.logbox.body.contents),
497
if self.max_log_length:
498
if len(self.log) > self.max_log_length:
499
del self.log[0:(len(self.log) - self.max_log_length)]
500
self.logbox.set_focus(len(self.logbox.body.contents)-1,
508
501
coming_from="above")
511
504
def toggle_log_display(self):
512
505
"""Toggle visibility of the log buffer."""
513
506
self.log_visible = not self.log_visible
515
self.log_message("Log visibility changed to: {}"
516
.format(self.log_visible), level=0)
508
log.debug("Log visibility changed to: %s", self.log_visible)
518
510
def change_log_display(self):
519
511
"""Change type of log display.
520
512
Currently, this toggles wrapping of text lines."""
576
559
self.clients_dict[path] = client
577
560
self.clients.sort(key=lambda c: c.properties["Name"])
580
563
def remove_client(self, client, path=None):
581
564
self.clients.remove(client)
583
566
path = client.proxy.object_path
584
567
del self.clients_dict[path]
587
570
def refresh(self):
588
571
"""Redraw the screen"""
589
572
canvas = self.topwidget.render(self.size, focus=True)
590
573
self.screen.draw_screen(self.size, canvas)
593
576
"""Start the main loop and exit when it's done."""
577
log.addHandler(self.loghandler)
578
self.orig_log_propagate = log.propagate
579
log.propagate = False
580
self.orig_log_level = log.level
594
582
self.bus = dbus.SystemBus()
595
583
mandos_dbus_objc = self.bus.get_object(
596
584
self.busname, "/", follow_name_owner_changes=True)
597
self.mandos_serv = dbus.Interface(mandos_dbus_objc,
585
self.mandos_serv = dbus.Interface(
586
mandos_dbus_objc, dbus_interface=server_interface)
601
588
mandos_clients = (self.mandos_serv
602
589
.GetAllClientsWithProperties())
603
590
if not mandos_clients:
604
self.log_message_raw(("bold", "Note: Server has no clients."))
591
log.warning("Note: Server has no clients.")
605
592
except dbus.exceptions.DBusException:
606
self.log_message_raw(("bold", "Note: No Mandos server running."))
593
log.warning("Note: No Mandos server running.")
607
594
mandos_clients = dbus.Dictionary()
609
596
(self.mandos_serv
610
597
.connect_to_signal("InterfacesRemoved",
611
598
self.find_and_remove_client,
613
= dbus.OBJECT_MANAGER_IFACE,
599
dbus_interface=dbus.OBJECT_MANAGER_IFACE,
614
600
byte_arrays=True))
615
601
(self.mandos_serv
616
602
.connect_to_signal("InterfacesAdded",
617
603
self.add_new_client,
619
= dbus.OBJECT_MANAGER_IFACE,
604
dbus_interface=dbus.OBJECT_MANAGER_IFACE,
620
605
byte_arrays=True))
621
606
(self.mandos_serv
622
607
.connect_to_signal("ClientNotFound",
626
611
for path, client in mandos_clients.items():
627
612
client_proxy_object = self.bus.get_object(self.busname,
629
self.add_client(MandosClientWidget(server_proxy_object
632
=client_proxy_object,
614
self.add_client(MandosClientWidget(
615
server_proxy_object=self.mandos_serv,
616
proxy_object=client_proxy_object,
618
update_hook=self.refresh,
619
delete_hook=self.remove_client),
643
self._input_callback_tag = (gobject.io_add_watch
623
self._input_callback_tag = (
625
GLib.IOChannel.unix_new(sys.stdin.fileno()),
626
GLib.PRIORITY_DEFAULT, GLib.IO_IN,
627
glib_safely(self.process_input)))
647
628
self.main_loop.run()
648
629
# Main loop has finished, we should close everything now
649
gobject.source_remove(self._input_callback_tag)
630
GLib.source_remove(self._input_callback_tag)
631
with warnings.catch_warnings():
632
warnings.simplefilter("ignore", BytesWarning)
653
636
self.main_loop.quit()
637
log.removeHandler(self.loghandler)
638
log.propagate = self.orig_log_propagate
655
640
def process_input(self, source, condition):
656
641
keys = self.screen.get_input()
657
translations = { "ctrl n": "down", # Emacs
658
"ctrl p": "up", # Emacs
659
"ctrl v": "page down", # Emacs
660
"meta v": "page up", # Emacs
661
" ": "page down", # less
662
"f": "page down", # less
663
"b": "page up", # less
642
translations = {"ctrl n": "down", # Emacs
643
"ctrl p": "up", # Emacs
644
"ctrl v": "page down", # Emacs
645
"meta v": "page up", # Emacs
646
" ": "page down", # less
647
"f": "page down", # less
648
"b": "page up", # less
669
654
key = translations[key]
670
655
except KeyError: # :-)
673
658
if key == "q" or key == "Q":
717
701
self.topwidget.set_focus(self.logbox)
720
if self.log_level == 0:
722
self.log_message("Verbose mode: Off")
704
if log.level < logging.INFO:
705
log.setLevel(logging.INFO)
706
log.info("Verbose mode: Off")
725
self.log_message("Verbose mode: On")
726
#elif (key == "end" or key == "meta >" or key == "G"
728
# pass # xxx end-of-buffer
729
#elif (key == "home" or key == "meta <" or key == "g"
731
# pass # xxx beginning-of-buffer
732
#elif key == "ctrl e" or key == "$":
733
# pass # xxx move-end-of-line
734
#elif key == "ctrl a" or key == "^":
735
# pass # xxx move-beginning-of-line
736
#elif key == "ctrl b" or key == "meta (" or key == "h":
738
#elif key == "ctrl f" or key == "meta )" or key == "l":
741
# pass # scroll up log
743
# pass # scroll down log
708
log.setLevel(logging.NOTSET)
709
log.info("Verbose mode: On")
710
# elif (key == "end" or key == "meta >" or key == "G"
712
# pass # xxx end-of-buffer
713
# elif (key == "home" or key == "meta <" or key == "g"
715
# pass # xxx beginning-of-buffer
716
# elif key == "ctrl e" or key == "$":
717
# pass # xxx move-end-of-line
718
# elif key == "ctrl a" or key == "^":
719
# pass # xxx move-beginning-of-line
720
# elif key == "ctrl b" or key == "meta (" or key == "h":
722
# elif key == "ctrl f" or key == "meta )" or key == "l":
725
# pass # scroll up log
727
# pass # scroll down log
744
728
elif self.topwidget.selectable():
745
729
self.topwidget.keypress(self.size, key)
734
class UILogHandler(logging.Handler):
735
def __init__(self, ui, *args, **kwargs):
737
super(UILogHandler, self).__init__(*args, **kwargs)
739
logging.Formatter("%(asctime)s: %(message)s"))
740
def emit(self, record):
741
msg = self.format(record)
742
if record.levelno > logging.INFO:
744
self.ui.add_log_line(msg)
749
747
ui = UserInterface()
752
750
except KeyboardInterrupt:
754
except Exception as e:
755
ui.log_message(str(e))
751
with warnings.catch_warnings():
752
warnings.filterwarnings("ignore", "", BytesWarning)
755
with warnings.catch_warnings():
756
warnings.filterwarnings("ignore", "", BytesWarning)