/mandos/trunk

To get this branch, use:
bzr branch http://bzr.recompile.se/loggerhead/mandos/trunk

« back to all changes in this revision

Viewing changes to mandos-ctl

  • Committer: Teddy Hogeborn
  • Date: 2019-03-02 02:17:17 UTC
  • Revision ID: teddy@recompile.se-20190302021717-kzk7xwjtef3syau6
mandos-ctl: Refactor

* mandos-ctl (TableOfClients.rows): Extract methods.
  (TableOfClients.row_formatting_string): New.
  (TableOfClients.string_from_client): New.
  (TableOfClients.header_line): New.
  (TableOfClients.client_line): New.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
1
#!/usr/bin/python
2
 
# -*- mode: python; coding: utf-8 -*-
 
2
# -*- mode: python; coding: utf-8; after-save-hook: (lambda () (let ((command (if (and (boundp 'tramp-file-name-structure) (string-match (car tramp-file-name-structure) (buffer-file-name))) (tramp-file-name-localname (tramp-dissect-file-name (buffer-file-name))) (buffer-file-name)))) (if (= (shell-command (format "%s --check" (shell-quote-argument command)) "*Test*") 0) (let ((w (get-buffer-window "*Test*"))) (if w (delete-window w)) (kill-buffer "*Test*")) (display-buffer "*Test*")))); -*-
3
3
#
4
4
# Mandos Monitor - Control and monitor the Mandos server
5
5
#
40
40
import os
41
41
import collections
42
42
import json
 
43
import unittest
 
44
import logging
43
45
 
44
46
import dbus
45
47
 
 
48
# Show warnings by default
 
49
if not sys.warnoptions:
 
50
    import warnings
 
51
    warnings.simplefilter("default")
 
52
 
 
53
log = logging.getLogger(sys.argv[0])
 
54
logging.basicConfig(level="INFO", # Show info level messages
 
55
                    format="%(message)s") # Show basic log messages
 
56
 
 
57
logging.captureWarnings(True)   # Show warnings via the logging system
 
58
 
46
59
if sys.version_info.major == 2:
47
60
    str = unicode
48
61
 
49
62
locale.setlocale(locale.LC_ALL, "")
50
63
 
51
 
tablewords = {
52
 
    "Name": "Name",
53
 
    "Enabled": "Enabled",
54
 
    "Timeout": "Timeout",
55
 
    "LastCheckedOK": "Last Successful Check",
56
 
    "LastApprovalRequest": "Last Approval Request",
57
 
    "Created": "Created",
58
 
    "Interval": "Interval",
59
 
    "Host": "Host",
60
 
    "Fingerprint": "Fingerprint",
61
 
    "KeyID": "Key ID",
62
 
    "CheckerRunning": "Check Is Running",
63
 
    "LastEnabled": "Last Enabled",
64
 
    "ApprovalPending": "Approval Is Pending",
65
 
    "ApprovedByDefault": "Approved By Default",
66
 
    "ApprovalDelay": "Approval Delay",
67
 
    "ApprovalDuration": "Approval Duration",
68
 
    "Checker": "Checker",
69
 
    "ExtendedTimeout": "Extended Timeout",
70
 
    "Expires": "Expires",
71
 
    "LastCheckerStatus": "Last Checker Status",
72
 
}
73
64
defaultkeywords = ("Name", "Enabled", "Timeout", "LastCheckedOK")
74
65
domain = "se.recompile"
75
66
busname = domain + ".Mandos"
103
94
    datetime.timedelta(0, 60)
104
95
    >>> rfc3339_duration_to_delta("PT60M")
105
96
    datetime.timedelta(0, 3600)
 
97
    >>> rfc3339_duration_to_delta("P60M")
 
98
    datetime.timedelta(1680)
106
99
    >>> rfc3339_duration_to_delta("PT24H")
107
100
    datetime.timedelta(1)
108
101
    >>> rfc3339_duration_to_delta("P1W")
111
104
    datetime.timedelta(0, 330)
112
105
    >>> rfc3339_duration_to_delta("P1DT3M20S")
