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"
70
dbus.OBJECT_MANAGER_IFACE
71
except AttributeError:
72
dbus.OBJECT_MANAGER_IFACE = "org.freedesktop.DBus.ObjectManager"
65
75
def isoformat_to_datetime(iso):
66
76
"Parse an ISO 8601 date string to a datetime.datetime()"
78
int(second), # Whole seconds
79
int(fraction*1000000)) # Microseconds
81
class MandosClientPropertyCache(object):
88
int(second), # Whole seconds
89
int(fraction*1000000)) # Microseconds
92
class MandosClientPropertyCache:
82
93
"""This wraps a Mandos Client D-Bus proxy object, caches the
83
94
properties and calls a hook function when any of them are
86
97
def __init__(self, proxy_object=None, properties=None, **kwargs):
87
self.proxy = proxy_object # Mandos Client proxy object
98
self.proxy = proxy_object # Mandos Client proxy object
88
99
self.properties = dict() if properties is None else properties
89
100
self.property_changed_match = (
90
101
self.proxy.connect_to_signal("PropertiesChanged",
91
102
self.properties_changed,
92
103
dbus.PROPERTIES_IFACE,
93
104
byte_arrays=True))
95
106
if properties is None:
96
self.properties.update(
97
self.proxy.GetAll(client_interface,
99
= dbus.PROPERTIES_IFACE))
107
self.properties.update(self.proxy.GetAll(
109
dbus_interface=dbus.PROPERTIES_IFACE))
101
111
super(MandosClientPropertyCache, self).__init__(**kwargs)
103
113
def properties_changed(self, interface, properties, invalidated):
104
114
"""This is called whenever we get a PropertiesChanged signal
105
115
It updates the changed properties in the "properties" dict.
107
117
# Update properties dict with new value
108
self.properties.update(properties)
118
if interface == client_interface:
119
self.properties.update(properties)
110
121
def delete(self):
111
122
self.property_changed_match.remove()
158
167
client_interface,
159
168
byte_arrays=True))
160
self.logger('Created client {}'
161
.format(self.properties["Name"]), level=0)
169
log.debug("Created client %s", self.properties["Name"])
163
171
def using_timer(self, flag):
164
172
"""Call this method with True or False when timer should be
165
173
activated or deactivated.
167
175
if flag and self._update_timer_callback_tag is None:
168
176
# Will update the shown timer value every second
169
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)))
172
180
elif not (flag or self._update_timer_callback_tag is None):
173
gobject.source_remove(self._update_timer_callback_tag)
181
GLib.source_remove(self._update_timer_callback_tag)
174
182
self._update_timer_callback_tag = None
176
def checker_completed(self, exitstatus, signal, command):
184
def checker_completed(self, exitstatus, condition, command):
177
185
if exitstatus == 0:
178
self.logger('Checker for client {} (command "{}")'
179
' succeeded'.format(self.properties["Name"],
186
log.debug('Checker for client %s (command "%s")'
187
" succeeded", self.properties["Name"], command)
185
self.logger('Checker for client {} (command "{}") failed'
187
.format(self.properties["Name"], command,
190
self.logger('Checker for client {} (command "{}") was'
191
' killed by signal {}'
192
.format(self.properties["Name"], command,
195
self.logger('Checker for client {} completed'
197
.format(self.properties["Name"]))
191
if os.WIFEXITED(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
elif os.WIFSIGNALED(condition):
196
log.info('Checker for client %s (command "%s") was'
197
" killed by signal %d", self.properties["Name"],
198
command, os.WTERMSIG(condition))
200
201
def checker_started(self, command):
201
202
"""Server signals that a checker started."""
202
self.logger('Client {} started checker "{}"'
203
.format(self.properties["Name"],
203
log.debug('Client %s started checker "%s"',
204
self.properties["Name"], command)
206
206
def got_secret(self):
207
self.logger('Client {} received its secret'
208
.format(self.properties["Name"]))
207
log.info("Client %s received its secret",
208
self.properties["Name"])
210
210
def need_approval(self, timeout, default):
212
message = 'Client {} needs approval within {} seconds'
212
message = "Client %s needs approval within %f seconds"
214
message = 'Client {} will get its secret in {} seconds'
215
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)
218
217
def rejected(self, reason):
219
self.logger('Client {} was rejected; reason: {}'
220
.format(self.properties["Name"], reason))
218
log.info("Client %s was rejected; reason: %s",
219
self.properties["Name"], reason)
222
221
def selectable(self):
223
222
"""Make this a "selectable" widget.
224
223
This overrides the method from urwid.FlowWidget."""
227
226
def rows(self, maxcolrow, focus=False):
228
227
"""How many rows this widget will occupy might depend on
229
228
whether we have focus or not.
230
229
This overrides the method from urwid.FlowWidget"""
231
230
return self.current_widget(focus).rows(maxcolrow, focus=focus)
233
232
def current_widget(self, focus=False):
234
233
if focus or self.opened:
235
234
return self._focus_widget
236
235
return self._widget
238
237
def update(self):
239
238
"Called when what is visible on the screen should be updated."
240
239
# How to add standout mode to a style
241
with_standout = { "normal": "standout",
242
"bold": "bold-standout",
244
"underline-blink-standout",
245
"bold-underline-blink":
246
"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",
249
248
# Rebuild focus and non-focus widgets using current properties
251
250
# Base part of a client. Name!
252
base = '{name}: '.format(name=self.properties["Name"])
251
base = "{name}: ".format(name=self.properties["Name"])
253
252
if not self.properties["Enabled"]:
254
253
message = "DISABLED"
255
254
self.using_timer(False)
256
255
elif self.properties["ApprovalPending"]:
257
timeout = datetime.timedelta(milliseconds
256
timeout = datetime.timedelta(
257
milliseconds=self.properties["ApprovalDelay"])
260
258
last_approval_request = isoformat_to_datetime(
261
259
self.properties["LastApprovalRequest"])
262
260
if last_approval_request is not None:
322
321
if self.delete_hook is not None:
323
322
self.delete_hook(self)
324
323
return super(MandosClientWidget, self).delete(**kwargs)
326
325
def render(self, maxcolrow, focus=False):
327
326
"""Render differently if we have focus.
328
327
This overrides the method from urwid.FlowWidget"""
329
328
return self.current_widget(focus).render(maxcolrow,
332
331
def keypress(self, maxcolrow, key):
334
333
This overrides the method from urwid.FlowWidget"""
336
335
self.proxy.Set(client_interface, "Enabled",
337
dbus.Boolean(True), ignore_reply = True,
338
dbus_interface = dbus.PROPERTIES_IFACE)
336
dbus.Boolean(True), ignore_reply=True,
337
dbus_interface=dbus.PROPERTIES_IFACE)
340
339
self.proxy.Set(client_interface, "Enabled", False,
342
dbus_interface = dbus.PROPERTIES_IFACE)
341
dbus_interface=dbus.PROPERTIES_IFACE)
344
343
self.proxy.Approve(dbus.Boolean(True, variant_level=1),
345
dbus_interface = client_interface,
344
dbus_interface=client_interface,
346
345
ignore_reply=True)
348
347
self.proxy.Approve(dbus.Boolean(False, variant_level=1),
349
dbus_interface = client_interface,
348
dbus_interface=client_interface,
350
349
ignore_reply=True)
351
350
elif key == "R" or key == "_" or key == "ctrl k":
352
351
self.server_proxy_object.RemoveClient(self.proxy
354
353
ignore_reply=True)
356
355
self.proxy.Set(client_interface, "CheckerRunning",
357
dbus.Boolean(True), ignore_reply = True,
358
dbus_interface = dbus.PROPERTIES_IFACE)
356
dbus.Boolean(True), ignore_reply=True,
357
dbus_interface=dbus.PROPERTIES_IFACE)
360
359
self.proxy.Set(client_interface, "CheckerRunning",
361
dbus.Boolean(False), ignore_reply = True,
362
dbus_interface = dbus.PROPERTIES_IFACE)
360
dbus.Boolean(False), ignore_reply=True,
361
dbus_interface=dbus.PROPERTIES_IFACE)
364
self.proxy.CheckedOK(dbus_interface = client_interface,
363
self.proxy.CheckedOK(dbus_interface=client_interface,
365
364
ignore_reply=True)
367
366
# elif key == "p" or key == "=":
387
def glib_safely(func, retval=True):
388
def safe_func(*args, **kwargs):
390
return func(*args, **kwargs)
388
397
class ConstrainedListBox(urwid.ListBox):
389
398
"""Like a normal urwid.ListBox, but will consume all "up" or
390
399
"down" key presses, thus not allowing any containing widgets to
391
400
use them as an excuse to shift focus away from this widget.
393
402
def keypress(self, *args, **kwargs):
394
ret = super(ConstrainedListBox, self).keypress(*args, **kwargs)
403
ret = (super(ConstrainedListBox, self)
404
.keypress(*args, **kwargs))
395
405
if ret in ("up", "down"):
400
class UserInterface(object):
401
411
"""This is the entire user interface - the whole screen
402
412
with boxes, lists of client widgets, etc.
404
def __init__(self, max_log_length=1000, log_level=1):
414
def __init__(self, max_log_length=1000):
405
415
DBusGMainLoop(set_as_default=True)
407
417
self.screen = urwid.curses_display.Screen()
409
419
self.screen.register_palette((
411
421
"default", "default", None),
426
437
"bold,underline,blink,standout", "default",
427
438
"bold,underline,blink,standout"),
430
441
if urwid.supports_unicode():
431
self.divider = "─" # \u2500
432
#self.divider = "━" # \u2501
442
self.divider = "─" # \u2500
434
#self.divider = "-" # \u002d
435
self.divider = "_" # \u005f
444
self.divider = "_" # \u005f
437
446
self.screen.start()
439
448
self.size = self.screen.get_cols_rows()
441
450
self.clients = urwid.SimpleListWalker([])
442
451
self.clients_dict = {}
444
453
# We will add Text widgets to this list
454
self.log = urwid.SimpleListWalker([])
446
455
self.max_log_length = max_log_length
448
self.log_level = log_level
450
457
# We keep a reference to the log widget so we can remove it
451
458
# from the ListWalker without it getting destroyed
452
459
self.logbox = ConstrainedListBox(self.log)
454
461
# This keeps track of whether self.uilist currently has
455
462
# self.logbox in it or not
456
463
self.log_visible = True
457
464
self.log_wrap = "any"
466
self.loghandler = UILogHandler(self)
460
self.log_message_raw(("bold",
461
"Mandos Monitor version " + version))
462
self.log_message_raw(("bold",
465
self.busname = domain + '.Mandos'
466
self.main_loop = gobject.MainLoop()
468
def client_not_found(self, fingerprint, address):
469
self.log_message("Client with address {} and fingerprint {}"
470
" could not be found"
471
.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)
473
480
def rebuild(self):
474
481
"""This rebuilds the User Interface.
475
482
Call this when the widget layout needs to change"""
477
#self.uilist.append(urwid.ListBox(self.clients))
484
# self.uilist.append(urwid.ListBox(self.clients))
478
485
self.uilist.append(urwid.Frame(ConstrainedListBox(self.
480
#header=urwid.Divider(),
487
# header=urwid.Divider(),
483
urwid.Divider(div_char=
489
footer=urwid.Divider(
490
div_char=self.divider)))
485
491
if self.log_visible:
486
492
self.uilist.append(self.logbox)
487
493
self.topwidget = urwid.Pile(self.uilist)
489
def log_message(self, message, level=1):
490
"""Log message formatted with timestamp"""
491
if level < self.log_level:
493
timestamp = datetime.datetime.now().isoformat()
494
self.log_message_raw("{}: {}".format(timestamp, message),
497
def log_message_raw(self, markup, level=1):
498
"""Add a log message to the log buffer."""
499
if level < self.log_level:
495
def add_log_line(self, markup):
501
496
self.log.append(urwid.Text(markup, wrap=self.log_wrap))
502
if (self.max_log_length
503
and len(self.log) > self.max_log_length):
504
del self.log[0:len(self.log)-self.max_log_length-1]
505
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,
506
501
coming_from="above")
509
504
def toggle_log_display(self):
510
505
"""Toggle visibility of the log buffer."""
511
506
self.log_visible = not self.log_visible
513
self.log_message("Log visibility changed to: {}"
514
.format(self.log_visible), level=0)
508
log.debug("Log visibility changed to: %s", self.log_visible)
516
510
def change_log_display(self):
517
511
"""Change type of log display.
518
512
Currently, this toggles wrapping of text lines."""
522
516
self.log_wrap = "clip"
523
517
for textwidget in self.log:
524
518
textwidget.set_wrap_mode(self.log_wrap)
525
self.log_message("Wrap mode: {}".format(self.log_wrap),
528
def find_and_remove_client(self, path, name):
519
log.debug("Wrap mode: %s", self.log_wrap)
521
def find_and_remove_client(self, path, interfaces):
529
522
"""Find a client by its object path and remove it.
531
This is connected to the ClientRemoved signal from the
524
This is connected to the InterfacesRemoved signal from the
532
525
Mandos server object."""
526
if client_interface not in interfaces:
527
# Not a Mandos client object; ignore
534
530
client = self.clients_dict[path]
537
self.log_message("Unknown client {!r} ({!r}) removed"
533
log.warning("Unknown client %s removed", path)
542
def add_new_client(self, path):
537
def add_new_client(self, path, ifs_and_props):
538
"""Find a client by its object path and remove it.
540
This is connected to the InterfacesAdded signal from the
541
Mandos server object.
543
if client_interface not in ifs_and_props:
544
# Not a Mandos client object; ignore
543
546
client_proxy_object = self.bus.get_object(self.busname, path)
544
self.add_client(MandosClientWidget(server_proxy_object
547
=client_proxy_object,
547
self.add_client(MandosClientWidget(
548
server_proxy_object=self.mandos_serv,
549
proxy_object=client_proxy_object,
550
update_hook=self.refresh,
551
delete_hook=self.remove_client,
552
properties=dict(ifs_and_props[client_interface])),
556
555
def add_client(self, client, path=None):
557
556
self.clients.append(client)
560
559
self.clients_dict[path] = client
561
560
self.clients.sort(key=lambda c: c.properties["Name"])
564
563
def remove_client(self, client, path=None):
565
564
self.clients.remove(client)
567
566
path = client.proxy.object_path
568
567
del self.clients_dict[path]
571
570
def refresh(self):
572
571
"""Redraw the screen"""
573
572
canvas = self.topwidget.render(self.size, focus=True)
574
573
self.screen.draw_screen(self.size, canvas)
577
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
578
582
self.bus = dbus.SystemBus()
579
583
mandos_dbus_objc = self.bus.get_object(
580
584
self.busname, "/", follow_name_owner_changes=True)
581
self.mandos_serv = dbus.Interface(mandos_dbus_objc,
585
self.mandos_serv = dbus.Interface(
586
mandos_dbus_objc, dbus_interface=server_interface)
585
588
mandos_clients = (self.mandos_serv
586
589
.GetAllClientsWithProperties())
587
590
if not mandos_clients:
588
self.log_message_raw(("bold", "Note: Server has no clients."))
591
log.warning("Note: Server has no clients.")
589
592
except dbus.exceptions.DBusException:
590
self.log_message_raw(("bold", "Note: No Mandos server running."))
593
log.warning("Note: No Mandos server running.")
591
594
mandos_clients = dbus.Dictionary()
593
596
(self.mandos_serv
594
.connect_to_signal("ClientRemoved",
597
.connect_to_signal("InterfacesRemoved",
595
598
self.find_and_remove_client,
596
dbus_interface=server_interface,
599
dbus_interface=dbus.OBJECT_MANAGER_IFACE,
597
600
byte_arrays=True))
598
601
(self.mandos_serv
599
.connect_to_signal("ClientAdded",
602
.connect_to_signal("InterfacesAdded",
600
603
self.add_new_client,
601
dbus_interface=server_interface,
604
dbus_interface=dbus.OBJECT_MANAGER_IFACE,
602
605
byte_arrays=True))
603
606
(self.mandos_serv
604
607
.connect_to_signal("ClientNotFound",
608
611
for path, client in mandos_clients.items():
609
612
client_proxy_object = self.bus.get_object(self.busname,
611
self.add_client(MandosClientWidget(server_proxy_object
614
=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),
625
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)))
629
628
self.main_loop.run()
630
629
# Main loop has finished, we should close everything now
631
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)
635
636
self.main_loop.quit()
637
log.removeHandler(self.loghandler)
638
log.propagate = self.orig_log_propagate
637
640
def process_input(self, source, condition):
638
641
keys = self.screen.get_input()
639
translations = { "ctrl n": "down", # Emacs
640
"ctrl p": "up", # Emacs
641
"ctrl v": "page down", # Emacs
642
"meta v": "page up", # Emacs
643
" ": "page down", # less
644
"f": "page down", # less
645
"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
651
654
key = translations[key]
652
655
except KeyError: # :-)
655
658
if key == "q" or key == "Q":
699
701
self.topwidget.set_focus(self.logbox)
702
if self.log_level == 0:
704
self.log_message("Verbose mode: Off")
704
if log.level < logging.INFO:
705
log.setLevel(logging.INFO)
706
log.info("Verbose mode: Off")
707
self.log_message("Verbose mode: On")
708
#elif (key == "end" or key == "meta >" or key == "G"
710
# pass # xxx end-of-buffer
711
#elif (key == "home" or key == "meta <" or key == "g"
713
# pass # xxx beginning-of-buffer
714
#elif key == "ctrl e" or key == "$":
715
# pass # xxx move-end-of-line
716
#elif key == "ctrl a" or key == "^":
717
# pass # xxx move-beginning-of-line
718
#elif key == "ctrl b" or key == "meta (" or key == "h":
720
#elif key == "ctrl f" or key == "meta )" or key == "l":
723
# pass # scroll up log
725
# 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
726
728
elif self.topwidget.selectable():
727
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)
731
747
ui = UserInterface()
734
750
except KeyboardInterrupt:
736
except Exception as e:
737
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)