Initial implementation of renewer

This commit is contained in:
Seth Schoen
2015-04-21 19:12:49 -07:00
parent ea88fc6401
commit 9463de3367
6 changed files with 573 additions and 0 deletions
+42
View File
@@ -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
+28
View File
@@ -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:
+30
View File
@@ -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 <root>")
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
+11
View File
@@ -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
+460
View File
@@ -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()
+2
View File
@@ -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',