113
106
    datetime.timedelta(1, 200)
 
107
    >>> # Can not be empty:
 
108
    >>> rfc3339_duration_to_delta("")
 
109
    Traceback (most recent call last):
 
110
    ...
 
111
    ValueError: Invalid RFC 3339 duration: u''
 
112
    >>> # Must start with "P":
 
113
    >>> rfc3339_duration_to_delta("1D")
 
114
    Traceback (most recent call last):
 
115
    ...
 
116
    ValueError: Invalid RFC 3339 duration: u'1D'
 
117
    >>> # Must use correct order
 
118
    >>> rfc3339_duration_to_delta("PT1S2M")
 
119
    Traceback (most recent call last):
 
120
    ...
 
121
    ValueError: Invalid RFC 3339 duration: u'PT1S2M'
 
122
    >>> # Time needs time marker
 
123
    >>> rfc3339_duration_to_delta("P1H2S")
 
124
    Traceback (most recent call last):
 
125
    ...
 
126
    ValueError: Invalid RFC 3339 duration: u'P1H2S'
 
127
    >>> # Weeks can not be combined with anything else
 
128
    >>> rfc3339_duration_to_delta("P1D2W")
 
129
    Traceback (most recent call last):
 
130
    ...
 
131
    ValueError: Invalid RFC 3339 duration: u'P1D2W'
 
132
    >>> rfc3339_duration_to_delta("P2W2H")
 
133
    Traceback (most recent call last):
 
134
    ...
 
135
    ValueError: Invalid RFC 3339 duration: u'P2W2H'
114
136
    """
115
137
 
116
138
    # Parsing an RFC 3339 duration with regular expressions is not
195
217
 
196
218
def string_to_delta(interval):
197
219
    """Parse a string and return a datetime.timedelta
198
 
 
199
 
    >>> string_to_delta('7d')
200
 
    datetime.timedelta(7)
201
 
    >>> string_to_delta('60s')
202
 
    datetime.timedelta(0, 60)
203
 
    >>> string_to_delta('60m')
204
 
    datetime.timedelta(0, 3600)
205
 
    >>> string_to_delta('24h')
206
 
    datetime.timedelta(1)
207
 
    >>> string_to_delta('1w')
208
 
    datetime.timedelta(7)
209
 
    >>> string_to_delta('5m 30s')
210
 
    datetime.timedelta(0, 330)
211
220
    """
212
221
 
213
222
    try:
214
223
        return rfc3339_duration_to_delta(interval)
215
 
    except ValueError:
216
 
        pass
 
224
    except ValueError as e:
 
225
        log.warning("%s - Parsing as pre-1.6.1 interval instead",
 
226
                    ' '.join(e.args))
 
227
    return parse_pre_1_6_1_interval(interval)
 
228
 
 
229
 
 
230
def parse_pre_1_6_1_interval(interval):
 
231
    """Parse an interval string as documented by Mandos before 1.6.1, and
 
232
    return a datetime.timedelta
 
233
    >>> parse_pre_1_6_1_interval('7d')
 
234
    datetime.timedelta(7)
 
235
    >>> parse_pre_1_6_1_interval('60s')
 
236
    datetime.timedelta(0, 60)
 
237
    >>> parse_pre_1_6_1_interval('60m')
 
238
    datetime.timedelta(0, 3600)
 
239
    >>> parse_pre_1_6_1_interval('24h')
 
240
    datetime.timedelta(1)
 
241
    >>> parse_pre_1_6_1_interval('1w')
 
242
    datetime.timedelta(7)
 
243
    >>> parse_pre_1_6_1_interval('5m 30s')
 
244
    datetime.timedelta(0, 330)
 
245
    >>> parse_pre_1_6_1_interval('')
 
246
    datetime.timedelta(0)
 
247
    >>> # Ignore unknown characters, allow any order and repetitions
 
248
    >>> parse_pre_1_6_1_interval('2dxy7zz11y3m5m')
 
249
    datetime.timedelta(2, 480, 18000)
 
