/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:43:59 UTC
  • Revision ID: teddy@recompile.se-20190302024359-x3p601t67ki2h2mn
mandos-ctl: Refactor

* mandos-ctl (Test_TableOfClients): Don't test rows() method; test
                                    string representation directly.

Show diffs side-by-side

added added

removed removed

Lines of Context:
61
61
 
62
62
locale.setlocale(locale.LC_ALL, "")
63
63
 
 
64
defaultkeywords = ("Name", "Enabled", "Timeout", "LastCheckedOK")
64
65
domain = "se.recompile"
65
66
busname = domain + ".Mandos"
66
67
server_path = "/"
215
216
 
216
217
 
217
218
def string_to_delta(interval):
218
 
    """Parse a string and return a datetime.timedelta"""
 
219
    """Parse a string and return a datetime.timedelta
 
220
    """
219
221
 
220
222
    try:
221
223
        return rfc3339_duration_to_delta(interval)
226
228
 
227
229
 
228
230
def parse_pre_1_6_1_interval(interval):
229
 
    """Parse an interval string as documented by Mandos before 1.6.1,
230
 
    and return a datetime.timedelta
231
 
 
 
231
    """Parse an interval string as documented by Mandos before 1.6.1, and
 
232
    return a datetime.timedelta
232
233
    >>> parse_pre_1_6_1_interval('7d')
233
234
    datetime.timedelta(7)
234
235
    >>> parse_pre_1_6_1_interval('60s')
269
270
 
270
271
 
271
272
class TableOfClients(object):
272
 
    tableheaders = {
 
273
    tablewords = {
273
274
        "Name": "Name",
274
275
        "Enabled": "Enabled",
275
276
        "Timeout": "Timeout",
292
293
        "LastCheckerStatus": "Last Checker Status",
293
294
    }
294
295
 
295
 
    def __init__(self, clients, keywords, tableheaders=None):
 
296
    def __init__(self, clients, keywords, tablewords=None):
296
297
        self.clients = clients
297
298
        self.keywords = keywords
298
 
        if tableheaders is not None:
299
 
            self.tableheaders = tableheaders
 
299
        if tablewords is not None:
 
300
            self.tablewords = tablewords
300
301
 
301
302
    def __str__(self):
302
303
        return "\n".join(self.rows())
316
317
    def row_formatting_string(self):
317
318
        "Format string used to format table rows"
318
319
        return " ".join("{{{key}:{width}}}".format(
319
 
            width=max(len(self.tableheaders[key]),
320
 
                      *(len(self.string_from_client(client, key))
321
 
                        for client in self.clients)),
 
320
            width=max(len(self.tablewords[key]),
 
321
                      max(len(self.string_from_client(client, key))
 
322
                          for client in self.clients)),
322
323
            key=key)
323
 
                        for key in self.keywords)
 
324
                                 for key in self.keywords)
324
325
 
325
326
    def string_from_client(self, client, key):
326
327
        return self.valuetostring(client[key], key)
335
336
        return str(value)
336
337
 
337
338
    def header_line(self, format_string):
338
 
        return format_string.format(**self.tableheaders)
 
339
        return format_string.format(**self.tablewords)
339
340
 
340
341
    def client_line(self, client, format_string):
341
342
        return format_string.format(
343
344
               for key in self.keywords})
344
345
 
345
346
 
346
 
## Classes for commands.
347
 
 
348
 
# Abstract classes first
349
 
class Command(object):
350
 
    """Abstract class for commands"""
351
 
    def run(self, clients):
352
 
        """Normal commands should implement run_on_one_client(), but
353
 
        commands which want to operate on all clients at the same time
354
 
        can override this run() method instead."""
355
 
        for client in clients:
356
 
            self.run_on_one_client(client)
357
 
 
358
 
class PrintCmd(Command):
359
 
    """Abstract class for commands printing client details"""
360
 
    all_keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
361
 
                    "Created", "Interval", "Host", "KeyID",
362
 
                    "Fingerprint", "CheckerRunning", "LastEnabled",
363
 
                    "ApprovalPending", "ApprovedByDefault",
364
 
                    "LastApprovalRequest", "ApprovalDelay",
365
 
                    "ApprovalDuration", "Checker", "ExtendedTimeout",
