diff --git a/.pylintrc b/.pylintrc index 7d519b08e..6de90f2f8 100644 --- a/.pylintrc +++ b/.pylintrc @@ -21,25 +21,15 @@ persistent=yes # usually to register additional checkers. load-plugins= -# Use multiple processes to speed up Pylint. -jobs=1 +# DEPRECATED +include-ids=no -# Allow loading of arbitrary C extensions. Extensions are imported into the -# active Python interpreter and may run arbitrary code. -unsafe-load-any-extension=no - -# A comma-separated list of package or module names from where C extensions may -# be loaded. Extensions are loading into the active Python interpreter and may -# run arbitrary code -extension-pkg-whitelist= +# DEPRECATED +symbols=no [MESSAGES CONTROL] -# Only show warnings with the listed confidence levels. Leave empty to show -# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED -confidence= - # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option # multiple time. See also the "--disable" option for examples. @@ -94,7 +84,7 @@ comment=no required-attributes= # List of builtins function names that should not be used, separated by a comma -bad-functions=map,filter,input +bad-functions=map,filter,apply,input,file # Good variable names which should always be accepted, separated by a comma good-names=i,j,k,ex,Run,_ @@ -191,23 +181,6 @@ notes=FIXME,XXX,TODO logging-modules=logging -[SPELLING] - -# Spelling dictionary name. Available dictionaries: none. To make it working -# install python-enchant package. -spelling-dict= - -# List of comma separated words that should not be checked. -spelling-ignore-words= - -# A path to a file that contains private dictionary; one word per line. -spelling-private-dict-file= - -# Tells whether to store unknown words to indicated private dictionary in -# --spelling-private-dict-file option instead of raising a message. -spelling-store-unknown-words=no - - [VARIABLES] # Tells whether we should check for unused import in __init__ files. @@ -221,10 +194,6 @@ dummy-variables-rgx=_$|dummy # you should avoid to define new builtins when possible. additional-builtins= -# List of strings which can identify a callback function by name. A callback -# name must start or end with one of those strings. -callbacks=cb_,_cb - [SIMILARITIES] @@ -257,7 +226,7 @@ single-line-if-stmt=no no-space-check=trailing-comma,dict-separator # Maximum number of lines in a module -max-module-lines=2000 +max-module-lines=1250 # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 # tab). @@ -266,9 +235,6 @@ indent-string=' ' # Number of spaces of indent required inside a hanging or continued line. indent-after-paren=4 -# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. -expected-line-ending-format= - [TYPECHECK] @@ -279,7 +245,9 @@ ignore-mixin-members=yes # List of module names for which member attributes should not be checked # (useful for modules/projects where namespaces are manipulated during runtime # and thus existing member attributes cannot be deduced by static analysis -ignored-modules= + +# Get rid of the spurious no-member errors in pkg_resources +ignored-modules=pkg_resources # List of classes names for which member attributes should not be checked # (useful for classes with attributes dynamically set). @@ -287,7 +255,7 @@ ignored-classes=SQLObject # When zope mode is activated, add a predefined set of Zope acquired attributes # to generated-members. -zope=no +zope=yes # List of members which are set dynamically and missed by pylint inference # system, and so shouldn't trigger E0201 when accessed. Python regular @@ -328,10 +296,6 @@ valid-classmethod-first-arg=cls # List of valid names for the first argument in a metaclass class method. valid-metaclass-classmethod-first-arg=mcs -# List of member names, which should be excluded from the protected access -# warning. -exclude-protected=_asdict,_fields,_replace,_source,_make - [DESIGN] @@ -364,7 +328,7 @@ max-attributes=7 min-public-methods=2 # Maximum number of public methods for a class (see R0904). -max-public-methods=30 +max-public-methods=20 [EXCEPTIONS] diff --git a/README.md b/README.md index d3cd50db8..1ab23f296 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ sudo apt-get install python python-setuptools python-virtualenv \ ### Installation ``` -virtualenv --no-site-packages venv +virtualenv --no-site-packages -p python2 venv ./venv/bin/python setup.py install sudo ./venv/bin/letsencrypt ``` diff --git a/docs/api/client/apache.rst b/docs/api/client/apache.rst new file mode 100644 index 000000000..e69826cf9 --- /dev/null +++ b/docs/api/client/apache.rst @@ -0,0 +1,29 @@ +:mod:`letsencrypt.client.apache` +-------------------------------- + +.. automodule:: letsencrypt.client.apache + :members: + +:mod:`letsencrypt.client.apache.configurator` +============================================= + +.. automodule:: letsencrypt.client.apache.configurator + :members: + +:mod:`letsencrypt.client.apache.dvsni` +============================================= + +.. automodule:: letsencrypt.client.apache.dvsni + :members: + +:mod:`letsencrypt.client.apache.obj` +==================================== + +.. automodule:: letsencrypt.client.apache.obj + :members: + +:mod:`letsencrypt.client.apache.parser` +======================================= + +.. automodule:: letsencrypt.client.apache.parser + :members: diff --git a/docs/api/client/apache_configurator.rst b/docs/api/client/apache_configurator.rst deleted file mode 100644 index 76818e05a..000000000 --- a/docs/api/client/apache_configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.apache_configurator` ---------------------------------------------- - -.. automodule:: letsencrypt.client.apache_configurator - :members: diff --git a/docs/api/client/auth_handler.rst b/docs/api/client/auth_handler.rst new file mode 100644 index 000000000..e84745d1e --- /dev/null +++ b/docs/api/client/auth_handler.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.auth_handler` +-------------------------------- + +.. automodule:: letsencrypt.client.auth_handler + :members: diff --git a/docs/api/client/challenge.rst b/docs/api/client/challenge.rst deleted file mode 100644 index c46e1b053..000000000 --- a/docs/api/client/challenge.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.challenge` ------------------------------------ - -.. automodule:: letsencrypt.client.challenge - :members: diff --git a/docs/api/client/challenge_util.rst b/docs/api/client/challenge_util.rst new file mode 100644 index 000000000..3866230a5 --- /dev/null +++ b/docs/api/client/challenge_util.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.challenge_util` +---------------------------------------- + +.. automodule:: letsencrypt.client.challenge_util + :members: diff --git a/docs/api/client/client_authenticator.rst b/docs/api/client/client_authenticator.rst new file mode 100644 index 000000000..a9050de50 --- /dev/null +++ b/docs/api/client/client_authenticator.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.client_authenticator` +-------------------------------- + +.. automodule:: letsencrypt.client.client_authenticator + :members: diff --git a/docs/api/client/configurator.rst b/docs/api/client/configurator.rst deleted file mode 100644 index 7331f35ec..000000000 --- a/docs/api/client/configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.configurator` --------------------------------------- - -.. automodule:: letsencrypt.client.configurator - :members: diff --git a/docs/api/client/interfaces.rst b/docs/api/client/interfaces.rst new file mode 100644 index 000000000..e14daed7f --- /dev/null +++ b/docs/api/client/interfaces.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.interfaces` +------------------------------------ + +.. automodule:: letsencrypt.client.interfaces + :members: diff --git a/docs/api/client/network.rst b/docs/api/client/network.rst new file mode 100644 index 000000000..7b4ec633a --- /dev/null +++ b/docs/api/client/network.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.network` +--------------------------------- + +.. automodule:: letsencrypt.client.network + :members: diff --git a/docs/api/client/nginx_configurator.rst b/docs/api/client/nginx_configurator.rst deleted file mode 100644 index efcef3ffe..000000000 --- a/docs/api/client/nginx_configurator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.nginx_configurator` --------------------------------------------- - -.. automodule:: letsencrypt.client.nginx_configurator - :members: diff --git a/docs/api/client/recovery_token.rst b/docs/api/client/recovery_token.rst new file mode 100644 index 000000000..cc37e036d --- /dev/null +++ b/docs/api/client/recovery_token.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.recovery_token` +-------------------------------------------------- + +.. automodule:: letsencrypt.client.recovery_token + :members: diff --git a/docs/api/client/recovery_token_challenge.rst b/docs/api/client/recovery_token_challenge.rst deleted file mode 100644 index 68fbdc6e1..000000000 --- a/docs/api/client/recovery_token_challenge.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.recovery_token_challenge` --------------------------------------------------- - -.. automodule:: letsencrypt.client.recovery_token_challenge - :members: diff --git a/docs/api/client/revoker.rst b/docs/api/client/revoker.rst new file mode 100644 index 000000000..e0a7db533 --- /dev/null +++ b/docs/api/client/revoker.rst @@ -0,0 +1,5 @@ +:mod:`letsencrypt.client.revoker` +--------------------------------- + +.. automodule:: letsencrypt.client.revoker + :members: diff --git a/docs/api/client/validator.rst b/docs/api/client/validator.rst deleted file mode 100644 index 7f990e2a4..000000000 --- a/docs/api/client/validator.rst +++ /dev/null @@ -1,5 +0,0 @@ -:mod:`letsencrypt.client.validator` ------------------------------------ - -.. automodule:: letsencrypt.client.validator - :members: diff --git a/letsencrypt/client/CONFIG.py b/letsencrypt/client/CONFIG.py index 3cc9d09a6..2ce39a73b 100644 --- a/letsencrypt/client/CONFIG.py +++ b/letsencrypt/client/CONFIG.py @@ -30,9 +30,10 @@ IN_PROGRESS_DIR = os.path.join(BACKUP_DIR, "IN_PROGRESS/") """Directory used before a permanent checkpoint is finalized""" CERT_KEY_BACKUP = os.path.join(WORK_DIR, "keys-certs/") -"""Directory where all certificates/keys are stored. +"""Directory where all certificates/keys are stored. Used for easy revocation""" -Used for easy revocation""" +REV_TOKENS_DIR = os.path.join(WORK_DIR, "revocation_tokens/") +"""Directory where all revocation tokens are saved.""" KEY_DIR = os.path.join(SERVER_ROOT, "ssl/") """Where all keys should be stored""" @@ -47,9 +48,6 @@ OPTIONS_SSL_CONF = os.path.join(CONFIG_DIR, "options-ssl.conf") LE_VHOST_EXT = "-le-ssl.conf" """Let's Encrypt SSL vhost configuration extension""" -APACHE_CHALLENGE_CONF = os.path.join(CONFIG_DIR, "le_dvsni_cert_challenge.conf") -"""Temporary file for challenge virtual hosts""" - CERT_PATH = CERT_DIR + "cert-letsencrypt.pem" """Let's Encrypt cert file.""" @@ -59,15 +57,15 @@ CHAIN_PATH = CERT_DIR + "chain-letsencrypt.pem" INVALID_EXT = ".acme.invalid" """Invalid Extension""" -# Challenge Information -CHALLENGE_PREFERENCES = ["dvsni", "recoveryToken"] -"""Challenge Preferences Dict for currently supported challenges""" - EXCLUSIVE_CHALLENGES = [frozenset(["dvsni", "simpleHttps"])] """Mutually Exclusive Challenges - only solve 1""" -CONFIG_CHALLENGES = frozenset(["dvsni", "simpleHttps"]) -"""These are challenges that must be solved by a Configurator object""" +DV_CHALLENGES = frozenset(["dvsni", "simpleHttps", "dns"]) +"""These are challenges that must be solved by an Authenticator object""" + +CLIENT_CHALLENGES = frozenset( + ["recoveryToken", "recoveryContact", "proofOfPossession"]) +"""These are challenges that are handled by client.py""" # Challenge Constants S_SIZE = 32 diff --git a/letsencrypt/client/apache/__init__.py b/letsencrypt/client/apache/__init__.py new file mode 100644 index 000000000..f1b2c08e7 --- /dev/null +++ b/letsencrypt/client/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.apache.""" diff --git a/letsencrypt/client/apache/configurator.py b/letsencrypt/client/apache/configurator.py new file mode 100644 index 000000000..e0e1852ed --- /dev/null +++ b/letsencrypt/client/apache/configurator.py @@ -0,0 +1,1109 @@ +"""Apache Configuration based off of Augeas Configurator.""" +import logging +import os +import pkg_resources +import re +import shutil +import socket +import subprocess +import sys + +import zope.interface + +from letsencrypt.client import augeas_configurator +from letsencrypt.client import challenge_util +from letsencrypt.client import CONFIG +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util + +from letsencrypt.client.apache import dvsni +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser + + +# TODO: Augeas sections ie. , beginning and closing +# tags need to be the same case, otherwise Augeas doesn't recognize them. +# This is not able to be completely remedied by regular expressions because +# Augeas views as an error. This will just +# require another check_parsing_errors() after all files are included... +# (after a find_directive search is executed currently). It can be a one +# time check however because all of LE's transactions will ensure +# only properly formed sections are added. + +# Note: This protocol works for filenames with spaces in it, the sites are +# properly set up and directives are changed appropriately, but Apache won't +# recognize names in sites-enabled that have spaces. These are not added to the +# Apache configuration. It may be wise to warn the user if they are trying +# to use vhost filenames that contain spaces and offer to change ' ' to '_' + +# Note: FILEPATHS and changes to files are transactional. They are copied +# over before the updates are made to the existing files. NEW_FILES is +# transactional due to the use of register_file_creation() + + +class ApacheConfigurator(augeas_configurator.AugeasConfigurator): + """Apache configurator. + + State of Configurator: This code has been tested under Ubuntu 12.04 + Apache 2.2 and this code works for Ubuntu 14.04 Apache 2.4. Further + notes below. + + This class was originally developed for Apache 2.2 and has not seen a + an overhaul to include proper setup of new Apache configurations. + I have implemented most of the changes... the missing ones are + mod_ssl.c vs ssl_mod, and I need to account for configuration variables. + That being said, this class can still adequately configure most typical + Apache 2.4 servers as the deprecated NameVirtualHost has no effect + and the typical directories are parsed by the Augeas configuration + parser automatically. + + .. todo:: Add support for config file variables Define rootDir /var/www/ + .. todo:: Add proper support for module configuration + + The API of this class will change in the coming weeks as the exact + needs of clients are clarified with the new and developing protocol. + + :ivar str server_root: Path to Apache root directory + :ivar dict location: Path to various files associated + with the configuration + :ivar float version: version of Apache + :ivar list vhosts: All vhosts found in the configuration + (:class:`list` of :class:`letsencrypt.client.apache.obj.VirtualHost`) + + :ivar dict assoc: Mapping between domains and vhosts + + """ + zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + + def __init__(self, server_root=CONFIG.SERVER_ROOT, direc=None, + ssl_options=CONFIG.OPTIONS_SSL_CONF, version=None): + """Initialize an Apache Configurator. + + :param str server_root: the apache server root directory + :param dict direc: locations of various config directories + (used mostly for unittesting) + :param str ssl_options: path of options-ssl.conf + (used mostly for unittesting) + :param tup version: version of Apache as a tuple (2, 4, 7) + (used mostly for unittesting) + + """ + if direc is None: + direc = {"backup": CONFIG.BACKUP_DIR, + "temp": CONFIG.TEMP_CHECKPOINT_DIR, + "progress": CONFIG.IN_PROGRESS_DIR, + "config": CONFIG.CONFIG_DIR, + "work": CONFIG.WORK_DIR} + + super(ApacheConfigurator, self).__init__(direc) + + # See if any temporary changes need to be recovered + # This needs to occur before VirtualHost objects are setup... + # because this will change the underlying configuration and potential + # vhosts + self.recovery_routine() + + # Verify that all directories and files exist with proper permissions + if os.geteuid() == 0: + self.verify_setup() + + self.parser = parser.ApacheParser(self.aug, server_root, ssl_options) + # Check for errors in parsing files with Augeas + self.check_parsing_errors("httpd.aug") + + # Set Version + self.version = self.get_version() if version is None else version + + # Get all of the available vhosts + self.vhosts = self.get_virtual_hosts() + # Add name_server association dict + self.assoc = dict() + # Add number of outstanding challenges + self.chall_out = 0 + + # Enable mod_ssl if it isn't already enabled + # This is Let's Encrypt... we enable mod_ssl on initialization :) + # TODO: attempt to make the check faster... this enable should + # be asynchronous as it shouldn't be that time sensitive + # on initialization + self._prepare_server_https() + + # Move temporary files before release to reduce developer + # problems. + temp_install(ssl_options) + + def deploy_cert(self, vhost, cert, key, cert_chain=None): + """Deploys certificate to specified virtual host. + + Currently tries to find the last directives to deploy the cert in + the given virtualhost. If it can't find the directives, it searches + the "included" confs. The function verifies that it has located + the three directives and finally modifies them to point to the correct + destination + + .. todo:: Make sure last directive is changed + + .. todo:: Might be nice to remove chain directive if none exists + This shouldn't happen within letsencrypt though + + :param vhost: ssl vhost to deploy certificate + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :param str cert: certificate filename + :param str key: private key filename + :param str cert_chain: certificate chain filename + + :returns: Success + :rtype: bool + + """ + path = {} + + path["cert_file"] = self.parser.find_dir(parser.case_i( + "SSLCertificateFile"), None, vhost.path) + path["cert_key"] = self.parser.find_dir(parser.case_i( + "SSLCertificateKeyFile"), None, vhost.path) + + # Only include if a certificate chain is specified + if cert_chain is not None: + path["cert_chain"] = self.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), None, vhost.path) + + if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: + # Throw some "can't find all of the directives error" + logging.warn( + "Cannot find a cert or key directive in %s", vhost.path) + logging.warn("VirtualHost was not modified") + # Presumably break here so that the virtualhost is not modified + return False + + logging.info("Deploying Certificate to VirtualHost %s", vhost.filep) + + self.aug.set(path["cert_file"][0], cert) + self.aug.set(path["cert_key"][0], key) + if cert_chain is not None: + if len(path["cert_chain"]) == 0: + self.parser.add_dir( + vhost.path, "SSLCertificateChainFile", cert_chain) + else: + self.aug.set(path["cert_chain"][0], cert_chain) + + self.save_notes += ("Changed vhost at %s with addresses of %s\n" % + (vhost.filep, vhost.addrs)) + self.save_notes += "\tSSLCertificateFile %s\n" % cert + self.save_notes += "\tSSLCertificateKeyFile %s\n" % key + if cert_chain: + self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain + # This is a significant operation, make a checkpoint + return self.save() + + def choose_virtual_host(self, target_name): + """ Chooses a virtual host based on the given domain name. + + .. todo:: This should maybe return list if no obvious answer + is presented. + + :param str target_name: domain name + + :returns: ssl vhost associated with name + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + + """ + # Allows for domain names to be associated with a virtual host + # Client isn't using create_dn_server_assoc(self, dn, vh) yet + if target_name in self.assoc: + return self.assoc[target_name] + # Check for servernames/aliases for ssl hosts + for vhost in self.vhosts: + if vhost.ssl and target_name in vhost.names: + return vhost + # Checking for domain name in vhost address + # This technique is not recommended by Apache but is technically valid + target_addr = obj.Addr((target_name, "443")) + for vhost in self.vhosts: + if target_addr in vhost.addrs: + return vhost + + # Check for non ssl vhosts with servernames/aliases == 'name' + for vhost in self.vhosts: + if not vhost.ssl and target_name in vhost.names: + return self.make_vhost_ssl(vhost) + + # No matches, search for the default + for vhost in self.vhosts: + if "_default_:443" in vhost.addrs: + return vhost + return None + + def create_dn_server_assoc(self, domain, vhost): + """Create an association between a domain name and virtual host. + + Helps to choose an appropriate vhost + + :param str domain: domain name to associate + + :param vhost: virtual host to associate with domain + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + """ + self.assoc[domain] = vhost + + def get_all_names(self): + """Returns all names found in the Apache Configuration. + + :returns: All ServerNames, ServerAliases, and reverse DNS entries for + virtual host addresses + :rtype: set + + """ + all_names = set() + + # Kept in same function to avoid multiple compilations of the regex + priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|" + r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)") + private_ips = re.compile(priv_ip_regex) + + for vhost in self.vhosts: + all_names.update(vhost.names) + for addr in vhost.addrs: + # If it isn't a private IP, do a reverse DNS lookup + if not private_ips.match(addr.get_addr()): + try: + socket.inet_aton(addr.get_addr()) + all_names.add(socket.gethostbyaddr(addr.get_addr())[0]) + except (socket.error, socket.herror, socket.timeout): + continue + + return all_names + + def _add_servernames(self, host): + """Helper function for get_virtual_hosts(). + + :param host: In progress vhost whose names will be added + :type host: :class:`letsencrypt.client.apache.obj.VirtualHost` + + """ + name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " + "%s//*[self::directive=~regexp('%s')]" % + (host.path, + parser.case_i('ServerName'), + host.path, + parser.case_i('ServerAlias')))) + + for name in name_match: + args = self.aug.match(name + "/*") + for arg in args: + host.add_name(self.aug.get(arg)) + + def _create_vhost(self, path): + """Used by get_virtual_hosts to create vhost objects + + :param str path: Augeas path to virtual host + + :returns: newly created vhost + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + + """ + addrs = set() + args = self.aug.match(path + "/arg") + for arg in args: + addrs.add(obj.Addr.fromstring(self.aug.get(arg))) + is_ssl = False + + if self.parser.find_dir( + parser.case_i("SSLEngine"), parser.case_i("on"), path): + is_ssl = True + + filename = get_file_path(path) + is_enabled = self.is_site_enabled(filename) + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) + self._add_servernames(vhost) + return vhost + + # TODO: make "sites-available" a configurable directory + def get_virtual_hosts(self): + """Returns list of virtual hosts found in the Apache configuration. + + :returns: List of + :class:`letsencrypt.client.apache.obj.VirtualHost` objects + found in configuration + :rtype: list + + """ + # Search sites-available, httpd.conf for possible virtual hosts + paths = self.aug.match( + ("/files%ssites-available//*[label()=~regexp('%s')]" % + (self.parser.root, parser.case_i('VirtualHost')))) + vhs = [] + + for path in paths: + vhs.append(self._create_vhost(path)) + + return vhs + + # pylint: disable=anomalous-backslash-in-string + def is_name_vhost(self, target_addr): + """Returns if vhost is a name based vhost + + NameVirtualHost was deprecated in Apache 2.4 as all VirtualHosts are + now NameVirtualHosts. If version is earlier than 2.4, check if addr + has a NameVirtualHost directive in the Apache config + + :param str target_addr: vhost address ie. \*:443 + + :returns: Success + :rtype: bool + + """ + # Mixed and matched wildcard NameVirtualHost with VirtualHost + # behavior is undefined. Make sure that an exact match exists + + # search for NameVirtualHost directive for ip_addr + # note ip_addr can be FQDN although Apache does not recommend it + return (self.version >= (2, 4) or + self.parser.find_dir( + parser.case_i("NameVirtualHost"), + parser.case_i(str(target_addr)))) + + def add_name_vhost(self, addr): + """Adds NameVirtualHost directive for given address. + + :param str addr: Address that will be added as NameVirtualHost directive + + """ + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path( + self.parser.loc["name"]), "NameVirtualHost", str(addr)) + + self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr + self.save_notes += "\tDirective added to %s\n" % path + + def _prepare_server_https(self): + """Prepare the server for HTTPS. + + Make sure that the ssl_module is loaded and that the server + is appropriately listening on port 443. + + """ + if not check_ssl_loaded(): + logging.info("Loading mod_ssl into Apache Server") + enable_mod("ssl") + + # Check for Listen 443 + # Note: This could be made to also look for ip:443 combo + # TODO: Need to search only open directives and IfMod mod_ssl.c + if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0: + logging.debug("No Listen 443 directive found") + logging.debug("Setting the Apache Server to Listen on port 443") + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443") + self.save_notes += "Added Listen 443 directive to %s\n" % path + + def make_server_sni_ready(self, vhost, default_addr="*:443"): + """Checks to see if the server is ready for SNI challenges. + + :param vhost: VirtualHost to check SNI compatibility + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :param str default_addr: TODO - investigate function further + + """ + if self.version >= (2, 4): + return + # Check for NameVirtualHost + # First see if any of the vhost addresses is a _default_ addr + for addr in vhost.addrs: + if addr.get_addr() == "_default_": + if not self.is_name_vhost(default_addr): + logging.debug("Setting all VirtualHosts on %s to be " + "name based vhosts", default_addr) + self.add_name_vhost(default_addr) + + # No default addresses... so set each one individually + for addr in vhost.addrs: + if not self.is_name_vhost(addr): + logging.debug("Setting VirtualHost at %s to be a name " + "based virtual host", addr) + self.add_name_vhost(addr) + + def make_vhost_ssl(self, nonssl_vhost): + """Makes an ssl_vhost version of a nonssl_vhost. + + Duplicates vhost and adds default ssl options + New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT + + :param nonssl_vhost: Valid VH that doesn't have SSLEngine on + :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: SSL vhost + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` + + """ + avail_fp = nonssl_vhost.filep + # Get filepath of new ssl_vhost + if avail_fp.endswith(".conf"): + ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT + else: + ssl_fp = avail_fp + CONFIG.LE_VHOST_EXT + + # First register the creation so that it is properly removed if + # configuration is rolled back + self.register_file_creation(False, ssl_fp) + + try: + orig_file = open(avail_fp, 'r') + new_file = open(ssl_fp, 'w') + new_file.write("\n") + for line in orig_file: + new_file.write(line) + new_file.write("\n") + except IOError: + logging.fatal("Error writing/reading to file in make_vhost_ssl") + sys.exit(49) + finally: + orig_file.close() + new_file.close() + + self.aug.load() + + ssl_addrs = set() + + # change address to address:443 + addr_match = "/files%s//* [label()=~regexp('%s')]/arg" + ssl_addr_p = self.aug.match( + addr_match % (ssl_fp, parser.case_i('VirtualHost'))) + + for i in range(len(ssl_addr_p)): + old_addr = obj.Addr.fromstring( + str(self.aug.get(ssl_addr_p[i]))) + ssl_addr = old_addr.get_addr_obj("443") + self.aug.set(ssl_addr_p[i], str(ssl_addr)) + ssl_addrs.add(ssl_addr) + + # Add directives + vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % + (ssl_fp, parser.case_i('VirtualHost'))) + if len(vh_p) != 1: + logging.error("Error: should only be one vhost in %s", avail_fp) + sys.exit(1) + + self.parser.add_dir(vh_p[0], "SSLCertificateFile", + "/etc/ssl/certs/ssl-cert-snakeoil.pem") + self.parser.add_dir(vh_p[0], "SSLCertificateKeyFile", + "/etc/ssl/private/ssl-cert-snakeoil.key") + self.parser.add_dir(vh_p[0], "Include", self.parser.loc["ssl_options"]) + + # Log actions and create save notes + logging.info("Created an SSL vhost at %s", ssl_fp) + self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp + self.save() + + # We know the length is one because of the assertion above + ssl_vhost = self._create_vhost(vh_p[0]) + self.vhosts.append(ssl_vhost) + + # NOTE: Searches through Augeas seem to ruin changes to directives + # The configuration must also be saved before being searched + # for the new directives; For these reasons... this is tacked + # on after fully creating the new vhost + need_to_save = False + # See if the exact address appears in any other vhost + for addr in ssl_addrs: + for vhost in self.vhosts: + if (ssl_vhost.filep != vhost.filep and addr in vhost.addrs and + not self.is_name_vhost(addr)): + self.add_name_vhost(addr) + logging.info("Enabling NameVirtualHosts on %s", addr) + need_to_save = True + + if need_to_save: + self.save() + + return ssl_vhost + + def enable_redirect(self, ssl_vhost): + """Redirect all equivalent HTTP traffic to ssl_vhost. + + Adds Redirect directive to the port 80 equivalent of ssl_vhost + First the function attempts to find the vhost with equivalent + ip addresses that serves on non-ssl ports + The function then adds the directive + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + + """ + # TODO: Enable check to see if it is already there + # to avoid the extra restart + enable_mod("rewrite") + + general_v = self._general_vhost(ssl_vhost) + if general_v is None: + # Add virtual_server with redirect + logging.debug( + "Did not find http version of ssl virtual host... creating") + return self.create_redirect_vhost(ssl_vhost) + else: + # Check if redirection already exists + exists, code = self.existing_redirect(general_v) + if exists: + if code == 0: + logging.debug("Redirect already added") + return True, general_v + else: + logging.debug("Unknown redirect exists for this vhost") + return False, general_v + # Add directives to server + self.parser.add_dir(general_v.path, "RewriteEngine", "On") + self.parser.add_dir( + general_v.path, "RewriteRule", CONFIG.REWRITE_HTTPS_ARGS) + self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % + (general_v.filep, ssl_vhost.filep)) + self.save() + return True, general_v + + def existing_redirect(self, vhost): + """Checks to see if existing redirect is in place. + + Checks to see if virtualhost already contains a rewrite or redirect + returns boolean, integer + The boolean indicates whether the redirection exists... + The integer has the following code: + 0 - Existing letsencrypt https rewrite rule is appropriate and in place + 1 - Virtual host contains a Redirect directive + 2 - Virtual host contains an unknown RewriteRule + + -1 is also returned in case of no redirection/rewrite directives + + :param vhost: vhost to check + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: Success, code value... see documentation + :rtype: bool, int + + """ + rewrite_path = self.parser.find_dir( + parser.case_i("RewriteRule"), None, vhost.path) + redirect_path = self.parser.find_dir( + parser.case_i("Redirect"), None, vhost.path) + + if redirect_path: + # "Existing Redirect directive for virtualhost" + return True, 1 + if not rewrite_path: + # "No existing redirection for virtualhost" + return False, -1 + if len(rewrite_path) == len(CONFIG.REWRITE_HTTPS_ARGS): + for idx, match in enumerate(rewrite_path): + if self.aug.get(match) != CONFIG.REWRITE_HTTPS_ARGS[idx]: + # Not a letsencrypt https rewrite + return True, 2 + # Existing letsencrypt https rewrite rule is in place + return True, 0 + # Rewrite path exists but is not a letsencrypt https rule + return True, 2 + + def create_redirect_vhost(self, ssl_vhost): + """Creates an http_vhost specifically to redirect for the ssl_vhost. + + :param ssl_vhost: ssl vhost + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: Success, vhost + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) + + """ + # Consider changing this to a dictionary check + # Make sure adding the vhost will be safe + conflict, host_or_addrs = self._conflicting_host(ssl_vhost) + if conflict: + return False, host_or_addrs + + redirect_addrs = host_or_addrs + + # get servernames and serveraliases + serveralias = "" + servername = "" + size_n = len(ssl_vhost.names) + if size_n > 0: + servername = "ServerName " + ssl_vhost.names[0] + if size_n > 1: + serveralias = " ".join(ssl_vhost.names[1:size_n]) + serveralias = "ServerAlias " + serveralias + redirect_file = ("\n" + "%s \n" + "%s \n" + "ServerSignature Off\n" + "\n" + "RewriteEngine On\n" + "RewriteRule %s\n" + "\n" + "ErrorLog /var/log/apache2/redirect.error.log\n" + "LogLevel warn\n" + "\n" + % (servername, serveralias, + " ".join(CONFIG.REWRITE_HTTPS_ARGS))) + + # Write out the file + # This is the default name + redirect_filename = "le-redirect.conf" + + # See if a more appropriate name can be applied + if len(ssl_vhost.names) > 0: + # Sanity check... + # make sure servername doesn't exceed filename length restriction + if ssl_vhost.names[0] < (255-23): + redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] + + redirect_filepath = ("%ssites-available/%s" % + (self.parser.root, redirect_filename)) + + # Register the new file that will be created + # Note: always register the creation before writing to ensure file will + # be removed in case of unexpected program exit + self.register_file_creation(False, redirect_filepath) + + # Write out file + with open(redirect_filepath, 'w') as redirect_fd: + redirect_fd.write(redirect_file) + logging.info("Created redirect file: %s", redirect_filename) + + self.aug.load() + # Make a new vhost data structure and add it to the lists + new_fp = self.parser.root + "sites-available/" + redirect_filename + new_vhost = self._create_vhost(parser.get_aug_path(new_fp)) + self.vhosts.append(new_vhost) + + # Finally create documentation for the change + self.save_notes += ('Created a port 80 vhost, %s, for redirection to ' + 'ssl vhost %s\n' % + (new_vhost.filep, ssl_vhost.filep)) + + return True, new_vhost + + def _conflicting_host(self, ssl_vhost): + """Checks for conflicting HTTP vhost for ssl_vhost. + + Checks for a conflicting host, such that a new port 80 host could not + be created without ruining the apache config + Used with redirection + + returns: conflict, host_or_addrs - boolean + if conflict: returns conflicting vhost + if not conflict: returns space separated list of new host addrs + + :param ssl_vhost: SSL Vhost to check for possible port 80 redirection + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: TODO + :rtype: TODO + + """ + # Consider changing this to a dictionary check + redirect_addrs = "" + for ssl_a in ssl_vhost.addrs: + # Add space on each new addr, combine "VirtualHost"+redirect_addrs + redirect_addrs = redirect_addrs + " " + ssl_a_vhttp = ssl_a.get_addr_obj("80") + # Search for a conflicting host... + for vhost in self.vhosts: + if vhost.enabled: + if (ssl_a_vhttp in vhost.addrs or + ssl_a.get_addr_obj("") in vhost.addrs or + ssl_a.get_addr_obj("*") in vhost.addrs): + # We have found a conflicting host... just return + return True, vhost + + redirect_addrs = redirect_addrs + ssl_a_vhttp + + return False, redirect_addrs + + def _general_vhost(self, ssl_vhost): + """Find appropriate HTTP vhost for ssl_vhost. + + Function needs to be thoroughly tested and perhaps improved + Will not do well with malformed configurations + Consider changing this into a dict check + + :param ssl_vhost: ssl vhost to check + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: HTTP vhost or None if unsuccessful + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` or None + + """ + # _default_:443 check + # Instead... should look for vhost of the form *:80 + # Should we prompt the user? + ssl_addrs = ssl_vhost.addrs + if ssl_addrs == obj.Addr.fromstring("_default_:443"): + ssl_addrs = [obj.Addr.fromstring("*:443")] + + for vhost in self.vhosts: + found = 0 + # Not the same vhost, and same number of addresses + if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs): + # Find each address in ssl_host in test_host + for ssl_a in ssl_addrs: + for test_a in vhost.addrs: + if test_a.get_addr() == ssl_a.get_addr(): + # Check if found... + if (test_a.get_port() == "80" or + test_a.get_port() == "" or + test_a.get_port() == "*"): + found += 1 + break + # Check to make sure all addresses were found + # and names are equal + if (found == len(ssl_vhost.addrs) and + vhost.names == ssl_vhost.names): + return vhost + return None + + # TODO: Handle this as outlined in Interfaces. + def enable_ocsp_stapling(self, ssl_vhost): + """Enable OCSP Stapling.""" + return False + + def enable_hsts(self, ssl_vhost): + """Enable HSTS.""" + return False + + def get_all_certs_keys(self): + """Find all existing keys, certs from configuration. + + Retrieve all certs and keys set in VirtualHosts on the Apache server + + :returns: list of tuples with form [(cert, key, path)] + :rtype: list + + """ + c_k = set() + + for vhost in self.vhosts: + if vhost.ssl: + cert_path = self.parser.find_dir( + parser.case_i("SSLCertificateFile"), None, vhost.path) + key_path = self.parser.find_dir( + parser.case_i("SSLCertificateKeyFile"), None, vhost.path) + + # Can be removed once find directive can return ordered results + if len(cert_path) != 1 or len(key_path) != 1: + logging.error("Too many cert or key directives in vhost %s", + vhost.filep) + sys.exit(40) + + cert = os.path.abspath(self.aug.get(cert_path[0])) + key = os.path.abspath(self.aug.get(key_path[0])) + c_k.add((cert, key, get_file_path(cert_path[0]))) + + return c_k + + def is_site_enabled(self, avail_fp): + """Checks to see if the given site is enabled. + + .. todo:: fix hardcoded sites-enabled, check os.path.samefile + + :param str avail_fp: Complete file path of available site + + :returns: Success + :rtype: bool + + """ + enabled_dir = os.path.join(self.parser.root, "sites-enabled/") + for entry in os.listdir(enabled_dir): + if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp: + return True + + return False + + def enable_site(self, vhost): + """Enables an available site, Apache restart required. + + .. todo:: This function should number subdomains before the domain vhost + + .. todo:: Make sure link is not broken... + + :param vhost: vhost to enable + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` + + :returns: Success + :rtype: bool + + """ + if self.is_site_enabled(vhost.filep): + return True + + if "/sites-available/" in vhost.filep: + enabled_path = ("%ssites-enabled/%s" % + (self.parser.root, os.path.basename(vhost.filep))) + self.register_file_creation(False, enabled_path) + os.symlink(vhost.filep, enabled_path) + vhost.enabled = True + logging.info("Enabling available site: %s", vhost.filep) + self.save_notes += 'Enabled site %s\n' % vhost.filep + return True + return False + + def restart(self): # pylint: disable=no-self-use + """Restarts apache server. + + :returns: Success + :rtype: bool + + """ + return apache_restart() + + def config_test(self): + """Check the configuration of Apache for errors. + + :returns: Success + :rtype: bool + + """ + try: + proc = subprocess.Popen( + ['sudo', '/usr/sbin/apache2ctl', 'configtest'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate() + except (OSError, ValueError): + logging.fatal("Unable to run /usr/sbin/apache2ctl configtest") + sys.exit(1) + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Configtest failed") + logging.error(text[0]) + logging.error(text[1]) + return False + + return True + + def get_version(self): # pylint: disable=no-self-use + """Return version of Apache Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) + + :returns: version + :rtype: tuple + + :raises errors.LetsEncryptConfiguratorError: + Unable to find Apache version + + """ + try: + proc = subprocess.Popen( + [CONFIG.APACHE_CTL, '-v'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate()[0] + except (OSError, ValueError): + raise errors.LetsEncryptConfiguratorError( + "Unable to run %s -v" % CONFIG.APACHE_CTL) + + regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) + matches = regex.findall(text) + + if len(matches) != 1: + raise errors.LetsEncryptConfiguratorError( + "Unable to find Apache version") + + return tuple([int(i) for i in matches[0].split('.')]) + + def verify_setup(self): + """Verify the setup to ensure safe operating environment. + + Make sure that files/directories are setup with appropriate permissions + Aim for defensive coding... make sure all input files + have permissions of root + + """ + uid = os.geteuid() + le_util.make_or_verify_dir(self.direc["config"], 0o755, uid) + le_util.make_or_verify_dir(self.direc["work"], 0o755, uid) + le_util.make_or_verify_dir(self.direc["backup"], 0o755, uid) + + ########################################################################### + # Challenges Section + ########################################################################### + # pylint: disable=no-self-use, unused-argument + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" + + return ["dvsni"] + + def perform(self, chall_list): + """Perform the configuration related challenge. + + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + :param list chall_list: List of challenges to be + fulfilled by configurator. + + :returns: list of responses. All responses are returned in the same + order as received by the perform function. A None response + indicates the challenge was not perfromed. + :rtype: list + + """ + self.chall_out += len(chall_list) + responses = [None] * len(chall_list) + apache_dvsni = dvsni.ApacheDvsni(self) + + for i, chall in enumerate(chall_list): + if isinstance(chall, challenge_util.DvsniChall): + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + apache_dvsni.add_chall(chall, i) + + sni_response = apache_dvsni.perform() + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types + self.restart() + + # Go through all of the challenges and assign them to the proper place + # in the responses return value. All responses must be in the same order + # as the original challenges. + for i, resp in enumerate(sni_response): + responses[apache_dvsni.indices[i]] = resp + + return responses + + def cleanup(self, chall_list): + """Revert all challenges.""" + self.chall_out -= len(chall_list) + + # If all of the challenges have been finished, clean up everything + if self.chall_out <= 0: + self.revert_challenge_config() + self.restart() + + +def enable_mod(mod_name): + """Enables module in Apache. + + Both enables and restarts Apache so module is active. + + :param str mod_name: Name of the module to enable + + """ + try: + # Use check_output so the command will finish before reloading + subprocess.check_call(["sudo", "a2enmod", mod_name], + stdout=open("/dev/null", 'w'), + stderr=open("/dev/null", 'w')) + # Hopefully this waits for output + subprocess.check_call(["sudo", CONFIG.APACHE2, "restart"], + stdout=open("/dev/null", 'w'), + stderr=open("/dev/null", 'w')) + except (OSError, subprocess.CalledProcessError) as err: + logging.error("Error enabling mod_%s", mod_name) + logging.error("Exception: %s", err) + sys.exit(1) + + +def check_ssl_loaded(): + """Checks to see if mod_ssl is loaded + + Currently uses apache2ctl to get loaded module list + + .. todo:: This function is likely fragile to versions/distros + + :returns: If ssl_module is included and active in Apache + :rtype: bool + + """ + try: + # p=subprocess.check_output(['sudo', '/usr/sbin/apache2ctl', '-M'], + # stderr=open("/dev/null", 'w')) + proc = subprocess.Popen([CONFIG.APACHE_CTL, '-M'], + stdout=subprocess.PIPE, + stderr=open( + "/dev/null", 'w')).communicate()[0] + except (OSError, ValueError): + logging.error( + "Error accessing %s for loaded modules!", CONFIG.APACHE_CTL) + logging.error("This may be caused by an Apache Configuration Error") + return False + + if "ssl_module" in proc: + return True + return False + + +def apache_restart(): + """Restarts the Apache Server. + + .. todo:: Try to use reload instead. (This caused timing problems before) + .. todo:: This should be written to use the process return code. + + """ + try: + proc = subprocess.Popen([CONFIG.APACHE2, 'restart'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate() + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Configtest failed") + logging.error(text[0]) + logging.error(text[1]) + return False + + except (OSError, ValueError): + logging.fatal( + "Apache Restart Failed - Please Check the Configuration") + sys.exit(1) + + return True + + +def get_file_path(vhost_path): + """Get file path from augeas_vhost_path. + + Takes in Augeas path and returns the file name + + :param str vhost_path: Augeas virtual host path + + :returns: filename of vhost + :rtype: str + + """ + # Strip off /files + avail_fp = vhost_path[6:] + # This can be optimized... + while True: + # Cast both to lowercase to be case insensitive + find_if = avail_fp.lower().find("/ifmodule") + if find_if != -1: + avail_fp = avail_fp[:find_if] + continue + find_vh = avail_fp.lower().find("/virtualhost") + if find_vh != -1: + avail_fp = avail_fp[:find_vh] + continue + break + return avail_fp + + +def temp_install(options_ssl): + """Temporary install for convenience.""" + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM. + + # Check to make sure options-ssl.conf is installed + if not os.path.isfile(options_ssl): + dist_conf = pkg_resources.resource_filename( + __name__, os.path.basename(options_ssl)) + shutil.copyfile(dist_conf, options_ssl) diff --git a/letsencrypt/client/apache/dvsni.py b/letsencrypt/client/apache/dvsni.py new file mode 100644 index 000000000..c0aa552ad --- /dev/null +++ b/letsencrypt/client/apache/dvsni.py @@ -0,0 +1,179 @@ +"""ApacheDVSNI""" +import logging +import os + +from letsencrypt.client import challenge_util +from letsencrypt.client import CONFIG + +from letsencrypt.client.apache import parser + + +class ApacheDvsni(object): + """Class performs DVSNI challenges within the Apache configurator. + + :ivar config: ApacheConfigurator object + :type config: :class:`letsencrypt.client.apache.configurator` + + :ivar dvsni_chall: Data required for challenges. + where DvsniChall tuples have the following fields + `domain` (`str`), `r_b64` (base64 `str`), `nonce` (hex `str`) + `key` (:class:`letsencrypt.client.client.Client.Key`) + :type dvsni_chall: `list` of + :class:`letsencrypt.client.challenge_util.DvsniChall` + + :param list indicies: Meant to hold indices of challenges in a + larger array. ApacheDvsni is capable of solving many challenges + at once which causes an indexing issue within ApacheConfigurator + who must return all responses in order. Imagine ApacheConfigurator + maintaining state about where all of the SimpleHttps Challenges, + Dvsni Challenges belong in the response array. This is an optional + utility. + + :param str challenge_conf: location of the challenge config file + + """ + def __init__(self, config): + self.config = config + self.dvsni_chall = [] + self.indices = [] + self.challenge_conf = os.path.join( + config.direc["config"], "le_dvsni_cert_challenge.conf") + # self.completed = 0 + + def add_chall(self, chall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param chall: DVSNI challenge info + :type chall: :class:`letsencrypt.client.challenge_util.DvsniChall` + + :param int idx: index to challenge in a larger array + + """ + self.dvsni_chall.append(chall) + if idx is not None: + self.indices.append(idx) + + def perform(self): + """Peform a DVSNI challenge.""" + if not self.dvsni_chall: + return None + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.config.save() + + addresses = [] + default_addr = "*:443" + for chall in self.dvsni_chall: + vhost = self.config.choose_virtual_host(chall.domain) + if vhost is None: + logging.error( + "No vhost exists with servername or alias of: %s", + chall.domain) + logging.error("No _default_:443 vhost exists") + logging.error("Please specify servernames in the Apache config") + return None + + # TODO - @jdkasten review this code to make sure it makes sense + self.config.make_server_sni_ready(vhost, default_addr) + + for addr in vhost.addrs: + if "_default_" == addr.get_addr(): + addresses.append([default_addr]) + break + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + for chall in self.dvsni_chall: + cert_path = self.get_cert_file(chall.nonce) + self.config.register_file_creation(cert_path) + s_b64 = challenge_util.dvsni_gen_cert( + cert_path, chall.domain, chall.r_b64, chall.nonce, chall.key) + + responses.append({"type": "dvsni", "s": s_b64}) + + # Setup the configuration + self._mod_config(addresses) + + # Save reversible changes + self.config.save("SNI Challenge", True) + + return responses + + def _mod_config(self, ll_addrs): + """Modifies Apache config files to include challenge vhosts. + + Result: Apache config includes virtual servers for issued challs + + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache.obj.Addr` to apply + + """ + # TODO: Use ip address of existing vhost instead of relying on FQDN + config_text = "\n" + for idx, lis in enumerate(ll_addrs): + config_text += self._get_config_text( + self.dvsni_chall[idx].nonce, lis, + self.dvsni_chall[idx].key.file) + config_text += "\n" + + self._conf_include_check(self.config.parser.loc["default"]) + self.config.register_file_creation(True, self.challenge_conf) + + with open(self.challenge_conf, 'w') as new_conf: + new_conf.write(config_text) + + def _conf_include_check(self, main_config): + """Adds DVSNI challenge conf file into configuration. + + Adds DVSNI challenge include file if it does not already exist + within mainConfig + + :param str main_config: file path to main user apache config file + + """ + if len(self.config.parser.find_dir( + parser.case_i("Include"), self.challenge_conf)) == 0: + # print "Including challenge virtual host(s)" + self.config.parser.add_dir(parser.get_aug_path(main_config), + "Include", self.challenge_conf) + + def _get_config_text(self, nonce, ip_addrs, dvsni_key_file): + """Chocolate virtual server configuration text + + :param str nonce: hex form of nonce + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`letsencrypt.client.apache.obj.Addr` + :param str dvsni_key_file: Path to key file + + :returns: virtual host configuration text + :rtype: str + + """ + ips = " ".join(str(i) for i in ip_addrs) + return ("\n" + "ServerName " + nonce + CONFIG.INVALID_EXT + "\n" + "UseCanonicalName on\n" + "SSLStrictSNIVHostCheck on\n" + "\n" + "LimitRequestBody 1048576\n" + "\n" + "Include " + self.config.parser.loc["ssl_options"] + "\n" + "SSLCertificateFile " + self.get_cert_file(nonce) + "\n" + "SSLCertificateKeyFile " + dvsni_key_file + "\n" + "\n" + "DocumentRoot " + self.config.direc["config"] + "dvsni_page/\n" + "\n\n") + + def get_cert_file(self, nonce): + """Returns standardized name for challenge certificate. + + :param str nonce: hex form of nonce + + :returns: certificate file name + :rtype: str + + """ + return self.config.direc["work"] + nonce + ".crt" diff --git a/letsencrypt/client/apache/obj.py b/letsencrypt/client/apache/obj.py new file mode 100644 index 000000000..df2f36ec4 --- /dev/null +++ b/letsencrypt/client/apache/obj.py @@ -0,0 +1,91 @@ +"""Module contains classes used by the Apache Configurator.""" + + +class Addr(object): + """Represents an Apache VirtualHost address. + + :param str addr: addr part of vhost address + :param str port: port number or \*, or "" + + """ + def __init__(self, tup): + self.tup = tup + + @classmethod + def fromstring(cls, str_addr): + """Initialize Addr from string.""" + tup = str_addr.partition(':') + return cls((tup[0], tup[2])) + + def __str__(self): + if self.tup[1]: + return "%s:%s" % self.tup + return self.tup[0] + + def __eq__(self, other): + if isinstance(other, self.__class__): + return self.tup == other.tup + return False + + def __hash__(self): + return hash(self.tup) + + def get_addr(self): + """Return addr part of Addr object.""" + return self.tup[0] + + def get_port(self): + """Return port.""" + return self.tup[1] + + def get_addr_obj(self, port): + """Return new address object with same addr and new port.""" + return self.__class__((self.tup[0], port)) + + +# pylint: disable=too-few-public-methods +class VirtualHost(object): + """Represents an Apache Virtualhost. + + :ivar str filep: file path of VH + :ivar str path: Augeas path to virtual host + :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`Addr`) + :ivar set names: Server names/aliases of vhost + (:class:`list` of :class:`str`) + + :ivar bool ssl: SSLEngine on in vhost + :ivar bool enabled: Virtual host is enabled + + """ + + def __init__(self, filep, path, addrs, ssl, enabled, names=None): + """Initialize a VH.""" + self.filep = filep + self.path = path + self.addrs = addrs + self.names = set() if names is None else set(names) + self.ssl = ssl + self.enabled = enabled + + def add_name(self, name): + """Add name to vhost.""" + self.names.add(name) + + def __str__(self): + addr_str = ", ".join(str(addr) for addr in self.addrs) + return ("file: %s\n" + "vh_path: %s\n" + "addrs: %s\n" + "names: %s\n" + "ssl: %s\n" + "enabled: %s" % (self.filep, self.path, addr_str, + self.names, self.ssl, self.enabled)) + + def __eq__(self, other): + if isinstance(other, self.__class__): + return (self.filep == other.filep and self.path == other.path and + self.addrs == other.addrs and + self.names == other.names and + self.ssl == other.ssl and self.enabled == other.enabled) + + return False diff --git a/letsencrypt/client/options-ssl.conf b/letsencrypt/client/apache/options-ssl.conf similarity index 100% rename from letsencrypt/client/options-ssl.conf rename to letsencrypt/client/apache/options-ssl.conf diff --git a/letsencrypt/client/apache/parser.py b/letsencrypt/client/apache/parser.py new file mode 100644 index 000000000..792257b5a --- /dev/null +++ b/letsencrypt/client/apache/parser.py @@ -0,0 +1,401 @@ +"""ApacheParser is a member object of the ApacheConfigurator class.""" +import os +import re + +from letsencrypt.client import errors + + +class ApacheParser(object): + """Class handles the fine details of parsing the Apache Configuration.""" + + def __init__(self, aug, root, ssl_options): + # Find configuration root and make sure augeas can parse it. + self.aug = aug + self.root = root + self.loc = self._set_locations(ssl_options) + self._parse_file(self.loc["root"]) + + # Must also attempt to parse sites-available or equivalent + # Sites-available is not included naturally in configuration + self._parse_file(os.path.join(self.root, "sites-available/*")) + + # This problem has been fixed in Augeas 1.0 + self.standardize_excl() + + def add_dir_to_ifmodssl(self, aug_conf_path, directive, val): + """Adds directive and value to IfMod ssl block. + + Adds given directive and value along configuration path within + an IfMod mod_ssl.c block. If the IfMod block does not exist in + the file, it is created. + + :param str aug_conf_path: Desired Augeas config path to add directive + :param str directive: Directive you would like to add + :param str val: Value of directive ie. Listen 443, 443 is the value + + """ + # TODO: Add error checking code... does the path given even exist? + # Does it throw exceptions? + if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c") + # IfModule can have only one valid argument, so append after + self.aug.insert(if_mod_path + "arg", "directive", False) + nvh_path = if_mod_path + "directive[1]" + self.aug.set(nvh_path, directive) + self.aug.set(nvh_path + "/arg", val) + + def _get_ifmod(self, aug_conf_path, mod): + """Returns the path to and creates one if it doesn't exist. + + :param str aug_conf_path: Augeas configuration path + :param str mod: module ie. mod_ssl.c + + """ + if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % + (aug_conf_path, mod))) + if len(if_mods) == 0: + self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "") + self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod) + if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % + (aug_conf_path, mod))) + # Strip off "arg" at end of first ifmod path + return if_mods[0][:len(if_mods[0]) - 3] + + def add_dir(self, aug_conf_path, directive, arg): + """Appends directive to the end fo the file given by aug_conf_path. + + .. note:: Not added to AugeasConfigurator because it may depend + on the lens + + :param str aug_conf_path: Augeas configuration path to add directive + :param str directive: Directive to add + :param str arg: Value of the directive. ie. Listen 443, 443 is arg + + """ + self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) + if type(arg) is not list: + self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) + else: + for i in range(len(arg)): + self.aug.set("%s/directive[last()]/arg[%d]" % + (aug_conf_path, (i+1)), + arg[i]) + + def find_dir(self, directive, arg=None, start=None): + """Finds directive in the configuration. + + Recursively searches through config files to find directives + Directives should be in the form of a case insensitive regex currently + + .. todo:: Add order to directives returned. Last directive comes last.. + .. todo:: arg should probably be a list + + Note: Augeas is inherently case sensitive while Apache is case + insensitive. Augeas 1.0 allows case insensitive regexes like + regexp(/Listen/, 'i'), however the version currently supported + by Ubuntu 0.10 does not. Thus I have included my own case insensitive + transformation by calling case_i() on everything to maintain + compatibility. + + :param str directive: Directive to look for + + :param arg: Specific value directive must have, None if all should + be considered + :type arg: str or None + + :param str start: Beginning Augeas path to begin looking + + """ + # Cannot place member variable in the definition of the function so... + if not start: + start = get_aug_path(self.loc["root"]) + + # Debug code + # print "find_dir:", directive, "arg:", arg, " | Looking in:", start + # No regexp code + # if arg is None: + # matches = self.aug.match(start + + # "//*[self::directive='"+directive+"']/arg") + # else: + # matches = self.aug.match(start + + # "//*[self::directive='" + directive+"']/* [self::arg='" + arg + "']") + + # includes = self.aug.match(start + + # "//* [self::directive='Include']/* [label()='arg']") + + if arg is None: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" + % (start, directive))) + else: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" + "[self::arg=~regexp('%s')]" % + (start, directive, arg))) + + incl_regex = "(%s)|(%s)" % (case_i('Include'), + case_i('IncludeOptional')) + + includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " + "[label()='arg']" % (start, incl_regex))) + + # for inc in includes: + # print inc, self.aug.get(inc) + + for include in includes: + # start[6:] to strip off /files + matches.extend(self.find_dir( + directive, arg, self._get_include_path( + strip_dir(start[6:]), self.aug.get(include)))) + + return matches + + def _get_include_path(self, cur_dir, arg): + """Converts an Apache Include directive into Augeas path. + + Converts an Apache Include directive argument into an Augeas + searchable path + + .. todo:: convert to use os.path.join() + + :param str cur_dir: current working directory + + :param str arg: Argument of Include directive + + :returns: Augeas path string + :rtype: str + + """ + # Sanity check argument - maybe + # Question: what can the attacker do with control over this string + # Effect parse file... maybe exploit unknown errors in Augeas + # If the attacker can Include anything though... and this function + # only operates on Apache real config data... then the attacker has + # already won. + # Perhaps it is better to simply check the permissions on all + # included files? + # check_config to validate apache config doesn't work because it + # would create a race condition between the check and this input + + # TODO: Maybe... although I am convinced we have lost if + # Apache files can't be trusted. The augeas include path + # should be made to be exact. + + # Check to make sure only expected characters are used <- maybe remove + # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") + # matchObj = validChars.match(arg) + # if matchObj.group() != arg: + # logging.error("Error: Invalid regexp characters in %s", arg) + # return [] + + # Standardize the include argument based on server root + if not arg.startswith("/"): + arg = cur_dir + arg + # conf/ is a special variable for ServerRoot in Apache + elif arg.startswith("conf/"): + arg = self.root + arg[5:] + # TODO: Test if Apache allows ../ or ~/ for Includes + + # Attempts to add a transform to the file if one does not already exist + self._parse_file(arg) + + # Argument represents an fnmatch regular expression, convert it + # Split up the path and convert each into an Augeas accepted regex + # then reassemble + if "*" in arg or "?" in arg: + split_arg = arg.split("/") + for idx, split in enumerate(split_arg): + # * and ? are the two special fnmatch characters + if "*" in split or "?" in split: + # Turn it into a augeas regex + # TODO: Can this instead be an augeas glob instead of regex + split_arg[idx] = ("* [label()=~regexp('%s')]" % + self.fnmatch_to_re(split)) + # Reassemble the argument + arg = "/".join(split_arg) + + # If the include is a directory, just return the directory as a file + if arg.endswith("/"): + return get_aug_path(arg[:len(arg)-1]) + return get_aug_path(arg) + + def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use + """Method converts Apache's basic fnmatch to regular expression. + + :param str clean_fn_match: Apache style filename match, similar to globs + + :returns: regex suitable for augeas + :rtype: str + + """ + # Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py + regex = "" + for letter in clean_fn_match: + if letter == '.': + regex = regex + r"\." + elif letter == '*': + regex = regex + ".*" + # According to apache.org ? shouldn't appear + # but in case it is valid... + elif letter == '?': + regex = regex + "." + else: + regex = regex + letter + return regex + + def _parse_file(self, file_path): + """Parse file with Augeas + + Checks to see if file_path is parsed by Augeas + If file_path isn't parsed, the file is added and Augeas is reloaded + + :param str file_path: Apache config file path + + """ + # Test if augeas included file for Httpd.lens + # Note: This works for augeas globs, ie. *.conf + inc_test = self.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % file_path) + if not inc_test: + # Load up files + # self.httpd_incl.append(file_path) + # self.aug.add_transform("Httpd.lns", + # self.httpd_incl, None, self.httpd_excl) + self._add_httpd_transform(file_path) + self.aug.load() + + def standardize_excl(self): + """Standardize the excl arguments for the Httpd lens in Augeas. + + Note: Hack! + Standardize the excl arguments for the Httpd lens in Augeas + Servers sometimes give incorrect defaults + Note: This problem should be fixed in Augeas 1.0. Unfortunately, + Augeas 0.10 appears to be the most popular version currently. + + """ + # attempt to protect against augeas error in 0.10.0 - ubuntu + # *.augsave -> /*.augsave upon augeas.load() + # Try to avoid bad httpd files + # There has to be a better way... but after a day and a half of testing + # I had no luck + # This is a hack... work around... submit to augeas if still not fixed + + excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", + "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", + "*~", + self.root + "*.augsave", + self.root + "*~", + self.root + "*/*augsave", + self.root + "*/*~", + self.root + "*/*/*.augsave", + self.root + "*/*/*~"] + + for i in range(len(excl)): + self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i]) + + self.aug.load() + + def _add_httpd_transform(self, incl): + """Add a transform to Augeas. + + This function will correctly add a transform to augeas + The existing augeas.add_transform in python is broken. + + :param str incl: TODO + + """ + last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") + self.aug.insert(last_include[0], "incl", False) + self.aug.set("/augeas/load/Httpd/incl[last()]", incl) + + def _set_locations(self, ssl_options): + """Set default location for directives. + + Locations are given as file_paths + .. todo:: Make sure that files are included + + """ + root = self._find_config_root() + default = self._set_user_config_file(root) + + temp = os.path.join(self.root, "ports.conf") + if os.path.isfile(temp): + listen = temp + name = temp + else: + listen = default + name = default + + return {"root": root, "default": default, "listen": listen, + "name": name, "ssl_options": ssl_options} + + def _find_config_root(self): + """Find the Apache Configuration Root file.""" + location = ["apache2.conf", "httpd.conf"] + + for name in location: + if os.path.isfile(os.path.join(self.root, name)): + return os.path.join(self.root, name) + + raise errors.LetsEncryptConfiguratorError( + "Could not find configuration root") + + def _set_user_config_file(self, root): + """Set the appropriate user configuration file + + .. todo:: This will have to be updated for other distros versions + + :param str root: pathname which contains the user config + + """ + # Basic check to see if httpd.conf exists and + # in hierarchy via direct include + # httpd.conf was very common as a user file in Apache 2.2 + if (os.path.isfile(os.path.join(self.root, 'httpd.conf')) and + self.find_dir( + case_i("Include"), case_i("httpd.conf"), root)): + return os.path.join(self.root, 'httpd.conf') + else: + return os.path.join(self.root, 'apache2.conf') + + +def case_i(string): + """Returns case insensitive regex. + + Returns a sloppy, but necessary version of a case insensitive regex. + Any string should be able to be submitted and the string is + escaped and then made case insensitive. + May be replaced by a more proper /i once augeas 1.0 is widely + supported. + + :param str string: string to make case i regex + + """ + return "".join(["["+c.upper()+c.lower()+"]" + if c.isalpha() else c for c in re.escape(string)]) + + +def get_aug_path(file_path): + """Return augeas path for full filepath. + + :param str file_path: Full filepath + + """ + return "/files%s" % file_path + + +def strip_dir(path): + """Returns directory of file path. + + .. todo:: Replace this with Python standard function + + :param str path: path is a file path. not an augeas section or + directive path + + :returns: directory + :rtype: str + + """ + index = path.rfind("/") + if index > 0: + return path[:index+1] + # No directory + return "" diff --git a/letsencrypt/client/apache_configurator.py b/letsencrypt/client/apache_configurator.py deleted file mode 100644 index a1e582d38..000000000 --- a/letsencrypt/client/apache_configurator.py +++ /dev/null @@ -1,1773 +0,0 @@ -"""Apache Configuration based off of Augeas Configurator.""" -import hashlib -import logging -import os -import pkg_resources -import re -import shutil -import socket -import subprocess -import sys - -from Crypto import Random - -from letsencrypt.client import augeas_configurator -from letsencrypt.client import CONFIG -from letsencrypt.client import crypto_util -from letsencrypt.client import errors -from letsencrypt.client import le_util - - -# Configurator should be turned into a Singleton - -# Note: Apache 2.4 NameVirtualHost directive is deprecated... all vhost twins -# are considered name based vhosts by default. The use of the directive will -# emit a warning. - -# TODO: Augeas sections ie. , beginning and closing -# tags need to be the same case, otherwise Augeas doesn't recognize them. -# This is not able to be completely remedied by regular expressions because -# Augeas views as an error. This will just -# require another check_parsing_errors() after all files are included... -# (after a find_directive search is executed currently). It can be a one -# time check however because all of Trustifies transactions will ensure -# only properly formed sections are added. - -# Note: This protocol works for filenames with spaces in it, the sites are -# properly set up and directives are changed appropriately, but Apache won't -# recognize names in sites-enabled that have spaces. These are not added to the -# Apache configuration. It may be wise to warn the user if they are trying -# to use vhost filenames that contain spaces and offer to change ' ' to '_' - -# Note: FILEPATHS and changes to files are transactional. They are copied -# over before the updates are made to the existing files. NEW_FILES is -# transactional due to the use of register_file_creation() - -class VH(object): - """Represents an Apache Virtualhost. - - :ivar str filep: file path of VH - :ivar str path: Augeas path to virtual host - :ivar list addrs: Virtual Host addresses (:class:`list` of :class:`str`) - :ivar list names: Server names/aliases of vhost - (:class:`list` of :class:`str`) - - :ivar bool ssl: SSLEngine on in vhost - :ivar bool enabled: Virtual host is enabled - - """ - - def __init__(self, filep, path, addrs, ssl, enabled, names=None): - """Initialize a VH.""" - self.filep = filep - self.path = path - self.addrs = addrs - self.names = [] if names is None else names - self.ssl = ssl - self.enabled = enabled - - def add_name(self, name): - """Add name to vhost.""" - self.names.append(name) - - def __str__(self): - return ("file: %s\n" - "vh_path: %s\n" - "addrs: %s\n" - "names: %s\n" - "ssl: %s\n" - "enabled: %s" % (self.filep, self.path, self.addrs, - self.names, self.ssl, self.enabled)) - - def __eq__(self, other): - if isinstance(other, self.__class__): - return (self.filep == other.filep and self.path == other.path and - set(self.addrs) == set(other.addrs) and - set(self.names) == set(other.names) and - self.ssl == other.ssl and self.enabled == other.enabled) - - return False - - -class ApacheConfigurator(augeas_configurator.AugeasConfigurator): - """Apache configurator. - - State of Configurator: This code has been tested under Ubuntu 12.04 - Apache 2.2 and this code works for Ubuntu 14.04 Apache 2.4. Further - notes below. - - This class was originally developed for Apache 2.2 and has not seen a - an overhaul to include proper setup of new Apache configurations. - I have implemented most of the changes... the missing ones are - mod_ssl.c vs ssl_mod, and I need to account for configuration variables. - That being said, this class can still adequately configure most typical - Apache 2.4 servers as the deprecated NameVirtualHost has no effect - and the typical directories are parsed by the Augeas configuration - parser automatically. - - .. todo:: Add support for config file variables Define rootDir /var/www/ - - The API of this class will change in the coming weeks as the exact - needs of client's are clarified with the new and developing protocol. - - :ivar str server_root: Path to Apache root directory - :ivar dict location: Path to various files associated - with the configuration - :ivar float version: version of Apache - :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`VH`) - - :ivar dict assoc: Mapping between domains and vhosts - - """ - def __init__(self, server_root=CONFIG.SERVER_ROOT, direc=None, - ssl_options=CONFIG.OPTIONS_SSL_CONF, version=None): - """Initialize an Apache Configurator. - - :param str server_root: the apache server root directory - :param dict direc: locations of various config directories - (used mostly for unittesting) - :param str ssl_options: path of options-ssl.conf - (used mostly for unittesting) - :param tup version: version of Apache as a tuple (2, 4, 7) - (used mostly for unittesting) - - """ - if direc is None: - direc = {"backup": CONFIG.BACKUP_DIR, - "temp": CONFIG.TEMP_CHECKPOINT_DIR, - "progress": CONFIG.IN_PROGRESS_DIR, - "config": CONFIG.CONFIG_DIR, - "work": CONFIG.WORK_DIR} - - super(ApacheConfigurator, self).__init__(direc) - - self.server_root = server_root - - # See if any temporary changes need to be recovered - # This needs to occur before VH objects are setup... - # because this will change the underlying configuration and potential - # vhosts - self.recovery_routine() - - # Verify that all directories and files exist with proper permissions - if os.geteuid() == 0: - self.verify_setup() - - # Find configuration root and make sure augeas can parse it. - self.location = self._set_locations(ssl_options) - self._parse_file(self.location["root"]) - - # Must also attempt to parse sites-available or equivalent - # Sites-available is not included naturally in configuration - self._parse_file(os.path.join(self.server_root, "sites-available/*")) - - # Set Version - self.version = self.get_version() if version is None else version - - # Check for errors in parsing files with Augeas - self.check_parsing_errors("httpd.aug") - # This problem has been fixed in Augeas 1.0 - self.standardize_excl() - - # Get all of the available vhosts - self.vhosts = self.get_virtual_hosts() - # Add name_server association dict - self.assoc = dict() - - # Enable mod_ssl if it isn't already enabled - # This is Let's Encrypt... we enable mod_ssl on initialization :) - # TODO: attempt to make the check faster... this enable should - # be asynchronous as it shouldn't be that time sensitive - # on initialization - self._prepare_server_https() - - # Note: initialization doesn't check to see if the config is correct - # by Apache's standards. This should be done by the client (client.py) - # if it is desired. There may be instances where correct configuration - # isn't required on startup. - - # TODO: This function can be improved to ensure that the final directives - # are being modified whether that be in the include files or in the - # virtualhost declaration - these directives can be overwritten - def deploy_cert(self, vhost, cert, key, cert_chain=None): - """Deploys certificate to specified virtual host. - - Currently tries to find the last directives to deploy the cert in - the given virtualhost. If it can't find the directives, it searches - the "included" confs. The function verifies that it has located - the three directives and finally modifies them to point to the correct - destination - - .. todo:: Make sure last directive is changed - - .. todo:: Might be nice to remove chain directive if none exists - This shouldn't happen within letsencrypt though - - :param vhost: ssl vhost to deploy certificate - :type vhost: :class:`VH` - - :param str cert: certificate filename - :param str key: private key filename - :param str cert_chain: certificate chain filename - - :returns: Success - :rtype: bool - - """ - path = {} - - path["cert_file"] = self.find_directive(case_i( - "SSLCertificateFile"), None, vhost.path) - path["cert_key"] = self.find_directive(case_i( - "SSLCertificateKeyFile"), None, vhost.path) - - # Only include if a certificate chain is specified - if cert_chain is not None: - path["cert_chain"] = self.find_directive( - case_i("SSLCertificateChainFile"), None, vhost.path) - - if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: - # Throw some "can't find all of the directives error" - logging.warn( - "Cannot find a cert or key directive in %s", vhost.path) - logging.warn("VirtualHost was not modified") - # Presumably break here so that the virtualhost is not modified - return False - - logging.info("Deploying Certificate to VirtualHost %s", vhost.filep) - - self.aug.set(path["cert_file"][0], cert) - self.aug.set(path["cert_key"][0], key) - if cert_chain is not None: - if len(path["cert_chain"]) == 0: - self.add_dir(vhost.path, "SSLCertificateChainFile", cert_chain) - else: - self.aug.set(path["cert_chain"][0], cert_chain) - - self.save_notes += ("Changed vhost at %s with addresses of %s\n" % - (vhost.filep, vhost.addrs)) - self.save_notes += "\tSSLCertificateFile %s\n" % cert - self.save_notes += "\tSSLCertificateKeyFile %s\n" % key - if cert_chain: - self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain - # This is a significant operation, make a checkpoint - return self.save() - - def choose_virtual_host(self, target_name): - """ Chooses a virtual host based on the given domain name. - - .. todo:: This should maybe return list if no obvious answer - is presented. - - :param str name: domain name - - :returns: ssl vhost associated with name - :rtype: :class:`VH` - - """ - # Allows for domain names to be associated with a virtual host - # Client isn't using create_dn_server_assoc(self, dn, vh) yet - for domain, vhost in self.assoc: - if domain == target_name: - return vhost - # Check for servernames/aliases for ssl hosts - for vhost in self.vhosts: - if vhost.ssl: - for name in vhost.names: - if name == target_name: - return vhost - # Checking for domain name in vhost address - # This technique is not recommended by Apache but is technically valid - for vhost in self.vhosts: - for addr in vhost.addrs: - tup = addr.partition(":") - if tup[0] == target_name and tup[2] == "443": - return vhost - - # Check for non ssl vhosts with servernames/aliases == 'name' - for vhost in self.vhosts: - if not vhost.ssl: - for name in vhost.names: - if name == target_name: - # When do we need to self.make_vhost_ssl(v) - return self.make_vhost_ssl(vhost) - - # No matches, search for the default - for vhost in self.vhosts: - for addr in vhost.addrs: - if addr == "_default_:443": - return vhost - return None - - def create_dn_server_assoc(self, domain, vhost): - """Create an association between a domain name and virtual host. - - Helps to choose an appropriate vhost - - :param str domain: domain name to associate - - :param vhost: virtual host to associate with domain - :type vhost: :class:`VH` - - """ - self.assoc[domain] = vhost - - def get_all_names(self): - """Returns all names found in the Apache Configuration. - - :returns: All ServerNames, ServerAliases, and reverse DNS entries for - virtual host addresses - :rtype: set - - """ - all_names = set() - - # Kept in same function to avoid multiple compilations of the regex - priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|" - r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)") - private_ips = re.compile(priv_ip_regex) - - for vhost in self.vhosts: - all_names.update(vhost.names) - for addr in vhost.addrs: - a_tup = addr.partition(":") - - # If it isn't a private IP, do a reverse DNS lookup - if not private_ips.match(a_tup[0]): - try: - socket.inet_aton(a_tup[0]) - all_names.add(socket.gethostbyaddr(a_tup[0])[0]) - except (socket.error, socket.herror, socket.timeout): - continue - - return all_names - - def _set_locations(self, ssl_options): - """Set default location for directives. - - Locations are given as file_paths - .. todo:: Make sure that files are included - - """ - root = self._find_config_root() - default = self._set_user_config_file() - - temp = os.path.join(self.server_root, "ports.conf") - if os.path.isfile(temp): - listen = temp - name = temp - else: - listen = default - name = default - - return {"root": root, "default": default, "listen": listen, - "name": name, "ssl_options": ssl_options} - - def _find_config_root(self): - """Find the Apache Configuration Root file.""" - location = ["apache2.conf", "httpd.conf"] - - for name in location: - if os.path.isfile(os.path.join(self.server_root, name)): - return os.path.join(self.server_root, name) - - raise errors.LetsEncryptConfiguratorError( - "Could not find configuration root") - - def _set_user_config_file(self, filename=''): - """Set the appropriate user configuration file - - .. todo:: This will have to be updated for other distros versions - - :param str filename: optional filename that will be used as the - user config - - """ - if filename: - return filename - else: - # Basic check to see if httpd.conf exists and - # in heirarchy via direct include - # httpd.conf was very common as a user file in Apache 2.2 - if (os.path.isfile(self.server_root + 'httpd.conf') and - self.find_directive( - case_i("Include"), case_i("httpd.conf"))): - return os.path.join(self.server_root, 'httpd.conf') - else: - return os.path.join(self.server_root + 'apache2.conf') - - def _add_servernames(self, host): - """Helper function for get_virtual_hosts(). - - :param host: In progress vhost whose names will be added - :type host: :class:`VH` - - """ - name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " - "%s//*[self::directive=~regexp('%s')]" % - (host.path, - case_i('ServerName'), - host.path, - case_i('ServerAlias')))) - - for name in name_match: - args = self.aug.match(name + "/*") - for arg in args: - host.add_name(self.aug.get(arg)) - - def _create_vhost(self, path): - """Used by get_virtual_hosts to create vhost objects - - :param str path: Augeas path to virtual host - - :returns: newly created vhost - :rtype: :class:`VH` - - """ - addrs = [] - args = self.aug.match(path + "/arg") - for arg in args: - addrs.append(self.aug.get(arg)) - is_ssl = False - - if self.find_directive( - case_i("SSLEngine"), case_i("on"), path): - is_ssl = True - - filename = get_file_path(path) - is_enabled = self.is_site_enabled(filename) - vhost = VH(filename, path, addrs, is_ssl, is_enabled) - self._add_servernames(vhost) - return vhost - - # TODO: make "sites-available" a configurable directory - def get_virtual_hosts(self): - """Returns list of virtual hosts found in the Apache configuration. - - :returns: List of :class:`VH` objects found in configuration - :rtype: list - - """ - # Search sites-available, httpd.conf for possible virtual hosts - paths = self.aug.match( - ("/files%ssites-available//*[label()=~regexp('%s')]" % - (self.server_root, case_i('VirtualHost')))) - vhs = [] - - for path in paths: - vhs.append(self._create_vhost(path)) - - return vhs - - # pylint: disable=anomalous-backslash-in-string - def is_name_vhost(self, target_addr): - """Returns if vhost is a name based vhost - - NameVirtualHost was deprecated in Apache 2.4 as all VirtualHosts are - now NameVirtualHosts. If version is earlier than 2.4, check if addr - has a NameVirtualHost directive in the Apache config - - :param str addr: vhost address ie. \*:443 - - :returns: Success - :rtype: bool - - """ - # Mixed and matched wildcard NameVirtualHost with VirtualHost - # behavior is undefined. Make sure that an exact match exists - - # search for NameVirtualHost directive for ip_addr - # note ip_addr can be FQDN although Apache does not recommend it - return (self.version >= (2, 4) or - self.find_directive( - case_i("NameVirtualHost"), case_i(target_addr))) - - def add_name_vhost(self, addr): - """Adds NameVirtualHost directive for given address. - - :param str addr: Address that will be added as NameVirtualHost directive - - """ - path = self._add_dir_to_ifmodssl( - get_aug_path(self.location["name"]), "NameVirtualHost", addr) - - self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr - self.save_notes += "\tDirective added to %s\n" % path - - def _add_dir_to_ifmodssl(self, aug_conf_path, directive, val): - """Adds directive and value to IfMod ssl block. - - Adds given directive and value along configuration path within - an IfMod mod_ssl.c block. If the IfMod block does not exist in - the file, it is created. - - :param str aug_conf_path: Desired Augeas config path to add directive - :param str directive: Directive you would like to add - :param str val: Value of directive ie. Listen 443, 443 is the value - - """ - # TODO: Add error checking code... does the path given even exist? - # Does it throw exceptions? - if_mod_path = self._get_ifmod(aug_conf_path, "mod_ssl.c") - # IfModule can have only one valid argument, so append after - self.aug.insert(if_mod_path + "arg", "directive", False) - nvh_path = if_mod_path + "directive[1]" - self.aug.set(nvh_path, directive) - self.aug.set(nvh_path + "/arg", val) - - def _prepare_server_https(self): - """Prepare the server for HTTPS. - - Make sure that the ssl_module is loaded and that the server - is appropriately listening on port 443. - - """ - if not check_ssl_loaded(): - logging.info("Loading mod_ssl into Apache Server") - enable_mod("ssl") - - # Check for Listen 443 - # Note: This could be made to also look for ip:443 combo - # TODO: Need to search only open directives and IfMod mod_ssl.c - if len(self.find_directive(case_i("Listen"), "443")) == 0: - logging.debug("No Listen 443 directive found") - logging.debug("Setting the Apache Server to Listen on port 443") - path = self._add_dir_to_ifmodssl( - get_aug_path(self.location["listen"]), "Listen", "443") - self.save_notes += "Added Listen 443 directive to %s\n" % path - - def make_server_sni_ready(self, vhost, default_addr="*:443"): - """Checks to see if the server is ready for SNI challenges. - - :param vhost: VHost to check SNI compatibility - :type vhost: :class:`VH` - - :param str default_addr: TODO - investigate function further - - """ - if self.version >= (2, 4): - return - # Check for NameVirtualHost - # First see if any of the vhost addresses is a _default_ addr - for addr in vhost.addrs: - tup = addr.partition(":") - if tup[0] == "_default_": - if not self.is_name_vhost(default_addr): - logging.debug("Setting all VirtualHosts on %s to be " - "name based vhosts", default_addr) - self.add_name_vhost(default_addr) - - # No default addresses... so set each one individually - for addr in vhost.addrs: - if not self.is_name_vhost(addr): - logging.debug("Setting VirtualHost at %s to be a name " - "based virtual host", addr) - self.add_name_vhost(addr) - - def _get_ifmod(self, aug_conf_path, mod): - """Returns the path to and creates one if it doesn't exist. - - :param str aug_conf_path: Augeas configuration path - :param str mod: module ie. mod_ssl.c - - """ - if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % - (aug_conf_path, mod))) - if len(if_mods) == 0: - self.aug.set("%s/IfModule[last() + 1]" % aug_conf_path, "") - self.aug.set("%s/IfModule[last()]/arg" % aug_conf_path, mod) - if_mods = self.aug.match(("%s/IfModule/*[self::arg='%s']" % - (aug_conf_path, mod))) - # Strip off "arg" at end of first ifmod path - return if_mods[0][:len(if_mods[0]) - 3] - - def add_dir(self, aug_conf_path, directive, arg): - """Appends directive to the end fo the file given by aug_conf_path. - - .. note:: Not added to AugeasConfigurator because it may depend - on the lens - - :param str aug_conf_path: Augeas configuration path to add directive - :param str directive: Directive to add - :param str arg: Value of the directive. ie. Listen 443, 443 is arg - - """ - self.aug.set(aug_conf_path + "/directive[last() + 1]", directive) - if type(arg) is not list: - self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) - else: - for i in range(len(arg)): - self.aug.set("%s/directive[last()]/arg[%d]" % - (aug_conf_path, (i+1)), - arg[i]) - - def find_directive(self, directive, arg=None, start=None): - """Finds directive in the configuration. - - Recursively searches through config files to find directives - Directives should be in the form of a case insensitive regex currently - - .. todo:: arg should probably be a list - - Note: Augeas is inherently case sensitive while Apache is case - insensitive. Augeas 1.0 allows case insensitive regexes like - regexp(/Listen/, 'i'), however the version currently supported - by Ubuntu 0.10 does not. Thus I have included my own case insensitive - transformation by calling case_i() on everything to maintain - compatibility. - - :param str directive: Directive to look for - - :param arg: Specific value direcitve must have, None if all should - be considered - :type arg: str or None - - :param str start: Beginning Augeas path to begin looking - - """ - # Cannot place member variable in the definition of the function so... - if not start: - start = get_aug_path(self.location["root"]) - - # Debug code - # print "find_dir:", directive, "arg:", arg, " | Looking in:", start - # No regexp code - # if arg is None: - # matches = self.aug.match(start + - # "//*[self::directive='"+directive+"']/arg") - # else: - # matches = self.aug.match(start + - # "//*[self::directive='" + directive+"']/* [self::arg='" + arg + "']") - - # includes = self.aug.match(start + - # "//* [self::directive='Include']/* [label()='arg']") - - if arg is None: - matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" - % (start, directive))) - else: - matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" - "[self::arg=~regexp('%s')]" % - (start, directive, arg))) - - incl_regex = "(%s)|(%s)" % (case_i('Include'), - case_i('IncludeOptional')) - - includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " - "[label()='arg']" % (start, incl_regex))) - - # for inc in includes: - # print inc, self.aug.get(inc) - - for include in includes: - # start[6:] to strip off /files - matches.extend(self.find_directive( - directive, arg, self._get_include_path(strip_dir(start[6:]), - self.aug.get(include)))) - - return matches - - def _get_include_path(self, cur_dir, arg): - """Converts an Apache Include directive into Augeas path. - - Converts an Apache Include directive argument into an Augeas - searchable path - - .. todo:: convert to use os.path.join() - - :param str cur_dir: current working directory - - :param str arg: Argument of Include directive - - :returns: Augeas path string - :rtype: str - - """ - # Sanity check argument - maybe - # Question: what can the attacker do with control over this string - # Effect parse file... maybe exploit unknown errors in Augeas - # If the attacker can Include anything though... and this function - # only operates on Apache real config data... then the attacker has - # already won. - # Perhaps it is better to simply check the permissions on all - # included files? - # check_config to validate apache config doesn't work because it - # would create a race condition between the check and this input - - # TODO: Maybe... although I am convinced we have lost if - # Apache files can't be trusted. The augeas include path - # should be made to be exact. - - # Check to make sure only expected characters are used <- maybe remove - # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") - # matchObj = validChars.match(arg) - # if matchObj.group() != arg: - # logging.error("Error: Invalid regexp characters in %s", arg) - # return [] - - # Standardize the include argument based on server root - if not arg.startswith("/"): - arg = cur_dir + arg - # conf/ is a special variable for ServerRoot in Apache - elif arg.startswith("conf/"): - arg = self.server_root + arg[5:] - # TODO: Test if Apache allows ../ or ~/ for Includes - - # Attempts to add a transform to the file if one does not already exist - self._parse_file(arg) - - # Argument represents an fnmatch regular expression, convert it - # Split up the path and convert each into an Augeas accepted regex - # then reassemble - if "*" in arg or "?" in arg: - split_arg = arg.split("/") - for idx, split in enumerate(split_arg): - # * and ? are the two special fnmatch characters - if "*" in split or "?" in split: - # Turn it into a augeas regex - # TODO: Can this instead be an augeas glob instead of regex - split_arg[idx] = ("* [label()=~regexp('%s')]" % - self.fnmatch_to_re(split)) - # Reassemble the argument - arg = "/".join(split_arg) - - # If the include is a directory, just return the directory as a file - if arg.endswith("/"): - return get_aug_path(arg[:len(arg)-1]) - return get_aug_path(arg) - - def make_vhost_ssl(self, nonssl_vhost): - """Makes an ssl_vhost version of a nonssl_vhost. - - Duplicates vhost and adds default ssl options - New vhost will reside as (nonssl_vhost.path) + CONFIG.LE_VHOST_EXT - - :param nonssl_vhost: Valid VH that doesn't have SSLEngine on - :type nonssl_vhost: :class:`VH` - - :returns: SSL vhost - :rtype: :class:`VH` - - """ - avail_fp = nonssl_vhost.filep - # Copy file - if avail_fp.endswith(".conf"): - ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT - else: - ssl_fp = avail_fp + CONFIG.LE_VHOST_EXT - - # First register the creation so that it is properly removed if - # configuration is rolled back - self.register_file_creation(False, ssl_fp) - - try: - orig_file = open(avail_fp, 'r') - new_file = open(ssl_fp, 'w') - new_file.write("\n") - for line in orig_file: - new_file.write(line) - new_file.write("\n") - except IOError: - logging.fatal("Error writing/reading to file in make_vhost_ssl") - sys.exit(49) - finally: - orig_file.close() - new_file.close() - - self.aug.load() - # Delete the VH addresses because they may change here - del nonssl_vhost.addrs[:] - ssl_addrs = [] - - # change address to address:443, address:80 - addr_match = "/files%s//* [label()=~regexp('%s')]/arg" - ssl_addr_p = self.aug.match( - addr_match % (ssl_fp, case_i('VirtualHost'))) - avail_addr_p = self.aug.match( - addr_match % (avail_fp, case_i('VirtualHost'))) - - for i in range(len(avail_addr_p)): - avail_old_arg = str(self.aug.get(avail_addr_p[i])) - ssl_old_arg = str(self.aug.get(ssl_addr_p[i])) - avail_tup = avail_old_arg.partition(":") - ssl_tup = ssl_old_arg.partition(":") - avail_new_addr = avail_tup[0] + ":80" - ssl_new_addr = ssl_tup[0] + ":443" - self.aug.set(avail_addr_p[i], avail_new_addr) - self.aug.set(ssl_addr_p[i], ssl_new_addr) - nonssl_vhost.addrs.append(avail_new_addr) - ssl_addrs.append(ssl_new_addr) - - # Add directives - vh_p = self.aug.match(("/files%s//* [label()=~regexp('%s')]" % - (ssl_fp, case_i('VirtualHost')))) - if len(vh_p) != 1: - logging.error("Error: should only be one vhost in %s", avail_fp) - sys.exit(1) - - self.add_dir(vh_p[0], "SSLCertificateFile", - "/etc/ssl/certs/ssl-cert-snakeoil.pem") - self.add_dir(vh_p[0], "SSLCertificateKeyFile", - "/etc/ssl/private/ssl-cert-snakeoil.key") - self.add_dir(vh_p[0], "Include", self.location["ssl_options"]) - - # Log actions and create save notes - logging.info("Created an SSL vhost at %s", ssl_fp) - self.save_notes += 'Created ssl vhost at %s\n' % ssl_fp - self.save() - - # We know the length is one because of the assertion above - ssl_vhost = self._create_vhost(vh_p[0]) - self.vhosts.append(ssl_vhost) - - # Check if nonssl_vhost's address was NameVirtualHost - # NOTE: Searches through Augeas seem to ruin changes to directives - # The configuration must also be saved before being searched - # for the new directives; For these reasons... this is tacked - # on after fully creating the new vhost - need_to_save = False - for i in range(len(nonssl_vhost.addrs)): - - if (self.is_name_vhost(nonssl_vhost.addrs[i]) and - not self.is_name_vhost(ssl_addrs[i])): - self.add_name_vhost(ssl_addrs[i]) - logging.info("Enabling NameVirtualHosts on %s", ssl_addrs[i]) - need_to_save = True - - if need_to_save: - self.save() - - return ssl_vhost - - def enable_redirect(self, ssl_vhost): - """Redirect all equivalent HTTP traffic to ssl_vhost. - - Adds Redirect directive to the port 80 equivalent of ssl_vhost - First the function attempts to find the vhost with equivalent - ip addresses that serves on non-ssl ports - The function then adds the directive - - :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`VH` - - :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`VH`) - - """ - # TODO: Enable check to see if it is already there - # to avoid the extra restart - enable_mod("rewrite") - - general_v = self._general_vhost(ssl_vhost) - if general_v is None: - # Add virtual_server with redirect - logging.debug( - "Did not find http version of ssl virtual host... creating") - return self.create_redirect_vhost(ssl_vhost) - else: - # Check if redirection already exists - exists, code = self.existing_redirect(general_v) - if exists: - if code == 0: - logging.debug("Redirect already added") - return True, general_v - else: - logging.debug("Unknown redirect exists for this vhost") - return False, general_v - # Add directives to server - self.add_dir(general_v.path, "RewriteEngine", "On") - self.add_dir(general_v.path, - "RewriteRule", CONFIG.REWRITE_HTTPS_ARGS) - self.save_notes += ('Redirecting host in %s to ssl vhost in %s\n' % - (general_v.filep, ssl_vhost.filep)) - self.save() - return True, general_v - - def existing_redirect(self, vhost): - """Checks to see if existing redirect is in place. - - Checks to see if virtualhost already contains a rewrite or redirect - returns boolean, integer - The boolean indicates whether the redirection exists... - The integer has the following code: - 0 - Existing letsencrypt https rewrite rule is appropriate and in place - 1 - Virtual host contains a Redirect directive - 2 - Virtual host contains an unknown RewriteRule - - -1 is also returned in case of no redirection/rewrite directives - - :param vhost: vhost to check - :type vhost: :class:`VH` - - :returns: Success, code value... see documentation - :rtype: bool, int - - """ - rewrite_path = self.find_directive( - case_i("RewriteRule"), None, vhost.path) - redirect_path = self.find_directive( - case_i("Redirect"), None, vhost.path) - - if redirect_path: - # "Existing Redirect directive for virtualhost" - return True, 1 - if not rewrite_path: - # "No existing redirection for virtualhost" - return False, -1 - if len(rewrite_path) == len(CONFIG.REWRITE_HTTPS_ARGS): - for idx, match in enumerate(rewrite_path): - if self.aug.get(match) != CONFIG.REWRITE_HTTPS_ARGS[idx]: - # Not a letsencrypt https rewrite - return True, 2 - # Existing letsencrypt https rewrite rule is in place - return True, 0 - # Rewrite path exists but is not a letsencrypt https rule - return True, 2 - - def create_redirect_vhost(self, ssl_vhost): - """Creates an http_vhost specifically to redirect for the ssl_vhost. - - :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`VH` - - :returns: Success, vhost - :rtype: (bool, :class:`VH`) - - """ - # Consider changing this to a dictionary check - # Make sure adding the vhost will be safe - conflict, host_or_addrs = self._conflicting_host(ssl_vhost) - if conflict: - return False, host_or_addrs - - redirect_addrs = host_or_addrs - - # get servernames and serveraliases - serveralias = "" - servername = "" - size_n = len(ssl_vhost.names) - if size_n > 0: - servername = "ServerName " + ssl_vhost.names[0] - if size_n > 1: - serveralias = " ".join(ssl_vhost.names[1:size_n]) - serveralias = "ServerAlias " + serveralias - redirect_file = " \n\ -" + servername + "\n\ -" + serveralias + " \n\ -ServerSignature Off \n\ -\n\ -RewriteEngine On \n\ -RewriteRule ^.*$ https://%{SERVER_NAME}%{REQUEST_URI} [L,R=permanent]\n\ -\n\ -ErrorLog /var/log/apache2/redirect.error.log \n\ -LogLevel warn \n\ -\n" - - # Write out the file - # This is the default name - redirect_filename = "le-redirect.conf" - - # See if a more appropriate name can be applied - if len(ssl_vhost.names) > 0: - # Sanity check... - # make sure servername doesn't exceed filename length restriction - if ssl_vhost.names[0] < (255-23): - redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] - - redirect_filepath = ("%ssites-available/%s" % - (self.server_root, redirect_filename)) - - # Register the new file that will be created - # Note: always register the creation before writing to ensure file will - # be removed in case of unexpected program exit - self.register_file_creation(False, redirect_filepath) - - # Write out file - with open(redirect_filepath, 'w') as redirect_fd: - redirect_fd.write(redirect_file) - logging.info("Created redirect file: %s", redirect_filename) - - self.aug.load() - # Make a new vhost data structure and add it to the lists - new_fp = self.server_root + "sites-available/" + redirect_filename - new_vhost = self._create_vhost(get_aug_path(new_fp)) - self.vhosts.append(new_vhost) - - # Finally create documentation for the change - self.save_notes += ('Created a port 80 vhost, %s, for redirection to ' - 'ssl vhost %s\n' % - (new_vhost.filep, ssl_vhost.filep)) - - return True, new_vhost - - def _conflicting_host(self, ssl_vhost): - """Checks for conflicting HTTP vhost for ssl_vhost. - - Checks for a conflicting host, such that a new port 80 host could not - be created without ruining the apache config - Used with redirection - - returns: conflict, host_or_addrs - boolean - if conflict: returns conflicting vhost - if not conflict: returns space separated list of new host addrs - - :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`VH` - - :returns: TODO - :rtype: TODO - - """ - # Consider changing this to a dictionary check - redirect_addrs = "" - for ssl_a in ssl_vhost.addrs: - # Add space on each new addr, combine "VirtualHost"+redirect_addrs - redirect_addrs = redirect_addrs + " " - ssl_tup = ssl_a.partition(":") - ssl_a_vhttp = ssl_tup[0] + ":80" - # Search for a conflicting host... - for vhost in self.vhosts: - if vhost.enabled: - for addr in vhost.addrs: - # Convert :* to standard ip address - if addr.endswith(":*"): - addr = addr[:len(addr)-2] - # Would require NameBasedVirtualHosts,too complicated? - # Maybe do later... right now just return false - # or overlapping addresses... order matters - if addr == ssl_a_vhttp or addr == ssl_tup[0]: - # We have found a conflicting host... just return - return True, vhost - - redirect_addrs = redirect_addrs + ssl_a_vhttp - - return False, redirect_addrs - - def _general_vhost(self, ssl_vhost): - """Find appropriate HTTP vhost for ssl_vhost. - - Function needs to be thoroughly tested and perhaps improved - Will not do well with malformed configurations - Consider changing this into a dict check - - :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`VH` - - :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`VH` or None - - """ - # _default_:443 check - # Instead... should look for vhost of the form *:80 - # Should we prompt the user? - ssl_addrs = ssl_vhost.addrs - if ssl_addrs == ["_default_:443"]: - ssl_addrs = ["*:443"] - - for vhost in self.vhosts: - found = 0 - # Not the same vhost, and same number of addresses - if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs): - # Find each address in ssl_host in test_host - for ssl_a in ssl_addrs: - ssl_tup = ssl_a.partition(":") - for test_a in vhost.addrs: - test_tup = test_a.partition(":") - if test_tup[0] == ssl_tup[0]: - # Check if found... - if (test_tup[2] == "80" or - test_tup[2] == "" or - test_tup[2] == "*"): - found += 1 - break - # Check to make sure all addresses were found - # and names are equal - if (found == len(ssl_vhost.addrs) and - set(vhost.names) == set(ssl_vhost.names)): - return vhost - return None - - # TODO - both of these - def enable_ocsp_stapling(self, ssl_vhost): - return False - - def enable_hsts(self, ssl_vhost): - return False - - def get_all_certs_keys(self): - """ Find all existing keys, certs from configuration. - - Retrieve all certs and keys set in VirtualHosts on the Apache server - - :returns: list of tuples with form [(cert, key, path)] - :rtype: list - - """ - c_k = set() - - for vhost in self.vhosts: - if vhost.ssl: - cert_path = self.find_directive( - case_i("SSLCertificateFile"), None, vhost.path) - key_path = self.find_directive( - case_i("SSLCertificateKeyFile"), None, vhost.path) - - # Can be removed once find directive can return ordered results - if len(cert_path) != 1 or len(key_path) != 1: - logging.error("Too many cert or key directives in vhost %s", - vhost.filep) - sys.exit(40) - - cert = os.path.abspath(self.aug.get(cert_path[0])) - key = os.path.abspath(self.aug.get(key_path[0])) - c_k.add((cert, key, get_file_path(cert_path[0]))) - - return c_k - - def is_site_enabled(self, avail_fp): - """Checks to see if the given site is enabled. - - .. todo:: fix hardcoded sites-enabled - - :param str avail_fp: Complete file path of available site - - :returns: Success - :rtype: bool - - """ - enabled_dir = os.path.join(self.server_root, "sites-enabled/") - for entry in os.listdir(enabled_dir): - if os.path.realpath(enabled_dir + entry) == avail_fp: - return True - - return False - - def enable_site(self, vhost): - """Enables an available site, Apache restart required. - - .. todo:: This function should number subdomains before the domain vhost - - .. todo:: Make sure link is not broken... - - :param vhost: vhost to enable - :type vhost: :class:`VH` - - :returns: Success - :rtype: bool - - """ - if self.is_site_enabled(vhost.filep): - return True - - if "/sites-available/" in vhost.filep: - enabled_path = ("%ssites-enabled/%s" % - (self.server_root, os.path.basename(vhost.filep))) - self.register_file_creation(False, enabled_path) - os.symlink(vhost.filep, enabled_path) - vhost.enabled = True - logging.info("Enabling available site: %s", vhost.filep) - self.save_notes += 'Enabled site %s\n' % vhost.filep - return True - return False - - def fnmatch_to_re(self, clean_fn_match): # pylint: disable=no-self-use - """Method converts Apache's basic fnmatch to regular expression. - - :param str clean_fn_match: Apache style filename match, similar to globs - - :returns: regex suitable for augeas - :rtype: str - - """ - regex = "" - for letter in clean_fn_match: - if letter == '.': - regex = regex + r"\." - elif letter == '*': - regex = regex + ".*" - # According to apache.org ? shouldn't appear - # but in case it is valid... - elif letter == '?': - regex = regex + "." - else: - regex = regex + letter - return regex - - def _parse_file(self, file_path): - """Parse file with Augeas - - Checks to see if file_path is parsed by Augeas - If file_path isn't parsed, the file is added and Augeas is reloaded - - :param str file_path: Apache config file path - - """ - # Test if augeas included file for Httpd.lens - # Note: This works for augeas globs, ie. *.conf - inc_test = self.aug.match( - "/augeas/load/Httpd/incl [. ='%s']" % file_path) - if not inc_test: - # Load up files - # self.httpd_incl.append(file_path) - # self.aug.add_transform("Httpd.lns", - # self.httpd_incl, None, self.httpd_excl) - self._add_httpd_transform(file_path) - self.aug.load() - - def standardize_excl(self): - """Standardize the excl arguments for the Httpd lens in Augeas. - - Note: Hack! - Standardize the excl arguments for the Httpd lens in Augeas - Servers sometimes give incorrect defaults - Note: This problem should be fixed in Augeas 1.0. Unfortunately, - Augeas 0.10 appears to be the most popular version currently. - - """ - # attempt to protect against augeas error in 0.10.0 - ubuntu - # *.augsave -> /*.augsave upon augeas.load() - # Try to avoid bad httpd files - # There has to be a better way... but after a day and a half of testing - # I had no luck - # This is a hack... work around... submit to augeas if still not fixed - - excl = ["*.augnew", "*.augsave", "*.dpkg-dist", "*.dpkg-bak", - "*.dpkg-new", "*.dpkg-old", "*.rpmsave", "*.rpmnew", - "*~", - self.server_root + "*.augsave", - self.server_root + "*~", - self.server_root + "*/*augsave", - self.server_root + "*/*~", - self.server_root + "*/*/*.augsave", - self.server_root + "*/*/*~"] - - for i in range(len(excl)): - self.aug.set("/augeas/load/Httpd/excl[%d]" % (i+1), excl[i]) - - self.aug.load() - - def restart(self, quiet=False): # pylint: disable=no-self-use - """Restarts apache server. - - :returns: Success - :rtype: bool - - """ - return apache_restart() - - def _add_httpd_transform(self, incl): - """Add a transform to Augeas. - - This function will correctly add a transform to augeas - The existing augeas.add_transform in python is broken. - - :param str incl: TODO - - """ - last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") - self.aug.insert(last_include[0], "incl", False) - self.aug.set("/augeas/load/Httpd/incl[last()]", incl) - - def config_test(self): - """Check the configuration of Apache for errors. - - :returns: Success - :rtype: bool - - """ - try: - proc = subprocess.Popen( - ['sudo', '/usr/sbin/apache2ctl', 'configtest'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - text = proc.communicate() - except (OSError, ValueError): - logging.fatal("Unable to run /usr/sbin/apache2ctl configtest") - sys.exit(1) - - if proc.returncode != 0: - # Enter recovery routine... - logging.error("Configtest failed") - logging.error(text[0]) - logging.error(text[1]) - return False - - return True - - def get_version(self): # pylint: disable=no-self-use - """Return version of Apache Server. - - Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) - - :returns: version - :rtype: tuple - - :raises errors.LetsEncryptConfiguratorError: - Unable to find Apache version - - """ - try: - proc = subprocess.Popen( - [CONFIG.APACHE_CTL, '-v'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - text = proc.communicate()[0] - except (OSError, ValueError): - raise errors.LetsEncryptConfiguratorError( - "Unable to run %s -v" % CONFIG.APACHE_CTL) - - regex = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) - matches = regex.findall(text) - - if len(matches) != 1: - raise errors.LetsEncryptConfiguratorError( - "Unable to find Apache version") - - return tuple([int(i) for i in matches[0].split('.')]) - - def verify_setup(self): - """Verify the setup to ensure safe operating environment. - - Make sure that files/directories are setup with appropriate permissions - Aim for defensive coding... make sure all input files - have permissions of root - - """ - uid = os.geteuid() - le_util.make_or_verify_dir(self.direc["config"], 0o755, uid) - le_util.make_or_verify_dir(self.direc["work"], 0o755, uid) - le_util.make_or_verify_dir(self.direc["backup"], 0o755, uid) - - ########################################################################### - # Challenges Section - ########################################################################### - - # TODO: Change list_sni_tuple to namedtuple. Also include key within tuple. - # This allows the keys to be different for each SNI challenge - - def perform(self, chall_dict): - """Perform the configuration related challenge. - - :param dict chall_dict: Dictionary representing a challenge. - - """ - - if chall_dict.get("type", "") == 'dvsni': - return self.dvsni_perform(chall_dict) - return None - - def dvsni_perform(self, chall_dict): - """Peform a DVSNI challenge. - - `chall_dict` composed of: - - list_sni_tuple: - List of tuples with form `(addr, r, nonce)`, where - `addr` (`str`), `r` (base64 `str`), `nonce` (hex `str`) - - dvsni_key: - DVSNI key (:class:`letsencrypt.client.client.Client.Key`) - - :param dict chall_dict: dvsni challenge - see documentation - - """ - # Save any changes to the configuration as a precaution - # About to make temporary changes to the config - self.save() - - # Do weak validation that challenge is of expected type - if not ("list_sni_tuple" in chall_dict and "dvsni_key" in chall_dict): - logging.fatal("Incorrect parameter given to Apache DVSNI challenge") - logging.fatal("Chall dict: %s", chall_dict) - sys.exit(1) - - addresses = [] - default_addr = "*:443" - for tup in chall_dict["list_sni_tuple"]: - vhost = self.choose_virtual_host(tup[0]) - if vhost is None: - logging.error( - "No vhost exists with servername or alias of: %s", tup[0]) - logging.error("No _default_:443 vhost exists") - logging.error("Please specify servernames in the Apache config") - return None - - # TODO - @jdkasten review this code to make sure it makes sense - self.make_server_sni_ready(vhost, default_addr) - - for addr in vhost.addrs: - if "_default_" in addr: - addresses.append([default_addr]) - break - else: - addresses.append(vhost.addrs) - - # Generate S - dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE) - # Create all of the challenge certs - for tup in chall_dict["list_sni_tuple"]: - # Need to decode from base64 - dvsni_r = le_util.jose_b64decode(tup[1]) - ext = dvsni_gen_ext(dvsni_r, dvsni_s) - self.dvsni_create_chall_cert( - tup[0], ext, tup[2], chall_dict["dvsni_key"]) - - self.dvsni_mod_config(chall_dict["list_sni_tuple"], - chall_dict["dvsni_key"], - addresses) - # Save reversible changes and restart the server - self.save("SNI Challenge", True) - self.restart(True) - - return {"type": "dvsni", "s": le_util.jose_b64encode(dvsni_s)} - - def cleanup(self): - """Revert all challenges.""" - - self.revert_challenge_config() - self.restart(True) - - # TODO: Variable names - def dvsni_mod_config(self, list_sni_tuple, dvsni_key, - ll_addrs): - """Modifies Apache config files to include challenge vhosts. - - Result: Apache config includes virtual servers for issued challs - - :param list list_sni_tuple: list of tuples with the form - `(addr, y, nonce)`, where `addr` is `str`, `y` is `bytearray`, - and nonce is hex `str` - - :param dvsni_key: DVSNI key - :type dvsni_key: :class:`letsencrypt.client.client.Client.Key` - - :param list ll_addrs: list of list of addresses to apply - - """ - # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY - # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER - # AND TAKEN OUT BEFORE RELEASE, INSTEAD - # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM - - # Check to make sure options-ssl.conf is installed - # pylint: disable=no-member - if not os.path.isfile(CONFIG.OPTIONS_SSL_CONF): - dist_conf = pkg_resources.resource_filename( - __name__, os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) - - # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = " \n" - for idx, lis in enumerate(ll_addrs): - config_text += self.get_config_text( - list_sni_tuple[idx][2], lis, dvsni_key.file) - config_text += " \n" - - self.dvsni_conf_include_check(self.location["default"]) - self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) - - with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: - new_conf.write(config_text) - - def dvsni_conf_include_check(self, main_config): - """Adds DVSNI challenge conf file into configuration. - - Adds DVSNI challenge include file if it does not already exist - within mainConfig - - :param str main_config: file path to main user apache config file - - """ - if len(self.find_directive( - case_i("Include"), CONFIG.APACHE_CHALLENGE_CONF)) == 0: - # print "Including challenge virtual host(s)" - self.add_dir(get_aug_path(main_config), - "Include", CONFIG.APACHE_CHALLENGE_CONF) - - def dvsni_create_chall_cert(self, name, ext, nonce, dvsni_key): - """Creates DVSNI challenge certifiate. - - Certificate created at self.dvsni_get_cert_file(nonce) - - :param str nonce: hex form of nonce - - :param dvsni_key: absolute path to key file - :type dvsni_key: `client.Client.Key` - - """ - self.register_file_creation(True, self.dvsni_get_cert_file(nonce)) - - cert_pem = crypto_util.make_ss_cert( - dvsni_key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) - - with open(self.dvsni_get_cert_file(nonce), 'w') as chall_cert_file: - chall_cert_file.write(cert_pem) - - def get_config_text(self, nonce, ip_addrs, dvsni_key_file): - """Chocolate virtual server configuration text - - :param str nonce: hex form of nonce - :param str ip_addrs: addresses of challenged domain - :param str dvsni_key_file: Path to key file - - :returns: virtual host configuration text - :rtype: str - - """ - return (" \n" - "ServerName " + nonce + CONFIG.INVALID_EXT + " \n" - "UseCanonicalName on \n" - "SSLStrictSNIVHostCheck on \n" - "\n" - "LimitRequestBody 1048576 \n" - "\n" - "Include " + self.location["ssl_options"] + " \n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + " \n" - "SSLCertificateKeyFile " + dvsni_key_file + " \n" - "\n" - "DocumentRoot " + self.direc["config"] + "challenge_page/ \n" - " \n\n") - - def dvsni_get_cert_file(self, nonce): - """Returns standardized name for challenge certificate. - - :param str nonce: hex form of nonce - - :returns: certificate file name - :rtype: str - - """ - return self.direc["work"] + nonce + ".crt" - - -def enable_mod(mod_name): - """Enables module in Apache. - - Both enables and restarts Apache so module is active. - - :param str mod_name: Name of the module to enable - - """ - try: - # Use check_output so the command will finish before reloading - subprocess.check_call(["sudo", "a2enmod", mod_name], - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) - # Hopefully this waits for output - subprocess.check_call(["sudo", CONFIG.APACHE2, "restart"], - stdout=open("/dev/null", 'w'), - stderr=open("/dev/null", 'w')) - except (OSError, subprocess.CalledProcessError) as err: - logging.error("Error enabling mod_%s", mod_name) - logging.error("Exception: %s", err) - sys.exit(1) - - -def check_ssl_loaded(): - """Checks to see if mod_ssl is loaded - - Currently uses apache2ctl to get loaded module list - - .. todo:: This function is likely fragile to versions/distros - - :returns: If ssl_module is included and active in Apache - :rtype: bool - - """ - try: - # p=subprocess.check_output(['sudo', '/usr/sbin/apache2ctl', '-M'], - # stderr=open("/dev/null", 'w')) - proc = subprocess.Popen([CONFIG.APACHE_CTL, '-M'], - stdout=subprocess.PIPE, - stderr=open( - "/dev/null", 'w')).communicate()[0] - except (OSError, ValueError): - logging.error( - "Error accessing %s for loaded modules!", CONFIG.APACHE_CTL) - logging.error("This may be caused by an Apache Configuration Error") - return False - - if "ssl_module" in proc: - return True - return False - - -def apache_restart(): - """Restarts the Apache Server. - - .. todo:: Try to use reload instead. (This caused timing problems before) - .. todo:: This should be written to use the process return code. - - """ - try: - proc = subprocess.Popen([CONFIG.APACHE2, 'restart'], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - text = proc.communicate() - - if proc.returncode != 0: - # Enter recovery routine... - logging.error("Configtest failed") - logging.error(text[0]) - logging.error(text[1]) - return False - - except (OSError, ValueError): - logging.fatal( - "Apache Restart Failed - Please Check the Configuration") - sys.exit(1) - - return True - - -def case_i(string): - """Returns case insensitive regex. - - Returns a sloppy, but necessary version of a case insensitive regex. - Any string should be able to be submitted and the string is - escaped and then made case insensitive. - May be replaced by a more proper /i once augeas 1.0 is widely - supported. - - :param str string: string to make case i regex - - """ - return "".join(["["+c.upper()+c.lower()+"]" - if c.isalpha() else c for c in re.escape(string)]) - - -def get_file_path(vhost_path): - """Get file path from augeas_vhost_path. - - Takes in Augeas path and returns the file name - - :param str vhost_path: Augeas virtual host path - - :returns: filename of vhost - :rtype: str - - """ - # Strip off /files - avail_fp = vhost_path[6:] - # This can be optimized... - while True: - # Cast both to lowercase to be case insensitive - find_if = avail_fp.lower().find("/ifmodule") - if find_if != -1: - avail_fp = avail_fp[:find_if] - continue - find_vh = avail_fp.lower().find("/virtualhost") - if find_vh != -1: - avail_fp = avail_fp[:find_vh] - continue - break - return avail_fp - - -def get_aug_path(file_path): - """Return augeas path for full filepath. - - :param str file_path: Full filepath - - """ - return "/files%s" % file_path - - -def strip_dir(path): - """Returns directory of file path. - - .. todo:: Replace this with Python standard function - - :param str path: path is a file path. not an augeas section or - directive path - - :returns: directory - :rtype: str - - """ - index = path.rfind("/") - if index > 0: - return path[:index+1] - # No directory - return "" - - -def dvsni_gen_ext(dvsni_r, dvsni_s): - """Generates z extension to be placed in certificate extension. - - :param bytearray dvsni_r: DVSNI r value - :param bytearray dvsni_s: DVSNI s value - - :returns: z + CONFIG.INVALID_EXT - :rtype: str - - """ - z_base = hashlib.new('sha256') - z_base.update(dvsni_r) - z_base.update(dvsni_s) - - return z_base.hexdigest() + CONFIG.INVALID_EXT - - -def main(): - """Main function used for quick testing purposes""" - - config = ApacheConfigurator() - - # for v in config.vhosts: - # print v.filep - # print v.addrs - # for name in v.names: - # print name - - print config.find_directive( - case_i("NameVirtualHost"), case_i("holla:443")) - - # for m in config.find_directive("Listen", "443"): - # print "Directive Path:", m, "Value:", config.aug.get(m) - - # for v in config.vhosts: - # for a in v.addrs: - # print "Address:",a, "- Is name vhost?", config.is_name_vhost(a) - - # print config.get_all_names() - - # test_file = "/home/james/Desktop/ports_test.conf" - # config._parse_file(test_file) - - # config.aug.insert("/files"+test_file+"/IfModule[1]/arg","directive",False) - # config.aug.set("/files"+test_file+"/IfModule[1]/directive[1]", "Listen") - # config.aug.set( - # "/files" +test_file+ "/IfModule[1]/directive[1]/arg", "556") - - # #config.save_notes = "Added listen 431 for test" - # #config.register_file_creation("/home/james/Desktop/new_file.txt") - # #config.save("Testing Saves", False) - # #config.recover_checkpoint(1) - - # # config.display_checkpoints() - config.config_test() - - # # Testing redirection and make_vhost_ssl - # ssl_vh = None - # for vh in config.vhosts: - # if not vh.addrs: - # print vh.names - # print vh.filep - # if vh.addrs[0] == "23.20.47.131:80": - # print "Here we go" - # ssl_vh = config.make_vhost_ssl(vh) - - # config.enable_redirect(ssl_vh) - - # for vh in config.vhosts: - # if len(vh.names) > 0: - # config.deploy_cert( - # vh, - # "/home/james/Documents/apache_choc/req.pem", - # "/home/james/Documents/apache_choc/key.pem", - # "/home/james/Downloads/sub.class1.server.ca.pem") - -if __name__ == "__main__": - main() diff --git a/letsencrypt/client/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 0ad813c8a..4d1caf61d 100644 --- a/letsencrypt/client/augeas_configurator.py +++ b/letsencrypt/client/augeas_configurator.py @@ -8,15 +8,12 @@ import time import augeas from letsencrypt.client import CONFIG -from letsencrypt.client import configurator from letsencrypt.client import le_util -class AugeasConfigurator(configurator.Configurator): +class AugeasConfigurator(object): """Base Augeas Configurator class. - .. todo:: Fix generic exception handling. - :ivar aug: Augeas object :type aug: :class:`augeas.Augeas` @@ -32,7 +29,6 @@ class AugeasConfigurator(configurator.Configurator): (used mostly for testing) """ - super(AugeasConfigurator, self).__init__() if not direc: direc = {"backup": CONFIG.BACKUP_DIR, @@ -247,7 +243,6 @@ class AugeasConfigurator(configurator.Configurator): le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) existing_filepaths = [] - op_fd = None filepaths_path = os.path.join(cp_dir, "FILEPATHS") # Open up FILEPATHS differently depending on if it already exists @@ -291,8 +286,7 @@ class AugeasConfigurator(configurator.Configurator): for idx, path in enumerate(filepaths): shutil.copy2(os.path.join( cp_dir, - os.path.basename(path) + '_' + str(idx)), - path) + os.path.basename(path) + '_' + str(idx)), path) except (IOError, OSError): # This file is required in all checkpoints. logging.error("Unable to recover files from %s", cp_dir) @@ -329,7 +323,7 @@ class AugeasConfigurator(configurator.Configurator): return True, "" - # pylint: disable=no-self-use + # pylint: disable=no-self-use, anomalous-backslash-in-string def register_file_creation(self, temporary, *files): """Register the creation of all files during letsencrypt execution. @@ -348,7 +342,7 @@ class AugeasConfigurator(configurator.Configurator): else: cp_dir = self.direc["progress"] - le_util.make_or_verify_dir(cp_dir) + le_util.make_or_verify_dir(cp_dir, 0o755, os.geteuid()) try: with open(os.path.join(cp_dir, "NEW_FILES"), 'a') as new_fd: for file_path in files: @@ -405,7 +399,7 @@ class AugeasConfigurator(configurator.Configurator): else: logging.warn( "File: %s - Could not be found to be deleted\n" - "Program was probably shut down unexpectedly, ") + "LE probably shut down unexpectedly", path) except (IOError, OSError): logging.fatal( "Unable to remove filepaths contained within %s", file_list) diff --git a/letsencrypt/client/auth_handler.py b/letsencrypt/client/auth_handler.py new file mode 100644 index 000000000..f33aede1c --- /dev/null +++ b/letsencrypt/client/auth_handler.py @@ -0,0 +1,441 @@ +"""ACME AuthHandler.""" +import logging +import sys + +from letsencrypt.client import acme +from letsencrypt.client import CONFIG +from letsencrypt.client import challenge_util +from letsencrypt.client import errors + + +class AuthHandler(object): + """ACME Authorization Handler for a client. + + :ivar dv_auth: Authenticator capable of solving CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar client_auth: Authenticator capable of solving CONFIG.CLIENT_CHALLENGES + :type client_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar network: Network object for sending and receiving authorization + messages + :type network: :class:`letsencrypt.client.network.Network` + + :ivar list domains: list of str domains to get authorization + :ivar dict authkey: Authorized Keys for each domain. + values are of type :class:`letsencrypt.client.client.Client.Key` + :ivar dict responses: keys: domain, values: list of dict responses + :ivar dict msgs: ACME Challenge messages with domain as a key + :ivar dict paths: optimal path for authorization. eg. paths[domain] + :ivar dict dv_c: Keys - domain, Values are DV challenges in the form of + :class:`letsencrypt.client.challenge_util.IndexedChall` + :ivar dict client_c: Keys - domain, Values are Client challenges in the form + of :class:`letsencrypt.client.challenge_util.IndexedChall` + + """ + def __init__(self, dv_auth, client_auth, network): + self.dv_auth = dv_auth + self.client_auth = client_auth + self.network = network + + self.domains = [] + self.authkey = dict() + self.responses = dict() + self.msgs = dict() + self.paths = dict() + + self.dv_c = dict() + self.client_c = dict() + + def add_chall_msg(self, domain, msg, authkey): + """Add a challenge message to the AuthHandler. + + :param str domain: domain for authorization + :param dict msg: ACME challenge message + + :param authkey: authorized key for the challenge + :type authkey: :class:`letsencrypt.client.client.Client.Key` + + """ + if domain in self.domains: + raise errors.LetsEncryptAuthHandlerError( + "Multiple ACMEChallengeMessages for the same domain " + "is not supported.") + self.domains.append(domain) + self.responses[domain] = ["null"] * len(msg["challenges"]) + self.msgs[domain] = msg + self.authkey[domain] = authkey + + def get_authorizations(self): + """Retreive all authorizations for challenges. + + :raises LetsEncryptAuthHandlerError: If unable to retrieve all + authorizations + + """ + progress = True + while self.msgs and progress: + progress = False + self._satisfy_challenges() + + delete_list = [] + + for dom in self.domains: + if self._path_satisfied(dom): + self.acme_authorization(dom) + delete_list.append(dom) + + # This avoids modifying while iterating over the list + if delete_list: + self._cleanup_state(delete_list) + progress = True + + if not progress: + raise errors.LetsEncryptAuthHandlerError( + "Unable to solve challenges for requested names.") + + def acme_authorization(self, domain): + """Handle ACME "authorization" phase. + + :param str domain: domain that is requesting authorization + + :returns: ACME "authorization" message. + :rtype: dict + + """ + try: + auth = self.network.send_and_receive_expected( + acme.authorization_request( + self.msgs[domain]["sessionID"], + domain, + self.msgs[domain]["nonce"], + self.responses[domain], + self.authkey[domain].pem), + "authorization") + logging.info("Received Authorization for %s", domain) + return auth + except errors.LetsEncryptClientError as err: + logging.fatal(str(err)) + logging.fatal( + "Failed Authorization procedure - cleaning up challenges") + sys.exit(1) + finally: + self._cleanup_challenges(domain) + + def _satisfy_challenges(self): + """Attempt to satisfy all saved challenge messages.""" + logging.info("Performing the following challenges:") + for dom in self.domains: + self.paths[dom] = gen_challenge_path( + self.msgs[dom]["challenges"], + self._get_chall_pref(dom), + self.msgs[dom].get("combinations", None)) + + self.dv_c[dom], self.client_c[dom] = self._challenge_factory( + dom, self.paths[dom]) + + # Flatten challs for authenticator functions and remove index + # Order is important here as we will not expose the outside + # Authenticator to our own indices. + flat_client = [] + flat_auth = [] + for dom in self.domains: + flat_client.extend(ichall.chall for ichall in self.client_c[dom]) + flat_auth.extend(ichall.chall for ichall in self.dv_c[dom]) + + client_resp = self.client_auth.perform(flat_client) + dv_resp = self.dv_auth.perform(flat_auth) + + logging.info("Ready for verification...") + + # Assemble Responses + self._assign_responses(client_resp, self.client_c) + self._assign_responses(dv_resp, self.dv_c) + + def _assign_responses(self, flat_list, ichall_dict): + """Assign responses from flat_list back to the IndexedChall dicts. + + :param list flat_list: flat_list of responses from an IAuthenticator + :param dict ichall_dict: Master dict mapping all domains to a list of + their associated 'client' and 'dv' IndexedChallenges, or their + :class:`letsencrypt.client.challenge_util.IndexedChall` list + + """ + flat_index = 0 + for dom in self.domains: + for ichall in ichall_dict[dom]: + self.responses[dom][ichall.index] = flat_list[flat_index] + flat_index += 1 + + def _path_satisfied(self, dom): + """Returns whether a path has been completely satisfied.""" + return all( + None != self.responses[dom][i] and "null" != self.responses[dom][i] + for i in self.paths[dom]) + + def _get_chall_pref(self, domain): + """Return list of challenge preferences. + + :param str domain: domain for which you are requesting preferences + + """ + chall_prefs = [] + chall_prefs.extend(self.client_auth.get_chall_pref(domain)) + chall_prefs.extend(self.dv_auth.get_chall_pref(domain)) + return chall_prefs + + def _cleanup_challenges(self, domain): + """Cleanup configuration challenges + + :param str domain: domain for which to clean up challenges + + """ + logging.info("Cleaning up challenges for %s", domain) + self.dv_auth.cleanup(self.dv_c[domain]) + self.client_auth.cleanup(self.client_c[domain]) + + def _cleanup_state(self, delete_list): + """Cleanup state after an authorization is received. + + :param list delete_list: list of domains in str form + + """ + for domain in delete_list: + del self.msgs[domain] + del self.responses[domain] + del self.paths[domain] + + del self.authkey[domain] + + del self.client_c[domain] + del self.dv_c[domain] + + self.domains.remove(domain) + + def _challenge_factory(self, domain, path): + """Construct Namedtuple Challenges + + :param str domain: domain of the enrollee + + :param list path: List of indices from `challenges`. + + :returns: dv_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + client_chall, list of + :class:`letsencrypt.client.challenge_util.IndexedChall` + :rtype: tuple + + :raises errors.LetsEncryptClientError: If Challenge type is not + recognized + + """ + challenges = self.msgs[domain]["challenges"] + + dv_chall = [] + client_chall = [] + + for index in path: + chall = challenges[index] + + # Authenticator Challenges + if chall["type"] in CONFIG.DV_CHALLENGES: + dv_chall.append(challenge_util.IndexedChall( + self._construct_dv_chall(chall, domain), index)) + + # Client Challenges + elif chall["type"] in CONFIG.CLIENT_CHALLENGES: + client_chall.append(challenge_util.IndexedChall( + self._construct_client_chall(chall, domain), index)) + + else: + raise errors.LetsEncryptClientError( + "Received unrecognized challenge of type: " + "%s" % chall["type"]) + + return dv_chall, client_chall + + def _construct_dv_chall(self, chall, domain): + """Construct Auth Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "dvsni": + logging.info(" DVSNI challenge for name %s.", domain) + return challenge_util.DvsniChall( + domain, str(chall["r"]), str(chall["nonce"]), + self.authkey[domain]) + + elif chall["type"] == "simpleHttps": + logging.info(" SimpleHTTPS challenge for name %s.", domain) + return challenge_util.SimpleHttpsChall( + domain, str(chall["token"]), self.authkey[domain]) + + elif chall["type"] == "dns": + logging.info(" DNS challenge for name %s.", domain) + return challenge_util.DnsChall( + domain, str(chall["token"]), self.authkey[domain]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Auth Challenge: %s" % chall["type"]) + + def _construct_client_chall(self, chall, domain): + """Construct Client Type Challenges. + + :param dict chall: Single challenge + :param str domain: challenge's domain + + :returns: challenge_util named tuple Chall object + :rtype: `collections.namedtuple` + + :raises errors.LetsEncryptClientError: If unimplemented challenge exists + + """ + if chall["type"] == "recoveryToken": + logging.info(" Recovery Token Challenge for name: %s.", domain) + return challenge_util.RecTokenChall(domain) + + elif chall["type"] == "recoveryContact": + logging.info(" Recovery Contact Challenge for name: %s.", domain) + return challenge_util.RecContactChall( + domain, + chall.get("activationURL", None), + chall.get("successURL", None), + chall.get("contact", None)) + + elif chall["type"] == "proofOfPossession": + logging.info(" Proof-of-Possession Challenge for name: " + "%s", domain) + return challenge_util.PopChall( + domain, chall["alg"], chall["nonce"], chall["hints"]) + + else: + raise errors.LetsEncryptClientError( + "Unimplemented Client Challenge: %s" % chall["type"]) + + +def gen_challenge_path(challenges, preferences, combos=None): + """Generate a plan to get authority over the identity. + + .. todo:: Make sure that the challenges are feasible... + Example: Do you have the recovery key? + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param list preferences: List of challenge preferences for domain + + :param combos: A collection of sets of challenges from ACME + "challenge" server message ("combinations"), each of which would + be sufficient to prove possession of the identifier. + :type combos: list or None + + :returns: List of indices from `challenges`. + :rtype: list + + """ + if combos: + return _find_smart_path(challenges, preferences, combos) + else: + return _find_dumb_path(challenges, preferences) + + +def _find_smart_path(challenges, preferences, combos): + """Find challenge path with server hints. + + Can be called if combinations is included. Function uses a simple + ranking system to choose the combo with the lowest cost. + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param combos: A collection of sets of challenges from ACME + "challenge" server message ("combinations"), each of which would + be sufficient to prove possession of the identifier. + :type combos: list or None + + :returns: List of indices from `challenges`. + :rtype: list + + """ + chall_cost = {} + max_cost = 0 + for i, chall in enumerate(preferences): + chall_cost[chall] = i + max_cost += i + + best_combo = [] + # Set above completing all of the available challenges + best_combo_cost = max_cost + 1 + + combo_total = 0 + for combo in combos: + for challenge_index in combo: + combo_total += chall_cost.get(challenges[ + challenge_index]["type"], max_cost) + if combo_total < best_combo_cost: + best_combo = combo + best_combo_cost = combo_total + combo_total = 0 + + if not best_combo: + logging.fatal("Client does not support any combination of " + "challenges to satisfy ACME server") + sys.exit(22) + + return best_combo + + +def _find_dumb_path(challenges, preferences): + """Find challenge path without server hints. + + Should be called if the combinations hint is not included by the + server. This function returns the best path that does not contain + multiple mutually exclusive challenges. + + :param list challenges: A list of challenges from ACME "challenge" + server message to be fulfilled by the client in order to prove + possession of the identifier. + + :param list preferences: A list of preferences representing the + challenge type found within the ACME spec. Each challenge type + can only be listed once. + + :returns: List of indices from `challenges`. + :rtype: list + + """ + # Add logic for a crappy server + # Choose a DV + path = [] + assert len(preferences) == len(set(preferences)) + for pref_c in preferences: + for i, offered_challenge in enumerate(challenges): + if (pref_c == offered_challenge["type"] and + is_preferred(offered_challenge["type"], path)): + path.append((i, offered_challenge["type"])) + + return [i for (i, _) in path] + + +def is_preferred(offered_challenge_type, path): + """Return whether or not the challenge is preferred in path.""" + for _, challenge_type in path: + for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: + # Second part is in case we eventually allow multiple names + # to be challenges at the same time + if (challenge_type in mutually_exclusive and + offered_challenge_type in mutually_exclusive and + challenge_type != offered_challenge_type): + return False + + return True diff --git a/letsencrypt/client/challenge.py b/letsencrypt/client/challenge.py deleted file mode 100644 index 44aabcda4..000000000 --- a/letsencrypt/client/challenge.py +++ /dev/null @@ -1,140 +0,0 @@ -"""ACME challenge.""" -import logging -import sys - -from letsencrypt.client import CONFIG - - -class Challenge(object): - """Let's Encrypt challenge.""" - - def __init__(self, configurator): - self.config = configurator - - def perform(self, quiet=True): - """Perform the challange. - - :param bool quiet: TODO - - """ - raise NotImplementedError() - - def generate_response(self): - """Generate response.""" - raise NotImplementedError() - - def cleanup(self): - """Cleanup.""" - raise NotImplementedError() - - -def gen_challenge_path(challenges, combos=None): - """Generate a plan to get authority over the identity. - - .. todo:: Make sure that the challenges are feasible... - Example: Do you have the recovery key? - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would - be sufficient to prove possession of the identifier. - :type combos: list or None - - :returns: List of indices from `challenges`. - :rtype: list - - """ - if combos: - return _find_smart_path(challenges, combos) - else: - return _find_dumb_path(challenges) - - -def _find_smart_path(challenges, combos): - """Find challenge path with server hints. - - Can be called if combinations is included. Function uses a simple - ranking system to choose the combo with the lowest cost. - - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :param combos: A collection of sets of challenges from ACME - "challenge" server message ("combinations"), each of which would - be sufficient to prove possession of the identifier. - :type combos: list or None - - :returns: List of indices from `challenges`. - :rtype: list - - """ - chall_cost = {} - max_cost = 0 - for i, chall in enumerate(CONFIG.CHALLENGE_PREFERENCES): - chall_cost[chall] = i - max_cost += i - - best_combo = [] - # Set above completing all of the available challenges - best_combo_cost = max_cost + 1 - - combo_total = 0 - for combo in combos: - for challenge_index in combo: - combo_total += chall_cost.get(challenges[ - challenge_index]["type"], max_cost) - if combo_total < best_combo_cost: - best_combo = combo - best_combo_cost = combo_total - combo_total = 0 - - if not best_combo: - logging.fatal("Client does not support any combination of " - "challenges to satisfy ACME server") - sys.exit(22) - - return best_combo - - -def _find_dumb_path(challenges): - """Find challange path without server hints. - - Should be called if the combinations hint is not included by the - server. This function returns the best path that does not contain - multiple mutually exclusive challenges. - - :param list challanges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. - - :returns: List of indices from `challenges`. - :rtype: list - - """ - # Add logic for a crappy server - # Choose a DV - path = [] - for pref_c in CONFIG.CHALLENGE_PREFERENCES: - for i, offered_challenge in enumerate(challenges): - if (pref_c == offered_challenge["type"] and - is_preferred(offered_challenge["type"], path)): - path.append((i, offered_challenge["type"])) - - return [i for (i, _) in path] - - -def is_preferred(offered_challenge_type, path): - for _, challenge_type in path: - for mutually_exclusive in CONFIG.EXCLUSIVE_CHALLENGES: - # Second part is in case we eventually allow multiple names - # to be challenges at the same time - if (challenge_type in mutually_exclusive and - offered_challenge_type in mutually_exclusive and - challenge_type != offered_challenge_type): - return False - - return True diff --git a/letsencrypt/client/challenge_util.py b/letsencrypt/client/challenge_util.py new file mode 100644 index 000000000..365d77edb --- /dev/null +++ b/letsencrypt/client/challenge_util.py @@ -0,0 +1,75 @@ +"""Challenge specific utility functions.""" +import collections +import hashlib + +from Crypto import Random + +from letsencrypt.client import CONFIG +from letsencrypt.client import crypto_util +from letsencrypt.client import le_util + +# Authenticator Challenges +DvsniChall = collections.namedtuple("DvsniChall", "domain, r_b64, nonce, key") +SimpleHttpsChall = collections.namedtuple( + "SimpleHttpsChall", "domain, token, key") +DnsChall = collections.namedtuple("DnsChall", "domain, token, key") + +# Client Challenges +RecContactChall = collections.namedtuple( + "RecContactChall", "domain, a_url, s_url, contact") +RecTokenChall = collections.namedtuple("RecTokenChall", "domain") +PopChall = collections.namedtuple("PopChall", "domain, alg, nonce, hints") + +# Helper Challenge Wrapper - Can be used to maintain the proper position of +# the response within a larger challenge list +IndexedChall = collections.namedtuple("IndexedChall", "chall, index") + + +# DVSNI Challenge functions +def dvsni_gen_cert(filepath, name, r_b64, nonce, key): + """Generate a DVSNI cert and save it to filepath. + + :param str filepath: destination to save certificate. This will overwrite + any file that is currently at the location. + :param str name: domain to validate + :param str r_b64: jose base64 encoded dvsni r value + :param str nonce: hex value of nonce + + :param key: Key to perform challenge + :type key: :class:`letsencrypt.client.client.Client.Key` + + :returns: dvsni s value jose base64 encoded + :rtype: str + + """ + # Generate S + dvsni_s = Random.get_random_bytes(CONFIG.S_SIZE) + dvsni_r = le_util.jose_b64decode(r_b64) + + # Generate extension + ext = _dvsni_gen_ext(dvsni_r, dvsni_s) + + cert_pem = crypto_util.make_ss_cert( + key.pem, [nonce + CONFIG.INVALID_EXT, name, ext]) + + with open(filepath, 'w') as chall_cert_file: + chall_cert_file.write(cert_pem) + + return le_util.jose_b64encode(dvsni_s) + + +def _dvsni_gen_ext(dvsni_r, dvsni_s): + """Generates z extension to be placed in certificate extension. + + :param bytearray dvsni_r: DVSNI r value + :param bytearray dvsni_s: DVSNI s value + + :returns: z + CONFIG.INVALID_EXT + :rtype: str + + """ + z_base = hashlib.new('sha256') + z_base.update(dvsni_r) + z_base.update(dvsni_s) + + return z_base.hexdigest() + CONFIG.INVALID_EXT diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index b6f507688..117dd143e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,27 +1,25 @@ """ACME protocol client class and helper functions.""" import collections import csv -import json import logging import os import shutil import socket import string import sys -import time -import jsonschema import M2Crypto -import requests +import zope.component from letsencrypt.client import acme -from letsencrypt.client import apache_configurator -from letsencrypt.client import challenge +from letsencrypt.client import auth_handler +from letsencrypt.client import client_authenticator from letsencrypt.client import CONFIG from letsencrypt.client import crypto_util -from letsencrypt.client import display from letsencrypt.client import errors +from letsencrypt.client import interfaces from letsencrypt.client import le_util +from letsencrypt.client import network # it's weird to point to chocolate servers via raw IPv6 addresses, and @@ -33,153 +31,92 @@ ALLOW_RAW_IPV6_SERVER = False class Client(object): """ACME protocol client. - :ivar config: Configurator. - :type config: :class:`letsencrypt.client.configurator.Configurator` - - :ivar str server: Certificate authority server - :ivar str server_url: Full URL of the CSR server - - :ivar csr: Certificate Signing Request - :type csr: :class:`CSR` + :ivar network: Network object for sending and receiving messages + :type network: :class:`letsencrypt.client.network.Network` :ivar list names: Domain names (:class:`list` of :class:`str`). - :ivar privkey: Private key - :type privkey: :class:`Key` + :ivar authkey: Authorization Key + :type authkey: :class:`letsencrypt.client.client.Client.Key` - :ivar bool use_curses: Use curses UI + :ivar auth_handler: Object that supports the IAuthenticator interface. + auth_handler contains both a dv_authenticator and a client_authenticator + :type auth_handler: :class:`letsencrypt.client.auth_handler.AuthHandler` + + :ivar installer: Object supporting the IInstaller interface. + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` """ + zope.interface.implements(interfaces.IAuthenticator) + Key = collections.namedtuple("Key", "file pem") - CSR = collections.namedtuple("CSR", "file data type") + CSR = collections.namedtuple("CSR", "file data form") - def __init__(self, server, csr=CSR(None, None, None), - privkey=Key(None, None), use_curses=True): - """Initialize a client.""" - self.server = server - self.server_url = "https://%s/acme/" % self.server - self.names = [] - self.use_curses = use_curses + def __init__(self, server, names, authkey, dv_auth, installer): + """Initialize a client. - self.csr = csr - self.privkey = privkey - self._validate_csr_key_cli() # TODO: catch exceptions - - # TODO: Can probably figure out which configurator to use - # without special packaging based on system info Command - # line arg or client function to discover - self.config = apache_configurator.ApacheConfigurator( - CONFIG.SERVER_ROOT) - - def authenticate(self, domains=None, eula=False, redirect=None): - """ - - :param list domains: List of domains - :param bool eula: EULA accepted - - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None - - :raises errors.LetsEncryptClientError: CSR does not contain one of the - specified names. + :param str server: CA server to contact + :param dv_auth: IAuthenticator Interface that can solve the + CONFIG.DV_CHALLENGES + :type dv_auth: :class:`letsencrypt.client.interfaces.IAuthenticator` """ - domains = [] if domains is None else domains + self.network = network.Network(server) + self.names = names + self.authkey = authkey - # Check configuration - if not self.config.config_test(): - sys.exit(1) + sanity_check_names([server] + names) - # Display preview warning - if not eula: - with open('EULA') as eula_file: - if not display.generic_yesno(eula_file.read(), - "Agree", "Cancel"): - sys.exit(0) + self.installer = installer - # Display screen to select domains to validate - if domains: - sanity_check_names([self.server] + domains) - self.names = domains - else: - # This function adds all names - # found within the config to self.names - # Then filters them based on user selection - code, self.names = display.filter_names(self.get_all_names()) - if code == display.OK and self.names: - # TODO: Allow multiple names once it is setup - self.names = [self.names[0]] - else: - sys.exit(0) + client_auth = client_authenticator.ClientAuthenticator(server) + self.auth_handler = auth_handler.AuthHandler( + dv_auth, client_auth, self.network) + def obtain_certificate(self, csr, + cert_path=CONFIG.CERT_PATH, + chain_path=CONFIG.CHAIN_PATH): + """Obtains a certificate from the ACME server. + + :param csr: A valid CSR in DER format for the certificate the client + intends to receive. + :type csr: :class:`CSR` + + :param str cert_path: Full desired path to end certificate. + :param str chain_path: Full desired path to end chain file. + + :returns: cert_file, chain_file (paths to respective files) + :rtype: `tuple` of `str` + + """ # Request Challenges - challenge_msg = self.acme_challenge() + for name in self.names: + self.auth_handler.add_chall_msg( + name, self.acme_challenge(name), self.authkey) - # Make sure we have key and csr to perform challenges - self.init_key_csr() - - # TODO: Handle this exception/problem - if not crypto_util.csr_matches_names(self.csr.data, self.names): - raise errors.LetsEncryptClientError( - "CSR subject does not contain one of the specified names") - - # Perform Challenges - responses, challenge_objs = self.verify_identity(challenge_msg) - # Get Authorization - self.acme_authorization(challenge_msg, challenge_objs, responses) + # Perform Challenges/Get Authorizations + self.auth_handler.get_authorizations() # Retrieve certificate - certificate_dict = self.acme_certificate(self.csr.data) + certificate_dict = self.acme_certificate(csr.data) - # Find set of virtual hosts to deploy certificates to - vhost = self.get_virtual_hosts(self.names) - - # Install Certificate - cert_file = self.install_certificate(certificate_dict, vhost) - - # Perform optimal config changes - self.optimize_config(vhost, redirect) - - self.config.save("Completed Let's Encrypt Authentication") + # Save Certificate + cert_file, chain_file = self.save_certificate( + certificate_dict, cert_path, chain_path) self.store_cert_key(cert_file, False) - def acme_challenge(self): - """Handle ACME "challenge" phase. + return cert_file, chain_file - .. todo:: Handle more than one domain name in self.names + def acme_challenge(self, domain): + """Handle ACME "challenge" phase. :returns: ACME "challenge" message. :rtype: dict """ - return self.send_and_receive_expected( - acme.challenge_request(self.names[0]), "challenge") - - def acme_authorization(self, challenge_msg, chal_objs, responses): - """Handle ACME "authorization" phase. - - :param dict challenge_msg: ACME "challenge" message. - - :param chal_objs: TODO - :param responses: TODO - - :returns: ACME "authorization" message. - :rtype: dict - - """ - auth_dict = self.send(acme.authorization_request( - challenge_msg["sessionID"], self.names[0], - challenge_msg["nonce"], responses, self.privkey.pem)) - - try: - return self.is_expected_msg(auth_dict, "authorization") - except: - logging.fatal( - "Failed Authorization procedure - cleaning up challenges") - sys.exit(1) - finally: - self.cleanup_challenges(chal_objs) + return self.network.send_and_receive_expected( + acme.challenge_request(domain), "challenge") def acme_certificate(self, csr_der): """Handle ACME "certificate" phase. @@ -191,207 +128,25 @@ class Client(object): """ logging.info("Preparing and sending CSR...") - return self.send_and_receive_expected( - acme.certificate_request(csr_der, self.privkey.pem), "certificate") + return self.network.send_and_receive_expected( + acme.certificate_request(csr_der, self.authkey.pem), "certificate") - def acme_revocation(self, cert): - """Handle ACME "revocation" phase. + # pylint: disable=no-self-use + def save_certificate(self, certificate_dict, cert_path, chain_path): + """Saves the certificate received from the ACME server. - :param dict cert: TODO + :param dict certificate_dict: certificate message from server + :param str cert_path: Path to attempt to save the cert file + :param str chain_path: Path to attempt to save the chain file - :returns: ACME "revocation" message. - :rtype: dict + :returns: cert_file, chain_file (absolute paths to the actual files) + :rtype: `tuple` of `str` - """ - cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() - with open(cert["backup_key_file"], 'rU') as backup_key_file: - key = backup_key_file.read() - - revocation = self.send_and_receive_expected( - acme.revocation_request(cert_der, key), "revocation") - - display.generic_notification( - "You have successfully revoked the certificate for " - "%s" % cert["cn"], width=70, height=9) - - remove_cert_key(cert) - self.list_certs_keys() - - return revocation - - def send(self, msg): - """Send ACME message to server. - - :param dict msg: ACME message (JSON serializable). - - :returns: Server response message. - :rtype: dict - - :raises TypeError: if `msg` is not JSON serializable - :raises jsonschema.ValidationError: if not valid ACME message - :raises errors.LetsEncryptClientError: in case of connection error - or if response from server is not a valid ACME message. - - """ - json_encoded = json.dumps(msg) - acme.acme_object_validate(json_encoded) - - try: - response = requests.post( - self.server_url, - data=json_encoded, - headers={"Content-Type": "application/json"}, - ) - except requests.exceptions.RequestException as error: - raise errors.LetsEncryptClientError( - 'Sending ACME message to server has failed: %s' % error) - - try: - acme.acme_object_validate(response.content) - except ValueError: - raise errors.LetsEncryptClientError( - 'Server did not send JSON serializable message') - except jsonschema.ValidationError as error: - raise errors.LetsEncryptClientError( - 'Response from server is not a valid ACME message') - - return response.json() - - def send_and_receive_expected(self, msg, expected): - """Send ACME message to server and return expected message. - - :param dict msg: ACME message (JSON serializable). - :param str expected: Name of the expected response ACME message type. - - :returns: ACME response message of expected type. - :rtype: dict - - :raises errors.LetsEncryptClientError: An exception is thrown - - """ - response = self.send(msg) - try: - return self.is_expected_msg(response, expected) - except: # TODO: too generic exception - raise errors.LetsEncryptClientError( - 'Expected message (%s) not received' % expected) - - def is_expected_msg(self, response, expected, delay=3, rounds=20): - """Is reponse expected ACME message? - - :param dict response: ACME response message from server. - - :param str expected: Name of the expected response ACME message type. - - :param int delay: Number of seconds to delay before next round - in case of ACME "defer" response message. - - :param int rounds: Number of resend attempts in case of ACME "defer" - reponse message. - - :returns: ACME response message from server. - :rtype: dict - - :raises LetsEncryptClientError: if server sent ACME "error" message - - """ - for _ in xrange(rounds): - if response["type"] == expected: - return response - - elif response["type"] == "error": - logging.error( - "%s: %s - More Info: %s", response["error"], - response.get("message", ""), response.get("moreInfo", "")) - raise errors.LetsEncryptClientError(response["error"]) - - elif response["type"] == "defer": - logging.info("Waiting for %d seconds...", delay) - time.sleep(delay) - response = self.send(acme.status_request(response["token"])) - else: - logging.fatal("Received unexpected message") - logging.fatal("Expected: %s" % expected) - logging.fatal("Received: " + response) - sys.exit(33) - - logging.error( - "Server has deferred past the max of %d seconds", rounds * delay) - - def list_certs_keys(self): - """List trusted Let's Encrypt certificates.""" - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - certs = [] - - if not os.path.isfile(list_file): - logging.info( - "You don't have any certificates saved from letsencrypt") - return - - c_sha1_vh = {} - for (cert, _, path) in self.config.get_all_certs_keys(): - try: - c_sha1_vh[M2Crypto.X509.load_cert( - cert).get_fingerprint(md='sha1')] = path - except: - continue - - with open(list_file, 'rb') as csvfile: - csvreader = csv.reader(csvfile) - for row in csvreader: - cert = crypto_util.get_cert_info(row[1]) - - b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, - os.path.basename(row[2]) + "_" + row[0]) - b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, - os.path.basename(row[1]) + "_" + row[0]) - - cert.update({ - "orig_key_file": row[2], - "orig_cert_file": row[1], - "idx": int(row[0]), - "backup_key_file": b_k, - "backup_cert_file": b_c, - "installed": c_sha1_vh.get(cert["fingerprint"], ""), - }) - certs.append(cert) - if certs: - self.choose_certs(certs) - else: - display.generic_notification( - "There are not any trusted Let's Encrypt " - "certificates for this server.") - - def choose_certs(self, certs): - """Display choose certificates menu. - - :param list certs: List of cert dicts. - - """ - code, tag = display.display_certs(certs) - - if code == display.OK: - cert = certs[tag] - if display.confirm_revocation(cert): - self.acme_revocation(cert) - else: - self.choose_certs(certs) - elif code == display.HELP: - cert = certs[tag] - display.more_info_cert(cert) - self.choose_certs(certs) - else: - exit(0) - - def install_certificate(self, certificate_dict, vhost): - """Install certificate - - :returns: Path to a certificate file. - :rtype: str + :raises IOError: If unable to find room to write the cert files """ cert_chain_abspath = None - cert_fd, cert_file = le_util.unique_file(CONFIG.CERT_PATH, 0o644) + cert_fd, cert_file = le_util.unique_file(cert_path, 0o644) cert_fd.write( crypto_util.b64_cert_to_pem(certificate_dict["certificate"])) cert_fd.close() @@ -399,7 +154,7 @@ class Client(object): "Server issued certificate; certificate written to %s", cert_file) if certificate_dict.get("chain", None): - chain_fd, chain_fn = le_util.unique_file(CONFIG.CHAIN_PATH, 0o644) + chain_fd, chain_fn = le_util.unique_file(chain_path, 0o644) for cert in certificate_dict.get("chain", []): chain_fd.write(crypto_util.b64_cert_to_pem(cert)) chain_fd.close() @@ -409,40 +164,58 @@ class Client(object): # This expects a valid chain file cert_chain_abspath = os.path.abspath(chain_fn) + return os.path.abspath(cert_file), cert_chain_abspath + + def deploy_certificate(self, privkey, cert_file, chain_file): + """Install certificate + + :returns: Path to a certificate file. + :rtype: str + + """ + # Find set of virtual hosts to deploy certificates to + vhost = self.get_virtual_hosts(self.names) + + chain = None if chain_file is None else os.path.abspath(chain_file) + for host in vhost: - self.config.deploy_cert(host, - os.path.abspath(cert_file), - os.path.abspath(self.privkey.file), - cert_chain_abspath) + self.installer.deploy_cert(host, + os.path.abspath(cert_file), + os.path.abspath(privkey.file), + chain) # Enable any vhost that was issued to, but not enabled if not host.enabled: logging.info("Enabling Site %s", host.filep) - self.config.enable_site(host) + self.installer.enable_site(host) + self.installer.save("Deployed Let's Encrypt Certificate") # sites may have been enabled / final cleanup - self.config.restart(quiet=self.use_curses) + self.installer.restart() - display.success_installation(self.names) + zope.component.getUtility( + interfaces.IDisplay).success_installation(self.names) - return cert_file + return vhost def optimize_config(self, vhost, redirect=None): """Optimize the configuration. + .. todo:: Handle multiple vhosts + :param vhost: vhost to optimize - :type vhost: :class:`apache_configurator.VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param redirect: If traffic should be forwarded from HTTP to HTTPS. :type redirect: bool or None """ - # TODO: this should most definitely be moved to __init__ if redirect is None: - redirect = display.redirect_by_default() + redirect = zope.component.getUtility( + interfaces.IDisplay).redirect_by_default() if redirect: self.redirect_to_ssl(vhost) - self.config.restart(quiet=self.use_curses) + self.installer.restart() # if self.ocsp_stapling is None: # q = ("Would you like to protect the privacy of your users " @@ -454,60 +227,6 @@ class Client(object): # # TODO enable OCSP Stapling # continue - def cleanup_challenges(self, challenges): - """Cleanup configuration challenges - - :param dict challenges: challenges from a challenge message - - """ - logging.info("Cleaning up challenges...") - for chall in challenges: - if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.config.cleanup() - else: - # Handle other cleanup if needed - pass - - def verify_identity(self, challenge_msg): - """Verify identity. - - :param dict challenge_msg: ACME "challenge" message. - - :returns: TODO - :rtype: dict - - """ - path = challenge.gen_challenge_path( - challenge_msg["challenges"], challenge_msg.get("combinations", [])) - - logging.info("Performing the following challenges:") - - # Every indices element is a list of integers referring to which - # challenges in the master list the challenge object satisfies - # Single Challenge objects that can satisfy multiple server challenges - # mess up the order of the challenges, thus requiring the indices - challenge_objs, indices = self.challenge_factory( - self.names[0], challenge_msg["challenges"], path) - - responses = ["null"] * len(challenge_msg["challenges"]) - - # Perform challenges - for i, c_obj in enumerate(challenge_objs): - response = "null" - if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - response = self.config.perform(c_obj) - else: - # Handle RecoveryToken type challenges - pass - - for index in indices[i]: - responses[index] = response - - logging.info( - "Configured Apache for challenges; waiting for verification...") - - return responses, challenge_objs - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -536,17 +255,17 @@ class Client(object): for row in csvreader: idx = int(row[0]) + 1 csvwriter = csv.writer(csvfile) - csvwriter.writerow([str(idx), cert_file, self.privkey.file]) + csvwriter.writerow([str(idx), cert_file, self.authkey.file]) else: with open(list_file, 'wb') as csvfile: csvwriter = csv.writer(csvfile) - csvwriter.writerow(["0", cert_file, self.privkey.file]) + csvwriter.writerow(["0", cert_file, self.authkey.file]) - shutil.copy2(self.privkey.file, + shutil.copy2(self.authkey.file, os.path.join( CONFIG.CERT_KEY_BACKUP, - os.path.basename(self.privkey.file) + "_" + str(idx))) + os.path.basename(self.authkey.file) + "_" + str(idx))) shutil.copy2(cert_file, os.path.join( CONFIG.CERT_KEY_BACKUP, @@ -558,16 +277,16 @@ class Client(object): """Redirect all traffic from HTTP to HTTPS :param vhost: list of ssl_vhosts - :type vhost: :class:`apache_configurator.VH` + :type vhost: :class:`letsencrypt.client.interfaces.IInstaller` """ for ssl_vh in vhost: - success, redirect_vhost = self.config.enable_redirect(ssl_vh) + success, redirect_vhost = self.installer.enable_redirect(ssl_vh) logging.info( "\nRedirect vhost: %s - %s ", redirect_vhost.filep, success) # If successful, make sure redirect site is enabled if success: - self.config.enable_site(redirect_vhost) + self.installer.enable_site(redirect_vhost) def get_virtual_hosts(self, domains): """Retrieve the appropriate virtual host for the domain @@ -575,187 +294,104 @@ class Client(object): :param list domains: Domains to find ssl vhosts for :returns: associated vhosts - :rtype: :class:`apache_configurator.VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ vhost = set() for name in domains: - host = self.config.choose_virtual_host(name) + host = self.installer.choose_virtual_host(name) if host is not None: vhost.add(host) return vhost - def challenge_factory(self, name, challenges, path): - """ - :param name: TODO +def validate_key_csr(privkey, csr): + """Validate CSR and key files. - :param list challenges: A list of challenges from ACME "challenge" - server message to be fulfilled by the client in order to prove - possession of the identifier. + Verifies that the client key and csr arguments are valid and correspond to + one another. This does not currently check the names in the CSR. - :param list path: List of indices from `challenges`. + :param privkey: Key associated with CSR + :type privkey: :class:`letsencrypt.client.client.Client.Key` - :returns: A pair of TODO - :rtype: tuple + :param csr: CSR + :type csr: :class:`letsencrypt.client.client.Client.CSR` - """ - sni_todo = [] - # Since a single invocation of SNI challenge can satisfy multiple - # challenges. We must keep track of all the challenges it satisfies - sni_satisfies = [] - - challenge_objs = [] - challenge_obj_indices = [] - for index in path: - chall = challenges[index] - - if chall["type"] == "dvsni": - logging.info(" DVSNI challenge for name %s.", name) - sni_satisfies.append(index) - sni_todo.append((str(name), str(chall["r"]), - str(chall["nonce"]))) - - elif chall["type"] == "recoveryToken": - logging.info("\tRecovery Token Challenge for name: %s.", name) - challenge_obj_indices.append(index) - challenge_objs.append({ - type: "recoveryToken", - }) - - else: - logging.fatal("Challenge not currently supported") - sys.exit(82) - - if sni_todo: - # SNI_Challenge can satisfy many sni challenges at once so only - # one "challenge object" is issued for all sni_challenges - challenge_objs.append({ - "type": "dvsni", - "list_sni_tuple": sni_todo, - "dvsni_key": self.privkey, - }) - challenge_obj_indices.append(sni_satisfies) - logging.debug(sni_todo) - - return challenge_objs, challenge_obj_indices - - def init_key_csr(self): - """Initializes privkey and csr. - - Inits key and CSR using provided files or generating new files - if necessary. Both will be saved in PEM format on the - filesystem. The CSR is placed into DER format to allow - the namedtuple to easily work with the protocol. - - """ - if not self.privkey.file: - key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE) - - # Save file - le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700) - key_f, key_filename = le_util.unique_file( - os.path.join(CONFIG.KEY_DIR, "key-letsencrypt.pem"), 0o600) - key_f.write(key_pem) - key_f.close() - - logging.info("Generating key: %s", key_filename) - - self.privkey = Client.Key(key_filename, key_pem) - - if not self.csr.file: - csr_pem, csr_der = crypto_util.make_csr( - self.privkey.pem, self.names) - - # Save CSR - le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) - csr_f, csr_filename = le_util.unique_file( - os.path.join(CONFIG.CERT_DIR, "csr-letsencrypt.pem"), 0o644) - csr_f.write(csr_pem) - csr_f.close() - - logging.info("Creating CSR: %s", csr_filename) - - self.csr = Client.CSR(csr_filename, csr_der, "der") - elif self.csr.type != "der": - # The user is going to pass in a pem format file - # That is why we must conver it to der since the - # protocol uses der exclusively. - csr_obj = M2Crypto.X509.load_request_string(self.csr.data) - self.csr = Client.CSR(self.csr.file, csr_obj.as_der(), "der") - - def _validate_csr_key_cli(self): - """Validate CSR and key files. - - Verifies that the client key and csr arguments are valid and - correspond to one another. - - :raises LetsEncryptClientError: if validation fails - - """ - # TODO: Handle all of these problems appropriately - # The client can eventually do things like prompt the user - # and allow the user to take more appropriate actions - - # If CSR is provided, it must be readable and valid. - if self.csr.data and not crypto_util.valid_csr(self.csr.data): - raise errors.LetsEncryptClientError( - "The provided CSR is not a valid CSR") - - # If key is provided, it must be readable and valid. - if (self.privkey.pem and - not crypto_util.valid_privkey(self.privkey.pem)): - raise errors.LetsEncryptClientError( - "The provided key is not a valid key") - - # If CSR and key are provided, the key must be the same key used - # in the CSR. - if self.csr.data and self.privkey.pem: - if not crypto_util.csr_matches_pubkey( - self.csr.data, self.privkey.pem): - raise errors.LetsEncryptClientError( - "The key and CSR do not match") - - def get_all_names(self): - """Return all valid names in the configuration.""" - names = list(self.config.get_all_names()) - sanity_check_names(names) - - if not names: - logging.fatal("No domain names were found in your apache config") - logging.fatal("Either specify which names you would like " - "letsencrypt to validate or add server names " - "to your virtual hosts") - sys.exit(1) - - return names - - -def remove_cert_key(cert): - """Remove certificate key. - - :param dict cert: + :raises LetsEncryptClientError: if validation fails """ - list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") - list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + # TODO: Handle all of these problems appropriately + # The client can eventually do things like prompt the user + # and allow the user to take more appropriate actions - with open(list_file, 'rb') as orgfile: - csvreader = csv.reader(orgfile) + if csr.form == "der": + csr_obj = M2Crypto.X509.load_request_der_string(csr.data) + csr = Client.CSR(csr.file, csr_obj.as_pem(), "der") - with open(list_file2, 'wb') as newfile: - csvwriter = csv.writer(newfile) + # If CSR is provided, it must be readable and valid. + if csr.data and not crypto_util.valid_csr(csr.data): + raise errors.LetsEncryptClientError( + "The provided CSR is not a valid CSR") - for row in csvreader: - if not (row[0] == str(cert["idx"]) and - row[1] == cert["orig_cert_file"] and - row[2] == cert["orig_key_file"]): - csvwriter.writerow(row) + # If key is provided, it must be readable and valid. + if privkey.pem and not crypto_util.valid_privkey(privkey.pem): + raise errors.LetsEncryptClientError( + "The provided key is not a valid key") - shutil.copy2(list_file2, list_file) - os.remove(list_file2) - os.remove(cert["backup_cert_file"]) - os.remove(cert["backup_key_file"]) + # If CSR and key are provided, the key must be the same key used + # in the CSR. + if csr.data and privkey.pem: + if not crypto_util.csr_matches_pubkey( + csr.data, privkey.pem): + raise errors.LetsEncryptClientError( + "The key and CSR do not match") + + +def init_key(): + """Initializes privkey. + + Inits key and CSR using provided files or generating new files + if necessary. Both will be saved in PEM format on the + filesystem. The CSR is placed into DER format to allow + the namedtuple to easily work with the protocol. + + """ + key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE) + + # Save file + le_util.make_or_verify_dir(CONFIG.KEY_DIR, 0o700) + key_f, key_filename = le_util.unique_file( + os.path.join(CONFIG.KEY_DIR, "key-letsencrypt.pem"), 0o600) + key_f.write(key_pem) + key_f.close() + + logging.info("Generating key: %s", key_filename) + + return Client.Key(key_filename, key_pem) + + +def init_csr(privkey, names): + """Initialize a CSR with the given private key.""" + + csr_pem, csr_der = crypto_util.make_csr(privkey.pem, names) + + # Save CSR + le_util.make_or_verify_dir(CONFIG.CERT_DIR, 0o755) + csr_f, csr_filename = le_util.unique_file( + os.path.join(CONFIG.CERT_DIR, "csr-letsencrypt.pem"), 0o644) + csr_f.write(csr_pem) + csr_f.close() + + logging.info("Creating CSR: %s", csr_filename) + + return Client.CSR(csr_filename, csr_der, "der") + + +def csr_pem_to_der(csr): + """Convert pem CSR to der.""" + + csr_obj = M2Crypto.X509.load_request_string(csr.data) + return Client.CSR(csr.file, csr_obj.as_der(), "der") def sanity_check_names(names): @@ -795,5 +431,5 @@ def is_hostname_sane(hostname): # is this a valid IPv6 address? socket.getaddrinfo(hostname, 443, socket.AF_INET6) return True - except: + except socket.error: return False diff --git a/letsencrypt/client/client_authenticator.py b/letsencrypt/client/client_authenticator.py new file mode 100644 index 000000000..1847d3760 --- /dev/null +++ b/letsencrypt/client/client_authenticator.py @@ -0,0 +1,49 @@ +"""Client Authenticator""" +import zope.interface + +from letsencrypt.client import challenge_util +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import recovery_token + + +class ClientAuthenticator(object): + """IAuthenticator for CONFIG.CLIENT_CHALLENGES. + + :ivar rec_token: Performs "recoveryToken" challenges + :type rec_token: :class:`letsencrypt.client.recovery_token.RecoveryToken` + + """ + zope.interface.implements(interfaces.IAuthenticator) + + # This will have an installer soon for get_key/cert purposes + def __init__(self, server): + """Initialize Client Authenticator. + + :param str server: ACME CA Server + + """ + self.rec_token = recovery_token.RecoveryToken(server) + + # pylint: disable=unused-argument,no-self-use + def get_chall_pref(self, domain): + """Return list of challenge preferences.""" + return ["recoveryToken"] + + def perform(self, chall_list): + """Perform client specific challenges for IAuthenticator""" + responses = [] + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + responses.append(self.rec_token.perform(chall)) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") + return responses + + def cleanup(self, chall_list): + """Cleanup call for IAuthenticator.""" + for chall in chall_list: + if isinstance(chall, challenge_util.RecTokenChall): + self.rec_token.cleanup(chall) + else: + raise errors.LetsEncryptClientAuthError("Unexpected Challenge") diff --git a/letsencrypt/client/configurator.py b/letsencrypt/client/configurator.py deleted file mode 100644 index c47557289..000000000 --- a/letsencrypt/client/configurator.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Configurator.""" - - -class Configurator(object): - """Generic Let's Encrypt configurator. - - Class represents all possible webservers and configuration editors - This includes the generic webserver which wont have configuration - files at all, but instead create a new process to handle the DVSNI - and other challenges. - """ - - def deploy_cert(self, vhost, cert, key, cert_chain=None): - """Deploy certificate. - - :param vhost - :param str cert: CSR - :param str key: Private key - - """ - raise NotImplementedError() - - def choose_virtual_host(self, name): - """Chooses a virtual host based on a given domain name.""" - raise NotImplementedError() - - def get_all_names(self): - """Returns all names found in the configuration.""" - raise NotImplementedError() - - def enable_redirect(self, ssl_vhost): - """Redirect all traffic to the given ssl_vhost (port 80 => 443).""" - raise NotImplementedError() - - def enable_hsts(self, ssl_vhost): - """Enable HSTS on the given ssl_vhost.""" - raise NotImplementedError() - - def enable_ocsp_stapling(self, ssl_vhost): - """Enable OCSP stapling on given ssl_vhost.""" - raise NotImplementedError() - - def get_all_certs_keys(self): - """Retrieve all certs and keys set in configuration. - - :returns: List of tuples with form [(cert, key, path)]. - :rtype: list - - """ - raise NotImplementedError() - - def enable_site(self, vhost): - """Enable the site at the given vhost.""" - raise NotImplementedError() - - def save(self, title=None, temporary=False): - """Saves all changes to the configuration files. - - Both title and temporary are needed because a save may be - intended to be permanent, but the save is not ready to be a full - checkpoint - - :param str title: The title of the save. If a title is given, the - configuration will be saved as a new checkpoint and put in a - timestamped directory. `title` has no effect if temporary is true. - - :param bool temporary: Indicates whether the changes made will - be quickly reversed in the future (challenges) - """ - raise NotImplementedError() - - def revert_challenge_config(self): - """Reload the users original configuration files.""" - raise NotImplementedError() - - def rollback_checkpoints(self, rollback=1): - """Revert `rollback` number of configuration checkpoints.""" - raise NotImplementedError() - - def display_checkpoints(self): - """Display the saved configuration checkpoints.""" - raise NotImplementedError() - - def config_test(self): - """Make sure the configuration is valid.""" - raise NotImplementedError() - - def restart(self, quiet=False): - """Restart or refresh the server content.""" - raise NotImplementedError() - - def perform(self, chall_dict): - """Perform the given challenge""" - raise NotImplementedError() - - def cleanup(self): - """Cleanup configuration changes from challenge.""" - raise NotImplementedError() diff --git a/letsencrypt/client/crypto_util.py b/letsencrypt/client/crypto_util.py index d19cbc0da..c11719343 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -1,6 +1,5 @@ """Let's Encrypt client crypto utility functions""" import binascii -import hashlib import logging import time @@ -15,13 +14,6 @@ from letsencrypt.client import CONFIG from letsencrypt.client import le_util -# TODO: All of these functions need unit tests - -def b64_cert_to_pem(b64_der_cert): - return M2Crypto.X509.load_cert_der_string( - le_util.jose_b64decode(b64_der_cert)).as_pem() - - def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): """Create signature with nonce prepended to the message. @@ -36,10 +28,10 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): :param str msg: Message to be signed :param nonce: Nonce to be used. If None, nonce of `nonce_len` size - will be randomly genereted. + will be randomly generated. :type nonce: str or None - :param int nonce_len: Size of the automaticaly generated nonce. + :param int nonce_len: Size of the automatically generated nonce. :returns: Signature. :rtype: dict @@ -55,8 +47,8 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): logging.debug('%s signed as %s', msg_with_nonce, signature) - n_bytes = binascii.unhexlify(leading_zeros(hex(key.n)[2:].rstrip("L"))) - e_bytes = binascii.unhexlify(leading_zeros(hex(key.e)[2:].rstrip("L"))) + n_bytes = binascii.unhexlify(_leading_zeros(hex(key.n)[2:].rstrip("L"))) + e_bytes = binascii.unhexlify(_leading_zeros(hex(key.e)[2:].rstrip("L"))) return { "nonce": le_util.jose_b64encode(nonce), @@ -70,33 +62,21 @@ def create_sig(msg, key_str, nonce=None, nonce_len=CONFIG.NONCE_SIZE): } -def leading_zeros(arg): +def _leading_zeros(arg): if len(arg) % 2: return "0" + arg return arg -def sha256(arg): - return hashlib.sha256(arg).hexdigest() - - -# based on M2Crypto unit test written by Toby Allsopp -def make_key(bits=CONFIG.RSA_KEY_SIZE): - """ - Returns new RSA key in PEM form with specified bits - """ - # Python Crypto module doesn't produce any stdout - key = Crypto.PublicKey.RSA.generate(bits) - # rsa = M2Crypto.RSA.gen_key(bits, 65537) - # key_pem = rsa.as_pem(cipher=None) - # rsa = None # should not be freed here - - return key.exportKey(format='PEM') - - def make_csr(key_str, domains): - """ - Returns new CSR in PEM and DER form using key_file containing all domains + """Generate a CSR. + + :param str key_str: RSA key. + :param list domains: Domains included in the certificate. + + :returns: new CSR in PEM and DER form containing all domains + :rtype: tuple + """ assert domains, "Must provide one or more hostnames for the CSR." rsa_key = M2Crypto.RSA.load_key_string(key_str) @@ -115,7 +95,7 @@ def make_csr(key_str, domains): extstack = M2Crypto.X509.X509_Extension_Stack() ext = M2Crypto.X509.new_extension( - 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains])) + 'subjectAltName', ", ".join("DNS:%s" % d for d in domains)) extstack.push(ext) csr.add_extensions(extstack) @@ -126,10 +106,82 @@ def make_csr(key_str, domains): return csr.as_pem(), csr.as_der() -def make_ss_cert(key_str, domains): +# WARNING: the csr and private key file are possible attack vectors for TOCTOU +# We should either... +# A. Do more checks to verify that the CSR is trusted/valid +# B. Audit the parsing code for vulnerabilities + +def valid_csr(csr): + """Validate CSR. + + Check if `csr` is a valid CSR for the given domains. + + :param str csr: CSR in PEM. + + :returns: Validity of CSR. + :rtype: bool + + """ + try: + csr_obj = M2Crypto.X509.load_request_string(csr) + return bool(csr_obj.verify(csr_obj.get_pubkey())) + except M2Crypto.X509.X509Error: + return False + + +def csr_matches_pubkey(csr, privkey): + """Does private key correspond to the subject public key in the CSR? + + :param str csr: CSR in PEM. + :param str privkey: Private key file contents + + :returns: Correspondence of private key to CSR subject public key. + :rtype: bool + + """ + csr_obj = M2Crypto.X509.load_request_string(csr) + privkey_obj = M2Crypto.RSA.load_key_string(privkey) + return csr_obj.get_pubkey().get_rsa().pub() == privkey_obj.pub() + + +# based on M2Crypto unit test written by Toby Allsopp +def make_key(bits=CONFIG.RSA_KEY_SIZE): + """Generate PEM encoded RSA key. + + :param int bits: Number of bits, at least 1024. + + :returns: new RSA key in PEM form with specified number of bits + :rtype: str + + """ + # rsa = M2Crypto.RSA.gen_key(bits, 65537) + # key_pem = rsa.as_pem(cipher=None) + # rsa = None # should not be freed here + # Python Crypto module doesn't produce any stdout + return Crypto.PublicKey.RSA.generate(bits).exportKey(format='PEM') + + +def valid_privkey(privkey): + """Is valid RSA private key? + + :param str privkey: Private key file contents + + :returns: Validity of private key. + :rtype: bool + + """ + try: + return bool(M2Crypto.RSA.load_key_string(privkey).check_key()) + except M2Crypto.RSA.RSAError: + return False + + +def make_ss_cert(key_str, domains, not_before=None, + validity=(7 * 24 * 60 * 60)): """Returns new self-signed cert in PEM form. Uses key_str and contains all domains. + """ assert domains, "Must provide one or more hostnames for the CSR." @@ -142,11 +194,11 @@ def make_ss_cert(key_str, domains): cert.set_serial_number(1337) cert.set_version(2) - current_ts = long(time.time()) + current_ts = long(time.time() if not_before is None else not_before) current = M2Crypto.ASN1.ASN1_UTCTIME() current.set_time(current_ts) expire = M2Crypto.ASN1.ASN1_UTCTIME() - expire.set_time((7 * 24 * 60 * 60) + current_ts) + expire.set_time(current_ts + validity) cert.set_not_before(current) cert.set_not_after(expire) @@ -158,11 +210,13 @@ def make_ss_cert(key_str, domains): subject.CN = domains[0] cert.set_issuer(cert.get_subject()) - cert.add_ext(M2Crypto.X509.new_extension('basicConstraints', 'CA:FALSE')) - # cert.add_ext(M2Crypto.X509.new_extension( - # 'extendedKeyUsage', 'TLS Web Server Authentication')) - cert.add_ext(M2Crypto.X509.new_extension( - 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) + if len(domains) > 1: + cert.add_ext(M2Crypto.X509.new_extension( + 'basicConstraints', 'CA:FALSE')) + # cert.add_ext(M2Crypto.X509.new_extension( + # 'extendedKeyUsage', 'TLS Web Server Authentication')) + cert.add_ext(M2Crypto.X509.new_extension( + 'subjectAltName', ", ".join(["DNS:%s" % d for d in domains]))) cert.sign(pubkey, 'sha256') assert cert.verify(pubkey) @@ -200,75 +254,6 @@ def get_cert_info(filename): } -# WARNING: the csr and private key file are possible attack vectors for TOCTOU -# We should either... -# A. Do more checks to verify that the CSR is trusted/valid -# B. Audit the parsing code for vulnerabilities - -def valid_csr(csr): - """Validate CSR. - - Check if `csr` is a valid CSR for the given domains. - - :param str csr: CSR file contents - - :returns: Validity of CSR. - :rtype: bool - - """ - try: - csr_obj = M2Crypto.X509.load_request_string(csr) - return bool(csr_obj.verify(csr_obj.get_pubkey())) - except M2Crypto.X509.X509Error: - return False - - -def csr_matches_names(csr, domains): - """Check if CSR contains the subject of one of the domains. - - M2Crypto currently does not expose the OpenSSL interface to - also check the SAN extension. This is insufficient for full testing - - :param str csr: CSR file contents - - :param list domains: Domains the CSR should contain. - - :returns: If the CSR subject contains one of the domains - :rtype: bool - - """ - try: - csr_obj = M2Crypto.X509.load_request_der_string(csr) - return csr_obj.get_subject().CN in domains - except M2Crypto.X509.X509Error: - return False - - -def valid_privkey(privkey): - """Is valid RSA private key? - - :param str privkey: Private key file contents - - :returns: Validity of private key. - :rtype: bool - - """ - try: - return bool(M2Crypto.RSA.load_key_string(privkey).check_key()) - except M2Crypto.RSA.RSAError: - return False - - -def csr_matches_pubkey(csr, privkey): - """Does private key correspond to the subject public key in the CSR? - - :param str csr: CSR file contents - :param str privkey: Private key file contents - - :returns: Correspondence of private key to CSR subject public key. - :rtype: bool - - """ - csr_obj = M2Crypto.X509.load_request_string(csr) - privkey_obj = M2Crypto.RSA.load_key_string(privkey) - return csr_obj.get_pubkey().get_rsa().pub() == privkey_obj.pub() +def b64_cert_to_pem(b64_der_cert): + return M2Crypto.X509.load_cert_der_string( + le_util.jose_b64decode(b64_der_cert)).as_pem() diff --git a/letsencrypt/client/display.py b/letsencrypt/client/display.py index ac1f9d819..b25e432ee 100644 --- a/letsencrypt/client/display.py +++ b/letsencrypt/client/display.py @@ -1,73 +1,37 @@ import textwrap import dialog +import zope.interface + +from letsencrypt.client import interfaces WIDTH = 72 HEIGHT = 20 -class SingletonD(object): - _instance = None +class NcursesDisplay(object): + zope.interface.implements(interfaces.IDisplay) - def __new__(cls, *args, **kwargs): - if not cls._instance: - cls._instance = super(SingletonD, cls).__new__( - cls, *args, **kwargs) - return cls._instance - - -class Display(SingletonD): - """Generic display.""" - - def generic_notification(self, message, width=WIDTH, height=HEIGHT): - raise NotImplementedError() - - def generic_menu(self, message, choices, input_text="", - width=WIDTH, height=HEIGHT): - raise NotImplementedError() - - def generic_input(self, message): - raise NotImplementedError() - - def generic_yesno(self, message, yes_label="Yes", no_label="No"): - raise NotImplementedError() - - def filter_names(self, names): - raise NotImplementedError() - - def success_installation(self, domains): - raise NotImplementedError() - - def display_certs(self, certs): - raise NotImplementedError() - - def confirm_revocation(self, cert): - raise NotImplementedError() - - def more_info_cert(self, cert): - raise NotImplementedError() - - -class NcursesDisplay(Display): - - def __init__(self): + def __init__(self, width=WIDTH, height=HEIGHT): + super(NcursesDisplay, self).__init__() self.dialog = dialog.Dialog() + self.width = width + self.height = height - def generic_notification(self, message, w=WIDTH, h=HEIGHT): - self.dialog.msgbox(message, width=w, height=h) + def generic_notification(self, message): + self.dialog.msgbox(message, width=self.width) - def generic_menu(self, message, choices, input_text="", width=WIDTH, - height=HEIGHT): + def generic_menu(self, message, choices, input_text=""): # Can accept either tuples or just the actual choices if choices and isinstance(choices[0], tuple): code, selection = self.dialog.menu( - message, choices=choices, width=WIDTH, height=HEIGHT) + message, choices=choices, width=self.width, height=self.height) return code, str(selection) else: choices = list(enumerate(choices, 1)) code, tag = self.dialog.menu( - message, choices=choices, width=WIDTH, height=HEIGHT) + message, choices=choices, width=self.width, height=self.height) return code(int(tag) - 1) @@ -76,7 +40,7 @@ class NcursesDisplay(Display): def generic_yesno(self, message, yes="Yes", no="No"): return self.dialog.DIALOG_OK == self.dialog.yesno( - message, HEIGHT, WIDTH, yes_label=yes, no_label=no) + message, self.height, self.width, yes_label=yes, no_label=no) def filter_names(self, names): choices = [(n, "", 0) for n in names] @@ -88,12 +52,12 @@ class NcursesDisplay(Display): def success_installation(self, domains): self.dialog.msgbox( "\nCongratulations! You have successfully enabled " - + gen_https_names(domains) + "!", width=WIDTH) + + gen_https_names(domains) + "!", width=self.width) def display_certs(self, certs): list_choices = [ (str(i+1), "%s | %s | %s" % - (str(c["cn"].ljust(WIDTH - 39)), + (str(c["cn"].ljust(self.width - 39)), c["not_before"].strftime("%m-%d-%y"), "Installed" if c["installed"] else "")) for i, c in enumerate(certs)] @@ -102,7 +66,7 @@ class NcursesDisplay(Display): "Which certificates would you like to revoke?", choices=list_choices, help_button=True, help_label="More Info", ok_label="Revoke", - width=WIDTH, height=HEIGHT) + width=self.width, height=self.height) if not tag: tag = -1 return code, (int(tag) - 1) @@ -113,35 +77,51 @@ class NcursesDisplay(Display): text += cert_info_frame(cert) text += "This action cannot be reversed!" return self.dialog.DIALOG_OK == self.dialog.yesno( - text, width=WIDTH, height=HEIGHT) + text, width=self.width, height=self.height) def more_info_cert(self, cert): text = "Certificate Information:\n" text += cert_info_frame(cert) print text - self.dialog.msgbox(text, width=WIDTH, height=HEIGHT) + self.dialog.msgbox(text, width=self.width, height=self.height) + + def redirect_by_default(self): + choices = [ + ("Easy", "Allow both HTTP and HTTPS access to these sites"), + ("Secure", "Make all requests redirect to secure HTTPS access")] + + result = self.generic_menu( + "Please choose whether HTTPS access is required or optional.", + choices, "Please enter the appropriate number") + + if result[0] != OK: + return False + + # different answer for each type of display + return str(result[1]) == "Secure" or result[1] == 1 -class FileDisplay(Display): +class FileDisplay(object): + zope.interface.implements(interfaces.IDisplay) def __init__(self, outfile): + super(FileDisplay, self).__init__() self.outfile = outfile - def generic_notification(self, message, width=WIDTH, height=HEIGHT): - side_frame = '-' * (79) + def generic_notification(self, message): + side_frame = '-' * 79 wm = textwrap.fill(message, 80) text = "\n%s\n%s\n%s\n" % (side_frame, wm, side_frame) self.outfile.write(text) raw_input("Press Enter to Continue") - def generic_menu(self, message, choices, input_text="", - width=WIDTH, height=HEIGHT): + def generic_menu(self, message, choices, input_text=""): # Can take either tuples or single items in choices list if choices and isinstance(choices[0], tuple): choices = ["%s - %s" % (c[0], c[1]) for c in choices] self.outfile.write("\n%s\n" % message) - side_frame = '-' * (79) + side_frame = '-' * 79 self.outfile.write("%s\n" % side_frame) for i, choice in enumerate(choices, 1): @@ -202,7 +182,7 @@ class FileDisplay(Display): else: try: selection = int(ans) - # TODO add check to make sure it is liess than max + # TODO add check to make sure it is less than max if selection < 0: self.outfile.write(e_msg) continue @@ -214,7 +194,7 @@ class FileDisplay(Display): return code, selection def success_installation(self, domains): - s_f = '*' * (79) + s_f = '*' * 79 wm = textwrap.fill(("Congratulations! You have successfully " + "enabled %s!") % gen_https_names(domains)) msg = "%s\n%s\n%s\n" @@ -232,41 +212,11 @@ class FileDisplay(Display): self.outfile.write("\nCertificate Information:\n") self.outfile.write(cert_info_frame(cert)) -display = None OK = "ok" CANCEL = "cancel" HELP = "help" -def set_display(display_inst): - global display - display = display_inst - - -def generic_notification(message, width=WIDTH, height=HEIGHT): - display.generic_notification(message, width, height) - - -def generic_menu(message, choices, input_text="", width=WIDTH, height=HEIGHT): - return display.generic_menu(message, choices, input_text, width, height) - - -def generic_input(message): - return display.generic_message(message) - - -def generic_yesno(message, yes_label="Yes", no_label="No"): - return display.generic_yesno(message, yes_label, no_label) - - -def filter_names(names): - return display.filter_names(names) - - -def display_certs(certs): - return display.display_certs(certs) - - def cert_info_frame(cert): text = "-" * (WIDTH - 4) + "\n" text += cert_info_string(cert) @@ -303,33 +253,3 @@ def gen_https_names(domains): result = result + "https://" + domains[len(domains)-1] return result - - -def success_installation(domains): - return display.success_installation(domains) - - -def redirect_by_default(): - choices = [ - ("Easy", "Allow both HTTP and HTTPS access to these sites"), - ("Secure", "Make all requests redirect to secure HTTPS access")] - - result = display.generic_menu("Please choose whether HTTPS access " + - "is required or optional.", - choices, - "Please enter the appropriate number", - width=WIDTH) - - if result[0] != OK: - return False - - # different answer for each type of display - return str(result[1]) == "Secure" or result[1] == 1 - - -def confirm_revocation(cert): - return display.confirm_revocation(cert) - - -def more_info_cert(cert): - return display.more_info_cert(cert) diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index dddfc5e4e..ec046c0a5 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -5,8 +5,16 @@ class LetsEncryptClientError(Exception): """Generic Let's Encrypt client error.""" +class LetsEncryptAuthHandlerError(LetsEncryptClientError): + """Let's Encrypt Auth Handler error.""" + + +class LetsEncryptClientAuthError(LetsEncryptAuthHandlerError): + """Let's Encrypt Client Authenticator Error.""" + + class LetsEncryptConfiguratorError(LetsEncryptClientError): - """Let's Encrypt configurator error.""" + """Let's Encrypt Configurator error.""" class LetsEncryptDvsniError(LetsEncryptConfiguratorError): diff --git a/letsencrypt/client/interactive_challenge.py b/letsencrypt/client/interactive_challenge.py index 4f93f1e4f..c802ca191 100644 --- a/letsencrypt/client/interactive_challenge.py +++ b/letsencrypt/client/interactive_challenge.py @@ -1,12 +1,13 @@ import textwrap import dialog +import zope.interface -from letsencrypt.client import challenge +from letsencrypt.client import interfaces -class InteractiveChallenge(challenge.Challenge): - """Interactive challange. +class InteractiveChallenge(object): + """Interactive challenge. Interactive challenge displays the string sent by the CA formatted to fit on the screen of the client. The Challenge also adds proper @@ -14,9 +15,12 @@ class InteractiveChallenge(challenge.Challenge): process. """ + zope.interface.implements(interfaces.IChallenge) + BOX_SIZE = 70 def __init__(self, string): + super(InteractiveChallenge, self).__init__() self.string = string def perform(self, quiet=True): diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py new file mode 100644 index 000000000..be3c6e09f --- /dev/null +++ b/letsencrypt/client/interfaces.py @@ -0,0 +1,174 @@ +"""Let's Encrypt client interfaces.""" +import zope.interface + +# pylint: disable=no-self-argument,no-method-argument + + +class IAuthenticator(zope.interface.Interface): + """Generic Let's Encrypt Authenticator. + + Class represents all possible tools processes that have the + ability to perform challenges and attain a certificate. + + """ + def get_chall_pref(domain): + """Return list of challenge preferences. + + :param str domain: Domain for which challenge preferences are sought. + + :returns: list of strings with the most preferred challenges first. + :rtype: list + + """ + def perform(chall_list): + """Perform the given challenge. + + :param list chall_list: List of challenge types defined in client.py + + :returns: List of responses + If the challenge cant be completed... + None - Authenticator can perform challenge, but can't at this time + False - Authenticator will never be able to perform (error) + :rtype: `list` of dicts + + """ + def cleanup(chall_list): + """Revert changes and shutdown after challenges complete.""" + + +class IChallenge(zope.interface.Interface): + """Let's Encrypt challenge.""" + + def perform(): + """Perform the challenge.""" + + def generate_response(): + """Generate response.""" + + def cleanup(): + """Cleanup.""" + + +class IInstaller(zope.interface.Interface): + """Generic Let's Encrypt Installer Interface. + + Represents any server that an X509 certificate can be placed. + With a focus on HTTPS optimizations. + + .. todo:: All optimizations should be of the form .enable("hsts") + This will make it general towards any optimization... we should also + define a function to glean what optimizations are available. + Perhaps with text that describes the optimizations... + + """ + def get_all_names(): + """Returns all names that may be authenticated.""" + + def deploy_cert(vhost, cert, key, cert_chain=None): + """Deploy certificate. + + :param vhost + :param str cert: CSR + :param str key: Private key + + """ + + def choose_virtual_host(name): + """Chooses a virtual host based on a given domain name.""" + + def enable_redirect(ssl_vhost): + """Redirect all traffic to the given ssl_vhost (port 80 => 443).""" + + def enable_hsts(ssl_vhost): + """Enable HSTS on the given ssl_vhost.""" + + def enable_ocsp_stapling(ssl_vhost): + """Enable OCSP stapling on given ssl_vhost.""" + + def get_all_certs_keys(): + """Retrieve all certs and keys set in configuration. + + :returns: List of tuples with form [(cert, key, path)]. + :rtype: list + + """ + + def enable_site(vhost): + """Enable the site at the given vhost.""" + + def save(title=None, temporary=False): + """Saves all changes to the configuration files. + + Both title and temporary are needed because a save may be + intended to be permanent, but the save is not ready to be a full + checkpoint + + :param str title: The title of the save. If a title is given, the + configuration will be saved as a new checkpoint and put in a + timestamped directory. `title` has no effect if temporary is true. + + :param bool temporary: Indicates whether the changes made will + be quickly reversed in the future (challenges) + """ + + def rollback_checkpoints(rollback=1): + """Revert `rollback` number of configuration checkpoints.""" + + def display_checkpoints(): + """Display the saved configuration checkpoints.""" + + def config_test(): + """Make sure the configuration is valid.""" + + def restart(): + """Restart or refresh the server content.""" + + +class IDisplay(zope.interface.Interface): + """Generic display.""" + + def generic_notification(message): + pass + + def generic_menu(message, choices, input_text=""): + pass + + def generic_input(message): + pass + + def generic_yesno(message, yes_label="Yes", no_label="No"): + pass + + def filter_names(names): + pass + + def success_installation(domains): + pass + + def display_certs(certs): + pass + + def confirm_revocation(cert): + pass + + def more_info_cert(cert): + pass + + def redirect_by_default(): + pass + + +class IValidator(object): + """Configuration validator.""" + + def redirect(name): + pass + + def ocsp_stapling(name): + pass + + def https(names): + pass + + def hsts(name): + pass diff --git a/letsencrypt/client/le_util.py b/letsencrypt/client/le_util.py index 42f88bc5d..08b0f6114 100644 --- a/letsencrypt/client/le_util.py +++ b/letsencrypt/client/le_util.py @@ -10,8 +10,8 @@ from letsencrypt.client import errors def make_or_verify_dir(directory, mode=0o755, uid=0): """Make sure directory exists with proper permissions. - :param str directory: Path to a directry. - :param int mode: Diretory mode. + :param str directory: Path to a directory. + :param int mode: Directory mode. :param int uid: Directory owner. :raises LetsEncryptClientError: if a directory already exists, @@ -28,7 +28,7 @@ def make_or_verify_dir(directory, mode=0o755, uid=0): if exception.errno == errno.EEXIST: if not check_permissions(directory, mode, uid): raise errors.LetsEncryptClientError( - '%s exists and does not contain the proper ' + '%s exists, but does not have the proper ' 'permissions or owner' % directory) else: raise @@ -62,9 +62,9 @@ def unique_file(default_name, mode=0o777): f_parsed = os.path.splitext(default_name) while 1: try: - fd = os.open( + file_d = os.open( default_name, os.O_CREAT | os.O_EXCL | os.O_RDWR, mode) - return os.fdopen(fd, 'w'), default_name + return os.fdopen(file_d, 'w'), default_name except OSError: pass default_name = f_parsed[0] + '_' + str(count) + f_parsed[1] @@ -107,7 +107,7 @@ def jose_b64decode(data): :returns: Decoded data. :raises TypeError: if input is of incorrect type - :raises ValueError: if unput is unicode with non-ASCII characters + :raises ValueError: if input is unicode with non-ASCII characters """ if isinstance(data, unicode): diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py new file mode 100644 index 000000000..8ee9ae206 --- /dev/null +++ b/letsencrypt/client/network.py @@ -0,0 +1,122 @@ +"""Network Module.""" +import json +import logging +import sys +import time + +import jsonschema +import requests + +from letsencrypt.client import acme +from letsencrypt.client import errors + + +logging.getLogger("requests").setLevel(logging.WARNING) + + +class Network(object): + """Class for communicating with ACME servers. + + :ivar str server: Certificate authority server + :ivar str server_url: Full URL of the CSR server + + """ + def __init__(self, server): + self.server = server + self.server_url = "https://%s/acme/" % self.server + + def send(self, msg): + """Send ACME message to server. + + :param dict msg: ACME message (JSON serializable). + + :returns: Server response message. + :rtype: dict + + :raises TypeError: if `msg` is not JSON serializable + :raises jsonschema.ValidationError: if not valid ACME message + :raises errors.LetsEncryptClientError: in case of connection error + or if response from server is not a valid ACME message. + + """ + json_encoded = json.dumps(msg) + acme.acme_object_validate(json_encoded) + + try: + response = requests.post( + self.server_url, + data=json_encoded, + headers={"Content-Type": "application/json"}, + ) + except requests.exceptions.RequestException as error: + raise errors.LetsEncryptClientError( + 'Sending ACME message to server has failed: %s' % error) + + try: + acme.acme_object_validate(response.content) + except ValueError: + raise errors.LetsEncryptClientError( + 'Server did not send JSON serializable message') + except jsonschema.ValidationError as error: + raise errors.LetsEncryptClientError( + 'Response from server is not a valid ACME message') + + return response.json() + + def send_and_receive_expected(self, msg, expected): + """Send ACME message to server and return expected message. + + :param dict msg: ACME message (JSON serializable). + :param str expected: Name of the expected response ACME message type. + + :returns: ACME response message of expected type. + :rtype: dict + + :raises errors.LetsEncryptClientError: An exception is thrown + + """ + response = self.send(msg) + try: + return self.is_expected_msg(response, expected) + except: # TODO: too generic exception + raise errors.LetsEncryptClientError( + 'Expected message (%s) not received' % expected) + + def is_expected_msg(self, response, expected, delay=3, rounds=20): + """Is response expected ACME message? + + :param dict response: ACME response message from server. + :param str expected: Name of the expected response ACME message type. + :param int delay: Number of seconds to delay before next round + in case of ACME "defer" response message. + :param int rounds: Number of resend attempts in case of ACME "defer" + response message. + + :returns: ACME response message from server. + :rtype: dict + + :raises LetsEncryptClientError: if server sent ACME "error" message + + """ + for _ in xrange(rounds): + if response["type"] == expected: + return response + + elif response["type"] == "error": + logging.error( + "%s: %s - More Info: %s", response["error"], + response.get("message", ""), response.get("moreInfo", "")) + raise errors.LetsEncryptClientError(response["error"]) + + elif response["type"] == "defer": + logging.info("Waiting for %d seconds...", delay) + time.sleep(delay) + response = self.send(acme.status_request(response["token"])) + else: + logging.fatal("Received unexpected message") + logging.fatal("Expected: %s", expected) + logging.fatal("Received: %s", response) + sys.exit(33) + + logging.error( + "Server has deferred past the max of %d seconds", rounds * delay) diff --git a/letsencrypt/client/nginx_configurator.py b/letsencrypt/client/nginx_configurator.py deleted file mode 100644 index 24bf4529b..000000000 --- a/letsencrypt/client/nginx_configurator.py +++ /dev/null @@ -1,203 +0,0 @@ -from letsencrypt.client import CONFIG -from letsencrypt.client import augeas_configurator - - -# This might be helpful... but feel free to use whatever you want -# class VH(object): -# def __init__(self, filename_path, vh_path, vh_addrs, is_ssl, is_enabled): -# self.file = filename_path -# self.path = vh_path -# self.addrs = vh_addrs -# self.names = [] -# self.ssl = is_ssl -# self.enabled = is_enabled - -# def set_names(self, listOfNames): -# self.names = listOfNames - -# def add_name(self, name): -# self.names.append(name) - -class NginxConfigurator(augeas_configurator.AugeasConfigurator): - - def __init__(self, server_root=CONFIG.SERVER_ROOT): - super(NginxConfigurator, self).__init__() - self.server_root = server_root - - # See if any temporary changes need to be recovered - # This needs to occur before VH objects are setup... - # because this will change the underlying configuration and potential - # vhosts - self.recovery_routine() - # Check for errors in parsing files with Augeas - # TODO - insert nginx lens info here??? - #self.check_parsing_errors("httpd.aug") - - def deploy_cert(self, vhost, cert, key, cert_chain=None): - """Deploy cert in nginx""" - - def choose_virtual_host(self, name): - """Chooses a virtual host based on the given domain name""" - - def get_all_names(self): - """Returns all names found in the nginx configuration""" - return set() - - # Might be helpful... I know nothing about nginx lens - # def get_include_path(self, cur_dir, arg): - # """ - # Converts an Apache Include directive argument into an Augeas - # searchable path - # Returns path string - # """ - # # Sanity check argument - maybe - # # Question: what can the attacker do with control over this string - # # Effect parse file... maybe exploit unknown errors in Augeas - # # If the attacker can Include anything though... and this function - # # only operates on Apache real config data... then the attacker has - # # already won. - # # Perhaps it is better to simply check the permissions on all - # # included files? - # # check_config to validate apache config doesn't work because it - # # would create a race condition between the check and this input - - # # TODO: Fix this - # # Check to make sure only expected characters are used, maybe remove - # # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") - # # matchObj = validChars.match(arg) - # # if matchObj.group() != arg: - # # logging.error("Error: Invalid regexp characters in %s", arg) - # # return [] - - # # Standardize the include argument based on server root - # if not arg.startswith("/"): - # arg = cur_dir + arg - # # conf/ is a special variable for ServerRoot in Apache - # elif arg.startswith("conf/"): - # arg = self.server_root + arg[5:] - # # TODO: Test if Apache allows ../ or ~/ for Includes - - # # Attempts to add a transform to the file if one does not already - # # exist - # self.parse_file(arg) - - # # Argument represents an fnmatch regular expression, convert it - # # Split up the path and convert each into an Augeas accepted regex - # # then reassemble - # if "*" in arg or "?" in arg: - # postfix = "" - # splitArg = arg.split("/") - # for idx, split in enumerate(splitArg): - # # * and ? are the two special fnmatch characters - # if "*" in split or "?" in split: - # # Turn it into a augeas regex - # # TODO: Can this be an augeas glob instead of regex - # splitArg[idx] = ("* [label()=~regexp('%s')]" % - # self.fnmatch_to_re(split) - # # Reassemble the argument - # arg = "/".join(splitArg) - - # # If the include is a directory, just return the directory as a file - # if arg.endswith("/"): - # return "/files" + arg[:len(arg)-1] - # return "/files"+arg - - def enable_redirect(self, ssl_vhost): - """ - Adds Redirect directive to the port 80 equivalent of ssl_vhost - First the function attempts to find the vhost with equivalent - ip addresses that serves on non-ssl ports - The function then adds the directive - """ - return - - def enable_ocsp_stapling(self, ssl_vhost): - return False - - def enable_hsts(self, ssl_vhost): - return False - - def get_all_certs_keys(self): - """ - Retrieve all certs and keys set in VirtualHosts on the Apache server - returns: list of tuples with form [(cert, key, path)] - """ - return None - - # Probably helpful reference - # def get_file_path(self, vhost_path): - # """ - # Takes in Augeas path and returns the file name - # """ - # # Strip off /files - # avail_fp = vhost_path[6:] - # # This can be optimized... - # while True: - # # Cast both to lowercase to be case insensitive - # find_if = avail_fp.lower().find("/ifmodule") - # if find_if != -1: - # avail_fp = avail_fp[:find_if] - # continue - # find_vh = avail_fp.lower().find("/virtualhost") - # if find_vh != -1: - # avail_fp = avail_fp[:find_vh] - # continue - # break - # return avail_fp - - def enable_site(self, vhost): - """Enables an available site, Apache restart required""" - return False - - # Might be a usefule reference - # def parse_file(self, file_path): - # """ - # Checks to see if file_path is parsed by Augeas - # If file_path isn't parsed, the file is added and Augeas is reloaded - # """ - # # Test if augeas included file for Httpd.lens - # # Note: This works for augeas globs, ie. *.conf - # incTest = self.aug.match( - # "/augeas/load/Httpd/incl [. ='" + file_path + "']") - # if not incTest: - # # Load up files - # #self.httpd_incl.append(file_path) - # #self.aug.add_transform( - # # "Httpd.lns", self.httpd_incl, None, self.httpd_excl) - # self.__add_httpd_transform(file_path) - # self.aug.load() - - # Helpful reference? - # def verify_setup(self): - # """ - # Make sure that files/directories are setup with appropriate - # permissions. Aim for defensive coding... make sure all input files - # have permissions of root - # """ - # le_util.make_or_verify_dir(CONFIG.CONFIG_DIR, 0o755) - # le_util.make_or_verify_dir(CONFIG.WORK_DIR, 0o755) - # le_util.make_or_verify_dir(CONFIG.BACKUP_DIR, 0o755) - - def restart(self, quiet=False): - """Restarts nginx server""" - - # May be of use? - # def __add_httpd_transform(self, incl): - # """ - # This function will correctly add a transform to augeas - # The existing augeas.add_transform in python is broken - # """ - # lastInclude = self.aug.match("/augeas/load/Httpd/incl [last()]") - # self.aug.insert(lastInclude[0], "incl", False) - # self.aug.set("/augeas/load/Httpd/incl[last()]", incl) - - def config_test(self): - """Check Configuration""" - return False - - -def main(): - return - -if __name__ == "__main__": - main() diff --git a/letsencrypt/client/recovery_contact_challenge.py b/letsencrypt/client/recovery_contact_challenge.py index 6b8f23e91..6bafed829 100644 --- a/letsencrypt/client/recovery_contact_challenge.py +++ b/letsencrypt/client/recovery_contact_challenge.py @@ -8,19 +8,22 @@ import time import dialog import requests +import zope.interface -from letsencrypt.client import challenge +from letsencrypt.client import interfaces -class RecoveryContact(challenge.Challenge): - """Recovery Contact Identitifier Validation Challange. +class RecoveryContact(object): + """Recovery Contact Identifier Validation Challenge. Based on draft-barnes-acme, section 6.3. """ + zope.interface.implements(interfaces.IChallenge) def __init__(self, activation_url="", success_url="", contact="", poll_delay=3): + super(RecoveryContact, self).__init__() self.token = "" self.activation_url = activation_url self.success_url = success_url diff --git a/letsencrypt/client/recovery_token.py b/letsencrypt/client/recovery_token.py new file mode 100644 index 000000000..2da8a9c3f --- /dev/null +++ b/letsencrypt/client/recovery_token.py @@ -0,0 +1,79 @@ +"""Recovery Token Identifier Validation Challenge.""" +import errno +import os + +import zope.component +# import zope.interface + +from letsencrypt.client import CONFIG +from letsencrypt.client import le_util +from letsencrypt.client import interfaces + + +class RecoveryToken(object): + """Recovery Token Identifier Validation Challenge. + + Based on draft-barnes-acme, section 6.4. + + """ + def __init__(self, server, direc=CONFIG.REV_TOKENS_DIR): + self.token_dir = os.path.join(direc, server) + + def perform(self, chall): + """Perform the Recovery Token Challenge. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + :returns: response + :rtype: dict + + """ + token_fp = os.path.join(self.token_dir, chall.domain) + if os.path.isfile(token_fp): + with open(token_fp) as token_fd: + return self.generate_response(token_fd.read()) + + cancel, token = zope.component.getUtility( + interfaces.IDisplay).generic_input( + "%s - Input Recovery Token: " % chall.domain) + if cancel != 1: + return self.generate_response(token) + + return None + + def cleanup(self, chall): + """Cleanup the saved recovery token if it exists. + + :param chall: Recovery Token Challenge + :type chall: :class:`letsencrypt.client.challenge_util.RecTokenChall` + + """ + try: + os.remove(os.path.join(self.token_dir, chall.domain)) + except OSError as err: + if err.errno != errno.ENOENT: + raise + + def generate_response(self, token): # pylint: disable=no-self-use + """Generate json response.""" + return { + "type": "recoveryToken", + "token": token, + } + + def requires_human(self, domain): + """Indicates whether or not domain can be auto solved.""" + return not os.path.isfile(os.path.join(self.token_dir, domain)) + + def store_token(self, domain, token): + """Store token for later automatic use. + + :param str domain: domain associated with the token + :param str token: token from authorization + + """ + le_util.make_or_verify_dir(self.token_dir, 0o700, os.geteuid()) + + with open(os.path.join(self.token_dir, domain), 'w') as token_fd: + token_fd.write(str(token)) diff --git a/letsencrypt/client/recovery_token_challenge.py b/letsencrypt/client/recovery_token_challenge.py deleted file mode 100644 index 04a3d3ec9..000000000 --- a/letsencrypt/client/recovery_token_challenge.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Recovery Token Identifier Validation Challenge. - -.. note:: This challenge has not been implemented into the project yet - -""" -import display - -from letsencrypt.client import challenge - - -class RecoveryToken(challenge.Challenge): - """Recovery Token Identifier Validation Challenge. - - Based on draft-barnes-acme, section 6.4. - - """ - - def __init__(self, configurator): - super(RecoveryToken, self).__init__(configurator) - self.token = "" - - def perform(self, quiet=True): - cancel, self.token = display.generic_input( - "Please Input Recovery Token: ") - return cancel != 1 - - def cleanup(self): - pass - - def generate_response(self): - return { - "type": "recoveryToken", - "token": self.token, - } diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py new file mode 100644 index 000000000..073362501 --- /dev/null +++ b/letsencrypt/client/revoker.py @@ -0,0 +1,140 @@ +"""Revoker module to enable LE revocations.""" +import csv +import logging +import os +import shutil + +import M2Crypto +import zope.component + +from letsencrypt.client import acme +from letsencrypt.client import CONFIG +from letsencrypt.client import crypto_util +from letsencrypt.client import display +from letsencrypt.client import interfaces +from letsencrypt.client import network + + +class Revoker(object): + """A revocation class for LE.""" + def __init__(self, server, installer): + self.network = network.Network(server) + self.installer = installer + + def acme_revocation(self, cert): + """Handle ACME "revocation" phase. + + :param dict cert: TODO + + :returns: ACME "revocation" message. + :rtype: dict + + """ + cert_der = M2Crypto.X509.load_cert(cert["backup_cert_file"]).as_der() + with open(cert["backup_key_file"], 'rU') as backup_key_file: + key = backup_key_file.read() + + revocation = self.network.send_and_receive_expected( + acme.revocation_request(cert_der, key), "revocation") + + zope.component.getUtility(interfaces.IDisplay).generic_notification( + "You have successfully revoked the certificate for " + "%s" % cert["cn"]) + + self.remove_cert_key(cert) + self.list_certs_keys() + + return revocation + + def list_certs_keys(self): + """List trusted Let's Encrypt certificates.""" + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + certs = [] + + if not os.path.isfile(list_file): + logging.info( + "You don't have any certificates saved from letsencrypt") + return + + c_sha1_vh = {} + for (cert, _, path) in self.installer.get_all_certs_keys(): + try: + c_sha1_vh[M2Crypto.X509.load_cert( + cert).get_fingerprint(md='sha1')] = path + except: + continue + + with open(list_file, 'rb') as csvfile: + csvreader = csv.reader(csvfile) + for row in csvreader: + cert = crypto_util.get_cert_info(row[1]) + + b_k = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[2]) + "_" + row[0]) + b_c = os.path.join(CONFIG.CERT_KEY_BACKUP, + os.path.basename(row[1]) + "_" + row[0]) + + cert.update({ + "orig_key_file": row[2], + "orig_cert_file": row[1], + "idx": int(row[0]), + "backup_key_file": b_k, + "backup_cert_file": b_c, + "installed": c_sha1_vh.get(cert["fingerprint"], ""), + }) + certs.append(cert) + if certs: + self.choose_certs(certs) + else: + zope.component.getUtility(interfaces.IDisplay).generic_notification( + "There are not any trusted Let's Encrypt " + "certificates for this server.") + + def choose_certs(self, certs): + """Display choose certificates menu. + + :param list certs: List of cert dicts. + + """ + displayer = zope.component.getUtility(interfaces.IDisplay) + code, tag = displayer.display_certs(certs) + + if code == display.OK: + cert = certs[tag] + if displayer.confirm_revocation(cert): + self.acme_revocation(cert) + else: + self.choose_certs(certs) + elif code == display.HELP: + cert = certs[tag] + displayer.more_info_cert(cert) + self.choose_certs(certs) + else: + exit(0) + + # pylint: disable=no-self-use + def remove_cert_key(self, cert): + """Remove certificate and key. + + :param dict cert: Cert dict used throughout revocation + + """ + list_file = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST") + list_file2 = os.path.join(CONFIG.CERT_KEY_BACKUP, "LIST.tmp") + + with open(list_file, 'rb') as orgfile: + csvreader = csv.reader(orgfile) + + with open(list_file2, 'wb') as newfile: + csvwriter = csv.writer(newfile) + + for row in csvreader: + if not (row[0] == str(cert["idx"]) and + row[1] == cert["orig_cert_file"] and + row[2] == cert["orig_key_file"]): + csvwriter.writerow(row) + + shutil.copy2(list_file2, list_file) + os.remove(list_file2) + os.remove(cert["backup_cert_file"]) + os.remove(cert["backup_key_file"]) diff --git a/letsencrypt/client/setup.sh b/letsencrypt/client/setup.sh old mode 100644 new mode 100755 index fb35eb4f7..9db81cbd2 --- a/letsencrypt/client/setup.sh +++ b/letsencrypt/client/setup.sh @@ -1,2 +1,2 @@ -#!/usr/bin/sh +#!/bin/sh cp options-ssl.conf /etc/letsencrypt/options-ssl.conf diff --git a/letsencrypt/client/tests/acme_test.py b/letsencrypt/client/tests/acme_test.py index df232c75a..808eefc1b 100644 --- a/letsencrypt/client/tests/acme_test.py +++ b/letsencrypt/client/tests/acme_test.py @@ -1,4 +1,5 @@ """Tests for letsencrypt.client.acme.""" +import pkg_resources import unittest import jsonschema @@ -58,15 +59,8 @@ class MessageFactoriesTest(unittest.TestCase): """Tests for ACME message factories from letsencrypt.client.acme.""" def setUp(self): - self.privkey = """-----BEGIN RSA PRIVATE KEY----- -MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 -vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn -elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc -mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp -Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj -8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq -6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ ------END RSA PRIVATE KEY-----""" + self.privkey = pkg_resources.resource_string( + __name__, 'testdata/rsa256_key.pem') self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' diff --git a/letsencrypt/client/tests/acme_util.py b/letsencrypt/client/tests/acme_util.py new file mode 100644 index 000000000..504009f02 --- /dev/null +++ b/letsencrypt/client/tests/acme_util.py @@ -0,0 +1,112 @@ +"""Class helps construct valid ACME messages for testing.""" +from letsencrypt.client import CONFIG + + +CHALLENGES = { + "simpleHttps": + { + "type": "simpleHttps", + "token": "evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA" + }, + "dvsni": + { + "type": "dvsni", + "r": "Tyq0La3slT7tqQ0wlOiXnCY2vyez7Zo5blgPJ1xt5xI", + "nonce": "a82d5ff8ef740d12881f6d3c2277ab2e" + }, + "dns": + { + "type": "dns", + "token": "17817c66b60ce2e4012dfad92657527a" + }, + "recoveryContact": + { + "type": "recoveryContact", + "activationURL": "https://example.ca/sendrecovery/a5bd99383fb0", + "successURL": "https://example.ca/confirmrecovery/bb1b9928932", + "contact": "c********n@example.com" + }, + "recoveryTokent": + { + "type": "recoveryToken" + }, + "proofOfPossession": + { + "type": "proofOfPossession", + "alg": "RS256", + "nonce": "eET5udtV7aoX8Xl8gYiZIA", + "hints": { + "jwk": { + "kty": "RSA", + "e": "AQAB", + "n": "KxITJ0rNlfDMAtfDr8eAw...fSSoehDFNZKQKzTZPtQ" + }, + "certFingerprints": [ + "93416768eb85e33adc4277f4c9acd63e7418fcfe", + "16d95b7b63f1972b980b14c20291f3c0d1855d95", + "48b46570d9fc6358108af43ad1649484def0debf" + ], + "subjectKeyIdentifiers": + ["d0083162dcc4c8a23ecb8aecbd86120e56fd24e5"], + "serialNumbers": [34234239832, 23993939911, 17], + "issuers": [ + "C=US, O=SuperT LLC, CN=SuperTrustworthy Public CA", + "O=LessTrustworthy CA Inc, CN=LessTrustworthy But StillSecure" + ], + "authorizedFor": ["www.example.com", "example.net"] + } + } +} + + +def get_dv_challenges(): + """Returns all auth challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.DV_CHALLENGES] + + +def get_client_challenges(): + """Returns all client challenges.""" + return [chall for typ, chall in CHALLENGES.iteritems() + if typ in CONFIG.CLIENT_CHALLENGES] + + +def get_challenges(): + """Returns all challenges.""" + return [chall for chall in CHALLENGES.itervalues()] + + +def gen_combos(challs): + """Generate natural combinations for challs.""" + dv_chall = [] + renewal_chall = [] + combos = [] + + for i, chall in enumerate(challs): + if chall["type"] in CONFIG.DV_CHALLENGES: + dv_chall.append(i) + else: + renewal_chall.append(i) + + # Gen combos for 1 of each type + for i in range(len(dv_chall)): + for j in range(len(renewal_chall)): + combos.append([i, j]) + + return combos + + +def get_chall_msg(iden, nonce, challenges, combos=None): + """Produce an ACME challenge message.""" + chall_msg = { + "type": "challenge", + "sessionID": iden, + "nonce": nonce, + "challenges": challenges + } + + if combos is None: + return chall_msg + + chall_msg["combinations"] = combos + return chall_msg diff --git a/letsencrypt/client/tests/apache/__init__.py b/letsencrypt/client/tests/apache/__init__.py new file mode 100644 index 000000000..2c0849a3d --- /dev/null +++ b/letsencrypt/client/tests/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Apache Tests""" diff --git a/letsencrypt/client/tests/apache/config_util.py b/letsencrypt/client/tests/apache/config_util.py new file mode 100644 index 000000000..ad38818ab --- /dev/null +++ b/letsencrypt/client/tests/apache/config_util.py @@ -0,0 +1,93 @@ +import os +import pkg_resources +import shutil +import tempfile + +import mock + +from letsencrypt.client import CONFIG +from letsencrypt.client.apache import configurator +from letsencrypt.client.apache import obj + + +def dir_setup(test_dir="debian_apache_2_4/two_vhost_80"): + """Setup the directories necessary for the configurator.""" + temp_dir = tempfile.mkdtemp("temp") + config_dir = tempfile.mkdtemp("config") + work_dir = tempfile.mkdtemp("work") + + test_configs = pkg_resources.resource_filename( + "letsencrypt.client.tests", "testdata/%s" % test_dir) + + shutil.copytree( + test_configs, os.path.join(temp_dir, test_dir), symlinks=True) + + return temp_dir, config_dir, work_dir + + +def setup_apache_ssl_options(config_dir): + """Move the ssl_options into position and return the path.""" + option_path = os.path.join(config_dir, "options-ssl.conf") + temp_options = pkg_resources.resource_filename( + "letsencrypt.client.apache", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) + shutil.copyfile( + temp_options, option_path) + + return option_path + + +def get_apache_configurator( + config_path, config_dir, work_dir, ssl_options, version=(2, 4, 7)): + """Create an Apache Configurator with the specified options.""" + + backups = os.path.join(work_dir, "backups") + + with mock.patch("letsencrypt.client.apache.configurator." + "subprocess.Popen") as mock_popen: + # This just states that the ssl module is already loaded + mock_popen().communicate.return_value = ("ssl_module", "") + config = configurator.ApacheConfigurator( + config_path, + { + "backup": backups, + "temp": os.path.join(work_dir, "temp_checkpoint"), + "progress": os.path.join(backups, "IN_PROGRESS"), + "config": config_dir, + "work": work_dir, + }, + ssl_options, + version) + + return config + + +def get_vh_truth(temp_dir, config_name): + """Return the ground truth for the specified directory.""" + if config_name == "debian_apache_2_4/two_vhost_80": + prefix = os.path.join( + temp_dir, config_name, "apache2/sites-available") + aug_pre = "/files" + prefix + vh_truth = [ + obj.VirtualHost( + os.path.join(prefix, "encryption-example.conf"), + os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), + False, True, set(["encryption-example.demo"])), + obj.VirtualHost( + os.path.join(prefix, "default-ssl.conf"), + os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), + set([obj.Addr.fromstring("_default_:443")]), True, False), + obj.VirtualHost( + os.path.join(prefix, "000-default.conf"), + os.path.join(aug_pre, "000-default.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, + set(["ip-172-30-0-17"])), + obj.VirtualHost( + os.path.join(prefix, "letsencrypt.conf"), + os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), + set([obj.Addr.fromstring("*:80")]), False, True, + set(["letsencrypt.demo"])), + ] + return vh_truth + + return None diff --git a/letsencrypt/client/tests/apache/configurator_test.py b/letsencrypt/client/tests/apache/configurator_test.py new file mode 100644 index 000000000..ce12a137e --- /dev/null +++ b/letsencrypt/client/tests/apache/configurator_test.py @@ -0,0 +1,200 @@ +"""Test for letsencrypt.client.apache.configurator.""" +import os +import pkg_resources +import re +import shutil +import unittest + +import mock +import zope.component + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import errors + +from letsencrypt.client.apache import configurator +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser + +from letsencrypt.client.tests.apache import config_util + + +class TwoVhost80Test(unittest.TestCase): + """Test two standard well configured HTTP vhosts.""" + + def setUp(self): + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + self.config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) + + self.vh_truth = config_util.get_vh_truth( + self.temp_dir, "debian_apache_2_4/two_vhost_80") + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_get_all_names(self): + names = self.config.get_all_names() + self.assertEqual(names, set( + ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) + + def test_get_virtual_hosts(self): + vhs = self.config.get_virtual_hosts() + self.assertEqual(len(vhs), 4) + found = 0 + + for vhost in vhs: + for truth in self.vh_truth: + if vhost == truth: + found += 1 + break + + self.assertEqual(found, 4) + + def test_is_site_enabled(self): + self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) + self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) + self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) + self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) + + def test_deploy_cert(self): + self.config.deploy_cert( + self.vh_truth[1], + "example/cert.pem", "example/key.pem", "example/cert_chain.pem") + + loc_cert = self.config.parser.find_dir( + parser.case_i("sslcertificatefile"), + re.escape("example/cert.pem"), self.vh_truth[1].path) + loc_key = self.config.parser.find_dir( + parser.case_i("sslcertificateKeyfile"), + re.escape("example/key.pem"), self.vh_truth[1].path) + loc_chain = self.config.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), + re.escape("example/cert_chain.pem"), self.vh_truth[1].path) + + # Verify one directive was found in the correct file + self.assertEqual(len(loc_cert), 1) + self.assertEqual(configurator.get_file_path(loc_cert[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_key), 1) + self.assertEqual(configurator.get_file_path(loc_key[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_chain), 1) + self.assertEqual(configurator.get_file_path(loc_chain[0]), + self.vh_truth[1].filep) + + def test_is_name_vhost(self): + addr = obj.Addr.fromstring("*:80") + self.assertTrue(self.config.is_name_vhost(addr)) + self.config.version = (2, 2) + self.assertFalse(self.config.is_name_vhost(addr)) + + def test_add_name_vhost(self): + self.config.add_name_vhost("*:443") + self.assertTrue(self.config.parser.find_dir( + "NameVirtualHost", re.escape("*:443"))) + + def test_make_vhost_ssl(self): + ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) + + self.assertEqual( + ssl_vhost.filep, + os.path.join(self.config_path, "sites-available", + "encryption-example-le-ssl.conf")) + + self.assertEqual(ssl_vhost.path, + "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") + self.assertEqual(len(ssl_vhost.addrs), 1) + self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs) + self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) + self.assertTrue(ssl_vhost.ssl) + self.assertFalse(ssl_vhost.enabled) + + self.assertTrue(self.config.parser.find_dir( + "SSLCertificateFile", None, ssl_vhost.path)) + self.assertTrue(self.config.parser.find_dir( + "SSLCertificateKeyFile", None, ssl_vhost.path)) + self.assertTrue(self.config.parser.find_dir( + "Include", self.ssl_options, ssl_vhost.path)) + + self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), + self.config.is_name_vhost(ssl_vhost)) + + self.assertEqual(len(self.config.vhosts), 5) + + @mock.patch("letsencrypt.client.apache.configurator." + "subprocess.Popen") + def test_get_version(self, mock_popen): + mock_popen().communicate.return_value = ( + "Server Version: Apache/2.4.2 (Debian)", "") + self.assertEqual(self.config.get_version(), (2, 4, 2)) + + mock_popen().communicate.return_value = ( + "Server Version: Apache/2 (Linux)", "") + self.assertEqual(self.config.get_version(), (2,)) + + mock_popen().communicate.return_value = ( + "Server Version: Apache (Debian)", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + mock_popen().communicate.return_value = ( + "Server Version: Apache/2.3\n Apache/2.4.7", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + mock_popen.side_effect = OSError("Can't find program") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + @mock.patch("letsencrypt.client.apache.configurator." + "dvsni.ApacheDvsni.perform") + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + def test_perform(self, mock_restart, mock_dvsni_perform): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + chall1 = challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key) + chall2 = challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key) + + dvsni_ret_val = [ + {"type": "dvsni", "s": "randomS1"}, + {"type": "dvsni", "s": "randomS2"} + ] + + mock_dvsni_perform.return_value = dvsni_ret_val + responses = self.config.perform([chall1, chall2]) + + self.assertEqual(mock_dvsni_perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + + self.assertEqual(mock_restart.call_count, 1) + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/apache/dvsni_test.py b/letsencrypt/client/tests/apache/dvsni_test.py new file mode 100644 index 000000000..b24d47b45 --- /dev/null +++ b/letsencrypt/client/tests/apache/dvsni_test.py @@ -0,0 +1,151 @@ +"""Test for letsencrypt.client.apache.dvsni.""" +import os +import pkg_resources +import unittest +import shutil + +import mock +import zope.component + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import CONFIG + +from letsencrypt.client.tests.apache import config_util + + +class DvsniPerformTest(unittest.TestCase): + + def setUp(self): + from letsencrypt.client.apache import dvsni + + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) + + self.sni = dvsni.ApacheDvsni(config) + + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + + auth_key = client.Client.Key(rsa256_file, rsa256_pem) + self.challs = [] + self.challs.append(challenge_util.DvsniChall( + "encryption-example.demo", + "jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + "37bc5eb75d3e00a19b4f6355845e5a18", + auth_key)) + self.challs.append(challenge_util.DvsniChall( + "letsencrypt.demo", + "uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + "59ed014cac95f77057b1d7a1b2c596ba", + auth_key)) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_perform0(self): + resp = self.sni.perform() + self.assertTrue(resp is None) + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform1(self, mock_dvsni_gen_cert, mock_restart): + chall = self.challs[0] + self.sni.add_chall(chall) + mock_dvsni_gen_cert.return_value = "randomS1" + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 1) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [ + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key) + ] + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 1) + self.assertEqual(responses[0]["s"], "randomS1") + + @mock.patch("letsencrypt.client.apache.configurator." + "ApacheConfigurator.restart") + @mock.patch("letsencrypt.client.challenge_util.dvsni_gen_cert") + def test_perform2(self, mock_dvsni_gen_cert, mock_restart): + for chall in self.challs: + self.sni.add_chall(chall) + + mock_dvsni_gen_cert.side_effect = ["randomS0", "randomS1"] + responses = self.sni.perform() + + self.assertEqual(mock_dvsni_gen_cert.call_count, 2) + calls = mock_dvsni_gen_cert.call_args_list + expected_call_list = [] + + for chall in self.challs: + expected_call_list.append( + (self.sni.get_cert_file(chall.nonce), chall.domain, + chall.r_b64, chall.nonce, chall.key)) + + for i in range(len(expected_call_list)): + for j in range(len(expected_call_list[0])): + self.assertEqual(calls[i][0][j], expected_call_list[i][j]) + + self.assertEqual( + len(self.sni.config.parser.find_dir( + "Include", self.sni.challenge_conf)), + 1) + self.assertEqual(len(responses), 2) + for i in range(2): + self.assertEqual(responses[i]["s"], "randomS%d" % i) + + def test_mod_config(self): + from letsencrypt.client.apache.obj import Addr + for chall in self.challs: + self.sni.add_chall(chall) + v_addr1 = [Addr(("1.2.3.4", "443")), Addr(("5.6.7.8", "443"))] + v_addr2 = [Addr(("127.0.0.1", "443"))] + ll_addr = [] + ll_addr.append(v_addr1) + ll_addr.append(v_addr2) + self.sni._mod_config(ll_addr) # pylint: disable=protected-access + self.sni.config.save() + + self.sni.config.parser.find_dir("Include", self.sni.challenge_conf) + vh_match = self.sni.config.aug.match( + "/files" + self.sni.challenge_conf + "//VirtualHost") + + vhs = [] + for match in vh_match: + # pylint: disable=protected-access + vhs.append(self.sni.config._create_vhost(match)) + self.assertEqual(len(vhs), 2) + for vhost in vhs: + if vhost.addrs == set(v_addr1): + self.assertEqual( + vhost.names, + set([str(self.challs[0].nonce + CONFIG.INVALID_EXT)])) + else: + self.assertEqual(vhost.addrs, set(v_addr2)) + self.assertEqual( + vhost.names, + set([str(self.challs[1].nonce + CONFIG.INVALID_EXT)])) diff --git a/letsencrypt/client/tests/apache/obj_test.py b/letsencrypt/client/tests/apache/obj_test.py new file mode 100644 index 000000000..f78e83bb4 --- /dev/null +++ b/letsencrypt/client/tests/apache/obj_test.py @@ -0,0 +1,66 @@ +"""Test the helper objects in apache.obj.py.""" +import unittest + + +class AddrTest(unittest.TestCase): + """Test the Addr class.""" + def setUp(self): + from letsencrypt.client.apache.obj import Addr + self.addr1 = Addr.fromstring("192.168.1.1") + self.addr2 = Addr.fromstring("192.168.1.1:*") + self.addr3 = Addr.fromstring("192.168.1.1:80") + + def test_fromstring(self): + self.assertEqual(self.addr1.get_addr(), "192.168.1.1") + self.assertEqual(self.addr1.get_port(), "") + self.assertEqual(self.addr2.get_addr(), "192.168.1.1") + self.assertEqual(self.addr2.get_port(), "*") + self.assertEqual(self.addr3.get_addr(), "192.168.1.1") + self.assertEqual(self.addr3.get_port(), "80") + + def test_str(self): + self.assertEqual(str(self.addr1), "192.168.1.1") + self.assertEqual(str(self.addr2), "192.168.1.1:*") + self.assertEqual(str(self.addr3), "192.168.1.1:80") + + def test_get_addr_obj(self): + self.assertEqual(str(self.addr1.get_addr_obj("443")), "192.168.1.1:443") + self.assertEqual(str(self.addr2.get_addr_obj("")), "192.168.1.1") + self.assertEqual(str(self.addr1.get_addr_obj("*")), "192.168.1.1:*") + + def test_eq(self): + self.assertEqual(self.addr1, self.addr2.get_addr_obj("")) + self.assertNotEqual(self.addr1, self.addr2) + # This is specifically designed to hit line 28 but coverage denies me + # the satisfaction :( + self.assertNotEqual(self.addr1, 3333) + + def test_set_inclusion(self): + from letsencrypt.client.apache.obj import Addr + set_a = set([self.addr1, self.addr2]) + addr1b = Addr.fromstring("192.168.1.1") + addr2b = Addr.fromstring("192.168.1.1:*") + set_b = set([addr1b, addr2b]) + + self.assertEqual(set_a, set_b) + + +class VirtualHostTest(unittest.TestCase): + """Test the VirtualHost class.""" + def setUp(self): + from letsencrypt.client.apache.obj import VirtualHost + from letsencrypt.client.apache.obj import Addr + self.vhost1 = VirtualHost( + "filep", "vh_path", + set([Addr.fromstring("localhost")]), False, False) + + def test_eq(self): + from letsencrypt.client.apache.obj import Addr + from letsencrypt.client.apache.obj import VirtualHost + vhost1b = VirtualHost( + "filep", "vh_path", + set([Addr.fromstring("localhost")]), False, False) + + self.assertEqual(vhost1b, self.vhost1) + self.assertEqual(str(vhost1b), str(self.vhost1)) + self.assertNotEqual(vhost1b, 1234) diff --git a/letsencrypt/client/tests/apache/parser_test.py b/letsencrypt/client/tests/apache/parser_test.py new file mode 100644 index 000000000..2acf6533e --- /dev/null +++ b/letsencrypt/client/tests/apache/parser_test.py @@ -0,0 +1,114 @@ +"""Tests the ApacheParser class.""" +import os +import shutil +import sys +import unittest + +import augeas +import mock +import zope.component + +from letsencrypt.client import display +from letsencrypt.client import errors +from letsencrypt.client.apache import parser +from letsencrypt.client.tests.apache import config_util + + +class ApacheParserTest(unittest.TestCase): + """Apache Parser Test.""" + def setUp(self): + zope.component.provideUtility(display.FileDisplay(sys.stdout)) + + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") + + self.ssl_options = config_util.setup_apache_ssl_options(self.config_dir) + + # Final slash is currently important + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") + + self.parser = parser.ApacheParser( + augeas.Augeas(flags=augeas.Augeas.NONE), + self.config_path, self.ssl_options) + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_parse_file(self): + """Test parse_file. + + letsencrypt.conf is chosen as the test file as it will not be + included during the normal course of execution. + + """ + file_path = os.path.join( + self.config_path, "sites-available", "letsencrypt.conf") + + # pylint: disable=protected-access + self.parser._parse_file(file_path) + + # search for the httpd incl + matches = self.parser.aug.match( + "/augeas/load/Httpd/incl [. ='%s']" % file_path) + + self.assertTrue(matches) + + def test_find_dir(self): + test = self.parser.find_dir(parser.case_i("Listen"), "443") + # This will only look in enabled hosts + test2 = self.parser.find_dir( + parser.case_i("documentroot")) + self.assertEqual(len(test), 2) + self.assertEqual(len(test2), 3) + + def test_add_dir(self): + aug_default = "/files" + self.parser.loc["default"] + self.parser.add_dir(aug_default, "AddDirective", "test") + + self.assertTrue( + self.parser.find_dir("AddDirective", "test", aug_default)) + + self.parser.add_dir(aug_default, "AddList", ["1", "2", "3", "4"]) + matches = self.parser.find_dir("AddList", None, aug_default) + for i, match in enumerate(matches): + self.assertEqual(self.parser.aug.get(match), str(i + 1)) + + def test_add_dir_to_ifmodssl(self): + """test add_dir_to_ifmodssl. + + Path must be valid before attempting to add to augeas + + """ + self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["default"]), + "FakeDirective", "123") + + matches = self.parser.find_dir("FakeDirective", "123") + + self.assertEqual(len(matches), 1) + self.assertTrue("IfModule" in matches[0]) + + def test_get_aug_path(self): + self.assertEqual( + "/files/etc/apache", parser.get_aug_path("/etc/apache")) + + def test_set_locations(self): + with mock.patch("letsencrypt.client.apache.parser." + "os.path") as mock_path: + + mock_path.isfile.return_value = False + + # pylint: disable=protected-access + self.assertRaises(errors.LetsEncryptConfiguratorError, + self.parser._set_locations, self.ssl_options) + + mock_path.isfile.side_effect = [True, False, False] + + # pylint: disable=protected-access + results = self.parser._set_locations(self.ssl_options) + + self.assertEqual(results["default"], results["listen"]) + self.assertEqual(results["default"], results["name"]) diff --git a/letsencrypt/client/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py deleted file mode 100644 index 08c99cbeb..000000000 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Test for letsencrypt.client.apache_configurator.""" -import os -import pkg_resources -import re -import shutil -import sys -import tempfile -import unittest - -import mock - -from letsencrypt.client import apache_configurator -from letsencrypt.client import CONFIG -from letsencrypt.client import display -from letsencrypt.client import errors - - -UBUNTU_CONFIGS = pkg_resources.resource_filename( - __name__, "testdata/debian_apache_2_4") - - -class TwoVhost80Test(unittest.TestCase): - """Test two standard well configured HTTP vhosts.""" - - def setUp(self): - display.set_display(display.NcursesDisplay()) - - self.temp_dir = os.path.join( - tempfile.mkdtemp("temp"), "debian_apache_2_4") - self.config_dir = tempfile.mkdtemp("config") - self.work_dir = tempfile.mkdtemp("work") - - shutil.copytree(UBUNTU_CONFIGS, self.temp_dir, symlinks=True) - - temp_options = pkg_resources.resource_filename( - "letsencrypt.client", os.path.basename(CONFIG.OPTIONS_SSL_CONF)) - shutil.copyfile( - temp_options, os.path.join(self.config_dir, "options-ssl.conf")) - - # Final slash is currently important - self.config_path = os.path.join(self.temp_dir, "two_vhost_80/apache2/") - self.ssl_options = os.path.join(self.config_dir, "options-ssl.conf") - backups = os.path.join(self.work_dir, "backups") - - with mock.patch("letsencrypt.client.apache_configurator." - "subprocess.Popen") as mock_popen: - # This just states that the ssl module is already loaded - mock_popen().communicate.return_value = ("ssl_module", "") - self.config = apache_configurator.ApacheConfigurator( - self.config_path, - { - "backup": backups, - "temp": os.path.join(self.work_dir, "temp_checkpoint"), - "progress": os.path.join(backups, "IN_PROGRESS"), - "config": self.config_dir, - "work": self.work_dir, - }, - self.ssl_options, - (2, 4, 7)) - - prefix = os.path.join( - self.temp_dir, "two_vhost_80/apache2/sites-available") - aug_pre = "/files" + prefix - self.vh_truth = [ - apache_configurator.VH( - os.path.join(prefix, "encryption-example.conf"), - os.path.join(aug_pre, "encryption-example.conf/VirtualHost"), - ["*:80"], False, True, ["encryption-example.demo"]), - apache_configurator.VH( - os.path.join(prefix, "default-ssl.conf"), - os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), - ["_default_:443"], True, False), - apache_configurator.VH( - os.path.join(prefix, "000-default.conf"), - os.path.join(aug_pre, "000-default.conf/VirtualHost"), - ["*:80"], False, True, ["ip-172-30-0-17"]), - apache_configurator.VH( - os.path.join(prefix, "letsencrypt.conf"), - os.path.join(aug_pre, "letsencrypt.conf/VirtualHost"), - ["*:80"], False, True, ["letsencrypt.demo"]), - ] - - def tearDown(self): - shutil.rmtree(self.temp_dir) - shutil.rmtree(self.config_dir) - shutil.rmtree(self.work_dir) - - def test_parse_file(self): - """Test parse_file. - - letsencrypt.conf is chosen as the test file as it will not be - included during the normal course of execution. - - """ - file_path = os.path.join( - self.config_path, "sites-available", "letsencrypt.conf") - self.config._parse_file(file_path) # pylint: disable=protected-access - - # search for the httpd incl - matches = self.config.aug.match( - "/augeas/load/Httpd/incl [. ='%s']" % file_path) - - self.assertTrue(matches) - - def test_get_all_names(self): - names = self.config.get_all_names() - self.assertEqual(set(names), set( - ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) - - def test_find_directive(self): - test = self.config.find_directive( - apache_configurator.case_i("Listen"), "443") - # This will only look in enabled hosts - test2 = self.config.find_directive( - apache_configurator.case_i("documentroot")) - self.assertEqual(len(test), 2) - self.assertEqual(len(test2), 3) - - def test_get_virtual_hosts(self): - vhs = self.config.get_virtual_hosts() - self.assertEqual(len(vhs), 4) - found = 0 - for vhost in vhs: - for truth in self.vh_truth: - if vhost == truth: - found += 1 - break - - self.assertEqual(found, 4) - - def test_is_site_enabled(self): - self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) - self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) - self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) - self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) - - def test_add_dir(self): - aug_default = "/files" + self.config.location["default"] - self.config.add_dir( - aug_default, "AddDirective", "test") - - self.assertTrue( - self.config.find_directive("AddDirective", "test", aug_default)) - - def test_deploy_cert(self): - self.config.deploy_cert( - self.vh_truth[1], - "example/cert.pem", "example/key.pem", "example/cert_chain.pem") - - loc_cert = self.config.find_directive( - apache_configurator.case_i("sslcertificatefile"), - re.escape("example/cert.pem"), self.vh_truth[1].path) - loc_key = self.config.find_directive( - apache_configurator.case_i("sslcertificateKeyfile"), - re.escape("example/key.pem"), self.vh_truth[1].path) - loc_chain = self.config.find_directive( - apache_configurator.case_i("SSLCertificateChainFile"), - re.escape("example/cert_chain.pem"), self.vh_truth[1].path) - - # Verify one directive was found in the correct file - self.assertEqual(len(loc_cert), 1) - self.assertEqual(apache_configurator.get_file_path(loc_cert[0]), - self.vh_truth[1].filep) - - self.assertEqual(len(loc_key), 1) - self.assertEqual(apache_configurator.get_file_path(loc_key[0]), - self.vh_truth[1].filep) - - self.assertEqual(len(loc_chain), 1) - self.assertEqual(apache_configurator.get_file_path(loc_chain[0]), - self.vh_truth[1].filep) - - def test_is_name_vhost(self): - self.assertTrue(self.config.is_name_vhost("*:80")) - self.config.version = (2, 2) - self.assertFalse(self.config.is_name_vhost("*:80")) - - def test_add_name_vhost(self): - self.config.add_name_vhost("*:443") - # self.config.save(temporary=True) - self.assertTrue(self.config.find_directive( - "NameVirtualHost", re.escape("*:443"))) - - def test_add_dir_to_ifmodssl(self): - """test _add_dir_to_ifmodssl. - - Path must be valid before attempting to add to augeas - - """ - self.config._add_dir_to_ifmodssl( # pylint: disable=protected-access - "/files" + self.config.location["default"], "FakeDirective", "123") - - matches = self.config.find_directive("FakeDirective", "123") - - self.assertEqual(len(matches), 1) - self.assertTrue("IfModule" in matches[0]) - - def test_make_vhost_ssl(self): - ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) - - self.assertEqual( - ssl_vhost.filep, - os.path.join(self.config_path, "sites-available", - "encryption-example-le-ssl.conf")) - - self.assertEqual(ssl_vhost.path, - "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") - self.assertEqual(ssl_vhost.addrs, ["*:443"]) - self.assertEqual(ssl_vhost.names, ["encryption-example.demo"]) - self.assertTrue(ssl_vhost.ssl) - self.assertFalse(ssl_vhost.enabled) - - self.assertTrue(self.config.find_directive( - "SSLCertificateFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( - "SSLCertificateKeyFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( - "Include", self.ssl_options, ssl_vhost.path)) - - self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), - self.config.is_name_vhost(ssl_vhost)) - - self.assertEqual(len(self.config.vhosts), 5) - - @mock.patch("letsencrypt.client.apache_configurator." - "subprocess.Popen") - def test_get_version(self, mock_popen): - mock_popen().communicate.return_value = ( - "Server Version: Apache/2.4.2 (Debian)", "") - self.assertEqual(self.config.get_version(), (2, 4, 2)) - - mock_popen().communicate.return_value = ( - "Server Version: Apache/2 (Linux)", "") - self.assertEqual(self.config.get_version(), (2,)) - - mock_popen().communicate.return_value = ( - "Server Version: Apache (Debian)", "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - - mock_popen().communicate.return_value = ( - "Server Version: Apache/2.3\n Apache/2.4.7", "") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - - mock_popen.side_effect = OSError("Can't find program") - self.assertRaises( - errors.LetsEncryptConfiguratorError, self.config.get_version) - - -if __name__ == '__main__': - unittest.main() diff --git a/letsencrypt/client/tests/auth_handler_test.py b/letsencrypt/client/tests/auth_handler_test.py new file mode 100644 index 000000000..2cb801efc --- /dev/null +++ b/letsencrypt/client/tests/auth_handler_test.py @@ -0,0 +1,427 @@ +"""Test auth_handler.py.""" +import unittest +import mock + +from letsencrypt.client.tests import acme_util + + +TRANSLATE = {"dvsni": "DvsniChall", + "simpleHttps": "SimpleHttpsChall", + "dns": "DnsChall", + "recoveryToken": "RecTokenChall", + "recoveryContact": "RecContactChall", + "proofOfPossession": "PopChall"} + + +# pylint: disable=protected-access +class SatisfyChallengesTest(unittest.TestCase): + """verify_identities test.""" + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_dv_auth.get_chall_pref.return_value = ["dvsni"] + self.mock_client_auth.get_chall_pref.return_value = ["recoveryToken"] + + self.mock_client_auth.perform.side_effect = gen_auth_resp + self.mock_dv_auth.perform.side_effect = gen_auth_resp + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + def test_name1_dvsni1(self): + dom = "0" + challenge = [acme_util.CHALLENGES["dvsni"]] + msg = acme_util.get_chall_msg(dom, "nonce0", challenge) + self.handler.add_chall_msg(dom, msg, "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), 1) + + self.assertEqual("DvsniChall0", self.handler.responses[dom][0]) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + + def test_name5_dvsni5(self): + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + # Each message contains 1 auth, 0 client + + for i in range(5): + dom = str(i) + self.assertEqual(len(self.handler.responses[dom]), 1) + self.assertEqual(self.handler.responses[dom][0], "DvsniChall%d" % i) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_auth(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_dv_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg("0", "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps"], challenges) + mock_chall_path.return_value = path + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 0) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name1_all(self, mock_chall_path): + dom = "0" + + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce0", challenges, combos), + "dummy_key") + + path = gen_path(["simpleHttps", "recoveryToken"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 1) + self.assertEqual(len(self.handler.responses[dom]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 1) + self.assertEqual(len(self.handler.client_c), 1) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, "RecTokenChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_all(self, mock_chall_path): + challenges = acme_util.get_challenges() + combos = acme_util.gen_combos(challenges) + for i in range(5): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg( + str(i), "nonce%d" % i, challenges, combos), + "dummy_key") + + path = gen_path(["dvsni", "recoveryContact"], challenges) + mock_chall_path.return_value = path + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + for i in range(5): + self.assertEqual( + len(self.handler.responses[str(i)]), len(challenges)) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + self.assertEqual( + self.handler.responses[dom], + self._get_exp_response(dom, path, challenges)) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual(len(self.handler.client_c[dom]), 1) + + self.assertEqual( + type(self.handler.dv_c[dom][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.client_c[dom][0].chall).__name__, + "RecContactChall") + + @mock.patch("letsencrypt.client.auth_handler.gen_challenge_path") + def test_name5_mix(self, mock_chall_path): + paths = [] + chosen_chall = [["dns"], + ["dvsni"], + ["simpleHttps", "proofOfPossession"], + ["simpleHttps"], + ["dns", "recoveryToken"]] + challenge_list = [acme_util.get_dv_challenges(), + [acme_util.CHALLENGES["dvsni"]], + acme_util.get_challenges(), + acme_util.get_dv_challenges(), + acme_util.get_challenges()] + + # Combos doesn't matter since I am overriding the gen_path function + for i in range(5): + dom = str(i) + paths.append(gen_path(chosen_chall[i], challenge_list[i])) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg( + dom, "nonce%d" % i, challenge_list[i]), + "dummy_key") + + mock_chall_path.side_effect = paths + + self.handler._satisfy_challenges() + + self.assertEqual(len(self.handler.responses), 5) + self.assertEqual(len(self.handler.dv_c), 5) + self.assertEqual(len(self.handler.client_c), 5) + + for i in range(5): + dom = str(i) + resp = self._get_exp_response(i, paths[i], challenge_list[i]) + self.assertEqual(self.handler.responses[dom], resp) + self.assertEqual(len(self.handler.dv_c[dom]), 1) + self.assertEqual( + len(self.handler.client_c[dom]), len(chosen_chall[i]) - 1) + + self.assertEqual( + type(self.handler.dv_c["0"][0].chall).__name__, "DnsChall") + self.assertEqual( + type(self.handler.dv_c["1"][0].chall).__name__, "DvsniChall") + self.assertEqual( + type(self.handler.dv_c["2"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["3"][0].chall).__name__, "SimpleHttpsChall") + self.assertEqual( + type(self.handler.dv_c["4"][0].chall).__name__, "DnsChall") + + self.assertEqual( + type(self.handler.client_c["2"][0].chall).__name__, "PopChall") + self.assertEqual( + type(self.handler.client_c["4"][0].chall).__name__, "RecTokenChall") + + def _get_exp_response(self, domain, path, challenges): + exp_resp = ["null"] * len(challenges) + for i in path: + exp_resp[i] = TRANSLATE[challenges[i]["type"]] + str(domain) + + return exp_resp + + +# pylint: disable=protected-access +class GetAuthorizationsTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + + self.mock_dv_auth = mock.MagicMock(name='ApacheConfigurator') + self.mock_client_auth = mock.MagicMock(name='ClientAuthenticator') + + self.mock_sat_chall = mock.MagicMock(name="_satisfy_challenges") + self.mock_acme_auth = mock.MagicMock(name="acme_authorization") + + self.iteration = 0 + + self.handler = AuthHandler( + self.mock_dv_auth, self.mock_client_auth, None) + + self.handler._satisfy_challenges = self.mock_sat_chall + self.handler.acme_authorization = self.mock_acme_auth + + def test_solved3_at_once(self): + # Set 3 DVSNI challenges + challenge = [acme_util.CHALLENGES["dvsni"]] + for i in range(3): + self.handler.add_chall_msg( + str(i), + acme_util.get_chall_msg(str(i), "nonce%d" % i, challenge), + "dummy_key") + + self.mock_sat_chall.side_effect = self._sat_solved_at_once + self.handler.get_authorizations() + + self.assertEqual(self.mock_sat_chall.call_count, 1) + self.assertEqual(self.mock_acme_auth.call_count, 3) + + exp_call_list = [mock.call("0"), mock.call("1"), mock.call("2")] + self.assertEqual( + self.mock_acme_auth.call_args_list, exp_call_list) + self._test_finished() + + def _sat_solved_at_once(self): + for i in range(3): + dom = str(i) + self.handler.responses[dom] = ["DvsniChall%d" % i] + self.handler.paths[dom] = [0] + # Assignment was > 80 char... + dv_c, c_c = self.handler._challenge_factory(dom, [0]) + + self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + + def test_progress_failure(self): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + challenges = acme_util.get_challenges() + self.handler.add_chall_msg( + "0", + acme_util.get_chall_msg("0", "nonce0", challenges), + "dummy_key") + + # Don't do anything to satisfy challenges + self.mock_sat_chall.side_effect = self._sat_failure + + self.assertRaises( + LetsEncryptAuthHandlerError, self.handler.get_authorizations) + + # Check to make sure program didn't loop + self.assertEqual(self.mock_sat_chall.call_count, 1) + + def _sat_failure(self): + dom = "0" + self.handler.paths[dom] = gen_path( + ["dns", "recoveryToken"], self.handler.msgs[dom]["challenges"]) + dv_c, c_c = self.handler._challenge_factory( + dom, self.handler.paths[dom]) + self.handler.dv_c[dom], self.handler.client_c[dom] = dv_c, c_c + + def test_incremental_progress(self): + challs = [] + challs.append(acme_util.get_challenges()) + challs.append(acme_util.get_dv_challenges()) + for i in range(2): + dom = str(i) + self.handler.add_chall_msg( + dom, + acme_util.get_chall_msg(dom, "nonce%d" % i, challs[i]), + "dummy_key") + + self.mock_sat_chall.side_effect = self._sat_incremental + + self.handler.get_authorizations() + + self._test_finished() + self.assertEqual(self.mock_acme_auth.call_args_list, + [mock.call("1"), mock.call("0")]) + + def _sat_incremental(self): + from letsencrypt.client.errors import LetsEncryptAuthHandlerError + + # Exact responses don't matter, just path/response match + if self.iteration == 0: + # Only solve one of "0" required challs + self.handler.responses["0"][1] = "onecomplete" + self.handler.responses["0"][3] = None + self.handler.responses["1"] = ["null", "null", "goodresp"] + self.handler.paths["0"] = [1, 3] + self.handler.paths["1"] = [2] + # This is probably overkill... but set it anyway + dv_c, c_c = self.handler._challenge_factory("0", [1, 3]) + self.handler.dv_c["0"], self.handler.client_c["0"] = dv_c, c_c + dv_c, c_c = self.handler._challenge_factory("1", [2]) + self.handler.dv_c["1"], self.handler.client_c["1"] = dv_c, c_c + + self.iteration += 1 + + elif self.iteration == 1: + # Quick check to make sure it was actually completed. + self.assertEqual( + self.mock_acme_auth.call_args_list, [mock.call("1")]) + self.handler.responses["0"][1] = "now_finish" + self.handler.responses["0"][3] = "finally!" + + else: + raise LetsEncryptAuthHandlerError( + "Failed incremental test: too many invocations") + + def _test_finished(self): + self.assertFalse(self.handler.msgs) + self.assertFalse(self.handler.dv_c) + self.assertFalse(self.handler.responses) + self.assertFalse(self.handler.paths) + self.assertFalse(self.handler.domains) + + +# pylint: disable=protected-access +class PathSatisfiedTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.auth_handler import AuthHandler + self.handler = AuthHandler(None, None, None) + + def test_satisfied_true(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["null", "sat", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = ["sat", None, None, "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = ["sat"] + + self.handler.paths[dom[3]] = [] + self.handler.responses[dom[3]] = [] + + self.handler.paths[dom[4]] = [] + self.handler.responses[dom[4]] = ["respond... sure"] + + for i in range(5): + self.assertTrue(self.handler._path_satisfied(dom[i])) + + def test_not_satisfied(self): + dom = ["0", "1", "2", "3", "4"] + self.handler.paths[dom[0]] = [1, 2] + self.handler.responses[dom[0]] = ["sat1", "null", "sat2", "null"] + + self.handler.paths[dom[1]] = [0] + self.handler.responses[dom[1]] = [None, "null", "null", "null"] + + self.handler.paths[dom[2]] = [0] + self.handler.responses[dom[2]] = [None] + + self.handler.paths[dom[3]] = [0] + self.handler.responses[dom[3]] = ["null"] + + for i in range(4): + self.assertFalse(self.handler._path_satisfied(dom[i])) + + +def gen_auth_resp(chall_list): + return ["%s%s" % (type(chall).__name__, chall.domain) + for chall in chall_list] + + +def gen_path(str_list, challenges): + path = [] + for i, chall in enumerate(challenges): + for str_chall in str_list: + if chall["type"] == str_chall: + path.append(i) + continue + return path + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/challenge_util_test.py b/letsencrypt/client/tests/challenge_util_test.py new file mode 100644 index 000000000..e0b99122f --- /dev/null +++ b/letsencrypt/client/tests/challenge_util_test.py @@ -0,0 +1,65 @@ +"""Tests for challenge_util.""" +import os +import pkg_resources +import re +import unittest + +import M2Crypto +import mock + +from letsencrypt.client import challenge_util +from letsencrypt.client import client +from letsencrypt.client import CONFIG +from letsencrypt.client import le_util + +# pylint: disable=too-few-public-methods +class DvsniGenCertTest(unittest.TestCase): + """Tests for letsencrypt.client.challenge_util.dvsni_gen_cert.""" + + def test_standard(self): + """Basic test for straightline code.""" + # This is a helper function that can be used for handling + # open context managers more elegantly. It avoids dealing with + # __enter__ and __exit__ calls. + # http://www.voidspace.org.uk/python/mock/helpers.html#mock.mock_open + m_open = mock.mock_open() + with mock.patch("letsencrypt.client.challenge_util.open", + m_open, create=True): + + domain = "example.com" + dvsni_r = "r_value" + r_b64 = le_util.jose_b64encode(dvsni_r) + pem = pkg_resources.resource_string( + __name__, os.path.join("testdata", "rsa256_key.pem")) + key = client.Client.Key("path", pem) + nonce = "12345ABCDE" + s_b64 = self._call("tmp.crt", domain, r_b64, nonce, key) + + self.assertTrue(m_open.called) + self.assertEqual(m_open.call_args[0], ("tmp.crt", 'w')) + self.assertEqual(m_open().write.call_count, 1) + + # pylint: disable=protected-access + ext = challenge_util._dvsni_gen_ext( + dvsni_r, le_util.jose_b64decode(s_b64)) + self._standard_check_cert( + m_open().write.call_args[0][0], domain, nonce, ext) + + def _standard_check_cert(self, pem, domain, nonce, ext): + """Check the certificate fields.""" + dns_regex = r"DNS:([^, $]*)" + cert = M2Crypto.X509.load_cert_string(pem) + self.assertEqual( + cert.get_subject().CN, nonce + CONFIG.INVALID_EXT) + + sans = cert.get_ext("subjectAltName").get_value() + + exp_sans = set([nonce + CONFIG.INVALID_EXT, domain, ext]) + act_sans = set(re.findall(dns_regex, sans)) + + self.assertEqual(exp_sans, act_sans) + + # pylint: disable= no-self-use + def _call(self, filepath, name, r_b64, nonce, key): + from letsencrypt.client.challenge_util import dvsni_gen_cert + return dvsni_gen_cert(filepath, name, r_b64, nonce, key) diff --git a/letsencrypt/client/tests/client_authenticator_test.py b/letsencrypt/client/tests/client_authenticator_test.py new file mode 100644 index 000000000..6027e1dba --- /dev/null +++ b/letsencrypt/client/tests/client_authenticator_test.py @@ -0,0 +1,80 @@ +import unittest + +import mock + + +class PerformTest(unittest.TestCase): + """Test client perform function.""" + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.auth.rec_token.perform = mock.MagicMock( + name="rec_token_perform", side_effect=gen_client_resp) + + def test_rec_token1(self): + from letsencrypt.client.challenge_util import RecTokenChall + token = RecTokenChall("0") + + responses = self.auth.perform([token]) + + self.assertEqual(responses, ["RecTokenChall0"]) + + def test_rec_token5(self): + from letsencrypt.client.challenge_util import RecTokenChall + tokens = [] + for i in range(5): + tokens.append(RecTokenChall(str(i))) + + responses = self.auth.perform(tokens) + + self.assertEqual(len(responses), 5) + for i in range(5): + self.assertEqual(responses[i], "RecTokenChall%d" % i) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + unexpected = DvsniChall("0", "rb64", "123", "invalid_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.perform, [unexpected]) + + +class CleanupTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.client_authenticator import ClientAuthenticator + + self.auth = ClientAuthenticator("demo_server.org") + self.mock_cleanup = mock.MagicMock(name="rec_token_cleanup") + self.auth.rec_token.cleanup = self.mock_cleanup + + def test_rec_token2(self): + from letsencrypt.client.challenge_util import RecTokenChall + token1 = RecTokenChall("0") + token2 = RecTokenChall("1") + + self.auth.cleanup([token1, token2]) + + self.assertEqual(self.mock_cleanup.call_args_list, + [mock.call(token1), mock.call(token2)]) + + def test_unexpected(self): + from letsencrypt.client.challenge_util import DvsniChall + from letsencrypt.client.challenge_util import RecTokenChall + from letsencrypt.client.errors import LetsEncryptClientAuthError + + token = RecTokenChall("0") + unexpected = DvsniChall("0", "rb64", "123", "dummy_key") + + self.assertRaises( + LetsEncryptClientAuthError, self.auth.cleanup, [token, unexpected]) + + +def gen_client_resp(chall): + return "%s%s" % (type(chall).__name__, chall.domain) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/crypto_util_test.py b/letsencrypt/client/tests/crypto_util_test.py new file mode 100644 index 000000000..e80988d83 --- /dev/null +++ b/letsencrypt/client/tests/crypto_util_test.py @@ -0,0 +1,178 @@ +"""Tests for letsencrypt.client.crypto_util.""" +import datetime +import os +import pkg_resources +import unittest + +import M2Crypto + + +RSA256_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa256_key.pem') +RSA512_KEY = pkg_resources.resource_string(__name__, 'testdata/rsa512_key.pem') + + +class CreateSigTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.create_sig.""" + + def setUp(self): + self.nonce = '\xec\xd6\xf2oYH\xeb\x13\xd5#q\xe0\xdd\xa2\x92\xa9' + self.b64nonce = '7Nbyb1lI6xPVI3Hg3aKSqQ' + self.signature = { + 'nonce': self.b64nonce, + 'alg': 'RS256', + 'jwk': { + 'kty': 'RSA', + 'e': 'AQAB', + 'n': 'rHVztFHtH92ucFJD_N_HW9AsdRsUuHUBBBDlHwNlRd3fp5' + '80rv2-6QWE30cWgdmJS86ObRz6lUTor4R0T-3C5Q', + }, + 'sig': 'SUPYKucUnhlTt8_sMxLiigOYdf_wlOLXPI-o7aRLTsOquVjDd6r' + 'AX9AFJHk-bCMQPJbSzXKjG6H1IWbvxjS2Ew', + } + + def _call(self, *args, **kwargs): + from letsencrypt.client.crypto_util import create_sig + return create_sig(*args, **kwargs) + + def test_it(self): + self.assertEqual( + self._call('message', RSA256_KEY, self.nonce), self.signature) + + def test_random_nonce(self): + signature = self._call('message', RSA256_KEY) + signature.pop('sig') + signature.pop('nonce') + del self.signature['sig'] + del self.signature['nonce'] + self.assertEqual(signature, self.signature) + + +class ValidCSRTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.valid_csr.""" + + def _call(self, csr): + from letsencrypt.client.crypto_util import valid_csr + return valid_csr(csr) + + def _call_testdata(self, name): + return self._call(pkg_resources.resource_string( + __name__, os.path.join('testdata', name))) + + def test_valid_pem_true(self): + self.assertTrue(self._call_testdata('csr.pem')) + + def test_valid_pem_san_true(self): + self.assertTrue(self._call_testdata('csr-san.pem')) + + def test_valid_der_false(self): + self.assertFalse(self._call_testdata('csr.der')) + + def test_valid_der_san_false(self): + self.assertFalse(self._call_testdata('csr-san.der')) + + def test_empty_false(self): + self.assertFalse(self._call('')) + + def test_random_false(self): + self.assertFalse(self._call('foo bar')) + + +class CSRMatchesPubkeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.csr_matches_pubkey.""" + + def _call_testdata(self, name, privkey): + from letsencrypt.client.crypto_util import csr_matches_pubkey + return csr_matches_pubkey(pkg_resources.resource_string( + __name__, os.path.join('testdata', name)), privkey) + + def test_valid_true(self): + self.assertTrue(self._call_testdata('csr.pem', RSA256_KEY)) + + def test_invalid_false(self): + self.assertFalse(self._call_testdata('csr.pem', RSA512_KEY)) + + +class MakeKeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.make_key.""" + + def test_it(self): + from letsencrypt.client.crypto_util import make_key + M2Crypto.RSA.load_key_string(make_key(1024)) + + +class ValidPrivkeyTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.valid_privkey.""" + + def _call(self, privkey): + from letsencrypt.client.crypto_util import valid_privkey + return valid_privkey(privkey) + + def test_valid_true(self): + self.assertTrue(self._call(RSA256_KEY)) + + def test_empty_false(self): + self.assertFalse(self._call('')) + + def test_random_false(self): + self.assertFalse(self._call('foo bar')) + + +class MakeSSCertTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.make_ss_cert.""" + + def test_it(self): + from letsencrypt.client.crypto_util import make_ss_cert + make_ss_cert(RSA256_KEY, ['example.com', 'www.example.com']) + + +class GetCertInfoTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.get_cert_info.""" + + def setUp(self): + self.cert_info = { + 'not_before': datetime.datetime( + 2014, 12, 11, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), + 'not_after': datetime.datetime( + 2014, 12, 18, 22, 34, 45, tzinfo=M2Crypto.ASN1.UTC), + 'subject': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' + 'of Michigan and the EFF, CN=example.com', + 'cn': 'example.com', + 'issuer': 'C=US, ST=Michigan, L=Ann Arbor, O=University ' + 'of Michigan and the EFF, CN=example.com', + 'serial': 1337L, + 'pub_key': 'RSA 512', + } + + def _call(self, name): + from letsencrypt.client.crypto_util import get_cert_info + self.assertEqual(get_cert_info(pkg_resources.resource_filename( + __name__, os.path.join('testdata', name))), self.cert_info) + + def test_single_domain(self): + self.cert_info.update({ + 'san': '', + 'fingerprint': '9F8CE01450D288467C3326AC0457E351939C72E', + }) + self._call('cert.pem') + + def test_san(self): + self.cert_info.update({ + 'san': 'DNS:example.com, DNS:www.example.com', + 'fingerprint': '62F7110431B8E8F55905DBE5592518F9634AC50A', + }) + self._call('cert-san.pem') + + +class B64CertToPEMTest(unittest.TestCase): + """Tests for letsencrypt.client.crypto_util.b64_cert_to_pem.""" + + def test_it(self): + from letsencrypt.client.crypto_util import b64_cert_to_pem + self.assertEqual( + b64_cert_to_pem(pkg_resources.resource_string( + __name__, 'testdata/cert.b64jose')), + pkg_resources.resource_string(__name__, 'testdata/cert.pem')) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/le_util_test.py b/letsencrypt/client/tests/le_util_test.py index 2432a6d65..f6c58ac0b 100644 --- a/letsencrypt/client/tests/le_util_test.py +++ b/letsencrypt/client/tests/le_util_test.py @@ -83,6 +83,9 @@ class UniqueFileTest(unittest.TestCase): self.root_path = tempfile.mkdtemp() self.default_name = os.path.join(self.root_path, 'foo.txt') + def tearDown(self): + shutil.rmtree(self.root_path, ignore_errors=True) + def _call(self, mode=0o600): from letsencrypt.client.le_util import unique_file return unique_file(self.default_name, mode) diff --git a/letsencrypt/client/tests/recovery_token_test.py b/letsencrypt/client/tests/recovery_token_test.py new file mode 100644 index 000000000..d3d82e8ad --- /dev/null +++ b/letsencrypt/client/tests/recovery_token_test.py @@ -0,0 +1,64 @@ +"""Tests for recovery_token.py.""" +import os +import unittest +import shutil +import tempfile + +import mock + + +class RecoveryTokenTest(unittest.TestCase): + def setUp(self): + from letsencrypt.client.recovery_token import RecoveryToken + server = "demo_server" + self.base_dir = tempfile.mkdtemp("tokens") + self.token_dir = os.path.join(self.base_dir, server) + self.rec_token = RecoveryToken(server, self.base_dir) + + def tearDown(self): + shutil.rmtree(self.base_dir) + + def test_store_token(self): + self.rec_token.store_token("example.com", 111) + path = os.path.join(self.token_dir, "example.com") + self.assertTrue(os.path.isfile(path)) + with open(path) as token_fd: + self.assertEqual(token_fd.read(), "111") + + def test_requires_human(self): + self.rec_token.store_token("example2.com", 222) + self.assertFalse(self.rec_token.requires_human("example2.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + def test_cleanup(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example3.com", 333) + self.assertFalse(self.rec_token.requires_human("example3.com")) + + self.rec_token.cleanup(RecTokenChall("example3.com")) + self.assertTrue(self.rec_token.requires_human("example3.com")) + + # Shouldn't throw an error + self.rec_token.cleanup(RecTokenChall("example4.com")) + + def test_perform_stored(self): + from letsencrypt.client.challenge_util import RecTokenChall + self.rec_token.store_token("example4.com", 444) + response = self.rec_token.perform(RecTokenChall("example4.com")) + + self.assertEqual(response, {"type": "recoveryToken", "token": "444"}) + + @mock.patch("letsencrypt.client.recovery_token.zope.component.getUtility") + def test_perform_not_stored(self, mock_input): + from letsencrypt.client.challenge_util import RecTokenChall + + mock_input().generic_input.side_effect = [(0, "555"), (1, "000")] + response = self.rec_token.perform(RecTokenChall("example5.com")) + self.assertEqual(response, {"type": "recoveryToken", "token": "555"}) + + response = self.rec_token.perform(RecTokenChall("example6.com")) + self.assertTrue(response is None) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/tests/testdata/cert-san.pem b/letsencrypt/client/tests/testdata/cert-san.pem new file mode 100644 index 000000000..dcb835994 --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert-san.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICFjCCAcCgAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx +ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM +IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4 +YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG +A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix +KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR +7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c ++pVE6K+EdE/twuUCAwEAAaM2MDQwCQYDVR0TBAIwADAnBgNVHREEIDAeggtleGFt +cGxlLmNvbYIPd3d3LmV4YW1wbGUuY29tMA0GCSqGSIb3DQEBCwUAA0EASuvNKFTF +nTJsvnSXn52f4BMZJJ2id/kW7+r+FJRm+L20gKQ1aqq8d3e/lzRUrv5SMf1TAOe7 +RDjyGMKy5ZgM2w== +-----END CERTIFICATE----- diff --git a/letsencrypt/client/tests/testdata/cert.b64jose b/letsencrypt/client/tests/testdata/cert.b64jose new file mode 100644 index 000000000..fa1abdb9f --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert.b64jose @@ -0,0 +1 @@ +MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR7R_drnBSQ_zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c-pVE6K-EdE_twuUCAwEAATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksllvr6zJepBH5fMndfk3XJp10jT6VE-14KNtjh02a56GoraAvJAT5_H67E8GvJ_ocNnB_o \ No newline at end of file diff --git a/letsencrypt/client/tests/testdata/cert.pem b/letsencrypt/client/tests/testdata/cert.pem new file mode 100644 index 000000000..96c55cbf4 --- /dev/null +++ b/letsencrypt/client/tests/testdata/cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB3jCCAYigAwIBAgICBTkwDQYJKoZIhvcNAQELBQAwdzELMAkGA1UEBhMCVVMx +ETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3IxKzApBgNVBAoM +IlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDASBgNVBAMMC2V4 +YW1wbGUuY29tMB4XDTE0MTIxMTIyMzQ0NVoXDTE0MTIxODIyMzQ0NVowdzELMAkG +A1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIwEAYDVQQHDAlBbm4gQXJib3Ix +KzApBgNVBAoMIlVuaXZlcnNpdHkgb2YgTWljaGlnYW4gYW5kIHRoZSBFRkYxFDAS +BgNVBAMMC2V4YW1wbGUuY29tMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAKx1c7RR +7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79vukFhN9HFoHZiUvOjm0c ++pVE6K+EdE/twuUCAwEAATANBgkqhkiG9w0BAQsFAANBAC24z0IdwIVKSlntksll +vr6zJepBH5fMndfk3XJp10jT6VE+14KNtjh02a56GoraAvJAT5/H67E8GvJ/ocNn +B/o= +-----END CERTIFICATE----- diff --git a/letsencrypt/client/tests/testdata/csr-san.der b/letsencrypt/client/tests/testdata/csr-san.der new file mode 100644 index 000000000..68fd38723 Binary files /dev/null and b/letsencrypt/client/tests/testdata/csr-san.der differ diff --git a/letsencrypt/client/tests/testdata/csr-san.pem b/letsencrypt/client/tests/testdata/csr-san.pem new file mode 100644 index 000000000..a7128e35c --- /dev/null +++ b/letsencrypt/client/tests/testdata/csr-san.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBbjCCARgCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw +EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy +c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG +9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3f +p580rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABoDowOAYJKoZIhvcN +AQkOMSswKTAnBgNVHREEIDAeggtleGFtcGxlLmNvbYIPd3d3LmV4YW1wbGUuY29t +MA0GCSqGSIb3DQEBCwUAA0EAZGBM8J1rRs7onFgtc76mOeoT1c3v0ZsEmxQfb2Wy +tmReY6X1N4cs38D9VSow+VMRu2LWkKvzS7RUFSaTaeQz1A== +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt/client/tests/testdata/csr.der b/letsencrypt/client/tests/testdata/csr.der new file mode 100644 index 000000000..22900a612 Binary files /dev/null and b/letsencrypt/client/tests/testdata/csr.der differ diff --git a/letsencrypt/client/tests/testdata/csr.pem b/letsencrypt/client/tests/testdata/csr.pem new file mode 100644 index 000000000..b6818e39d --- /dev/null +++ b/letsencrypt/client/tests/testdata/csr.pem @@ -0,0 +1,10 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw +EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy +c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG +9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3f +p580rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABoCkwJwYJKoZIhvcN +AQkOMRowGDAWBgNVHREEDzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAANB +AHJH/O6BtC9aGzEVCMGOZ7z9iIRHWSzr9x/bOzn7hLwsbXPAgO1QxEwL+X+4g20G +n9XBE1N9W6HCIEut2d8wACg= +-----END CERTIFICATE REQUEST----- diff --git a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites b/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites index 5a7ad7622..3e73390fd 100644 --- a/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites +++ b/letsencrypt/client/tests/testdata/debian_apache_2_4/two_vhost_80/sites @@ -1,2 +1,2 @@ -sites-available/letsencrypt.conf, letencrypt.demo +sites-available/letsencrypt.conf, letsencrypt.demo sites-available/encryption-example.conf, encryption-example.demo diff --git a/letsencrypt/client/tests/testdata/rsa256_key.pem b/letsencrypt/client/tests/testdata/rsa256_key.pem new file mode 100644 index 000000000..610c8d315 --- /dev/null +++ b/letsencrypt/client/tests/testdata/rsa256_key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKx1c7RR7R/drnBSQ/zfx1vQLHUbFLh1AQQQ5R8DZUXd36efNK79 +vukFhN9HFoHZiUvOjm0c+pVE6K+EdE/twuUCAwEAAQJAMbrEnJCrQe8YqAbw1/Bn +elAzIamndfE3U8bTavf9sgFpS4HL83rhd6PDbvx81ucaJAT/5x048fM/nFl4fzAc +mQIhAOF/a9o3EIsDKEmUl+Z1OaOiUxDF3kqWSmALEsmvDhwXAiEAw8ljV5RO/rUp +Zu2YMDFq3MKpyyMgBIJ8CxmGRc6gCmMCIGRQzkcmhfqBrhOFwkmozrqIBRIKJIjj +8TRm2LXWZZ2DAiAqVO7PztdNpynugUy4jtbGKKjBrTSNBRGA7OHlUgm0dQIhALQq +6oGU29Vxlvt3k0vmiRKU4AVfLyNXIGtcWcNG46h/ +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/client/tests/testdata/rsa512_key.pem b/letsencrypt/client/tests/testdata/rsa512_key.pem new file mode 100644 index 000000000..709b6d8e3 --- /dev/null +++ b/letsencrypt/client/tests/testdata/rsa512_key.pem @@ -0,0 +1,9 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIBOwIBAAJBAPS2EXFRNza/qpXnnBHF/CcFQ543htV+7nLAmrLrmTNHtPXJmLlM +8SJDIzv/ceAFXL110VzxFfi81lpH5E5c0TMCAwEAAQJBALmppYQ/JVARjWBcsEm/ +1/bXBJ127YLv4gQIY5baL4r6IdEE33OXMTTmD9wf+ajuq1eaH0htHkwhOvREu0sz +bskCIQD/Cg+xhEVLcwK3pFp3afPIhj1IPFiL3Uy/nqyMZ6O/RQIhAPWiDBofp7Cp +J4dGZs+hkRySq/IOeeRJlNK1Pq64nToXAiBZ7+te1100YSd5KT051SRB94zO13EG +SZESFduVW8rz3QIgK+tLiqg6TYYRQUi/PUTAM4GuKNuZw828RGiPyqHLywUCIQCd +pkZrNphL/y0D7HSbPIfZzD90M2V8tUjlK0BTqk1bHA== +-----END RSA PRIVATE KEY----- diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py deleted file mode 100644 index 716d1528f..000000000 --- a/letsencrypt/client/validator.py +++ /dev/null @@ -1,14 +0,0 @@ -class Validator(object): - """Configuration validator.""" - - def redirect(self, name): - raise NotImplementedError() - - def ocsp_stapling(self, name): - raise NotImplementedError() - - def https(self, names): - raise NotImplementedError() - - def hsts(self, name): - raise NotImplementedError() diff --git a/letsencrypt/scripts/main.py b/letsencrypt/scripts/main.py index 8cbda62dc..12db6e33d 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -5,11 +5,16 @@ import logging import os import sys -from letsencrypt.client import apache_configurator +import zope.component + from letsencrypt.client import CONFIG from letsencrypt.client import client from letsencrypt.client import display +from letsencrypt.client import interfaces +from letsencrypt.client import errors from letsencrypt.client import log +from letsencrypt.client import revoker +from letsencrypt.client.apache import configurator def main(): @@ -62,9 +67,40 @@ def main(): # Set up logging logger = logging.getLogger() - logger.setLevel(logging.INFO) # TODO: --log + logger.setLevel(logging.INFO) if args.use_curses: logger.addHandler(log.DialogHandler()) + displayer = display.NcursesDisplay() + else: + displayer = display.FileDisplay(sys.stdout) + zope.component.provideUtility(displayer) + + installer = determine_installer() + server = CONFIG.ACME_SERVER if args.server is None else args.server + + if args.revoke: + revoc = revoker.Revoker(server, installer) + revoc.list_certs_keys() + sys.exit() + + if args.rollback > 0: + rollback(installer, args.rollback) + sys.exit() + + if args.view_checkpoints: + view_checkpoints(installer) + sys.exit() + + # Use the same object if possible + if interfaces.IAuthenticator.providedBy(installer): + auth = installer + else: + auth = determine_authenticator() + + if not args.eula: + display_eula() + + domains = choose_names(installer) if args.domains is None else args.domains # Enforce '--privkey' is set along with '--csr'. if args.csr and not args.privkey: @@ -72,36 +108,88 @@ def main(): "with the certificate signing request file (--csr)" .format(os.linesep)) - if args.use_curses: - display.set_display(display.NcursesDisplay()) - else: - display.set_display(display.FileDisplay(sys.stdout)) - - if args.rollback > 0: - rollback(apache_configurator.ApacheConfigurator(), args.rollback) - sys.exit() - - if args.view_checkpoints: - view_checkpoints(apache_configurator.ApacheConfigurator()) - sys.exit() - - server = args.server is None and CONFIG.ACME_SERVER or args.server - # Prepare for init of Client if args.privkey is None: - privkey = client.Client.Key(None, None) + privkey = client.init_key() else: privkey = client.Client.Key(args.privkey[0], args.privkey[1]) if args.csr is None: - csr = client.Client.CSR(None, None, None) + csr = client.init_csr(privkey, domains) else: - csr = client.Client.CSR(args.csr[0], args.csr[1], "pem") + csr = client.csr_pem_to_der( + client.Client.CSR(args.csr[0], args.csr[1], "pem")) - acme = client.Client(server, csr, privkey, args.use_curses) - if args.revoke: - acme.list_certs_keys() + acme = client.Client(server, domains, privkey, auth, installer) + + # Validate the key and csr + client.validate_key_csr(privkey, csr) + + cert_file, chain_file = acme.obtain_certificate(csr) + vhost = acme.deploy_certificate(privkey, cert_file, chain_file) + acme.optimize_config(vhost, args.redirect) + + +def display_eula(): + """Displays the end user agreement.""" + with open('EULA') as eula_file: + if not zope.component.getUtility(interfaces.IDisplay).generic_yesno( + eula_file.read(), "Agree", "Cancel"): + sys.exit(0) + + +def choose_names(installer): + """Display screen to select domains to validate. + + :param installer: An installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ + # This function adds all names + # found within the config to self.names + # Then filters them based on user selection + code, names = zope.component.getUtility( + interfaces.IDisplay).filter_names(get_all_names(installer)) + if code == display.OK and names: + return names else: - acme.authenticate(args.domains, args.eula, args.redirect) + sys.exit(0) + + +def get_all_names(installer): + """Return all valid names in the configuration. + + :param installer: An installer object + :type installer: :class:`letsencrypt.client.interfaces.IInstaller` + + """ + names = list(installer.get_all_names()) + client.sanity_check_names(names) + + if not names: + logging.fatal("No domain names were found in your installation") + logging.fatal("Either specify which names you would like " + "letsencrypt to validate or add server names " + "to your virtual hosts") + sys.exit(1) + + return names + + +# This should be controlled by commandline parameters +def determine_authenticator(): + """Returns a valid authenticator.""" + try: + return configurator.ApacheConfigurator() + except errors.LetsEncryptConfiguratorError: + logging.info("Unable to find a way to authenticate.") + + +def determine_installer(): + """Returns a valid installer if one exists.""" + try: + return configurator.ApacheConfigurator() + except errors.LetsEncryptConfiguratorError: + logging.info("Unable to find a way to install the certificate.") def read_file(filename): @@ -116,7 +204,7 @@ def read_file(filename): """ try: - return filename, file(filename, 'rU').read() + return filename, open(filename, 'rU').read() except IOError as exc: raise argparse.ArgumentTypeError(exc.strerror) @@ -143,6 +231,5 @@ def view_checkpoints(config): """ config.display_checkpoints() - if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index 24ff3752d..c356e0841 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,8 @@ install_requires = [ 'python-augeas', 'python2-pythondialog', 'requests', + 'zope.component', + 'zope.interface', ] docs_extras = [ @@ -35,6 +37,8 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.client', + 'letsencrypt.client.apache', + 'letsencrypt.client.tests', 'letsencrypt.scripts', ], install_requires=install_requires, diff --git a/tox.ini b/tox.ini index a26a52145..b2d7b9ae9 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ commands = [testenv:cover] commands = python setup.py dev - python setup.py nosetests --with-coverage --cover-min-percentage=45 + python setup.py nosetests --with-coverage --cover-min-percentage=59 [testenv:lint] commands =