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 similarity index 57% rename from letsencrypt/client/apache_configurator.py rename to letsencrypt/client/apache/configurator.py index 2a2f77812..2e2a02238 100644 --- a/letsencrypt/client/apache_configurator.py +++ b/letsencrypt/client/apache/configurator.py @@ -1,5 +1,4 @@ """Apache Configuration based off of Augeas Configurator.""" -import hashlib import logging import os import pkg_resources @@ -9,21 +8,17 @@ import socket import subprocess import sys -from Crypto import Random +import zope.interface from letsencrypt.client import augeas_configurator from letsencrypt.client import challenge_util from letsencrypt.client import CONFIG -from letsencrypt.client import crypto_util from letsencrypt.client import errors +from letsencrypt.client import interfaces 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. +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. @@ -31,7 +26,7 @@ from letsencrypt.client import le_util # 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 +# 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 @@ -44,51 +39,6 @@ from letsencrypt.client import le_util # 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. @@ -107,6 +57,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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 client's are clarified with the new and developing protocol. @@ -116,11 +67,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): with the configuration :ivar float version: version of Apache :ivar list vhosts: All vhosts found in the configuration - (:class:`list` of :class:`VH`) + (: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. @@ -143,10 +96,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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... + # This needs to occur before VirtualHost objects are setup... # because this will change the underlying configuration and potential # vhosts self.recovery_routine() @@ -155,22 +106,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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/*")) + 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 - # 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 @@ -188,9 +130,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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. @@ -206,7 +145,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): This shouldn't happen within letsencrypt though :param vhost: ssl vhost to deploy certificate - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param str cert: certificate filename :param str key: private key filename @@ -218,15 +157,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """ path = {} - path["cert_file"] = self.find_directive(case_i( + path["cert_file"] = self.parser.find_dir(parser.case_i( "SSLCertificateFile"), None, vhost.path) - path["cert_key"] = self.find_directive(case_i( + 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.find_directive( - case_i("SSLCertificateChainFile"), None, vhost.path) + 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" @@ -242,7 +181,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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) + self.parser.add_dir( + vhost.path, "SSLCertificateChainFile", cert_chain) else: self.aug.set(path["cert_chain"][0], cert_chain) @@ -264,7 +204,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str name: domain name :returns: ssl vhost associated with name - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ # Allows for domain names to be associated with a virtual host @@ -274,31 +214,24 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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 + 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: - for addr in vhost.addrs: - tup = addr.partition(":") - if tup[0] == target_name and tup[2] == "443": - return vhost + 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: - 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) + 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: - for addr in vhost.addrs: - if addr == "_default_:443": - return vhost + if "_defualt_:443" in vhost.addrs: + return vhost return None def create_dn_server_assoc(self, domain, vhost): @@ -309,7 +242,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str domain: domain name to associate :param vhost: virtual host to associate with domain - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` """ self.assoc[domain] = vhost @@ -332,86 +265,29 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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]): + if not private_ips.match(addr.get_addr()): try: - socket.inet_aton(a_tup[0]) - all_names.add(socket.gethostbyaddr(a_tup[0])[0]) + 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 _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.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, root, 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"), - get_aug_path(root))): - 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` + :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, - case_i('ServerName'), + parser.case_i('ServerName'), host.path, - case_i('ServerAlias')))) + parser.case_i('ServerAlias')))) for name in name_match: args = self.aug.match(name + "/*") @@ -424,22 +300,22 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param str path: Augeas path to virtual host :returns: newly created vhost - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ - addrs = [] + addrs = set() args = self.aug.match(path + "/arg") for arg in args: - addrs.append(self.aug.get(arg)) + addrs.add(obj.Addr.fromstring(self.aug.get(arg))) is_ssl = False - if self.find_directive( - case_i("SSLEngine"), case_i("on"), path): + 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 = VH(filename, path, addrs, is_ssl, is_enabled) + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) self._add_servernames(vhost) return vhost @@ -447,14 +323,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): def get_virtual_hosts(self): """Returns list of virtual hosts found in the Apache configuration. - :returns: List of :class:`VH` objects found in 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.server_root, case_i('VirtualHost')))) + (self.parser.root, parser.case_i('VirtualHost')))) vhs = [] for path in paths: @@ -482,8 +360,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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))) + 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. @@ -491,33 +370,13 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :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) + 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 _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. @@ -532,18 +391,18 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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: + 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._add_dir_to_ifmodssl( - get_aug_path(self.location["listen"]), "Listen", "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: VHost to check SNI compatibility - :type vhost: :class:`VH` + :param vhost: VirtualHostost to check SNI compatibility + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :param str default_addr: TODO - investigate function further @@ -553,8 +412,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): # 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 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) @@ -567,178 +425,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "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. @@ -746,14 +432,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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` + :type nonssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: SSL vhost - :rtype: :class:`VH` + :rtype: :class:`letsencrypt.client.apache.obj.VirtualHost` """ avail_fp = nonssl_vhost.filep - # Copy file + # Get filepath of new ssl_vhost if avail_fp.endswith(".conf"): ssl_fp = avail_fp[:-(len(".conf"))] + CONFIG.LE_VHOST_EXT else: @@ -778,41 +464,33 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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 + 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, case_i('VirtualHost'))) - avail_addr_p = self.aug.match( - addr_match % (avail_fp, case_i('VirtualHost'))) + addr_match % (ssl_fp, parser.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) + 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, case_i('VirtualHost')))) + 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.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"]) + 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) @@ -823,19 +501,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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 + # 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() @@ -851,10 +529,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): The function then adds the directive :param ssl_vhost: Destination of traffic, an ssl enabled vhost - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success, general_vhost (HTTP vhost) - :rtype: (bool, :class:`VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) """ # TODO: Enable check to see if it is already there @@ -878,9 +556,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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.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() @@ -900,16 +578,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): -1 is also returned in case of no redirection/rewrite directives :param vhost: vhost to check - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :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) + 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" @@ -931,10 +609,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): """Creates an http_vhost specifically to redirect for the ssl_vhost. :param ssl_vhost: ssl vhost - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success, vhost - :rtype: (bool, :class:`VH`) + :rtype: (bool, :class:`letsencrypt.client.apache.obj.VirtualHost`) """ # Consider changing this to a dictionary check @@ -954,17 +632,19 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): 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" + 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 @@ -978,7 +658,7 @@ LogLevel warn \n\ redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] redirect_filepath = ("%ssites-available/%s" % - (self.server_root, redirect_filename)) + (self.parser.root, redirect_filename)) # Register the new file that will be created # Note: always register the creation before writing to ensure file will @@ -992,8 +672,8 @@ LogLevel warn \n\ 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)) + 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 @@ -1015,7 +695,7 @@ LogLevel warn \n\ 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` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: TODO :rtype: TODO @@ -1026,21 +706,15 @@ LogLevel warn \n\ 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" + ssl_a_vhttp = ssl_a.get_addr_obj("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 + 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 @@ -1054,18 +728,18 @@ LogLevel warn \n\ Consider changing this into a dict check :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`VH` + :type ssl_vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`VH` or None + :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 == ["_default_:443"]: - ssl_addrs = ["*:443"] + if ssl_addrs == obj.Addr.fromstring("_default_:443"): + ssl_addrs = [obj.Addr.fromstring("*:443")] for vhost in self.vhosts: found = 0 @@ -1073,28 +747,28 @@ LogLevel warn \n\ 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]: + if test_a.get_addr() == ssl_a.get_addr(): # Check if found... - if (test_tup[2] == "80" or - test_tup[2] == "" or - test_tup[2] == "*"): + 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 - set(vhost.names) == set(ssl_vhost.names)): + vhost.names == ssl_vhost.names): return vhost return None - # TODO - both of these + # TODO: Handle ths 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): @@ -1110,10 +784,10 @@ LogLevel warn \n\ 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) + 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: @@ -1138,7 +812,7 @@ LogLevel warn \n\ :rtype: bool """ - enabled_dir = os.path.join(self.server_root, "sites-enabled/") + enabled_dir = os.path.join(self.parser.root, "sites-enabled/") for entry in os.listdir(enabled_dir): if os.path.realpath(enabled_dir + entry) == avail_fp: return True @@ -1153,7 +827,7 @@ LogLevel warn \n\ .. todo:: Make sure link is not broken... :param vhost: vhost to enable - :type vhost: :class:`VH` + :type vhost: :class:`letsencrypt.client.apache.obj.VirtualHost` :returns: Success :rtype: bool @@ -1164,7 +838,7 @@ LogLevel warn \n\ if "/sites-available/" in vhost.filep: enabled_path = ("%ssites-enabled/%s" % - (self.server_root, os.path.basename(vhost.filep))) + (self.parser.root, os.path.basename(vhost.filep))) self.register_file_creation(False, enabled_path) os.symlink(vhost.filep, enabled_path) vhost.enabled = True @@ -1173,83 +847,7 @@ LogLevel warn \n\ 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 + def restart(self): # pylint: disable=no-self-use """Restarts apache server. :returns: Success @@ -1258,19 +856,6 @@ LogLevel warn \n\ """ 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. @@ -1365,8 +950,8 @@ LogLevel warn \n\ `chall_dict` composed of: list_sni_tuple: - List of tuples with form `(addr, r, nonce)`, where - `addr` (`str`), `r` (base64 `str`), `nonce` (hex `str`) + List of tuples with form `(name, r, nonce)`, where + `name` (`str`), `r` (base64 `str`), `nonce` (hex `str`) dvsni_key: DVSNI key (:class:`letsencrypt.client.client.Client.Key`) @@ -1399,11 +984,11 @@ LogLevel warn \n\ self.make_server_sni_ready(vhost, default_addr) for addr in vhost.addrs: - if "_default_" in addr: + if "_default_" == addr.get_addr(): addresses.append([default_addr]) break else: - addresses.append(vhost.addrs) + addresses.append(list(vhost.addrs)) responses = [] @@ -1423,7 +1008,7 @@ LogLevel warn \n\ # Save reversible changes and restart the server self.save("SNI Challenge", True) - self.restart(True) + self.restart() return responses @@ -1431,7 +1016,7 @@ LogLevel warn \n\ """Revert all challenges.""" self.revert_challenge_config() - self.restart(True) + self.restart() # TODO: Variable names def dvsni_mod_config(self, list_sni_tuple, dvsni_key, @@ -1447,7 +1032,8 @@ LogLevel warn \n\ :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 + :param list ll_addrs: list of list of + :class:`letsencrypt.client.apache.obj.Addr` to apply """ # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY @@ -1463,13 +1049,13 @@ LogLevel warn \n\ shutil.copyfile(dist_conf, CONFIG.OPTIONS_SSL_CONF) # TODO: Use ip address of existing vhost instead of relying on FQDN - config_text = " \n" + 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" + config_text += "\n" - self.dvsni_conf_include_check(self.location["default"]) + self.dvsni_conf_include_check(self.parser.loc["default"]) self.register_file_creation(True, CONFIG.APACHE_CHALLENGE_CONF) with open(CONFIG.APACHE_CHALLENGE_CONF, 'w') as new_conf: @@ -1484,36 +1070,38 @@ LogLevel warn \n\ :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: + if len(self.parser.find_dir( + parser.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) + self.parser.add_dir(parser.get_aug_path(main_config), + "Include", CONFIG.APACHE_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 str ip_addrs: addresses of challenged domain + :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 """ - return (" \n" - "ServerName " + nonce + CONFIG.INVALID_EXT + " \n" - "UseCanonicalName on \n" - "SSLStrictSNIVHostCheck on \n" + 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" + "LimitRequestBody 1048576\n" "\n" - "Include " + self.location["ssl_options"] + " \n" - "SSLCertificateFile " + self.dvsni_get_cert_file(nonce) + " \n" - "SSLCertificateKeyFile " + dvsni_key_file + " \n" + "Include " + self.parser.loc["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") + "DocumentRoot " + self.direc["config"] + "challenge_page/\n" + "\n\n") def dvsni_get_cert_file(self, nonce): """Returns standardized name for challenge certificate. @@ -1607,22 +1195,6 @@ def apache_restart(): 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. @@ -1649,94 +1221,3 @@ def get_file_path(vhost_path): 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 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/apache/obj.py b/letsencrypt/client/apache/obj.py new file mode 100644 index 000000000..c4a481acd --- /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/apache/parser.py b/letsencrypt/client/apache/parser.py new file mode 100644 index 000000000..9d92e9271 --- /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 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.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 + + """ + 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 filename: optional filename that will be used as the + user config + + """ + # 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(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/augeas_configurator.py b/letsencrypt/client/augeas_configurator.py index 0ad813c8a..231faa99d 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, @@ -291,8 +287,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 +324,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. diff --git a/letsencrypt/client/client.py b/letsencrypt/client/client.py index a68d8dd39..36116f10e 100644 --- a/letsencrypt/client/client.py +++ b/letsencrypt/client/client.py @@ -1,27 +1,23 @@ """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 from letsencrypt.client import acme -from letsencrypt.client import apache_configurator from letsencrypt.client import challenge 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 le_util +from letsencrypt.client import network # it's weird to point to chocolate servers via raw IPv6 addresses, and @@ -33,112 +29,71 @@ 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: Object that supports the IAuthenticator interface. + :type auth: :class:`letsencrypt.client.interfaces.IAuthenticator` + + :ivar installer: Object supporting the IInstaller interface. + :type installer: :class:`letsencrypt.client.interfaces.IInstraller` """ 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): + def __init__(self, server, names, authkey, auth, installer): """Initialize a client.""" - self.server = server - self.server_url = "https://%s/acme/" % self.server - self.names = [] - self.use_curses = use_curses + self.network = network.Network(server) + self.names = names + self.authkey = authkey - self.csr = csr - self.privkey = privkey - self._validate_csr_key_cli() # TODO: catch exceptions + sanity_check_names([server] + names) - # 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) + self.auth = auth + self.installer = installer - def authenticate(self, domains=None, eula=False, redirect=None): - """ + def obtain_certificate(self, csr, + cert_path=CONFIG.CERT_PATH, + chain_path=CONFIG.CHAIN_PATH): + """Obtains a certificate from the ACME server. - :param list domains: List of domains - :param bool eula: EULA accepted + :param csr: A valid CSR in DER format for the certificate the client + intends to receive. + :type csr: :class:`CSR` - :param redirect: If traffic should be forwarded from HTTP to HTTPS. - :type redirect: bool or None + :param str cert_path: Full desired path to end certificate. + :param str chain_path: Full desired path to end chain file. - :raises errors.LetsEncryptClientError: CSR does not contain one of the - specified names. + :returns: cert_file, chain_file (paths to respective files) + :rtype: `tuple` of `str` """ - domains = [] if domains is None else domains - - # Check configuration - if not self.config.config_test(): - sys.exit(1) - - # 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) - - # 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) - # Request Challenges challenge_msg = self.acme_challenge() - # Make sure we have key and csr to perform challenges - self.init_key_csr() - # Perform Challenges responses, challenge_objs = self.verify_identity(challenge_msg) + # Get Authorization self.acme_authorization(challenge_msg, challenge_objs, responses) # 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) + return cert_file, chain_file + def acme_challenge(self): """Handle ACME "challenge" phase. @@ -148,7 +103,7 @@ class Client(object): :rtype: dict """ - return self.send_and_receive_expected( + return self.network.send_and_receive_expected( acme.challenge_request(self.names[0]), "challenge") def acme_authorization(self, challenge_msg, chal_objs, responses): @@ -156,20 +111,21 @@ class Client(object): :param dict challenge_msg: ACME "challenge" message. - :param chal_objs: TODO + :param chal_objs: TODO - this will be a new object... :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: + return self.network.send_and_receive_expected( + acme.authorization_request( + challenge_msg["sessionID"], self.names[0], + challenge_msg["nonce"], responses, self.authkey.pem), + "authorization") + except errors.LetsEncryptClientError as err: + logging.fatal(str(err)) logging.fatal( "Failed Authorization procedure - cleaning up challenges") sys.exit(1) @@ -186,207 +142,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() @@ -394,7 +168,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() @@ -404,40 +178,56 @@ 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) - 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() 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 " @@ -458,7 +248,7 @@ class Client(object): logging.info("Cleaning up challenges...") for chall in challenges: if chall["type"] in CONFIG.CONFIG_CHALLENGES: - self.config.cleanup() + self.auth.cleanup() else: # Handle other cleanup if needed pass @@ -490,11 +280,11 @@ class Client(object): for i, c_obj in enumerate(challenge_objs): resp = "null" if c_obj["type"] in CONFIG.CONFIG_CHALLENGES: - resp = self.config.perform(c_obj) + resp = self.auth.perform(c_obj) else: # Handle RecoveryToken type challenges pass - + self._assign_responses(resp, indices[i], responses) logging.info( @@ -502,6 +292,7 @@ class Client(object): return responses, challenge_objs + # pylint: disable=no-self-use def _assign_responses(self, resp, index_list, responses): """Assign chall_response to appropriate places in response list. @@ -513,14 +304,13 @@ class Client(object): """ if isinstance(resp, list): - assert(len(resp) == len(index_list)) + assert len(resp) == len(index_list) for j, index in enumerate(index_list): responses[index] = resp[j] - else: + else: for index in index_list: responses[index] = resp - def store_cert_key(self, cert_file, encrypt=False): """Store certificate key. @@ -549,17 +339,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, @@ -571,16 +361,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 @@ -588,12 +378,12 @@ 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 @@ -646,129 +436,95 @@ class Client(object): challenge_objs.append({ "type": "dvsni", "list_sni_tuple": sni_todo, - "dvsni_key": self.privkey, + "dvsni_key": self.authkey, }) 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. +def validate_key_csr(privkey, csr, names): + """Validate CSR and key files. - """ - if not self.privkey.file: - key_pem = crypto_util.make_key(CONFIG.RSA_KEY_SIZE) + Verifies that the client key and csr arguments are valid and + correspond to one another. - # 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): @@ -808,5 +564,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/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 554ccf684..04a8ac373 100644 --- a/letsencrypt/client/crypto_util.py +++ b/letsencrypt/client/crypto_util.py @@ -47,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), @@ -62,7 +62,7 @@ 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 diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py new file mode 100644 index 000000000..06bb5e83e --- /dev/null +++ b/letsencrypt/client/interfaces.py @@ -0,0 +1,91 @@ +"""Interfaces.""" +import zope.interface + + +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 perform(chall_dict): + """Perform the given challenge""" + + def cleanup(): + """Revert changes and shutdown after challenges complete.""" + + +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.""" diff --git a/letsencrypt/client/network.py b/letsencrypt/client/network.py new file mode 100644 index 000000000..855008b6b --- /dev/null +++ b/letsencrypt/client/network.py @@ -0,0 +1,119 @@ +"""Network Module.""" +import json +import logging +import sys +import time + +import jsonschema +import requests + +from letsencrypt.client import acme +from letsencrypt.client import errors + + +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 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: %s", response) + sys.exit(33) + + logging.error( + "Server has deferred past the max of %d seconds", rounds * delay) diff --git a/letsencrypt/client/revoker.py b/letsencrypt/client/revoker.py new file mode 100644 index 000000000..7fda722b8 --- /dev/null +++ b/letsencrypt/client/revoker.py @@ -0,0 +1,137 @@ +"""Revoker module to enable LE revocations.""" +import csv +import logging +import os +import shutil + +import M2Crypto + +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 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") + + display.generic_notification( + "You have successfully revoked the certificate for " + "%s" % cert["cn"], width=70, height=9) + + 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: + 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) + + # 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/tests/apache_configurator_test.py b/letsencrypt/client/tests/apache_configurator_test.py index 08c99cbeb..20eb6e0c9 100644 --- a/letsencrypt/client/tests/apache_configurator_test.py +++ b/letsencrypt/client/tests/apache_configurator_test.py @@ -1,22 +1,19 @@ """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 +from letsencrypt.client.apache import configurator +from letsencrypt.client.apache import obj +from letsencrypt.client.apache import parser -UBUNTU_CONFIGS = pkg_resources.resource_filename( - __name__, "testdata/debian_apache_2_4") +from letsencrypt.client.tests import config_util class TwoVhost80Test(unittest.TestCase): @@ -25,101 +22,36 @@ class TwoVhost80Test(unittest.TestCase): 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") + self.temp_dir, self.config_dir, self.work_dir = config_util.dir_setup( + "debian_apache_2_4/two_vhost_80") - 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")) + 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, "two_vhost_80/apache2/") - self.ssl_options = os.path.join(self.config_dir, "options-ssl.conf") - backups = os.path.join(self.work_dir, "backups") + self.config_path = os.path.join( + self.temp_dir, "debian_apache_2_4/two_vhost_80/apache2/") - 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)) + self.config = config_util.get_apache_configurator( + self.config_path, self.config_dir, self.work_dir, self.ssl_options) - 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"]), - ] + 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_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( + self.assertEqual(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: @@ -134,67 +66,45 @@ class TwoVhost80Test(unittest.TestCase): 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"), + 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.find_directive( - apache_configurator.case_i("sslcertificateKeyfile"), + 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.find_directive( - apache_configurator.case_i("SSLCertificateChainFile"), + 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(apache_configurator.get_file_path(loc_cert[0]), + self.assertEqual(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.assertEqual(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.assertEqual(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")) + 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("*:80")) + self.assertFalse(self.config.is_name_vhost(addr)) def test_add_name_vhost(self): self.config.add_name_vhost("*:443") - # self.config.save(temporary=True) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "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]) @@ -205,16 +115,17 @@ class TwoVhost80Test(unittest.TestCase): 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.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.find_directive( + self.assertTrue(self.config.parser.find_dir( "SSLCertificateFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( + self.assertTrue(self.config.parser.find_dir( "SSLCertificateKeyFile", None, ssl_vhost.path)) - self.assertTrue(self.config.find_directive( + 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]), @@ -222,7 +133,7 @@ class TwoVhost80Test(unittest.TestCase): self.assertEqual(len(self.config.vhosts), 5) - @mock.patch("letsencrypt.client.apache_configurator." + @mock.patch("letsencrypt.client.apache.configurator." "subprocess.Popen") def test_get_version(self, mock_popen): mock_popen().communicate.return_value = ( @@ -247,6 +158,5 @@ class TwoVhost80Test(unittest.TestCase): self.assertRaises( errors.LetsEncryptConfiguratorError, self.config.get_version) - if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/tests/apache_obj_test.py b/letsencrypt/client/tests/apache_obj_test.py new file mode 100644 index 000000000..46e76d4bd --- /dev/null +++ b/letsencrypt/client/tests/apache_obj_test.py @@ -0,0 +1,61 @@ +import unittest + +from letsencrypt.client.apache import obj + + +class AddrTest(unittest.TestCase): + """Test the Addr class.""" + def setUp(self): + self.addr1 = obj.Addr.fromstring("192.168.1.1") + self.addr2 = obj.Addr.fromstring("192.168.1.1:*") + self.addr3 = obj.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): + set_a = set([self.addr1, self.addr2]) + addr1b = obj.Addr.fromstring("192.168.1.1") + addr2b = obj.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): + self.vhost1 = obj.VirtualHost( + "filep", "vh_path", + set([obj.Addr.fromstring("localhost")]), False, False) + + def test_eq(self): + vhost1b = obj.VirtualHost( + "filep", "vh_path", + set([obj.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..baf5c746e --- /dev/null +++ b/letsencrypt/client/tests/apache_parser_test.py @@ -0,0 +1,112 @@ +import os +import shutil +import sys +import unittest + +import augeas +import mock + +from letsencrypt.client import display +from letsencrypt.client import errors +from letsencrypt.client.apache import parser +from letsencrypt.client.tests import config_util + + +class ApacheParserTest(unittest.TestCase): + + def setUp(self): + display.set_display(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/config_util.py b/letsencrypt/client/tests/config_util.py new file mode 100644 index 000000000..41e29031a --- /dev/null +++ b/letsencrypt/client/tests/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( + __name__, "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", 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/scripts/main.py b/letsencrypt/scripts/main.py index 8cbda62dc..9030d8f69 100755 --- a/letsencrypt/scripts/main.py +++ b/letsencrypt/scripts/main.py @@ -5,11 +5,14 @@ import logging import os import sys -from letsencrypt.client import apache_configurator 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 +65,39 @@ 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()) + display.set_display(display.NcursesDisplay()) + else: + display.set_display(display.FileDisplay(sys.stdout)) + + 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 +105,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, domains) + + 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 display.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 = display.filter_names(get_all_names(installer)) + if code == display.OK and names: + # TODO: Allow multiple names once it is setup + return [names[0]] 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): @@ -143,6 +228,5 @@ def view_checkpoints(config): """ config.display_checkpoints() - if __name__ == "__main__": main() diff --git a/setup.py b/setup.py index 8a473ebb4..f6d8f2880 100755 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ install_requires = [ 'python-augeas', 'python2-pythondialog', 'requests', + 'zope.interface', ] docs_extras = [ @@ -35,6 +36,7 @@ setup( packages=[ 'letsencrypt', 'letsencrypt.client', + 'letsencrypt.client.apache', 'letsencrypt.client.tests', 'letsencrypt.scripts', ], diff --git a/tox.ini b/tox.ini index 013b19c6c..4ebe69305 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=44 + python setup.py nosetests --with-coverage --cover-min-percentage=47 [testenv:lint] commands =