mirror of
https://github.com/certbot/certbot.git
synced 2026-07-27 16:30:31 +02:00
Merge pull request #758 from letsencrypt/treat_duplicate_as_renewal
Treat duplicative certificate requests as renewals by default
This commit is contained in:
+116
-9
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user