Files
certbot-certbot-2/certbot/renewal.py
T
Adrien FerrandandBrad Warren 1e8c13ebf9 [Windows] Create the CI logic (#6374)
So here we are: after #6361 has been merged, time is to provide an environment to execute the automated testing on Windows.

Here are the assertions used to build the CI on Windows:

every test running on Linux should ultimately be runnable on Windows, in a cross-platform compatible manner (there is one or two exception, when a test does not have any meaning for Windows),
currently some tests are not runnable on Windows: theses tests are ignored by default when the environment is Windows using a custom decorator: @broken_on_windows,
test environment should have functionalities similar to Travis, in particular an execution test matrix against various versions of Python and Windows,
so test execution is done through AppVeyor, as it supports the requirements: it add a CI step along Travis and Codecov for each PR, all of this ensuring that Certbot is entirely functional on both Linux and Windows,
code in tests can be changed, but code in Certbot should be changed as little as possible, to avoid regression risks.
So far in this PR, I focused on the tests on Certbot core and ACME library. Concerning the plugins, it will be done later, for plugins which have an interest on Windows. Test are executed against Python 3.4, 3.5, 3.6 and 3.7, for Windows Server 2012 R2 and Windows Server 2016.

I succeeded at making 258/259 of acme tests to work, and 828/868 of certbot core tests to work. Most of the errors where not because of Certbot itself, but because of how the tests are written. After redesigning some test utilitaries, and things like file path handling, or CRLF/LF, a lot of the errors vanished.

I needed also to ignore a lot of IO errors typically occurring when a tearDown test process tries to delete a file before it has been closed: this kind of behavior is acceptable for Linux, but not for Windows. As a consequence, and until the tearDown process is improved, a lot of temporary files are not cleared on Windows after a test campaign.

Remaining broken tests requires a more subtile approach to solve the errors, I will correct them progressively in future PR.

Last words about tox. I did not used the existing tox.ini for now. It is just to far from what is supported on Windows: lot of bash scripts that should be rewritten completely, and that contain test logic not ready/relevant for Windows (plugin tests, Docker compilation/test, GNU distribution versatility handling and so on). So I use an independent file tox-win.ini for now, with the goal to merge it ultimately with the existing logic.

* Define a tox configuration for windows, to execute tests against Python 3.4, 3.5, 3.6 and 3.7 + code coverage on Codecov.io

* Correct windows compatibility on certbot codebase

* Correct windows compatibility on certbot display functionalities

* Correct windows compatibility on certbot plugins

* Correct test utils to run tests on windows. Add decorator to skip (permanently) or mark broken (temporarily) tests on windows

* Correct tests on certbot core to run them both on windows and linux. Mark some of them as broken on windows for now.

* Lock tests are completely skipped on windows. Planned to be replace in next PR.

* Correct tests on certbot display to run them both on windows and linux. Mark some of them as broken on windows for now.

* Correct test utils for acme on windows. Add decorator to skip (permanently) or mark broken (temporarily) tests on windows.

* Correct acme tests to run them both on windows and linux. Allow a reduction of code coverage of 1% on acme code base.

* Create AppVeyor CI for Certbot on Windows, to run the test matrix (py34,35,36,37+coverage) on Windows Server 2012 R2 and Windows Server 2016.

* Update changelog with Windows compatibility of Certbot.

* Corrections about tox, pyreadline and CI logic

* Correct english

* Some corrections for acme

* Newlines corrections

* Remove changelog

* Use os.devnull instead of /dev/null to be used on Windows

* Uid is a always a number now.

* Correct linting

* PR https://github.com/python/typeshed/pull/2136 has been merge to third-party upstream 6 months ago, so code patch can be removed.

* And so acme coverage should be 100% again.

* More compatible tests Windows+Linux

* Use stable line separator

* Remove unused import

* Do not rely on pytest in certbot tests

* Use json.dumps to another json embedding weird characters

* Change comment

* Add import

* Test rolling builds #1

* Test rolling builds #2

* Correction on json serialization

* It seems that rolling builds are not canceling jobs on PR. Revert back to fail fast code in the pipeline.
2018-10-19 14:53:15 -07:00

458 lines
19 KiB
Python

"""Functionality for autorenewal and associated juggling of configurations"""
from __future__ import print_function
import copy
import itertools
import logging
import os
import traceback
import six
import zope.component
import OpenSSL
from acme.magic_typing import List # pylint: disable=unused-import, no-name-in-module
from certbot import cli
from certbot import crypto_util
from certbot import errors
from certbot import interfaces
from certbot import util
from certbot import hooks
from certbot import storage
from certbot import updater
from certbot.plugins import disco as plugins_disco
logger = logging.getLogger(__name__)
# These are the items which get pulled out of a renewal configuration
# file's renewalparams and actually used in the client configuration
# during the renewal process. We have to record their types here because
# the renewal configuration process loses this information.
STR_CONFIG_ITEMS = ["config_dir", "logs_dir", "work_dir", "user_agent",
"server", "account", "authenticator", "installer",
"standalone_supported_challenges", "renew_hook",
"pre_hook", "post_hook", "tls_sni_01_address",
"http01_address"]
INT_CONFIG_ITEMS = ["rsa_key_size", "tls_sni_01_port", "http01_port"]
BOOL_CONFIG_ITEMS = ["must_staple", "allow_subset_of_names", "reuse_key",
"autorenew"]
CONFIG_ITEMS = set(itertools.chain(
BOOL_CONFIG_ITEMS, INT_CONFIG_ITEMS, STR_CONFIG_ITEMS, ('pref_challs',)))
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 renewal
request. The config argument is modified by including relevant options
read from the renewal configuration file.
:param configuration.NamespaceConfig config: configuration for the
current lineage
:param str full_path: Absolute path to the configuration file that
defines this lineage
:returns: the RenewableCert object or None if a fatal error occurred
:rtype: `storage.RenewableCert` or NoneType
"""
try:
renewal_candidate = storage.RenewableCert(full_path, config)
except (errors.CertStorageError, IOError):
logger.warning("", exc_info=True)
logger.warning("Renewal configuration file %s is broken. Skipping.", full_path)
logger.debug("Traceback was:\n%s", traceback.format_exc())
return None
if "renewalparams" not in renewal_candidate.configuration:
logger.warning("Renewal configuration file %s lacks "
"renewalparams. Skipping.", full_path)
return None
renewalparams = renewal_candidate.configuration["renewalparams"]
if "authenticator" not in renewalparams:
logger.warning("Renewal configuration file %s does not specify "
"an authenticator. Skipping.", full_path)
return None
# Now restore specific values along with their data types, if
# those elements are present.
try:
restore_required_config_elements(config, renewalparams)
_restore_plugin_configs(config, renewalparams)
except (ValueError, errors.Error) as error:
logger.warning(
"An error occurred while parsing %s. The error was %s. "
"Skipping the file.", full_path, str(error))
logger.debug("Traceback was:\n%s", traceback.format_exc())
return None
try:
config.domains = [util.enforce_domain_sanity(d)
for d in renewal_candidate.names()]
except errors.ConfigurationError as error:
logger.warning("Renewal configuration file %s references a cert "
"that contains an invalid domain name. The problem "
"was: %s. Skipping.", full_path, error)
return None
return renewal_candidate
def _restore_webroot_config(config, renewalparams):
"""
webroot_map is, uniquely, a dict, and the general-purpose configuration
restoring logic is not able to correctly parse it from the serialized
form.
"""
if "webroot_map" in renewalparams:
if not cli.set_by_cli("webroot_map"):
config.webroot_map = renewalparams["webroot_map"]
elif "webroot_path" in renewalparams:
logger.debug("Ancient renewal conf file without webroot-map, restoring webroot-path")
wp = renewalparams["webroot_path"]
if isinstance(wp, six.string_types): # prior to 0.1.0, webroot_path was a string
wp = [wp]
config.webroot_path = wp
def _restore_plugin_configs(config, renewalparams):
"""Sets plugin specific values in config from renewalparams
:param configuration.NamespaceConfig config: configuration for the
current lineage
:param configobj.Section renewalparams: Parameters from the renewal
configuration file that defines this lineage
"""
# Now use parser to get plugin-prefixed items with correct types
# XXX: the current approach of extracting only prefixed items
# related to the actually-used installer and authenticator
# works as long as plugins don't need to read plugin-specific
# variables set by someone else (e.g., assuming Apache
# configurator doesn't need to read webroot_ variables).
# Note: if a parameter that used to be defined in the parser is no
# longer defined, stored copies of that parameter will be
# deserialized as strings by this logic even if they were
# originally meant to be some other type.
plugin_prefixes = [] # type: List[str]
if renewalparams["authenticator"] == "webroot":
_restore_webroot_config(config, renewalparams)
else:
plugin_prefixes.append(renewalparams["authenticator"])
if renewalparams.get("installer") is not None:
plugin_prefixes.append(renewalparams["installer"])
for plugin_prefix in set(plugin_prefixes):
plugin_prefix = plugin_prefix.replace('-', '_')
for config_item, config_value in six.iteritems(renewalparams):
if config_item.startswith(plugin_prefix + "_") and not cli.set_by_cli(config_item):
# Values None, True, and False need to be treated specially,
# As their types aren't handled correctly by configobj
if config_value in ("None", "True", "False"):
# bool("False") == True
# pylint: disable=eval-used
setattr(config, config_item, eval(config_value))
else:
cast = cli.argparse_type(config_item)
setattr(config, config_item, cast(config_value))
def restore_required_config_elements(config, renewalparams):
"""Sets non-plugin specific values in config from renewalparams
:param configuration.NamespaceConfig config: configuration for the
current lineage
:param configobj.Section renewalparams: parameters from the renewal
configuration file that defines this lineage
"""
required_items = itertools.chain(
(("pref_challs", _restore_pref_challs),),
six.moves.zip(BOOL_CONFIG_ITEMS, itertools.repeat(_restore_bool)),
six.moves.zip(INT_CONFIG_ITEMS, itertools.repeat(_restore_int)),
six.moves.zip(STR_CONFIG_ITEMS, itertools.repeat(_restore_str)))
for item_name, restore_func in required_items:
if item_name in renewalparams and not cli.set_by_cli(item_name):
value = restore_func(item_name, renewalparams[item_name])
setattr(config, item_name, value)
def _restore_pref_challs(unused_name, value):
"""Restores preferred challenges from a renewal config file.
If value is a `str`, it should be a single challenge type.
:param str unused_name: option name
:param value: option value
:type value: `list` of `str` or `str`
:returns: converted option value to be stored in the runtime config
:rtype: `list` of `str`
:raises errors.Error: if value can't be converted to an bool
"""
# If pref_challs has only one element, configobj saves the value
# with a trailing comma so it's parsed as a list. If this comma is
# removed by the user, the value is parsed as a str.
value = [value] if isinstance(value, six.string_types) else value
return cli.parse_preferred_challenges(value)
def _restore_bool(name, value):
"""Restores an boolean key-value pair from a renewal config file.
:param str name: option name
:param str value: option value
:returns: converted option value to be stored in the runtime config
:rtype: bool
:raises errors.Error: if value can't be converted to an bool
"""
lowercase_value = value.lower()
if lowercase_value not in ("true", "false"):
raise errors.Error(
"Expected True or False for {0} but found {1}".format(name, value))
return lowercase_value == "true"
def _restore_int(name, value):
"""Restores an integer key-value pair from a renewal config file.
:param str name: option name
:param str value: option value
:returns: converted option value to be stored in the runtime config
:rtype: int
:raises errors.Error: if value can't be converted to an int
"""
if name == "http01_port" and value == "None":
logger.info("updating legacy http01_port value")
return cli.flag_default("http01_port")
try:
return int(value)
except ValueError:
raise errors.Error("Expected a numeric value for {0}".format(name))
def _restore_str(unused_name, value):
"""Restores an string key-value pair from a renewal config file.
:param str unused_name: option name
:param str value: option value
:returns: converted option value to be stored in the runtime config
:rtype: str or None
"""
return None if value == "None" else value
def should_renew(config, lineage):
"Return true if any of the circumstances for automatic renewal apply."
if config.renew_by_default:
logger.debug("Auto-renewal forced with --force-renewal...")
return True
if lineage.should_autorenew():
logger.info("Cert is due for renewal, auto-renewing...")
return True
if config.dry_run:
logger.info("Cert not due for renewal, but simulating renewal for dry run")
return True
logger.info("Cert not yet due for renewal")
return False
def _avoid_invalidating_lineage(config, lineage, original_server):
"Do not renew a valid cert with one from a staging server!"
# Some lineages may have begun with --staging, but then had production certs
# added to them
latest_cert = OpenSSL.crypto.load_certificate(
OpenSSL.crypto.FILETYPE_PEM, open(lineage.cert).read())
# all our test certs are from happy hacker fake CA, though maybe one day
# we should test more methodically
now_valid = "fake" not in repr(latest_cert.get_issuer()).lower()
if util.is_staging(config.server):
if not util.is_staging(original_server) or now_valid:
if not config.break_my_certs:
names = ", ".join(lineage.names())
raise errors.Error(
"You've asked to renew/replace a seemingly valid certificate with "
"a test certificate (domains: {0}). We will not do that "
"unless you use the --break-my-certs flag!".format(names))
def renew_cert(config, domains, le_client, lineage):
"Renew a certificate lineage."
renewal_params = lineage.configuration["renewalparams"]
original_server = renewal_params.get("server", cli.flag_default("server"))
_avoid_invalidating_lineage(config, lineage, original_server)
if not domains:
domains = lineage.names()
# The private key is the existing lineage private key if reuse_key is set.
# Otherwise, generate a fresh private key by passing None.
new_key = os.path.normpath(lineage.privkey) if config.reuse_key else None
new_cert, new_chain, new_key, _ = le_client.obtain_certificate(domains, new_key)
if config.dry_run:
logger.debug("Dry run: skipping updating lineage at %s",
os.path.dirname(lineage.cert))
else:
prior_version = lineage.latest_common_version()
# TODO: Check return value of save_successor
lineage.save_successor(prior_version, new_cert, new_key.pem, new_chain, config)
lineage.update_all_links_to(lineage.latest_common_version())
hooks.renew_hook(config, domains, lineage.live_dir)
def report(msgs, category):
"Format a results report for a category of renewal outcomes"
lines = ("%s (%s)" % (m, category) for m in msgs)
return " " + "\n ".join(lines)
def _renew_describe_results(config, renew_successes, renew_failures,
renew_skipped, parse_failures):
out = [] # type: List[str]
notify = out.append
disp = zope.component.getUtility(interfaces.IDisplay)
def notify_error(err):
"""Notify and log errors."""
notify(str(err))
logger.error(err)
if config.dry_run:
notify("** DRY RUN: simulating 'certbot renew' close to cert expiry")
notify("** (The test certificates below have not been saved.)")
notify("")
if renew_skipped:
notify("The following certs are not due for renewal yet:")
notify(report(renew_skipped, "skipped"))
if not renew_successes and not renew_failures:
notify("No renewals were attempted.")
if (config.pre_hook is not None or
config.renew_hook is not None or config.post_hook is not None):
notify("No hooks were run.")
elif renew_successes and not renew_failures:
notify("Congratulations, all renewals succeeded. The following certs "
"have been renewed:")
notify(report(renew_successes, "success"))
elif renew_failures and not renew_successes:
notify_error("All renewal attempts failed. The following certs could "
"not be renewed:")
notify_error(report(renew_failures, "failure"))
elif renew_failures and renew_successes:
notify("The following certs were successfully renewed:")
notify(report(renew_successes, "success") + "\n")
notify_error("The following certs could not be renewed:")
notify_error(report(renew_failures, "failure"))
if parse_failures:
notify("\nAdditionally, the following renewal configurations "
"were invalid: ")
notify(report(parse_failures, "parsefail"))
if config.dry_run:
notify("** DRY RUN: simulating 'certbot renew' close to cert expiry")
notify("** (The test certificates above have not been saved.)")
disp.notification("\n".join(out), wrap=False)
def handle_renewal_request(config):
"""Examine each lineage; renew if due and report results"""
# This is trivially False if config.domains is empty
if any(domain not in config.webroot_map for domain in config.domains):
# If more plugins start using cli.add_domains,
# we may want to only log a warning here
raise errors.Error("Currently, the renew verb is capable of either "
"renewing all installed certificates that are due "
"to be renewed or renewing a single certificate specified "
"by its name. If you would like to renew specific "
"certificates by their domains, use the certonly command "
"instead. The renew verb may provide other options "
"for selecting certificates to renew in the future.")
if config.certname:
conf_files = [storage.renewal_file_for_certname(config, config.certname)]
else:
conf_files = storage.renewal_conf_files(config)
renew_successes = []
renew_failures = []
renew_skipped = []
parse_failures = []
for renewal_file in conf_files:
disp = zope.component.getUtility(interfaces.IDisplay)
disp.notification("Processing " + renewal_file, pause=False)
lineage_config = copy.deepcopy(config)
lineagename = storage.lineagename_for_filename(renewal_file)
# Note that this modifies config (to add back the configuration
# elements from within the renewal configuration file).
try:
renewal_candidate = _reconstitute(lineage_config, renewal_file)
except Exception as e: # pylint: disable=broad-except
logger.warning("Renewal configuration file %s (cert: %s) "
"produced an unexpected error: %s. Skipping.",
renewal_file, lineagename, e)
logger.debug("Traceback was:\n%s", traceback.format_exc())
parse_failures.append(renewal_file)
continue
try:
if renewal_candidate is None:
parse_failures.append(renewal_file)
else:
# XXX: ensure that each call here replaces the previous one
zope.component.provideUtility(lineage_config)
renewal_candidate.ensure_deployed()
from certbot import main
plugins = plugins_disco.PluginsRegistry.find_all()
if should_renew(lineage_config, renewal_candidate):
# domains have been restored into lineage_config by reconstitute
# but they're unnecessary anyway because renew_cert here
# will just grab them from the certificate
# we already know it's time to renew based on should_renew
# and we have a lineage in renewal_candidate
main.renew_cert(lineage_config, plugins, renewal_candidate)
renew_successes.append(renewal_candidate.fullchain)
else:
expiry = crypto_util.notAfter(renewal_candidate.version(
"cert", renewal_candidate.latest_common_version()))
renew_skipped.append("%s expires on %s" % (renewal_candidate.fullchain,
expiry.strftime("%Y-%m-%d")))
# Run updater interface methods
updater.run_generic_updaters(lineage_config, renewal_candidate,
plugins)
except Exception as e: # pylint: disable=broad-except
# obtain_cert (presumably) encountered an unanticipated problem.
logger.warning("Attempting to renew cert (%s) from %s produced an "
"unexpected error: %s. Skipping.", lineagename,
renewal_file, e)
logger.debug("Traceback was:\n%s", traceback.format_exc())
renew_failures.append(renewal_candidate.fullchain)
# Describe all the results
_renew_describe_results(config, renew_successes, renew_failures,
renew_skipped, parse_failures)
if renew_failures or parse_failures:
raise errors.Error("{0} renew failure(s), {1} parse failure(s)".format(
len(renew_failures), len(parse_failures)))
else:
logger.debug("no renewal failures")