Skip to content

Commit

Permalink
Add list-certs command (certbot#3669)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ohemorange authored and bmw committed Nov 8, 2016
1 parent a7bfefc commit af46f64
Show file tree
Hide file tree
Showing 8 changed files with 197 additions and 13 deletions.
84 changes: 84 additions & 0 deletions certbot/cert_manager.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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)
4 changes: 3 additions & 1 deletion certbot/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
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)

Expand Down Expand Up @@ -324,7 +325,8 @@ def __init__(self, args, plugins, detect_defaults=False):
"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)
Expand Down
5 changes: 5 additions & 0 deletions certbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion certbot/renewal.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 10 additions & 2 deletions certbot/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,15 @@ def __init__(self, config_filename, cli_config, update_symlinks=False):
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"""
Expand Down Expand Up @@ -671,9 +680,8 @@ def should_autodeploy(self, interactive=False):
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

Expand Down
97 changes: 89 additions & 8 deletions certbot/tests/cert_manager_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,21 @@
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 = {
Expand Down Expand Up @@ -67,6 +63,9 @@ def _set_up_config(self, domain, custom_archive):
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
Expand Down Expand Up @@ -97,5 +96,87 @@ def test_update_live_symlinks(self):
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
5 changes: 5 additions & 0 deletions certbot/tests/cli_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,11 @@ def test_update_symlinks(self, mock_cert_manager):
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(
Expand Down
1 change: 0 additions & 1 deletion certbot/tests/storage_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,6 +751,5 @@ def test_update_symlinks(self):
storage.RenewableCert(self.config.filename, self.cli_config,
update_symlinks=True)


if __name__ == "__main__":
unittest.main() # pragma: no cover

0 comments on commit af46f64

Please sign in to comment.