250
 
 
251
    """
217
252
 
218
253
    value = datetime.timedelta(0)
219
254
    regexp = re.compile(r"(\d+)([dsmhw]?)")
235
270
 
236
271
 
237
272
def print_clients(clients, keywords):
 
273
    print('\n'.join(TableOfClients(clients, keywords).rows()))
 
274
 
 
275
class TableOfClients(object):
 
276
    tablewords = {
 
277
        "Name": "Name",
 
278
        "Enabled": "Enabled",
 
279
        "Timeout": "Timeout",
 
280
        "LastCheckedOK": "Last Successful Check",
 
281
        "LastApprovalRequest": "Last Approval Request",
 
282
        "Created": "Created",
 
283
        "Interval": "Interval",
 
284
        "Host": "Host",
 
285
        "Fingerprint": "Fingerprint",
 
286
        "KeyID": "Key ID",
 
287
        "CheckerRunning": "Check Is Running",
 
288
        "LastEnabled": "Last Enabled",
 
289
        "ApprovalPending": "Approval Is Pending",
 
290
        "ApprovedByDefault": "Approved By Default",
 
291
        "ApprovalDelay": "Approval Delay",
 
292
        "ApprovalDuration": "Approval Duration",
 
293
        "Checker": "Checker",
 
294
        "ExtendedTimeout": "Extended Timeout",
 
295
        "Expires": "Expires",
 
296
        "LastCheckerStatus": "Last Checker Status",
 
297
    }
 
298
 
 
299
    def __init__(self, clients, keywords, tablewords=None):
 
300
        self.clients = clients
 
301
        self.keywords = keywords
 
302
        if tablewords is not None:
 
303
            self.tablewords = tablewords
 
304
 
 
305
    def rows(self):
 
306
        format_string = self.row_formatting_string()
 
307
        rows = [self.header_line(format_string)]
 
308
        rows.extend(self.client_line(client, format_string)
 
309
                    for client in self.clients)
 
310
        return rows
 
311
 
 
312
    def row_formatting_string(self):
 
313
        "Format string used to format table rows"
 
314
        return " ".join("{{{key}:{width}}}".format(
 
315
            width=max(len(self.tablewords[key]),
 
316
                      max(len(self.string_from_client(client, key))
 
317
                          for client in self.clients)),
 
318
            key=key)
 
319
                                 for key in self.keywords)
 
320
 
 
321
    def string_from_client(self, client, key):
 
322
        return self.valuetostring(client[key], key)
 
323
 
 
324
    @staticmethod
238
325
    def valuetostring(value, keyword):
239
 
        if type(value) is dbus.Boolean:
 
326
        if isinstance(value, dbus.Boolean):
240
327
            return "Yes" if value else "No"
241
328
        if keyword in ("Timeout", "Interval", "ApprovalDelay",
242
329
                       "ApprovalDuration", "ExtendedTimeout"):
243
330
            return milliseconds_to_string(value)
244
331
        return str(value)
245
332
 
246
 
    # Create format string to print table rows
247
 
    format_string = " ".join("{{{key}:{width}}}".format(
248
 
        width=max(len(tablewords[key]),
249
 
                  max(len(valuetostring(client[key], key))
250
 
                      for client in clients)),
251
 
        key=key)
252
 
                             for key in keywords)
253
 
    # Print header line
254
 
    print(format_string.format(**tablewords))
255
 
    for client in clients:
256
 
        print(format_string
257
 
              .format(**{key: valuetostring(client[key], key)
258
 
                         for key in keywords}))
 
333
    def header_line(self, format_string):
 
334
        return format_string.format(**self.tablewords)
 
335
 
 
336
    def client_line(self, client, format_string):
 
337
        return format_string.format(
 
338
            **{key: self.string_from_client(client, key)
 
339
               for key in self.keywords})
259
340
 
260
341
 
261
342
def has_actions(options):
345
426
    if options.all and not has_actions(options):
346
427
        parser.error("--all requires an action.")
347
428
 
348
 
    if options.check:
349
 
        import doctest
350
 
        fail_count, test_count = doctest.testmod()
351
 
        sys.exit(os.EX_OK if fail_count == 0 else 1)
352
 
 
353
429
    try:
354
430
        bus = dbus.SystemBus()
355
431
        mandos_dbus_objc = bus.get_object(busname, server_path)
356
432
    except dbus.exceptions.DBusException:
357
 
        print("Could not connect to Mandos server", file=sys.stderr)
 
433
        log.critical("Could not connect to Mandos server")
358
434
        sys.exit(1)
359
435
 
360
436
    mandos_serv = dbus.Interface(mandos_dbus_objc,
379
455
            os.dup2(stderrcopy, sys.stderr.fileno())
380
456
            os.close(stderrcopy)
381
457
    except dbus.exceptions.DBusException as e:
382
 
        print("Access denied: "
383
 
              "Accessing mandos server through D-Bus: {}".format(e),
384
 
              file=sys.stderr)
 
458
        log.critical("Failed to access Mandos server through D-Bus:"
 
459
                     "\n%s", e)
385
460
        sys.exit(1)
386
461
 
387
462
    # Compile dict of (clients: properties) to process
398
473
                    clients[client_objc] = client
399
474
                    break
400
475
            else:
401
 
                print("Client not found on server: {!r}"
402
 
                      .format(name), file=sys.stderr)
 
476
                log.critical("Client not found on server: %r", name)
403
477
                sys.exit(1)
404
478
 
405
479
    if not has_actions(options) and clients:
493
567
                client.Approve(dbus.Boolean(False),
494
568
                               dbus_interface=client_interface)
495
569
 
 
570
 
 
571
class Test_milliseconds_to_string(unittest.TestCase):
 
572
    def test_all(self):
 
573
        self.assertEqual(milliseconds_to_string(93785000),
 
574
                         "1T02:03:05")
 
575
    def test_no_days(self):
 
576
        self.assertEqual(milliseconds_to_string(7385000), "02:03:05")
 
577
    def test_all_zero(self):
 
578
        self.assertEqual(milliseconds_to_string(0), "00:00:00")
 
579
    def test_no_fractional_seconds(self):
 
580
        self.assertEqual(milliseconds_to_string(400), "00:00:00")
 
581
        self.assertEqual(milliseconds_to_string(900), "00:00:00")
 
582
        self.assertEqual(milliseconds_to_string(1900), "00:00:01")
 
583
 
 
584
class Test_string_to_delta(unittest.TestCase):
 
585
    def test_handles_basic_rfc3339(self):
 
586
        self.assertEqual(string_to_delta("PT2H"),
 
587
                         datetime.timedelta(0, 7200))
 
588
    def test_falls_back_to_pre_1_6_1_with_warning(self):
 
589
        # assertLogs only exists in Python 3.4
 
590
        if hasattr(self, "assertLogs"):
 
591
            with self.assertLogs(log, logging.WARNING):
 
592
                value = string_to_delta("2h")
 
593
        else:
 
594
            value = string_to_delta("2h")
 
595
        self.assertEqual(value, datetime.timedelta(0, 7200))
 
596
 
 
597
class Test_TableOfClients(unittest.TestCase):
 
598
    def setUp(self):
 
599
        self.tablewords = {
 
600
            "Attr1": "X",
 
601
            "AttrTwo": "Yy",
 
602
            "AttrThree": "Zzz",
 
603
            "Bool": "A D-BUS Boolean",
 
604
            "NonDbusBoolean": "A Non-D-BUS Boolean",
 
605
            "Integer": "An Integer",
 
606
            "Timeout": "Timedelta 1",
 
607
            "Interval": "Timedelta 2",
 
608
            "ApprovalDelay": "Timedelta 3",
 
609
            "ApprovalDuration": "Timedelta 4",
 
610
            "ExtendedTimeout": "Timedelta 5",
 
611
            "String": "A String",
 
612
        }
 
613
        self.keywords = ["Attr1", "AttrTwo"]
 
614
        self.clients = [
 
615
            {
 
616
                "Attr1": "x1",
 
617
                "AttrTwo": "y1",
 
618
                "AttrThree": "z1",
 
619
                "Bool": dbus.Boolean(False),
 
620
                "NonDbusBoolean": False,
 
621
                "Integer": 0,
 
622
                "Timeout": 0,
 
623
                "Interval": 1000,
 
624
                "ApprovalDelay": 2000,
 
625
                "ApprovalDuration": 3000,
 
626
                "ExtendedTimeout": 4000,
 
627
                "String": "",
 
628
            },
 
629
            {
 
630
                "Attr1": "x2",
 
631
                "AttrTwo": "y2",
 
632
                "AttrThree": "z2",
 
633
                "Bool": dbus.Boolean(True),
 
634
                "NonDbusBoolean": True,
 
635
                "Integer": 1,
 
636
                "Timeout": 93785000,
 
637
                "Interval": 93786000,
 
638
                "ApprovalDelay": 93787000,
 
639
                "ApprovalDuration": 93788000,
 
640
                "ExtendedTimeout": 93789000,
 
641
                "String": "A huge string which will not fit," * 10,
 
642
            },
 
643
        ]
 
644
    def test_short_header(self):
 
645
        rows = TableOfClients(self.clients, self.keywords,
 
646
                              self.tablewords).rows()
 
647
        expected_rows = [
 
648
            "X  Yy",
 
649
            "x1 y1",
 
650
            "x2 y2"]
 
651
        self.assertEqual(rows, expected_rows)
 
652
    def test_booleans(self):
 
653
        keywords = ["Bool", "NonDbusBoolean"]
 
654
        rows = TableOfClients(self.clients, keywords,
 
655
                              self.tablewords).rows()
 
656
        expected_rows = [
 
657
            "A D-BUS Boolean A Non-D-BUS Boolean",
 
658
            "No              False              ",
 
659
            "Yes             True               ",
 
660
        ]
 
661
        self.assertEqual(rows, expected_rows)
 
662
    def test_milliseconds_detection(self):
 
663
        keywords = ["Integer", "Timeout", "Interval", "ApprovalDelay",
 
664
                    "ApprovalDuration", "ExtendedTimeout"]
 
665
        rows = TableOfClients(self.clients, keywords,
 
666
                              self.tablewords).rows()
 
667
        expected_rows = ("""
 
