Merge pull request #758 from letsencrypt/treat_duplicate_as_renewal

Treat duplicative certificate requests as renewals by default
This commit is contained in:
James Kasten
2015-09-15 21:30:22 -04:00
8 changed files with 267 additions and 27 deletions
+116 -9
View File
@@ -12,6 +12,8 @@ import time
import traceback
import configargparse
import configobj
import OpenSSL
import zope.component
import zope.interface.exceptions
import zope.interface.verify
@@ -31,6 +33,7 @@ from letsencrypt import interfaces
from letsencrypt import le_util
from letsencrypt import log
from letsencrypt import reporter
from letsencrypt import storage
from letsencrypt.display import util as display_util
from letsencrypt.display import ops as display_ops
@@ -163,7 +166,37 @@ def _init_le_client(args, config, authenticator, installer):
return client.Client(config, acc, authenticator, installer, acme=acme)
def run(args, config, plugins):
def _find_duplicative_certs(domains, config, renew_config):
"""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)
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, errors.CertStorageError, IOError):
logger.warning("Renewal configuration file %s is broken. "
"Skipping.", full_path)
continue
# TODO: Handle these differently depending on whether they are
# expired or still valid?
candidate_names = set(candidate_lineage.names())
if candidate_names == set(domains):
identical_names_cert = candidate_lineage
elif candidate_names.issubset(set(domains)):
subset_names_cert = candidate_lineage
return identical_names_cert, subset_names_cert
def run(args, config, plugins): # pylint: disable=too-many-branches,too-many-locals
"""Obtain a certificate and install."""
if args.configurator is not None and (args.installer is not None or
args.authenticator is not None):
@@ -185,15 +218,86 @@ def run(args, config, plugins):
return "Configurator could not be determined"
domains = _find_domains(args, installer)
treat_as_renewal = False
# Considering the possibility that the requested certificate is
# related to an existing certificate. (config.duplicate, which
# is set with --duplicate, skips all of this logic and forces any
# kind of certificate to be obtained with treat_as_renewal = False.)
if not config.duplicate:
identical_names_cert, subset_names_cert = _find_duplicative_certs(
domains, config, configuration.RenewerConfiguration(config))
# I am not sure whether that correctly reads the systemwide
# configuration file.
question = None
if identical_names_cert is not None:
question = (
"You have an existing certificate that contains exactly the "
"same domains you requested (ref: {0})\n\nDo you want to "
"renew and replace this certificate with a newly-issued one?"
).format(identical_names_cert.configfile.filename)
elif subset_names_cert is not None:
question = (
"You have an existing certificate that contains a portion of "
"the domains you requested (ref: {0})\n\nIt contains these "
"names: {1}\n\nYou requested these names for the new "
"certificate: {2}.\n\nDo you want to replace this existing "
"certificate with the new certificate?"
).format(subset_names_cert.configfile.filename,
", ".join(subset_names_cert.names()),
", ".join(domains))
if question is None:
# We aren't in a duplicative-names situation at all, so we don't
# have to tell or ask the user anything about this.
pass
elif zope.component.getUtility(interfaces.IDisplay).yesno(
question, "Replace", "Cancel"):
treat_as_renewal = True
else:
reporter_util = zope.component.getUtility(interfaces.IReporter)
reporter_util.add_message(
"To obtain a new certificate that {0} an existing certificate "
"in its domain-name coverage, you must use the --duplicate "
"option.\n\nFor example:\n\n{1} --duplicate {2}".format(
"duplicates" if identical_names_cert is not None else
"overlaps with", sys.argv[0], " ".join(sys.argv[1:])),
reporter_util.HIGH_PRIORITY)
return 1
# Attempting to obtain the certificate
# TODO: Handle errors from _init_le_client?
le_client = _init_le_client(args, config, authenticator, installer)
lineage = le_client.obtain_and_enroll_certificate(
domains, authenticator, installer, plugins)
if not lineage:
return "Certificate could not be obtained"
le_client.deploy_certificate(
domains, lineage.privkey, lineage.cert, lineage.chain)
le_client.enhance_config(domains, args.redirect)
if treat_as_renewal:
lineage = identical_names_cert if identical_names_cert is not None else subset_names_cert
# TODO: Use existing privkey instead of generating a new one
new_certr, new_chain, new_key, _ = le_client.obtain_certificate(domains)
# TODO: Check whether it worked!
lineage.save_successor(
lineage.latest_common_version(), OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, new_certr.body),
new_key.pem, OpenSSL.crypto.dump_certificate(
OpenSSL.crypto.FILETYPE_PEM, new_chain))
lineage.update_all_links_to(lineage.latest_common_version())
# TODO: Check return value of save_successor
# TODO: Also update lineage renewal config with any relevant
# configuration values from this attempt?
le_client.deploy_certificate(
domains, lineage.privkey, lineage.cert, lineage.chain)
display_ops.success_renewal(domains)
else:
# TREAT AS NEW REQUEST
lineage = le_client.obtain_and_enroll_certificate(
domains, authenticator, installer, plugins)
if not lineage:
return "Certificate could not be obtained"
# TODO: This treats the key as changed even when it wasn't
# TODO: We also need to pass the fullchain (for Nginx)
le_client.deploy_certificate(
domains, lineage.privkey, lineage.cert, lineage.chain)
le_client.enhance_config(domains, args.redirect)
display_ops.success_installation(domains)
def auth(args, config, plugins):
@@ -503,6 +607,9 @@ def create_parser(plugins, args):
#for subparser in parser_run, parser_auth, parser_install:
# subparser.add_argument("domains", nargs="*", metavar="domain")
helpful.add(None, "-d", "--domains", metavar="DOMAIN", action="append")
helpful.add(
None, "--duplicate", dest="duplicate", action="store_true",
help="Allow getting a certificate that duplicates an existing one")
helpful.add_group(
"automation",
@@ -688,7 +795,7 @@ def _setup_logging(args):
# TODO: change before release?
log_file_name = os.path.join(args.logs_dir, 'letsencrypt.log')
file_handler = logging.handlers.RotatingFileHandler(
log_file_name, maxBytes=2**20, backupCount=10)
log_file_name, maxBytes=2 ** 20, backupCount=10)
# rotate on each invocation, rollover only possible when maxBytes
# is nonzero and backupCount is nonzero, so we set maxBytes as big
# as possible not to overrun in single CLI invocation (1MB).
-2
View File
@@ -378,8 +378,6 @@ class Client(object):
# sites may have been enabled / final cleanup
self.installer.restart()
display_ops.success_installation(domains)
def enhance_config(self, domains, redirect=None):
"""Enhance the configuration.
+20
View File
@@ -233,6 +233,26 @@ def success_installation(domains):
pause=False)
def success_renewal(domains):
"""Display a box confirming the renewal of an existing certificate.
.. todo:: This should be centered on the screen
:param list domains: domain names which were renewed
"""
util(interfaces.IDisplay).notification(
"Your existing certificate has been successfully renewed, and the "
"new certificate has been installed.{1}{1}"
"The new certificate covers the following domains: {0}{1}{1}"
"You should test your configuration at:{1}{2}".format(
_gen_https_names(domains),
os.linesep,
os.linesep.join(_gen_ssl_lab_urls(domains))),
height=(14 + len(domains)),
pause=False)
def _gen_ssl_lab_urls(domains):
"""Returns a list of urls.
+18
View File
@@ -11,6 +11,7 @@ import pytz
import pyrfc3339
from letsencrypt import constants
from letsencrypt import crypto_util
from letsencrypt import errors
from letsencrypt import le_util
@@ -421,6 +422,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes
"""
return self._notafterbefore(lambda x509: x509.get_notAfter(), version)
def names(self, version=None):
"""What are the subject names of this certificate?
(If no version is specified, use the current version.)
:param int version: the desired version number
:returns: the subject names
:rtype: `list` of `str`
"""
if version is None:
target = self.current_target("cert")
else:
target = self.version("cert", version)
with open(target) as f:
return crypto_util.get_sans_from_cert(f.read())
def should_autodeploy(self):
"""Should this lineage now automatically deploy a newer version?
+44
View File
@@ -12,6 +12,9 @@ from letsencrypt import account
from letsencrypt import configuration
from letsencrypt import errors
from letsencrypt.tests import renewer_test
from letsencrypt.tests import test_util
class CLITest(unittest.TestCase):
"""Tests for different commands."""
@@ -162,5 +165,46 @@ class DetermineAccountTest(unittest.TestCase):
self.assertEqual("other email", self.args.email)
class DuplicativeCertsTest(renewer_test.BaseRenewableCertTest):
"""Test to avoid duplicate lineages."""
def setUp(self):
super(DuplicativeCertsTest, self).setUp()
self.config.write()
self._write_out_ex_kinds()
def tearDown(self):
shutil.rmtree(self.tempdir)
def test_find_duplicative_names(self):
from letsencrypt.cli import _find_duplicative_certs
test_cert = test_util.load_vector("cert-san.pem")
with open(self.test_rc.cert, "w") as f:
f.write(test_cert)
# No overlap at all
result = _find_duplicative_certs(["wow.net", "hooray.org"],
self.config, self.cli_config)
self.assertEqual(result, (None, None))
# Totally identical
result = _find_duplicative_certs(["example.com", "www.example.com"],
self.config, self.cli_config)
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)
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)
self.assertEqual(result, (None, None))
if __name__ == '__main__':
unittest.main() # pragma: no cover
+22
View File
@@ -384,5 +384,27 @@ class SuccessInstallationTest(unittest.TestCase):
self.assertTrue(name in arg)
class SuccessRenewalTest(unittest.TestCase):
# pylint: disable=too-few-public-methods
"""Test the success renewal message."""
@classmethod
def _call(cls, names):
from letsencrypt.display.ops import success_renewal
success_renewal(names)
@mock.patch("letsencrypt.display.ops.util")
def test_success_renewal(self, mock_util):
mock_util().notification.return_value = None
names = ["example.com", "abc.com"]
self._call(names)
self.assertEqual(mock_util().notification.call_count, 1)
arg = mock_util().notification.call_args_list[0][0][0]
for name in names:
self.assertTrue(name in arg)
if __name__ == "__main__":
unittest.main() # pragma: no cover
+46 -15
View File
@@ -32,9 +32,8 @@ def fill_with_sample_data(rc_object):
f.write(kind)
class RenewableCertTests(unittest.TestCase):
# pylint: disable=too-many-public-methods
"""Tests for letsencrypt.renewer.*."""
class BaseRenewableCertTest(unittest.TestCase):
def setUp(self):
from letsencrypt import storage
self.tempdir = tempfile.mkdtemp()
@@ -52,10 +51,31 @@ class RenewableCertTests(unittest.TestCase):
kind + ".pem")
config.filename = os.path.join(self.tempdir, "configs",
"example.org.conf")
self.config = config
self.defaults = configobj.ConfigObj()
self.test_rc = storage.RenewableCert(
config, self.defaults, self.cli_config)
self.config, self.defaults, self.cli_config)
def _write_out_ex_kinds(self):
for kind in ALL_FOUR:
where = getattr(self.test_rc, kind)
os.symlink(os.path.join("..", "..", "archive", "example.org",
"{0}12.pem".format(kind)), where)
with open(where, "w") as f:
f.write(kind)
os.unlink(where)
os.symlink(os.path.join("..", "..", "archive", "example.org",
"{0}11.pem".format(kind)), where)
with open(where, "w") as f:
f.write(kind)
class RenewableCertTests(BaseRenewableCertTest):
# pylint: disable=too-many-public-methods
"""Tests for letsencrypt.renewer.*."""
def setUp(self):
super(RenewableCertTests, self).setUp()
def tearDown(self):
shutil.rmtree(self.tempdir)
@@ -296,6 +316,26 @@ class RenewableCertTests(unittest.TestCase):
else:
self.assertFalse(self.test_rc.has_pending_deployment())
def test_names(self):
# Trying the current version
test_cert = test_util.load_vector("cert-san.pem")
os.symlink(os.path.join("..", "..", "archive", "example.org",
"cert12.pem"), self.test_rc.cert)
with open(self.test_rc.cert, "w") as f:
f.write(test_cert)
self.assertEqual(self.test_rc.names(),
["example.com", "www.example.com"])
# Trying a non-current version
test_cert = test_util.load_vector("cert.pem")
os.unlink(self.test_rc.cert)
os.symlink(os.path.join("..", "..", "archive", "example.org",
"cert15.pem"), self.test_rc.cert)
with open(self.test_rc.cert, "w") as f:
f.write(test_cert)
self.assertEqual(self.test_rc.names(12),
["example.com", "www.example.com"])
def _test_notafterbefore(self, function, timestamp):
test_cert = test_util.load_vector("cert.pem")
os.symlink(os.path.join("..", "..", "archive", "example.org",
@@ -321,17 +361,8 @@ class RenewableCertTests(unittest.TestCase):
"""Test should_autodeploy() and should_autorenew() on the basis
of expiry time windows."""
test_cert = test_util.load_vector("cert.pem")
for kind in ALL_FOUR:
where = getattr(self.test_rc, kind)
os.symlink(os.path.join("..", "..", "archive", "example.org",
"{0}12.pem".format(kind)), where)
with open(where, "w") as f:
f.write(kind)
os.unlink(where)
os.symlink(os.path.join("..", "..", "archive", "example.org",
"{0}11.pem".format(kind)), where)
with open(where, "w") as f:
f.write(kind)
self._write_out_ex_kinds()
self.test_rc.update_all_links_to(12)
with open(self.test_rc.cert, "w") as f:
f.write(test_cert)
+1 -1
View File
@@ -16,7 +16,7 @@ fi
cover () {
if [ "$1" = "letsencrypt" ]; then
min=97
min=96
elif [ "$1" = "acme" ]; then
min=100
elif [ "$1" = "letsencrypt_apache" ]; then