366
 
                    "Expires", "LastCheckerStatus")
367
 
    def run(self, clients):
368
 
        print(self.output(clients))
369
 
 
370
 
class PropertyCmd(Command):
371
 
    """Abstract class for Actions for setting one client property"""
372
 
    def run_on_one_client(self, client):
373
 
        """Set the Client's D-Bus property"""
374
 
        client.Set(client_interface, self.property, self.value_to_set,
375
 
                   dbus_interface=dbus.PROPERTIES_IFACE)
376
 
 
377
 
class ValueArgumentMixIn(object):
378
 
    """Mixin class for commands taking a value as argument"""
379
 
    def __init__(self, value):
380
 
        self.value_to_set = value
381
 
 
382
 
class MillisecondsValueArgumentMixIn(ValueArgumentMixIn):
383
 
    """Mixin class for commands taking a value argument as
384
 
    milliseconds."""
385
 
    @property
386
 
    def value_to_set(self):
387
 
        return self._vts
388
 
    @value_to_set.setter
389
 
    def value_to_set(self, value):
390
 
        """When setting, convert value to a datetime.timedelta"""
391
 
        self._vts = string_to_delta(value).total_seconds() * 1000
392
 
 
393
 
# Actual (non-abstract) command classes
394
 
 
395
 
class PrintTableCmd(PrintCmd):
396
 
    def __init__(self, verbose=False):
397
 
        self.verbose = verbose
398
 
    def output(self, clients):
399
 
        if self.verbose:
400
 
            keywords = self.all_keywords
401
 
        else:
402
 
            keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK")
403
 
        return str(TableOfClients(clients.values(), keywords))
404
 
 
405
 
class DumpJSONCmd(PrintCmd):
406
 
    def output(self, clients):
407
 
        data = {client["Name"]:
408
 
                {key: self.dbus_boolean_to_bool(client[key])
409
 
                 for key in self.all_keywords}
410
 
                for client in clients.values()}
411
 
        return json.dumps(data, indent=4, separators=(',', ': '))
412
 
    @staticmethod
413
 
    def dbus_boolean_to_bool(value):
414
 
        if isinstance(value, dbus.Boolean):
415
 
            value = bool(value)
416
 
        return value
417
 
 
418
 
class IsEnabledCmd(Command):
419
 
    def run_on_one_client(self, client):
420
 
        if self.is_enabled(client):
421
 
            sys.exit(0)
422
 
        sys.exit(1)
423
 
    def is_enabled(self, client):
424
 
        return client.Get(client_interface, "Enabled",
425
 
                          dbus_interface=dbus.PROPERTIES_IFACE)
426
 
 
427
 
class RemoveCmd(Command):
428
 
    def __init__(self, mandos):
429
 
        self.mandos = mandos
430
 
    def run_on_one_client(self, client):
431
 
        self.mandos.RemoveClient(client.__dbus_object_path__)
432
 
 
433
 
class ApproveCmd(Command):
434
 
    def run_on_one_client(self, client):
435
 
        client.Approve(dbus.Boolean(True),
436
 
                       dbus_interface=client_interface)
437
 
 
438
 
class DenyCmd(Command):
439
 
    def run_on_one_client(self, client):
440
 
        client.Approve(dbus.Boolean(False),
441
 
                       dbus_interface=client_interface)
442
 
 
443
 
class EnableCmd(PropertyCmd):
444
 
    property = "Enabled"
445
 
    value_to_set = dbus.Boolean(True)
446
 
 
447
 
class DisableCmd(PropertyCmd):
448
 
    property = "Enabled"
449
 
    value_to_set = dbus.Boolean(False)
450
 
 
451
 
class BumpTimeoutCmd(PropertyCmd):
452
 
    property = "LastCheckedOK"
453
 
    value_to_set = ""
454
 
 
455
 
class StartCheckerCmd(PropertyCmd):
456
 
    property = "CheckerRunning"
457
 
    value_to_set = dbus.Boolean(True)
458
 
 
459
 
class StopCheckerCmd(PropertyCmd):
460
 
    property = "CheckerRunning"
461
 
    value_to_set = dbus.Boolean(False)
462
 
 
463
 
class ApproveByDefaultCmd(PropertyCmd):
464
 
    property = "ApprovedByDefault"
