mirror of
https://github.com/certbot/certbot.git
synced 2026-07-28 00:35:50 +02:00
Work in progress: make renewer renew
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
@@ -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:")]
|
||||
|
||||
+55
-425
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user