1
0
mirror of https://github.com/certbot/certbot.git synced 2026-01-21 19:01:07 +03:00

Merge pull request #970 from letsencrypt/storage_paranoia

Storage paranoia
This commit is contained in:
bmw
2015-10-22 13:18:56 -07:00
5 changed files with 144 additions and 93 deletions

View File

@@ -12,7 +12,6 @@ import time
import traceback
import configargparse
import configobj
import OpenSSL
import zope.component
import zope.interface.exceptions
@@ -167,25 +166,21 @@ def _init_le_client(args, config, authenticator, installer):
return client.Client(config, acc, authenticator, installer, acme=acme)
def _find_duplicative_certs(domains, config, renew_config):
def _find_duplicative_certs(config, domains):
"""Find existing certs that duplicate the request."""
identical_names_cert, subset_names_cert = None, None
configs_dir = renew_config.renewal_configs_dir
cli_config = configuration.RenewerConfiguration(config)
configs_dir = cli_config.renewal_configs_dir
# Verify the directory is there
le_util.make_or_verify_dir(configs_dir, mode=0o755, uid=os.geteuid())
cli_config = configuration.RenewerConfiguration(config)
for renewal_file in os.listdir(configs_dir):
try:
full_path = os.path.join(configs_dir, renewal_file)
rc_config = configobj.ConfigObj(renew_config.renewer_config_file)
rc_config.merge(configobj.ConfigObj(full_path))
rc_config.filename = full_path
candidate_lineage = storage.RenewableCert(
rc_config, config_opts=None, cli_config=cli_config)
except (configobj.ConfigObjError, CertStorageError, IOError):
candidate_lineage = storage.RenewableCert(full_path, cli_config)
except (CertStorageError, IOError):
logger.warning("Renewal configuration file %s is broken. "
"Skipping.", full_path)
continue
@@ -217,7 +212,7 @@ def _treat_as_renewal(config, domains):
# kind of certificate to be obtained with renewal = False.)
if not config.duplicate:
ident_names_cert, subset_names_cert = _find_duplicative_certs(
domains, config, configuration.RenewerConfiguration(config))
config, domains)
# I am not sure whether that correctly reads the systemwide
# configuration file.
question = None

View File

@@ -12,7 +12,6 @@ import logging
import os
import sys
import configobj
import OpenSSL
import zope.component
@@ -142,7 +141,7 @@ def _create_parser():
return _paths_parser(parser)
def main(config=None, cli_args=sys.argv[1:]):
def main(cli_args=sys.argv[1:]):
"""Main function for autorenewer script."""
# TODO: Distinguish automated invocation from manual invocation,
# perhaps by looking at sys.argv[0] and inhibiting automated
@@ -150,6 +149,11 @@ def main(config=None, cli_args=sys.argv[1:]):
# turned it off. (The boolean parameter should probably be
# called renewer_enabled.)
# TODO: When we have a more elaborate renewer command line, we will
# presumably also be able to specify a config file on the
# command line, which, if provided, should take precedence over
# te default config files
zope.component.provideUtility(display_util.FileDisplay(sys.stdout))
args = _create_parser().parse_args(cli_args)
@@ -160,27 +164,12 @@ def main(config=None, cli_args=sys.argv[1:]):
cli_config = configuration.RenewerConfiguration(args)
config = storage.config_with_defaults(config)
# Now attempt to read the renewer config file and augment or replace
# the renewer defaults with any options contained in that file. If
# renewer_config_file is undefined or if the file is nonexistent or
# empty, this .merge() will have no effect. TODO: when we have a more
# elaborate renewer command line, we will presumably also be able to
# specify a config file on the command line, which, if provided, should
# take precedence over this one.
config.merge(configobj.ConfigObj(cli_config.renewer_config_file))
# Ensure that all of the needed folders have been created before continuing
le_util.make_or_verify_dir(cli_config.work_dir,
constants.CONFIG_DIRS_MODE, uid)
for i in os.listdir(cli_config.renewal_configs_dir):
print "Processing", i
if not i.endswith(".conf"):
continue
rc_config = configobj.ConfigObj(cli_config.renewer_config_file)
rc_config.merge(configobj.ConfigObj(
os.path.join(cli_config.renewal_configs_dir, i)))
rc_config.filename = os.path.join(cli_config.renewal_configs_dir, i)
for renewal_file in os.listdir(cli_config.renewal_configs_dir):
print "Processing", renewal_file
try:
# TODO: Before trying to initialize the RenewableCert object,
# we could check here whether the combination of the config
@@ -190,7 +179,7 @@ def main(config=None, cli_args=sys.argv[1:]):
# RenewableCert object for this cert at all, which could
# dramatically improve performance for large deployments
# where autorenewal is widely turned off.
cert = storage.RenewableCert(rc_config, cli_config=cli_config)
cert = storage.RenewableCert(renewal_file, cli_config)
except errors.CertStorageError:
# This indicates an invalid renewal configuration file, such
# as one missing a required parameter (in the future, perhaps

