diff --git a/letsencrypt/client/plugins/nginx/__init__.py b/letsencrypt/client/plugins/nginx/__init__.py new file mode 100644 index 000000000..63728924f --- /dev/null +++ b/letsencrypt/client/plugins/nginx/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt client.plugins.nginx.""" diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py new file mode 100644 index 000000000..240dbe55e --- /dev/null +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -0,0 +1,1163 @@ +"""Nginx Configuration based off of Augeas Configurator.""" +import logging +import os +import re +import shutil +import socket +import subprocess +import sys + +import zope.interface + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import augeas_configurator +from letsencrypt.client import constants +from letsencrypt.client import errors +from letsencrypt.client import interfaces +from letsencrypt.client import le_util + +from letsencrypt.client.plugins.nginx import dvsni +from letsencrypt.client.plugins.nginx import obj +from letsencrypt.client.plugins.nginx import parser + + +# TODO: Augeas sections ie. , beginning and closing +# tags need to be the same case, otherwise Augeas doesn't recognize them. +# This is not able to be completely remedied by regular expressions because +# Augeas views as an error. This will just +# require another check_parsing_errors() after all files are included... +# (after a find_directive search is executed currently). It can be a one +# time check however because all of LE's transactions will ensure +# only properly formed sections are added. + +# Note: This protocol works for filenames with spaces in it, the sites are +# properly set up and directives are changed appropriately, but Nginx won't +# recognize names in sites-enabled that have spaces. These are not added to the +# Nginx configuration. It may be wise to warn the user if they are trying +# to use vhost filenames that contain spaces and offer to change ' ' to '_' + +# Note: FILEPATHS and changes to files are transactional. They are copied +# over before the updates are made to the existing files. NEW_FILES is +# transactional due to the use of register_file_creation() + + +class NginxConfigurator(augeas_configurator.AugeasConfigurator): + # pylint: disable=too-many-instance-attributes,too-many-public-methods + """Nginx configurator. + + State of Configurator: This code has been tested under Ubuntu 12.04 + Nginx 2.2 and this code works for Ubuntu 14.04 Nginx 2.4. Further + notes below. + + This class was originally developed for Nginx 2.2 and I have been slowly + transitioning the codebase to work with all of the 2.4 features. + I have implemented most of the changes... the missing ones are + mod_ssl.c vs ssl_mod, and I need to account for configuration variables. + This class can adequately configure most typical configurations but + is not ready to handle very complex configurations. + + .. todo:: Add support for config file variables Define rootDir /var/www/ + .. todo:: Add proper support for module configuration + + The API of this class will change in the coming weeks as the exact + needs of clients are clarified with the new and developing protocol. + + :ivar config: Configuration. + :type config: :class:`~letsencrypt.client.interfaces.IConfig` + + :ivar parser: Handles low level parsing + :type parser: :class:`~letsencrypt.client.plugins.nginx.parser` + + :ivar tup version: version of Nginx + :ivar list vhosts: All vhosts found in the configuration + (:class:`list` of + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost`) + + :ivar dict assoc: Mapping between domains and vhosts + + """ + zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + + description = "Nginx Web Server" + + def __init__(self, config, version=None): + """Initialize an Nginx Configurator. + + :param tup version: version of Nginx as a tuple (2, 4, 7) + (used mostly for unittesting) + + """ + super(NginxConfigurator, self).__init__(config) + + # Verify that all directories and files exist with proper permissions + if os.geteuid() == 0: + self.verify_setup() + + # Add name_server association dict + self.assoc = dict() + # Add number of outstanding challenges + self._chall_out = 0 + + # These will be set in the prepare function + self.parser = None + self.version = version + self.vhosts = None + self._enhance_func = {"redirect": self._enable_redirect} + + def prepare(self): + """Prepare the authenticator/installer.""" + self.parser = parser.NginxParser( + self.aug, self.config.nginx_server_root, + self.config.nginx_mod_ssl_conf) + # Check for errors in parsing files with Augeas + self.check_parsing_errors("httpd.aug") + + # Set Version + if self.version is None: + self.version = self.get_version() + + # Get all of the available vhosts + self.vhosts = self.get_virtual_hosts() + + # Enable mod_ssl if it isn't already enabled + # This is Let's Encrypt... we enable mod_ssl on initialization :) + # TODO: attempt to make the check faster... this enable should + # be asynchronous as it shouldn't be that time sensitive + # on initialization + self._prepare_server_https() + + temp_install(self.config.nginx_mod_ssl_conf) + + def deploy_cert(self, domain, cert, key, cert_chain=None): + """Deploys certificate to specified virtual host. + + Currently tries to find the last directives to deploy the cert in + the VHost associated with the given domain. If it can't find the + directives, it searches the "included" confs. The function verifies that + it has located the three directives and finally modifies them to point + to the correct destination. After the certificate is installed, the + VirtualHost is enabled if it isn't already. + + .. todo:: Make sure last directive is changed + + .. todo:: Might be nice to remove chain directive if none exists + This shouldn't happen within letsencrypt though + + :param str domain: domain to deploy certificate + :param str cert: certificate filename + :param str key: private key filename + :param str cert_chain: certificate chain filename + + """ + vhost = self.choose_vhost(domain) + path = {} + + path["cert_file"] = self.parser.find_dir(parser.case_i( + "SSLCertificateFile"), None, vhost.path) + path["cert_key"] = self.parser.find_dir(parser.case_i( + "SSLCertificateKeyFile"), None, vhost.path) + + # Only include if a certificate chain is specified + if cert_chain is not None: + path["cert_chain"] = self.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), None, vhost.path) + + if len(path["cert_file"]) == 0 or len(path["cert_key"]) == 0: + # Throw some can't find all of the directives error" + logging.warn( + "Cannot find a cert or key directive in %s", vhost.path) + logging.warn("VirtualHost was not modified") + # Presumably break here so that the virtualhost is not modified + return False + + logging.info("Deploying Certificate to VirtualHost %s", vhost.filep) + + self.aug.set(path["cert_file"][0], cert) + self.aug.set(path["cert_key"][0], key) + if cert_chain is not None: + if len(path["cert_chain"]) == 0: + self.parser.add_dir( + vhost.path, "SSLCertificateChainFile", cert_chain) + else: + self.aug.set(path["cert_chain"][0], cert_chain) + + self.save_notes += ("Changed vhost at %s with addresses of %s\n" % + (vhost.filep, + ", ".join(str(addr) for addr in vhost.addrs))) + self.save_notes += "\tSSLCertificateFile %s\n" % cert + self.save_notes += "\tSSLCertificateKeyFile %s\n" % key + if cert_chain: + self.save_notes += "\tSSLCertificateChainFile %s\n" % cert_chain + + # Make sure vhost is enabled + if not vhost.enabled: + self.enable_site(vhost) + + def choose_vhost(self, target_name): + """Chooses a virtual host based on the given domain name. + + .. todo:: This should maybe return list if no obvious answer + is presented. + + :param str target_name: domain name + + :returns: ssl vhost associated with name + :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + # Allows for domain names to be associated with a virtual host + # Client isn't using create_dn_server_assoc(self, dn, vh) yet + if target_name in self.assoc: + return self.assoc[target_name] + # Check for servernames/aliases for ssl hosts + for vhost in self.vhosts: + if vhost.ssl and target_name in vhost.names: + self.assoc[target_name] = vhost + return vhost + # Checking for domain name in vhost address + # This technique is not recommended by Nginx but is technically valid + target_addr = obj.Addr((target_name, "443")) + for vhost in self.vhosts: + if target_addr in vhost.addrs: + self.assoc[target_name] = vhost + return vhost + + # Check for non ssl vhosts with servernames/aliases == "name" + for vhost in self.vhosts: + if not vhost.ssl and target_name in vhost.names: + vhost = self.make_vhost_ssl(vhost) + self.assoc[target_name] = vhost + return vhost + + # No matches, search for the default + for vhost in self.vhosts: + if "_default_:443" in vhost.addrs: + return vhost + return None + + def create_dn_server_assoc(self, domain, vhost): + """Create an association between a domain name and virtual host. + + Helps to choose an appropriate vhost + + :param str domain: domain name to associate + + :param vhost: virtual host to associate with domain + :type vhost: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + self.assoc[domain] = vhost + + def get_all_names(self): + """Returns all names found in the Nginx Configuration. + + :returns: All ServerNames, ServerAliases, and reverse DNS entries for + virtual host addresses + :rtype: set + + """ + all_names = set() + + # Kept in same function to avoid multiple compilations of the regex + priv_ip_regex = (r"(^127\.0\.0\.1)|(^10\.)|(^172\.1[6-9]\.)|" + r"(^172\.2[0-9]\.)|(^172\.3[0-1]\.)|(^192\.168\.)") + private_ips = re.compile(priv_ip_regex) + + for vhost in self.vhosts: + all_names.update(vhost.names) + for addr in vhost.addrs: + # If it isn't a private IP, do a reverse DNS lookup + if not private_ips.match(addr.get_addr()): + try: + socket.inet_aton(addr.get_addr()) + all_names.add(socket.gethostbyaddr(addr.get_addr())[0]) + except (socket.error, socket.herror, socket.timeout): + continue + + return all_names + + def _add_servernames(self, host): + """Helper function for get_virtual_hosts(). + + :param host: In progress vhost whose names will be added + :type host: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " + "%s//*[self::directive=~regexp('%s')]" % + (host.path, + parser.case_i("ServerName"), + host.path, + parser.case_i("ServerAlias")))) + + for name in name_match: + args = self.aug.match(name + "/*") + for arg in args: + host.add_name(self.aug.get(arg)) + + def _create_vhost(self, path): + """Used by get_virtual_hosts to create vhost objects + + :param str path: Augeas path to virtual host + + :returns: newly created vhost + :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + addrs = set() + args = self.aug.match(path + "/arg") + for arg in args: + addrs.add(obj.Addr.fromstring(self.aug.get(arg))) + is_ssl = False + + if self.parser.find_dir( + parser.case_i("SSLEngine"), parser.case_i("on"), path): + is_ssl = True + + filename = get_file_path(path) + is_enabled = self.is_site_enabled(filename) + vhost = obj.VirtualHost(filename, path, addrs, is_ssl, is_enabled) + self._add_servernames(vhost) + return vhost + + # TODO: make "sites-available" a configurable directory + def get_virtual_hosts(self): + """Returns list of virtual hosts found in the Nginx configuration. + + :returns: List of + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` objects + found in configuration + :rtype: list + + """ + # Search sites-available, httpd.conf for possible virtual hosts + paths = self.aug.match( + ("/files%s/sites-available//*[label()=~regexp('%s')]" % + (self.parser.root, parser.case_i("VirtualHost")))) + vhs = [] + + for path in paths: + vhs.append(self._create_vhost(path)) + + return vhs + + def is_name_vhost(self, target_addr): + r"""Returns if vhost is a name based vhost + + NameVirtualHost was deprecated in Nginx 2.4 as all VirtualHosts are + now NameVirtualHosts. If version is earlier than 2.4, check if addr + has a NameVirtualHost directive in the Nginx config + + :param str target_addr: vhost address ie. \*:443 + + :returns: Success + :rtype: bool + + """ + # Mixed and matched wildcard NameVirtualHost with VirtualHost + # behavior is undefined. Make sure that an exact match exists + + # search for NameVirtualHost directive for ip_addr + # note ip_addr can be FQDN although Nginx does not recommend it + return (self.version >= (2, 4) or + self.parser.find_dir( + parser.case_i("NameVirtualHost"), + parser.case_i(str(target_addr)))) + + def add_name_vhost(self, addr): + """Adds NameVirtualHost directive for given address. + + :param str addr: Address that will be added as NameVirtualHost directive + + """ + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path( + self.parser.loc["name"]), "NameVirtualHost", str(addr)) + + self.save_notes += "Setting %s to be NameBasedVirtualHost\n" % addr + self.save_notes += "\tDirective added to %s\n" % path + + def _prepare_server_https(self): + """Prepare the server for HTTPS. + + Make sure that the ssl_module is loaded and that the server + is appropriately listening on port 443. + + """ + if not mod_loaded("ssl_module", self.config.nginx_ctl): + logging.info("Loading mod_ssl into Nginx Server") + enable_mod("ssl", self.config.nginx_init_script, + self.config.nginx_enmod) + + # Check for Listen 443 + # Note: This could be made to also look for ip:443 combo + # TODO: Need to search only open directives and IfMod mod_ssl.c + if len(self.parser.find_dir(parser.case_i("Listen"), "443")) == 0: + logging.debug("No Listen 443 directive found") + logging.debug("Setting the Nginx Server to Listen on port 443") + path = self.parser.add_dir_to_ifmodssl( + parser.get_aug_path(self.parser.loc["listen"]), "Listen", "443") + self.save_notes += "Added Listen 443 directive to %s\n" % path + + def make_server_sni_ready(self, vhost, default_addr="*:443"): + """Checks to see if the server is ready for SNI challenges. + + :param vhost: VirtualHost to check SNI compatibility + :type vhost: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :param str default_addr: TODO - investigate function further + + """ + if self.version >= (2, 4): + return + # Check for NameVirtualHost + # First see if any of the vhost addresses is a _default_ addr + for addr in vhost.addrs: + if addr.get_addr() == "_default_": + if not self.is_name_vhost(default_addr): + logging.debug("Setting all VirtualHosts on %s to be " + "name based vhosts", default_addr) + self.add_name_vhost(default_addr) + + # No default addresses... so set each one individually + for addr in vhost.addrs: + if not self.is_name_vhost(addr): + logging.debug("Setting VirtualHost at %s to be a name " + "based virtual host", addr) + self.add_name_vhost(addr) + + def make_vhost_ssl(self, nonssl_vhost): # pylint: disable=too-many-locals + """Makes an ssl_vhost version of a nonssl_vhost. + + Duplicates vhost and adds default ssl options + New vhost will reside as (nonssl_vhost.path) + ``IConfig.le_vhost_ext`` + + .. note:: This function saves the configuration + + :param nonssl_vhost: Valid VH that doesn't have SSLEngine on + :type nonssl_vhost: + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: SSL vhost + :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + """ + avail_fp = nonssl_vhost.filep + # Get filepath of new ssl_vhost + if avail_fp.endswith(".conf"): + ssl_fp = avail_fp[:-(len(".conf"))] + self.config.le_vhost_ext + else: + ssl_fp = avail_fp + self.config.le_vhost_ext + + # First register the creation so that it is properly removed if + # configuration is rolled back + self.reverter.register_file_creation(False, ssl_fp) + + try: + with open(avail_fp, "r") as orig_file: + with open(ssl_fp, "w") as new_file: + new_file.write("\n") + for line in orig_file: + new_file.write(line) + new_file.write("\n") + except IOError: + logging.fatal("Error writing/reading to file in make_vhost_ssl") + sys.exit(49) + + self.aug.load() + + ssl_addrs = set() + + # change address to address:443 + addr_match = "/files%s//* [label()=~regexp('%s')]/arg" + ssl_addr_p = self.aug.match( + addr_match % (ssl_fp, parser.case_i("VirtualHost"))) + + for addr in ssl_addr_p: + old_addr = obj.Addr.fromstring( + str(self.aug.get(addr))) + ssl_addr = old_addr.get_addr_obj("443") + self.aug.set(addr, str(ssl_addr)) + ssl_addrs.add(ssl_addr) + + # Add directives + vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" % + (ssl_fp, parser.case_i("VirtualHost"))) + if len(vh_p) != 1: + logging.error("Error: should only be one vhost in %s", avail_fp) + sys.exit(1) + + self.parser.add_dir(vh_p[0], "SSLCertificateFile", + "/etc/ssl/certs/ssl-cert-snakeoil.pem") + self.parser.add_dir(vh_p[0], "SSLCertificateKeyFile", + "/etc/ssl/private/ssl-cert-snakeoil.key") + self.parser.add_dir(vh_p[0], "Include", self.parser.loc["ssl_options"]) + + # Log actions and create save notes + logging.info("Created an SSL vhost at %s", ssl_fp) + self.save_notes += "Created ssl vhost at %s\n" % ssl_fp + self.save() + + # We know the length is one because of the assertion above + ssl_vhost = self._create_vhost(vh_p[0]) + self.vhosts.append(ssl_vhost) + + # NOTE: Searches through Augeas seem to ruin changes to directives + # The configuration must also be saved before being searched + # for the new directives; For these reasons... this is tacked + # on after fully creating the new vhost + need_to_save = False + # See if the exact address appears in any other vhost + for addr in ssl_addrs: + for vhost in self.vhosts: + if (ssl_vhost.filep != vhost.filep and addr in vhost.addrs and + not self.is_name_vhost(addr)): + self.add_name_vhost(addr) + logging.info("Enabling NameVirtualHosts on %s", addr) + need_to_save = True + + if need_to_save: + self.save() + + return ssl_vhost + + def supported_enhancements(self): # pylint: disable=no-self-use + """Returns currently supported enhancements.""" + return ["redirect"] + + def enhance(self, domain, enhancement, options=None): + """Enhance configuration. + + :param str domain: domain to enhance + :param str enhancement: enhancement type defined in + :const:`~letsencrypt.client.constants.ENHANCEMENTS` + :param options: options for the enhancement + See :const:`~letsencrypt.client.constants.ENHANCEMENTS` + documentation for appropriate parameter. + + """ + try: + return self._enhance_func[enhancement]( + self.choose_vhost(domain), options) + except ValueError: + raise errors.LetsEncryptConfiguratorError( + "Unsupported enhancement: {}".format(enhancement)) + except errors.LetsEncryptConfiguratorError: + logging.warn("Failed %s for %s", enhancement, domain) + + def _enable_redirect(self, ssl_vhost, unused_options): + """Redirect all equivalent HTTP traffic to ssl_vhost. + + .. todo:: This enhancement should be rewritten and will + unfortunately require lots of debugging by hand. + + Adds Redirect directive to the port 80 equivalent of ssl_vhost + First the function attempts to find the vhost with equivalent + ip addresses that serves on non-ssl ports + The function then adds the directive + + .. note:: This function saves the configuration + + :param ssl_vhost: Destination of traffic, an ssl enabled vhost + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :param unused_options: Not currently used + :type unused_options: Not Available + + :returns: Success, general_vhost (HTTP vhost) + :rtype: (bool, + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost`) + + """ + if not mod_loaded("rewrite_module", self.config.nginx_ctl): + enable_mod("rewrite", self.config.nginx_init_script, + self.config.nginx_enmod) + + general_v = self._general_vhost(ssl_vhost) + if general_v is None: + # Add virtual_server with redirect + logging.debug( + "Did not find http version of ssl virtual host... creating") + return self._create_redirect_vhost(ssl_vhost) + else: + # Check if redirection already exists + exists, code = self._existing_redirect(general_v) + if exists: + if code == 0: + logging.debug("Redirect already added") + logging.info( + "Configuration is already redirecting traffic to HTTPS") + return + else: + logging.info("Unknown redirect exists for this vhost") + raise errors.LetsEncryptConfiguratorError( + "Unknown redirect already exists " + "in {}".format(general_v.filep)) + # Add directives to server + self.parser.add_dir(general_v.path, "RewriteEngine", "On") + self.parser.add_dir(general_v.path, "RewriteRule", + constants.APACHE_REWRITE_HTTPS_ARGS) + self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % + (general_v.filep, ssl_vhost.filep)) + self.save() + + logging.info("Redirecting vhost in %s to ssl vhost in %s", + general_v.filep, ssl_vhost.filep) + + def _existing_redirect(self, vhost): + """Checks to see if existing redirect is in place. + + Checks to see if virtualhost already contains a rewrite or redirect + returns boolean, integer + The boolean indicates whether the redirection exists... + The integer has the following code: + 0 - Existing letsencrypt https rewrite rule is appropriate and in place + 1 - Virtual host contains a Redirect directive + 2 - Virtual host contains an unknown RewriteRule + + -1 is also returned in case of no redirection/rewrite directives + + :param vhost: vhost to check + :type vhost: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: Success, code value... see documentation + :rtype: bool, int + + """ + rewrite_path = self.parser.find_dir( + parser.case_i("RewriteRule"), None, vhost.path) + redirect_path = self.parser.find_dir( + parser.case_i("Redirect"), None, vhost.path) + + if redirect_path: + # "Existing Redirect directive for virtualhost" + return True, 1 + if not rewrite_path: + # "No existing redirection for virtualhost" + return False, -1 + if len(rewrite_path) == len(constants.APACHE_REWRITE_HTTPS_ARGS): + for idx, match in enumerate(rewrite_path): + if (self.aug.get(match) != + constants.APACHE_REWRITE_HTTPS_ARGS[idx]): + # Not a letsencrypt https rewrite + return True, 2 + # Existing letsencrypt https rewrite rule is in place + return True, 0 + # Rewrite path exists but is not a letsencrypt https rule + return True, 2 + + def _create_redirect_vhost(self, ssl_vhost): + """Creates an http_vhost specifically to redirect for the ssl_vhost. + + :param ssl_vhost: ssl vhost + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: tuple of the form + (`success`, + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost`) + :rtype: tuple + + """ + # Consider changing this to a dictionary check + # Make sure adding the vhost will be safe + conflict, host_or_addrs = self._conflicting_host(ssl_vhost) + if conflict: + raise errors.LetsEncryptConfiguratorError( + "Unable to create a redirection vhost " + "- {}".format(host_or_addrs)) + + redirect_addrs = host_or_addrs + + # get servernames and serveraliases + serveralias = "" + servername = "" + size_n = len(ssl_vhost.names) + if size_n > 0: + servername = "ServerName " + ssl_vhost.names[0] + if size_n > 1: + serveralias = " ".join(ssl_vhost.names[1:size_n]) + serveralias = "ServerAlias " + serveralias + redirect_file = ("\n" + "%s \n" + "%s \n" + "ServerSignature Off\n" + "\n" + "RewriteEngine On\n" + "RewriteRule %s\n" + "\n" + "ErrorLog /var/log/nginx2/redirect.error.log\n" + "LogLevel warn\n" + "\n" + % (servername, serveralias, + " ".join(constants.APACHE_REWRITE_HTTPS_ARGS))) + + # Write out the file + # This is the default name + redirect_filename = "le-redirect.conf" + + # See if a more appropriate name can be applied + if len(ssl_vhost.names) > 0: + # Sanity check... + # make sure servername doesn't exceed filename length restriction + if ssl_vhost.names[0] < (255-23): + redirect_filename = "le-redirect-%s.conf" % ssl_vhost.names[0] + + redirect_filepath = os.path.join( + self.parser.root, "sites-available", redirect_filename) + + # Register the new file that will be created + # Note: always register the creation before writing to ensure file will + # be removed in case of unexpected program exit + self.reverter.register_file_creation(False, redirect_filepath) + + # Write out file + with open(redirect_filepath, "w") as redirect_fd: + redirect_fd.write(redirect_file) + logging.info("Created redirect file: %s", redirect_filename) + + self.aug.load() + # Make a new vhost data structure and add it to the lists + new_vhost = self._create_vhost(parser.get_aug_path(redirect_filepath)) + self.vhosts.append(new_vhost) + + # Finally create documentation for the change + self.save_notes += ("Created a port 80 vhost, %s, for redirection to " + "ssl vhost %s\n" % + (new_vhost.filep, ssl_vhost.filep)) + + def _conflicting_host(self, ssl_vhost): + """Checks for conflicting HTTP vhost for ssl_vhost. + + Checks for a conflicting host, such that a new port 80 host could not + be created without ruining the nginx config + Used with redirection + + returns: conflict, host_or_addrs - boolean + if conflict: returns conflicting vhost + if not conflict: returns space separated list of new host addrs + + :param ssl_vhost: SSL Vhost to check for possible port 80 redirection + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: TODO + :rtype: TODO + + """ + # Consider changing this to a dictionary check + redirect_addrs = "" + for ssl_a in ssl_vhost.addrs: + # Add space on each new addr, combine "VirtualHost"+redirect_addrs + redirect_addrs = redirect_addrs + " " + ssl_a_vhttp = ssl_a.get_addr_obj("80") + # Search for a conflicting host... + for vhost in self.vhosts: + if vhost.enabled: + if (ssl_a_vhttp in vhost.addrs or + ssl_a.get_addr_obj("") in vhost.addrs or + ssl_a.get_addr_obj("*") in vhost.addrs): + # We have found a conflicting host... just return + return True, vhost + + redirect_addrs = redirect_addrs + ssl_a_vhttp + + return False, redirect_addrs + + def _general_vhost(self, ssl_vhost): + """Find appropriate HTTP vhost for ssl_vhost. + + Function needs to be thoroughly tested and perhaps improved + Will not do well with malformed configurations + Consider changing this into a dict check + + :param ssl_vhost: ssl vhost to check + :type ssl_vhost: + :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: HTTP vhost or None if unsuccessful + :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + or None + + """ + # _default_:443 check + # Instead... should look for vhost of the form *:80 + # Should we prompt the user? + ssl_addrs = ssl_vhost.addrs + if ssl_addrs == obj.Addr.fromstring("_default_:443"): + ssl_addrs = [obj.Addr.fromstring("*:443")] + + for vhost in self.vhosts: + found = 0 + # Not the same vhost, and same number of addresses + if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs): + # Find each address in ssl_host in test_host + for ssl_a in ssl_addrs: + for test_a in vhost.addrs: + if test_a.get_addr() == ssl_a.get_addr(): + # Check if found... + if (test_a.get_port() == "80" or + test_a.get_port() == "" or + test_a.get_port() == "*"): + found += 1 + break + # Check to make sure all addresses were found + # and names are equal + if (found == len(ssl_vhost.addrs) and + vhost.names == ssl_vhost.names): + return vhost + return None + + def get_all_certs_keys(self): + """Find all existing keys, certs from configuration. + + Retrieve all certs and keys set in VirtualHosts on the Nginx server + + :returns: list of tuples with form [(cert, key, path)] + cert - str path to certificate file + key - str path to associated key file + path - File path to configuration file. + :rtype: list + + """ + c_k = set() + + for vhost in self.vhosts: + if vhost.ssl: + cert_path = self.parser.find_dir( + parser.case_i("SSLCertificateFile"), None, vhost.path) + key_path = self.parser.find_dir( + parser.case_i("SSLCertificateKeyFile"), None, vhost.path) + + # Can be removed once find directive can return ordered results + if len(cert_path) != 1 or len(key_path) != 1: + logging.error("Too many cert or key directives in vhost %s", + vhost.filep) + sys.exit(40) + + cert = os.path.abspath(self.aug.get(cert_path[0])) + key = os.path.abspath(self.aug.get(key_path[0])) + c_k.add((cert, key, get_file_path(cert_path[0]))) + + return c_k + + def is_site_enabled(self, avail_fp): + """Checks to see if the given site is enabled. + + .. todo:: fix hardcoded sites-enabled, check os.path.samefile + + :param str avail_fp: Complete file path of available site + + :returns: Success + :rtype: bool + + """ + enabled_dir = os.path.join(self.parser.root, "sites-enabled") + for entry in os.listdir(enabled_dir): + if os.path.realpath(os.path.join(enabled_dir, entry)) == avail_fp: + return True + + return False + + def enable_site(self, vhost): + """Enables an available site, Nginx restart required. + + .. todo:: This function should number subdomains before the domain vhost + + .. todo:: Make sure link is not broken... + + :param vhost: vhost to enable + :type vhost: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` + + :returns: Success + :rtype: bool + + """ + if self.is_site_enabled(vhost.filep): + return True + + if "/sites-available/" in vhost.filep: + enabled_path = ("%s/sites-enabled/%s" % + (self.parser.root, os.path.basename(vhost.filep))) + self.reverter.register_file_creation(False, enabled_path) + os.symlink(vhost.filep, enabled_path) + vhost.enabled = True + logging.info("Enabling available site: %s", vhost.filep) + self.save_notes += "Enabled site %s\n" % vhost.filep + return True + return False + + def restart(self): + """Restarts nginx server. + + :returns: Success + :rtype: bool + + """ + return nginx_restart(self.config.nginx_init_script) + + def config_test(self): # pylint: disable=no-self-use + """Check the configuration of Nginx for errors. + + :returns: Success + :rtype: bool + + """ + try: + proc = subprocess.Popen( + ["sudo", self.config.nginx_ctl, "configtest"], # TODO: sudo? + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + except (OSError, ValueError): + logging.fatal("Unable to run /usr/sbin/nginx2ctl configtest") + sys.exit(1) + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Configtest failed") + logging.error(stdout) + logging.error(stderr) + return False + + return True + + def verify_setup(self): + """Verify the setup to ensure safe operating environment. + + Make sure that files/directories are setup with appropriate permissions + Aim for defensive coding... make sure all input files + have permissions of root + + """ + uid = os.geteuid() + le_util.make_or_verify_dir(self.config.config_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.work_dir, 0o755, uid) + le_util.make_or_verify_dir(self.config.backup_dir, 0o755, uid) + + def get_version(self): + """Return version of Nginx Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)) + + :returns: version + :rtype: tuple + + :raises errors.LetsEncryptConfiguratorError: + Unable to find Nginx version + + """ + try: + proc = subprocess.Popen( + [self.config.nginx_ctl, "-v"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + text = proc.communicate()[0] + except (OSError, ValueError): + raise errors.LetsEncryptConfiguratorError( + "Unable to run %s -v" % self.config.nginx_ctl) + + regex = re.compile(r"Nginx/([0-9\.]*)", re.IGNORECASE) + matches = regex.findall(text) + + if len(matches) != 1: + raise errors.LetsEncryptConfiguratorError( + "Unable to find Nginx version") + + return tuple([int(i) for i in matches[0].split(".")]) + + def more_info(self): + """Human-readable string to help understand the module""" + return ( + "Configures Nginx to authenticate and install HTTPS.{0}" + "Server root: {root}{0}" + "Version: {version}".format( + os.linesep, root=self.parser.loc["root"], + version=".".join(str(i) for i in self.version)) + ) + + ########################################################################### + # Challenges Section + ########################################################################### + def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use + """Return list of challenge preferences.""" + return [challenges.DVSNI] + + def perform(self, achalls): + """Perform the configuration related challenge. + + This function currently assumes all challenges will be fulfilled. + If this turns out not to be the case in the future. Cleanup and + outstanding challenges will have to be designed better. + + """ + self._chall_out += len(achalls) + responses = [None] * len(achalls) + nginx_dvsni = dvsni.NginxDvsni(self) + + for i, achall in enumerate(achalls): + if isinstance(achall, achallenges.DVSNI): + # Currently also have dvsni hold associated index + # of the challenge. This helps to put all of the responses back + # together when they are all complete. + nginx_dvsni.add_chall(achall, i) + + sni_response = nginx_dvsni.perform() + # Must restart in order to activate the challenges. + # Handled here because we may be able to load up other challenge types + self.restart() + + # Go through all of the challenges and assign them to the proper place + # in the responses return value. All responses must be in the same order + # as the original challenges. + for i, resp in enumerate(sni_response): + responses[nginx_dvsni.indices[i]] = resp + + return responses + + def cleanup(self, achalls): + """Revert all challenges.""" + self._chall_out -= len(achalls) + + # If all of the challenges have been finished, clean up everything + if self._chall_out <= 0: + self.revert_challenge_config() + self.restart() + + +def enable_mod(mod_name, nginx_init_script, nginx_enmod): + """Enables module in Nginx. + + Both enables and restarts Nginx so module is active. + + :param str mod_name: Name of the module to enable. + :param str nginx_init_script: Path to the Nginx init script. + :param str nginx_enmod: Path to the Nginx a2enmod script. + + """ + try: + # Use check_output so the command will finish before reloading + # TODO: a2enmod is debian specific... + subprocess.check_call(["sudo", nginx_enmod, mod_name], # TODO: sudo? + stdout=open("/dev/null", "w"), + stderr=open("/dev/null", "w")) + nginx_restart(nginx_init_script) + except (OSError, subprocess.CalledProcessError) as err: + logging.error("Error enabling mod_%s", mod_name) + logging.error("Exception: %s", err) + sys.exit(1) + + +def mod_loaded(module, nginx_ctl): + """Checks to see if mod_ssl is loaded + + Uses ``nginx_ctl`` to get loaded module list. This also effectively + serves as a config_test. + + :param str nginx_ctl: Path to nginx2ctl binary. + + :returns: If ssl_module is included and active in Nginx + :rtype: bool + + """ + try: + proc = subprocess.Popen( + [nginx_ctl, "-M"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + + except (OSError, ValueError): + logging.error( + "Error accessing %s for loaded modules!", nginx_ctl) + raise errors.LetsEncryptConfiguratorError( + "Error accessing loaded modules") + # Small errors that do not impede + if proc.returncode != 0: + logging.warn("Error in checking loaded module list: %s", stderr) + raise errors.LetsEncryptMisconfigurationError( + "Nginx is unable to check whether or not the module is " + "loaded because Nginx is misconfigured.") + + if module in stdout: + return True + return False + + +def nginx_restart(nginx_init_script): + """Restarts the Nginx Server. + + :param str nginx_init_script: Path to the Nginx init script. + + .. todo:: Try to use reload instead. (This caused timing problems before) + + .. todo:: On failure, this should be a recovery_routine call with another + restart. This will confuse and inhibit developers from testing code + though. This change should happen after + the NginxConfigurator has been thoroughly tested. The function will + need to be moved into the class again. Perhaps + this version can live on... for testing purposes. + + """ + try: + proc = subprocess.Popen([nginx_init_script, "restart"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + stdout, stderr = proc.communicate() + + if proc.returncode != 0: + # Enter recovery routine... + logging.error("Nginx Restart Failed!") + logging.error(stdout) + logging.error(stderr) + return False + + except (OSError, ValueError): + logging.fatal( + "Nginx Restart Failed - Please Check the Configuration") + sys.exit(1) + + return True + + +def get_file_path(vhost_path): + """Get file path from augeas_vhost_path. + + Takes in Augeas path and returns the file name + + :param str vhost_path: Augeas virtual host path + + :returns: filename of vhost + :rtype: str + + """ + # Strip off /files + avail_fp = vhost_path[6:] + # This can be optimized... + while True: + # Cast both to lowercase to be case insensitive + find_if = avail_fp.lower().find("/ifmodule") + if find_if != -1: + avail_fp = avail_fp[:find_if] + continue + find_vh = avail_fp.lower().find("/virtualhost") + if find_vh != -1: + avail_fp = avail_fp[:find_vh] + continue + break + return avail_fp + + +def temp_install(options_ssl): + """Temporary install for convenience.""" + # WARNING: THIS IS A POTENTIAL SECURITY VULNERABILITY + # THIS SHOULD BE HANDLED BY THE PACKAGE MANAGER + # AND TAKEN OUT BEFORE RELEASE, INSTEAD + # SHOWING A NICE ERROR MESSAGE ABOUT THE PROBLEM. + + # Check to make sure options-ssl.conf is installed + if not os.path.isfile(options_ssl): + shutil.copyfile(constants.APACHE_MOD_SSL_CONF, options_ssl) diff --git a/letsencrypt/client/plugins/nginx/dvsni.py b/letsencrypt/client/plugins/nginx/dvsni.py new file mode 100644 index 000000000..960352831 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/dvsni.py @@ -0,0 +1,201 @@ +"""NginxDVSNI""" +import logging +import os + +from letsencrypt.client.plugins.nginx import parser + + +class NginxDvsni(object): + """Class performs DVSNI challenges within the Nginx configurator. + + :ivar configurator: NginxConfigurator object + :type configurator: :class:`~nginx.configurator.NginxConfigurator` + + :ivar list achalls: Annotated :class:`~letsencrypt.client.achallenges.DVSNI` + challenges. + + :param list indices: Meant to hold indices of challenges in a + larger array. NginxDvsni is capable of solving many challenges + at once which causes an indexing issue within NginxConfigurator + who must return all responses in order. Imagine NginxConfigurator + maintaining state about where all of the SimpleHTTPS Challenges, + Dvsni Challenges belong in the response array. This is an optional + utility. + + :param str challenge_conf: location of the challenge config file + + """ + + VHOST_TEMPLATE = """\ + + ServerName {server_name} + UseCanonicalName on + SSLStrictSNIVHostCheck on + + LimitRequestBody 1048576 + + Include {ssl_options_conf_path} + SSLCertificateFile {cert_path} + SSLCertificateKeyFile {key_path} + + DocumentRoot {document_root} + + +""" + def __init__(self, configurator): + self.configurator = configurator + self.achalls = [] + self.indices = [] + self.challenge_conf = os.path.join( + configurator.config.config_dir, "le_dvsni_cert_challenge.conf") + # self.completed = 0 + + def add_chall(self, achall, idx=None): + """Add challenge to DVSNI object to perform at once. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` + + :param int idx: index to challenge in a larger array + + """ + self.achalls.append(achall) + if idx is not None: + self.indices.append(idx) + + def perform(self): + """Peform a DVSNI challenge.""" + if not self.achalls: + return [] + # Save any changes to the configuration as a precaution + # About to make temporary changes to the config + self.configurator.save() + + addresses = [] + default_addr = "*:443" + for achall in self.achalls: + vhost = self.configurator.choose_vhost(achall.domain) + if vhost is None: + logging.error( + "No vhost exists with servername or alias of: %s", + achall.domain) + logging.error("No _default_:443 vhost exists") + logging.error("Please specify servernames in the Nginx config") + return None + + # TODO - @jdkasten review this code to make sure it makes sense + self.configurator.make_server_sni_ready(vhost, default_addr) + + for addr in vhost.addrs: + if "_default_" == addr.get_addr(): + addresses.append([default_addr]) + break + else: + addresses.append(list(vhost.addrs)) + + responses = [] + + # Create all of the challenge certs + for achall in self.achalls: + responses.append(self._setup_challenge_cert(achall)) + + # Setup the configuration + self._mod_config(addresses) + + # Save reversible changes + self.configurator.save("SNI Challenge", True) + + return responses + + def _setup_challenge_cert(self, achall, s=None): + # pylint: disable=invalid-name + """Generate and write out challenge certificate.""" + cert_path = self.get_cert_file(achall) + # Register the path before you write out the file + self.configurator.reverter.register_file_creation(True, cert_path) + + cert_pem, response = achall.gen_cert_and_response(s) + + # Write out challenge cert + with open(cert_path, "w") as cert_chall_fd: + cert_chall_fd.write(cert_pem) + + return response + + def _mod_config(self, ll_addrs): + """Modifies Nginx config files to include challenge vhosts. + + Result: Nginx config includes virtual servers for issued challs + + :param list ll_addrs: list of list of + :class:`letsencrypt.client.plugins.nginx.obj.Addr` to apply + + """ + # TODO: Use ip address of existing vhost instead of relying on FQDN + config_text = "\n" + for idx, lis in enumerate(ll_addrs): + config_text += self._get_config_text(self.achalls[idx], lis) + config_text += "\n" + + self._conf_include_check(self.configurator.parser.loc["default"]) + self.configurator.reverter.register_file_creation( + True, self.challenge_conf) + + with open(self.challenge_conf, "w") as new_conf: + new_conf.write(config_text) + + def _conf_include_check(self, main_config): + """Adds DVSNI challenge conf file into configuration. + + Adds DVSNI challenge include file if it does not already exist + within mainConfig + + :param str main_config: file path to main user nginx config file + + """ + if len(self.configurator.parser.find_dir( + parser.case_i("Include"), self.challenge_conf)) == 0: + # print "Including challenge virtual host(s)" + self.configurator.parser.add_dir( + parser.get_aug_path(main_config), + "Include", self.challenge_conf) + + def _get_config_text(self, achall, ip_addrs): + """Chocolate virtual server configuration text + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` + + :param list ip_addrs: addresses of challenged domain + :class:`list` of type :class:`~nginx.obj.Addr` + + :returns: virtual host configuration text + :rtype: str + + """ + ips = " ".join(str(i) for i in ip_addrs) + document_root = os.path.join( + self.configurator.config.config_dir, "dvsni_page/") + # TODO: Python docs is not clear how mutliline string literal + # newlines are parsed on different platforms. At least on + # Linux (Debian sid), when source file uses CRLF, Python still + # parses it as "\n"... c.f.: + # https://docs.python.org/2.7/reference/lexical_analysis.html + return self.VHOST_TEMPLATE.format( + vhost=ips, server_name=achall.nonce_domain, + ssl_options_conf_path=self.configurator.parser.loc["ssl_options"], + cert_path=self.get_cert_file(achall), key_path=achall.key.file, + document_root=document_root).replace("\n", os.linesep) + + def get_cert_file(self, achall): + """Returns standardized name for challenge certificate. + + :param achall: Annotated DVSNI challenge. + :type achall: :class:`letsencrypt.client.achallenges.DVSNI` + + :returns: certificate file name + :rtype: str + + """ + return os.path.join( + self.configurator.config.work_dir, achall.nonce_domain + ".crt") diff --git a/letsencrypt/client/plugins/nginx/nginx_configurator.py b/letsencrypt/client/plugins/nginx/nginx_configurator.py new file mode 100644 index 000000000..86aa7e371 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/nginx_configurator.py @@ -0,0 +1,208 @@ +import zope.interface + +from letsencrypt.client import augeas_configurator +from letsencrypt.client import CONFIG +from letsencrypt.client import interfaces + + +# This might be helpful... but feel free to use whatever you want +# class VH(object): +# def __init__(self, filename_path, vh_path, vh_addrs, is_ssl, is_enabled): +# self.file = filename_path +# self.path = vh_path +# self.addrs = vh_addrs +# self.names = [] +# self.ssl = is_ssl +# self.enabled = is_enabled + +# def set_names(self, listOfNames): +# self.names = listOfNames + +# def add_name(self, name): +# self.names.append(name) + +class NginxConfigurator(augeas_configurator.AugeasConfigurator): + """Nginx Configurator class.""" + zope.interface.implements(interfaces.IAuthenticator, interfaces.IInstaller) + + def __init__(self, server_root=CONFIG.SERVER_ROOT): + super(NginxConfigurator, self).__init__() + self.server_root = server_root + + # See if any temporary changes need to be recovered + # This needs to occur before VH objects are setup... + # because this will change the underlying configuration and potential + # vhosts + self.recovery_routine() + # Check for errors in parsing files with Augeas + # TODO - insert nginx lens info here??? + #self.check_parsing_errors("httpd.aug") + + def deploy_cert(self, vhost, cert, key, cert_chain=None): + """Deploy cert in nginx""" + + def choose_virtual_host(self, name): + """Chooses a virtual host based on the given domain name""" + + def get_all_names(self): + """Returns all names found in the nginx configuration""" + return set() + + # Might be helpful... I know nothing about nginx lens + # def get_include_path(self, cur_dir, arg): + # """ + # Converts an Nginx Include directive argument into an Augeas + # searchable path + # Returns path string + # """ + # # Sanity check argument - maybe + # # Question: what can the attacker do with control over this string + # # Effect parse file... maybe exploit unknown errors in Augeas + # # If the attacker can Include anything though... and this function + # # only operates on Nginx 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 nginx config doesn't work because it + # # would create a race condition between the check and this input + + # # TODO: Fix this + # # Check to make sure only expected characters are used, maybe remove + # # validChars = re.compile("[a-zA-Z0-9.*?_-/]*") + # # matchObj = validChars.match(arg) + # # if matchObj.group() != arg: + # # logging.error("Error: Invalid regexp characters in %s", arg) + # # return [] + + # # Standardize the include argument based on server root + # if not arg.startswith("/"): + # arg = cur_dir + arg + # # conf/ is a special variable for ServerRoot in Nginx + # elif arg.startswith("conf/"): + # arg = self.server_root + arg[5:] + # # TODO: Test if Nginx allows ../ or ~/ for Includes + + # # Attempts to add a transform to the file if one does not already + # # exist + # self.parse_file(arg) + + # # Argument represents an fnmatch regular expression, convert it + # # Split up the path and convert each into an Augeas accepted regex + # # then reassemble + # if "*" in arg or "?" in arg: + # postfix = "" + # splitArg = arg.split("/") + # for idx, split in enumerate(splitArg): + # # * and ? are the two special fnmatch characters + # if "*" in split or "?" in split: + # # Turn it into a augeas regex + # # TODO: Can this be an augeas glob instead of regex + # splitArg[idx] = ("* [label()=~regexp('%s')]" % + # self.fnmatch_to_re(split) + # # Reassemble the argument + # arg = "/".join(splitArg) + + # # If the include is a directory, just return the directory as a file + # if arg.endswith("/"): + # return "/files" + arg[:len(arg)-1] + # return "/files"+arg + + def enable_redirect(self, ssl_vhost): + """ + Adds Redirect directive to the port 80 equivalent of ssl_vhost + First the function attempts to find the vhost with equivalent + ip addresses that serves on non-ssl ports + The function then adds the directive + """ + return + + def enable_ocsp_stapling(self, ssl_vhost): + return False + + def enable_hsts(self, ssl_vhost): + return False + + def get_all_certs_keys(self): + """ + Retrieve all certs and keys set in VirtualHosts on the Nginx server + returns: list of tuples with form [(cert, key, path)] + """ + return None + + # Probably helpful reference + # def get_file_path(self, vhost_path): + # """ + # Takes in Augeas path and returns the file name + # """ + # # Strip off /files + # avail_fp = vhost_path[6:] + # # This can be optimized... + # while True: + # # Cast both to lowercase to be case insensitive + # find_if = avail_fp.lower().find("/ifmodule") + # if find_if != -1: + # avail_fp = avail_fp[:find_if] + # continue + # find_vh = avail_fp.lower().find("/virtualhost") + # if find_vh != -1: + # avail_fp = avail_fp[:find_vh] + # continue + # break + # return avail_fp + + def enable_site(self, vhost): + """Enables an available site, Nginx restart required""" + return False + + # Might be a usefule reference + # def parse_file(self, file_path): + # """ + # Checks to see if file_path is parsed by Augeas + # If file_path isn't parsed, the file is added and Augeas is reloaded + # """ + # # Test if augeas included file for Httpd.lens + # # Note: This works for augeas globs, ie. *.conf + # incTest = self.aug.match( + # "/augeas/load/Httpd/incl [. ='" + file_path + "']") + # if not incTest: + # # Load up files + # #self.httpd_incl.append(file_path) + # #self.aug.add_transform( + # # "Httpd.lns", self.httpd_incl, None, self.httpd_excl) + # self.__add_httpd_transform(file_path) + # self.aug.load() + + # Helpful reference? + # def verify_setup(self): + # """ + # Make sure that files/directories are setup with appropriate + # permissions. Aim for defensive coding... make sure all input files + # have permissions of root + # """ + # le_util.make_or_verify_dir(CONFIG.CONFIG_DIR, 0o755) + # le_util.make_or_verify_dir(CONFIG.WORK_DIR, 0o755) + # le_util.make_or_verify_dir(CONFIG.BACKUP_DIR, 0o755) + + def restart(self, quiet=False): + """Restarts nginx server""" + + # May be of use? + # def __add_httpd_transform(self, incl): + # """ + # This function will correctly add a transform to augeas + # The existing augeas.add_transform in python is broken + # """ + # lastInclude = self.aug.match("/augeas/load/Httpd/incl [last()]") + # self.aug.insert(lastInclude[0], "incl", False) + # self.aug.set("/augeas/load/Httpd/incl[last()]", incl) + + def config_test(self): + """Check Configuration""" + return False + + +def main(): + return + +if __name__ == "__main__": + main() diff --git a/letsencrypt/client/plugins/nginx/nginxparser.py b/letsencrypt/client/plugins/nginx/nginxparser.py new file mode 100644 index 000000000..3d01d7ad4 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/nginxparser.py @@ -0,0 +1,110 @@ +"""An nginx config parser based on pyparsing.""" +import string + +from pyparsing import ( + Literal, White, Word, alphanums, CharsNotIn, Forward, Group, + Optional, OneOrMore, ZeroOrMore, pythonStyleComment) + + +class NginxParser(object): + """ + A class that parses nginx configuration with pyparsing + """ + + # constants + left_bracket = Literal("{").suppress() + right_bracket = Literal("}").suppress() + semicolon = Literal(";").suppress() + space = White().suppress() + key = Word(alphanums + "_/") + value = CharsNotIn("{};,") + location = CharsNotIn("{};," + string.whitespace) + # modifier for location uri [ = | ~ | ~* | ^~ ] + modifier = Literal("=") | Literal("~*") | Literal("~") | Literal("^~") + + # rules + assignment = (key + Optional(space + value) + semicolon) + block = Forward() + + block << Group( + Group(key + Optional(space + modifier) + Optional(space + location)) + + left_bracket + + Group(ZeroOrMore(Group(assignment) | block)) + + right_bracket) + + script = OneOrMore(Group(assignment) | block).ignore(pythonStyleComment) + + def __init__(self, source): + self.source = source + + def parse(self): + """ + Returns the parsed tree. + """ + return self.script.parseString(self.source) + + def as_list(self): + """ + Returns the list of tree. + """ + return self.parse().asList() + + +class NginxDumper(object): + """ + A class that dumps nginx configuration from the provided tree. + """ + def __init__(self, blocks, indentation=4): + self.blocks = blocks + self.indentation = indentation + + def __iter__(self, blocks=None, current_indent=0, spacer=' '): + """ + Iterates the dumped nginx content. + """ + blocks = blocks or self.blocks + for key, values in blocks: + if current_indent: + yield spacer + indentation = spacer * current_indent + if isinstance(key, list): + yield indentation + spacer.join(key) + ' {' + for parameter in values: + if isinstance(parameter[0], list): + dumped = self.__iter__( + [parameter], + current_indent + self.indentation) + for line in dumped: + yield line + else: + dumped = spacer.join(parameter) + ';' + yield spacer * ( + current_indent + self.indentation) + dumped + + yield indentation + '}' + else: + yield spacer * current_indent + key + spacer + values + ';' + + def as_string(self): + return '\n'.join(self) + + +# Shortcut functions to respect Python's serialization interface +# (like pyyaml, picker or json) + +def loads(source): + return NginxParser(source).as_list() + + +def load(_file): + return loads(_file.read()) + + +def dumps(blocks, indentation=4): + return NginxDumper(blocks, indentation).as_string() + + +def dump(blocks, _file, indentation=4): + _file.write(dumps(blocks, indentation)) + _file.close() + return _file diff --git a/letsencrypt/client/plugins/nginx/obj.py b/letsencrypt/client/plugins/nginx/obj.py new file mode 100644 index 000000000..69e0d6b20 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/obj.py @@ -0,0 +1,91 @@ +"""Module contains classes used by the Nginx Configurator.""" + + +class Addr(object): + r"""Represents an Nginx 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)) + + +class VirtualHost(object): # pylint: disable=too-few-public-methods + """Represents an Nginx 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): + # pylint: disable=too-many-arguments + """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/plugins/nginx/options-ssl.conf b/letsencrypt/client/plugins/nginx/options-ssl.conf new file mode 100644 index 000000000..8380542c0 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/options-ssl.conf @@ -0,0 +1,27 @@ +ssl_session_cache shared:SSL:1m; # 1MB is ~4000 sessions, if it fills old sessions are dropped +ssl_session_timeout 1440m; # Reuse sessions for 24hrs + +# Redirect all traffic to SSL +server { + listen 80 default; + server_name www.example.com example.com; + access_log off; + error_log off; + return 301 https://example.com$request_uri; +} + +server { + listen 443 ssl default_server; + server_name example.com; + + ssl_certificate /path/to/bundle.crt; + ssl_certificate_key /path/to/private.key; + + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_prefer_server_ciphers on; + + # Using list of ciphers from "Bulletproof SSL and TLS" + ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256 ECDHE-ECDSA-AES256-GCM-SHA384 ECDHE-ECDSA-AES128-SHA ECDHE-ECDSA-AES256-SHA ECDHE-ECDSA-AES128-SHA256 ECDHE-ECDSA-AES256-SHA384 ECDHE-RSA-AES128-GCM-SHA256 ECDHE-RSA-AES256-GCM-SHA384 ECDHE-RSA-AES128-SHA ECDHE-RSA-AES128-SHA256 ECDHE-RSA-AES256-SHA384 DHE-RSA-AES128-GCM-SHA256 DHE-RSA-AES256-GCM-SHA384 DHE-RSA-AES128-SHA DHE-RSA-AES256-SHA DHE-RSA-AES128-SHA256 DHE-RSA-AES256-SHA256 EDH-RSA-DES-CBC3-SHA"; + + # Normal stuff below here +} diff --git a/letsencrypt/client/plugins/nginx/parser.py b/letsencrypt/client/plugins/nginx/parser.py new file mode 100644 index 000000000..0f95c056c --- /dev/null +++ b/letsencrypt/client/plugins/nginx/parser.py @@ -0,0 +1,413 @@ +"""NginxParser is a member object of the NginxConfigurator class.""" +import os +import re + +from letsencrypt.client import errors + + +class NginxParser(object): + """Class handles the fine details of parsing the Nginx Configuration. + + :ivar str root: Normalized abosulte path to the server root + directory. Without trailing slash. + + """ + + def __init__(self, aug, root, ssl_options): + # Find configuration root and make sure augeas can parse it. + self.aug = aug + self.root = os.path.abspath(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 isinstance(arg, list): + for i, value in enumerate(arg, 1): + self.aug.set( + "%s/directive[last()]/arg[%d]" % (aug_conf_path, i), value) + else: + self.aug.set(aug_conf_path + "/directive[last()]/arg", arg) + + 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 Nginx is case + insensitive. Augeas 1.0 allows case insensitive regexes like + regexp(/Listen/, "i"), however the version currently supported + by Ubuntu 0.10 does not. Thus I have included my own case insensitive + transformation by calling case_i() on everything to maintain + compatibility. + + :param str directive: Directive to look for + + :param arg: Specific value directive must have, None if all should + be considered + :type arg: str or None + + :param str start: Beginning Augeas path to begin looking + + """ + # Cannot place member variable in the definition of the function so... + if not start: + start = get_aug_path(self.loc["root"]) + + # Debug code + # print "find_dir:", directive, "arg:", arg, " | Looking in:", start + # No regexp code + # if arg is None: + # matches = self.aug.match(start + + # "//*[self::directive='" + directive + "']/arg") + # else: + # matches = self.aug.match(start + + # "//*[self::directive='" + directive + + # "']/* [self::arg='" + arg + "']") + + # includes = self.aug.match(start + + # "//* [self::directive='Include']/* [label()='arg']") + + if arg is None: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/arg" + % (start, directive))) + else: + matches = self.aug.match(("%s//*[self::directive=~regexp('%s')]/*" + "[self::arg=~regexp('%s')]" % + (start, directive, arg))) + + incl_regex = "(%s)|(%s)" % (case_i('Include'), + case_i('IncludeOptional')) + + includes = self.aug.match(("%s//* [self::directive=~regexp('%s')]/* " + "[label()='arg']" % (start, incl_regex))) + + # for inc in includes: + # print inc, self.aug.get(inc) + + for include in includes: + # start[6:] to strip off /files + matches.extend(self.find_dir( + directive, arg, self._get_include_path( + strip_dir(start[6:]), self.aug.get(include)))) + + return matches + + def _get_include_path(self, cur_dir, arg): + """Converts an Nginx Include directive into Augeas path. + + Converts an Nginx 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 Nginx 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 nginx 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 + # Nginx 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 Nginx + elif arg.startswith("conf/"): + arg = self.root + arg[4:] + # TODO: Test if Nginx 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 Nginx's basic fnmatch to regular expression. + + :param str clean_fn_match: Nginx style filename match, similar to globs + + :returns: regex suitable for augeas + :rtype: str + + """ + # Checkout fnmatch.py in venv/local/lib/python2.7/fnmatch.py + regex = "" + for letter in clean_fn_match: + if letter == '.': + regex = regex + r"\." + elif letter == '*': + regex = regex + ".*" + # According to nginx.org ? shouldn't appear + # but in case it is valid... + elif letter == '?': + regex = regex + "." + else: + regex = regex + letter + return regex + + def _parse_file(self, filepath): + """Parse file with Augeas + + Checks to see if file_path is parsed by Augeas + If filepath isn't parsed, the file is added and Augeas is reloaded + + :param str filepath: Nginx 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']" % filepath) + if not inc_test: + # Load up files + # This doesn't seem to work on TravisCI + # self.aug.add_transform("Httpd.lns", [filepath]) + self._add_httpd_transform(filepath) + 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 doesn't seem to work for + Travis CI as it loads in libaugeas.so.0.10.0 + + :param str incl: filepath to include for transform + + """ + last_include = self.aug.match("/augeas/load/Httpd/incl [last()]") + if last_include: + # Insert a new node immediately after the last incl + self.aug.insert(last_include[0], "incl", False) + self.aug.set("/augeas/load/Httpd/incl[last()]", incl) + # On first use... must load lens and add file to incl + else: + # Augeas uses base 1 indexing... insert at beginning... + self.aug.set("/augeas/load/Httpd/lens", "Httpd.lns") + self.aug.set("/augeas/load/Httpd/incl", incl) + + 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, excluded in enumerate(excl, 1): + self.aug.set("/augeas/load/Httpd/excl[%d]" % i, excluded) + + self.aug.load() + + 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 Nginx Configuration Root file.""" + location = ["nginx2.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.LetsEncryptNoInstallationError( + "Could not find configuration root") + + def _set_user_config_file(self, root): + """Set the appropriate user configuration file + + .. todo:: This will have to be updated for other distros versions + + :param str root: pathname which contains the user config + + """ + # Basic check to see if httpd.conf exists and + # in hierarchy via direct include + # httpd.conf was very common as a user file in Nginx 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, 'nginx2.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/plugins/nginx/tests/__init__.py b/letsencrypt/client/plugins/nginx/tests/__init__.py new file mode 100644 index 000000000..157a70759 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Nginx Tests""" diff --git a/letsencrypt/client/plugins/nginx/tests/configurator_test.py b/letsencrypt/client/plugins/nginx/tests/configurator_test.py new file mode 100644 index 000000000..cb059285a --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/configurator_test.py @@ -0,0 +1,196 @@ +"""Test for letsencrypt.client.nginx.configurator.""" +import os +import re +import shutil +import unittest + +import mock + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import errors +from letsencrypt.client import le_util + +from letsencrypt.client.nginx import configurator +from letsencrypt.client.nginx import obj +from letsencrypt.client.nginx import parser + +from letsencrypt.client.tests.nginx import util + + +class TwoVhost80Test(util.NginxTest): + """Test two standard well configured HTTP vhosts.""" + + def setUp(self): + super(TwoVhost80Test, self).setUp() + + with mock.patch("letsencrypt.client.nginx.configurator." + "mod_loaded") as mock_load: + mock_load.return_value = True + self.config = util.get_nginx_configurator( + self.config_path, self.config_dir, self.work_dir, + self.ssl_options) + + self.vh_truth = util.get_vh_truth( + self.temp_dir, "debian_nginx_2_4/two_vhost_80") + + def tearDown(self): + shutil.rmtree(self.temp_dir) + shutil.rmtree(self.config_dir) + shutil.rmtree(self.work_dir) + + def test_get_all_names(self): + names = self.config.get_all_names() + self.assertEqual(names, set( + ['letsencrypt.demo', 'encryption-example.demo', 'ip-172-30-0-17'])) + + def test_get_virtual_hosts(self): + vhs = self.config.get_virtual_hosts() + self.assertEqual(len(vhs), 4) + found = 0 + + for vhost in vhs: + for truth in self.vh_truth: + if vhost == truth: + found += 1 + break + + self.assertEqual(found, 4) + + def test_is_site_enabled(self): + self.assertTrue(self.config.is_site_enabled(self.vh_truth[0].filep)) + self.assertFalse(self.config.is_site_enabled(self.vh_truth[1].filep)) + self.assertTrue(self.config.is_site_enabled(self.vh_truth[2].filep)) + self.assertTrue(self.config.is_site_enabled(self.vh_truth[3].filep)) + + def test_deploy_cert(self): + # Get the default 443 vhost + self.config.assoc["random.demo"] = self.vh_truth[1] + self.config.deploy_cert( + "random.demo", + "example/cert.pem", "example/key.pem", "example/cert_chain.pem") + self.config.save() + + loc_cert = self.config.parser.find_dir( + parser.case_i("sslcertificatefile"), + re.escape("example/cert.pem"), self.vh_truth[1].path) + loc_key = self.config.parser.find_dir( + parser.case_i("sslcertificateKeyfile"), + re.escape("example/key.pem"), self.vh_truth[1].path) + loc_chain = self.config.parser.find_dir( + parser.case_i("SSLCertificateChainFile"), + re.escape("example/cert_chain.pem"), self.vh_truth[1].path) + + # Verify one directive was found in the correct file + self.assertEqual(len(loc_cert), 1) + self.assertEqual(configurator.get_file_path(loc_cert[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_key), 1) + self.assertEqual(configurator.get_file_path(loc_key[0]), + self.vh_truth[1].filep) + + self.assertEqual(len(loc_chain), 1) + self.assertEqual(configurator.get_file_path(loc_chain[0]), + self.vh_truth[1].filep) + + def test_is_name_vhost(self): + addr = obj.Addr.fromstring("*:80") + self.assertTrue(self.config.is_name_vhost(addr)) + self.config.version = (2, 2) + self.assertFalse(self.config.is_name_vhost(addr)) + + def test_add_name_vhost(self): + self.config.add_name_vhost("*:443") + self.assertTrue(self.config.parser.find_dir( + "NameVirtualHost", re.escape("*:443"))) + + def test_make_vhost_ssl(self): + ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0]) + + self.assertEqual( + ssl_vhost.filep, + os.path.join(self.config_path, "sites-available", + "encryption-example-le-ssl.conf")) + + self.assertEqual(ssl_vhost.path, + "/files" + ssl_vhost.filep + "/IfModule/VirtualHost") + self.assertEqual(len(ssl_vhost.addrs), 1) + self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs) + self.assertEqual(ssl_vhost.names, set(["encryption-example.demo"])) + self.assertTrue(ssl_vhost.ssl) + self.assertFalse(ssl_vhost.enabled) + + self.assertTrue(self.config.parser.find_dir( + "SSLCertificateFile", None, ssl_vhost.path)) + self.assertTrue(self.config.parser.find_dir( + "SSLCertificateKeyFile", None, ssl_vhost.path)) + self.assertTrue(self.config.parser.find_dir( + "Include", self.ssl_options, ssl_vhost.path)) + + self.assertEqual(self.config.is_name_vhost(self.vh_truth[0]), + self.config.is_name_vhost(ssl_vhost)) + + self.assertEqual(len(self.config.vhosts), 5) + + @mock.patch("letsencrypt.client.nginx.configurator." + "dvsni.NginxDvsni.perform") + @mock.patch("letsencrypt.client.nginx.configurator." + "NginxConfigurator.restart") + def test_perform(self, mock_restart, mock_dvsni_perform): + # Only tests functionality specific to configurator.perform + # Note: As more challenges are offered this will have to be expanded + auth_key = le_util.Key(self.rsa256_file, self.rsa256_pem) + achall1 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="jIq_Xy1mXGN37tb4L6Xj_es58fW571ZNyXekdZzhh7Q", + nonce="37bc5eb75d3e00a19b4f6355845e5a18"), + domain="encryption-example.demo", key=auth_key) + achall2 = achallenges.DVSNI( + chall=challenges.DVSNI( + r="uqnaPzxtrndteOqtrXb0Asl5gOJfWAnnx6QJyvcmlDU", + nonce="59ed014cac95f77057b1d7a1b2c596ba"), + domain="letsencrypt.demo", key=auth_key) + + dvsni_ret_val = [ + challenges.DVSNIResponse(s="randomS1"), + challenges.DVSNIResponse(s="randomS2"), + ] + + mock_dvsni_perform.return_value = dvsni_ret_val + responses = self.config.perform([achall1, achall2]) + + self.assertEqual(mock_dvsni_perform.call_count, 1) + self.assertEqual(responses, dvsni_ret_val) + + self.assertEqual(mock_restart.call_count, 1) + + @mock.patch("letsencrypt.client.nginx.configurator." + "subprocess.Popen") + def test_get_version(self, mock_popen): + mock_popen().communicate.return_value = ( + "Server Version: Nginx/2.4.2 (Debian)", "") + self.assertEqual(self.config.get_version(), (2, 4, 2)) + + mock_popen().communicate.return_value = ( + "Server Version: Nginx/2 (Linux)", "") + self.assertEqual(self.config.get_version(), (2,)) + + mock_popen().communicate.return_value = ( + "Server Version: Nginx (Debian)", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + mock_popen().communicate.return_value = ( + "Server Version: Nginx/2.3\n Nginx/2.4.7", "") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + mock_popen.side_effect = OSError("Can't find program") + self.assertRaises( + errors.LetsEncryptConfiguratorError, self.config.get_version) + + +if __name__ == '__main__': + unittest.main() diff --git a/letsencrypt/client/plugins/nginx/tests/dvsni_test.py b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py new file mode 100644 index 000000000..869b5e806 --- /dev/null +++ b/letsencrypt/client/plugins/nginx/tests/dvsni_test.py @@ -0,0 +1,170 @@ +"""Test for letsencrypt.client.nginx.dvsni.""" +import pkg_resources +import unittest +import shutil + +import mock + +from letsencrypt.acme import challenges + +from letsencrypt.client import achallenges +from letsencrypt.client import le_util + +from letsencrypt.client.nginx.obj import Addr + +from letsencrypt.client.tests.nginx import util + + +class DvsniPerformTest(util.NginxTest): + """Test the NginxDVSNI challenge.""" + + def setUp(self): + super(DvsniPerformTest, self).setUp() + + with mock.patch("letsencrypt.client.nginx.configurator." + "mod_loaded") as mock_load: + mock_load.return_value = True + config = util.get_nginx_configurator( + self.config_path, self.config_dir, self.work_dir, + self.ssl_options) + + from letsencrypt.client.nginx import dvsni + self.sni = dvsni.NginxDvsni(config) + + rsa256_file = pkg_resources.resource_filename( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + rsa256_pem = pkg_resources.resource_string( + "letsencrypt.client.tests", 'testdata/rsa256_key.pem') + + auth_key = le_util.Key(rsa256_file, rsa256_pem) + self.achalls = [ + achallenges.DVSNI( + chall=challenges.DVSNI( + r="\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd\xeb9\xf1" + "\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4", + nonce="7\xbc^\xb7]>\x00\xa1\x9bOcU\x84^Z\x18", + ), domain="encryption-example.demo", key=auth_key), + achallenges.DVSNI( + chall=challenges.DVSNI( + r="\xba\xa9\xda?=1.5.5', 'pyrfc3339', 'python-augeas', 'python2-pythondialog>=3.2.2rc1', # Debian squeeze support, cf. #280 @@ -103,6 +105,8 @@ setup( 'letsencrypt.client.plugins', 'letsencrypt.client.plugins.apache', 'letsencrypt.client.plugins.apache.tests', + 'letsencrypt.client.plugins.nginx', + 'letsencrypt.client.plugins.nginx.tests', 'letsencrypt.client.plugins.standalone', 'letsencrypt.client.plugins.standalone.tests', 'letsencrypt.client.tests',