From 9463de3367af03a4351f72ff6f1602dd3dc5924a Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Tue, 21 Apr 2015 19:12:49 -0700 Subject: [PATCH 01/66] Initial implementation of renewer --- letsencrypt/client/example.org.conf | 42 +++ letsencrypt/client/le_util.py | 28 ++ letsencrypt/client/notify.py | 30 ++ letsencrypt/client/renewal.conf | 11 + letsencrypt/client/renewer.py | 460 ++++++++++++++++++++++++++++ setup.py | 2 + 6 files changed, 573 insertions(+) create mode 100644 letsencrypt/client/example.org.conf create mode 100644 letsencrypt/client/notify.py create mode 100644 letsencrypt/client/renewal.conf create mode 100644 letsencrypt/client/renewer.py diff --git a/letsencrypt/client/example.org.conf b/letsencrypt/client/example.org.conf new file mode 100644 index 000000000..df565c0d8 --- /dev/null +++ b/letsencrypt/client/example.org.conf @@ -0,0 +1,42 @@ +# These are automatically generated and should normally not be edited. +# Changing these values may prevent Let's Encrypt from correctly managing +# this certificate. + +cert_path = /etc/letsencrypt/live/example.org/cert.pem +privkey_path = /etc/letsencrypt/live/example.org/privkey.pem +chain_path = /etc/letsencrypt/live/example.org/chain.pem +fullchain_path = /etc/letsencrypt/live/example.org/fullchain.pem + +authenticator = letsencrypt.ApacheConfigurator +installer = letsencrypt.ApacheConfigurator + +# This will be None when there is no currently-scheduled deployment, such +# as when there is no pending renewed version to deploy. + +# XXX: This field should possibly be removed because it's not clear that +# we derive a benefit from attempting to track this. But it might +# be useful to the human user to have a place to look this up. +next_scheduled_deployment = None + +# These options can be changed to enable or disable automated renewal +# and automated deployment. (When commented out or not present, the default +# values from renewal.conf are used.) + +# autorenew = 1 +# autodeploy = 1 +# renew_before_expiry = 10 days +# deploy_before_expiry = 5 days + +# This is set to 1 if we have knowledge that the currently-deployed version +# of this certificate has been revoked by the certificate authority. + +was_revoked = 0 + +# Account data that allows reissuance of this certificate +# This e-mail address is not directly used to cause re-issuance, but +# rather to look up a key on this system that may be authorized for +# this purpose. +renewal_account = user@example.com + +# Recovery tokens that allow reissuance of this certificate +recovery_tokens = None diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 1615fc29d..c3356f3c1 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -75,6 +75,34 @@ def unique_file(path, mode=0o777): count += 1 +def unique_lineage_name(path, filename, mode=0o777): + """Safely finds a unique file for writing only (by default). Uses a + file lineage convention. + + :param str path: path + :param str filename: filename + :param int mode: File mode + + :return: tuple of file object and file name + + """ + fname = os.path.join(path, "%s.conf" % (filename)) + try: + file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) + return os.fdopen(file_d, "w"), fname + except OSError: + pass + count = 1 + while True: + fname = os.path.join(path, "%s-%04d.conf" % (filename, count)) + try: + file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) + return os.fdopen(file_d, "w"), fname + except OSError: + pass + count += 1 + + def safely_remove(path): """Remove a file that may not exist.""" try: diff --git a/letsencrypt/client/notify.py b/letsencrypt/client/notify.py new file mode 100644 index 000000000..954862e3b --- /dev/null +++ b/letsencrypt/client/notify.py @@ -0,0 +1,30 @@ +"""Send e-mail notification to system administrators.""" + +import email +import smtplib +import socket +import subprocess + +def notify(subject, whom, what): + """Try to notify the addressee (whom) by e-mail, with Subject: + defined by subject and message body by what.""" + msg = email.message_from_string(what) + msg.add_header("From", "Let's Encrypt renewal agent ") + msg.add_header("To", whom) + msg.add_header("Subject", subject) + msg = msg.as_string() + try: + lmtp = smtplib.LMTP() + lmtp.connect() + lmtp.sendmail("root", [whom], msg) + except (smtplib.SMTPHeloError, smtplib.SMTPRecipientsRefused, + smtplib.SMTPSenderRefused, smtplib.SMTPDataError, socket.error): + # We should try using /usr/sbin/sendmail in this case + try: + proc = subprocess.Popen(["/usr/sbin/sendmail", "-t"], + stdin=subprocess.PIPE) + proc.communicate(msg) + except OSError, err: + print err + return False + return True diff --git a/letsencrypt/client/renewal.conf b/letsencrypt/client/renewal.conf new file mode 100644 index 000000000..98b1db215 --- /dev/null +++ b/letsencrypt/client/renewal.conf @@ -0,0 +1,11 @@ +renewer_enabled = 1 + +default_autorenew = 1 +default_autodeploy = 1 +renew_before_expiry = 10 days +deploy_before_expiry = 300 days +notification = 1 +notification_method = root@example.org +max_notifications_per_event = 5 +# account_key = /etc/letsencrypt/accountkey/foo.pem + diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py new file mode 100644 index 000000000..a4a8e071e --- /dev/null +++ b/letsencrypt/client/renewer.py @@ -0,0 +1,460 @@ +#!/usr/bin/env python + +"""Renewer tool to handle autorenewal and autodeployment of renewed +certs within lineages of successor certificates, according to +configuration.""" + +# os.path.islink +# os.readlink +# os.path.dirname / os.path.basename +# os.path.join + +# TODO: sanity checking consistency, validity, freshness? + +# TODO: call new installer API to restart servers after deployment + +# TODO: when renewing or deploying, update config file to +# memorialize the fact that it happened + +import configobj +import copy +import datetime +import os +import OpenSSL +import parsedatetime +import pyrfc3339 +import pytz +import re +import time + +from letsencrypt.client import le_util +from letsencrypt.client import notify + +DEFAULTS = configobj.ConfigObj("renewal.conf") +DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" +DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" +DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" +ALL_FOUR = ("cert", "privkey", "chain", "fullchain") + +def parse_time_interval(interval, textparser=parsedatetime.Calendar()): + """Parse the time specified time interval, which can be in the + English-language format understood by parsedatetime, e.g., '10 days', + '3 weeks', '6 months', '9 hours', or a sequence of such intervals + like '6 months 1 week' or '3 days 12 hours'. If an integer is found + with no associated unit, it is interpreted by default as a number of + days.""" + if interval.strip().isdigit(): + interval += " days" + return datetime.timedelta(0, time.mktime(textparser.parse( + interval, time.localtime(0))[0])) + +class RenewableCert(object): # pylint: disable=too-many-instance-attributes + """Represents a lineage of certificates that is under the management + of the Let's Encrypt client, indicated by the existence of an + associated renewal configuration file.""" + + def __init__(self, configfile, defaults=DEFAULTS): + # 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.configuration = copy.deepcopy(defaults) + self.configfile = configobj.ConfigObj(configfile) + self.configuration.merge(self.configfile) + + if not configfile.filename.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + self.lineagename = os.path.basename(configfile.filename)[:-5] + self.configfilename = configfile.filename + + self.cert = self.configuration["cert"] + self.privkey = self.configuration["privkey"] + self.chain = self.configuration["chain"] + self.fullchain = self.configuration["fullchain"] + + def consistent(self): + """Is the structure of the archived files and links related to this + lineage correct and self-consistent?""" + # Each element must be referenced with an absolute path + if any(not os.path.isabs(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + # Each element must exist and be a symbolic link + if any(not os.path.islink(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + for kind in ALL_FOUR: + link = self.__getattribute__(kind) + where = os.path.dirname(link) + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(where, target) + # Each element's link must point within the cert lineage's + # directory within the official archive directory + desired_directory = os.path.join( + self.configuration["official_archive_dir"], self.lineagename) + if not os.path.samefile(os.path.dirname(target), + desired_directory): + return False + # The link must point to a file that exists + if not os.path.exists(target): + return False + # The link must point to a file that follows the archive + # naming convention + pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + if not pattern.match(os.path.basename(target)): + return False + # It is NOT required that the link's target be a regular + # file (it may itself be a symlink). But we should probably + # do a recursive check that ultimately the target does + # exist? + # XXX: Additional possible consistency checks + # XXX: All four of the targets are in the same directory + # (This check is redundant with the check that they + # are all in the desired directory!) + # len(set(os.path.basename(self.current_target(x) + # for x in ALL_FOUR))) == 1 + return True + + def fix(self): + """Attempt to fix some kinds of defects or inconsistencies + in the symlink structure, if possible.""" + # TODO: Figure out what kinds of fixes are possible. For + # example, checking if there is a valid version that + # we can update the symlinks to. (Maybe involve + # parsing keys and certs to see if they exist and + # if a key corresponds to the subject key of a cert?) + + # TODO: In general, the symlink-reading functions below are not + # cautious enough about the possibility that links or their + # targets may not exist. (This shouldn't happen, but might + # happen as a result of random tampering by a sysadmin, or + # filesystem errors, or crashes.) + + def current_target(self, kind): + """Returns the full path to which the link of the specified + kind currently points.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + if not os.path.exists(link): + return None + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(link), target) + return target + + def current_version(self, kind): + """Returns the numerical version of the object to which the link + of the specified kind currently points. For example, if kind + is "chain" and the current chain link points to a file named + "chain7.pem", returns the integer 7.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + target = self.current_target(kind) + if not target or not os.path.exists(target): + target = "" + matches = pattern.match(os.path.basename(target)) + if matches: + return int(matches.groups()[0]) + else: + return None + + def version(self, kind, version): + """Constructs the filename that would correspond to the + specified version of the specified kind of item in this + lineage. Warning: the specified version may not exist.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + return os.path.join(where, "{}{}.pem".format(kind, version)) + + def available_versions(self, kind): + """Which alternative versions of the specified kind of item + exist in the archive directory where the current version is + stored?""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + files = os.listdir(where) + pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + matches = [pattern.match(f) for f in files] + return sorted([int(m.groups()[0]) for m in matches if m]) + + def newest_available_version(self, kind): + """What is the newest available version of the specified + kind of item?""" + return max(self.available_versions(kind)) + + def latest_common_version(self): + """What is the largest version number for which versions + of cert, privkey, chain, and fullchain are all available?""" + # TODO: this can raise ValueError if there is no version overlap + # (it should probably return None instead) + # TODO: this can raise a spurious AttributeError if the current + # link for any kind is missing (it should probably return None) + versions = [self.available_versions(x) for x in ALL_FOUR] + return max(n for n in versions[0] if all(n in v for v in versions[1:])) + + def next_free_version(self): + """What is the smallest new version number that is larger than + any available version of any managed item?""" + # TODO: consider locking/mutual exclusion between updating processes + # This isn't self.latest_common_version() + 1 because we don't want + # collide with a version that might exist for one file type but not + # for the others. + return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 + + def has_pending_deployment(self): + """Is there a later version of all of the managed items?""" + # TODO: consider whether to assume consistency or treat + # inconsistent/consistent versions differently + smallest_current = min(self.current_version(x) for x in ALL_FOUR) + return smallest_current < self.latest_common_version() + + def update_link_to(self, kind, version): + """Change the target of the link of the specified item to point + to the specified version. (Note that this method doesn't verify + that the specified version exists.)""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + filename = "{}{}.pem".format(kind, version) + # Relative rather than absolute target directory + target_directory = os.path.dirname(os.readlink(link)) + # TODO: it could be safer to make the link first under a temporary + # filename, then unlink the old link, then rename the new link + # to the old link; this ensures that this process is able to + # create symlinks. + # TODO: we might also want to check consistency of related links + # for the other corresponding items + os.unlink(link) + os.symlink(os.path.join(target_directory, filename), link) + + def update_all_links_to(self, version): + """Change the target of the cert, privkey, chain, and fullchain links + to point to the specified version.""" + for kind in ALL_FOUR: + self.update_link_to(kind, version) + + def notbefore(self, version=None): + """When is the beginning validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notBefore() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def notafter(self, version=None): + """When is the ending validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notAfter() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def should_autodeploy(self): + """Should this certificate lineage be updated automatically to + point to an existing pending newer version? (Considers whether + autodeployment is enabled, whether a relevant newer version + exists, and whether the time interval for autodeployment has + been reached.)""" + if (not self.configuration.has_key("autodeploy") or + self.configuration.as_bool("autodeploy")): + if self.has_pending_deployment(): + interval = self.configuration.get("deploy_before_expiry", + "5 days") + autodeploy_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autodeploy_interval: + return True + return False + + def ocsp_revoked(self, version=None): + # pylint: disable=no-self-use,unused-argument + """Is the specified version of this certificate lineage revoked + according to OCSP or intended to be revoked according to Let's + Encrypt OCSP extensions? (If no version is specified, use the + current version.)""" + # XXX: This query and its associated network service aren't + # implemented yet, so we currently return False (indicating that the + # certificate is not revoked). + return False + + def should_autorenew(self): + """Should an attempt be made to automatically renew the most + recent certificate in this certificate lineage right now?""" + if (not self.configuration.has_key("autorenew") + or self.configuration.as_bool("autorenew")): + # Consider whether to attempt to autorenew this cert now + # XXX: both self.ocsp_revoked() and self.notafter() are bugs + # here because we should be looking at the latest version, not + # the current version! + # Renewals on the basis of revocation + if self.ocsp_revoked(): + return True + # Renewals on the basis of expiry time + interval = self.configuration.get("renew_before_expiry", "10 days") + autorenew_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autorenew_interval: + return True + return False + + @classmethod + def new_lineage(cls, lineagename, cert, privkey, chain, config=DEFAULTS): + """Create a new certificate lineage with the (suggested) lineage name + lineagename, and the associated cert, privkey, and chain (the + associated fullchain will be created automatically). Returns a new + RenewableCert object referring to the created lineage. (The actual + lineage name, as well as all the relevant file paths, will be + available within this object.)""" + configs_dir = config["renewal_configs_dir"] + archive_dir = config["official_archive_dir"] + live_dir = config["live_dir"] + config_file, config_filename = le_util.unique_lineage_name(configs_dir, + lineagename) + if not config_filename.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + # lineagename will now potentially be modified based on what + # renewal configuration file could actually be created + lineagename = os.path.basename(config_filename)[:-5] + our_archive = os.path.join(archive_dir, lineagename) + our_live_dir = os.path.join(live_dir, lineagename) + if os.path.exists(our_archive): + raise ValueError("archive directory exists for " + lineagename) + if os.path.exists(our_live_dir): + raise ValueError("archive directory exists for " + lineagename) + os.mkdir(our_archive) + os.mkdir(our_live_dir) + relative_archive = os.path.join("..", "..", "archive", lineagename) + cert_target = os.path.join(our_live_dir, "cert.pem") + privkey_target = os.path.join(our_live_dir, "privkey.pem") + chain_target = os.path.join(our_live_dir, "chain.pem") + fullchain_target = os.path.join(our_live_dir, "fullchain.pem") + os.symlink(os.path.join(relative_archive, "cert1.pem"), + cert_target) + os.symlink(os.path.join(relative_archive, "privkey1.pem"), + privkey_target) + os.symlink(os.path.join(relative_archive, "chain1.pem"), + chain_target) + os.symlink(os.path.join(relative_archive, "fullchain1.pem"), + fullchain_target) + with open(cert_target, "w") as f: + f.write(cert) + with open(privkey_target, "w") as f: + f.write(privkey) + with open(chain_target, "w") as f: + f.write(chain) + with open(fullchain_target, "w") as f: + f.write(cert + chain) + config_file.close() + new_config = configobj.ConfigObj(config_filename, create_empty=True) + new_config["cert"] = cert_target + new_config["privkey"] = privkey_target + new_config["chain"] = chain_target + new_config["fullchain"] = fullchain_target + # TODO: add human-readable comments explaining other available + # parameters + new_config.write() + return cls(config_filename, config) + + def save_successor(self, prior_version, new_cert, new_chain): + """Save a new cert and chain as a successor of a specific prior + version in this lineage. Returns the new version number that was + created. Note: does NOT update links to deploy this version.""" + # XXX: no private key change: should be extended with a key=None + # default argument that allows changing the private key; also we + # should perhaps allow new_chain=None which makes a link to + # the prior chain's target + # XXX: assumes official archive location rather than examining links + # XXX: consider using os.open for availablity of os.O_EXCL + target_version = self.next_free_version() + archive = self.configuration["official_archive_dir"] + prefix = os.path.join(archive, self.lineagename) + cert_target = os.path.join( + prefix, "cert{}.pem".format(target_version)) + privkey_target = os.path.join( + prefix, "privkey{}.pem".format(target_version)) + chain_target = os.path.join( + prefix, "chain{}.pem".format(target_version)) + fullchain_target = os.path.join( + prefix, "fullchain{}.pem".format(target_version)) + with open(cert_target, "w") as f: + f.write(new_cert) + # The behavior below always keeps the prior key by creating a new + # symlink to the old key or the target of the old key symlink. + old_privkey = os.path.join( + prefix, "privkey{}.pem".format(prior_version)) + if os.path.islink(old_privkey): + old_privkey = os.readlink(old_privkey) + else: + old_privkey = "privkey{}.pem".format(prior_version) + os.symlink(old_privkey, privkey_target) + with open(chain_target, "w") as f: + f.write(new_chain) + with open(fullchain_target, "w") as f: + f.write(new_cert + new_chain) + return target_version + +def renew(cert, old_version): # pylint: disable=no-self-use,unused-argument + """Perform automated renewal of the referenced cert, if possible.""" + # Try to create Account object, if available + # via account.Account.from_config(config.get("renewal_account")) or + # something + # Instantiate relevant authenticator + # Instantiate client + # Call client.obtain_certificate + # if it_worked: + # self.save_successor(old_version, new_cert, new_chain) + # Update relevant config files to note what was done + # Notify results + # else: + # Notify negative results + +def main(config=DEFAULTS): + """main function for autorenewer script.""" + for i in os.listdir(config["renewal_configs_dir"]): + print "Processing", i + cert = RenewableCert(i) + if cert.should_autodeploy(): + cert.update_all_links_to(cert.latest_common_version()) + # TODO: restart web server + notify.notify("Autodeployed a cert!!!", "root", "It worked!") + # TODO: explain what happened + if cert.should_autorenew(): + # Note: not cert.current_version() because the basis for + # the renewal is the latest version, even if it hasn't been + # deployed yet! + old_version = cert.latest_common_version() + renew(cert, old_version) + notify.notify("Autorenewed a cert!!!", "root", "It worked!") + # TODO: explain what happened + + +if __name__ == "__main__": + if ("renewer_enabled" in DEFAULTS + and not DEFAULTS.as_bool("renewer_enabled")): + print "Renewer is disabled by configuration! Exiting." + raise SystemExit + else: + main() diff --git a/setup.py b/setup.py index a4c7f7683..4dd65288f 100644 --- a/setup.py +++ b/setup.py @@ -30,9 +30,11 @@ changes = read_file(os.path.join(here, 'CHANGES.rst')) install_requires = [ 'argparse', 'ConfArgParse', + 'configobj', 'jsonschema', 'mock', 'ndg-httpsclient', # urllib3 InsecurePlatformWarning (#304) + 'parsedatetime', 'psutil>=2.1.0', # net_connections introduced in 2.1.0 'pyasn1', # urllib3 InsecurePlatformWarning (#304) 'pycrypto', From 5180bda7e18fd2cd272a2a5564bc54abdb3566a9 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Tue, 21 Apr 2015 19:13:08 -0700 Subject: [PATCH 02/66] Initial tests for renewer --- .pylintrc | 2 +- letsencrypt/client/tests/renewer_test.py | 535 +++++++++++++++++++++++ 2 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 letsencrypt/client/tests/renewer_test.py diff --git a/.pylintrc b/.pylintrc index 4835dbf74..228c5ac8c 100644 --- a/.pylintrc +++ b/.pylintrc @@ -82,7 +82,7 @@ required-attributes= bad-functions=map,filter,apply,input,file # Good variable names which should always be accepted, separated by a comma -good-names=i,j,k,ex,Run,_,fd +good-names=f,i,j,k,ex,Run,_,fd # Bad variable names which should always be refused, separated by a comma bad-names=foo,bar,baz,toto,tutu,tata diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py new file mode 100644 index 000000000..d0136f4bf --- /dev/null +++ b/letsencrypt/client/tests/renewer_test.py @@ -0,0 +1,535 @@ +"""Tests for letsencrypt.client.renewer.py""" + +import configobj +import datetime +import mock +import os +import tempfile +import pkg_resources +import pytz +import shutil +import unittest + +ALL_FOUR = ("cert", "privkey", "chain", "fullchain") + +class RenewableCertTests(unittest.TestCase): + """Tests for the RenewableCert class as well as other functions + within renewer.py.""" + def setUp(self): + from letsencrypt.client import renewer + self.tempdir = tempfile.mkdtemp() + os.makedirs(os.path.join(self.tempdir, "live", "example.org")) + os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) + os.makedirs(os.path.join(self.tempdir, "configs")) + defaults = configobj.ConfigObj() + defaults["live_dir"] = os.path.join(self.tempdir, "live") + defaults["official_archive_dir"] = os.path.join(self.tempdir, "archive") + defaults["renewal_configs_dir"] = os.path.join(self.tempdir, "configs") + config = configobj.ConfigObj() + config["cert"] = os.path.join(self.tempdir, "live", "example.org", "cert.pem") + config["privkey"] = os.path.join(self.tempdir, "live", "example.org", "privkey.pem") + config["chain"] = os.path.join(self.tempdir, "live", "example.org", "chain.pem") + config["fullchain"] = os.path.join(self.tempdir, "live", "example.org", "fullchain.pem") + config.filename = os.path.join(self.tempdir, "configs", + "example.org.conf") + self.defaults = defaults # for main() test + self.test_rc = renewer.RenewableCert(config, defaults) + + def tearDown(self): + shutil.rmtree(self.tempdir) + + def test_initialization(self): + self.assertEqual(self.test_rc.lineagename, "example.org") + self.assertEqual(self.test_rc.cert, os.path.join(self.tempdir, "live", "example.org", "cert.pem")) + self.assertEqual(self.test_rc.privkey, os.path.join(self.tempdir, "live", "example.org", "privkey.pem")) + self.assertEqual(self.test_rc.chain, os.path.join(self.tempdir, "live", "example.org", "chain.pem")) + self.assertEqual(self.test_rc.fullchain, os.path.join(self.tempdir, "live", "example.org", "fullchain.pem")) + + def test_renewal_config_filename_not_ending_in_conf(self): + """Test that the RenewableCert constructor will complain if + the renewal configuration file doesn't end in ".conf".""" + from letsencrypt.client import renewer + defaults = configobj.ConfigObj() + config = configobj.ConfigObj() + config["cert"] = "/tmp/cert.pem" + config["privkey"] = "/tmp/privkey.pem" + config["chain"] = "/tmp/chain.pem" + config["fullchain"] = "/tmp/fullchain.pem" + config.filename = "/tmp/sillyfile" + self.assertRaises(ValueError, renewer.RenewableCert, config, defaults) + + def test_consistent(self): + oldcert = self.test_rc.cert + self.test_rc.cert = "relative/path" + # Absolute path for item requirement + self.assertEqual(self.test_rc.consistent(), False) + self.test_rc.cert = oldcert + # Items must exist requirement + self.assertEqual(self.test_rc.consistent(), False) + # Items must be symlinks requirements + with open(self.test_rc.cert, "w") as f: + f.write("hello") + with open(self.test_rc.privkey, "w") as f: + f.write("hello") + with open(self.test_rc.chain, "w") as f: + f.write("hello") + with open(self.test_rc.fullchain, "w") as f: + f.write("hello") + self.assertEqual(self.test_rc.consistent(), False) + os.unlink(self.test_rc.cert) + os.unlink(self.test_rc.privkey) + os.unlink(self.test_rc.chain) + os.unlink(self.test_rc.fullchain) + # Items must point to desired place if they are relative + os.symlink(os.path.join("..", "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "privkey17.pem"), self.test_rc.privkey) + os.symlink(os.path.join("..", "chain17.pem"), self.test_rc.chain) + os.symlink(os.path.join("..", "fullchain17.pem"), self.test_rc.fullchain) + self.assertEqual(self.test_rc.consistent(), False) + os.unlink(self.test_rc.cert) + os.unlink(self.test_rc.privkey) + os.unlink(self.test_rc.chain) + os.unlink(self.test_rc.fullchain) + # Items must point to desired place if they are absolute + os.symlink(os.path.join(self.tempdir, "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join(self.tempdir, "privkey17.pem"), self.test_rc.privkey) + os.symlink(os.path.join(self.tempdir, "chain17.pem"), self.test_rc.chain) + os.symlink(os.path.join(self.tempdir, "fullchain17.pem"), self.test_rc.fullchain) + self.assertEqual(self.test_rc.consistent(), False) + os.unlink(self.test_rc.cert) + os.unlink(self.test_rc.privkey) + os.unlink(self.test_rc.chain) + os.unlink(self.test_rc.fullchain) + # Items must point to things that exist + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", "privkey17.pem"), self.test_rc.privkey) + os.symlink(os.path.join("..", "..", "archive", "example.org", "chain17.pem"), self.test_rc.chain) + os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain17.pem"), self.test_rc.fullchain) + self.assertEqual(self.test_rc.consistent(), False) + # This version should work + with open(self.test_rc.cert, "w") as f: + f.write("cert") + with open(self.test_rc.privkey, "w") as f: + f.write("privkey") + with open(self.test_rc.chain, "w") as f: + f.write("chain") + with open(self.test_rc.fullchain, "w") as f: + f.write("fullchain") + self.assertEqual(self.test_rc.consistent(), True) + # Items must point to things that follow the naming convention + os.unlink(self.test_rc.fullchain) + os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain_17.pem"), self.test_rc.fullchain) + with open(self.test_rc.fullchain, "w") as f: + f.write("wrongly-named fullchain") + self.assertEqual(self.test_rc.consistent(), False) + + def test_current_target(self): + # Relative path logic + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert17.pem"), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write("cert") + self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"))) + # Absolute path logic + os.unlink(self.test_rc.cert) + os.symlink(os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write("cert") + self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"))) + + def test_current_version(self): + for ver in (1, 5, 10, 20): + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert{}.pem".format(ver)), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write("cert") + os.unlink(self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert10.pem"), self.test_rc.cert) + self.assertEqual(self.test_rc.current_version("cert"), 10) + + def test_no_current_version(self): + self.assertEqual(self.test_rc.current_version("cert"), None) + + def test_latest_and_next_versions(self): + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertEqual(self.test_rc.latest_common_version(), 5) + self.assertEqual(self.test_rc.next_free_version(), 6) + # Having one kind of file of a later version doesn't change the + # result + os.unlink(self.test_rc.privkey) + os.symlink(os.path.join("..", "..", "archive", "example.org", "privkey7.pem"), self.test_rc.privkey) + with open(self.test_rc.privkey, "w") as f: + f.write("privkey") + self.assertEqual(self.test_rc.latest_common_version(), 5) + # ... although it does change the next free version + self.assertEqual(self.test_rc.next_free_version(), 8) + # Nor does having three out of four change the result + os.unlink(self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert7.pem"), self.test_rc.cert) + with open(self.test_rc.cert, "w") as f: + f.write("cert") + os.unlink(self.test_rc.fullchain) + os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain7.pem"), self.test_rc.fullchain) + with open(self.test_rc.fullchain, "w") as f: + f.write("fullchain") + self.assertEqual(self.test_rc.latest_common_version(), 5) + # If we have everything from a much later version, it does change + # the result + ver = 17 + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertEqual(self.test_rc.latest_common_version(), 17) + self.assertEqual(self.test_rc.next_free_version(), 18) + + def test_update_link_to(self): + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertEqual(ver, self.test_rc.current_version(kind)) + self.test_rc.update_link_to("cert", 3) + self.test_rc.update_link_to("privkey", 2) + self.assertEqual(3, self.test_rc.current_version("cert")) + self.assertEqual(2, self.test_rc.current_version("privkey")) + self.assertEqual(5, self.test_rc.current_version("chain")) + self.assertEqual(5, self.test_rc.current_version("fullchain")) + # Currently we are allowed to update to a version that doesn't + # exist + self.test_rc.update_link_to("chain", 3000) + # However, current_version doesn't allow querying the resulting + # version (because it's a broken link). + self.assertEqual(os.path.basename(os.readlink(self.test_rc.chain)), + "chain3000.pem") + + def test_version(self): + 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("cert") + # TODO: We should probably test that the directory is still the + # same, but it's tricky because we can get an absolute + # path out when we put a relative path in. + self.assertEqual("cert8.pem", + os.path.basename(self.test_rc.version("cert", 8))) + + def test_update_all_links_to(self): + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertEqual(ver, self.test_rc.current_version(kind)) + self.assertEqual(self.test_rc.latest_common_version(), 5) + for ver in range(1, 6): + self.test_rc.update_all_links_to(ver) + for kind in ALL_FOUR: + self.assertEqual(ver, self.test_rc.current_version(kind)) + self.assertEqual(self.test_rc.latest_common_version(), 5) + + def test_has_pending_deployment(self): + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertEqual(ver, self.test_rc.current_version(kind)) + for ver in range(1, 6): + self.test_rc.update_all_links_to(ver) + for kind in ALL_FOUR: + self.assertEqual(ver, self.test_rc.current_version(kind)) + if ver < 5: + self.assertTrue(self.test_rc.has_pending_deployment()) + else: + self.assertFalse(self.test_rc.has_pending_deployment()) + + def test_notbefore(self): + test_cert = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/cert.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) + for result in (self.test_rc.notbefore(), self.test_rc.notbefore(12)): + self.assertEqual(result, datetime.datetime.utcfromtimestamp(1418337285).replace(tzinfo=pytz.UTC)) + self.assertEqual(result.utcoffset(), datetime.timedelta(0)) + # 2014-12-11 22:34:45+00:00 = Unix time 1418337285 + + def test_notafter(self): + test_cert = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/cert.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) + for result in (self.test_rc.notafter(), self.test_rc.notafter(12)): + self.assertEqual(result, datetime.datetime.utcfromtimestamp(1418942085).replace(tzinfo=pytz.UTC)) + self.assertEqual(result.utcoffset(), datetime.timedelta(0)) + # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 + + @mock.patch("letsencrypt.client.renewer.datetime") + def test_should_autodeploy(self, mock_datetime): + # Autodeployment turned off + self.test_rc.configuration["autodeploy"] = "0" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["autodeploy"] = "1" + # No pending deployment + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.assertFalse(self.test_rc.should_autodeploy()) + test_cert = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/cert.pem") + mock_datetime.timedelta = datetime.timedelta + # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1418472000) + self.test_rc.update_all_links_to(3) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + self.test_rc.configuration["deploy_before_expiry"] = "2 months" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "1 week" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "4 days" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "2 days" + self.assertFalse(self.test_rc.should_autodeploy()) + # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1241179200) + self.test_rc.configuration["deploy_before_expiry"] = "8 hours" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "2 days" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "40 days" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "9 months" + self.assertFalse(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "7 years" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "11 years 2 months" + self.assertTrue(self.test_rc.should_autodeploy()) + # 2015-01-01 (after expiry has already happened) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1420070400) + self.test_rc.configuration["deploy_before_expiry"] = "0 seconds" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "10 seconds" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "10 minutes" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "10 weeks" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "10 months" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "10 years" + self.assertTrue(self.test_rc.should_autodeploy()) + self.test_rc.configuration["deploy_before_expiry"] = "300 months" + self.assertTrue(self.test_rc.should_autodeploy()) + + @mock.patch("letsencrypt.client.renewer.datetime") + @mock.patch("letsencrypt.client.renewer.RenewableCert.ocsp_revoked") + def test_should_autorenew(self, mock_ocsp, mock_datetime): + # Autorenewal turned off + self.test_rc.configuration["autorenew"] = "0" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["autorenew"] = "1" + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}12.pem".format(kind)), where) + with open(where, "w") as f: + f.write(kind) + test_cert = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/cert.pem") + # Mandatory renewal on the basis of OCSP revocation + mock_ocsp.return_value = True + self.assertTrue(self.test_rc.should_autorenew()) + mock_ocsp.return_value = False + # On the basis of expiry time + mock_datetime.timedelta = datetime.timedelta + # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1418472000) + self.test_rc.update_all_links_to(12) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + self.test_rc.configuration["renew_before_expiry"] = "2 months" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "1 week" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "4 days" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "2 days" + self.assertFalse(self.test_rc.should_autorenew()) + # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1241179200) + self.test_rc.configuration["renew_before_expiry"] = "8 hours" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "2 days" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "40 days" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "9 months" + self.assertFalse(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "7 years" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "11 years 2 months" + self.assertTrue(self.test_rc.should_autorenew()) + # 2015-01-01 (after expiry has already happened) + mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1420070400) + self.test_rc.configuration["renew_before_expiry"] = "0 seconds" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "10 seconds" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "10 minutes" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "10 weeks" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "10 months" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "10 years" + self.assertTrue(self.test_rc.should_autorenew()) + self.test_rc.configuration["renew_before_expiry"] = "300 months" + self.assertTrue(self.test_rc.should_autorenew()) + + def test_save_successor(self): + for ver in range(1, 6): + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(kind) + if os.path.islink(where): + os.unlink(where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + with open(where, "w") as f: + f.write(kind) + self.test_rc.update_all_links_to(3) + self.assertEqual(6, self.test_rc.save_successor(3, "new cert", "new chain")) + with open(self.test_rc.version("cert", 6)) as f: + self.assertEqual(f.read(), "new cert") + with open(self.test_rc.version("chain", 6)) as f: + self.assertEqual(f.read(), "new chain") + with open(self.test_rc.version("fullchain", 6)) as f: + self.assertEqual(f.read(), "new cert" + "new chain") + # version 6 of the key should be a link back to version 3 + self.assertFalse(os.path.islink(self.test_rc.version("privkey", 3))) + self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) + # Let's try two more updates + self.assertEqual(7, self.test_rc.save_successor(6, "again", "newer chain")) + self.assertEqual(8, self.test_rc.save_successor(7, "hello", "other chain")) + # All of the subsequent versions should link directly to the original + # privkey. + self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) + self.assertTrue(os.path.islink(self.test_rc.version("privkey", 7))) + self.assertTrue(os.path.islink(self.test_rc.version("privkey", 8))) + self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 6))), "privkey3.pem") + self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 7))), "privkey3.pem") + self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 8))), "privkey3.pem") + for kind in ALL_FOUR: + self.assertEqual(self.test_rc.available_versions(kind), range(1, 9)) + self.assertEqual(self.test_rc.current_version(kind), 3) + # Test updating from latest version rather than old version + self.test_rc.update_all_links_to(8) + self.assertEqual(9, self.test_rc.save_successor(8, "last", "attempt")) + for kind in ALL_FOUR: + self.assertEqual(self.test_rc.available_versions(kind), range(1, 10)) + self.assertEqual(self.test_rc.current_version(kind), 8) + with open(self.test_rc.version("fullchain", 9)) as f: + self.assertEqual(f.read(), "last" + "attempt") + + def test_bad_kind(self): + self.assertRaises(ValueError, self.test_rc.current_target, "elephant") + self.assertRaises(ValueError, self.test_rc.current_version, "elephant") + self.assertRaises(ValueError, self.test_rc.version, "elephant", 17) + self.assertRaises(ValueError, self.test_rc.available_versions, "elephant") + self.assertRaises(ValueError, self.test_rc.newest_available_version, "elephant") + self.assertRaises(ValueError, self.test_rc.update_link_to, "elephant", 17) + + def test_ocsp_revoked(self): + # XXX: This is currently hardcoded to False due to a lack of an + # OCSP server to test against. + self.assertEqual(self.test_rc.ocsp_revoked(), False) + + def test_parse_time_interval(self): + from letsencrypt.client import renewer + # XXX: I'm not sure if intervals related to years and months + # take account of the current date (if so, some of these + # may fail in the future, like in leap years or even in + # months of different lengths!) + self.assertEqual(renewer.parse_time_interval(""), + datetime.timedelta(0)) + self.assertEqual(renewer.parse_time_interval("1 hour"), + datetime.timedelta(0, 3600)) + self.assertEqual(renewer.parse_time_interval("17 days"), + datetime.timedelta(17)) + # Days are assumed if no unit is specified. + self.assertEqual(renewer.parse_time_interval("23"), + datetime.timedelta(23)) + self.assertEqual(renewer.parse_time_interval("1 month"), + datetime.timedelta(31)) + self.assertEqual(renewer.parse_time_interval("7 weeks"), + datetime.timedelta(49)) + self.assertEqual(renewer.parse_time_interval("1 year 1 day"), + datetime.timedelta(366)) + self.assertEqual(renewer.parse_time_interval("1 year-1 day"), + datetime.timedelta(364)) + self.assertEqual(renewer.parse_time_interval("4 years"), + datetime.timedelta(1461)) + + @mock.patch("letsencrypt.client.renewer.notify") + @mock.patch("letsencrypt.client.renewer.RenewableCert") + @mock.patch("letsencrypt.client.renewer.renew") + def test_main(self, mock_renew, mock_rc, mock_notify): + """Test for main() function.""" + from letsencrypt.client import renewer + mock_rc_instance = mock.MagicMock() + mock_rc_instance.should_autodeploy.return_value = True + 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.defaults["renewal_configs_dir"], "example.org.conf"), "w") as f: + # This isn't actually parsed in this test; we have a separate + # test_initialization that tests the initialization, assuming + # that configobj can correctly parse the config file. + f.write("cert = cert.pem\nprivkey = privkey.pem\n") + f.write("chain = chain.pem\nfullchain = fullchain.pem\n") + with open(os.path.join(self.defaults["renewal_configs_dir"], "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) + 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) + self.assertEqual(mock_renew.call_count, 2) + # If we have instances that don't need any work done, no work should + # be done (call counts associated with processing deployments or + # renewals should not increase). + mock_happy_instance = mock.MagicMock() + mock_happy_instance.should_autodeploy.return_value = False + 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) + 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) + self.assertEqual(mock_renew.call_count, 2) + +if __name__ == "__main__": + unittest.main() From 64eaa1b1a49c6f5a33deff4ffd28df551c1a424c Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Tue, 21 Apr 2015 21:46:07 -0700 Subject: [PATCH 03/66] Add string format indices for Python 2.6 --- letsencrypt/client/renewer.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index a4a8e071e..56ce35658 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -101,7 +101,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return False # The link must point to a file that follows the archive # naming convention - pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): return False # It is NOT required that the link's target be a regular @@ -151,7 +151,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes "chain7.pem", returns the integer 7.""" if kind not in ALL_FOUR: raise ValueError("unknown kind of item") - pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) target = self.current_target(kind) if not target or not os.path.exists(target): target = "" @@ -168,7 +168,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if kind not in ALL_FOUR: raise ValueError("unknown kind of item") where = os.path.dirname(self.current_target(kind)) - return os.path.join(where, "{}{}.pem".format(kind, version)) + return os.path.join(where, "{0}{1}.pem".format(kind, version)) def available_versions(self, kind): """Which alternative versions of the specified kind of item @@ -178,7 +178,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise ValueError("unknown kind of item") where = os.path.dirname(self.current_target(kind)) files = os.listdir(where) - pattern = re.compile(r"^{}([0-9]+)\.pem$".format(kind)) + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) matches = [pattern.match(f) for f in files] return sorted([int(m.groups()[0]) for m in matches if m]) @@ -220,7 +220,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if kind not in ALL_FOUR: raise ValueError("unknown kind of item") link = self.__getattribute__(kind) - filename = "{}{}.pem".format(kind, version) + filename = "{0}{1}.pem".format(kind, version) # Relative rather than absolute target directory target_directory = os.path.dirname(os.readlink(link)) # TODO: it could be safer to make the link first under a temporary @@ -392,23 +392,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes archive = self.configuration["official_archive_dir"] prefix = os.path.join(archive, self.lineagename) cert_target = os.path.join( - prefix, "cert{}.pem".format(target_version)) + prefix, "cert{0}.pem".format(target_version)) privkey_target = os.path.join( - prefix, "privkey{}.pem".format(target_version)) + prefix, "privkey{0}.pem".format(target_version)) chain_target = os.path.join( - prefix, "chain{}.pem".format(target_version)) + prefix, "chain{0}.pem".format(target_version)) fullchain_target = os.path.join( - prefix, "fullchain{}.pem".format(target_version)) + prefix, "fullchain{0}.pem".format(target_version)) with open(cert_target, "w") as f: f.write(new_cert) # The behavior below always keeps the prior key by creating a new # symlink to the old key or the target of the old key symlink. old_privkey = os.path.join( - prefix, "privkey{}.pem".format(prior_version)) + prefix, "privkey{0}.pem".format(prior_version)) if os.path.islink(old_privkey): old_privkey = os.readlink(old_privkey) else: - old_privkey = "privkey{}.pem".format(prior_version) + old_privkey = "privkey{0}.pem".format(prior_version) os.symlink(old_privkey, privkey_target) with open(chain_target, "w") as f: f.write(new_chain) From aabbee3608babfda39fdc3cedee698ab6fec10a1 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Tue, 21 Apr 2015 21:52:04 -0700 Subject: [PATCH 04/66] Also add format indices to renewer tests --- letsencrypt/client/tests/renewer_test.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index d0136f4bf..6075e93f4 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -138,7 +138,7 @@ class RenewableCertTests(unittest.TestCase): def test_current_version(self): for ver in (1, 5, 10, 20): - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert{}.pem".format(ver)), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", "cert{0}.pem".format(ver)), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: f.write("cert") os.unlink(self.test_rc.cert) @@ -154,7 +154,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(self.test_rc.latest_common_version(), 5) @@ -185,7 +185,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(self.test_rc.latest_common_version(), 17) @@ -197,7 +197,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -231,7 +231,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -248,7 +248,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -295,7 +295,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertFalse(self.test_rc.should_autodeploy()) @@ -355,7 +355,7 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["autorenew"] = "1" for kind in ALL_FOUR: where = self.test_rc.__getattribute__(kind) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}12.pem".format(kind)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}12.pem".format(kind)), where) with open(where, "w") as f: f.write(kind) test_cert = pkg_resources.resource_string( @@ -416,7 +416,7 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{}{}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.test_rc.update_all_links_to(3) From b13b6de79f45cad21f12b668742f9b9a82c090f5 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 22 Apr 2015 12:24:33 -0700 Subject: [PATCH 05/66] Small fixes; new_lineage method and tests for it --- letsencrypt/client/renewer.py | 7 ++-- letsencrypt/client/tests/renewer_test.py | 44 ++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 56ce35658..e72fa4371 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -343,7 +343,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if os.path.exists(our_archive): raise ValueError("archive directory exists for " + lineagename) if os.path.exists(our_live_dir): - raise ValueError("archive directory exists for " + lineagename) + raise ValueError("live directory exists for " + lineagename) os.mkdir(our_archive) os.mkdir(our_live_dir) relative_archive = os.path.join("..", "..", "archive", lineagename) @@ -363,6 +363,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes f.write(cert) with open(privkey_target, "w") as f: f.write(privkey) + # XXX: Let's make sure to get the file permissions right here with open(chain_target, "w") as f: f.write(chain) with open(fullchain_target, "w") as f: @@ -376,7 +377,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # TODO: add human-readable comments explaining other available # parameters new_config.write() - return cls(config_filename, config) + return cls(new_config, config) def save_successor(self, prior_version, new_cert, new_chain): """Save a new cert and chain as a successor of a specific prior @@ -435,6 +436,8 @@ def main(config=DEFAULTS): """main function for autorenewer script.""" for i in os.listdir(config["renewal_configs_dir"]): print "Processing", i + if not i.endswith(".conf"): + continue cert = RenewableCert(i) if cert.should_autodeploy(): cert.update_all_links_to(cert.latest_common_version()) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 6075e93f4..31b85fa4c 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -453,6 +453,47 @@ class RenewableCertTests(unittest.TestCase): with open(self.test_rc.version("fullchain", 9)) as f: self.assertEqual(f.read(), "last" + "attempt") + def test_new_lineage(self): + """Test for new_lineage() class method.""" + from letsencrypt.client import renewer + config_dir = self.defaults["renewal_configs_dir"] + archive_dir = self.defaults["official_archive_dir"] + live_dir = self.defaults["live_dir"] + result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert", + "privkey", "chain", + self.defaults) + # This consistency check tests most relevant properties about the + # newly created cert lineage. + self.assertTrue(result.consistent()) + self.assertTrue(os.path.exists(os.path.join(config_dir, + "the-lineage.com.conf"))) + with open(result.fullchain) as f: + self.assertEqual(f.read(), "cert" + "chain") + # Let's do it again and make sure it makes a different lineage + result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + "privkey2", "chain2", + self.defaults) + print os.listdir(config_dir) + self.assertTrue(os.path.exists( + os.path.join(config_dir, "the-lineage.com-0001.conf"))) + # Now trigger the detection of already existing files + os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) + self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + "the-lineage.com", "cert3", "privkey3", "chain3", + self.defaults) + os.mkdir(os.path.join(archive_dir, "other-example.com")) + self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + "other-example.com", "cert4", "privkey4", "chain4", + self.defaults) + + @mock.patch("letsencrypt.client.renewer.le_util.unique_lineage_name") + def test_invalid_config_filename(self, mock_uln): + from letsencrypt.client import renewer + mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" + self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + "example.com", "cert", "privkey", "chain", + self.defaults) + def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") self.assertRaises(ValueError, self.test_rc.current_version, "elephant") @@ -503,6 +544,9 @@ class RenewableCertTests(unittest.TestCase): 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.defaults["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.defaults["renewal_configs_dir"], "example.org.conf"), "w") as f: # This isn't actually parsed in this test; we have a separate # test_initialization that tests the initialization, assuming From cdf8ccbd34ff49ba62ff517e4629e47441f8973e Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 22 Apr 2015 12:36:57 -0700 Subject: [PATCH 06/66] Split out runnable renewer into separate file --- letsencrypt/client/renewer.py | 11 ----------- letsencrypt/scripts/renewer.py | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 11 deletions(-) create mode 100755 letsencrypt/scripts/renewer.py diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index e72fa4371..af8fcf573 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -1,5 +1,3 @@ -#!/usr/bin/env python - """Renewer tool to handle autorenewal and autodeployment of renewed certs within lineages of successor certificates, according to configuration.""" @@ -452,12 +450,3 @@ def main(config=DEFAULTS): renew(cert, old_version) notify.notify("Autorenewed a cert!!!", "root", "It worked!") # TODO: explain what happened - - -if __name__ == "__main__": - if ("renewer_enabled" in DEFAULTS - and not DEFAULTS.as_bool("renewer_enabled")): - print "Renewer is disabled by configuration! Exiting." - raise SystemExit - else: - main() diff --git a/letsencrypt/scripts/renewer.py b/letsencrypt/scripts/renewer.py new file mode 100755 index 000000000..89cd129ad --- /dev/null +++ b/letsencrypt/scripts/renewer.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python + +from letsencrypt.client import renewer + +DEFAULTS = configobj.ConfigObj("renewal.conf") +DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" +DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" +DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" + +if __name__ == 'main': + if ("renewer_enabled" in DEFAULTS + and not DEFAULTS.as_bool("renewer_enabled")): + print "Renewer is disabled by configuration! Exiting." + raise SystemExit + else: + renewer.main() From e4f59e60e1e13f691205ee51ba891a25f9e4afe5 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 22 Apr 2015 12:42:32 -0700 Subject: [PATCH 07/66] import configobj --- letsencrypt/scripts/renewer.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/letsencrypt/scripts/renewer.py b/letsencrypt/scripts/renewer.py index 89cd129ad..14dae7a1b 100755 --- a/letsencrypt/scripts/renewer.py +++ b/letsencrypt/scripts/renewer.py @@ -1,5 +1,7 @@ #!/usr/bin/env python +import configobj + from letsencrypt.client import renewer DEFAULTS = configobj.ConfigObj("renewal.conf") From eeb625063bfc7bc22644b77f12b05a7825ce6d16 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 1 May 2015 18:38:04 -0700 Subject: [PATCH 08/66] Changes to pass pylint --- letsencrypt/client/renewer.py | 20 +-- letsencrypt/client/tests/renewer_test.py | 199 ++++++++++++++++------- letsencrypt/scripts/renewer.py | 2 + 3 files changed, 151 insertions(+), 70 deletions(-) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index af8fcf573..af271fe77 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -336,19 +336,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # lineagename will now potentially be modified based on what # renewal configuration file could actually be created lineagename = os.path.basename(config_filename)[:-5] - our_archive = os.path.join(archive_dir, lineagename) - our_live_dir = os.path.join(live_dir, lineagename) - if os.path.exists(our_archive): + archive = os.path.join(archive_dir, lineagename) + live_dir = os.path.join(live_dir, lineagename) + if os.path.exists(archive): raise ValueError("archive directory exists for " + lineagename) - if os.path.exists(our_live_dir): + if os.path.exists(live_dir): raise ValueError("live directory exists for " + lineagename) - os.mkdir(our_archive) - os.mkdir(our_live_dir) + os.mkdir(archive) + os.mkdir(live_dir) relative_archive = os.path.join("..", "..", "archive", lineagename) - cert_target = os.path.join(our_live_dir, "cert.pem") - privkey_target = os.path.join(our_live_dir, "privkey.pem") - chain_target = os.path.join(our_live_dir, "chain.pem") - fullchain_target = os.path.join(our_live_dir, "fullchain.pem") + cert_target = os.path.join(live_dir, "cert.pem") + privkey_target = os.path.join(live_dir, "privkey.pem") + chain_target = os.path.join(live_dir, "chain.pem") + fullchain_target = os.path.join(live_dir, "fullchain.pem") os.symlink(os.path.join(relative_archive, "cert1.pem"), cert_target) os.symlink(os.path.join(relative_archive, "privkey1.pem"), diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 31b85fa4c..1ac4ec853 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -13,6 +13,7 @@ import unittest ALL_FOUR = ("cert", "privkey", "chain", "fullchain") class RenewableCertTests(unittest.TestCase): + # pylint: disable=too-many-public-methods """Tests for the RenewableCert class as well as other functions within renewer.py.""" def setUp(self): @@ -26,10 +27,14 @@ class RenewableCertTests(unittest.TestCase): defaults["official_archive_dir"] = os.path.join(self.tempdir, "archive") defaults["renewal_configs_dir"] = os.path.join(self.tempdir, "configs") config = configobj.ConfigObj() - config["cert"] = os.path.join(self.tempdir, "live", "example.org", "cert.pem") - config["privkey"] = os.path.join(self.tempdir, "live", "example.org", "privkey.pem") - config["chain"] = os.path.join(self.tempdir, "live", "example.org", "chain.pem") - config["fullchain"] = os.path.join(self.tempdir, "live", "example.org", "fullchain.pem") + config["cert"] = os.path.join(self.tempdir, "live", "example.org", + "cert.pem") + config["privkey"] = os.path.join(self.tempdir, "live", "example.org", + "privkey.pem") + config["chain"] = os.path.join(self.tempdir, "live", "example.org", + "chain.pem") + config["fullchain"] = os.path.join(self.tempdir, "live", "example.org", + "fullchain.pem") config.filename = os.path.join(self.tempdir, "configs", "example.org.conf") self.defaults = defaults # for main() test @@ -40,10 +45,21 @@ class RenewableCertTests(unittest.TestCase): def test_initialization(self): self.assertEqual(self.test_rc.lineagename, "example.org") - self.assertEqual(self.test_rc.cert, os.path.join(self.tempdir, "live", "example.org", "cert.pem")) - self.assertEqual(self.test_rc.privkey, os.path.join(self.tempdir, "live", "example.org", "privkey.pem")) - self.assertEqual(self.test_rc.chain, os.path.join(self.tempdir, "live", "example.org", "chain.pem")) - self.assertEqual(self.test_rc.fullchain, os.path.join(self.tempdir, "live", "example.org", "fullchain.pem")) + self.assertEqual(self.test_rc.cert, os.path.join(self.tempdir, "live", + "example.org", + "cert.pem")) + self.assertEqual(self.test_rc.privkey, os.path.join(self.tempdir, + "live", + "example.org", + "privkey.pem")) + self.assertEqual(self.test_rc.chain, os.path.join(self.tempdir, + "live", + "example.org", + "chain.pem")) + self.assertEqual(self.test_rc.fullchain, os.path.join(self.tempdir, + "live", + "example.org", + "fullchain.pem")) def test_renewal_config_filename_not_ending_in_conf(self): """Test that the RenewableCert constructor will complain if @@ -58,7 +74,7 @@ class RenewableCertTests(unittest.TestCase): config.filename = "/tmp/sillyfile" self.assertRaises(ValueError, renewer.RenewableCert, config, defaults) - def test_consistent(self): + def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert self.test_rc.cert = "relative/path" # Absolute path for item requirement @@ -84,7 +100,8 @@ class RenewableCertTests(unittest.TestCase): os.symlink(os.path.join("..", "cert17.pem"), self.test_rc.cert) os.symlink(os.path.join("..", "privkey17.pem"), self.test_rc.privkey) os.symlink(os.path.join("..", "chain17.pem"), self.test_rc.chain) - os.symlink(os.path.join("..", "fullchain17.pem"), self.test_rc.fullchain) + os.symlink(os.path.join("..", "fullchain17.pem"), + self.test_rc.fullchain) self.assertEqual(self.test_rc.consistent(), False) os.unlink(self.test_rc.cert) os.unlink(self.test_rc.privkey) @@ -92,19 +109,26 @@ class RenewableCertTests(unittest.TestCase): os.unlink(self.test_rc.fullchain) # Items must point to desired place if they are absolute os.symlink(os.path.join(self.tempdir, "cert17.pem"), self.test_rc.cert) - os.symlink(os.path.join(self.tempdir, "privkey17.pem"), self.test_rc.privkey) - os.symlink(os.path.join(self.tempdir, "chain17.pem"), self.test_rc.chain) - os.symlink(os.path.join(self.tempdir, "fullchain17.pem"), self.test_rc.fullchain) + os.symlink(os.path.join(self.tempdir, "privkey17.pem"), + self.test_rc.privkey) + os.symlink(os.path.join(self.tempdir, "chain17.pem"), + self.test_rc.chain) + os.symlink(os.path.join(self.tempdir, "fullchain17.pem"), + self.test_rc.fullchain) self.assertEqual(self.test_rc.consistent(), False) os.unlink(self.test_rc.cert) os.unlink(self.test_rc.privkey) os.unlink(self.test_rc.chain) os.unlink(self.test_rc.fullchain) # Items must point to things that exist - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert17.pem"), self.test_rc.cert) - os.symlink(os.path.join("..", "..", "archive", "example.org", "privkey17.pem"), self.test_rc.privkey) - os.symlink(os.path.join("..", "..", "archive", "example.org", "chain17.pem"), self.test_rc.chain) - os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain17.pem"), self.test_rc.fullchain) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "privkey17.pem"), self.test_rc.privkey) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "chain17.pem"), self.test_rc.chain) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "fullchain17.pem"), self.test_rc.fullchain) self.assertEqual(self.test_rc.consistent(), False) # This version should work with open(self.test_rc.cert, "w") as f: @@ -118,31 +142,43 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.consistent(), True) # Items must point to things that follow the naming convention os.unlink(self.test_rc.fullchain) - os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain_17.pem"), self.test_rc.fullchain) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "fullchain_17.pem"), self.test_rc.fullchain) with open(self.test_rc.fullchain, "w") as f: f.write("wrongly-named fullchain") self.assertEqual(self.test_rc.consistent(), False) def test_current_target(self): # Relative path logic - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert17.pem"), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: f.write("cert") - self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"))) + self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), + os.path.join(self.tempdir, "archive", + "example.org", + "cert17.pem"))) # Absolute path logic os.unlink(self.test_rc.cert) - os.symlink(os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"), self.test_rc.cert) + os.symlink(os.path.join(self.tempdir, "archive", "example.org", + "cert17.pem"), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: f.write("cert") - self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), os.path.join(self.tempdir, "archive", "example.org", "cert17.pem"))) + self.assertTrue(os.path.samefile(self.test_rc.current_target("cert"), + os.path.join(self.tempdir, "archive", + "example.org", + "cert17.pem"))) def test_current_version(self): for ver in (1, 5, 10, 20): - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert{0}.pem".format(ver)), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert{0}.pem".format(ver)), + self.test_rc.cert) with open(self.test_rc.cert, "w") as f: f.write("cert") os.unlink(self.test_rc.cert) - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert10.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert10.pem"), self.test_rc.cert) self.assertEqual(self.test_rc.current_version("cert"), 10) def test_no_current_version(self): @@ -154,7 +190,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(self.test_rc.latest_common_version(), 5) @@ -162,7 +199,8 @@ class RenewableCertTests(unittest.TestCase): # Having one kind of file of a later version doesn't change the # result os.unlink(self.test_rc.privkey) - os.symlink(os.path.join("..", "..", "archive", "example.org", "privkey7.pem"), self.test_rc.privkey) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "privkey7.pem"), self.test_rc.privkey) with open(self.test_rc.privkey, "w") as f: f.write("privkey") self.assertEqual(self.test_rc.latest_common_version(), 5) @@ -170,11 +208,13 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.next_free_version(), 8) # Nor does having three out of four change the result os.unlink(self.test_rc.cert) - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert7.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert7.pem"), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: f.write("cert") os.unlink(self.test_rc.fullchain) - os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain7.pem"), self.test_rc.fullchain) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "fullchain7.pem"), self.test_rc.fullchain) with open(self.test_rc.fullchain, "w") as f: f.write("fullchain") self.assertEqual(self.test_rc.latest_common_version(), 5) @@ -185,7 +225,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(self.test_rc.latest_common_version(), 17) @@ -197,7 +238,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -216,7 +258,8 @@ class RenewableCertTests(unittest.TestCase): "chain3000.pem") def test_version(self): - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert12.pem"), self.test_rc.cert) + 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("cert") # TODO: We should probably test that the directory is still the @@ -231,7 +274,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -248,7 +292,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertEqual(ver, self.test_rc.current_version(kind)) @@ -264,27 +309,34 @@ class RenewableCertTests(unittest.TestCase): def test_notbefore(self): test_cert = pkg_resources.resource_string( "letsencrypt.client.tests", "testdata/cert.pem") - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert12.pem"), self.test_rc.cert) + 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) + desired_time = datetime.datetime.utcfromtimestamp(1418337285) + desired_time = desired_time.replace(tzinfo=pytz.UTC) for result in (self.test_rc.notbefore(), self.test_rc.notbefore(12)): - self.assertEqual(result, datetime.datetime.utcfromtimestamp(1418337285).replace(tzinfo=pytz.UTC)) + self.assertEqual(result, desired_time) self.assertEqual(result.utcoffset(), datetime.timedelta(0)) # 2014-12-11 22:34:45+00:00 = Unix time 1418337285 def test_notafter(self): test_cert = pkg_resources.resource_string( "letsencrypt.client.tests", "testdata/cert.pem") - os.symlink(os.path.join("..", "..", "archive", "example.org", "cert12.pem"), self.test_rc.cert) + 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) + desired_time = datetime.datetime.utcfromtimestamp(1418942085) + desired_time = desired_time.replace(tzinfo=pytz.UTC) for result in (self.test_rc.notafter(), self.test_rc.notafter(12)): - self.assertEqual(result, datetime.datetime.utcfromtimestamp(1418942085).replace(tzinfo=pytz.UTC)) + self.assertEqual(result, desired_time) self.assertEqual(result.utcoffset(), datetime.timedelta(0)) # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 @mock.patch("letsencrypt.client.renewer.datetime") def test_should_autodeploy(self, mock_datetime): + # pylint: disable=too-many-statements # Autodeployment turned off self.test_rc.configuration["autodeploy"] = "0" self.assertFalse(self.test_rc.should_autodeploy()) @@ -295,7 +347,8 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.assertFalse(self.test_rc.should_autodeploy()) @@ -303,7 +356,8 @@ class RenewableCertTests(unittest.TestCase): "letsencrypt.client.tests", "testdata/cert.pem") mock_datetime.timedelta = datetime.timedelta # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1418472000) + sometime = datetime.datetime.utcfromtimestamp(1418472000) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.update_all_links_to(3) with open(self.test_rc.cert, "w") as f: f.write(test_cert) @@ -316,7 +370,8 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["deploy_before_expiry"] = "2 days" self.assertFalse(self.test_rc.should_autodeploy()) # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1241179200) + sometime = datetime.datetime.utcfromtimestamp(1241179200) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.configuration["deploy_before_expiry"] = "8 hours" self.assertFalse(self.test_rc.should_autodeploy()) self.test_rc.configuration["deploy_before_expiry"] = "2 days" @@ -327,10 +382,12 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(self.test_rc.should_autodeploy()) self.test_rc.configuration["deploy_before_expiry"] = "7 years" self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "11 years 2 months" + self.test_rc.configuration["deploy_before_expiry"] = "11 years " + self.test_rc.configuration["deploy_before_expiry"] += "2 months" self.assertTrue(self.test_rc.should_autodeploy()) # 2015-01-01 (after expiry has already happened) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1420070400) + sometime = datetime.datetime.utcfromtimestamp(1420070400) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.configuration["deploy_before_expiry"] = "0 seconds" self.assertTrue(self.test_rc.should_autodeploy()) self.test_rc.configuration["deploy_before_expiry"] = "10 seconds" @@ -349,13 +406,15 @@ class RenewableCertTests(unittest.TestCase): @mock.patch("letsencrypt.client.renewer.datetime") @mock.patch("letsencrypt.client.renewer.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp, mock_datetime): + # pylint: disable=too-many-statements # Autorenewal turned off self.test_rc.configuration["autorenew"] = "0" self.assertFalse(self.test_rc.should_autorenew()) self.test_rc.configuration["autorenew"] = "1" for kind in ALL_FOUR: where = self.test_rc.__getattribute__(kind) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}12.pem".format(kind)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}12.pem".format(kind)), where) with open(where, "w") as f: f.write(kind) test_cert = pkg_resources.resource_string( @@ -367,7 +426,8 @@ class RenewableCertTests(unittest.TestCase): # On the basis of expiry time mock_datetime.timedelta = datetime.timedelta # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1418472000) + sometime = datetime.datetime.utcfromtimestamp(1418472000) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.update_all_links_to(12) with open(self.test_rc.cert, "w") as f: f.write(test_cert) @@ -380,7 +440,8 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["renew_before_expiry"] = "2 days" self.assertFalse(self.test_rc.should_autorenew()) # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1241179200) + sometime = datetime.datetime.utcfromtimestamp(1241179200) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.configuration["renew_before_expiry"] = "8 hours" self.assertFalse(self.test_rc.should_autorenew()) self.test_rc.configuration["renew_before_expiry"] = "2 days" @@ -394,7 +455,8 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["renew_before_expiry"] = "11 years 2 months" self.assertTrue(self.test_rc.should_autorenew()) # 2015-01-01 (after expiry has already happened) - mock_datetime.datetime.utcnow.return_value = datetime.datetime.utcfromtimestamp(1420070400) + sometime = datetime.datetime.utcfromtimestamp(1420070400) + mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.configuration["renew_before_expiry"] = "0 seconds" self.assertTrue(self.test_rc.should_autorenew()) self.test_rc.configuration["renew_before_expiry"] = "10 seconds" @@ -416,11 +478,13 @@ class RenewableCertTests(unittest.TestCase): where = self.test_rc.__getattribute__(kind) if os.path.islink(where): os.unlink(where) - os.symlink(os.path.join("..", "..", "archive", "example.org", "{0}{1}.pem".format(kind, ver)), where) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "{0}{1}.pem".format(kind, ver)), where) with open(where, "w") as f: f.write(kind) self.test_rc.update_all_links_to(3) - self.assertEqual(6, self.test_rc.save_successor(3, "new cert", "new chain")) + self.assertEqual(6, self.test_rc.save_successor(3, "new cert", + "new chain")) with open(self.test_rc.version("cert", 6)) as f: self.assertEqual(f.read(), "new cert") with open(self.test_rc.version("chain", 6)) as f: @@ -431,16 +495,24 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(os.path.islink(self.test_rc.version("privkey", 3))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) # Let's try two more updates - self.assertEqual(7, self.test_rc.save_successor(6, "again", "newer chain")) - self.assertEqual(8, self.test_rc.save_successor(7, "hello", "other chain")) + self.assertEqual(7, self.test_rc.save_successor(6, "again", + "newer chain")) + self.assertEqual(8, self.test_rc.save_successor(7, "hello", + "other chain")) # All of the subsequent versions should link directly to the original # privkey. self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 7))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 8))) - self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 6))), "privkey3.pem") - self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 7))), "privkey3.pem") - self.assertEqual(os.path.basename(os.readlink(self.test_rc.version("privkey", 8))), "privkey3.pem") + self.assertEqual( + os.path.basename(os.readlink(self.test_rc.version("privkey", 6))), + "privkey3.pem") + self.assertEqual( + os.path.basename(os.readlink(self.test_rc.version("privkey", 7))), + "privkey3.pem") + self.assertEqual( + os.path.basename(os.readlink(self.test_rc.version("privkey", 8))), + "privkey3.pem") for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), range(1, 9)) self.assertEqual(self.test_rc.current_version(kind), 3) @@ -448,7 +520,8 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.update_all_links_to(8) self.assertEqual(9, self.test_rc.save_successor(8, "last", "attempt")) for kind in ALL_FOUR: - self.assertEqual(self.test_rc.available_versions(kind), range(1, 10)) + self.assertEqual(self.test_rc.available_versions(kind), + range(1, 10)) self.assertEqual(self.test_rc.current_version(kind), 8) with open(self.test_rc.version("fullchain", 9)) as f: self.assertEqual(f.read(), "last" + "attempt") @@ -498,9 +571,12 @@ class RenewableCertTests(unittest.TestCase): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") self.assertRaises(ValueError, self.test_rc.current_version, "elephant") self.assertRaises(ValueError, self.test_rc.version, "elephant", 17) - self.assertRaises(ValueError, self.test_rc.available_versions, "elephant") - self.assertRaises(ValueError, self.test_rc.newest_available_version, "elephant") - self.assertRaises(ValueError, self.test_rc.update_link_to, "elephant", 17) + self.assertRaises(ValueError, self.test_rc.available_versions, + "elephant") + self.assertRaises(ValueError, self.test_rc.newest_available_version, + "elephant") + self.assertRaises(ValueError, self.test_rc.update_link_to, + "elephant", 17) def test_ocsp_revoked(self): # XXX: This is currently hardcoded to False due to a lack of an @@ -544,16 +620,19 @@ class RenewableCertTests(unittest.TestCase): 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.defaults["renewal_configs_dir"], "README"), "w") as f: + with open(os.path.join(self.defaults["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.defaults["renewal_configs_dir"], "example.org.conf"), "w") as f: + with open(os.path.join(self.defaults["renewal_configs_dir"], + "example.org.conf"), "w") as f: # This isn't actually parsed in this test; we have a separate # test_initialization that tests the initialization, assuming # that configobj can correctly parse the config file. f.write("cert = cert.pem\nprivkey = privkey.pem\n") f.write("chain = chain.pem\nfullchain = fullchain.pem\n") - with open(os.path.join(self.defaults["renewal_configs_dir"], "example.com.conf"), "w") as f: + with open(os.path.join(self.defaults["renewal_configs_dir"], + "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) diff --git a/letsencrypt/scripts/renewer.py b/letsencrypt/scripts/renewer.py index 14dae7a1b..f1e5358a4 100755 --- a/letsencrypt/scripts/renewer.py +++ b/letsencrypt/scripts/renewer.py @@ -1,5 +1,7 @@ #!/usr/bin/env python +"""Let's Encrypt certificate renewer command-line / cron script.""" + import configobj from letsencrypt.client import renewer From bdcb94a2b5ae3d757481ba0a90e76f23441aca65 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 1 May 2015 21:57:22 -0700 Subject: [PATCH 09/66] pylint seems strict about the local variable count --- letsencrypt/client/renewer.py | 1 + 1 file changed, 1 insertion(+) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index af271fe77..7016dda10 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -320,6 +320,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, config=DEFAULTS): + # pylint: disable=too-many-locals """Create a new certificate lineage with the (suggested) lineage name lineagename, and the associated cert, privkey, and chain (the associated fullchain will be created automatically). Returns a new From b0dfea33c64081dac8d19650f2d35b5df674b3e0 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 4 May 2015 16:33:17 -0700 Subject: [PATCH 10/66] Remove a debug print statement --- letsencrypt/client/tests/renewer_test.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 1ac4ec853..29310909c 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -546,7 +546,6 @@ class RenewableCertTests(unittest.TestCase): result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", self.defaults) - print os.listdir(config_dir) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files From 28438dd111c3c5f159e3d2d138810213359a1370 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 7 May 2015 15:07:04 -0700 Subject: [PATCH 11/66] Changes to get successful enrollment in renewer DB --- letsencrypt/client/client.py | 60 +++++++++++++++++++++---------- letsencrypt/client/interfaces.py | 4 +++ letsencrypt/client/le_util.py | 10 +++--- letsencrypt/client/renewer.py | 61 ++++++++++++++++++++++++-------- letsencrypt/scripts/main.py | 10 ++++-- 5 files changed, 105 insertions(+), 40 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 8518c56b9..f9701539d 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -17,6 +17,7 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network2 +from letsencrypt.client import renewer from letsencrypt.client import reverter from letsencrypt.client import revoker @@ -67,6 +68,10 @@ class Client(object): self.config = config + # TODO: Check if self.config.enroll_autorenew is None. If + # so, set it based to the default: figure out if dv_auth is + # standalone (then default is False, otherwise default is True) + if dv_auth is not None: cont_auth = continuity_auth.ContinuityAuthenticator(config) self.auth_handler = auth_handler.AuthHandler( @@ -94,7 +99,7 @@ class Client(object): self.account.save() - def obtain_certificate(self, domains, csr=None): + def _obtain_certificate(self, domains, csr=None): """Obtains a certificate from the ACME server. :meth:`.register` must be called before :meth:`.obtain_certificate` @@ -103,6 +108,10 @@ class Client(object): :param set domains: domains to get a certificate + :param bool renewal: whether this request is a renewal (which avoids + attempting to enroll the resulting certificate in the renewal + database) + :param csr: CSR must contain requested domains, the key used to generate this CSR can be different than self.authkey :type csr: :class:`CSR` @@ -135,20 +144,32 @@ class Client(object): M2Crypto.X509.load_request_der_string(csr.data)), authzr) - # Save Certificate - cert_path, chain_path = self.save_certificate( - certr, self.config.cert_path, self.config.chain_path) + cert_pem = certr.body.as_pem() + chain_pem = None + if certr.cert_chain_uri: + chain_pem = self.network.fetch_chain(certr.cert_chain_uri) - revoker.Revoker.store_cert_key( - cert_path, self.account.key.file, self.config) + if chain_pem is None: + # XXX: just to stop RenewableCert from complaining; this is + # probably not a good solution + chain_pem = "" + return cert_pem, cert_key.pem, chain_pem - return cert_key, cert_path, chain_path + def obtain_and_enroll_certificate(self, domains, csr=None): + cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr) + return renewer.RenewableCert.new_lineage(domains[0], cert_pem, + privkey, chain_pem) + # XXX: self.account.key.file is totally wrong here, that's + # the account key and not the cert key! + + def obtain_certificate(self, domains): + return self._obtain_certificate(domains, None) def save_certificate(self, certr, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. - :param certr: ACME "certificate" resource. + :param certr: ACME "certifica" resource. :type certr: :class:`letsencrypt.acme.messages.Certificate` :param str cert_path: Path to attempt to save the cert file @@ -191,30 +212,31 @@ class Client(object): return os.path.abspath(act_cert_path), cert_chain_abspath - def deploy_certificate(self, domains, privkey, cert_file, chain_file=None): + def deploy_certificate(self, domains, lineage): """Install certificate :param list domains: list of domains to install the certificate - :param privkey: private key for certificate - :type privkey: :class:`letsencrypt.client.le_util.Key` - - :param str cert_file: certificate file path - :param str chain_file: chain file path - + :param lineage: RenewableCert object representing the certificate """ if self.installer is None: logging.warning("No installer specified, client is unable to deploy" "the certificate") raise errors.LetsEncryptClientError("No installer available") - chain = None if chain_file is None else os.path.abspath(chain_file) + # TODO: Is it possible not to have a chain at all? (The + # RenewableCert class currently doesn't support this case, but + # perhaps the CA can issue according to ACME without providing + # a chain, which would currently be a problem for instantiating + # RenewableCert, and subsequently also for this method.) for dom in domains: + # TODO: Provide a fullchain reference for installers like + # nginx that want it self.installer.deploy_cert(dom, - os.path.abspath(cert_file), - os.path.abspath(privkey.file), - chain) + lineage.cert, + lineage.privkey, + lineage.chain) self.installer.save("Deployed Let's Encrypt Certificate") # sites may have been enabled / final cleanup diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 1d52d854c..b6b929e4e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -123,6 +123,10 @@ class IConfig(zope.interface.Interface): cert_path = zope.interface.Attribute("Let's Encrypt certificate file.") chain_path = zope.interface.Attribute("Let's Encrypt chain file.") + enroll_autorenew = zope.interface.Attribute( + "Register this certificate in the database to be renewed" + " automatically.") + apache_server_root = zope.interface.Attribute( "Apache server root directory.") apache_ctl = zope.interface.Attribute( diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index c3356f3c1..d948226b9 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -90,16 +90,18 @@ def unique_lineage_name(path, filename, mode=0o777): try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError: - pass + except OSError as e: + if e.errno != 17: # file exists + raise e count = 1 while True: fname = os.path.join(path, "%s-%04d.conf" % (filename, count)) try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError: - pass + except OSError as e: + if e.errno != 17: # file exists + raise e count += 1 diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 7016dda10..5b3973a9b 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -20,6 +20,7 @@ import datetime import os import OpenSSL import parsedatetime +import pkg_resources import pyrfc3339 import pytz import re @@ -106,7 +107,10 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # file (it may itself be a symlink). But we should probably # do a recursive check that ultimately the target does # exist? - # XXX: Additional possible consistency checks + # XXX: Additional possible consistency checks (e.g. + # cryptographic validation of the chain being a chain, + # the chain matching the cert, and the cert matching + # the subject key) # XXX: All four of the targets are in the same directory # (This check is redundant with the check that they # are all in the desired directory!) @@ -330,6 +334,9 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes configs_dir = config["renewal_configs_dir"] archive_dir = config["official_archive_dir"] live_dir = config["live_dir"] + for i in (configs_dir, archive_dir, live_dir): + if not os.path.exists(i): + os.makedirs(i, 0700) config_file, config_filename = le_util.unique_lineage_name(configs_dir, lineagename) if not config_filename.endswith(".conf"): @@ -388,6 +395,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # the prior chain's target # XXX: assumes official archive location rather than examining links # XXX: consider using os.open for availablity of os.O_EXCL + # XXX: ensure file permissions are correct; also create directories + # if needed (ensuring their permissions are correct) target_version = self.next_free_version() archive = self.configuration["official_archive_dir"] prefix = os.path.join(archive, self.lineagename) @@ -416,20 +425,44 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes f.write(new_cert + new_chain) return target_version -def renew(cert, old_version): # pylint: disable=no-self-use,unused-argument +def renew(cert, old_version): """Perform automated renewal of the referenced cert, if possible.""" - # Try to create Account object, if available - # via account.Account.from_config(config.get("renewal_account")) or - # something - # Instantiate relevant authenticator - # Instantiate client - # Call client.obtain_certificate - # if it_worked: - # self.save_successor(old_version, new_cert, new_chain) - # Update relevant config files to note what was done - # Notify results - # else: - # Notify negative results + # TODO: handle partial success + # TODO: handle obligatory key rotation + # XXX: Deserialize config here + + for entrypoint in pkg_resources.iter_entry_points( + SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): + auth_cls = entrypoint.load() + # XXX: need to regenerate "config" from serialized authenticator + # config! + auth = auth_cls(config) + try: + zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) + except zope.interface.exceptions.BrokenImplementation: + pass + else: + if entrypoint.name == cert.configuration["authenticator"]: + break + else: + # TODO: Notify failure to instantiate the authenticator + return False + auth.prepare() + + client = Client(config, None, auth, None) + new_cert, new_key, new_chain = client.obtain_certificate(domains) + if new_cert and new_key and new_chain: + # XXX: Assumes that there was no key change. We need logic + # for figuring out whether there was or not. Probably + # best is to have obtain_certificate return None for + # new_key if the old key is to be used (since save_successor + # already understands this distinction!) + self.save_successor(old_version, new_cert, new_chain) + # Notify results + else: + # Notify negative results + pass + # TODO: Consider the case where the renewal was partially successful def main(config=DEFAULTS): """main function for autorenewer script.""" diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 254df5bdd..07a2272f5 100644 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -122,6 +122,9 @@ def create_parser(): add("--chain-path", default="/etc/letsencrypt/certs/chain-letsencrypt.pem", help=config_help("chain_path")) + add("--enroll-autorenew", default=None, action="store_true", + help=config_help("enroll_autorenew")) + add("--apache-server-root", default="/etc/apache2", help=config_help("apache_server_root")) add("--apache-mod-ssl-conf", default="/etc/letsencrypt/options-ssl.conf", @@ -244,9 +247,10 @@ def main(): # pylint: disable=too-many-branches, too-many-statements acme.register() except errors.LetsEncryptClientError: sys.exit(0) - cert_key, cert_file, chain_file = acme.obtain_certificate(doms) - if installer is not None and cert_file is not None: - acme.deploy_certificate(doms, cert_key, cert_file, chain_file) + # TODO: decide whether to enroll or not from config/policy + lineage = acme.obtain_and_enroll_certificate(doms) + if installer is not None and lineage is not None: + acme.deploy_certificate(doms, lineage) if installer is not None: acme.enhance_config(doms, args.redirect) From 953b57453e1c7d64a39ae33839a242fd81a63b1f Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 7 May 2015 15:25:02 -0700 Subject: [PATCH 12/66] Test for creating renewer db dirs that don't exist --- letsencrypt/client/tests/renewer_test.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 29310909c..28c7e5b85 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -558,6 +558,25 @@ class RenewableCertTests(unittest.TestCase): "other-example.com", "cert4", "privkey4", "chain4", self.defaults) + def test_new_lineage_nonexistent_dirs(self): + """Test that directories can be created if they don't exist.""" + from letsencrypt.client import renewer + config_dir = self.defaults["renewal_configs_dir"] + archive_dir = self.defaults["official_archive_dir"] + live_dir = self.defaults["live_dir"] + shutil.rmtree(config_dir) + shutil.rmtree(archive_dir) + shutil.rmtree(live_dir) + result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + "privkey2", "chain2", + self.defaults) + self.assertTrue(os.path.exists( + os.path.join(config_dir, "the-lineage.com.conf"))) + self.assertTrue(os.path.exists( + os.path.join(live_dir, "the-lineage.com", "privkey.pem"))) + self.assertTrue(os.path.exists( + os.path.join(archive_dir, "the-lineage.com", "privkey1.pem"))) + @mock.patch("letsencrypt.client.renewer.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): from letsencrypt.client import renewer From 2ee1ab05b33370e3d8dcd2fc79b2974d61cd7fe1 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 8 May 2015 15:00:35 -0700 Subject: [PATCH 13/66] Work in progress toward renewer enrollment --- letsencrypt/client/client.py | 5 +++-- letsencrypt/client/renewer.py | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index f9701539d..1a2f198b3 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -147,7 +147,7 @@ class Client(object): cert_pem = certr.body.as_pem() chain_pem = None if certr.cert_chain_uri: - chain_pem = self.network.fetch_chain(certr.cert_chain_uri) + chain_pem = self.network.fetch_chain(certr) if chain_pem is None: # XXX: just to stop RenewableCert from complaining; this is @@ -158,7 +158,8 @@ class Client(object): def obtain_and_enroll_certificate(self, domains, csr=None): cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr) return renewer.RenewableCert.new_lineage(domains[0], cert_pem, - privkey, chain_pem) + privkey, chain_pem, None, + vars(self.config.namespace)) # XXX: self.account.key.file is totally wrong here, that's # the account key and not the cert key! diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 5b3973a9b..2ecd603b9 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -323,14 +323,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return False @classmethod - def new_lineage(cls, lineagename, cert, privkey, chain, config=DEFAULTS): + def new_lineage(cls, lineagename, cert, privkey, chain, configurator=None, + renewalparams=None, config=DEFAULTS): # pylint: disable=too-many-locals """Create a new certificate lineage with the (suggested) lineage name lineagename, and the associated cert, privkey, and chain (the - associated fullchain will be created automatically). Returns a new - RenewableCert object referring to the created lineage. (The actual - lineage name, as well as all the relevant file paths, will be - available within this object.)""" + associated fullchain will be created automatically). Optional + configurator and renewalparams record the configuration that was + originally used to obtain this cert, so that it can be reused later + during automated renewal. + + Returns a new RenewableCert object referring to the created + lineage. (The actual lineage name, as well as all the relevant + file paths, will be available within this object.)""" configs_dir = config["renewal_configs_dir"] archive_dir = config["official_archive_dir"] live_dir = config["live_dir"] @@ -380,6 +385,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes new_config["privkey"] = privkey_target new_config["chain"] = chain_target new_config["fullchain"] = fullchain_target + if configurator: new_config["configurator"] = configurator + if renewalparams: new_config["renewalparams"] = renewalparams # TODO: add human-readable comments explaining other available # parameters new_config.write() From 29146d1225bdbe85938a2738260e8ada875bb4a0 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Sat, 9 May 2015 15:09:29 +0000 Subject: [PATCH 14/66] Fix client to work with cert_chain_uri, "is (not) None" fixes --- letsencrypt/client/client.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 3a890d695..ec53436e8 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -81,7 +81,7 @@ class Client(object): def register(self): """New Registration with the ACME server.""" self.account = self.network.register_from_account(self.account) - if self.account.terms_of_service: + if self.account.terms_of_service is not None: if not self.config.tos: # TODO: Replace with self.account.terms_of_service eula = pkg_resources.resource_string("letsencrypt", "EULA") @@ -193,13 +193,13 @@ class Client(object): logging.info("Server issued certificate; certificate written to %s", act_cert_path) - if certr.cert_chain_uri: + if certr.cert_chain_uri is not None: # TODO: Except - chain_cert = self.network.fetch_chain(certr.cert_chain_uri) - if chain_cert: + chain_cert = self.network.fetch_chain(certr) + if chain_cert is not None: chain_file, act_chain_path = le_util.unique_file( chain_path, 0o644) - chain_pem = chain_cert.to_pem() + chain_pem = chain_cert.as_pem() try: chain_file.write(chain_pem) finally: From eaa0bae45f38b1459154359a523394b63fe1ac43 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 9 May 2015 20:58:54 -0700 Subject: [PATCH 15/66] We also need .as_pem() for the chain in this method --- letsencrypt/client/client.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index ec53436e8..67e639e5d 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -152,6 +152,9 @@ class Client(object): # XXX: just to stop RenewableCert from complaining; this is # probably not a good solution chain_pem = "" + else: + chain_pem = chain_pem.as_pem() + return cert_pem, cert_key.pem, chain_pem def obtain_and_enroll_certificate(self, domains, csr=None): From a9d6735bce7462840c6eeb09dc139af26b311d57 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 9 May 2015 21:24:38 -0700 Subject: [PATCH 16/66] Update for changed new_lineage call prototype --- letsencrypt/client/tests/renewer_test.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 28c7e5b85..c28ffd993 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -533,8 +533,8 @@ class RenewableCertTests(unittest.TestCase): archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert", - "privkey", "chain", - self.defaults) + "privkey", "chain", None, + None, self.defaults) # This consistency check tests most relevant properties about the # newly created cert lineage. self.assertTrue(result.consistent()) @@ -544,19 +544,19 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(f.read(), "cert" + "chain") # Let's do it again and make sure it makes a different lineage result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - self.defaults) + "privkey2", "chain2", None, + None, self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, "the-lineage.com", "cert3", "privkey3", "chain3", - self.defaults) + None, None, self.defaults) os.mkdir(os.path.join(archive_dir, "other-example.com")) self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", - self.defaults) + None, None, self.defaults) def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" @@ -569,7 +569,7 @@ class RenewableCertTests(unittest.TestCase): shutil.rmtree(live_dir) result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", - self.defaults) + None, None, self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com.conf"))) self.assertTrue(os.path.exists( @@ -583,7 +583,7 @@ class RenewableCertTests(unittest.TestCase): mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", - self.defaults) + None, None, self.defaults) def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") From f0ee6f1257e4696f902a16505cf88bf97e89c451 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sun, 10 May 2015 09:18:55 -0700 Subject: [PATCH 17/66] Make saving files, recording configurator names work --- letsencrypt/client/cli.py | 3 ++- letsencrypt/client/client.py | 19 +++++++++---------- letsencrypt/client/renewer.py | 10 +++++++--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/letsencrypt/client/cli.py b/letsencrypt/client/cli.py index 309ab4ce6..904cdce68 100644 --- a/letsencrypt/client/cli.py +++ b/letsencrypt/client/cli.py @@ -98,7 +98,8 @@ def run(args, config, plugins): return "Configurator could not be determined" acme, doms = _common_run(args, config, acc, authenticator, installer) - lineage = acme.obtain_and_enroll_certificate(doms) + lineage = acme.obtain_and_enroll_certificate(doms, authenticator, + installer) # TODO: Decide whether to enroll or not from config/policy acme.deploy_certificate(doms, lineage) acme.enhance_config(doms, args.redirect) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 67e639e5d..e9134fffd 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -107,16 +107,12 @@ class Client(object): :param set domains: domains to get a certificate - :param bool renewal: whether this request is a renewal (which avoids - attempting to enroll the resulting certificate in the renewal - database) - :param csr: CSR must contain requested domains, the key used to generate this CSR can be different than self.authkey :type csr: :class:`CSR` - :returns: cert_key, cert_path, chain_path - :rtype: `tuple` of (:class:`letsencrypt.client.le_util.Key`, str, str) + :returns: cert_pem, cert_pem, chain_pem + :rtype: `tuple` of (str, str, str) """ if self.auth_handler is None: @@ -157,13 +153,16 @@ class Client(object): return cert_pem, cert_key.pem, chain_pem - def obtain_and_enroll_certificate(self, domains, csr=None): + def obtain_and_enroll_certificate(self, domains, authenticator, installer, + csr=None): cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr) + # TODO: Add IPlugin.name or use PluginsFactory.find_init instead + # of assuming that each plugin has a .name attribute + self.config.namespace.authenticator = authenticator.name + self.config.namespace.installer = installer.name return renewer.RenewableCert.new_lineage(domains[0], cert_pem, - privkey, chain_pem, None, + privkey, chain_pem, vars(self.config.namespace)) - # XXX: self.account.key.file is totally wrong here, that's - # the account key and not the cert key! def obtain_certificate(self, domains): return self._obtain_certificate(domains, None) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 2ecd603b9..41ec37834 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -323,7 +323,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return False @classmethod - def new_lineage(cls, lineagename, cert, privkey, chain, configurator=None, + def new_lineage(cls, lineagename, cert, privkey, chain, renewalparams=None, config=DEFAULTS): # pylint: disable=too-many-locals """Create a new certificate lineage with the (suggested) lineage name @@ -336,6 +336,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes Returns a new RenewableCert object referring to the created lineage. (The actual lineage name, as well as all the relevant file paths, will be available within this object.)""" + print config configs_dir = config["renewal_configs_dir"] archive_dir = config["official_archive_dir"] live_dir = config["live_dir"] @@ -385,8 +386,11 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes new_config["privkey"] = privkey_target new_config["chain"] = chain_target new_config["fullchain"] = fullchain_target - if configurator: new_config["configurator"] = configurator - if renewalparams: new_config["renewalparams"] = renewalparams + if renewalparams: + new_config["renewalparams"] = renewalparams + new_config.comments["renewalparams"] = ["", + "Options and defaults used" + " in the renewal process"] # TODO: add human-readable comments explaining other available # parameters new_config.write() From b235dc4c8053fe0a4970150bf7bbc3d070483dfd Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sun, 10 May 2015 22:43:58 -0700 Subject: [PATCH 18/66] Work in progress: make renewer renew --- letsencrypt/client/client.py | 4 +- letsencrypt/client/crypto_util.py | 11 + letsencrypt/client/renewer.py | 480 +++-------------------- letsencrypt/client/storage.py | 432 ++++++++++++++++++++ letsencrypt/client/tests/renewer_test.py | 67 ++-- 5 files changed, 536 insertions(+), 458 deletions(-) create mode 100644 letsencrypt/client/storage.py diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index e9134fffd..239b3fcff 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -17,9 +17,9 @@ from letsencrypt.client import errors from letsencrypt.client import interfaces from letsencrypt.client import le_util from letsencrypt.client import network2 -from letsencrypt.client import renewer from letsencrypt.client import reverter from letsencrypt.client import revoker +from letsencrypt.client import storage from letsencrypt.client.display import ops as display_ops from letsencrypt.client.display import enhancements @@ -160,7 +160,7 @@ class Client(object): # of assuming that each plugin has a .name attribute self.config.namespace.authenticator = authenticator.name self.config.namespace.installer = installer.name - return renewer.RenewableCert.new_lineage(domains[0], cert_pem, + return storage.RenewableCert.new_lineage(domains[0], cert_pem, privkey, chain_pem, vars(self.config.namespace)) diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index c2b761d59..c813f3bd1 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -231,3 +231,14 @@ def make_ss_cert(key_str, domains, not_before=None, assert cert.verify() # print check_purpose(,0 return cert.as_pem() + + +def get_sans_from_cert(pem): + """Extracts the DNS subjectAltName values from a cert in PEM format. + """ + x509 = M2Crypto.X509.load_cert_string(pem) + try: + ext=x509.get_ext("subjectAltName") + except LookupError: + return [] + return [x[4:] for x in ext.get_value().split(", ") if x.startswith("DNS:")] diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 41ec37834..5af3a1064 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -14,461 +14,81 @@ configuration.""" # TODO: when renewing or deploying, update config file to # memorialize the fact that it happened +import code #XXX: remove + import configobj import copy import datetime import os import OpenSSL -import parsedatetime import pkg_resources import pyrfc3339 import pytz import re import time +from letsencrypt.client import configuration +from letsencrypt.client import client +from letsencrypt.client import crypto_util from letsencrypt.client import le_util from letsencrypt.client import notify +from letsencrypt.client import storage +from letsencrypt.client.plugins import disco as plugins_disco DEFAULTS = configobj.ConfigObj("renewal.conf") DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" -ALL_FOUR = ("cert", "privkey", "chain", "fullchain") -def parse_time_interval(interval, textparser=parsedatetime.Calendar()): - """Parse the time specified time interval, which can be in the - English-language format understood by parsedatetime, e.g., '10 days', - '3 weeks', '6 months', '9 hours', or a sequence of such intervals - like '6 months 1 week' or '3 days 12 hours'. If an integer is found - with no associated unit, it is interpreted by default as a number of - days.""" - if interval.strip().isdigit(): - interval += " days" - return datetime.timedelta(0, time.mktime(textparser.parse( - interval, time.localtime(0))[0])) - -class RenewableCert(object): # pylint: disable=too-many-instance-attributes - """Represents a lineage of certificates that is under the management - of the Let's Encrypt client, indicated by the existence of an - associated renewal configuration file.""" - - def __init__(self, configfile, defaults=DEFAULTS): - # 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.configuration = copy.deepcopy(defaults) - self.configfile = configobj.ConfigObj(configfile) - self.configuration.merge(self.configfile) - - if not configfile.filename.endswith(".conf"): - raise ValueError("renewal config file name must end in .conf") - self.lineagename = os.path.basename(configfile.filename)[:-5] - self.configfilename = configfile.filename - - self.cert = self.configuration["cert"] - self.privkey = self.configuration["privkey"] - self.chain = self.configuration["chain"] - self.fullchain = self.configuration["fullchain"] - - def consistent(self): - """Is the structure of the archived files and links related to this - lineage correct and self-consistent?""" - # Each element must be referenced with an absolute path - if any(not os.path.isabs(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False - # Each element must exist and be a symbolic link - if any(not os.path.islink(x) for x in - (self.cert, self.privkey, self.chain, self.fullchain)): - return False - for kind in ALL_FOUR: - link = self.__getattribute__(kind) - where = os.path.dirname(link) - target = os.readlink(link) - if not os.path.isabs(target): - target = os.path.join(where, target) - # Each element's link must point within the cert lineage's - # directory within the official archive directory - desired_directory = os.path.join( - self.configuration["official_archive_dir"], self.lineagename) - if not os.path.samefile(os.path.dirname(target), - desired_directory): - return False - # The link must point to a file that exists - if not os.path.exists(target): - return False - # The link must point to a file that follows the archive - # naming convention - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - if not pattern.match(os.path.basename(target)): - return False - # It is NOT required that the link's target be a regular - # file (it may itself be a symlink). But we should probably - # do a recursive check that ultimately the target does - # exist? - # XXX: Additional possible consistency checks (e.g. - # cryptographic validation of the chain being a chain, - # the chain matching the cert, and the cert matching - # the subject key) - # XXX: All four of the targets are in the same directory - # (This check is redundant with the check that they - # are all in the desired directory!) - # len(set(os.path.basename(self.current_target(x) - # for x in ALL_FOUR))) == 1 - return True - - def fix(self): - """Attempt to fix some kinds of defects or inconsistencies - in the symlink structure, if possible.""" - # TODO: Figure out what kinds of fixes are possible. For - # example, checking if there is a valid version that - # we can update the symlinks to. (Maybe involve - # parsing keys and certs to see if they exist and - # if a key corresponds to the subject key of a cert?) - - # TODO: In general, the symlink-reading functions below are not - # cautious enough about the possibility that links or their - # targets may not exist. (This shouldn't happen, but might - # happen as a result of random tampering by a sysadmin, or - # filesystem errors, or crashes.) - - def current_target(self, kind): - """Returns the full path to which the link of the specified - kind currently points.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) - if not os.path.exists(link): - return None - target = os.readlink(link) - if not os.path.isabs(target): - target = os.path.join(os.path.dirname(link), target) - return target - - def current_version(self, kind): - """Returns the numerical version of the object to which the link - of the specified kind currently points. For example, if kind - is "chain" and the current chain link points to a file named - "chain7.pem", returns the integer 7.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - target = self.current_target(kind) - if not target or not os.path.exists(target): - target = "" - matches = pattern.match(os.path.basename(target)) - if matches: - return int(matches.groups()[0]) - else: - return None - - def version(self, kind, version): - """Constructs the filename that would correspond to the - specified version of the specified kind of item in this - lineage. Warning: the specified version may not exist.""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - where = os.path.dirname(self.current_target(kind)) - return os.path.join(where, "{0}{1}.pem".format(kind, version)) - - def available_versions(self, kind): - """Which alternative versions of the specified kind of item - exist in the archive directory where the current version is - stored?""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - where = os.path.dirname(self.current_target(kind)) - files = os.listdir(where) - pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) - matches = [pattern.match(f) for f in files] - return sorted([int(m.groups()[0]) for m in matches if m]) - - def newest_available_version(self, kind): - """What is the newest available version of the specified - kind of item?""" - return max(self.available_versions(kind)) - - def latest_common_version(self): - """What is the largest version number for which versions - of cert, privkey, chain, and fullchain are all available?""" - # TODO: this can raise ValueError if there is no version overlap - # (it should probably return None instead) - # TODO: this can raise a spurious AttributeError if the current - # link for any kind is missing (it should probably return None) - versions = [self.available_versions(x) for x in ALL_FOUR] - return max(n for n in versions[0] if all(n in v for v in versions[1:])) - - def next_free_version(self): - """What is the smallest new version number that is larger than - any available version of any managed item?""" - # TODO: consider locking/mutual exclusion between updating processes - # This isn't self.latest_common_version() + 1 because we don't want - # collide with a version that might exist for one file type but not - # for the others. - return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 - - def has_pending_deployment(self): - """Is there a later version of all of the managed items?""" - # TODO: consider whether to assume consistency or treat - # inconsistent/consistent versions differently - smallest_current = min(self.current_version(x) for x in ALL_FOUR) - return smallest_current < self.latest_common_version() - - def update_link_to(self, kind, version): - """Change the target of the link of the specified item to point - to the specified version. (Note that this method doesn't verify - that the specified version exists.)""" - if kind not in ALL_FOUR: - raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) - filename = "{0}{1}.pem".format(kind, version) - # Relative rather than absolute target directory - target_directory = os.path.dirname(os.readlink(link)) - # TODO: it could be safer to make the link first under a temporary - # filename, then unlink the old link, then rename the new link - # to the old link; this ensures that this process is able to - # create symlinks. - # TODO: we might also want to check consistency of related links - # for the other corresponding items - os.unlink(link) - os.symlink(os.path.join(target_directory, filename), link) - - def update_all_links_to(self, version): - """Change the target of the cert, privkey, chain, and fullchain links - to point to the specified version.""" - for kind in ALL_FOUR: - self.update_link_to(kind, version) - - def notbefore(self, version=None): - """When is the beginning validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" - if version == None: - target = self.current_target("cert") - else: - target = self.version("cert", version) - pem = open(target).read() - x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem) - i = x509.get_notBefore() - return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) - - def notafter(self, version=None): - """When is the ending validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" - if version == None: - target = self.current_target("cert") - else: - target = self.version("cert", version) - pem = open(target).read() - x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem) - i = x509.get_notAfter() - return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) - - def should_autodeploy(self): - """Should this certificate lineage be updated automatically to - point to an existing pending newer version? (Considers whether - autodeployment is enabled, whether a relevant newer version - exists, and whether the time interval for autodeployment has - been reached.)""" - if (not self.configuration.has_key("autodeploy") or - self.configuration.as_bool("autodeploy")): - if self.has_pending_deployment(): - interval = self.configuration.get("deploy_before_expiry", - "5 days") - autodeploy_interval = parse_time_interval(interval) - expiry = self.notafter() - now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) - remaining = expiry - now - if remaining < autodeploy_interval: - return True - return False - - def ocsp_revoked(self, version=None): - # pylint: disable=no-self-use,unused-argument - """Is the specified version of this certificate lineage revoked - according to OCSP or intended to be revoked according to Let's - Encrypt OCSP extensions? (If no version is specified, use the - current version.)""" - # XXX: This query and its associated network service aren't - # implemented yet, so we currently return False (indicating that the - # certificate is not revoked). - return False - - def should_autorenew(self): - """Should an attempt be made to automatically renew the most - recent certificate in this certificate lineage right now?""" - if (not self.configuration.has_key("autorenew") - or self.configuration.as_bool("autorenew")): - # Consider whether to attempt to autorenew this cert now - # XXX: both self.ocsp_revoked() and self.notafter() are bugs - # here because we should be looking at the latest version, not - # the current version! - # Renewals on the basis of revocation - if self.ocsp_revoked(): - return True - # Renewals on the basis of expiry time - interval = self.configuration.get("renew_before_expiry", "10 days") - autorenew_interval = parse_time_interval(interval) - expiry = self.notafter() - now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) - remaining = expiry - now - if remaining < autorenew_interval: - return True - return False - - @classmethod - def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=DEFAULTS): - # pylint: disable=too-many-locals - """Create a new certificate lineage with the (suggested) lineage name - lineagename, and the associated cert, privkey, and chain (the - associated fullchain will be created automatically). Optional - configurator and renewalparams record the configuration that was - originally used to obtain this cert, so that it can be reused later - during automated renewal. - - Returns a new RenewableCert object referring to the created - lineage. (The actual lineage name, as well as all the relevant - file paths, will be available within this object.)""" - print config - configs_dir = config["renewal_configs_dir"] - archive_dir = config["official_archive_dir"] - live_dir = config["live_dir"] - for i in (configs_dir, archive_dir, live_dir): - if not os.path.exists(i): - os.makedirs(i, 0700) - config_file, config_filename = le_util.unique_lineage_name(configs_dir, - lineagename) - if not config_filename.endswith(".conf"): - raise ValueError("renewal config file name must end in .conf") - # lineagename will now potentially be modified based on what - # renewal configuration file could actually be created - lineagename = os.path.basename(config_filename)[:-5] - archive = os.path.join(archive_dir, lineagename) - live_dir = os.path.join(live_dir, lineagename) - if os.path.exists(archive): - raise ValueError("archive directory exists for " + lineagename) - if os.path.exists(live_dir): - raise ValueError("live directory exists for " + lineagename) - os.mkdir(archive) - os.mkdir(live_dir) - relative_archive = os.path.join("..", "..", "archive", lineagename) - cert_target = os.path.join(live_dir, "cert.pem") - privkey_target = os.path.join(live_dir, "privkey.pem") - chain_target = os.path.join(live_dir, "chain.pem") - fullchain_target = os.path.join(live_dir, "fullchain.pem") - os.symlink(os.path.join(relative_archive, "cert1.pem"), - cert_target) - os.symlink(os.path.join(relative_archive, "privkey1.pem"), - privkey_target) - os.symlink(os.path.join(relative_archive, "chain1.pem"), - chain_target) - os.symlink(os.path.join(relative_archive, "fullchain1.pem"), - fullchain_target) - with open(cert_target, "w") as f: - f.write(cert) - with open(privkey_target, "w") as f: - f.write(privkey) - # XXX: Let's make sure to get the file permissions right here - with open(chain_target, "w") as f: - f.write(chain) - with open(fullchain_target, "w") as f: - f.write(cert + chain) - config_file.close() - new_config = configobj.ConfigObj(config_filename, create_empty=True) - new_config["cert"] = cert_target - new_config["privkey"] = privkey_target - new_config["chain"] = chain_target - new_config["fullchain"] = fullchain_target - if renewalparams: - new_config["renewalparams"] = renewalparams - new_config.comments["renewalparams"] = ["", - "Options and defaults used" - " in the renewal process"] - # TODO: add human-readable comments explaining other available - # parameters - new_config.write() - return cls(new_config, config) - - def save_successor(self, prior_version, new_cert, new_chain): - """Save a new cert and chain as a successor of a specific prior - version in this lineage. Returns the new version number that was - created. Note: does NOT update links to deploy this version.""" - # XXX: no private key change: should be extended with a key=None - # default argument that allows changing the private key; also we - # should perhaps allow new_chain=None which makes a link to - # the prior chain's target - # XXX: assumes official archive location rather than examining links - # XXX: consider using os.open for availablity of os.O_EXCL - # XXX: ensure file permissions are correct; also create directories - # if needed (ensuring their permissions are correct) - target_version = self.next_free_version() - archive = self.configuration["official_archive_dir"] - prefix = os.path.join(archive, self.lineagename) - cert_target = os.path.join( - prefix, "cert{0}.pem".format(target_version)) - privkey_target = os.path.join( - prefix, "privkey{0}.pem".format(target_version)) - chain_target = os.path.join( - prefix, "chain{0}.pem".format(target_version)) - fullchain_target = os.path.join( - prefix, "fullchain{0}.pem".format(target_version)) - with open(cert_target, "w") as f: - f.write(new_cert) - # The behavior below always keeps the prior key by creating a new - # symlink to the old key or the target of the old key symlink. - old_privkey = os.path.join( - prefix, "privkey{0}.pem".format(prior_version)) - if os.path.islink(old_privkey): - old_privkey = os.readlink(old_privkey) - else: - old_privkey = "privkey{0}.pem".format(prior_version) - os.symlink(old_privkey, privkey_target) - with open(chain_target, "w") as f: - f.write(new_chain) - with open(fullchain_target, "w") as f: - f.write(new_cert + new_chain) - return target_version +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super(AttrDict, self).__init__(*args, **kwargs) + self.__dict__ = self def renew(cert, old_version): """Perform automated renewal of the referenced cert, if possible.""" # TODO: handle partial success - # TODO: handle obligatory key rotation - # XXX: Deserialize config here - - for entrypoint in pkg_resources.iter_entry_points( - SETUPTOOLS_AUTHENTICATORS_ENTRY_POINT): - auth_cls = entrypoint.load() - # XXX: need to regenerate "config" from serialized authenticator - # config! - auth = auth_cls(config) - try: - zope.interface.verify.verifyObject(interfaces.IAuthenticator, auth) - except zope.interface.exceptions.BrokenImplementation: - pass - else: - if entrypoint.name == cert.configuration["authenticator"]: - break - else: - # TODO: Notify failure to instantiate the authenticator + # TODO: handle obligatory key rotation vs. optional key rotation vs. + # requested key rotation + if not cert.configfile.has_key("renewalparams"): + # TODO: notify user? + return False + renewalparams = cert.configfile["renewalparams"] + if not renewalparams.has_key("authenticator"): + # TODO: notify user? + return False + # Instantiate the appropriate authenticator + plugins = plugins_disco.PluginsRegistry.find_all() + try: + config = configuration.NamespaceConfig(AttrDict(renewalparams)) + # XXX: this loses type data (for example, the fact that key_size + # was an int, not a str) + config.rsa_key_size = int(config.rsa_key_size) + authenticator = plugins[renewalparams["authenticator"]] + authenticator = authenticator.init(config) + except KeyError: + # TODO: Notify user? (authenticator could not be found) return False - auth.prepare() - client = Client(config, None, auth, None) - new_cert, new_key, new_chain = client.obtain_certificate(domains) + + authenticator.prepare() + account = client.determine_account(config) + # TODO: are there other ways to get the right account object, e.g. + # based on the email parameter that might be present in + # renewalparams? + + our_client = client.Client(config, account, authenticator, None) + # XXX: find the domains + with open(cert.version("cert", old_version)) as f: + sans = crypto_util.get_sans_from_cert(f.read()) + new_cert, new_key, new_chain = our_client.obtain_certificate(sans) if new_cert and new_key and new_chain: # XXX: Assumes that there was no key change. We need logic # for figuring out whether there was or not. Probably # best is to have obtain_certificate return None for # new_key if the old key is to be used (since save_successor # already understands this distinction!) - self.save_successor(old_version, new_cert, new_chain) + cert.save_successor(old_version, new_cert, new_key, new_chain) # Notify results else: # Notify negative results @@ -481,7 +101,17 @@ def main(config=DEFAULTS): print "Processing", i if not i.endswith(".conf"): continue - cert = RenewableCert(i) + try: + cert = storage.RenewableCert( + os.path.join(config["renewal_configs_dir"], i)) + except ValueError: + # This indicates an invalid renewal configuration file, such + # as one missing a required parameter (in the future, perhaps + # also one that is internally inconsistent or is missing a + # required parameter). As a TODO, maybe we should warn the + # user about the existence of an invalid or corrupt renewal + # config rather than simply ignoring it. + continue if cert.should_autodeploy(): cert.update_all_links_to(cert.latest_common_version()) # TODO: restart web server diff --git a/letsencrypt/client/storage.py b/letsencrypt/client/storage.py new file mode 100644 index 000000000..5bd38afc2 --- /dev/null +++ b/letsencrypt/client/storage.py @@ -0,0 +1,432 @@ +import configobj +import copy +import datetime +import os +import OpenSSL +import parsedatetime +import pyrfc3339 +import pytz +import re +import time + +from letsencrypt.client import le_util + +DEFAULTS = configobj.ConfigObj("renewal.conf") +DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" +DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" +DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" +ALL_FOUR = ("cert", "privkey", "chain", "fullchain") + +def parse_time_interval(interval, textparser=parsedatetime.Calendar()): + """Parse the time specified time interval, which can be in the + English-language format understood by parsedatetime, e.g., '10 days', + '3 weeks', '6 months', '9 hours', or a sequence of such intervals + like '6 months 1 week' or '3 days 12 hours'. If an integer is found + with no associated unit, it is interpreted by default as a number of + days.""" + if interval.strip().isdigit(): + interval += " days" + return datetime.timedelta(0, time.mktime(textparser.parse( + interval, time.localtime(0))[0])) + +class RenewableCert(object): # pylint: disable=too-many-instance-attributes + """Represents a lineage of certificates that is under the management + of the Let's Encrypt client, indicated by the existence of an + associated renewal configuration file.""" + + def __init__(self, configfile, defaults=DEFAULTS): + if isinstance(configfile, str): + if not os.path.exists(configfile): + raise ValueError( + "renewal config file {0} doesn't exist".format(configfile)) + if not configfile.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + self.lineagename = os.path.basename(configfile)[:-5] + self.configfilename = os.path.basename(configfile) + elif isinstance(configfile, configobj.ConfigObj): + self.lineagename = os.path.basename(configfile.filename)[:-5] + self.configfilename = os.path.basename(configfile.filename) + else: + raise TypeError("RenewableCert config must be file path " + "or ConfigObj object") + + # 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.configuration = copy.deepcopy(defaults) + self.configfile = configobj.ConfigObj(configfile) + self.configuration.merge(self.configfile) + + if not all(self.configuration.has_key(x) for x in ALL_FOUR): + raise ValueError("renewal config file {0} is missing a required " + "file reference".format(configfile)) + + self.cert = self.configuration["cert"] + self.privkey = self.configuration["privkey"] + self.chain = self.configuration["chain"] + self.fullchain = self.configuration["fullchain"] + + def consistent(self): + """Is the structure of the archived files and links related to this + lineage correct and self-consistent?""" + # Each element must be referenced with an absolute path + if any(not os.path.isabs(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + # Each element must exist and be a symbolic link + if any(not os.path.islink(x) for x in + (self.cert, self.privkey, self.chain, self.fullchain)): + return False + for kind in ALL_FOUR: + link = self.__getattribute__(kind) + where = os.path.dirname(link) + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(where, target) + # Each element's link must point within the cert lineage's + # directory within the official archive directory + desired_directory = os.path.join( + self.configuration["official_archive_dir"], self.lineagename) + if not os.path.samefile(os.path.dirname(target), + desired_directory): + return False + # The link must point to a file that exists + if not os.path.exists(target): + return False + # The link must point to a file that follows the archive + # naming convention + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + if not pattern.match(os.path.basename(target)): + return False + # It is NOT required that the link's target be a regular + # file (it may itself be a symlink). But we should probably + # do a recursive check that ultimately the target does + # exist? + # XXX: Additional possible consistency checks (e.g. + # cryptographic validation of the chain being a chain, + # the chain matching the cert, and the cert matching + # the subject key) + # XXX: All four of the targets are in the same directory + # (This check is redundant with the check that they + # are all in the desired directory!) + # len(set(os.path.basename(self.current_target(x) + # for x in ALL_FOUR))) == 1 + return True + + def fix(self): + """Attempt to fix some kinds of defects or inconsistencies + in the symlink structure, if possible.""" + # TODO: Figure out what kinds of fixes are possible. For + # example, checking if there is a valid version that + # we can update the symlinks to. (Maybe involve + # parsing keys and certs to see if they exist and + # if a key corresponds to the subject key of a cert?) + + # TODO: In general, the symlink-reading functions below are not + # cautious enough about the possibility that links or their + # targets may not exist. (This shouldn't happen, but might + # happen as a result of random tampering by a sysadmin, or + # filesystem errors, or crashes.) + + def current_target(self, kind): + """Returns the full path to which the link of the specified + kind currently points.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + if not os.path.exists(link): + return None + target = os.readlink(link) + if not os.path.isabs(target): + target = os.path.join(os.path.dirname(link), target) + return target + + def current_version(self, kind): + """Returns the numerical version of the object to which the link + of the specified kind currently points. For example, if kind + is "chain" and the current chain link points to a file named + "chain7.pem", returns the integer 7.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + target = self.current_target(kind) + if not target or not os.path.exists(target): + target = "" + matches = pattern.match(os.path.basename(target)) + if matches: + return int(matches.groups()[0]) + else: + return None + + def version(self, kind, version): + """Constructs the filename that would correspond to the + specified version of the specified kind of item in this + lineage. Warning: the specified version may not exist.""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + return os.path.join(where, "{0}{1}.pem".format(kind, version)) + + def available_versions(self, kind): + """Which alternative versions of the specified kind of item + exist in the archive directory where the current version is + stored?""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + where = os.path.dirname(self.current_target(kind)) + files = os.listdir(where) + pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) + matches = [pattern.match(f) for f in files] + return sorted([int(m.groups()[0]) for m in matches if m]) + + def newest_available_version(self, kind): + """What is the newest available version of the specified + kind of item?""" + return max(self.available_versions(kind)) + + def latest_common_version(self): + """What is the largest version number for which versions + of cert, privkey, chain, and fullchain are all available?""" + # TODO: this can raise ValueError if there is no version overlap + # (it should probably return None instead) + # TODO: this can raise a spurious AttributeError if the current + # link for any kind is missing (it should probably return None) + versions = [self.available_versions(x) for x in ALL_FOUR] + return max(n for n in versions[0] if all(n in v for v in versions[1:])) + + def next_free_version(self): + """What is the smallest new version number that is larger than + any available version of any managed item?""" + # TODO: consider locking/mutual exclusion between updating processes + # This isn't self.latest_common_version() + 1 because we don't want + # collide with a version that might exist for one file type but not + # for the others. + return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 + + def has_pending_deployment(self): + """Is there a later version of all of the managed items?""" + # TODO: consider whether to assume consistency or treat + # inconsistent/consistent versions differently + smallest_current = min(self.current_version(x) for x in ALL_FOUR) + return smallest_current < self.latest_common_version() + + def update_link_to(self, kind, version): + """Change the target of the link of the specified item to point + to the specified version. (Note that this method doesn't verify + that the specified version exists.)""" + if kind not in ALL_FOUR: + raise ValueError("unknown kind of item") + link = self.__getattribute__(kind) + filename = "{0}{1}.pem".format(kind, version) + # Relative rather than absolute target directory + target_directory = os.path.dirname(os.readlink(link)) + # TODO: it could be safer to make the link first under a temporary + # filename, then unlink the old link, then rename the new link + # to the old link; this ensures that this process is able to + # create symlinks. + # TODO: we might also want to check consistency of related links + # for the other corresponding items + os.unlink(link) + os.symlink(os.path.join(target_directory, filename), link) + + def update_all_links_to(self, version): + """Change the target of the cert, privkey, chain, and fullchain links + to point to the specified version.""" + for kind in ALL_FOUR: + self.update_link_to(kind, version) + + def notbefore(self, version=None): + """When is the beginning validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notBefore() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def notafter(self, version=None): + """When is the ending validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + if version == None: + target = self.current_target("cert") + else: + target = self.version("cert", version) + pem = open(target).read() + x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, + pem) + i = x509.get_notAfter() + return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + + i[8:10] + ":" + i[10:12] +":" +i[12:]) + + def should_autodeploy(self): + """Should this certificate lineage be updated automatically to + point to an existing pending newer version? (Considers whether + autodeployment is enabled, whether a relevant newer version + exists, and whether the time interval for autodeployment has + been reached.)""" + if (not self.configuration.has_key("autodeploy") or + self.configuration.as_bool("autodeploy")): + if self.has_pending_deployment(): + interval = self.configuration.get("deploy_before_expiry", + "5 days") + autodeploy_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autodeploy_interval: + return True + return False + + def ocsp_revoked(self, version=None): + # pylint: disable=no-self-use,unused-argument + """Is the specified version of this certificate lineage revoked + according to OCSP or intended to be revoked according to Let's + Encrypt OCSP extensions? (If no version is specified, use the + current version.)""" + # XXX: This query and its associated network service aren't + # implemented yet, so we currently return False (indicating that the + # certificate is not revoked). + return False + + def should_autorenew(self): + """Should an attempt be made to automatically renew the most + recent certificate in this certificate lineage right now?""" + if (not self.configuration.has_key("autorenew") + or self.configuration.as_bool("autorenew")): + # Consider whether to attempt to autorenew this cert now + # XXX: both self.ocsp_revoked() and self.notafter() are bugs + # here because we should be looking at the latest version, not + # the current version! + # Renewals on the basis of revocation + if self.ocsp_revoked(): + return True + # Renewals on the basis of expiry time + interval = self.configuration.get("renew_before_expiry", "10 days") + autorenew_interval = parse_time_interval(interval) + expiry = self.notafter() + now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) + remaining = expiry - now + if remaining < autorenew_interval: + return True + return False + + @classmethod + def new_lineage(cls, lineagename, cert, privkey, chain, + renewalparams=None, config=DEFAULTS): + # pylint: disable=too-many-locals + """Create a new certificate lineage with the (suggested) lineage name + lineagename, and the associated cert, privkey, and chain (the + associated fullchain will be created automatically). Optional + configurator and renewalparams record the configuration that was + originally used to obtain this cert, so that it can be reused later + during automated renewal. + + Returns a new RenewableCert object referring to the created + lineage. (The actual lineage name, as well as all the relevant + file paths, will be available within this object.)""" + configs_dir = config["renewal_configs_dir"] + archive_dir = config["official_archive_dir"] + live_dir = config["live_dir"] + for i in (configs_dir, archive_dir, live_dir): + if not os.path.exists(i): + os.makedirs(i, 0700) + config_file, config_filename = le_util.unique_lineage_name(configs_dir, + lineagename) + if not config_filename.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") + # lineagename will now potentially be modified based on what + # renewal configuration file could actually be created + lineagename = os.path.basename(config_filename)[:-5] + archive = os.path.join(archive_dir, lineagename) + live_dir = os.path.join(live_dir, lineagename) + if os.path.exists(archive): + raise ValueError("archive directory exists for " + lineagename) + if os.path.exists(live_dir): + raise ValueError("live directory exists for " + lineagename) + os.mkdir(archive) + os.mkdir(live_dir) + relative_archive = os.path.join("..", "..", "archive", lineagename) + cert_target = os.path.join(live_dir, "cert.pem") + privkey_target = os.path.join(live_dir, "privkey.pem") + chain_target = os.path.join(live_dir, "chain.pem") + fullchain_target = os.path.join(live_dir, "fullchain.pem") + os.symlink(os.path.join(relative_archive, "cert1.pem"), + cert_target) + os.symlink(os.path.join(relative_archive, "privkey1.pem"), + privkey_target) + os.symlink(os.path.join(relative_archive, "chain1.pem"), + chain_target) + os.symlink(os.path.join(relative_archive, "fullchain1.pem"), + fullchain_target) + with open(cert_target, "w") as f: + f.write(cert) + with open(privkey_target, "w") as f: + f.write(privkey) + # XXX: Let's make sure to get the file permissions right here + with open(chain_target, "w") as f: + f.write(chain) + with open(fullchain_target, "w") as f: + f.write(cert + chain) + config_file.close() + new_config = configobj.ConfigObj(config_filename, create_empty=True) + new_config["cert"] = cert_target + new_config["privkey"] = privkey_target + new_config["chain"] = chain_target + new_config["fullchain"] = fullchain_target + if renewalparams: + new_config["renewalparams"] = renewalparams + new_config.comments["renewalparams"] = ["", + "Options and defaults used" + " in the renewal process"] + # TODO: add human-readable comments explaining other available + # parameters + new_config.write() + return cls(new_config, config) + + def save_successor(self, prior_version, new_cert, new_privkey, new_chain): + """Save a new cert and chain as a successor of a specific prior + version in this lineage. Returns the new version number that was + created. Note: does NOT update links to deploy this version.""" + # XXX: assumes official archive location rather than examining links + # XXX: consider using os.open for availablity of os.O_EXCL + # XXX: ensure file permissions are correct; also create directories + # if needed (ensuring their permissions are correct) + target_version = self.next_free_version() + archive = self.configuration["official_archive_dir"] + prefix = os.path.join(archive, self.lineagename) + cert_target = os.path.join( + prefix, "cert{0}.pem".format(target_version)) + privkey_target = os.path.join( + prefix, "privkey{0}.pem".format(target_version)) + chain_target = os.path.join( + prefix, "chain{0}.pem".format(target_version)) + fullchain_target = os.path.join( + prefix, "fullchain{0}.pem".format(target_version)) + with open(cert_target, "w") as f: + f.write(new_cert) + if new_privkey is None: + # The behavior below keeps the prior key by creating a new + # symlink to the old key or the target of the old key symlink. + old_privkey = os.path.join( + prefix, "privkey{0}.pem".format(prior_version)) + if os.path.islink(old_privkey): + old_privkey = os.readlink(old_privkey) + else: + old_privkey = "privkey{0}.pem".format(prior_version) + os.symlink(old_privkey, privkey_target) + else: + with open(privkey_target, "w") as f: + f.write(new_privkey) + with open(chain_target, "w") as f: + f.write(new_chain) + with open(fullchain_target, "w") as f: + f.write(new_cert + new_chain) + return target_version diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index c28ffd993..d6025177c 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -17,7 +17,7 @@ class RenewableCertTests(unittest.TestCase): """Tests for the RenewableCert class as well as other functions within renewer.py.""" def setUp(self): - from letsencrypt.client import renewer + from letsencrypt.client import storage self.tempdir = tempfile.mkdtemp() os.makedirs(os.path.join(self.tempdir, "live", "example.org")) os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) @@ -38,7 +38,7 @@ class RenewableCertTests(unittest.TestCase): config.filename = os.path.join(self.tempdir, "configs", "example.org.conf") self.defaults = defaults # for main() test - self.test_rc = renewer.RenewableCert(config, defaults) + self.test_rc = storage.RenewableCert(config, defaults) def tearDown(self): shutil.rmtree(self.tempdir) @@ -65,6 +65,7 @@ class RenewableCertTests(unittest.TestCase): """Test that the RenewableCert constructor will complain if the renewal configuration file doesn't end in ".conf".""" from letsencrypt.client import renewer + from letsencrypt.client import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() config["cert"] = "/tmp/cert.pem" @@ -72,7 +73,7 @@ class RenewableCertTests(unittest.TestCase): config["chain"] = "/tmp/chain.pem" config["fullchain"] = "/tmp/fullchain.pem" config.filename = "/tmp/sillyfile" - self.assertRaises(ValueError, renewer.RenewableCert, config, defaults) + self.assertRaises(ValueError, storage.RenewableCert, config, defaults) def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert @@ -404,7 +405,7 @@ class RenewableCertTests(unittest.TestCase): self.assertTrue(self.test_rc.should_autodeploy()) @mock.patch("letsencrypt.client.renewer.datetime") - @mock.patch("letsencrypt.client.renewer.RenewableCert.ocsp_revoked") + @mock.patch("letsencrypt.client.storage.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp, mock_datetime): # pylint: disable=too-many-statements # Autorenewal turned off @@ -483,7 +484,7 @@ class RenewableCertTests(unittest.TestCase): with open(where, "w") as f: f.write(kind) self.test_rc.update_all_links_to(3) - self.assertEqual(6, self.test_rc.save_successor(3, "new cert", + self.assertEqual(6, self.test_rc.save_successor(3, "new cert", "key", "new chain")) with open(self.test_rc.version("cert", 6)) as f: self.assertEqual(f.read(), "new cert") @@ -495,9 +496,9 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(os.path.islink(self.test_rc.version("privkey", 3))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) # Let's try two more updates - self.assertEqual(7, self.test_rc.save_successor(6, "again", + self.assertEqual(7, self.test_rc.save_successor(6, "again", "key", "newer chain")) - self.assertEqual(8, self.test_rc.save_successor(7, "hello", + self.assertEqual(8, self.test_rc.save_successor(7, "hello", "key", "other chain")) # All of the subsequent versions should link directly to the original # privkey. @@ -518,7 +519,8 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.current_version(kind), 3) # Test updating from latest version rather than old version self.test_rc.update_all_links_to(8) - self.assertEqual(9, self.test_rc.save_successor(8, "last", "attempt")) + self.assertEqual(9, self.test_rc.save_successor(8, "a", "last", + "attempt")) for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), range(1, 10)) @@ -529,12 +531,13 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" from letsencrypt.client import renewer + from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert", "privkey", "chain", None, - None, self.defaults) + self.defaults) # This consistency check tests most relevant properties about the # newly created cert lineage. self.assertTrue(result.consistent()) @@ -543,33 +546,34 @@ class RenewableCertTests(unittest.TestCase): with open(result.fullchain) as f: self.assertEqual(f.read(), "cert" + "chain") # Let's do it again and make sure it makes a different lineage - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", None, - None, self.defaults) + self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com-0001.conf"))) # Now trigger the detection of already existing files os.mkdir(os.path.join(live_dir, "the-lineage.com-0002")) - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "the-lineage.com", "cert3", "privkey3", "chain3", - None, None, self.defaults) + None, self.defaults) os.mkdir(os.path.join(archive_dir, "other-example.com")) - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", - None, None, self.defaults) + None, self.defaults) def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" from letsencrypt.client import renewer + from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] shutil.rmtree(config_dir) shutil.rmtree(archive_dir) shutil.rmtree(live_dir) - result = renewer.RenewableCert.new_lineage("the-lineage.com", "cert2", + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", "privkey2", "chain2", - None, None, self.defaults) + None, self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com.conf"))) self.assertTrue(os.path.exists( @@ -580,10 +584,11 @@ class RenewableCertTests(unittest.TestCase): @mock.patch("letsencrypt.client.renewer.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): from letsencrypt.client import renewer + from letsencrypt.client import storage mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" - self.assertRaises(ValueError, renewer.RenewableCert.new_lineage, + self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", - None, None, self.defaults) + None, self.defaults) def test_bad_kind(self): self.assertRaises(ValueError, self.test_rc.current_target, "elephant") @@ -602,33 +607,33 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.ocsp_revoked(), False) def test_parse_time_interval(self): - from letsencrypt.client import renewer + from letsencrypt.client import storage # XXX: I'm not sure if intervals related to years and months # take account of the current date (if so, some of these # may fail in the future, like in leap years or even in # months of different lengths!) - self.assertEqual(renewer.parse_time_interval(""), + self.assertEqual(storage.parse_time_interval(""), datetime.timedelta(0)) - self.assertEqual(renewer.parse_time_interval("1 hour"), + self.assertEqual(storage.parse_time_interval("1 hour"), datetime.timedelta(0, 3600)) - self.assertEqual(renewer.parse_time_interval("17 days"), + self.assertEqual(storage.parse_time_interval("17 days"), datetime.timedelta(17)) # Days are assumed if no unit is specified. - self.assertEqual(renewer.parse_time_interval("23"), + self.assertEqual(storage.parse_time_interval("23"), datetime.timedelta(23)) - self.assertEqual(renewer.parse_time_interval("1 month"), + self.assertEqual(storage.parse_time_interval("1 month"), datetime.timedelta(31)) - self.assertEqual(renewer.parse_time_interval("7 weeks"), + self.assertEqual(storage.parse_time_interval("7 weeks"), datetime.timedelta(49)) - self.assertEqual(renewer.parse_time_interval("1 year 1 day"), + self.assertEqual(storage.parse_time_interval("1 year 1 day"), datetime.timedelta(366)) - self.assertEqual(renewer.parse_time_interval("1 year-1 day"), + self.assertEqual(storage.parse_time_interval("1 year-1 day"), datetime.timedelta(364)) - self.assertEqual(renewer.parse_time_interval("4 years"), + self.assertEqual(storage.parse_time_interval("4 years"), datetime.timedelta(1461)) @mock.patch("letsencrypt.client.renewer.notify") - @mock.patch("letsencrypt.client.renewer.RenewableCert") + @mock.patch("letsencrypt.client.storage.RenewableCert") @mock.patch("letsencrypt.client.renewer.renew") def test_main(self, mock_renew, mock_rc, mock_notify): """Test for main() function.""" From 1e39b72ab71570f3d226c022f07ab8dcce1c00d4 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sun, 10 May 2015 22:45:35 -0700 Subject: [PATCH 19/66] Updating TODO items --- letsencrypt/client/renewer.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 5af3a1064..de9537b4a 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -14,8 +14,6 @@ configuration.""" # TODO: when renewing or deploying, update config file to # memorialize the fact that it happened -import code #XXX: remove - import configobj import copy import datetime @@ -70,7 +68,6 @@ def renew(cert, old_version): # TODO: Notify user? (authenticator could not be found) return False - authenticator.prepare() account = client.determine_account(config) # TODO: are there other ways to get the right account object, e.g. @@ -78,7 +75,6 @@ def renew(cert, old_version): # renewalparams? our_client = client.Client(config, account, authenticator, None) - # XXX: find the domains with open(cert.version("cert", old_version)) as f: sans = crypto_util.get_sans_from_cert(f.read()) new_cert, new_key, new_chain = our_client.obtain_certificate(sans) @@ -89,9 +85,9 @@ def renew(cert, old_version): # new_key if the old key is to be used (since save_successor # already understands this distinction!) cert.save_successor(old_version, new_cert, new_key, new_chain) - # Notify results + # TODO: Notify results else: - # Notify negative results + # TODO: Notify negative results pass # TODO: Consider the case where the renewal was partially successful From 2fb684d784b9093b5d1fc80f899ce5554ebac9fe Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 11:20:53 -0700 Subject: [PATCH 20/66] Fix some existing tests --- letsencrypt/client/storage.py | 2 ++ letsencrypt/client/tests/renewer_test.py | 23 ++++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/letsencrypt/client/storage.py b/letsencrypt/client/storage.py index 5bd38afc2..218b0e6a9 100644 --- a/letsencrypt/client/storage.py +++ b/letsencrypt/client/storage.py @@ -46,6 +46,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes elif isinstance(configfile, configobj.ConfigObj): self.lineagename = os.path.basename(configfile.filename)[:-5] self.configfilename = os.path.basename(configfile.filename) + if not self.configfilename.endswith(".conf"): + raise ValueError("renewal config file name must end in .conf") else: raise TypeError("RenewableCert config must be file path " "or ConfigObj object") diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index d6025177c..dd6c5fa63 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -64,7 +64,6 @@ class RenewableCertTests(unittest.TestCase): def test_renewal_config_filename_not_ending_in_conf(self): """Test that the RenewableCert constructor will complain if the renewal configuration file doesn't end in ".conf".""" - from letsencrypt.client import renewer from letsencrypt.client import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() @@ -74,6 +73,14 @@ class RenewableCertTests(unittest.TestCase): config["fullchain"] = "/tmp/fullchain.pem" config.filename = "/tmp/sillyfile" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) + self.assertRaises(ValueError, storage.RenewableCert, "/tmp", defaults) + + def test_renewal_config_filename_exists(self): + """Test that the RenewableCert constructor will complain if + the renewal configuration file doesn't exist.""" + from letsencrypt.client import storage + defaults = configobj.ConfigObj() + self.assertRaises(ValueError, storage.RenewableCert, "XXXXX", defaults) def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert @@ -484,7 +491,7 @@ class RenewableCertTests(unittest.TestCase): with open(where, "w") as f: f.write(kind) self.test_rc.update_all_links_to(3) - self.assertEqual(6, self.test_rc.save_successor(3, "new cert", "key", + self.assertEqual(6, self.test_rc.save_successor(3, "new cert", None, "new chain")) with open(self.test_rc.version("cert", 6)) as f: self.assertEqual(f.read(), "new cert") @@ -496,9 +503,9 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(os.path.islink(self.test_rc.version("privkey", 3))) self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) # Let's try two more updates - self.assertEqual(7, self.test_rc.save_successor(6, "again", "key", + self.assertEqual(7, self.test_rc.save_successor(6, "again", None, "newer chain")) - self.assertEqual(8, self.test_rc.save_successor(7, "hello", "key", + self.assertEqual(8, self.test_rc.save_successor(7, "hello", None, "other chain")) # All of the subsequent versions should link directly to the original # privkey. @@ -519,7 +526,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.current_version(kind), 3) # Test updating from latest version rather than old version self.test_rc.update_all_links_to(8) - self.assertEqual(9, self.test_rc.save_successor(8, "a", "last", + self.assertEqual(9, self.test_rc.save_successor(8, "last", None, "attempt")) for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), @@ -527,6 +534,12 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.current_version(kind), 8) with open(self.test_rc.version("fullchain", 9)) as f: self.assertEqual(f.read(), "last" + "attempt") + # Test updating when providing a new privkey. The key should + # be saved in a new file rather than creating a new symlink. + self.assertEqual(10, self.test_rc.save_successor(9, "with", "a", + "key")) + self.assertTrue(os.path.exists(self.test_rc.version("privkey", 10))) + self.assertFalse(os.path.islink(self.test_rc.version("privkey", 10))) def test_new_lineage(self): """Test for new_lineage() class method.""" From 06a3f54c9210ecaba0c464eafb7dbebd145cd327 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 11:38:50 -0700 Subject: [PATCH 21/66] Updated target for @mock.patch in new storage.py --- letsencrypt/client/tests/renewer_test.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index dd6c5fa63..c08c7dc74 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -342,7 +342,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(result.utcoffset(), datetime.timedelta(0)) # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 - @mock.patch("letsencrypt.client.renewer.datetime") + @mock.patch("letsencrypt.client.storage.datetime") def test_should_autodeploy(self, mock_datetime): # pylint: disable=too-many-statements # Autodeployment turned off @@ -411,7 +411,7 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["deploy_before_expiry"] = "300 months" self.assertTrue(self.test_rc.should_autodeploy()) - @mock.patch("letsencrypt.client.renewer.datetime") + @mock.patch("letsencrypt.client.storage.datetime") @mock.patch("letsencrypt.client.storage.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp, mock_datetime): # pylint: disable=too-many-statements From 6f56dc1418eb13a51ce689fab62fd100fbeb622b Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 11:49:20 -0700 Subject: [PATCH 22/66] Again require RenewableCert to be given a ConfigObj --- letsencrypt/client/storage.py | 18 ++++-------------- letsencrypt/client/tests/renewer_test.py | 8 -------- 2 files changed, 4 insertions(+), 22 deletions(-) diff --git a/letsencrypt/client/storage.py b/letsencrypt/client/storage.py index 218b0e6a9..6f1f96199 100644 --- a/letsencrypt/client/storage.py +++ b/letsencrypt/client/storage.py @@ -35,29 +35,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes associated renewal configuration file.""" def __init__(self, configfile, defaults=DEFAULTS): - if isinstance(configfile, str): - if not os.path.exists(configfile): - raise ValueError( - "renewal config file {0} doesn't exist".format(configfile)) - if not configfile.endswith(".conf"): + if isinstance(configfile, configobj.ConfigObj): + if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") - self.lineagename = os.path.basename(configfile)[:-5] - self.configfilename = os.path.basename(configfile) - elif isinstance(configfile, configobj.ConfigObj): self.lineagename = os.path.basename(configfile.filename)[:-5] - self.configfilename = os.path.basename(configfile.filename) - if not self.configfilename.endswith(".conf"): - raise ValueError("renewal config file name must end in .conf") else: - raise TypeError("RenewableCert config must be file path " - "or ConfigObj object") + raise TypeError("RenewableCert config must be ConfigObj object") # 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 self.configuration = copy.deepcopy(defaults) - self.configfile = configobj.ConfigObj(configfile) self.configuration.merge(self.configfile) if not all(self.configuration.has_key(x) for x in ALL_FOUR): diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index c08c7dc74..43854c281 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -73,14 +73,6 @@ class RenewableCertTests(unittest.TestCase): config["fullchain"] = "/tmp/fullchain.pem" config.filename = "/tmp/sillyfile" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) - self.assertRaises(ValueError, storage.RenewableCert, "/tmp", defaults) - - def test_renewal_config_filename_exists(self): - """Test that the RenewableCert constructor will complain if - the renewal configuration file doesn't exist.""" - from letsencrypt.client import storage - defaults = configobj.ConfigObj() - self.assertRaises(ValueError, storage.RenewableCert, "XXXXX", defaults) def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert From 38cdd422d29f1f526fd9199c47d50244905c4ef7 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 12:00:54 -0700 Subject: [PATCH 23/66] Improve test coverage --- letsencrypt/client/tests/renewer_test.py | 27 ++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 43854c281..44681f4e5 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -61,9 +61,10 @@ class RenewableCertTests(unittest.TestCase): "example.org", "fullchain.pem")) - def test_renewal_config_filename_not_ending_in_conf(self): + def test_renewal_bad_config(self): """Test that the RenewableCert constructor will complain if - the renewal configuration file doesn't end in ".conf".""" + the renewal configuration file doesn't end in ".conf" or if it + isn't a ConfigObj.""" from letsencrypt.client import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() @@ -73,6 +74,21 @@ class RenewableCertTests(unittest.TestCase): config["fullchain"] = "/tmp/fullchain.pem" config.filename = "/tmp/sillyfile" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) + self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults) + + 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.client import storage + defaults = configobj.ConfigObj() + config = configobj.ConfigObj() + config["cert"] = "/tmp/cert.pem" + # Here the required privkey is missing. + config["chain"] = "/tmp/chain.pem" + config["fullchain"] = "/tmp/fullchain.pem" + config.filename = "/tmp/genuineconfig.conf" + self.assertRaises(ValueError, storage.RenewableCert, config, defaults) + def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert @@ -565,6 +581,13 @@ class RenewableCertTests(unittest.TestCase): self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "other-example.com", "cert4", "privkey4", "chain4", None, self.defaults) + # Make sure it can accept renewal parameters + params = {"stuff": "properties of stuff", "great": "awesome"} + result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", + "privkey2", "chain2", + params, self.defaults) + # TODO: Conceivably we could test that the renewal parameters actually + # got saved def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" From db93b7b3c6678c2b2d70e6d9fa79146b4bebe557 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 13:50:07 -0700 Subject: [PATCH 24/66] Complete test coverage for renewer.py/storage.py --- letsencrypt/client/renewer.py | 9 ++-- letsencrypt/client/tests/renewer_test.py | 58 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index de9537b4a..619be206f 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -84,12 +84,13 @@ def renew(cert, old_version): # best is to have obtain_certificate return None for # new_key if the old key is to be used (since save_successor # already understands this distinction!) - cert.save_successor(old_version, new_cert, new_key, new_chain) + return cert.save_successor(old_version, new_cert, new_key, new_chain) # TODO: Notify results else: # TODO: Notify negative results - pass + return False # TODO: Consider the case where the renewal was partially successful + # (where fewer than all names were renewed) def main(config=DEFAULTS): """main function for autorenewer script.""" @@ -98,8 +99,8 @@ def main(config=DEFAULTS): if not i.endswith(".conf"): continue try: - cert = storage.RenewableCert( - os.path.join(config["renewal_configs_dir"], i)) + cert = storage.RenewableCert(configobj.ConfigObj( + os.path.join(config["renewal_configs_dir"], i))) except ValueError: # This indicates an invalid renewal configuration file, such # as one missing a required parameter (in the future, perhaps diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 44681f4e5..1df1bec5c 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -660,6 +660,56 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(storage.parse_time_interval("4 years"), datetime.timedelta(1461)) + @mock.patch("letsencrypt.client.renewer.plugins_disco") + @mock.patch("letsencrypt.client.client.determine_account") + @mock.patch("letsencrypt.client.client.Client") + def test_renew(self, mock_c, mock_da, mock_pd): + """Tests for renew().""" + from letsencrypt.client import renewer + + test_cert = pkg_resources.resource_string( + "letsencrypt.client.tests", "testdata/cert-san.pem") + os.symlink(os.path.join("..", "..", "archive", "example.org", + "cert1.pem"), self.test_rc.cert) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "privkey1.pem"), self.test_rc.privkey) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "chain1.pem"), self.test_rc.chain) + os.symlink(os.path.join("..", "..", "archive", "example.org", + "fullchain1.pem"), self.test_rc.fullchain) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + with open(self.test_rc.privkey, "w") as f: + f.write("privkey") + with open(self.test_rc.chain, "w") as f: + f.write("chain") + with open(self.test_rc.fullchain, "w") as f: + f.write("fullchain") + + # Fails because renewalparams are missing + self.assertFalse(renewer.renew(self.test_rc, 1)) + self.test_rc.configfile["renewalparams"] = {"some": "stuff"} + # Fails because there's no authenticator specified + self.assertFalse(renewer.renew(self.test_rc, 1)) + self.test_rc.configfile["renewalparams"]["rsa_key_size"] = "2048" + self.test_rc.configfile["renewalparams"]["server"] = "acme.example.com" + self.test_rc.configfile["renewalparams"]["authenticator"] = "fake" + mock_auth = mock.MagicMock() + mock_pd.PluginsRegistry.find_all.return_value = {"apache": mock_auth} + # Fails because "fake" != "apache" + self.assertFalse(renewer.renew(self.test_rc, 1)) + self.test_rc.configfile["renewalparams"]["authenticator"] = "apache" + mock_client = mock.MagicMock() + mock_client.obtain_certificate.return_value = ("cert", "key", "chain") + mock_c.return_value = mock_client + self.assertEqual(2, renewer.renew(self.test_rc, 1)) + # TODO: We could also make several assertions about calls that should + # have been made to the mock functions here. + mock_client.obtain_certificate.return_value = (None, None, None) + # This should fail because the renewal itself appears to fail + self.assertEqual(False, renewer.renew(self.test_rc, 1)) + + @mock.patch("letsencrypt.client.renewer.notify") @mock.patch("letsencrypt.client.storage.RenewableCert") @mock.patch("letsencrypt.client.renewer.renew") @@ -705,5 +755,13 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(mock_notify.notify.call_count, 4) self.assertEqual(mock_renew.call_count, 2) + def test_bad_config_file(self): + from letsencrypt.client import renewer + with open(os.path.join(self.defaults["renewal_configs_dir"], + "bad.conf"), "w") as f: + f.write("incomplete = configfile\n") + renewer.main(self.defaults) + # The ValueError is caught inside and nothing happens. + if __name__ == "__main__": unittest.main() From 5a85e1e46ebcd67bb8b4ab8050d0c59d93720e9b Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 11 May 2015 15:02:02 -0700 Subject: [PATCH 25/66] Changes to satisfy pylint --- letsencrypt/client/cli.py | 3 ++- letsencrypt/client/client.py | 5 +++++ letsencrypt/client/crypto_util.py | 2 +- letsencrypt/client/le_util.py | 12 ++++++------ letsencrypt/client/renewer.py | 11 ++--------- letsencrypt/client/storage.py | 5 ++++- letsencrypt/client/tests/renewer_test.py | 12 +++++------- 7 files changed, 25 insertions(+), 25 deletions(-) diff --git a/letsencrypt/client/cli.py b/letsencrypt/client/cli.py index 904cdce68..02dbc3692 100644 --- a/letsencrypt/client/cli.py +++ b/letsencrypt/client/cli.py @@ -140,7 +140,8 @@ def install(args, config, plugins): acme, doms = _common_run( args, config, acc, authenticator=None, installer=installer) assert args.cert_path is not None - acme.deploy_certificate(doms, acc.key, args.cert_path, args.chain_path) + # XXX: This API has changed as a result of RenewableCert! + # acme.deploy_certificate(doms, acc.key, args.cert_path, args.chain_path) acme.enhance_config(doms, args.redirect) diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index 239b3fcff..48573f922 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -155,6 +155,9 @@ class Client(object): def obtain_and_enroll_certificate(self, domains, authenticator, installer, csr=None): + """Get a new certificate for the specified domains using the specified + authenticator and installer, and then create a new renewable lineage + containing it.""" cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr) # TODO: Add IPlugin.name or use PluginsFactory.find_init instead # of assuming that each plugin has a .name attribute @@ -165,6 +168,8 @@ class Client(object): vars(self.config.namespace)) def obtain_certificate(self, domains): + """Public method to obtain a certificate for the specified domains + using this client object. Returns the tuple (cert, privkey, chain).""" return self._obtain_certificate(domains, None) def save_certificate(self, certr, cert_path, chain_path): diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index c813f3bd1..7722bba44 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -238,7 +238,7 @@ def get_sans_from_cert(pem): """ x509 = M2Crypto.X509.load_cert_string(pem) try: - ext=x509.get_ext("subjectAltName") + ext = x509.get_ext("subjectAltName") except LookupError: return [] return [x[4:] for x in ext.get_value().split(", ") if x.startswith("DNS:")] diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index d948226b9..001bd9077 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -90,18 +90,18 @@ def unique_lineage_name(path, filename, mode=0o777): try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError as e: - if e.errno != 17: # file exists - raise e + except OSError as err: + if err.errno != 17: # file exists + raise err count = 1 while True: fname = os.path.join(path, "%s-%04d.conf" % (filename, count)) try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError as e: - if e.errno != 17: # file exists - raise e + except OSError as err: + if err.errno != 17: # file exists + raise err count += 1 diff --git a/letsencrypt/client/renewer.py b/letsencrypt/client/renewer.py index 619be206f..1fa88ae69 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/client/renewer.py @@ -15,20 +15,11 @@ configuration.""" # memorialize the fact that it happened import configobj -import copy -import datetime import os -import OpenSSL -import pkg_resources -import pyrfc3339 -import pytz -import re -import time from letsencrypt.client import configuration from letsencrypt.client import client from letsencrypt.client import crypto_util -from letsencrypt.client import le_util from letsencrypt.client import notify from letsencrypt.client import storage from letsencrypt.client.plugins import disco as plugins_disco @@ -39,6 +30,8 @@ DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" class AttrDict(dict): + """A trick to allow accessing dictionary keys as object + attributes.""" def __init__(self, *args, **kwargs): super(AttrDict, self).__init__(*args, **kwargs) self.__dict__ = self diff --git a/letsencrypt/client/storage.py b/letsencrypt/client/storage.py index 6f1f96199..3f2fea90b 100644 --- a/letsencrypt/client/storage.py +++ b/letsencrypt/client/storage.py @@ -1,3 +1,6 @@ +"""The RenewableCert class, representing renewable lineages of +certificates and storing the associated cert data and metadata.""" + import configobj import copy import datetime @@ -313,7 +316,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, renewalparams=None, config=DEFAULTS): - # pylint: disable=too-many-locals + # pylint: disable=too-many-locals,too-many-arguments """Create a new certificate lineage with the (suggested) lineage name lineagename, and the associated cert, privkey, and chain (the associated fullchain will be created automatically). Optional diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/client/tests/renewer_test.py index 1df1bec5c..c0485d5f0 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/client/tests/renewer_test.py @@ -551,7 +551,6 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" - from letsencrypt.client import renewer from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] @@ -591,7 +590,6 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" - from letsencrypt.client import renewer from letsencrypt.client import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] @@ -599,9 +597,9 @@ class RenewableCertTests(unittest.TestCase): shutil.rmtree(config_dir) shutil.rmtree(archive_dir) shutil.rmtree(live_dir) - result = storage.RenewableCert.new_lineage("the-lineage.com", "cert2", - "privkey2", "chain2", - None, self.defaults) + storage.RenewableCert.new_lineage("the-lineage.com", "cert2", + "privkey2", "chain2", + None, self.defaults) self.assertTrue(os.path.exists( os.path.join(config_dir, "the-lineage.com.conf"))) self.assertTrue(os.path.exists( @@ -609,9 +607,8 @@ class RenewableCertTests(unittest.TestCase): self.assertTrue(os.path.exists( os.path.join(archive_dir, "the-lineage.com", "privkey1.pem"))) - @mock.patch("letsencrypt.client.renewer.le_util.unique_lineage_name") + @mock.patch("letsencrypt.client.storage.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): - from letsencrypt.client import renewer from letsencrypt.client import storage mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" self.assertRaises(ValueError, storage.RenewableCert.new_lineage, @@ -705,6 +702,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(2, renewer.renew(self.test_rc, 1)) # TODO: We could also make several assertions about calls that should # have been made to the mock functions here. + self.assertEqual(mock_da.call_count, 1) mock_client.obtain_certificate.return_value = (None, None, None) # This should fail because the renewal itself appears to fail self.assertEqual(False, renewer.renew(self.test_rc, 1)) From 6f6ee897f8c40638b130c0f836d7a6a4d2332616 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 13 May 2015 12:37:04 -0700 Subject: [PATCH 26/66] Changes to fix problems after reorganization --- letsencrypt/client/example.org.conf | 42 --------------- letsencrypt/client/renewal.conf | 11 ---- letsencrypt/{client => }/notify.py | 0 letsencrypt/{client => }/renewer.py | 12 ++--- letsencrypt/storage.py | 2 +- .../{client => }/tests/renewer_test.py | 52 +++++++++---------- 6 files changed, 33 insertions(+), 86 deletions(-) delete mode 100644 letsencrypt/client/example.org.conf delete mode 100644 letsencrypt/client/renewal.conf rename letsencrypt/{client => }/notify.py (100%) rename letsencrypt/{client => }/renewer.py (94%) rename letsencrypt/{client => }/tests/renewer_test.py (96%) diff --git a/letsencrypt/client/example.org.conf b/letsencrypt/client/example.org.conf deleted file mode 100644 index df565c0d8..000000000 --- a/letsencrypt/client/example.org.conf +++ /dev/null @@ -1,42 +0,0 @@ -# These are automatically generated and should normally not be edited. -# Changing these values may prevent Let's Encrypt from correctly managing -# this certificate. - -cert_path = /etc/letsencrypt/live/example.org/cert.pem -privkey_path = /etc/letsencrypt/live/example.org/privkey.pem -chain_path = /etc/letsencrypt/live/example.org/chain.pem -fullchain_path = /etc/letsencrypt/live/example.org/fullchain.pem - -authenticator = letsencrypt.ApacheConfigurator -installer = letsencrypt.ApacheConfigurator - -# This will be None when there is no currently-scheduled deployment, such -# as when there is no pending renewed version to deploy. - -# XXX: This field should possibly be removed because it's not clear that -# we derive a benefit from attempting to track this. But it might -# be useful to the human user to have a place to look this up. -next_scheduled_deployment = None - -# These options can be changed to enable or disable automated renewal -# and automated deployment. (When commented out or not present, the default -# values from renewal.conf are used.) - -# autorenew = 1 -# autodeploy = 1 -# renew_before_expiry = 10 days -# deploy_before_expiry = 5 days - -# This is set to 1 if we have knowledge that the currently-deployed version -# of this certificate has been revoked by the certificate authority. - -was_revoked = 0 - -# Account data that allows reissuance of this certificate -# This e-mail address is not directly used to cause re-issuance, but -# rather to look up a key on this system that may be authorized for -# this purpose. -renewal_account = user@example.com - -# Recovery tokens that allow reissuance of this certificate -recovery_tokens = None diff --git a/letsencrypt/client/renewal.conf b/letsencrypt/client/renewal.conf deleted file mode 100644 index 98b1db215..000000000 --- a/letsencrypt/client/renewal.conf +++ /dev/null @@ -1,11 +0,0 @@ -renewer_enabled = 1 - -default_autorenew = 1 -default_autodeploy = 1 -renew_before_expiry = 10 days -deploy_before_expiry = 300 days -notification = 1 -notification_method = root@example.org -max_notifications_per_event = 5 -# account_key = /etc/letsencrypt/accountkey/foo.pem - diff --git a/letsencrypt/client/notify.py b/letsencrypt/notify.py similarity index 100% rename from letsencrypt/client/notify.py rename to letsencrypt/notify.py diff --git a/letsencrypt/client/renewer.py b/letsencrypt/renewer.py similarity index 94% rename from letsencrypt/client/renewer.py rename to letsencrypt/renewer.py index 1fa88ae69..062cf0910 100644 --- a/letsencrypt/client/renewer.py +++ b/letsencrypt/renewer.py @@ -17,12 +17,12 @@ configuration.""" import configobj import os -from letsencrypt.client import configuration -from letsencrypt.client import client -from letsencrypt.client import crypto_util -from letsencrypt.client import notify -from letsencrypt.client import storage -from letsencrypt.client.plugins import disco as plugins_disco +from letsencrypt import configuration +from letsencrypt import client +from letsencrypt import crypto_util +from letsencrypt import notify +from letsencrypt import storage +from letsencrypt.plugins import disco as plugins_disco DEFAULTS = configobj.ConfigObj("renewal.conf") DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 3f2fea90b..f073cf704 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -12,7 +12,7 @@ import pytz import re import time -from letsencrypt.client import le_util +from letsencrypt import le_util DEFAULTS = configobj.ConfigObj("renewal.conf") DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" diff --git a/letsencrypt/client/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py similarity index 96% rename from letsencrypt/client/tests/renewer_test.py rename to letsencrypt/tests/renewer_test.py index c0485d5f0..305b8ba85 100644 --- a/letsencrypt/client/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -1,4 +1,4 @@ -"""Tests for letsencrypt.client.renewer.py""" +"""Tests for letsencrypt/renewer.py""" import configobj import datetime @@ -17,7 +17,7 @@ class RenewableCertTests(unittest.TestCase): """Tests for the RenewableCert class as well as other functions within renewer.py.""" def setUp(self): - from letsencrypt.client import storage + from letsencrypt import storage self.tempdir = tempfile.mkdtemp() os.makedirs(os.path.join(self.tempdir, "live", "example.org")) os.makedirs(os.path.join(self.tempdir, "archive", "example.org")) @@ -65,7 +65,7 @@ class RenewableCertTests(unittest.TestCase): """Test that the RenewableCert constructor will complain if the renewal configuration file doesn't end in ".conf" or if it isn't a ConfigObj.""" - from letsencrypt.client import storage + from letsencrypt import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() config["cert"] = "/tmp/cert.pem" @@ -79,7 +79,7 @@ class RenewableCertTests(unittest.TestCase): 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.client import storage + from letsencrypt import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() config["cert"] = "/tmp/cert.pem" @@ -324,7 +324,7 @@ class RenewableCertTests(unittest.TestCase): def test_notbefore(self): test_cert = pkg_resources.resource_string( - "letsencrypt.client.tests", "testdata/cert.pem") + "letsencrypt.tests", "testdata/cert.pem") os.symlink(os.path.join("..", "..", "archive", "example.org", "cert12.pem"), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: @@ -338,7 +338,7 @@ class RenewableCertTests(unittest.TestCase): def test_notafter(self): test_cert = pkg_resources.resource_string( - "letsencrypt.client.tests", "testdata/cert.pem") + "letsencrypt.tests", "testdata/cert.pem") os.symlink(os.path.join("..", "..", "archive", "example.org", "cert12.pem"), self.test_rc.cert) with open(self.test_rc.cert, "w") as f: @@ -350,7 +350,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(result.utcoffset(), datetime.timedelta(0)) # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 - @mock.patch("letsencrypt.client.storage.datetime") + @mock.patch("letsencrypt.storage.datetime") def test_should_autodeploy(self, mock_datetime): # pylint: disable=too-many-statements # Autodeployment turned off @@ -369,7 +369,7 @@ class RenewableCertTests(unittest.TestCase): f.write(kind) self.assertFalse(self.test_rc.should_autodeploy()) test_cert = pkg_resources.resource_string( - "letsencrypt.client.tests", "testdata/cert.pem") + "letsencrypt.tests", "testdata/cert.pem") mock_datetime.timedelta = datetime.timedelta # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) sometime = datetime.datetime.utcfromtimestamp(1418472000) @@ -419,8 +419,8 @@ class RenewableCertTests(unittest.TestCase): self.test_rc.configuration["deploy_before_expiry"] = "300 months" self.assertTrue(self.test_rc.should_autodeploy()) - @mock.patch("letsencrypt.client.storage.datetime") - @mock.patch("letsencrypt.client.storage.RenewableCert.ocsp_revoked") + @mock.patch("letsencrypt.storage.datetime") + @mock.patch("letsencrypt.storage.RenewableCert.ocsp_revoked") def test_should_autorenew(self, mock_ocsp, mock_datetime): # pylint: disable=too-many-statements # Autorenewal turned off @@ -434,7 +434,7 @@ class RenewableCertTests(unittest.TestCase): with open(where, "w") as f: f.write(kind) test_cert = pkg_resources.resource_string( - "letsencrypt.client.tests", "testdata/cert.pem") + "letsencrypt.tests", "testdata/cert.pem") # Mandatory renewal on the basis of OCSP revocation mock_ocsp.return_value = True self.assertTrue(self.test_rc.should_autorenew()) @@ -551,7 +551,7 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage(self): """Test for new_lineage() class method.""" - from letsencrypt.client import storage + from letsencrypt import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] @@ -590,7 +590,7 @@ class RenewableCertTests(unittest.TestCase): def test_new_lineage_nonexistent_dirs(self): """Test that directories can be created if they don't exist.""" - from letsencrypt.client import storage + from letsencrypt import storage config_dir = self.defaults["renewal_configs_dir"] archive_dir = self.defaults["official_archive_dir"] live_dir = self.defaults["live_dir"] @@ -607,9 +607,9 @@ class RenewableCertTests(unittest.TestCase): self.assertTrue(os.path.exists( os.path.join(archive_dir, "the-lineage.com", "privkey1.pem"))) - @mock.patch("letsencrypt.client.storage.le_util.unique_lineage_name") + @mock.patch("letsencrypt.storage.le_util.unique_lineage_name") def test_invalid_config_filename(self, mock_uln): - from letsencrypt.client import storage + from letsencrypt import storage mock_uln.return_value = "this_does_not_end_with_dot_conf", "yikes" self.assertRaises(ValueError, storage.RenewableCert.new_lineage, "example.com", "cert", "privkey", "chain", @@ -632,7 +632,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.ocsp_revoked(), False) def test_parse_time_interval(self): - from letsencrypt.client import storage + from letsencrypt import storage # XXX: I'm not sure if intervals related to years and months # take account of the current date (if so, some of these # may fail in the future, like in leap years or even in @@ -657,15 +657,15 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(storage.parse_time_interval("4 years"), datetime.timedelta(1461)) - @mock.patch("letsencrypt.client.renewer.plugins_disco") - @mock.patch("letsencrypt.client.client.determine_account") - @mock.patch("letsencrypt.client.client.Client") + @mock.patch("letsencrypt.renewer.plugins_disco") + @mock.patch("letsencrypt.client.determine_account") + @mock.patch("letsencrypt.client.Client") def test_renew(self, mock_c, mock_da, mock_pd): """Tests for renew().""" - from letsencrypt.client import renewer + from letsencrypt import renewer test_cert = pkg_resources.resource_string( - "letsencrypt.client.tests", "testdata/cert-san.pem") + "letsencrypt.tests", "testdata/cert-san.pem") os.symlink(os.path.join("..", "..", "archive", "example.org", "cert1.pem"), self.test_rc.cert) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -708,12 +708,12 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(False, renewer.renew(self.test_rc, 1)) - @mock.patch("letsencrypt.client.renewer.notify") - @mock.patch("letsencrypt.client.storage.RenewableCert") - @mock.patch("letsencrypt.client.renewer.renew") + @mock.patch("letsencrypt.renewer.notify") + @mock.patch("letsencrypt.storage.RenewableCert") + @mock.patch("letsencrypt.renewer.renew") def test_main(self, mock_renew, mock_rc, mock_notify): """Test for main() function.""" - from letsencrypt.client import renewer + from letsencrypt import renewer mock_rc_instance = mock.MagicMock() mock_rc_instance.should_autodeploy.return_value = True mock_rc_instance.should_autorenew.return_value = True @@ -754,7 +754,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(mock_renew.call_count, 2) def test_bad_config_file(self): - from letsencrypt.client import renewer + from letsencrypt import renewer with open(os.path.join(self.defaults["renewal_configs_dir"], "bad.conf"), "w") as f: f.write("incomplete = configfile\n") From 2c6cfe3f81aa6ae999206b3c6ba5b2e988d48e02 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 13 May 2015 13:18:37 -0700 Subject: [PATCH 27/66] Cleanup to get rid of scripts directory --- letsencrypt/renewer.py | 10 +++++----- letsencrypt/scripts/renewer.py | 20 -------------------- setup.py | 1 + 3 files changed, 6 insertions(+), 25 deletions(-) delete mode 100755 letsencrypt/scripts/renewer.py diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 062cf0910..ca731a1d6 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -2,11 +2,6 @@ certs within lineages of successor certificates, according to configuration.""" -# os.path.islink -# os.readlink -# os.path.dirname / os.path.basename -# os.path.join - # TODO: sanity checking consistency, validity, freshness? # TODO: call new installer API to restart servers after deployment @@ -87,6 +82,11 @@ def renew(cert, old_version): def main(config=DEFAULTS): """main function for autorenewer script.""" + # TODO: Distinguish automated invocation from manual invocation, + # perhaps by looking at sys.argv[0] and inhibiting automated + # invocations if /etc/letsencrypt/renewal.conf defaults have + # turned it off. (The boolean parameter should probably be + # called renewer_enabled.) for i in os.listdir(config["renewal_configs_dir"]): print "Processing", i if not i.endswith(".conf"): diff --git a/letsencrypt/scripts/renewer.py b/letsencrypt/scripts/renewer.py deleted file mode 100755 index f1e5358a4..000000000 --- a/letsencrypt/scripts/renewer.py +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env python - -"""Let's Encrypt certificate renewer command-line / cron script.""" - -import configobj - -from letsencrypt.client import renewer - -DEFAULTS = configobj.ConfigObj("renewal.conf") -DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" -DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" -DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" - -if __name__ == 'main': - if ("renewer_enabled" in DEFAULTS - and not DEFAULTS.as_bool("renewer_enabled")): - print "Renewer is disabled by configuration! Exiting." - raise SystemExit - else: - renewer.main() diff --git a/setup.py b/setup.py index 7ee7b3e5e..cc11f3924 100644 --- a/setup.py +++ b/setup.py @@ -115,6 +115,7 @@ setup( entry_points={ 'console_scripts': [ 'letsencrypt = letsencrypt.cli:main', + 'letsencrypt-renewer = letsencrypt.renewer:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', ], 'letsencrypt.plugins': [ From 0a62bd6ebe8c097d9d8716af3c350d445241b783 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 13 May 2015 14:29:19 -0700 Subject: [PATCH 28/66] Reorganize and shorten some renewer tests --- letsencrypt/tests/renewer_test.py | 321 +++++++++++------------------- 1 file changed, 120 insertions(+), 201 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 305b8ba85..463a33b09 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -12,6 +12,19 @@ import unittest ALL_FOUR = ("cert", "privkey", "chain", "fullchain") +def unlink_all(rc_object): + """Unlink all four items associated with this RenewableCert. + (Helper function.)""" + for kind in ALL_FOUR: + os.unlink(rc_object.__getattribute__(kind)) + +def fill_with_sample_data(rc_object): + """Put dummy data into all four files of this RenewableCert. + (Helper function.)""" + for kind in ALL_FOUR: + with open(rc_object.__getattribute__(kind), "w") as f: + f.write(kind) + class RenewableCertTests(unittest.TestCase): # pylint: disable=too-many-public-methods """Tests for the RenewableCert class as well as other functions @@ -24,17 +37,14 @@ class RenewableCertTests(unittest.TestCase): os.makedirs(os.path.join(self.tempdir, "configs")) defaults = configobj.ConfigObj() defaults["live_dir"] = os.path.join(self.tempdir, "live") - defaults["official_archive_dir"] = os.path.join(self.tempdir, "archive") - defaults["renewal_configs_dir"] = os.path.join(self.tempdir, "configs") + defaults["official_archive_dir"] = os.path.join(self.tempdir, + "archive") + defaults["renewal_configs_dir"] = os.path.join(self.tempdir, + "configs") config = configobj.ConfigObj() - config["cert"] = os.path.join(self.tempdir, "live", "example.org", - "cert.pem") - config["privkey"] = os.path.join(self.tempdir, "live", "example.org", - "privkey.pem") - config["chain"] = os.path.join(self.tempdir, "live", "example.org", - "chain.pem") - config["fullchain"] = os.path.join(self.tempdir, "live", "example.org", - "fullchain.pem") + for kind in ALL_FOUR: + config[kind] = os.path.join(self.tempdir, "live", "example.org", + kind + ".pem") config.filename = os.path.join(self.tempdir, "configs", "example.org.conf") self.defaults = defaults # for main() test @@ -68,10 +78,8 @@ class RenewableCertTests(unittest.TestCase): from letsencrypt import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() - config["cert"] = "/tmp/cert.pem" - config["privkey"] = "/tmp/privkey.pem" - config["chain"] = "/tmp/chain.pem" - config["fullchain"] = "/tmp/fullchain.pem" + for kind in ALL_FOUR: + config["cert"] = "/tmp/" + kind + ".pem" config.filename = "/tmp/sillyfile" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults) @@ -89,7 +97,6 @@ class RenewableCertTests(unittest.TestCase): config.filename = "/tmp/genuineconfig.conf" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) - def test_consistent(self): # pylint: disable=too-many-statements oldcert = self.test_rc.cert self.test_rc.cert = "relative/path" @@ -99,62 +106,29 @@ class RenewableCertTests(unittest.TestCase): # Items must exist requirement self.assertEqual(self.test_rc.consistent(), False) # Items must be symlinks requirements - with open(self.test_rc.cert, "w") as f: - f.write("hello") - with open(self.test_rc.privkey, "w") as f: - f.write("hello") - with open(self.test_rc.chain, "w") as f: - f.write("hello") - with open(self.test_rc.fullchain, "w") as f: - f.write("hello") + fill_with_sample_data(self.test_rc) self.assertEqual(self.test_rc.consistent(), False) - os.unlink(self.test_rc.cert) - os.unlink(self.test_rc.privkey) - os.unlink(self.test_rc.chain) - os.unlink(self.test_rc.fullchain) + unlink_all(self.test_rc) # Items must point to desired place if they are relative - os.symlink(os.path.join("..", "cert17.pem"), self.test_rc.cert) - os.symlink(os.path.join("..", "privkey17.pem"), self.test_rc.privkey) - os.symlink(os.path.join("..", "chain17.pem"), self.test_rc.chain) - os.symlink(os.path.join("..", "fullchain17.pem"), - self.test_rc.fullchain) + for kind in ALL_FOUR: + os.symlink(os.path.join("..", kind + "17.pem"), + self.test_rc.__getattribute__(kind)) self.assertEqual(self.test_rc.consistent(), False) - os.unlink(self.test_rc.cert) - os.unlink(self.test_rc.privkey) - os.unlink(self.test_rc.chain) - os.unlink(self.test_rc.fullchain) + unlink_all(self.test_rc) # Items must point to desired place if they are absolute - os.symlink(os.path.join(self.tempdir, "cert17.pem"), self.test_rc.cert) - os.symlink(os.path.join(self.tempdir, "privkey17.pem"), - self.test_rc.privkey) - os.symlink(os.path.join(self.tempdir, "chain17.pem"), - self.test_rc.chain) - os.symlink(os.path.join(self.tempdir, "fullchain17.pem"), - self.test_rc.fullchain) + for kind in ALL_FOUR: + os.symlink(os.path.join(self.tempdir, kind + "17.pem"), + self.test_rc.__getattribute__(kind)) self.assertEqual(self.test_rc.consistent(), False) - os.unlink(self.test_rc.cert) - os.unlink(self.test_rc.privkey) - os.unlink(self.test_rc.chain) - os.unlink(self.test_rc.fullchain) + unlink_all(self.test_rc) # Items must point to things that exist - os.symlink(os.path.join("..", "..", "archive", "example.org", - "cert17.pem"), self.test_rc.cert) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "privkey17.pem"), self.test_rc.privkey) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "chain17.pem"), self.test_rc.chain) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "fullchain17.pem"), self.test_rc.fullchain) + for kind in ALL_FOUR: + os.symlink(os.path.join("..", "..", "archive", "example.org", + kind + "17.pem"), + self.test_rc.__getattribute__(kind)) self.assertEqual(self.test_rc.consistent(), False) # This version should work - with open(self.test_rc.cert, "w") as f: - f.write("cert") - with open(self.test_rc.privkey, "w") as f: - f.write("privkey") - with open(self.test_rc.chain, "w") as f: - f.write("chain") - with open(self.test_rc.fullchain, "w") as f: - f.write("fullchain") + fill_with_sample_data(self.test_rc) self.assertEqual(self.test_rc.consistent(), True) # Items must point to things that follow the naming convention os.unlink(self.test_rc.fullchain) @@ -265,8 +239,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(2, self.test_rc.current_version("privkey")) self.assertEqual(5, self.test_rc.current_version("chain")) self.assertEqual(5, self.test_rc.current_version("fullchain")) - # Currently we are allowed to update to a version that doesn't - # exist + # Currently we are allowed to update to a version that doesn't exist self.test_rc.update_link_to("chain", 3000) # However, current_version doesn't allow querying the resulting # version (because it's a broken link). @@ -351,7 +324,74 @@ class RenewableCertTests(unittest.TestCase): # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 @mock.patch("letsencrypt.storage.datetime") - def test_should_autodeploy(self, mock_datetime): + def test_time_interval_judgments(self, mock_datetime): + """Test should_autodeploy() and should_autorenew() on the basis + of expiry time windows.""" + test_cert = pkg_resources.resource_string( + "letsencrypt.tests", "testdata/cert.pem") + for kind in ALL_FOUR: + where = self.test_rc.__getattribute__(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.test_rc.update_all_links_to(12) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + self.test_rc.update_all_links_to(11) + with open(self.test_rc.cert, "w") as f: + f.write(test_cert) + + mock_datetime.timedelta = datetime.timedelta + # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + sometime = datetime.datetime.utcfromtimestamp(1418472000) + mock_datetime.datetime.utcnow.return_value = sometime + # Times that should result in autorenewal/autodeployment + for when in ("2 months", "1 week"): + self.test_rc.configuration["deploy_before_expiry"] = when + self.test_rc.configuration["renew_before_expiry"] = when + self.assertTrue(self.test_rc.should_autodeploy()) + self.assertTrue(self.test_rc.should_autorenew()) + # Times that should not + for when in ("4 days", "2 days"): + self.test_rc.configuration["deploy_before_expiry"] = when + self.test_rc.configuration["renew_before_expiry"] = when + self.assertFalse(self.test_rc.should_autodeploy()) + self.assertFalse(self.test_rc.should_autorenew()) + # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) + sometime = datetime.datetime.utcfromtimestamp(1241179200) + mock_datetime.datetime.utcnow.return_value = sometime + # Times that should result in autorenewal/autodeployment + for when in ("7 years", "11 years 2 months"): + self.test_rc.configuration["deploy_before_expiry"] = when + self.test_rc.configuration["renew_before_expiry"] = when + self.assertTrue(self.test_rc.should_autodeploy()) + self.assertTrue(self.test_rc.should_autorenew()) + # Times that should not + for when in ("8 hours", "2 days", "40 days", "9 months"): + self.test_rc.configuration["deploy_before_expiry"] = when + self.test_rc.configuration["renew_before_expiry"] = when + self.assertFalse(self.test_rc.should_autodeploy()) + self.assertFalse(self.test_rc.should_autorenew()) + # 2015-01-01 (after expiry has already happened, so all intervals + # should result in autorenewal/autodeployment) + sometime = datetime.datetime.utcfromtimestamp(1420070400) + mock_datetime.datetime.utcnow.return_value = sometime + for when in ("0 seconds", "10 seconds", "10 minutes", "10 weeks", + "10 months", "10 years", "300 months"): + self.test_rc.configuration["deploy_before_expiry"] = when + self.test_rc.configuration["renew_before_expiry"] = when + self.assertTrue(self.test_rc.should_autodeploy()) + self.assertTrue(self.test_rc.should_autorenew()) + + def test_should_autodeploy(self): + """Test should_autodeploy() on the basis of reasons other than + expiry time window.""" # pylint: disable=too-many-statements # Autodeployment turned off self.test_rc.configuration["autodeploy"] = "0" @@ -368,60 +408,11 @@ class RenewableCertTests(unittest.TestCase): with open(where, "w") as f: f.write(kind) self.assertFalse(self.test_rc.should_autodeploy()) - test_cert = pkg_resources.resource_string( - "letsencrypt.tests", "testdata/cert.pem") - mock_datetime.timedelta = datetime.timedelta - # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1418472000) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.update_all_links_to(3) - with open(self.test_rc.cert, "w") as f: - f.write(test_cert) - self.test_rc.configuration["deploy_before_expiry"] = "2 months" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "1 week" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "4 days" - self.assertFalse(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "2 days" - self.assertFalse(self.test_rc.should_autodeploy()) - # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1241179200) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.configuration["deploy_before_expiry"] = "8 hours" - self.assertFalse(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "2 days" - self.assertFalse(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "40 days" - self.assertFalse(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "9 months" - self.assertFalse(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "7 years" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "11 years " - self.test_rc.configuration["deploy_before_expiry"] += "2 months" - self.assertTrue(self.test_rc.should_autodeploy()) - # 2015-01-01 (after expiry has already happened) - sometime = datetime.datetime.utcfromtimestamp(1420070400) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.configuration["deploy_before_expiry"] = "0 seconds" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "10 seconds" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "10 minutes" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "10 weeks" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "10 months" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "10 years" - self.assertTrue(self.test_rc.should_autodeploy()) - self.test_rc.configuration["deploy_before_expiry"] = "300 months" - self.assertTrue(self.test_rc.should_autodeploy()) - @mock.patch("letsencrypt.storage.datetime") @mock.patch("letsencrypt.storage.RenewableCert.ocsp_revoked") - def test_should_autorenew(self, mock_ocsp, mock_datetime): + def test_should_autorenew(self, mock_ocsp): + """Test should_autorenew on the basis of reasons other than + expiry time window.""" # pylint: disable=too-many-statements # Autorenewal turned off self.test_rc.configuration["autorenew"] = "0" @@ -433,60 +424,10 @@ class RenewableCertTests(unittest.TestCase): "{0}12.pem".format(kind)), where) with open(where, "w") as f: f.write(kind) - test_cert = pkg_resources.resource_string( - "letsencrypt.tests", "testdata/cert.pem") # Mandatory renewal on the basis of OCSP revocation mock_ocsp.return_value = True self.assertTrue(self.test_rc.should_autorenew()) mock_ocsp.return_value = False - # On the basis of expiry time - mock_datetime.timedelta = datetime.timedelta - # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1418472000) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.update_all_links_to(12) - with open(self.test_rc.cert, "w") as f: - f.write(test_cert) - self.test_rc.configuration["renew_before_expiry"] = "2 months" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "1 week" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "4 days" - self.assertFalse(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "2 days" - self.assertFalse(self.test_rc.should_autorenew()) - # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1241179200) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.configuration["renew_before_expiry"] = "8 hours" - self.assertFalse(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "2 days" - self.assertFalse(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "40 days" - self.assertFalse(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "9 months" - self.assertFalse(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "7 years" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "11 years 2 months" - self.assertTrue(self.test_rc.should_autorenew()) - # 2015-01-01 (after expiry has already happened) - sometime = datetime.datetime.utcfromtimestamp(1420070400) - mock_datetime.datetime.utcnow.return_value = sometime - self.test_rc.configuration["renew_before_expiry"] = "0 seconds" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "10 seconds" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "10 minutes" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "10 weeks" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "10 months" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "10 years" - self.assertTrue(self.test_rc.should_autorenew()) - self.test_rc.configuration["renew_before_expiry"] = "300 months" - self.assertTrue(self.test_rc.should_autorenew()) def test_save_successor(self): for ver in range(1, 6): @@ -637,25 +578,12 @@ class RenewableCertTests(unittest.TestCase): # take account of the current date (if so, some of these # may fail in the future, like in leap years or even in # months of different lengths!) - self.assertEqual(storage.parse_time_interval(""), - datetime.timedelta(0)) - self.assertEqual(storage.parse_time_interval("1 hour"), - datetime.timedelta(0, 3600)) - self.assertEqual(storage.parse_time_interval("17 days"), - datetime.timedelta(17)) - # Days are assumed if no unit is specified. - self.assertEqual(storage.parse_time_interval("23"), - datetime.timedelta(23)) - self.assertEqual(storage.parse_time_interval("1 month"), - datetime.timedelta(31)) - self.assertEqual(storage.parse_time_interval("7 weeks"), - datetime.timedelta(49)) - self.assertEqual(storage.parse_time_interval("1 year 1 day"), - datetime.timedelta(366)) - self.assertEqual(storage.parse_time_interval("1 year-1 day"), - datetime.timedelta(364)) - self.assertEqual(storage.parse_time_interval("4 years"), - datetime.timedelta(1461)) + intended = {"": 0, "17 days": 17, "23": 23, "1 month": 31, + "7 weeks": 49, "1 year 1 day": 366, "1 year-1 day": 364, + "4 years": 1461} + for time in intended: + self.assertEqual(storage.parse_time_interval(time), + datetime.timedelta(intended[time])) @mock.patch("letsencrypt.renewer.plugins_disco") @mock.patch("letsencrypt.client.determine_account") @@ -666,22 +594,13 @@ class RenewableCertTests(unittest.TestCase): test_cert = pkg_resources.resource_string( "letsencrypt.tests", "testdata/cert-san.pem") - os.symlink(os.path.join("..", "..", "archive", "example.org", - "cert1.pem"), self.test_rc.cert) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "privkey1.pem"), self.test_rc.privkey) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "chain1.pem"), self.test_rc.chain) - os.symlink(os.path.join("..", "..", "archive", "example.org", - "fullchain1.pem"), self.test_rc.fullchain) + for kind in ALL_FOUR: + os.symlink(os.path.join("..", "..", "archive", "example.org", + kind + "1.pem"), + self.test_rc.__getattribute__(kind)) + fill_with_sample_data(self.test_rc) with open(self.test_rc.cert, "w") as f: f.write(test_cert) - with open(self.test_rc.privkey, "w") as f: - f.write("privkey") - with open(self.test_rc.chain, "w") as f: - f.write("chain") - with open(self.test_rc.fullchain, "w") as f: - f.write("fullchain") # Fails because renewalparams are missing self.assertFalse(renewer.renew(self.test_rc, 1)) From af767f917bdd577942e24cb3970045ed054ffe0c Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Wed, 13 May 2015 22:35:00 -0700 Subject: [PATCH 29/66] Unit tests for notify.py and get_sans_from_cert --- letsencrypt/notify.py | 3 +- letsencrypt/tests/crypto_util_test.py | 13 +++++++ letsencrypt/tests/notify_test.py | 51 +++++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 letsencrypt/tests/notify_test.py diff --git a/letsencrypt/notify.py b/letsencrypt/notify.py index 954862e3b..6efb42d21 100644 --- a/letsencrypt/notify.py +++ b/letsencrypt/notify.py @@ -24,7 +24,6 @@ def notify(subject, whom, what): proc = subprocess.Popen(["/usr/sbin/sendmail", "-t"], stdin=subprocess.PIPE) proc.communicate(msg) - except OSError, err: - print err + except OSError: return False return True diff --git a/letsencrypt/tests/crypto_util_test.py b/letsencrypt/tests/crypto_util_test.py index bdd67da6a..3e75ea220 100644 --- a/letsencrypt/tests/crypto_util_test.py +++ b/letsencrypt/tests/crypto_util_test.py @@ -14,6 +14,10 @@ RSA256_KEY = pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa256_key.pem')) RSA512_KEY = pkg_resources.resource_string( 'acme.jose', os.path.join('testdata', 'rsa512_key.pem')) +CERT = pkg_resources.resource_string( + 'letsencrypt.tests', os.path.join('testdata', 'cert.pem')) +SAN_CERT = pkg_resources.resource_string( + 'letsencrypt.tests', os.path.join('testdata', 'cert-san.pem')) class InitSaveKeyTest(unittest.TestCase): @@ -150,5 +154,14 @@ class MakeSSCertTest(unittest.TestCase): make_ss_cert(RSA512_KEY, ['example.com', 'www.example.com']) +class GetSansFromCertTest(unittest.TestCase): + # pylint: disable=too-few-public-methods + """Tests for letsencrypt.crypto_util.get_sans_from_cert.""" + def test_it(self): + from letsencrypt.crypto_util import get_sans_from_cert + self.assertEqual(get_sans_from_cert(CERT), []) + self.assertEqual(get_sans_from_cert(SAN_CERT), + ['example.com', 'www.example.com']) + if __name__ == '__main__': unittest.main() # pragma: no cover diff --git a/letsencrypt/tests/notify_test.py b/letsencrypt/tests/notify_test.py new file mode 100644 index 000000000..c6f76c42e --- /dev/null +++ b/letsencrypt/tests/notify_test.py @@ -0,0 +1,51 @@ +"""Tests for letsencrypt/notify.py""" + +import mock +import socket +import unittest + +class NotifyTests(unittest.TestCase): + """Tests for the notifier.""" + + @mock.patch("letsencrypt.notify.smtplib.LMTP") + def test_smtp_success(self, mock_lmtp): + from letsencrypt.notify import notify + lmtp_obj = mock.MagicMock() + mock_lmtp.return_value = lmtp_obj + self.assertTrue(notify("Goose", "auntrhody@example.com", + "The old grey goose is dead.")) + self.assertEqual(lmtp_obj.connect.call_count, 1) + self.assertEqual(lmtp_obj.sendmail.call_count, 1) + + @mock.patch("letsencrypt.notify.smtplib.LMTP") + @mock.patch("letsencrypt.notify.subprocess.Popen") + def test_smtp_failure(self, mock_popen, mock_lmtp): + from letsencrypt.notify import notify + lmtp_obj = mock.MagicMock() + mock_lmtp.return_value = lmtp_obj + lmtp_obj.sendmail.side_effect = socket.error(17) + proc = mock.MagicMock() + mock_popen.return_value = proc + self.assertTrue(notify("Goose", "auntrhody@example.com", + "The old grey goose is dead.")) + self.assertEqual(lmtp_obj.sendmail.call_count, 1) + self.assertEqual(proc.communicate.call_count, 1) + + @mock.patch("letsencrypt.notify.smtplib.LMTP") + @mock.patch("letsencrypt.notify.subprocess.Popen") + def test_everything_fails(self, mock_popen, mock_lmtp): + from letsencrypt.notify import notify + lmtp_obj = mock.MagicMock() + mock_lmtp.return_value = lmtp_obj + lmtp_obj.sendmail.side_effect = socket.error(17) + proc = mock.MagicMock() + mock_popen.return_value = proc + proc.communicate.side_effect = OSError("What we have here is a " + "failure to communicate.") + self.assertFalse(notify("Goose", "auntrhody@example.com", + "The old grey goose is dead.")) + self.assertEqual(lmtp_obj.sendmail.call_count, 1) + self.assertEqual(proc.communicate.call_count, 1) + +if __name__ == "__main__": + unittest.main() # pragma: no cover From 35308bfc7da81513035640c6886376829adcebd2 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 11:45:40 -0700 Subject: [PATCH 30/66] Unify implementation of notbefore and notafter --- letsencrypt/storage.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index f073cf704..ee08ba5cd 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -231,35 +231,30 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes for kind in ALL_FOUR: self.update_link_to(kind, version) - def notbefore(self, version=None): - """When is the beginning validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" - if version == None: + def _notafterbefore(self, method, version): + """Internal helper function for finding notbefore/notafter.""" + if version is None: target = self.current_target("cert") else: target = self.version("cert", version) pem = open(target).read() x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, pem) - i = x509.get_notBefore() + i = method(x509) return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + i[8:10] + ":" + i[10:12] +":" +i[12:]) + def notbefore(self, version=None): + """When is the beginning validity time of the specified version of the + cert in this lineage? (If no version is specified, use the current + version.)""" + return self._notafterbefore(lambda x509: x509.get_notBefore(), version) + def notafter(self, version=None): """When is the ending validity time of the specified version of the cert in this lineage? (If no version is specified, use the current version.)""" - if version == None: - target = self.current_target("cert") - else: - target = self.version("cert", version) - pem = open(target).read() - x509 = OpenSSL.crypto.load_certificate(OpenSSL.crypto.FILETYPE_PEM, - pem) - i = x509.get_notAfter() - return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) + return self._notafterbefore(lambda x509: x509.get_notAfter(), version) def should_autodeploy(self): """Should this certificate lineage be updated automatically to From 93953604a2266b0c5bc768051b34350f96ef2fa6 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 11:48:30 -0700 Subject: [PATCH 31/66] Don't redefine ALL_FOUR --- letsencrypt/tests/renewer_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 463a33b09..a3148c294 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -10,7 +10,7 @@ import pytz import shutil import unittest -ALL_FOUR = ("cert", "privkey", "chain", "fullchain") +from letsencrypt.storage import ALL_FOUR def unlink_all(rc_object): """Unlink all four items associated with this RenewableCert. From 995759abad6999ef2b02e78192af05bf0fd5b312 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 11:49:38 -0700 Subject: [PATCH 32/66] pragma: no cover for test main function --- letsencrypt/tests/renewer_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index a3148c294..7845d6aed 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -681,4 +681,4 @@ class RenewableCertTests(unittest.TestCase): # The ValueError is caught inside and nothing happens. if __name__ == "__main__": - unittest.main() + unittest.main() # pragma: no cover From fc81f18864c10367bc66d55d09838ea3d49e1da8 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 11:53:48 -0700 Subject: [PATCH 33/66] Clarify that these test files never exist --- letsencrypt/tests/renewer_test.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 7845d6aed..6fdd280c3 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -78,9 +78,12 @@ class RenewableCertTests(unittest.TestCase): 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"] = "/tmp/" + kind + ".pem" - config.filename = "/tmp/sillyfile" + config["cert"] = "nonexistent_" + kind + ".pem" + config.filename = "nonexistent_sillyfile" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) self.assertRaises(TypeError, storage.RenewableCert, "fun", defaults) From d443fd9074740e25900fd9df9b6de643b272462f Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:06:21 -0700 Subject: [PATCH 34/66] Explicit "is not None" --- letsencrypt/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 60d87cb6d..e7c6310eb 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -142,7 +142,7 @@ class Client(object): cert_pem = certr.body.as_pem() chain_pem = None - if certr.cert_chain_uri: + if certr.cert_chain_uri is not None: chain_pem = self.network.fetch_chain(certr) if chain_pem is None: From 56b71e3b32e988afbef9a792772aca8fc9254db9 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:07:39 -0700 Subject: [PATCH 35/66] Replacing magic constant --- letsencrypt/crypto_util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index eb9859c5b..86ba937e7 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -241,4 +241,6 @@ def get_sans_from_cert(pem): ext = x509.get_ext("subjectAltName") except LookupError: return [] - return [x[4:] for x in ext.get_value().split(", ") if x.startswith("DNS:")] + prefix = "DNS:" + return [x[len(prefix):] for x in ext.get_value().split(", ") + if x.startswith(prefix)] From c5a44f3e39981fc81a1032ecde3957b4a33370fb Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:12:16 -0700 Subject: [PATCH 36/66] Removing magic constants --- letsencrypt/le_util.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 02877ccd1..1abdcec92 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -91,7 +91,7 @@ def unique_lineage_name(path, filename, mode=0o777): file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname except OSError as err: - if err.errno != 17: # file exists + if err.errno != errno.EEXIST: raise err count = 1 while True: @@ -100,7 +100,7 @@ def unique_lineage_name(path, filename, mode=0o777): file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname except OSError as err: - if err.errno != 17: # file exists + if err.errno != errno.EEXIST: raise err count += 1 From b8fef70bf57f2aad5749d1b88f33a5fced14b7fc Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:12:26 -0700 Subject: [PATCH 37/66] Making clear that other files don't actually exist --- letsencrypt/tests/renewer_test.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 6fdd280c3..54c269da7 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -93,11 +93,11 @@ class RenewableCertTests(unittest.TestCase): from letsencrypt import storage defaults = configobj.ConfigObj() config = configobj.ConfigObj() - config["cert"] = "/tmp/cert.pem" + config["cert"] = "imaginary_cert.pem" # Here the required privkey is missing. - config["chain"] = "/tmp/chain.pem" - config["fullchain"] = "/tmp/fullchain.pem" - config.filename = "/tmp/genuineconfig.conf" + config["chain"] = "imaginary_chain.pem" + config["fullchain"] = "imaginary_fullchain.pem" + config.filename = "imaginary_config.conf" self.assertRaises(ValueError, storage.RenewableCert, config, defaults) def test_consistent(self): # pylint: disable=too-many-statements From ff41397ccfaf21743b08a60f2ce3ebe8325a0f17 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:27:38 -0700 Subject: [PATCH 38/66] Consolidate redundant tests in a loop --- letsencrypt/tests/renewer_test.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 54c269da7..4ae76d04d 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -461,18 +461,11 @@ class RenewableCertTests(unittest.TestCase): "other chain")) # All of the subsequent versions should link directly to the original # privkey. - self.assertTrue(os.path.islink(self.test_rc.version("privkey", 6))) - self.assertTrue(os.path.islink(self.test_rc.version("privkey", 7))) - self.assertTrue(os.path.islink(self.test_rc.version("privkey", 8))) - self.assertEqual( - os.path.basename(os.readlink(self.test_rc.version("privkey", 6))), - "privkey3.pem") - self.assertEqual( - os.path.basename(os.readlink(self.test_rc.version("privkey", 7))), - "privkey3.pem") - self.assertEqual( - os.path.basename(os.readlink(self.test_rc.version("privkey", 8))), - "privkey3.pem") + for i in (6, 7, 8): + self.assertTrue(os.path.islink(self.test_rc.version("privkey", i))) + self.assertEqual("privkey3.pem", os.path.basename(os.readlink( + self.test_rc.version("privkey", i)))) + for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), range(1, 9)) self.assertEqual(self.test_rc.current_version(kind), 3) From e469ae4ed89fce29f1b74926caa3177aeea4bf6e Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:27:52 -0700 Subject: [PATCH 39/66] Remove magic constant --- letsencrypt/storage.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index ee08ba5cd..c7b4dfb55 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -41,7 +41,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if isinstance(configfile, configobj.ConfigObj): if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") - self.lineagename = os.path.basename(configfile.filename)[:-5] + self.lineagename = os.path.basename( + configfile.filename)[:-len(".conf")] else: raise TypeError("RenewableCert config must be ConfigObj object") @@ -334,7 +335,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise ValueError("renewal config file name must end in .conf") # lineagename will now potentially be modified based on what # renewal configuration file could actually be created - lineagename = os.path.basename(config_filename)[:-5] + lineagename = os.path.basename(config_filename)[:-len(".conf")] archive = os.path.join(archive_dir, lineagename) live_dir = os.path.join(live_dir, lineagename) if os.path.exists(archive): From 9556203ae96d428bea27714844496c3709b61dc8 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:28:24 -0700 Subject: [PATCH 40/66] Explicit "is None" --- letsencrypt/storage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index c7b4dfb55..08405069a 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -147,7 +147,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes raise ValueError("unknown kind of item") pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) target = self.current_target(kind) - if not target or not os.path.exists(target): + if target is None or not os.path.exists(target): target = "" matches = pattern.match(os.path.basename(target)) if matches: From e612d52693a01b70ad9d523ba1450c14533d9f21 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:39:57 -0700 Subject: [PATCH 41/66] Indentation fix --- letsencrypt/storage.py | 2 +- letsencrypt/tests/renewer_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 08405069a..1274eb509 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -42,7 +42,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") self.lineagename = os.path.basename( - configfile.filename)[:-len(".conf")] + configfile.filename)[:-len(".conf")] else: raise TypeError("RenewableCert config must be ConfigObj object") diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 4ae76d04d..83881640c 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -464,7 +464,7 @@ class RenewableCertTests(unittest.TestCase): for i in (6, 7, 8): self.assertTrue(os.path.islink(self.test_rc.version("privkey", i))) self.assertEqual("privkey3.pem", os.path.basename(os.readlink( - self.test_rc.version("privkey", i)))) + self.test_rc.version("privkey", i)))) for kind in ALL_FOUR: self.assertEqual(self.test_rc.available_versions(kind), range(1, 9)) From fb8b2f1415cde36ebf0a7494c024641fe5c8ba3c Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:40:03 -0700 Subject: [PATCH 42/66] Moving code outside of try block --- letsencrypt/renewer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index ca731a1d6..daeeb1bf1 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -91,9 +91,10 @@ def main(config=DEFAULTS): print "Processing", i if not i.endswith(".conf"): continue + rc_config = configobj.ConfigObj( + os.path.join(config["renewal_configs_dir"], i)) try: - cert = storage.RenewableCert(configobj.ConfigObj( - os.path.join(config["renewal_configs_dir"], i))) + cert = storage.RenewableCert(rc_config) except ValueError: # This indicates an invalid renewal configuration file, such # as one missing a required parameter (in the future, perhaps From 87592d64a992e591e074844300cf7cb03890c9ff Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 12:42:48 -0700 Subject: [PATCH 43/66] Moving code outside of try block --- letsencrypt/renewer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index daeeb1bf1..6cd341aa0 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -45,16 +45,16 @@ def renew(cert, old_version): return False # Instantiate the appropriate authenticator plugins = plugins_disco.PluginsRegistry.find_all() + config = configuration.NamespaceConfig(AttrDict(renewalparams)) + # XXX: this loses type data (for example, the fact that key_size + # was an int, not a str) + config.rsa_key_size = int(config.rsa_key_size) try: - config = configuration.NamespaceConfig(AttrDict(renewalparams)) - # XXX: this loses type data (for example, the fact that key_size - # was an int, not a str) - config.rsa_key_size = int(config.rsa_key_size) authenticator = plugins[renewalparams["authenticator"]] - authenticator = authenticator.init(config) except KeyError: # TODO: Notify user? (authenticator could not be found) return False + authenticator = authenticator.init(config) authenticator.prepare() account = client.determine_account(config) From 866d236249047a14ea18b9072a6b502eb7e60655 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 14:26:01 -0700 Subject: [PATCH 44/66] Style cleanups in renewer test --- letsencrypt/tests/renewer_test.py | 41 +++++++++++++------------------ 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 83881640c..10e02f3ec 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -55,21 +55,14 @@ class RenewableCertTests(unittest.TestCase): def test_initialization(self): self.assertEqual(self.test_rc.lineagename, "example.org") - self.assertEqual(self.test_rc.cert, os.path.join(self.tempdir, "live", - "example.org", - "cert.pem")) - self.assertEqual(self.test_rc.privkey, os.path.join(self.tempdir, - "live", - "example.org", - "privkey.pem")) - self.assertEqual(self.test_rc.chain, os.path.join(self.tempdir, - "live", - "example.org", - "chain.pem")) - self.assertEqual(self.test_rc.fullchain, os.path.join(self.tempdir, - "live", - "example.org", - "fullchain.pem")) + self.assertEqual(self.test_rc.cert, os.path.join( + self.tempdir, "live", "example.org", "cert.pem")) + self.assertEqual(self.test_rc.privkey, os.path.join( + self.tempdir, "live", "example.org", "privkey.pem")) + self.assertEqual(self.test_rc.chain, os.path.join( + self.tempdir, "live", "example.org", "chain.pem")) + self.assertEqual(self.test_rc.fullchain, os.path.join( + self.tempdir, "live", "example.org", "fullchain.pem")) def test_renewal_bad_config(self): """Test that the RenewableCert constructor will complain if @@ -104,42 +97,42 @@ class RenewableCertTests(unittest.TestCase): oldcert = self.test_rc.cert self.test_rc.cert = "relative/path" # Absolute path for item requirement - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) self.test_rc.cert = oldcert # Items must exist requirement - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) # Items must be symlinks requirements fill_with_sample_data(self.test_rc) - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) unlink_all(self.test_rc) # Items must point to desired place if they are relative for kind in ALL_FOUR: os.symlink(os.path.join("..", kind + "17.pem"), self.test_rc.__getattribute__(kind)) - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) unlink_all(self.test_rc) # Items must point to desired place if they are absolute for kind in ALL_FOUR: os.symlink(os.path.join(self.tempdir, kind + "17.pem"), self.test_rc.__getattribute__(kind)) - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) unlink_all(self.test_rc) # Items must point to things that exist for kind in ALL_FOUR: os.symlink(os.path.join("..", "..", "archive", "example.org", kind + "17.pem"), self.test_rc.__getattribute__(kind)) - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) # This version should work fill_with_sample_data(self.test_rc) - self.assertEqual(self.test_rc.consistent(), True) + self.assertTrue(self.test_rc.consistent()) # Items must point to things that follow the naming convention os.unlink(self.test_rc.fullchain) os.symlink(os.path.join("..", "..", "archive", "example.org", "fullchain_17.pem"), self.test_rc.fullchain) with open(self.test_rc.fullchain, "w") as f: f.write("wrongly-named fullchain") - self.assertEqual(self.test_rc.consistent(), False) + self.assertFalse(self.test_rc.consistent()) def test_current_target(self): # Relative path logic @@ -566,7 +559,7 @@ class RenewableCertTests(unittest.TestCase): def test_ocsp_revoked(self): # XXX: This is currently hardcoded to False due to a lack of an # OCSP server to test against. - self.assertEqual(self.test_rc.ocsp_revoked(), False) + self.assertFalse(self.test_rc.ocsp_revoked()) def test_parse_time_interval(self): from letsencrypt import storage From c9514298951ab2c775a77890e7cb5ab3193e6ef9 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 14:36:43 -0700 Subject: [PATCH 45/66] Separate stdlib imports from third-party --- letsencrypt/renewer.py | 3 ++- letsencrypt/storage.py | 11 ++++++----- letsencrypt/tests/renewer_test.py | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 6cd341aa0..40a6cfc74 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -9,9 +9,10 @@ configuration.""" # TODO: when renewing or deploying, update config file to # memorialize the fact that it happened -import configobj import os +import configobj + from letsencrypt import configuration from letsencrypt import client from letsencrypt import crypto_util diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 1274eb509..0e410319e 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -1,17 +1,18 @@ """The RenewableCert class, representing renewable lineages of certificates and storing the associated cert data and metadata.""" -import configobj import copy import datetime import os -import OpenSSL -import parsedatetime -import pyrfc3339 -import pytz import re import time +import configobj +import OpenSSL +import parsedatetime +import pytz +import pyrfc3339 + from letsencrypt import le_util DEFAULTS = configobj.ConfigObj("renewal.conf") diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 10e02f3ec..c676dcadc 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -1,15 +1,16 @@ """Tests for letsencrypt/renewer.py""" -import configobj import datetime -import mock import os import tempfile import pkg_resources -import pytz import shutil import unittest +import configobj +import mock +import pytz + from letsencrypt.storage import ALL_FOUR def unlink_all(rc_object): From 183b49fbc204d5cb374a0d3f3f4511b32ca3cfbe Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 14:59:24 -0700 Subject: [PATCH 46/66] Consolidate and shorten some renewer tests --- letsencrypt/tests/renewer_test.py | 99 ++++++++++++------------------- 1 file changed, 37 insertions(+), 62 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index c676dcadc..a3597b3c4 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -56,14 +56,10 @@ class RenewableCertTests(unittest.TestCase): def test_initialization(self): self.assertEqual(self.test_rc.lineagename, "example.org") - self.assertEqual(self.test_rc.cert, os.path.join( - self.tempdir, "live", "example.org", "cert.pem")) - self.assertEqual(self.test_rc.privkey, os.path.join( - self.tempdir, "live", "example.org", "privkey.pem")) - self.assertEqual(self.test_rc.chain, os.path.join( - self.tempdir, "live", "example.org", "chain.pem")) - self.assertEqual(self.test_rc.fullchain, os.path.join( - self.tempdir, "live", "example.org", "fullchain.pem")) + for kind in ALL_FOUR: + self.assertEqual( + self.test_rc.__getattribute__(kind), os.path.join( + self.tempdir, "live", "example.org", kind + ".pem")) def test_renewal_bad_config(self): """Test that the RenewableCert constructor will complain if @@ -292,32 +288,25 @@ class RenewableCertTests(unittest.TestCase): else: self.assertFalse(self.test_rc.has_pending_deployment()) - def test_notbefore(self): + def _test_notafterbefore(self, function, timestamp): test_cert = pkg_resources.resource_string( "letsencrypt.tests", "testdata/cert.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) - desired_time = datetime.datetime.utcfromtimestamp(1418337285) + desired_time = datetime.datetime.utcfromtimestamp(timestamp) desired_time = desired_time.replace(tzinfo=pytz.UTC) - for result in (self.test_rc.notbefore(), self.test_rc.notbefore(12)): + for result in (function(), function(12)): self.assertEqual(result, desired_time) self.assertEqual(result.utcoffset(), datetime.timedelta(0)) + + def test_notbefore(self): + self._test_notafterbefore(self.test_rc.notbefore, 1418337285) # 2014-12-11 22:34:45+00:00 = Unix time 1418337285 def test_notafter(self): - test_cert = pkg_resources.resource_string( - "letsencrypt.tests", "testdata/cert.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) - desired_time = datetime.datetime.utcfromtimestamp(1418942085) - desired_time = desired_time.replace(tzinfo=pytz.UTC) - for result in (self.test_rc.notafter(), self.test_rc.notafter(12)): - self.assertEqual(result, desired_time) - self.assertEqual(result.utcoffset(), datetime.timedelta(0)) + self._test_notafterbefore(self.test_rc.notafter, 1418942085) # 2014-12-18 22:34:45+00:00 = Unix time 1418942085 @mock.patch("letsencrypt.storage.datetime") @@ -345,46 +334,32 @@ class RenewableCertTests(unittest.TestCase): f.write(test_cert) mock_datetime.timedelta = datetime.timedelta - # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1418472000) - mock_datetime.datetime.utcnow.return_value = sometime - # Times that should result in autorenewal/autodeployment - for when in ("2 months", "1 week"): - self.test_rc.configuration["deploy_before_expiry"] = when - self.test_rc.configuration["renew_before_expiry"] = when - self.assertTrue(self.test_rc.should_autodeploy()) - self.assertTrue(self.test_rc.should_autorenew()) - # Times that should not - for when in ("4 days", "2 days"): - self.test_rc.configuration["deploy_before_expiry"] = when - self.test_rc.configuration["renew_before_expiry"] = when - self.assertFalse(self.test_rc.should_autodeploy()) - self.assertFalse(self.test_rc.should_autorenew()) - # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - sometime = datetime.datetime.utcfromtimestamp(1241179200) - mock_datetime.datetime.utcnow.return_value = sometime - # Times that should result in autorenewal/autodeployment - for when in ("7 years", "11 years 2 months"): - self.test_rc.configuration["deploy_before_expiry"] = when - self.test_rc.configuration["renew_before_expiry"] = when - self.assertTrue(self.test_rc.should_autodeploy()) - self.assertTrue(self.test_rc.should_autorenew()) - # Times that should not - for when in ("8 hours", "2 days", "40 days", "9 months"): - self.test_rc.configuration["deploy_before_expiry"] = when - self.test_rc.configuration["renew_before_expiry"] = when - self.assertFalse(self.test_rc.should_autodeploy()) - self.assertFalse(self.test_rc.should_autorenew()) - # 2015-01-01 (after expiry has already happened, so all intervals - # should result in autorenewal/autodeployment) - sometime = datetime.datetime.utcfromtimestamp(1420070400) - mock_datetime.datetime.utcnow.return_value = sometime - for when in ("0 seconds", "10 seconds", "10 minutes", "10 weeks", - "10 months", "10 years", "300 months"): - self.test_rc.configuration["deploy_before_expiry"] = when - self.test_rc.configuration["renew_before_expiry"] = when - self.assertTrue(self.test_rc.should_autodeploy()) - self.assertTrue(self.test_rc.should_autorenew()) + + for (current_time, interval, result) in [ + # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + # Times that should result in autorenewal/autodeployment + (1418472000, "2 months", True), (1418472000, "1 week", True), + # Times that should not + (1418472000, "4 days", False), (1418472000, "2 days", False), + # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) + # Times that should result in autorenewal/autodeployment + (1241179200, "7 years", True), + (1241179200, "11 years 2 months", True), + # Times that should not + (1241179200, "8 hours", False), (1241179200, "2 days", False), + (1241179200, "40 days", False), (1241179200, "9 months", False), + # 2015-01-01 (after expiry has already happened, so all intervals + # should result in autorenewal/autodeployment) + (1420070400, "0 seconds", True), (1420070400, "10 seconds", True), + (1420070400, "10 minutes", True), (1420070400, "10 weeks", True), + (1420070400, "10 months", True), (1420070400, "10 years", True), + (1420070400, "300 months", True), ]: + sometime = datetime.datetime.utcfromtimestamp(current_time) + mock_datetime.datetime.utcnow.return_value = sometime + self.test_rc.configuration["deploy_before_expiry"] = interval + self.test_rc.configuration["renew_before_expiry"] = interval + self.assertEqual(self.test_rc.should_autodeploy(), result) + self.assertEqual(self.test_rc.should_autorenew(), result) def test_should_autodeploy(self): """Test should_autodeploy() on the basis of reasons other than From 018201170c89221ae89bbc377e30a639ef8cc6c2 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 15:06:22 -0700 Subject: [PATCH 47/66] Fix indentation --- letsencrypt/tests/renewer_test.py | 40 ++++++++++++++++--------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index a3597b3c4..a35ad6378 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -59,7 +59,7 @@ class RenewableCertTests(unittest.TestCase): for kind in ALL_FOUR: self.assertEqual( self.test_rc.__getattribute__(kind), os.path.join( - self.tempdir, "live", "example.org", kind + ".pem")) + self.tempdir, "live", "example.org", kind + ".pem")) def test_renewal_bad_config(self): """Test that the RenewableCert constructor will complain if @@ -336,24 +336,26 @@ class RenewableCertTests(unittest.TestCase): mock_datetime.timedelta = datetime.timedelta for (current_time, interval, result) in [ - # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) - # Times that should result in autorenewal/autodeployment - (1418472000, "2 months", True), (1418472000, "1 week", True), - # Times that should not - (1418472000, "4 days", False), (1418472000, "2 days", False), - # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) - # Times that should result in autorenewal/autodeployment - (1241179200, "7 years", True), - (1241179200, "11 years 2 months", True), - # Times that should not - (1241179200, "8 hours", False), (1241179200, "2 days", False), - (1241179200, "40 days", False), (1241179200, "9 months", False), - # 2015-01-01 (after expiry has already happened, so all intervals - # should result in autorenewal/autodeployment) - (1420070400, "0 seconds", True), (1420070400, "10 seconds", True), - (1420070400, "10 minutes", True), (1420070400, "10 weeks", True), - (1420070400, "10 months", True), (1420070400, "10 years", True), - (1420070400, "300 months", True), ]: + # 2014-12-13 12:00:00+00:00 (about 5 days prior to expiry) + # Times that should result in autorenewal/autodeployment + (1418472000, "2 months", True), (1418472000, "1 week", True), + # Times that should not + (1418472000, "4 days", False), (1418472000, "2 days", False), + # 2009-05-01 12:00:00+00:00 (about 5 years prior to expiry) + # Times that should result in autorenewal/autodeployment + (1241179200, "7 years", True), + (1241179200, "11 years 2 months", True), + # Times that should not + (1241179200, "8 hours", False), (1241179200, "2 days", False), + (1241179200, "40 days", False), (1241179200, "9 months", False), + # 2015-01-01 (after expiry has already happened, so all + # intervals should cause autorenewal/autodeployment) + (1420070400, "0 seconds", True), + (1420070400, "10 seconds", True), + (1420070400, "10 minutes", True), + (1420070400, "10 weeks", True), (1420070400, "10 months", True), + (1420070400, "10 years", True), (1420070400, "99 months", True), + ]: sometime = datetime.datetime.utcfromtimestamp(current_time) mock_datetime.datetime.utcnow.return_value = sometime self.test_rc.configuration["deploy_before_expiry"] = interval From 2201e7944d1b10513d79ad6c8fe9c3572975a06e Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 16:47:41 -0700 Subject: [PATCH 48/66] Unit tests for le_util.unique_lineage_name() --- letsencrypt/tests/le_util_test.py | 43 ++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/letsencrypt/tests/le_util_test.py b/letsencrypt/tests/le_util_test.py index c9da155c5..3f5f08c4c 100644 --- a/letsencrypt/tests/le_util_test.py +++ b/letsencrypt/tests/le_util_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.le_util.""" +import errno import os import shutil import stat @@ -78,7 +79,7 @@ class CheckPermissionsTest(unittest.TestCase): class UniqueFileTest(unittest.TestCase): - """Tests for letsencrypt.class.le_util.unique_file.""" + """Tests for letsencrypt.le_util.unique_file.""" def setUp(self): self.root_path = tempfile.mkdtemp() @@ -122,5 +123,45 @@ class UniqueFileTest(unittest.TestCase): self.assertTrue(basename3.endswith('foo.txt')) +class UniqueLineageNameTest(unittest.TestCase): + """Tests for letsencrypt.le_util.unique_lineage_name.""" + + def setUp(self): + self.root_path = tempfile.mkdtemp() + + def tearDown(self): + shutil.rmtree(self.root_path, ignore_errors=True) + + def _call(self, filename, mode=0o777): + from letsencrypt.le_util import unique_lineage_name + return unique_lineage_name(self.root_path, filename, mode) + + def test_basic(self): + f, name = self._call("wow") + self.assertTrue(isinstance(f, file)) + self.assertTrue(isinstance(name, str)) + + def test_multiple(self): + for _ in range(10): + f, name = self._call("wow") + self.assertTrue(isinstance(f, file)) + self.assertTrue(isinstance(name, str)) + self.assertTrue("wow-0009.conf" in name) + + @mock.patch("letsencrypt.le_util.os.fdopen") + def test_failure(self, mock_fdopen): + err = OSError("whoops") + err.errno = errno.EIO + mock_fdopen.side_effect = err + self.assertRaises(OSError, self._call, "wow") + + @mock.patch("letsencrypt.le_util.os.fdopen") + def test_subsequent_failure(self, mock_fdopen): + self._call("wow") + err = OSError("whoops") + err.errno = errno.EIO + mock_fdopen.side_effect = err + self.assertRaises(OSError, self._call, "wow") + if __name__ == '__main__': unittest.main() # pragma: no cover From 64d4e6249c010b8fcf3a0a69ed5d83504d690640 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 17:01:04 -0700 Subject: [PATCH 49/66] Fix some PEP8 issues --- letsencrypt/notify.py | 1 + letsencrypt/renewer.py | 11 +++++++---- letsencrypt/storage.py | 10 ++++++---- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/letsencrypt/notify.py b/letsencrypt/notify.py index 6efb42d21..b4eec938f 100644 --- a/letsencrypt/notify.py +++ b/letsencrypt/notify.py @@ -5,6 +5,7 @@ import smtplib import socket import subprocess + def notify(subject, whom, what): """Try to notify the addressee (whom) by e-mail, with Subject: defined by subject and message body by what.""" diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 40a6cfc74..0ded2baab 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -25,6 +25,7 @@ DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" + class AttrDict(dict): """A trick to allow accessing dictionary keys as object attributes.""" @@ -32,16 +33,17 @@ class AttrDict(dict): super(AttrDict, self).__init__(*args, **kwargs) self.__dict__ = self + def renew(cert, old_version): """Perform automated renewal of the referenced cert, if possible.""" # TODO: handle partial success # TODO: handle obligatory key rotation vs. optional key rotation vs. # requested key rotation - if not cert.configfile.has_key("renewalparams"): + if "renewalparams" not in cert.configfile: # TODO: notify user? return False renewalparams = cert.configfile["renewalparams"] - if not renewalparams.has_key("authenticator"): + if "authenticator" not in renewalparams: # TODO: notify user? return False # Instantiate the appropriate authenticator @@ -74,13 +76,14 @@ def renew(cert, old_version): # new_key if the old key is to be used (since save_successor # already understands this distinction!) return cert.save_successor(old_version, new_cert, new_key, new_chain) - # TODO: Notify results + # TODO: Notify results else: - # TODO: Notify negative results + # TODO: Notify negative results return False # TODO: Consider the case where the renewal was partially successful # (where fewer than all names were renewed) + def main(config=DEFAULTS): """main function for autorenewer script.""" # TODO: Distinguish automated invocation from manual invocation, diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 0e410319e..8a6c47768 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -21,6 +21,7 @@ DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" ALL_FOUR = ("cert", "privkey", "chain", "fullchain") + def parse_time_interval(interval, textparser=parsedatetime.Calendar()): """Parse the time specified time interval, which can be in the English-language format understood by parsedatetime, e.g., '10 days', @@ -33,6 +34,7 @@ def parse_time_interval(interval, textparser=parsedatetime.Calendar()): return datetime.timedelta(0, time.mktime(textparser.parse( interval, time.localtime(0))[0])) + class RenewableCert(object): # pylint: disable=too-many-instance-attributes """Represents a lineage of certificates that is under the management of the Let's Encrypt client, indicated by the existence of an @@ -55,7 +57,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.configuration = copy.deepcopy(defaults) self.configuration.merge(self.configfile) - if not all(self.configuration.has_key(x) for x in ALL_FOUR): + if not all(x in self.configuration for x in ALL_FOUR): raise ValueError("renewal config file {0} is missing a required " "file reference".format(configfile)) @@ -244,7 +246,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes pem) i = method(x509) return pyrfc3339.parse(i[0:4] + "-" + i[4:6] + "-" + i[6:8] + "T" + - i[8:10] + ":" + i[10:12] +":" +i[12:]) + i[8:10] + ":" + i[10:12] + ":" + i[12:]) def notbefore(self, version=None): """When is the beginning validity time of the specified version of the @@ -264,7 +266,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes autodeployment is enabled, whether a relevant newer version exists, and whether the time interval for autodeployment has been reached.)""" - if (not self.configuration.has_key("autodeploy") or + if ("autodeploy" not in self.configuration or self.configuration.as_bool("autodeploy")): if self.has_pending_deployment(): interval = self.configuration.get("deploy_before_expiry", @@ -291,7 +293,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes def should_autorenew(self): """Should an attempt be made to automatically renew the most recent certificate in this certificate lineage right now?""" - if (not self.configuration.has_key("autorenew") + if ("autorenew" not in self.configuration or self.configuration.as_bool("autorenew")): # Consider whether to attempt to autorenew this cert now # XXX: both self.ocsp_revoked() and self.notafter() are bugs From 9a144b46bc475737acfa7c829959944c703f527c Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 17:03:20 -0700 Subject: [PATCH 50/66] Remove TODO referring to obsolete .config feature --- letsencrypt/renewer.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 0ded2baab..80128285b 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -6,9 +6,6 @@ configuration.""" # TODO: call new installer API to restart servers after deployment -# TODO: when renewing or deploying, update config file to -# memorialize the fact that it happened - import os import configobj From 4a100490a116781be958cb08bea4302d9da1d047 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 14 May 2015 17:36:30 -0700 Subject: [PATCH 51/66] Readability improvements for storage.py --- letsencrypt/storage.py | 86 ++++++++++++++++++++++++------------------ 1 file changed, 49 insertions(+), 37 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 8a6c47768..3ca6a4c81 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -69,10 +69,12 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes def consistent(self): """Is the structure of the archived files and links related to this lineage correct and self-consistent?""" + # Each element must be referenced with an absolute path if any(not os.path.isabs(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): return False + # Each element must exist and be a symbolic link if any(not os.path.islink(x) for x in (self.cert, self.privkey, self.chain, self.fullchain)): @@ -83,6 +85,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes target = os.readlink(link) if not os.path.isabs(target): target = os.path.join(where, target) + # Each element's link must point within the cert lineage's # directory within the official archive directory desired_directory = os.path.join( @@ -90,14 +93,17 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if not os.path.samefile(os.path.dirname(target), desired_directory): return False + # The link must point to a file that exists if not os.path.exists(target): return False + # The link must point to a file that follows the archive # naming convention pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) if not pattern.match(os.path.basename(target)): return False + # It is NOT required that the link's target be a regular # file (it may itself be a symlink). But we should probably # do a recursive check that ultimately the target does @@ -109,8 +115,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # XXX: All four of the targets are in the same directory # (This check is redundant with the check that they # are all in the desired directory!) - # len(set(os.path.basename(self.current_target(x) - # for x in ALL_FOUR))) == 1 + # len(set(os.path.basename(self.current_target(x) + # for x in ALL_FOUR))) == 1 return True def fix(self): @@ -299,9 +305,11 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # XXX: both self.ocsp_revoked() and self.notafter() are bugs # here because we should be looking at the latest version, not # the current version! + # Renewals on the basis of revocation if self.ocsp_revoked(): return True + # Renewals on the basis of expiry time interval = self.configuration.get("renew_before_expiry", "10 days") autorenew_interval = parse_time_interval(interval) @@ -326,6 +334,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes Returns a new RenewableCert object referring to the created lineage. (The actual lineage name, as well as all the relevant file paths, will be available within this object.)""" + + # Examine the configuration and find the new lineage's name configs_dir = config["renewal_configs_dir"] archive_dir = config["official_archive_dir"] live_dir = config["live_dir"] @@ -336,7 +346,9 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes lineagename) if not config_filename.endswith(".conf"): raise ValueError("renewal config file name must end in .conf") - # lineagename will now potentially be modified based on what + + # Determine where on disk everything will go + # lineagename will now potentially be modified based on which # renewal configuration file could actually be created lineagename = os.path.basename(config_filename)[:-len(".conf")] archive = os.path.join(archive_dir, lineagename) @@ -348,33 +360,28 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.mkdir(archive) os.mkdir(live_dir) relative_archive = os.path.join("..", "..", "archive", lineagename) - cert_target = os.path.join(live_dir, "cert.pem") - privkey_target = os.path.join(live_dir, "privkey.pem") - chain_target = os.path.join(live_dir, "chain.pem") - fullchain_target = os.path.join(live_dir, "fullchain.pem") - os.symlink(os.path.join(relative_archive, "cert1.pem"), - cert_target) - os.symlink(os.path.join(relative_archive, "privkey1.pem"), - privkey_target) - os.symlink(os.path.join(relative_archive, "chain1.pem"), - chain_target) - os.symlink(os.path.join(relative_archive, "fullchain1.pem"), - fullchain_target) - with open(cert_target, "w") as f: + + # Put the data into the appropriate files on disk + target = dict([(kind, os.path.join(live_dir, kind + ".pem")) + for kind in ALL_FOUR]) + for kind in ALL_FOUR: + os.symlink(os.path.join(relative_archive, kind + "1.pem"), + target[kind]) + with open(target["cert"], "w") as f: f.write(cert) - with open(privkey_target, "w") as f: + with open(target["privkey"], "w") as f: f.write(privkey) # XXX: Let's make sure to get the file permissions right here - with open(chain_target, "w") as f: + with open(target["chain"], "w") as f: f.write(chain) - with open(fullchain_target, "w") as f: + with open(target["fullchain"], "w") as f: f.write(cert + chain) + + # Document what we've done in a new renewal config file config_file.close() new_config = configobj.ConfigObj(config_filename, create_empty=True) - new_config["cert"] = cert_target - new_config["privkey"] = privkey_target - new_config["chain"] = chain_target - new_config["fullchain"] = fullchain_target + for kind in ALL_FOUR: + new_config[kind] = target[kind] if renewalparams: new_config["renewalparams"] = renewalparams new_config.comments["renewalparams"] = ["", @@ -385,6 +392,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes new_config.write() return cls(new_config, config) + def save_successor(self, prior_version, new_cert, new_privkey, new_chain): """Save a new cert and chain as a successor of a specific prior version in this lineage. Returns the new version number that was @@ -393,19 +401,19 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # XXX: consider using os.open for availablity of os.O_EXCL # XXX: ensure file permissions are correct; also create directories # if needed (ensuring their permissions are correct) + # Figure out what the new version is and hence where to save things + target_version = self.next_free_version() archive = self.configuration["official_archive_dir"] prefix = os.path.join(archive, self.lineagename) - cert_target = os.path.join( - prefix, "cert{0}.pem".format(target_version)) - privkey_target = os.path.join( - prefix, "privkey{0}.pem".format(target_version)) - chain_target = os.path.join( - prefix, "chain{0}.pem".format(target_version)) - fullchain_target = os.path.join( - prefix, "fullchain{0}.pem".format(target_version)) - with open(cert_target, "w") as f: - f.write(new_cert) + target = dict( + [(kind, + os.path.join(prefix, "{0}{1}.pem".format(kind, target_version))) + for kind in ALL_FOUR]) + + # Distinguish the cases where the privkey has changed and where it + # has not changed (in the latter case, making an appropriate symlink + # to an earlier privkey version) if new_privkey is None: # The behavior below keeps the prior key by creating a new # symlink to the old key or the target of the old key symlink. @@ -415,12 +423,16 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes old_privkey = os.readlink(old_privkey) else: old_privkey = "privkey{0}.pem".format(prior_version) - os.symlink(old_privkey, privkey_target) + os.symlink(old_privkey, target["privkey"]) else: - with open(privkey_target, "w") as f: + with open(target["privkey"], "w") as f: f.write(new_privkey) - with open(chain_target, "w") as f: + + # Save everything else + with open(target["cert"], "w") as f: + f.write(new_cert) + with open(target["chain"], "w") as f: f.write(new_chain) - with open(fullchain_target, "w") as f: + with open(target["fullchain"], "w") as f: f.write(new_cert + new_chain) return target_version From dd18040e47c1a2a18c09af496800df8dcbd2257c Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 15 May 2015 15:55:11 -0700 Subject: [PATCH 52/66] Use getattr() instead of .__getattribute__() --- letsencrypt/storage.py | 6 +++--- letsencrypt/tests/renewer_test.py | 32 +++++++++++++++---------------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 3ca6a4c81..3e0340172 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -80,7 +80,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes (self.cert, self.privkey, self.chain, self.fullchain)): return False for kind in ALL_FOUR: - link = self.__getattribute__(kind) + link = getattr(self, kind) where = os.path.dirname(link) target = os.readlink(link) if not os.path.isabs(target): @@ -139,7 +139,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes kind currently points.""" if kind not in ALL_FOUR: raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) + link = getattr(self, kind) if not os.path.exists(link): return None target = os.readlink(link) @@ -222,7 +222,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes that the specified version exists.)""" if kind not in ALL_FOUR: raise ValueError("unknown kind of item") - link = self.__getattribute__(kind) + link = getattr(self, kind) filename = "{0}{1}.pem".format(kind, version) # Relative rather than absolute target directory target_directory = os.path.dirname(os.readlink(link)) diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index a35ad6378..0486c75e9 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -17,13 +17,13 @@ def unlink_all(rc_object): """Unlink all four items associated with this RenewableCert. (Helper function.)""" for kind in ALL_FOUR: - os.unlink(rc_object.__getattribute__(kind)) + os.unlink(getattr(rc_object, kind)) def fill_with_sample_data(rc_object): """Put dummy data into all four files of this RenewableCert. (Helper function.)""" for kind in ALL_FOUR: - with open(rc_object.__getattribute__(kind), "w") as f: + with open(getattr(rc_object, kind), "w") as f: f.write(kind) class RenewableCertTests(unittest.TestCase): @@ -58,7 +58,7 @@ class RenewableCertTests(unittest.TestCase): self.assertEqual(self.test_rc.lineagename, "example.org") for kind in ALL_FOUR: self.assertEqual( - self.test_rc.__getattribute__(kind), os.path.join( + getattr(self.test_rc, kind), os.path.join( self.tempdir, "live", "example.org", kind + ".pem")) def test_renewal_bad_config(self): @@ -105,20 +105,20 @@ class RenewableCertTests(unittest.TestCase): # Items must point to desired place if they are relative for kind in ALL_FOUR: os.symlink(os.path.join("..", kind + "17.pem"), - self.test_rc.__getattribute__(kind)) + getattr(self.test_rc, kind)) self.assertFalse(self.test_rc.consistent()) unlink_all(self.test_rc) # Items must point to desired place if they are absolute for kind in ALL_FOUR: os.symlink(os.path.join(self.tempdir, kind + "17.pem"), - self.test_rc.__getattribute__(kind)) + getattr(self.test_rc, kind)) self.assertFalse(self.test_rc.consistent()) unlink_all(self.test_rc) # Items must point to things that exist for kind in ALL_FOUR: os.symlink(os.path.join("..", "..", "archive", "example.org", kind + "17.pem"), - self.test_rc.__getattribute__(kind)) + getattr(self.test_rc, kind)) self.assertFalse(self.test_rc.consistent()) # This version should work fill_with_sample_data(self.test_rc) @@ -170,7 +170,7 @@ class RenewableCertTests(unittest.TestCase): def test_latest_and_next_versions(self): for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -205,7 +205,7 @@ class RenewableCertTests(unittest.TestCase): # the result ver = 17 for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -218,7 +218,7 @@ class RenewableCertTests(unittest.TestCase): def test_update_link_to(self): for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -253,7 +253,7 @@ class RenewableCertTests(unittest.TestCase): def test_update_all_links_to(self): for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -271,7 +271,7 @@ class RenewableCertTests(unittest.TestCase): def test_has_pending_deployment(self): for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -316,7 +316,7 @@ class RenewableCertTests(unittest.TestCase): test_cert = pkg_resources.resource_string( "letsencrypt.tests", "testdata/cert.pem") for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + 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: @@ -374,7 +374,7 @@ class RenewableCertTests(unittest.TestCase): # No pending deployment for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -393,7 +393,7 @@ class RenewableCertTests(unittest.TestCase): self.assertFalse(self.test_rc.should_autorenew()) self.test_rc.configuration["autorenew"] = "1" for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + 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: @@ -406,7 +406,7 @@ class RenewableCertTests(unittest.TestCase): def test_save_successor(self): for ver in range(1, 6): for kind in ALL_FOUR: - where = self.test_rc.__getattribute__(kind) + where = getattr(self.test_rc, kind) if os.path.islink(where): os.unlink(where) os.symlink(os.path.join("..", "..", "archive", "example.org", @@ -564,7 +564,7 @@ class RenewableCertTests(unittest.TestCase): for kind in ALL_FOUR: os.symlink(os.path.join("..", "..", "archive", "example.org", kind + "1.pem"), - self.test_rc.__getattribute__(kind)) + getattr(self.test_rc, kind)) fill_with_sample_data(self.test_rc) with open(self.test_rc.cert, "w") as f: f.write(test_cert) From 0f64082f1d5f104f9663a366ff44918b4d123d46 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 16 May 2015 21:27:06 -0700 Subject: [PATCH 53/66] Document newly-added functions and methods --- letsencrypt/le_util.py | 15 +- letsencrypt/renewer.py | 16 ++- letsencrypt/storage.py | 312 +++++++++++++++++++++++++++++++++-------- 3 files changed, 274 insertions(+), 69 deletions(-) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 1abdcec92..9a3dd77a5 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -60,7 +60,7 @@ def unique_file(path, mode=0o777): :param str path: path/filename.ext :param int mode: File mode - :return: tuple of file object and file name + :returns: tuple of file object and file name """ path, tail = os.path.split(path) @@ -79,11 +79,16 @@ def unique_lineage_name(path, filename, mode=0o777): """Safely finds a unique file for writing only (by default). Uses a file lineage convention. - :param str path: path - :param str filename: filename - :param int mode: File mode + :param str path: directory path + :param str filename: proposed filename + :param int mode: file mode - :return: tuple of file object and file name + :returns: tuple of file object and file name (which may be modified from + the requested one by appending digits to ensure uniqueness) + + :raises OSError: if writing files fails for an unanticipated reason, + such as a full disk or a lack of permission to write to specified + location. """ fname = os.path.join(path, "%s.conf" % (filename)) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 80128285b..0ff9d2c75 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -32,8 +32,18 @@ class AttrDict(dict): def renew(cert, old_version): - """Perform automated renewal of the referenced cert, if possible.""" - # TODO: handle partial success + """Perform automated renewal of the referenced cert, if possible. + + :param class:`letsencrypt.storage.RenewableCert` cert: the certificate + lineage to attempt to renew. + :param int old_version: the version of the certificate lineage relative + to which the renewal should be attempted. + + :returns: int referring to newly created version of this cert lineage, + or False if renewal was not successful.""" + + # TODO: handle partial success (some names can be renewed but not + # others) # TODO: handle obligatory key rotation vs. optional key rotation vs. # requested key rotation if "renewalparams" not in cert.configfile: @@ -106,7 +116,7 @@ def main(config=DEFAULTS): continue if cert.should_autodeploy(): cert.update_all_links_to(cert.latest_common_version()) - # TODO: restart web server + # TODO: restart web server (invoke IInstaller.restart() method) notify.notify("Autodeployed a cert!!!", "root", "It worked!") # TODO: explain what happened if cert.should_autorenew(): diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 3e0340172..b30c7077a 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -23,12 +23,19 @@ ALL_FOUR = ("cert", "privkey", "chain", "fullchain") def parse_time_interval(interval, textparser=parsedatetime.Calendar()): - """Parse the time specified time interval, which can be in the - English-language format understood by parsedatetime, e.g., '10 days', - '3 weeks', '6 months', '9 hours', or a sequence of such intervals - like '6 months 1 week' or '3 days 12 hours'. If an integer is found - with no associated unit, it is interpreted by default as a number of - days.""" + """Parse the time specified time interval. + + The interval can be in the English-language format understood by + parsedatetime, e.g., '10 days', '3 weeks', '6 months', '9 hours', + or a sequence of such intervals like '6 months 1 week' or '3 days + 12 hours'. If an integer is found with no associated unit, it is + interpreted by default as a number of days. + + :param str interval: the time interval to parse. + + :returns: the interpretation of the time interval. + :rtype: :class:`datetime.timedelta`""" + if interval.strip().isdigit(): interval += " days" return datetime.timedelta(0, time.mktime(textparser.parse( @@ -38,9 +45,57 @@ def parse_time_interval(interval, textparser=parsedatetime.Calendar()): class RenewableCert(object): # pylint: disable=too-many-instance-attributes """Represents a lineage of certificates that is under the management of the Let's Encrypt client, indicated by the existence of an - associated renewal configuration file.""" + associated renewal configuration file. + + Note that the notion of "current version" for a lineage is maintained + on disk in the structure of symbolic links, and is not explicitly + stored in any instance variable in this object. The RenewableCert + object is able to determine information about the current (or other) + version by accessing data on disk, but does not inherently know any + of this information except by examining the symbolic links as needed. + The instance variables mentioned below point to symlinks that reflect + the notion of "current version" of each managed object, and it is + these paths that should be used when configuring servers to use the + certificate managed in a lineage. These paths are normally within + the "live" directory, and their symlink targets -- the actual cert + files -- are normally found within the "archive" directory. + + :ivar cert: The path to the symlink representing the current version + of the certificate managed by this lineage. + :type cert: str + + :ivar privkey: The path to the symlink representing the current version + of the private key managed by this lineage. + :type privkey: str + + :ivar chain: The path to the symlink representing the current version + of the chain managed by this lineage. + :type chain: str + + :ivar fullchain: The path to the symlink representing the current version + of the fullchain (combined chain and cert) managed by this lineage. + :type fullchain: str + + :ivar configuration: The renewal configuration options associated with + this lineage, obtained from parsing the renewal configuration file + and/or systemwide defaults. + :type configuration: :class:`configobj.ConfigObj`""" def __init__(self, configfile, defaults=DEFAULTS): + """Instantiate a RenewableCert object from an existing lineage. + + :param :class:`configobj.ConfigObj` configfile: an already-parsed + ConfigObj object made from reading the renewal config file that + defines this lineage. + :param :class:`configobj.ConfigObj` defaults: systemwide defaults + for renewal properties not otherwise specified in the individual + renewal config file. + + :raises ValueError: 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.""" + if isinstance(configfile, configobj.ConfigObj): if not os.path.basename(configfile.filename).endswith(".conf"): raise ValueError("renewal config file name must end in .conf") @@ -67,8 +122,11 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes self.fullchain = self.configuration["fullchain"] def consistent(self): - """Is the structure of the archived files and links related to this - lineage correct and self-consistent?""" + """Are the files associated with this lineage self-consistent? + + :returns: whether the files stored in connection with this + lineage appear to be correct and consistent with one another. + :rtype: bool""" # Each element must be referenced with an absolute path if any(not os.path.isabs(x) for x in @@ -120,8 +178,8 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return True def fix(self): - """Attempt to fix some kinds of defects or inconsistencies - in the symlink structure, if possible.""" + """Attempt to fix defects or inconsistencies in this lineage. + (Currently unimplemented.)""" # TODO: Figure out what kinds of fixes are possible. For # example, checking if there is a valid version that # we can update the symlinks to. (Maybe involve @@ -135,8 +193,14 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # filesystem errors, or crashes.) def current_target(self, kind): - """Returns the full path to which the link of the specified - kind currently points.""" + """Returns full path to which the specified item currently points. + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + + :returns: the path to the current version of the specified member. + :rtype: str""" + if kind not in ALL_FOUR: raise ValueError("unknown kind of item") link = getattr(self, kind) @@ -148,10 +212,18 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return target def current_version(self, kind): - """Returns the numerical version of the object to which the link - of the specified kind currently points. For example, if kind + """Returns numerical version of the specified item. + + For example, if kind is "chain" and the current chain link points to a file named - "chain7.pem", returns the integer 7.""" + "chain7.pem", returns the integer 7. + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + + :returns: the current version of the specified member. + :rtype: int""" + if kind not in ALL_FOUR: raise ValueError("unknown kind of item") pattern = re.compile(r"^{0}([0-9]+)\.pem$".format(kind)) @@ -165,18 +237,36 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return None def version(self, kind, version): - """Constructs the filename that would correspond to the - specified version of the specified kind of item in this - lineage. Warning: the specified version may not exist.""" + """The filename that corresponds to the specified version and kind. + + Warning: the specified version may not exist in this lineage. There + is no guarantee that the file path returned by this method actually + exists. + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + :param int version: the desired version + + :returns: the path to the specified version of the specified member. + :rtype: str""" + if kind not in ALL_FOUR: raise ValueError("unknown kind of item") where = os.path.dirname(self.current_target(kind)) return os.path.join(where, "{0}{1}.pem".format(kind, version)) def available_versions(self, kind): - """Which alternative versions of the specified kind of item - exist in the archive directory where the current version is - stored?""" + """Which alternative versions of the specified kind of item exist? + + The archive directory where the current version is stored is + consulted to obtain the list of alternatives. + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + + :returns: all of the version numbers that currently exist + :rtype: list of int""" + if kind not in ALL_FOUR: raise ValueError("unknown kind of item") where = os.path.dirname(self.current_target(kind)) @@ -186,13 +276,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return sorted([int(m.groups()[0]) for m in matches if m]) def newest_available_version(self, kind): - """What is the newest available version of the specified - kind of item?""" + """What is the newest available version of the specified kind of item? + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + + :returns: the newest available version of this member + :rtype: int""" + return max(self.available_versions(kind)) def latest_common_version(self): - """What is the largest version number for which versions - of cert, privkey, chain, and fullchain are all available?""" + """What is the newest version for which all items are available? + + :returns: the newest available version for which all members (cert, + privkey, chain, and fullchain) exist + :rtype: int""" + # TODO: this can raise ValueError if there is no version overlap # (it should probably return None instead) # TODO: this can raise a spurious AttributeError if the current @@ -201,8 +301,13 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return max(n for n in versions[0] if all(n in v for v in versions[1:])) def next_free_version(self): - """What is the smallest new version number that is larger than - any available version of any managed item?""" + """What is the smallest version newer than all full or partial versions? + + :returns: the smallest version number that is larger than any version + of any item currently stored in this lineage + :rtype: int + """ + # TODO: consider locking/mutual exclusion between updating processes # This isn't self.latest_common_version() + 1 because we don't want # collide with a version that might exist for one file type but not @@ -210,16 +315,28 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return max(self.newest_available_version(x) for x in ALL_FOUR) + 1 def has_pending_deployment(self): - """Is there a later version of all of the managed items?""" + """Is there a later version of all of the managed items? + + :returns: True if there is a complete version of this lineage with + a larger version number than the current version, and False + otherwise + :rtype: bool""" + # TODO: consider whether to assume consistency or treat # inconsistent/consistent versions differently smallest_current = min(self.current_version(x) for x in ALL_FOUR) return smallest_current < self.latest_common_version() def update_link_to(self, kind, version): - """Change the target of the link of the specified item to point - to the specified version. (Note that this method doesn't verify - that the specified version exists.)""" + """Make the specified item point at the specified version. + + (Note that this method doesn't verify that the specified version + exists.) + + :param str kind: the lineage member item ("cert", "privkey", + "chain", or "fullchain") + :param int version: the desired version""" + if kind not in ALL_FOUR: raise ValueError("unknown kind of item") link = getattr(self, kind) @@ -236,8 +353,10 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes os.symlink(os.path.join(target_directory, filename), link) def update_all_links_to(self, version): - """Change the target of the cert, privkey, chain, and fullchain links - to point to the specified version.""" + """Change all member objects to point to the specified version. + + :param int version: the desired version""" + for kind in ALL_FOUR: self.update_link_to(kind, version) @@ -255,23 +374,43 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes i[8:10] + ":" + i[10:12] + ":" + i[12:]) def notbefore(self, version=None): - """When is the beginning validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" + """When does the specified cert version start being valid? + + (If no version is specified, use the current version.) + + :param int version: the desired version number + + :returns: the notBefore value from the specified cert version in this + lineage + :rtype: :class:`datetime.datetime`""" + return self._notafterbefore(lambda x509: x509.get_notBefore(), version) def notafter(self, version=None): - """When is the ending validity time of the specified version of the - cert in this lineage? (If no version is specified, use the current - version.)""" + """When does the specified cert version stop being valid? + + (If no version is specified, use the current version.) + + :param int version: the desired version number + + :returns: the notAfter value from the specified cert version in this + lineage + :rtype: :class:`datetime.datetime`""" + return self._notafterbefore(lambda x509: x509.get_notAfter(), version) def should_autodeploy(self): - """Should this certificate lineage be updated automatically to - point to an existing pending newer version? (Considers whether - autodeployment is enabled, whether a relevant newer version - exists, and whether the time interval for autodeployment has - been reached.)""" + """Should this lineage now automatically deploy a newer version? + + This is a policy question and does not only depend on whether there + is a newer version of the cert. (This considers whether autodeployment + is enabled, whether a relevant newer version exists, and whether the + time interval for autodeployment has been reached.) + + :returns: whether the lineage now ought to autodeploy an existing + newer cert version + :rtype: bool""" + if ("autodeploy" not in self.configuration or self.configuration.as_bool("autodeploy")): if self.has_pending_deployment(): @@ -287,18 +426,36 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes def ocsp_revoked(self, version=None): # pylint: disable=no-self-use,unused-argument - """Is the specified version of this certificate lineage revoked - according to OCSP or intended to be revoked according to Let's - Encrypt OCSP extensions? (If no version is specified, use the - current version.)""" + """Is the specified cert version revoked according to OCSP? + + Also returns True if the cert version is declared as intended to be + revoked according to Let's Encrypt OCSP extensions. (If no version + is specified, uses the current version.) + + This method is not yet implemented and currently always returns False. + + :param int version: the desired version number + + :returns: whether the certificate is or will be revoked + :rtype: bool""" + # XXX: This query and its associated network service aren't # implemented yet, so we currently return False (indicating that the # certificate is not revoked). return False def should_autorenew(self): - """Should an attempt be made to automatically renew the most - recent certificate in this certificate lineage right now?""" + """Should we now try to autorenew the most recent the most cert version? + + This is a policy question and does not only depend on whether the + cert is expired. (This considers whether autorenewal is enabled, + whether the cert is revoked, and whether the time interval for + autorenewal has been reached.) + + :returns: whether an attempt should now be made to autorenew the + most current cert version in this lineage + :rtype: bool""" + if ("autorenew" not in self.configuration or self.configuration.as_bool("autorenew")): # Consider whether to attempt to autorenew this cert now @@ -324,16 +481,35 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes def new_lineage(cls, lineagename, cert, privkey, chain, renewalparams=None, config=DEFAULTS): # pylint: disable=too-many-locals,too-many-arguments - """Create a new certificate lineage with the (suggested) lineage name - lineagename, and the associated cert, privkey, and chain (the - associated fullchain will be created automatically). Optional - configurator and renewalparams record the configuration that was - originally used to obtain this cert, so that it can be reused later - during automated renewal. + """Create a new certificate lineage. + + Attempts to create a certificate lineage -- enrolled for potential + future renewal -- with the (suggested) lineage name lineagename, + and the associated cert, privkey, and chain (the associated + fullchain will be created automatically). Optional configurator + and renewalparams record the configuration that was originally + used to obtain this cert, so that it can be reused later during + automated renewal. Returns a new RenewableCert object referring to the created lineage. (The actual lineage name, as well as all the relevant - file paths, will be available within this object.)""" + file paths, will be available within this object.) + + :param str lineagename: the suggested name for this lineage + (normally the current cert's first subject DNS name) + :param str cert: the initial certificate version in PEM format + :param str privkey: the private key in PEM format + :param str chain: the certificate chain in PEM format + :param :class:`configobj.ConfigObj` renewalparams: parameters that + should be used when instantiating authenticator and installer + objects in the future to attempt to renew this cert or deploy + new versions of it + :param :class:`configobj.ConfigObj` config: renewal configuration + defaults, affecting, for example, the locations of the + directories where the associated files will be saved + + :returns: the newly-created RenewalCert object + :rtype: :class:`storage.renewableCert`""" # Examine the configuration and find the new lineage's name configs_dir = config["renewal_configs_dir"] @@ -394,9 +570,23 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes def save_successor(self, prior_version, new_cert, new_privkey, new_chain): - """Save a new cert and chain as a successor of a specific prior - version in this lineage. Returns the new version number that was - created. Note: does NOT update links to deploy this version.""" + """Save new cert and chain as a successor of a prior version. + + Returns the new version number that was created. Note: does NOT + update links to deploy this version. + + :param int prior_version: the old version to which this version is + regarded as a successor (used to choose a privkey, if the key + has not changed, but otherwise this information is not permanently + recorded anywhere) + :param str new_cert: the new certificate, in PEM format + :param str new_privkey: the new private key, in PEM format, or None, + if the private key has not changed + :param str new_chain: the new chain, in PEM format + + :returns: the new version number that was created + :rtype: int""" + # XXX: assumes official archive location rather than examining links # XXX: consider using os.open for availablity of os.O_EXCL # XXX: ensure file permissions are correct; also create directories From 52fefad693d4b7252d419e9352c72d0e9bbf50c3 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Sat, 16 May 2015 23:51:58 -0700 Subject: [PATCH 54/66] Basic functionality of run/auth CLI verbs --- letsencrypt/cli.py | 8 ++++++-- letsencrypt/client.py | 3 ++- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 7b8231bde..4d32f9ca0 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -98,9 +98,11 @@ def run(args, config, plugins): return "Configurator could not be determined" acme, doms = _common_run(args, config, acc, authenticator, installer) + # TODO: Handle errors from _common_run? lineage = acme.obtain_and_enroll_certificate(doms, authenticator, installer) - # TODO: Decide whether to enroll or not from config/policy + if not lineage: + return "Certificate could not be obtained" acme.deploy_certificate(doms, lineage) acme.enhance_config(doms, args.redirect) @@ -122,9 +124,11 @@ def auth(args, config, plugins): else: installer = None + # TODO: Handle errors from _common_run? acme, doms = _common_run( args, config, acc, authenticator=authenticator, installer=installer) - acme.obtain_certificate(doms) + if not acme.obtain_and_enroll_certificate(doms, authenticator, installer): + return "Certificate could not be obtained" def install(args, config, plugins): diff --git a/letsencrypt/client.py b/letsencrypt/client.py index e7c6310eb..0a16dbff0 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -163,7 +163,8 @@ class Client(object): # TODO: Add IPlugin.name or use PluginsFactory.find_init instead # of assuming that each plugin has a .name attribute self.config.namespace.authenticator = authenticator.name - self.config.namespace.installer = installer.name + if installer is not None: + self.config.namespace.installer = installer.name return storage.RenewableCert.new_lineage(domains[0], cert_pem, privkey, chain_pem, vars(self.config.namespace)) From 8f252411705bc89e6d4097e25be364a4e69c1cf0 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 18 May 2015 15:57:12 -0700 Subject: [PATCH 55/66] Introduce proper renewer config via constants.py --- letsencrypt/constants.py | 13 +++++++++++++ letsencrypt/renewer.py | 15 +++++++++------ letsencrypt/storage.py | 24 +++++++++++++++--------- letsencrypt/tests/renewer_test.py | 7 +++---- 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 4eba69f20..c05d8828e 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -1,4 +1,5 @@ """Let's Encrypt constants.""" +import configobj import logging from acme import challenges @@ -25,6 +26,18 @@ CLI_DEFAULTS = dict( """Defaults for CLI flags and `.IConfig` attributes.""" +RENEWER_DEFAULTS = configobj.ConfigObj(dict( + renewer_config_file="/etc/letsencrypt/renewer.conf", + renewal_configs_dir="/etc/letsencrypt/configs", + archive_dir="/etc/letsencrypt/archive", + live_dir="/etc/letsencrypt/live", + renewer_enabled="yes", + renew_before_expiry="30 days", + deploy_before_expiry="20 days", +)) +"""Defaults for renewer script.""" + + EXCLUSIVE_CHALLENGES = frozenset([frozenset([ challenges.DVSNI, challenges.SimpleHTTPS])]) """Mutually exclusive challenges.""" diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 0ff9d2c75..79a489b05 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -11,17 +11,13 @@ import os import configobj from letsencrypt import configuration +from letsencrypt import constants from letsencrypt import client from letsencrypt import crypto_util from letsencrypt import notify from letsencrypt import storage from letsencrypt.plugins import disco as plugins_disco -DEFAULTS = configobj.ConfigObj("renewal.conf") -DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" -DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" -DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" - class AttrDict(dict): """A trick to allow accessing dictionary keys as object @@ -91,13 +87,20 @@ def renew(cert, old_version): # (where fewer than all names were renewed) -def main(config=DEFAULTS): +def main(config=constants.RENEWER_DEFAULTS): """main function for autorenewer script.""" # TODO: Distinguish automated invocation from manual invocation, # perhaps by looking at sys.argv[0] and inhibiting automated # invocations if /etc/letsencrypt/renewal.conf defaults have # turned it off. (The boolean parameter should probably be # called renewer_enabled.) + + # This attempts 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. + config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + for i in os.listdir(config["renewal_configs_dir"]): print "Processing", i if not i.endswith(".conf"): diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index b30c7077a..bdea23f7f 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -13,12 +13,9 @@ import parsedatetime import pytz import pyrfc3339 +from letsencrypt import constants from letsencrypt import le_util -DEFAULTS = configobj.ConfigObj("renewal.conf") -DEFAULTS["renewal_configs_dir"] = "/tmp/etc/letsencrypt/configs" -DEFAULTS["official_archive_dir"] = "/tmp/etc/letsencrypt/archive" -DEFAULTS["live_dir"] = "/tmp/etc/letsencrypt/live" ALL_FOUR = ("cert", "privkey", "chain", "fullchain") @@ -81,7 +78,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes and/or systemwide defaults. :type configuration: :class:`configobj.ConfigObj`""" - def __init__(self, configfile, defaults=DEFAULTS): + def __init__(self, configfile, defaults=constants.RENEWER_DEFAULTS): """Instantiate a RenewableCert object from an existing lineage. :param :class:`configobj.ConfigObj` configfile: an already-parsed @@ -109,6 +106,9 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # systemwide renewal configuration; self.configfile should be # used to make and save changes. self.configfile = configfile + # 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 = copy.deepcopy(defaults) self.configuration.merge(self.configfile) @@ -147,7 +147,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Each element's link must point within the cert lineage's # directory within the official archive directory desired_directory = os.path.join( - self.configuration["official_archive_dir"], self.lineagename) + self.configuration["archive_dir"], self.lineagename) if not os.path.samefile(os.path.dirname(target), desired_directory): return False @@ -479,7 +479,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=DEFAULTS): + renewalparams=None, config=constants.RENEWER_DEFAULTS): # pylint: disable=too-many-locals,too-many-arguments """Create a new certificate lineage. @@ -511,9 +511,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :returns: the newly-created RenewalCert object :rtype: :class:`storage.renewableCert`""" + # This attempts 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. + config.merge(configobj.ConfigObj(config.get("renewer_config_file", ""))) + # Examine the configuration and find the new lineage's name configs_dir = config["renewal_configs_dir"] - archive_dir = config["official_archive_dir"] + archive_dir = config["archive_dir"] live_dir = config["live_dir"] for i in (configs_dir, archive_dir, live_dir): if not os.path.exists(i): @@ -594,7 +600,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # Figure out what the new version is and hence where to save things target_version = self.next_free_version() - archive = self.configuration["official_archive_dir"] + archive = self.configuration["archive_dir"] prefix = os.path.join(archive, self.lineagename) target = dict( [(kind, diff --git a/letsencrypt/tests/renewer_test.py b/letsencrypt/tests/renewer_test.py index 0486c75e9..4f1fe6bfc 100644 --- a/letsencrypt/tests/renewer_test.py +++ b/letsencrypt/tests/renewer_test.py @@ -38,8 +38,7 @@ class RenewableCertTests(unittest.TestCase): os.makedirs(os.path.join(self.tempdir, "configs")) defaults = configobj.ConfigObj() defaults["live_dir"] = os.path.join(self.tempdir, "live") - defaults["official_archive_dir"] = os.path.join(self.tempdir, - "archive") + defaults["archive_dir"] = os.path.join(self.tempdir, "archive") defaults["renewal_configs_dir"] = os.path.join(self.tempdir, "configs") config = configobj.ConfigObj() @@ -461,7 +460,7 @@ class RenewableCertTests(unittest.TestCase): """Test for new_lineage() class method.""" from letsencrypt import storage config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["official_archive_dir"] + archive_dir = self.defaults["archive_dir"] live_dir = self.defaults["live_dir"] result = storage.RenewableCert.new_lineage("the-lineage.com", "cert", "privkey", "chain", None, @@ -500,7 +499,7 @@ class RenewableCertTests(unittest.TestCase): """Test that directories can be created if they don't exist.""" from letsencrypt import storage config_dir = self.defaults["renewal_configs_dir"] - archive_dir = self.defaults["official_archive_dir"] + archive_dir = self.defaults["archive_dir"] live_dir = self.defaults["live_dir"] shutil.rmtree(config_dir) shutil.rmtree(archive_dir) From 42b3e2180a7f4a88657c31da85c5ae68d645ba11 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Mon, 18 May 2015 16:50:46 -0700 Subject: [PATCH 56/66] Check latest, not current cert version. Fixes #423. --- letsencrypt/storage.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index bdea23f7f..852ab22df 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -445,13 +445,16 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes return False def should_autorenew(self): - """Should we now try to autorenew the most recent the most cert version? + """Should we now try to autorenew the most recent cert version? This is a policy question and does not only depend on whether the cert is expired. (This considers whether autorenewal is enabled, whether the cert is revoked, and whether the time interval for autorenewal has been reached.) + Note that this examines the numerically most recent cert version, + not the currently deployed version. + :returns: whether an attempt should now be made to autorenew the most current cert version in this lineage :rtype: bool""" @@ -459,18 +462,15 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes if ("autorenew" not in self.configuration or self.configuration.as_bool("autorenew")): # Consider whether to attempt to autorenew this cert now - # XXX: both self.ocsp_revoked() and self.notafter() are bugs - # here because we should be looking at the latest version, not - # the current version! # Renewals on the basis of revocation - if self.ocsp_revoked(): + if self.ocsp_revoked(self.latest_common_version()): return True # Renewals on the basis of expiry time interval = self.configuration.get("renew_before_expiry", "10 days") autorenew_interval = parse_time_interval(interval) - expiry = self.notafter() + expiry = self.notafter(self.latest_common_version()) now = datetime.datetime.utcnow().replace(tzinfo=pytz.UTC) remaining = expiry - now if remaining < autorenew_interval: From 083bd8701b5a159debde54d7949cb69cd9fffe53 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Tue, 19 May 2015 14:14:34 +0000 Subject: [PATCH 57/66] get_sans_from_cert, 100% test coverage for crypto_util. --- letsencrypt/crypto_util.py | 72 ++++++++++++++++++++------- letsencrypt/tests/crypto_util_test.py | 42 +++++++++++++++- 2 files changed, 95 insertions(+), 19 deletions(-) diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 94617eef6..db4b629d2 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -234,42 +234,78 @@ def make_ss_cert(key_str, domains, not_before=None, return cert.as_pem() -def _request_san(req): # TODO: implement directly in PyOpenSSL! +def _pyopenssl_cert_or_req_san(cert_or_req): + """Get Subject Alternative Names from certificate or CSR using pyOpenSSL. + + .. todo:: Implement directly in PyOpenSSL! + + :param cert_or_req: Certificate or CSR. + :type cert_or_req: `OpenSSL.crypto.X509` or `OpenSSL.crypto.X509Req`. + + :returns: A list of Subject Alternative Names. + :rtype: list + + """ # constants based on implementation of # OpenSSL.crypto.X509Error._subjectAltNameString parts_separator = ", " part_separator = ":" extension_short_name = "subjectAltName" + if hasattr(cert_or_req, 'get_extensions'): # X509Req + extensions = cert_or_req.get_extensions() + else: # X509 + extensions = [cert_or_req.get_extension(i) + for i in xrange(cert_or_req.get_extension_count())] + # pylint: disable=protected-access,no-member label = OpenSSL.crypto.X509Extension._prefixes[OpenSSL.crypto._lib.GEN_DNS] assert parts_separator not in label prefix = label + part_separator - extensions = [ext._subjectAltNameString().split(parts_separator) - for ext in req.get_extensions() - if ext.get_short_name() == extension_short_name] + san_extensions = [ + ext._subjectAltNameString().split(parts_separator) + for ext in extensions if ext.get_short_name() == extension_short_name] # WARNING: this function assumes that no SAN can include # parts_separator, hence the split! - return [part.split(part_separator)[1] for parts in extensions + return [part.split(part_separator)[1] for parts in san_extensions for part in parts if part.startswith(prefix)] -def get_sans_from_csr(csr, typ=OpenSSL.crypto.FILETYPE_PEM): - """Get list of Subject Alternative Names from signing request. - - :param str csr: Certificate Signing Request in PEM format (must contain - one or more subjectAlternativeNames, or the function will fail, - raising ValueError) - - :returns: List of referenced subject alternative names - :rtype: list - - """ +def _get_sans_from_cert_or_req( + cert_or_req_str, load_func, typ=OpenSSL.crypto.FILETYPE_PEM): try: - request = OpenSSL.crypto.load_certificate_request(typ, csr) + cert_or_req = load_func(typ, cert_or_req_str) except OpenSSL.crypto.Error as error: logging.exception(error) raise - return _request_san(request) + return _pyopenssl_cert_or_req_san(cert_or_req) + + +def get_sans_from_cert(cert, typ=OpenSSL.crypto.FILETYPE_PEM): + """Get a list of Subject Alternative Names from a certificate. + + :param str csr: Certificate (encoded). + :param typ: `OpenSSL.crypto.FILETYPE_PEM` or `OpenSSL.crypto.FILETYPE_ASN1` + + :returns: A list of Subject Alternative Names. + :rtype: list + + """ + return _get_sans_from_cert_or_req( + cert, OpenSSL.crypto.load_certificate, typ) + + +def get_sans_from_csr(csr, typ=OpenSSL.crypto.FILETYPE_PEM): + """Get a list of Subject Alternative Names from a CSR. + + :param str csr: CSR (encoded). + :param typ: `OpenSSL.crypto.FILETYPE_PEM` or `OpenSSL.crypto.FILETYPE_ASN1` + + :returns: A list of Subject Alternative Names. + :rtype: list + + """ + return _get_sans_from_cert_or_req( + csr, OpenSSL.crypto.load_certificate_request, typ) diff --git a/letsencrypt/tests/crypto_util_test.py b/letsencrypt/tests/crypto_util_test.py index 92cb4014b..a9f9da012 100644 --- a/letsencrypt/tests/crypto_util_test.py +++ b/letsencrypt/tests/crypto_util_test.py @@ -68,6 +68,27 @@ class InitSaveCSRTest(unittest.TestCase): self.assertEqual(csr.data, 'csr_der') self.assertTrue('csr-letsencrypt.pem' in csr.file) + +class MakeCSRTest(unittest.TestCase): + """Tests for letsencrypt.crypto_util.make_csr.""" + + @classmethod + def _call(cls, *args, **kwargs): + from letsencrypt.crypto_util import make_csr + return make_csr(*args, **kwargs) + + def test_san(self): + from letsencrypt.crypto_util import get_sans_from_csr + # TODO: Fails for RSA256_KEY + csr_pem, csr_der = self._call( + RSA512_KEY, ['example.com', 'www.example.com']) + self.assertEqual( + ['example.com', 'www.example.com'], get_sans_from_csr(csr_pem)) + self.assertEqual( + ['example.com', 'www.example.com'], get_sans_from_csr( + csr_der, OpenSSL.crypto.FILETYPE_ASN1)) + + class ValidCSRTest(unittest.TestCase): """Tests for letsencrypt.crypto_util.valid_csr.""" @@ -151,7 +172,26 @@ class MakeSSCertTest(unittest.TestCase): make_ss_cert(RSA512_KEY, ['example.com', 'www.example.com']) -class GetSansFromCsrTest(unittest.TestCase): +class GetSANsFromCertTest(unittest.TestCase): + """Tests for letsencrypt.crypto_util.get_sans_from_cert.""" + + @classmethod + def _call(cls, *args, **kwargs): + from letsencrypt.crypto_util import get_sans_from_cert + return get_sans_from_cert(*args, **kwargs) + + def test_single(self): + self.assertEqual([], self._call(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'cert.pem')))) + + def test_san(self): + self.assertEqual( + ['example.com', 'www.example.com'], + self._call(pkg_resources.resource_string( + __name__, os.path.join('testdata', 'cert-san.pem')))) + + +class GetSANsFromCSRTest(unittest.TestCase): """Tests for letsencrypt.crypto_util.get_sans_from_csr.""" def test_extract_one_san(self): from letsencrypt.crypto_util import get_sans_from_csr From b8a024b65b452f5375452ea91cde604eec3dd99f Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Thu, 21 May 2015 16:33:38 -0700 Subject: [PATCH 58/66] More generality for renewer config. (Still no CLI flags.) --- letsencrypt/renewer.py | 14 +++++++++++--- letsencrypt/storage.py | 15 +++++++++++---- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/letsencrypt/renewer.py b/letsencrypt/renewer.py index 79a489b05..c436c2ccd 100644 --- a/letsencrypt/renewer.py +++ b/letsencrypt/renewer.py @@ -6,6 +6,7 @@ configuration.""" # TODO: call new installer API to restart servers after deployment +import copy import os import configobj @@ -87,7 +88,7 @@ def renew(cert, old_version): # (where fewer than all names were renewed) -def main(config=constants.RENEWER_DEFAULTS): +def main(config=None): """main function for autorenewer script.""" # TODO: Distinguish automated invocation from manual invocation, # perhaps by looking at sys.argv[0] and inhibiting automated @@ -95,10 +96,17 @@ def main(config=constants.RENEWER_DEFAULTS): # turned it off. (The boolean parameter should probably be # called renewer_enabled.) - # This attempts to read the renewer config file and augment or replace + # Merge supplied config, if provided, on top of builtin defaults + defaults_copy = copy.deepcopy(constants.RENEWER_DEFAULTS) + defaults_copy.merge(config if config is not None else configobj.ConfigObj()) + config = defaults_copy + # 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. + # 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(config.get("renewer_config_file", ""))) for i in os.listdir(config["renewal_configs_dir"]): diff --git a/letsencrypt/storage.py b/letsencrypt/storage.py index 852ab22df..1fb17c561 100644 --- a/letsencrypt/storage.py +++ b/letsencrypt/storage.py @@ -78,13 +78,13 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes and/or systemwide defaults. :type configuration: :class:`configobj.ConfigObj`""" - def __init__(self, configfile, defaults=constants.RENEWER_DEFAULTS): + def __init__(self, configfile, config_opts=None): """Instantiate a RenewableCert object from an existing lineage. :param :class:`configobj.ConfigObj` configfile: an already-parsed ConfigObj object made from reading the renewal config file that defines this lineage. - :param :class:`configobj.ConfigObj` defaults: systemwide defaults + :param :class:`configobj.ConfigObj` config_opts: systemwide defaults for renewal properties not otherwise specified in the individual renewal config file. @@ -109,7 +109,10 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes # 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 = copy.deepcopy(defaults) + defaults_copy = copy.deepcopy(constants.RENEWER_DEFAULTS) + defaults_copy.merge(config_opts if config_opts is not None + else configobj.ConfigObj()) + self.configuration = defaults_copy self.configuration.merge(self.configfile) if not all(x in self.configuration for x in ALL_FOUR): @@ -479,7 +482,7 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes @classmethod def new_lineage(cls, lineagename, cert, privkey, chain, - renewalparams=None, config=constants.RENEWER_DEFAULTS): + renewalparams=None, config=None): # pylint: disable=too-many-locals,too-many-arguments """Create a new certificate lineage. @@ -511,6 +514,10 @@ class RenewableCert(object): # pylint: disable=too-many-instance-attributes :returns: the newly-created RenewalCert object :rtype: :class:`storage.renewableCert`""" + defaults_copy = copy.deepcopy(constants.RENEWER_DEFAULTS) + defaults_copy.merge(config if config is not None + else configobj.ConfigObj()) + config = defaults_copy # This attempts 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 From 424acfe16e542b28e2aabe66ccee796b1d548be5 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Thu, 21 May 2015 18:58:40 -0700 Subject: [PATCH 59/66] Fixes to running on command line. Use cert_dir instead of cert_path Restore server_url When creating a unique file, only loop for EEXISTS, not other OS errors like permission denied. Pass uid explicitly to make_or_verify_dir. --- letsencrypt/client.py | 12 ++++++------ letsencrypt/configuration.py | 4 ++++ letsencrypt/crypto_util.py | 2 +- letsencrypt/le_util.py | 6 ++++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 8b6bbf2e2..e2b5cf2df 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -138,22 +138,22 @@ class Client(object): # Save Certificate cert_path, chain_path = self.save_certificate( - certr, self.config.cert_path, self.config.chain_path) + certr, self.config.cert_dir, self.config.cert_dir) revoker.Revoker.store_cert_key( cert_path, self.account.key.file, self.config) return cert_key, cert_path, chain_path - def save_certificate(self, certr, cert_path, chain_path): + def save_certificate(self, certr, cert_dir, chain_dir): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. :param certr: ACME "certificate" resource. :type certr: :class:`acme.messages.Certificate` - :param str cert_path: Path to attempt to save the cert file - :param str chain_path: Path to attempt to save the chain file + :param str cert_dir: Path to attempt to save the cert file + :param str chain_dir: Path to attempt to save the chain file :returns: cert_path, chain_path (absolute paths to the actual files) :rtype: `tuple` of `str` @@ -163,7 +163,7 @@ class Client(object): """ # try finally close cert_chain_abspath = None - cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) + cert_file, act_cert_path = le_util.unique_file(cert_dir, 0o644) # TODO: Except cert_pem = certr.body.as_pem() try: @@ -178,7 +178,7 @@ class Client(object): chain_cert = self.network.fetch_chain(certr) if chain_cert is not None: chain_file, act_chain_path = le_util.unique_file( - chain_path, 0o644) + chain_dir, 0o644) chain_pem = chain_cert.as_pem() try: chain_file.write(chain_pem) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 6a808a6a9..9fba3047a 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -44,6 +44,10 @@ class NamespaceConfig(object): def in_progress_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) + @property + def server_url(self): + return self.namespace.server + @property def server_path(self): """File path based on ``server``.""" diff --git a/letsencrypt/crypto_util.py b/letsencrypt/crypto_util.py index 94617eef6..6fb6adbdc 100644 --- a/letsencrypt/crypto_util.py +++ b/letsencrypt/crypto_util.py @@ -72,7 +72,7 @@ def init_save_csr(privkey, names, cert_dir, csrname="csr-letsencrypt.pem"): csr_pem, csr_der = make_csr(privkey.pem, names) # Save CSR - le_util.make_or_verify_dir(cert_dir, 0o755) + le_util.make_or_verify_dir(cert_dir, 0o755, os.geteuid()) csr_f, csr_filename = le_util.unique_file( os.path.join(cert_dir, csrname), 0o644) csr_f.write(csr_pem) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index 27d795749..cab13965e 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -70,8 +70,10 @@ def unique_file(path, mode=0o777): try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError: - pass + except OSError, e: + # Errno 17, "File exists," is okay. + if e.errno != 17: + raise count += 1 From e1e6135313885e4e8a163cec3f4678c342bd5a07 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Thu, 21 May 2015 19:08:05 -0700 Subject: [PATCH 60/66] Use a different port for test mode. --- letsencrypt/plugins/standalone/authenticator.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/letsencrypt/plugins/standalone/authenticator.py b/letsencrypt/plugins/standalone/authenticator.py index 7d5c3ea6e..d93a10008 100644 --- a/letsencrypt/plugins/standalone/authenticator.py +++ b/letsencrypt/plugins/standalone/authenticator.py @@ -378,7 +378,10 @@ class StandaloneAuthenticator(common.Plugin): results_if_failure.append(False) if not self.tasks: raise ValueError("nothing for .perform() to do") - if self.already_listening(challenges.DVSNI.PORT): + port = challenges.DVSNI.PORT + if self.config.test_mode: + port = 5001 + if self.already_listening(port): # If we know a process is already listening on this port, # tell the user, and don't even attempt to bind it. (This # test is Linux-specific and won't indicate that the port @@ -386,7 +389,7 @@ class StandaloneAuthenticator(common.Plugin): return results_if_failure # Try to do the authentication; note that this creates # the listener subprocess via os.fork() - if self.start_listener(challenges.DVSNI.PORT, key): + if self.start_listener(port, key): return results_if_success else: # TODO: This should probably raise a DVAuthError exception From 969c2c052d2f92bcc9aafd4417d21721b8e7881b Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Thu, 21 May 2015 23:44:13 -0700 Subject: [PATCH 61/66] Re-remove server_url. --- letsencrypt/configuration.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/letsencrypt/configuration.py b/letsencrypt/configuration.py index 9fba3047a..6a808a6a9 100644 --- a/letsencrypt/configuration.py +++ b/letsencrypt/configuration.py @@ -44,10 +44,6 @@ class NamespaceConfig(object): def in_progress_dir(self): # pylint: disable=missing-docstring return os.path.join(self.namespace.work_dir, constants.IN_PROGRESS_DIR) - @property - def server_url(self): - return self.namespace.server - @property def server_path(self): """File path based on ``server``.""" From 8562496f82f64771b12b7b4140ea605c94541bf4 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 22 May 2015 13:06:17 -0700 Subject: [PATCH 62/66] Fixes from review comments. --- letsencrypt/client.py | 12 ++++++------ letsencrypt/constants.py | 4 ++++ letsencrypt/le_util.py | 4 ++-- letsencrypt/plugins/standalone/authenticator.py | 3 ++- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 2f4d02f70..ae1667dfa 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -138,22 +138,22 @@ class Client(object): # Save Certificate cert_path, chain_path = self.save_certificate( - certr, self.config.cert_dir, self.config.cert_dir) + certr, self.config.cert_path, self.config.chain_path) revoker.Revoker.store_cert_key( cert_path, self.account.key.file, self.config) return cert_key, cert_path, chain_path - def save_certificate(self, certr, cert_dir, chain_dir): + def save_certificate(self, certr, cert_path, chain_path): # pylint: disable=no-self-use """Saves the certificate received from the ACME server. :param certr: ACME "certificate" resource. :type certr: :class:`acme.messages.Certificate` - :param str cert_dir: Path to attempt to save the cert file - :param str chain_dir: Path to attempt to save the chain file + :param str cert_path: Path to attempt to save the cert file + :param str chain_path: Path to attempt to save the chain file :returns: cert_path, chain_path (absolute paths to the actual files) :rtype: `tuple` of `str` @@ -163,7 +163,7 @@ class Client(object): """ # try finally close cert_chain_abspath = None - cert_file, act_cert_path = le_util.unique_file(cert_dir, 0o644) + cert_file, act_cert_path = le_util.unique_file(cert_path, 0o644) # TODO: Except cert_pem = certr.body.as_pem() try: @@ -178,7 +178,7 @@ class Client(object): chain_cert = self.network.fetch_chain(certr) if chain_cert is not None: chain_file, act_chain_path = le_util.unique_file( - chain_dir, 0o644) + chain_path, 0o644) chain_pem = chain_cert.as_pem() try: chain_file.write(chain_pem) diff --git a/letsencrypt/constants.py b/letsencrypt/constants.py index 9ff0b128c..9bd33726d 100644 --- a/letsencrypt/constants.py +++ b/letsencrypt/constants.py @@ -71,3 +71,7 @@ IConfig.work_dir).""" NETSTAT = "/bin/netstat" """Location of netstat binary for checking whether a listener is already running on the specified port (Linux-specific).""" + +BOULDER_TEST_MODE_CHALLENGE_PORT = 5001 +"""Port that Boulder will connect on for validations in test mode.""" + diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index cab13965e..eacdfe341 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -71,8 +71,8 @@ def unique_file(path, mode=0o777): file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname except OSError, e: - # Errno 17, "File exists," is okay. - if e.errno != 17: + # "File exists," is okay, try a different name. + if exception.errno != errno.EEXIST: raise count += 1 diff --git a/letsencrypt/plugins/standalone/authenticator.py b/letsencrypt/plugins/standalone/authenticator.py index d93a10008..b124a490a 100644 --- a/letsencrypt/plugins/standalone/authenticator.py +++ b/letsencrypt/plugins/standalone/authenticator.py @@ -15,6 +15,7 @@ import zope.interface from acme import challenges from letsencrypt import achallenges +from letsencrypt import constants from letsencrypt import interfaces from letsencrypt.plugins import common @@ -380,7 +381,7 @@ class StandaloneAuthenticator(common.Plugin): raise ValueError("nothing for .perform() to do") port = challenges.DVSNI.PORT if self.config.test_mode: - port = 5001 + port = constants.BOULDER_TEST_MODE_CHALLENGE_PORT if self.already_listening(port): # If we know a process is already listening on this port, # tell the user, and don't even attempt to bind it. (This From 1a5d6ba90d230b0b90efdd7988fc6415fb427316 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 22 May 2015 13:16:55 -0700 Subject: [PATCH 63/66] Use more verbose exception catch. --- letsencrypt/le_util.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/le_util.py b/letsencrypt/le_util.py index eacdfe341..2e85de1a0 100644 --- a/letsencrypt/le_util.py +++ b/letsencrypt/le_util.py @@ -70,7 +70,7 @@ def unique_file(path, mode=0o777): try: file_d = os.open(fname, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) return os.fdopen(file_d, "w"), fname - except OSError, e: + except OSError as exception: # "File exists," is okay, try a different name. if exception.errno != errno.EEXIST: raise From 958b6b10483c37d8fcd4983e79435157b8b38073 Mon Sep 17 00:00:00 2001 From: Jacob Hoffman-Andrews Date: Fri, 22 May 2015 13:57:41 -0700 Subject: [PATCH 64/66] Only check config if it is defined. --- letsencrypt/plugins/standalone/authenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/plugins/standalone/authenticator.py b/letsencrypt/plugins/standalone/authenticator.py index b124a490a..3947c1e3e 100644 --- a/letsencrypt/plugins/standalone/authenticator.py +++ b/letsencrypt/plugins/standalone/authenticator.py @@ -380,7 +380,7 @@ class StandaloneAuthenticator(common.Plugin): if not self.tasks: raise ValueError("nothing for .perform() to do") port = challenges.DVSNI.PORT - if self.config.test_mode: + if self.config and self.config.test_mode: port = constants.BOULDER_TEST_MODE_CHALLENGE_PORT if self.already_listening(port): # If we know a process is already listening on this port, From 1cddd0fba12c6778a3b24ebd8f5dd8ea0446dff1 Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 22 May 2015 14:29:50 -0700 Subject: [PATCH 65/66] Use standard plugins interface in config serialization --- letsencrypt/cli.py | 5 +++-- letsencrypt/client.py | 24 ++++++++++++++++++------ 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/letsencrypt/cli.py b/letsencrypt/cli.py index 4902459e1..fb9a7244a 100644 --- a/letsencrypt/cli.py +++ b/letsencrypt/cli.py @@ -100,7 +100,7 @@ def run(args, config, plugins): acme, doms = _common_run(args, config, acc, authenticator, installer) # TODO: Handle errors from _common_run? lineage = acme.obtain_and_enroll_certificate(doms, authenticator, - installer) + installer, plugins) if not lineage: return "Certificate could not be obtained" acme.deploy_certificate(doms, lineage) @@ -127,7 +127,8 @@ def auth(args, config, plugins): # TODO: Handle errors from _common_run? acme, doms = _common_run( args, config, acc, authenticator=authenticator, installer=installer) - if not acme.obtain_and_enroll_certificate(doms, authenticator, installer): + if not acme.obtain_and_enroll_certificate(doms, authenticator, installer, + plugins): return "Certificate could not be obtained" diff --git a/letsencrypt/client.py b/letsencrypt/client.py index 06f36570a..ce93f9fe9 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -156,16 +156,28 @@ class Client(object): return cert_pem, cert_key.pem, chain_pem def obtain_and_enroll_certificate(self, domains, authenticator, installer, - csr=None): + plugins, csr=None): """Get a new certificate for the specified domains using the specified authenticator and installer, and then create a new renewable lineage - containing it.""" + containing it. + + :param list domains: Domains to request. + :param authenticator: The authenticator to use. + :type authenticator: :class:`letsencrypt.interfaces.IAuthenticator` + + :param installer: The installer to use. + :type installer: :class:`letsencrypt.interfaces.IInstaller` + + :param plugins: A PluginsFactory object. + + :param str csr: A preexisting CSR to use with this request. + """ + # TODO: fully identify object types in docstring. cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr) - # TODO: Add IPlugin.name or use PluginsFactory.find_init instead - # of assuming that each plugin has a .name attribute - self.config.namespace.authenticator = authenticator.name + self.config.namespace.authenticator = plugins.find_init( + authenticator).name if installer is not None: - self.config.namespace.installer = installer.name + self.config.namespace.installer = plugins.find_init(installer).name return storage.RenewableCert.new_lineage(domains[0], cert_pem, privkey, chain_pem, vars(self.config.namespace)) From f7718d14aa0be4145183365c48a8d0a4dd52140a Mon Sep 17 00:00:00 2001 From: Seth Schoen Date: Fri, 22 May 2015 14:42:19 -0700 Subject: [PATCH 66/66] API documentation on obtain_and_enroll_cert --- letsencrypt/client.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/letsencrypt/client.py b/letsencrypt/client.py index ce93f9fe9..58bdb6fda 100644 --- a/letsencrypt/client.py +++ b/letsencrypt/client.py @@ -171,6 +171,10 @@ class Client(object): :param plugins: A PluginsFactory object. :param str csr: A preexisting CSR to use with this request. + + :returns: A new :class:`letsencrypt.storage.RenewableCert` instance + referred to the enrolled cert lineage, or False if the cert could + not be obtained. """ # TODO: fully identify object types in docstring. cert_pem, privkey, chain_pem = self._obtain_certificate(domains, csr)