From af46f644a71b9778e4295a84c3e22e75bbb55f6c Mon Sep 17 00:00:00 2001 From: Erica Portnoy Date: Tue, 8 Nov 2016 15:21:42 -0800 Subject: [PATCH] Add list-certs command (#3669) * Switch to using absolute path in symlink * save archive_dir to config and read it back * cli_config.archive_dir --> cli_config.default_archive_dir * Use archive_dir specified in renewal config file * add helpful broken symlink info * add docstring to method * Add tests * remove extraneous test imports * fix tests * py2.6 syntax fix * git problems * Add list-certs command * no dict comprehension in python2.6 * add test coverage * More py26 wrangling * update tests for py3 and lint * remove extra dep from test * test coverage * test shouldn't be based on dict representation order * Redo report UX and add tests to cover * remove storage str test * lint and use mock properly * mock properly * address code review comments * lineage --> certificate name and print fullchain and privkey paths * make py26 happy * actually make py26 happy * don't wrap text --- certbot/cert_manager.py | 84 ++++++++++++++++++++++++++ certbot/cli.py | 4 +- certbot/main.py | 5 ++ certbot/renewal.py | 2 +- certbot/storage.py | 12 +++- certbot/tests/cert_manager_test.py | 97 +++++++++++++++++++++++++++--- certbot/tests/cli_test.py | 5 ++ certbot/tests/storage_test.py | 1 - 8 files changed, 197 insertions(+), 13 deletions(-) diff --git a/certbot/cert_manager.py b/certbot/cert_manager.py index 94714bc98..a3237253e 100644 --- a/certbot/cert_manager.py +++ b/certbot/cert_manager.py @@ -1,8 +1,17 @@ """Tools for managing certificates.""" +import datetime +import logging +import pytz +import traceback +import zope.component + from certbot import configuration +from certbot import interfaces from certbot import renewal from certbot import storage +logger = logging.getLogger(__name__) + def update_live_symlinks(config): """Update the certificate file family symlinks to use archive_dir. @@ -20,3 +29,78 @@ def update_live_symlinks(config): storage.RenewableCert(renewal_file, configuration.RenewerConfiguration(renewer_config), update_symlinks=True) + +def _report_lines(msgs): + """Format a results report for a category of single-line renewal outcomes""" + return " " + "\n ".join(str(msg) for msg in msgs) + +def _report_human_readable(parsed_certs): + """Format a results report for a parsed cert""" + certinfo = [] + for cert in parsed_certs: + now = pytz.UTC.fromutc(datetime.datetime.utcnow()) + if cert.target_expiry <= now: + expiration_text = "EXPIRED" + else: + diff = cert.target_expiry - now + if diff.days == 1: + expiration_text = "1 day" + elif diff.days < 1: + expiration_text = "under 1 day" + else: + expiration_text = "{0} days".format(diff.days) + valid_string = "{0} ({1})".format(cert.target_expiry, expiration_text) + certinfo.append(" Certificate Name: {0}\n" + " Domains: {1}\n" + " Valid Until: {2}\n" + " Certificate Path: {3}\n" + " Private Key Path: {4}".format( + cert.lineagename, + " ".join(cert.names()), + valid_string, + cert.fullchain, + cert.privkey)) + return "\n".join(certinfo) + +def _describe_certs(parsed_certs, parse_failures): + """Print information about the certs we know about""" + out = [] + + notify = out.append + + if not parsed_certs and not parse_failures: + notify("No certs found.") + else: + if parsed_certs: + notify("Found the following certs:") + notify(_report_human_readable(parsed_certs)) + if parse_failures: + notify("\nThe following renewal configuration files " + "were invalid:") + notify(_report_lines(parse_failures)) + + disp = zope.component.getUtility(interfaces.IDisplay) + disp.notification("\n".join(out), pause=False, wrap=False) + +def certificates(config): + """Display information about certs configured with Certbot + + :param config: Configuration. + :type config: :class:`certbot.interfaces.IConfig` + """ + renewer_config = configuration.RenewerConfiguration(config) + parsed_certs = [] + parse_failures = [] + for renewal_file in renewal.renewal_conf_files(renewer_config): + try: + renewal_candidate = storage.RenewableCert(renewal_file, + configuration.RenewerConfiguration(config)) + parsed_certs.append(renewal_candidate) + except Exception as e: # pylint: disable=broad-except + logger.warning("Renewal configuration file %s produced an " + "unexpected error: %s. Skipping.", renewal_file, e) + logger.debug("Traceback was:\n%s", traceback.format_exc()) + parse_failures.append(renewal_file) + + # Describe all the certs + _describe_certs(parsed_certs, parse_failures) diff --git a/certbot/cli.py b/certbot/cli.py index 41afa1391..fa3bcc48b 100644 --- a/certbot/cli.py +++ b/certbot/cli.py @@ -69,6 +69,7 @@ cert. Major SUBCOMMANDS are: config_changes Show changes made to server config during installation update_symlinks Update cert symlinks based on renewal config file plugins Display information about installed plugins + certificates Display information about certs configured with Certbot """.format(cli_command) @@ -324,7 +325,8 @@ class HelpfulArgumentParser(object): "install": main.install, "plugins": main.plugins_cmd, "register": main.register, "renew": main.renew, "revoke": main.revoke, "rollback": main.rollback, - "everything": main.run, "update_symlinks": main.update_symlinks} + "everything": main.run, "update_symlinks": main.update_symlinks, + "certificates": main.certificates} # List of topics for which additional help can be provided HELP_TOPICS = ["all", "security", "paths", "automation", "testing"] + list(self.VERBS) diff --git a/certbot/main.py b/certbot/main.py index b651249fa..39c19bd7a 100644 --- a/certbot/main.py +++ b/certbot/main.py @@ -484,6 +484,11 @@ def update_symlinks(config, unused_plugins): """ cert_manager.update_live_symlinks(config) +def certificates(config, unused_plugins): + """Display information about certs configured with Certbot + """ + cert_manager.certificates(config) + def revoke(config, unused_plugins): # TODO: coop with renewal config """Revoke a previously obtained certificate.""" # For user-agent construction diff --git a/certbot/renewal.py b/certbot/renewal.py index f5b2efa46..aa39c5fad 100644 --- a/certbot/renewal.py +++ b/certbot/renewal.py @@ -44,7 +44,7 @@ def _reconstitute(config, full_path): """Try to instantiate a RenewableCert, updating config with relevant items. This is specifically for use in renewal and enforces several checks - and policies to ensure that we can try to proceed with the renwal + and policies to ensure that we can try to proceed with the renewal request. The config argument is modified by including relevant options read from the renewal configuration file. diff --git a/certbot/storage.py b/certbot/storage.py index 5d7eeb88a..7b2f575b7 100644 --- a/certbot/storage.py +++ b/certbot/storage.py @@ -263,6 +263,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self._update_symlinks() self._check_symlinks() + @property + def target_expiry(self): + """The current target certificate's expiration datetime + + :returns: Expiration datetime of the current target certificate + :rtype: :class:`datetime.datetime` + """ + return crypto_util.notAfter(self.current_target("cert")) + @property def archive_dir(self): """Returns the default or specified archive directory""" @@ -671,9 +680,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if self.has_pending_deployment(): interval = self.configuration.get("deploy_before_expiry", "5 days") - expiry = crypto_util.notAfter(self.current_target("cert")) now = pytz.UTC.fromutc(datetime.datetime.utcnow()) - if expiry < add_time_interval(now, interval): + if self.target_expiry < add_time_interval(now, interval): return True return False diff --git a/certbot/tests/cert_manager_test.py b/certbot/tests/cert_manager_test.py index 46f555aac..c67ab5e50 100644 --- a/certbot/tests/cert_manager_test.py +++ b/certbot/tests/cert_manager_test.py @@ -8,25 +8,21 @@ import unittest import configobj import mock -from certbot import configuration from certbot.storage import ALL_FOUR -class CertManagerTest(unittest.TestCase): - """Tests for certbot.cert_manager +class BaseCertManagerTest(unittest.TestCase): + """Base class for setting up Cert Manager tests. """ def setUp(self): self.tempdir = tempfile.mkdtemp() os.makedirs(os.path.join(self.tempdir, "renewal")) - mock_namespace = mock.MagicMock( + self.cli_config = mock.MagicMock( config_dir=self.tempdir, work_dir=self.tempdir, logs_dir=self.tempdir, - ) - - self.cli_config = configuration.RenewerConfiguration( - namespace=mock_namespace + quiet=False, ) self.domains = { @@ -67,6 +63,9 @@ class CertManagerTest(unittest.TestCase): def tearDown(self): shutil.rmtree(self.tempdir) +class UpdateLiveSymlinksTest(BaseCertManagerTest): + """Tests for certbot.cert_manager.update_live_symlinks + """ def test_update_live_symlinks(self): """Test update_live_symlinks""" # pylint: disable=too-many-statements @@ -97,5 +96,87 @@ class CertManagerTest(unittest.TestCase): self.assertEqual(os.readlink(self.configs[domain][kind]), archive_paths[domain][kind]) +class CertificatesTest(BaseCertManagerTest): + """Tests for certbot.cert_manager.certificates + """ + def _certificates(self, *args, **kwargs): + from certbot.cert_manager import certificates + return certificates(*args, **kwargs) + + @mock.patch('certbot.cert_manager.logger') + @mock.patch('zope.component.getUtility') + def test_certificates_parse_fail(self, mock_utility, mock_logger): + self._certificates(self.cli_config) + self.assertTrue(mock_logger.warning.called) #pylint: disable=no-member + self.assertTrue(mock_utility.called) + + @mock.patch('certbot.cert_manager.logger') + @mock.patch('zope.component.getUtility') + def test_certificates_quiet(self, mock_utility, mock_logger): + self.cli_config.quiet = True + self._certificates(self.cli_config) + self.assertFalse(mock_utility.notification.called) + self.assertTrue(mock_logger.warning.called) #pylint: disable=no-member + + @mock.patch('certbot.cert_manager.logger') + @mock.patch('zope.component.getUtility') + @mock.patch("certbot.storage.RenewableCert") + @mock.patch('certbot.cert_manager._report_human_readable') + def test_certificates_parse_success(self, mock_report, mock_renewable_cert, + mock_utility, mock_logger): + mock_report.return_value = "" + self._certificates(self.cli_config) + self.assertFalse(mock_logger.warning.called) #pylint: disable=no-member + self.assertTrue(mock_report.called) + self.assertTrue(mock_utility.called) + self.assertTrue(mock_renewable_cert.called) + + @mock.patch('certbot.cert_manager.logger') + @mock.patch('zope.component.getUtility') + def test_certificates_no_files(self, mock_utility, mock_logger): + tempdir = tempfile.mkdtemp() + + cli_config = mock.MagicMock( + config_dir=tempdir, + work_dir=tempdir, + logs_dir=tempdir, + quiet=False, + ) + + os.makedirs(os.path.join(tempdir, "renewal")) + self._certificates(cli_config) + self.assertFalse(mock_logger.warning.called) #pylint: disable=no-member + self.assertTrue(mock_utility.called) + shutil.rmtree(tempdir) + + def test_report_human_readable(self): + from certbot import cert_manager + import datetime, pytz + expiry = pytz.UTC.fromutc(datetime.datetime.utcnow()) + + cert = mock.MagicMock(lineagename="nameone") + cert.target_expiry = expiry + cert.names.return_value = ["nameone", "nametwo"] + parsed_certs = [cert] + # pylint: disable=protected-access + out = cert_manager._report_human_readable(parsed_certs) + self.assertTrue('EXPIRED' in out) + + cert.target_expiry += datetime.timedelta(hours=2) + # pylint: disable=protected-access + out = cert_manager._report_human_readable(parsed_certs) + self.assertTrue('under 1 day' in out) + + cert.target_expiry += datetime.timedelta(days=1) + # pylint: disable=protected-access + out = cert_manager._report_human_readable(parsed_certs) + self.assertTrue('1 day' in out) + self.assertFalse('under' in out) + + cert.target_expiry += datetime.timedelta(days=2) + # pylint: disable=protected-access + out = cert_manager._report_human_readable(parsed_certs) + self.assertTrue('3 days' in out) + if __name__ == "__main__": unittest.main() # pragma: no cover diff --git a/certbot/tests/cli_test.py b/certbot/tests/cli_test.py index 63abe6451..8d4d0af62 100644 --- a/certbot/tests/cli_test.py +++ b/certbot/tests/cli_test.py @@ -277,6 +277,11 @@ class CLITest(unittest.TestCase): # pylint: disable=too-many-public-methods self._call_no_clientmock(['update_symlinks']) self.assertEqual(1, mock_cert_manager.call_count) + @mock.patch('certbot.cert_manager.certificates') + def test_certificates(self, mock_cert_manager): + self._call_no_clientmock(['certificates']) + self.assertEqual(1, mock_cert_manager.call_count) + def test_plugins(self): flags = ['--init', '--prepare', '--authenticators', '--installers'] for args in itertools.chain( diff --git a/certbot/tests/storage_test.py b/certbot/tests/storage_test.py index fb33a1864..4d7323e66 100644 --- a/certbot/tests/storage_test.py +++ b/certbot/tests/storage_test.py @@ -751,6 +751,5 @@ class RenewableCertTests(BaseRenewableCertTest): storage.RenewableCert(self.config.filename, self.cli_config, update_symlinks=True) - if __name__ == "__main__": unittest.main() # pragma: no cover