465
 
    value_to_set = dbus.Boolean(True)
466
 
 
467
 
class DenyByDefaultCmd(PropertyCmd):
468
 
    property = "ApprovedByDefault"
469
 
    value_to_set = dbus.Boolean(False)
470
 
 
471
 
class SetCheckerCmd(PropertyCmd, ValueArgumentMixIn):
472
 
    property = "Checker"
473
 
 
474
 
class SetHostCmd(PropertyCmd, ValueArgumentMixIn):
475
 
    property = "Host"
476
 
 
477
 
class SetSecretCmd(PropertyCmd, ValueArgumentMixIn):
478
 
    property = "Secret"
479
 
 
480
 
class SetTimeoutCmd(PropertyCmd, MillisecondsValueArgumentMixIn):
481
 
    property = "Timeout"
482
 
 
483
 
class SetExtendedTimeoutCmd(PropertyCmd,
484
 
                            MillisecondsValueArgumentMixIn):
485
 
    property = "ExtendedTimeout"
486
 
 
487
 
class SetIntervalCmd(PropertyCmd, MillisecondsValueArgumentMixIn):
488
 
    property = "Interval"
489
 
 
490
 
class SetApprovalDelayCmd(PropertyCmd,
491
 
                          MillisecondsValueArgumentMixIn):
492
 
    property = "ApprovalDelay"
493
 
 
494
 
class SetApprovalDurationCmd(PropertyCmd,
495
 
                             MillisecondsValueArgumentMixIn):
496
 
    property = "ApprovalDuration"