View File

@@ -11,6 +11,7 @@ import pytz
from letsencrypt import constants
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import error_handler
from letsencrypt import le_util
ALL_FOUR = ("cert", "privkey", "chain", "fullchain")
@@ -78,55 +79,50 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
renewal configuration file and/or systemwide defaults.
"""
def __init__(self, configfile, config_opts=None, cli_config=None):
def __init__(self, config_filename, cli_config):
"""Instantiate a RenewableCert object from an existing lineage.
:param configobj.ConfigObj configfile: an already-parsed
ConfigObj object made from reading the renewal config file
:param str config_filename: the path to the renewal config file
that defines this lineage.
:param configobj.ConfigObj config_opts: systemwide defaults for
renewal properties not otherwise specified in the individual
renewal config file.
:param .RenewerConfiguration cli_config:
:param .RenewerConfiguration: parsed command line arguments
:raises .CertStorageError: if the configuration file's name didn't end
in ".conf", or the file is missing or broken.
:raises TypeError: if the provided renewal configuration isn't a
ConfigObj object.
"""
self.cli_config = cli_config
if isinstance(configfile, configobj.ConfigObj):
if not os.path.basename(configfile.filename).endswith(".conf"):
raise errors.CertStorageError(
"renewal config file name must end in .conf")
self.lineagename = os.path.basename(
configfile.filename)[:-len(".conf")]
else:
raise TypeError("RenewableCert config must be ConfigObj object")
if not config_filename.endswith(".conf"):
raise errors.CertStorageError(
"renewal config file name must end in .conf")
self.lineagename = os.path.basename(
config_filename[:-len(".conf")])
# self.configuration should be used to read parameters that
# may have been chosen based on default values from the
# systemwide renewal configuration; self.configfile should be
# used to make and save changes.
self.configfile = configfile
try:
self.configfile = configobj.ConfigObj(config_filename)
except configobj.ConfigObjError:
raise errors.CertStorageError(
"error parsing {0}".format(config_filename))
# TODO: Do we actually use anything from defaults and do we want to
# read further defaults from the systemwide renewal configuration
# file at this stage?
self.configuration = config_with_defaults(config_opts)
self.configuration.merge(self.configfile)
self.configuration = config_with_defaults(self.configfile)
if not all(x in self.configuration for x in ALL_FOUR):
raise errors.CertStorageError(
"renewal config file {0} is missing a required "
"file reference".format(configfile))
"file reference".format(self.configfile))
self.cert = self.configuration["cert"]
self.privkey = self.configuration["privkey"]
self.chain = self.configuration["chain"]
self.fullchain = self.configuration["fullchain"]
self._fix_symlinks()
def _consistent(self):
"""Are the files associated with this lineage self-consistent?
@@ -203,6 +199,40 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# happen as a result of random tampering by a sysadmin, or
# filesystem errors, or crashes.)
def _previous_symlinks(self):
"""Returns the kind and path of all symlinks used in recovery.
:returns: list of (kind, symlink) tuples
:rtype: list
"""
previous_symlinks = []
for kind in ALL_FOUR:
link_dir = os.path.dirname(getattr(self, kind))
link_base = "previous_{0}.pem".format(kind)
previous_symlinks.append((kind, os.path.join(link_dir, link_base)))
return previous_symlinks
def _fix_symlinks(self):
"""Fixes symlinks in the event of an incomplete version update.
If there is no problem with the current symlinks, this function
has no effect.
"""
previous_symlinks = self._previous_symlinks()
if all(os.path.exists(link[1]) for link in previous_symlinks):
for kind, previous_link in previous_symlinks:
current_link = getattr(self, kind)
if os.path.lexists(current_link):
os.unlink(current_link)
os.symlink(os.readlink(previous_link), current_link)
for _, link in previous_symlinks:
if os.path.exists(link):
os.unlink(link)
def current_target(self, kind):
"""Returns full path to which the specified item currently points.
@@ -374,10 +404,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
def update_all_links_to(self, version):
"""Change all member objects to point to the specified version.
:param int version: the desired version"""
:param int version: the desired version
for kind in ALL_FOUR:
self._update_link_to(kind, version)
"""
with error_handler.ErrorHandler(self._fix_symlinks):
previous_links = self._previous_symlinks()
for kind, link in previous_links:
os.symlink(self.current_target(kind), link)
for kind in ALL_FOUR:
self._update_link_to(kind, version)
for _, link in previous_links:
os.unlink(link)
def names(self, version=None):
"""What are the subject names of this certificate?
@@ -532,6 +571,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
:param configobj.ConfigObj config: renewal configuration
defaults, affecting, for example, the locations of the
directories where the associated files will be saved
:param .RenewerConfiguration cli_config: parsed command line
arguments
:returns: the newly-created RenewalCert object
:rtype: :class:`storage.renewableCert`"""
@@ -601,7 +642,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
# TODO: add human-readable comments explaining other available
# parameters
new_config.write()
return cls(new_config, config, cli_config)
return cls(new_config.filename, cli_config)
def save_successor(self, prior_version, new_cert, new_privkey, new_chain):
"""Save new cert and chain as a successor of a prior version.

View File

@@ -346,26 +346,25 @@ class DuplicativeCertsTest(renewer_test.BaseRenewableCertTest):
f.write(test_cert)
# No overlap at all
result = _find_duplicative_certs(['wow.net', 'hooray.org'],
self.config, self.cli_config)
result = _find_duplicative_certs(
self.cli_config, ['wow.net', 'hooray.org'])
self.assertEqual(result, (None, None))
# Totally identical
result = _find_duplicative_certs(['example.com', 'www.example.com'],
self.config, self.cli_config)
result = _find_duplicative_certs(
self.cli_config, ['example.com', 'www.example.com'])
self.assertTrue(result[0].configfile.filename.endswith('example.org.conf'))
self.assertEqual(result[1], None)
# Superset
result = _find_duplicative_certs(['example.com', 'www.example.com',
'something.new'], self.config,
self.cli_config)
result = _find_duplicative_certs(
self.cli_config, ['example.com', 'www.example.com', 'something.new'])
self.assertEqual(result[0], None)
self.assertTrue(result[1].configfile.filename.endswith('example.org.conf'))
# Partial overlap doesn't count
result = _find_duplicative_certs(['example.com', 'something.new'],
self.config, self.cli_config)
result = _find_duplicative_certs(
self.cli_config, ['example.com', 'something.new'])
self.assertEqual(result, (None, None))

View File

@@ -62,11 +62,11 @@ class BaseRenewableCertTest(unittest.TestCase):
kind + ".pem")
config.filename = os.path.join(self.tempdir, "renewal",
"example.org.conf")
config.write()
self.config = config
self.defaults = configobj.ConfigObj()
self.test_rc = storage.RenewableCert(
self.config, self.defaults, self.cli_config)
self.test_rc = storage.RenewableCert(config.filename, self.cli_config)
def tearDown(self):
shutil.rmtree(self.tempdir)
@@ -98,34 +98,32 @@ class RenewableCertTests(BaseRenewableCertTest):
def test_renewal_bad_config(self):
"""Test that the RenewableCert constructor will complain if
the renewal configuration file doesn't end in ".conf" or if it
isn't a ConfigObj."""
the renewal configuration file doesn't end in ".conf"
"""
from letsencrypt import storage
defaults = configobj.ConfigObj()
config = configobj.ConfigObj()
# These files don't exist and aren't created here; the point of the test
# is to confirm that the constructor rejects them outright because of
# the configfile's name.
for kind in ALL_FOUR:
config["cert"] = "nonexistent_" + kind + ".pem"
config.filename = "nonexistent_sillyfile"
self.assertRaises(
errors.CertStorageError, storage.RenewableCert, config, defaults)
self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults)
broken = os.path.join(self.tempdir, "broken.conf")
with open(broken, "w") as f:
f.write("[No closing bracket for you!")
self.assertRaises(errors.CertStorageError, storage.RenewableCert,
broken, self.cli_config)
os.unlink(broken)
self.assertRaises(errors.CertStorageError, storage.RenewableCert,
"fun", self.cli_config)
def test_renewal_incomplete_config(self):
"""Test that the RenewableCert constructor will complain if
the renewal configuration file is missing a required file element."""
from letsencrypt import storage
defaults = configobj.ConfigObj()
config = configobj.ConfigObj()
config["cert"] = "imaginary_cert.pem"
# Here the required privkey is missing.
config["chain"] = "imaginary_chain.pem"
config["fullchain"] = "imaginary_fullchain.pem"
config.filename = "imaginary_config.conf"
self.assertRaises(
errors.CertStorageError, storage.RenewableCert, config, defaults)
config.filename = os.path.join(self.tempdir, "imaginary_config.conf")
config.write()
self.assertRaises(errors.CertStorageError, storage.RenewableCert,
config.filename, self.cli_config)
def test_consistent(self):
# pylint: disable=too-many-statements,protected-access
@@ -289,7 +287,7 @@ class RenewableCertTests(BaseRenewableCertTest):
self.assertEqual("cert8.pem",
os.path.basename(self.test_rc.version("cert", 8)))
def test_update_all_links_to(self):
def test_update_all_links_to_success(self):
for ver in xrange(1, 6):
for kind in ALL_FOUR:
where = getattr(self.test_rc, kind)
@@ -307,6 +305,39 @@ class RenewableCertTests(BaseRenewableCertTest):
self.assertEqual(ver, self.test_rc.current_version(kind))
self.assertEqual(self.test_rc.latest_common_version(), 5)
def test_update_all_links_to_partial_failure(self):
def unlink_or_raise(path, real_unlink=os.unlink):
# pylint: disable=missing-docstring
basename = os.path.basename(path)
if "fullchain" in basename and basename.startswith("prev"):
raise ValueError
else:
real_unlink(path)
self._write_out_ex_kinds()
with mock.patch("letsencrypt.storage.os.unlink") as mock_unlink:
mock_unlink.side_effect = unlink_or_raise
self.assertRaises(ValueError, self.test_rc.update_all_links_to, 12)
for kind in ALL_FOUR:
self.assertEqual(self.test_rc.current_version(kind), 12)
def test_update_all_links_to_full_failure(self):
def unlink_or_raise(path, real_unlink=os.unlink):
# pylint: disable=missing-docstring
if "fullchain" in os.path.basename(path):
raise ValueError
else:
real_unlink(path)
self._write_out_ex_kinds()
with mock.patch("letsencrypt.storage.os.unlink") as mock_unlink:
mock_unlink.side_effect = unlink_or_raise
self.assertRaises(ValueError, self.test_rc.update_all_links_to, 12)
for kind in ALL_FOUR:
self.assertEqual(self.test_rc.current_version(kind), 11)
def test_has_pending_deployment(self):
for ver in xrange(1, 6):
for kind in ALL_FOUR:
@@ -667,10 +698,6 @@ class RenewableCertTests(BaseRenewableCertTest):
mock_rc_instance.should_autorenew.return_value = True
mock_rc_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_rc_instance
with open(os.path.join(self.cli_config.renewal_configs_dir,
"README"), "w") as f:
f.write("This is a README file to make sure that the renewer is")
f.write("able to correctly ignore files that don't end in .conf.")
with open(os.path.join(self.cli_config.renewal_configs_dir,
"example.org.conf"), "w") as f:
# This isn't actually parsed in this test; we have a separate
@@ -682,7 +709,7 @@ class RenewableCertTests(BaseRenewableCertTest):
"example.com.conf"), "w") as f:
f.write("cert = cert.pem\nprivkey = privkey.pem\n")
f.write("chain = chain.pem\nfullchain = fullchain.pem\n")
renewer.main(self.defaults, cli_args=self._common_cli_args())
renewer.main(cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 2)
self.assertEqual(mock_rc_instance.update_all_links_to.call_count, 2)
self.assertEqual(mock_notify.notify.call_count, 4)
@@ -695,7 +722,7 @@ class RenewableCertTests(BaseRenewableCertTest):
mock_happy_instance.should_autorenew.return_value = False
mock_happy_instance.latest_common_version.return_value = 10
mock_rc.return_value = mock_happy_instance
renewer.main(self.defaults, cli_args=self._common_cli_args())
renewer.main(cli_args=self._common_cli_args())
self.assertEqual(mock_rc.call_count, 4)
self.assertEqual(mock_happy_instance.update_all_links_to.call_count, 0)
self.assertEqual(mock_notify.notify.call_count, 4)
@@ -706,7 +733,7 @@ class RenewableCertTests(BaseRenewableCertTest):
with open(os.path.join(self.cli_config.renewal_configs_dir,
"bad.conf"), "w") as f:
f.write("incomplete = configfile\n")
renewer.main(self.defaults, cli_args=self._common_cli_args())
renewer.main(cli_args=self._common_cli_args())
# The errors.CertStorageError is caught inside and nothing happens.