668
An Integer Timedelta 1 Timedelta 2 Timedelta 3 Timedelta 4 Timedelta 5
 
669
0          00:00:00    00:00:01    00:00:02    00:00:03    00:00:04   
 
670
1          1T02:03:05  1T02:03:06  1T02:03:07  1T02:03:08  1T02:03:09 
 
671
"""
 
672
        ).splitlines()[1:]
 
673
        self.assertEqual(rows, expected_rows)
 
674
    def test_empty_and_long_string_values(self):
 
675
        keywords = ["String"]
 
676
        rows = TableOfClients(self.clients, keywords,
 
677
                              self.tablewords).rows()
 
678
        expected_rows = ("""
 
679
A String                                                                                                                                                                                                                                                                                                                                  
 
680
                                                                                                                                                                                                                                                                                                                                          
 
681
A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,A huge string which will not fit,
 
682
"""
 
683
        ).splitlines()[1:]
 
684
        self.assertEqual(rows, expected_rows)
 
685
 
 
686
 
 
687
 
 
688
def should_only_run_tests():
 
689
    parser = argparse.ArgumentParser(add_help=False)
 
690
    parser.add_argument("--check", action='store_true')
 
691
    args, unknown_args = parser.parse_known_args()
 
692
    run_tests = args.check
 
693
    if run_tests:
 
694
        # Remove --check argument from sys.argv
 
695
        sys.argv[1:] = unknown_args
 
696
    return run_tests
 
697
 
 
698
# Add all tests from doctest strings
 
699
def load_tests(loader, tests, none):
 
700
    import doctest
 
701
    tests.addTests(doctest.DocTestSuite())
 
702
    return tests
496
703
 
497
704
if __name__ == "__main__":
498
 
    main()
 
705
    if should_only_run_tests():
 
706
        # Call using ./tdd-python-script --check [--verbose]
 
707
        unittest.main()
 
708
    else:
 
709
        main()