497
 
 
498
347
def has_actions(options):
499
348
    return any((options.enable,
500
349
                options.disable,
527
376
                        help="Print all fields")
528
377
    parser.add_argument("-j", "--dump-json", action="store_true",
529
378
                        help="Dump client data in JSON format")
530
 
    enable_disable = parser.add_mutually_exclusive_group()
531
 
    enable_disable.add_argument("-e", "--enable", action="store_true",
532
 
                                help="Enable client")
533
 
    enable_disable.add_argument("-d", "--disable",
534
 
                                action="store_true",
535
 
                                help="disable client")
 
379
    parser.add_argument("-e", "--enable", action="store_true",
 
380
                        help="Enable client")
 
381
    parser.add_argument("-d", "--disable", action="store_true",
 
382
                        help="disable client")
536
383
    parser.add_argument("-b", "--bump-timeout", action="store_true",
537
384
                        help="Bump timeout for client")
538
 
    start_stop_checker = parser.add_mutually_exclusive_group()
539
 
    start_stop_checker.add_argument("--start-checker",
540
 
                                    action="store_true",
541
 
                                    help="Start checker for client")
542
 
    start_stop_checker.add_argument("--stop-checker",
543
 
                                    action="store_true",
544
 
                                    help="Stop checker for client")
 
385
    parser.add_argument("--start-checker", action="store_true",
 
386
                        help="Start checker for client")
 
387
    parser.add_argument("--stop-checker", action="store_true",
 
388
                        help="Stop checker for client")
545
389
    parser.add_argument("-V", "--is-enabled", action="store_true",
546
390
                        help="Check if client is enabled")
547
391
    parser.add_argument("-r", "--remove", action="store_true",
554
398
                        help="Set extended timeout for client")
555
399
    parser.add_argument("-i", "--interval",
556
400
                        help="Set checker interval for client")
557
 
    approve_deny_default = parser.add_mutually_exclusive_group()
558
 
    approve_deny_default.add_argument(
559
 
        "--approve-by-default", action="store_true",
560
 
        default=None, dest="approved_by_default",
561
 
        help="Set client to be approved by default")
562
 
    approve_deny_default.add_argument(
563
 
        "--deny-by-default", action="store_false",
564
 
        dest="approved_by_default",
565
 
        help="Set client to be denied by default")
 
401
    parser.add_argument("--approve-by-default", action="store_true",
 
402
                        default=None, dest="approved_by_default",
 
403
                        help="Set client to be approved by default")
 
404
    parser.add_argument("--deny-by-default", action="store_false",
 
405
                        dest="approved_by_default",
 
406
                        help="Set client to be denied by default")
566
407
    parser.add_argument("--approval-delay",
567
408
                        help="Set delay before client approve/deny")
568
409
    parser.add_argument("--approval-duration",
571
412
    parser.add_argument("-s", "--secret",
572
413
                        type=argparse.FileType(mode="rb"),
573
414
                        help="Set password blob (file) for client")
574
 
    approve_deny = parser.add_mutually_exclusive_group()
575
 
    approve_deny.add_argument(
576
 
        "-A", "--approve", action="store_true",
577
 
        help="Approve any current client request")
578
 
    approve_deny.add_argument("-D", "--deny", action="store_true",
579
 
                              help="Deny any current client request")
 
415
    parser.add_argument("-A", "--approve", action="store_true",
 
416
                        help="Approve any current client request")
 
417
    parser.add_argument("-D", "--deny", action="store_true",
 
418
                        help="Deny any current client request")
580
419
    parser.add_argument("--check", action="store_true",
581
420
                        help="Run self-test")
582
421
    parser.add_argument("client", nargs="*", help="Client name")
591
430
        parser.error("--dump-json can only be used alone.")
592
431
    if options.all and not has_actions(options):
593
432
        parser.error("--all requires an action.")
594
 
    if options.is_enabled and len(options.client) > 1:
595
 
            parser.error("--is-enabled requires exactly one client")
596
433
 
597
434
    try:
598
435
        bus = dbus.SystemBus()
606
443
    mandos_serv_object_manager = dbus.Interface(
607
444
        mandos_dbus_objc, dbus_interface=dbus.OBJECT_MANAGER_IFACE)
608
445
 
609
 
    commands = []
610
 
 
611
 
    if options.dump_json:
612
 
        commands.append(DumpJSONCmd())
613
 
 
614
 
    if options.enable:
615
 
        commands.append(EnableCmd())
616
 
 
617
 
    if options.disable:
618
 
        commands.append(DisableCmd())
619
 
 
620
 
    if options.bump_timeout:
621
 
        commands.append(BumpTimeoutCmd(options.bump_timeout))
622
 
 
623
 
    if options.start_checker:
624
 
        commands.append(StartCheckerCmd())
625
 
 
626
 
    if options.stop_checker:
627
 
        commands.append(StopCheckerCmd())
628
 
 
629
 
    if options.is_enabled:
630
 
        commands.append(IsEnabledCmd())
631
 
 
632
 
    if options.remove:
633
 
        commands.append(RemoveCmd(mandos_serv))
634
 
 
635
 
    if options.checker is not None:
636
 
        commands.append(SetCheckerCmd())
637
 
 
638
 
    if options.timeout is not None:
639
 
        commands.append(SetTimeoutCmd(options.timeout))
640
 
 
641
 
    if options.extended_timeout:
642
 
        commands.append(
643
 
            SetExtendedTimeoutCmd(options.extended_timeout))
644
 
 
645
 
    if options.interval is not None:
646
 
        command.append(SetIntervalCmd(options.interval))
647
 
 
648
 
    if options.approved_by_default is not None:
649
 
        if options.approved_by_default:
650
 
            command.append(ApproveByDefaultCmd())
651
 
        else:
652
 
            command.append(DenyByDefaultCmd())
653
 
 
654
 
    if options.approval_delay is not None:
655
 
        command.append(SetApprovalDelayCmd(options.approval_delay))
656
 
 
657
 
    if options.approval_duration is not None:
658
 
        command.append(
659
 
            SetApprovalDurationCmd(options.approval_duration))
660
 
 
661
 
    if options.host is not None:
662
 
        command.append(SetHostCmd(options.host))
663
 
 
664
 
    if options.secret is not None:
665
 
        command.append(SetSecretCmd(options.secret))
666
 
 
667
 
    if options.approve:
668
 
        commands.append(ApproveCmd())
669
 
 
670
 
    if options.deny:
671
 
        commands.append(DenyCmd())
672
 
 
673
 
    # If no command option has been given, show table of clients,
674
 
    # optionally verbosely
675
 
    if not commands:
676
 
        commands.append(PrintTableCmd(verbose=options.verbose))
677
 
 
678
 
    # Filter out log message from dbus module
679
 
    dbus_logger = logging.getLogger("dbus.proxies")
680
 
    class NullFilter(logging.Filter):
681
 
        def filter(self, record):
682
 
            return False
683
 
    dbus_filter = NullFilter()
684
 
    dbus_logger.addFilter(dbus_filter)
 
446
    # block stderr since dbus library prints to stderr
 
447
    null = os.open(os.path.devnull, os.O_RDWR)
 
448
    stderrcopy = os.dup(sys.stderr.fileno())
 
449
    os.dup2(null, sys.stderr.fileno())
 
450
    os.close(null)
685
451
    try:
686
452
        try:
687
453
            mandos_clients = {path: ifs_and_props[client_interface]
690
456
                              .GetManagedObjects().items()
691
457
                              if client_interface in ifs_and_props}
692
458
        finally:
693
 
            # restore dbus logger
694
 
            dbus_logger.removeFilter(dbus_filter)
 
459
            # restore stderr
 
460
            os.dup2(stderrcopy, sys.stderr.fileno())
 
461
            os.close(stderrcopy)
695
462
    except dbus.exceptions.DBusException as e:
696
463
        log.critical("Failed to access Mandos server through D-Bus:"
697
464
                     "\n%s", e)
714
481
                log.critical("Client not found on server: %r", name)
715
482
                sys.exit(1)
716
483
 
717
 
    # Run all commands on clients
718
 
    for command in commands:
719
 
        command.run(clients)
 
484
    if not has_actions(options) and clients:
 
485
        if options.verbose or options.dump_json:
 
486
            keywords = ("Name", "Enabled", "Timeout", "LastCheckedOK",
 
487
                        "Created", "Interval", "Host", "KeyID",
 
488
                        "Fingerprint", "CheckerRunning",
 
489
                        "LastEnabled", "ApprovalPending",
 
490
                        "ApprovedByDefault", "LastApprovalRequest",
 
491
                        "ApprovalDelay", "ApprovalDuration",
 
492
                        "Checker", "ExtendedTimeout", "Expires",
 
493
                        "LastCheckerStatus")
 
494
        else:
 
495
            keywords = defaultkeywords
 
496
 
 
497
        if options.dump_json:
 
498
            json.dump({client["Name"]: {key:
 
499
                                        bool(client[key])
 
500
                                        if isinstance(client[key],
 
501
                                                      dbus.Boolean)
 
502
                                        else client[key]
 
503
                                        for key in keywords}
 
504
                       for client in clients.values()},
 
505
                      fp=sys.stdout, indent=4,
 
506
                      separators=(',', ': '))
 
507
            print()
 
508
        else:
 
509
            print(TableOfClients(clients.values(), keywords))
 
510
    else:
 
511
        # Process each client in the list by all selected options
 
512
        for client in clients:
 
513
 
 
514
            def set_client_prop(prop, value):
 
515
                """Set a Client D-Bus property"""
 
516
                client.Set(client_interface, prop, value,
 
517
                           dbus_interface=dbus.PROPERTIES_IFACE)
 
518
 
 
519
            def set_client_prop_ms(prop, value):
 
520
                """Set a Client D-Bus property, converted
 
521
                from a string to milliseconds."""
 
522
                set_client_prop(prop,
 
523
                                string_to_delta(value).total_seconds()
 
524
                                * 1000)
 
525
 
 
526
            if options.remove:
 
527
                mandos_serv.RemoveClient(client.__dbus_object_path__)
 
528
            if options.enable:
 
529
                set_client_prop("Enabled", dbus.Boolean(True))
 
530
            if options.disable:
 
531
                set_client_prop("Enabled", dbus.Boolean(False))
 
532
            if options.bump_timeout:
 
533
                set_client_prop("LastCheckedOK", "")
 
534
            if options.start_checker:
 
535
                set_client_prop("CheckerRunning", dbus.Boolean(True))
 
536
            if options.stop_checker:
 
537
                set_client_prop("CheckerRunning", dbus.Boolean(False))
 
538
            if options.is_enabled:
 
539
                if client.Get(client_interface, "Enabled",
 
540
                              dbus_interface=dbus.PROPERTIES_IFACE):
 
541
                    sys.exit(0)
 
542
                else:
 
543
                    sys.exit(1)
 
544
            if options.checker is not None:
 
545
                set_client_prop("Checker", options.checker)
 
546
            if options.host is not None:
 
547
                set_client_prop("Host", options.host)
 
548
            if options.interval is not None:
 
549
                set_client_prop_ms("Interval", options.interval)
 
550
            if options.approval_delay is not None:
 
551
                set_client_prop_ms("ApprovalDelay",
 
552
                                   options.approval_delay)
 
553
            if options.approval_duration is not None:
 
554
                set_client_prop_ms("ApprovalDuration",
 
555
                                   options.approval_duration)
 
556
            if options.timeout is not None:
 
557
                set_client_prop_ms("Timeout", options.timeout)
 
558
            if options.extended_timeout is not None:
 
559
                set_client_prop_ms("ExtendedTimeout",
 
560
                                   options.extended_timeout)
 
561
            if options.secret is not None:
 
562
                set_client_prop("Secret",
 
563
                                dbus.ByteArray(options.secret.read()))
 
564
            if options.approved_by_default is not None:
 
565
                set_client_prop("ApprovedByDefault",
 
566
                                dbus.Boolean(options
 
567
                                             .approved_by_default))
 
568
            if options.approve:
 
569
                client.Approve(dbus.Boolean(True),
 
570
                               dbus_interface=client_interface)
 
571
            elif options.deny:
 
572
                client.Approve(dbus.Boolean(False),
 
573
                               dbus_interface=client_interface)
720
574
 
721
575
 
722
576
class Test_milliseconds_to_string(unittest.TestCase):
742
596
            with self.assertLogs(log, logging.WARNING):
743
597
                value = string_to_delta("2h")
744
598
        else:
745
 
            class WarningFilter(logging.Filter):
746
 
                """Don't show, but record the presence of, warnings"""
747
 
                def filter(self, record):
748
 
                    is_warning = record.levelno >= logging.WARNING
749
 
                    self.found = is_warning or getattr(self, "found",
750
 
                                                       False)
751
 
                    return not is_warning
752
 
            warning_filter = WarningFilter()
753
 
            log.addFilter(warning_filter)
754
 
            try:
755
 
                value = string_to_delta("2h")
756
 
            finally:
757
 
                log.removeFilter(warning_filter)
758
 
            self.assertTrue(getattr(warning_filter, "found", False))
 
599
            value = string_to_delta("2h")
759
600
        self.assertEqual(value, datetime.timedelta(0, 7200))
760
601
 
761
602
class Test_TableOfClients(unittest.TestCase):
762
603
    def setUp(self):
763
 
        self.tableheaders = {
 
604
        self.tablewords = {
764
605
            "Attr1": "X",
765
606
            "AttrTwo": "Yy",
766
607
            "AttrThree": "Zzz",
807
648
        ]
808
649
    def test_short_header(self):
809
650
        text = str(TableOfClients(self.clients, self.keywords,
810
 
                                  self.tableheaders))
 
651
                                  self.tablewords))
811
652
        expected_text = """
812
653
X  Yy
813
654
x1 y1
817
658
    def test_booleans(self):
818
659
        keywords = ["Bool", "NonDbusBoolean"]
819
660
        text = str(TableOfClients(self.clients, keywords,
820
 
                                  self.tableheaders))
 
661
                                  self.tablewords))
821
662
        expected_text = """
822
663
A D-BUS Boolean A Non-D-BUS Boolean
823
664
No              False              
828
669
        keywords = ["Integer", "Timeout", "Interval", "ApprovalDelay",
829
670
                    "ApprovalDuration", "ExtendedTimeout"]
830
671
        text = str(TableOfClients(self.clients, keywords,
831
 
                                  self.tableheaders))
 
672
                                  self.tablewords))
832
673
        expected_text = """
833
674
An Integer Timedelta 1 Timedelta 2 Timedelta 3 Timedelta 4 Timedelta 5
834
675
0          00:00:00    00:00:01    00:00:02    00:00:03    00:00:04   
838
679
    def test_empty_and_long_string_values(self):
839
680
        keywords = ["String"]
840
681
        text = str(TableOfClients(self.clients, keywords,
841
 
                                  self.tableheaders))
 
682
                                  self.tablewords))
842
683
        expected_text = """
843
684
A String                                                                                                                                                                                                                                                                                                                                  
844
685