Files
certbot-certbot/certbot/cert_manager.py
T
Brad WarrenandGitHub 9c9004aff1 Stop IDisplay AssertionErrors (#4010)
Fixes #3996.

I'm pretty confident this PR solves the problem. I've audited all calls to IDisplay methods and the assertions done in certbot.display.util are now done in all our unit tests.

With that said, it wouldn't hurt to have someone else double check I didn't miss anything. The easiest way to do this is to grep for IDisplay in our code and ensure all calls to IDisplay methods are valid. This means every method call other than notification (because a notification call is always OK) either provides a value for default or force_interactive. This is defined in interfaces.py.

I've also been considering removing the assertion that's been causing us trouble here from our release. The only argument I have for not doing so is it may hinder 3rd party plugin development. When they use IDisplay, they have the same problem as we do with prompting users without a TTY. Not keeping this assertion in makes it more likely they won't notice the issue and Certbot will crash on an unsuspecting user.

With that said, none of our known 3rd party plugins use IDisplay at all.

* Provide force_interactive in _get_certname

* Use force_interactive when asking for webroot

* Factor IDisplay assertion into it's own function

* Add util.patch_get_utility()

* Allow custom path to patch_get_utiity

* Change GetEmailTest to use patch_get_utility

* Use new_callable to create new objects

* Modify tests to use patch_get_utility

* Improve FreezableMock documentation

* Add user facing error to TTY magic

* Comment out assert_valid_call

* Add test_input_assertion_fail2()
2017-01-10 16:25:33 -08:00

254 lines
9.6 KiB
Python

"""Tools for managing certificates."""
import datetime
import logging
import os
import pytz
import traceback
import zope.component
from certbot import errors
from certbot import interfaces
from certbot import ocsp
from certbot import storage
from certbot import util
from certbot.display import util as display_util
logger = logging.getLogger(__name__)
###################
# Commands
###################
def update_live_symlinks(config):
"""Update the certificate file family symlinks to use archive_dir.
Use the information in the config file to make symlinks point to
the correct archive directory.
.. note:: This assumes that the installation is using a Reverter object.
:param config: Configuration.
:type config: :class:`certbot.configuration.NamespaceConfig`
"""
for renewal_file in storage.renewal_conf_files(config):
storage.RenewableCert(renewal_file, config, update_symlinks=True)
def rename_lineage(config):
"""Rename the specified lineage to the new name.
:param config: Configuration.
:type config: :class:`certbot.configuration.NamespaceConfig`
"""
disp = zope.component.getUtility(interfaces.IDisplay)
certname = _get_certname(config, "rename")
new_certname = config.new_certname
if not new_certname:
code, new_certname = disp.input(
"Enter the new name for certificate {0}".format(certname),
flag="--updated-cert-name", force_interactive=True)
if code != display_util.OK or not new_certname:
raise errors.Error("User ended interaction.")
lineage = lineage_for_certname(config, certname)
if not lineage:
raise errors.ConfigurationError("No existing certificate with name "
"{0} found.".format(certname))
storage.rename_renewal_config(certname, new_certname, config)
disp.notification("Successfully renamed {0} to {1}."
.format(certname, new_certname), pause=False)
def certificates(config):
"""Display information about certs configured with Certbot
:param config: Configuration.
:type config: :class:`certbot.configuration.NamespaceConfig`
"""
parsed_certs = []
parse_failures = []
for renewal_file in storage.renewal_conf_files(config):
try:
renewal_candidate = storage.RenewableCert(renewal_file, 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(config, parsed_certs, parse_failures)
def delete(config):
"""Delete Certbot files associated with a certificate lineage."""
certname = _get_certname(config, "delete")
storage.delete_files(config, certname)
disp = zope.component.getUtility(interfaces.IDisplay)
disp.notification("Deleted all files relating to certificate {0}."
.format(certname), pause=False)
###################
# Public Helpers
###################
def lineage_for_certname(config, certname):
"""Find a lineage object with name certname."""
def update_cert_for_name_match(candidate_lineage, rv):
"""Return cert if it has name certname, else return rv
"""
matching_lineage_name_cert = rv
if candidate_lineage.lineagename == certname:
matching_lineage_name_cert = candidate_lineage
return matching_lineage_name_cert
return _search_lineages(config, update_cert_for_name_match, None)
def domains_for_certname(config, certname):
"""Find the domains in the cert with name certname."""
def update_domains_for_name_match(candidate_lineage, rv):
"""Return domains if certname matches, else return rv
"""
matching_domains = rv
if candidate_lineage.lineagename == certname:
matching_domains = candidate_lineage.names()
return matching_domains
return _search_lineages(config, update_domains_for_name_match, None)
def find_duplicative_certs(config, domains):
"""Find existing certs that duplicate the request."""
def update_certs_for_domain_matches(candidate_lineage, rv):
"""Return cert as identical_names_cert if it matches,
or subset_names_cert if it matches as subset
"""
# TODO: Handle these differently depending on whether they are
# expired or still valid?
identical_names_cert, subset_names_cert = rv
candidate_names = set(candidate_lineage.names())
if candidate_names == set(domains):
identical_names_cert = candidate_lineage
elif candidate_names.issubset(set(domains)):
# This logic finds and returns the largest subset-names cert
# in the case where there are several available.
if subset_names_cert is None:
subset_names_cert = candidate_lineage
elif len(candidate_names) > len(subset_names_cert.names()):
subset_names_cert = candidate_lineage
return (identical_names_cert, subset_names_cert)
return _search_lineages(config, update_certs_for_domain_matches, (None, None))
###################
# Private Helpers
###################
def _get_certname(config, verb):
"""Get certname from flag, interactively, or error out.
"""
certname = config.certname
if not certname:
disp = zope.component.getUtility(interfaces.IDisplay)
filenames = storage.renewal_conf_files(config)
choices = [storage.lineagename_for_filename(name) for name in filenames]
if not choices:
raise errors.Error("No existing certificates found.")
code, index = disp.menu("Which certificate would you like to {0}?".format(verb),
choices, ok_label="Select", flag="--cert-name",
force_interactive=True)
if code != display_util.OK or not index in range(0, len(choices)):
raise errors.Error("User ended interaction.")
certname = choices[index]
return certname
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(config, parsed_certs):
"""Format a results report for a parsed cert"""
certinfo = []
checker = ocsp.RevocationChecker()
for cert in parsed_certs:
if config.certname and cert.lineagename != config.certname:
continue
if config.domains and not set(config.domains).issubset(cert.names()):
continue
now = pytz.UTC.fromutc(datetime.datetime.utcnow())
reasons = []
if cert.is_test_cert:
reasons.append('TEST_CERT')
if cert.target_expiry <= now:
reasons.append('EXPIRED')
if checker.ocsp_revoked(cert.cert, cert.chain):
reasons.append('REVOKED')
if reasons:
status = "INVALID: " + ", ".join(reasons)
else:
diff = cert.target_expiry - now
if diff.days == 1:
status = "VALID: 1 day"
elif diff.days < 1:
status = "VALID: {0} hour(s)".format(diff.seconds // 3600)
else:
status = "VALID: {0} days".format(diff.days)
valid_string = "{0} ({1})".format(cert.target_expiry, status)
certinfo.append(" Certificate Name: {0}\n"
" Domains: {1}\n"
" Expiry Date: {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(config, 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:
match = "matching " if config.certname or config.domains else ""
notify("Found the following {0}certs:".format(match))
notify(_report_human_readable(config, 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 _search_lineages(cli_config, func, initial_rv):
"""Iterate func over unbroken lineages, allowing custom return conditions.
Allows flexible customization of return values, including multiple
return values and complex checks.
"""
configs_dir = cli_config.renewal_configs_dir
# Verify the directory is there
util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
rv = initial_rv
for renewal_file in storage.renewal_conf_files(cli_config):
try:
candidate_lineage = storage.RenewableCert(renewal_file, cli_config)
except (errors.CertStorageError, IOError):
logger.debug("Renewal conf file %s is broken. Skipping.", renewal_file)
logger.debug("Traceback was:\n%s", traceback.format_exc())
continue
rv = func(candidate_lineage, rv)
return rv