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',