From d25c7c36e7f2e468f93317ec08fd6719bf9430b8 Mon Sep 17 00:00:00 2001 From: James Kasten Date: Tue, 21 Jul 2015 17:16:46 -0700 Subject: [PATCH] Convert to servername/serveralias --- .../letsencrypt_apache/configurator.py | 310 ++++++------------ .../letsencrypt_apache/constants.py | 2 +- letsencrypt-apache/letsencrypt_apache/obj.py | 104 +++++- .../letsencrypt_apache/parser.py | 3 +- .../tests/configurator_test.py | 2 +- .../letsencrypt_apache/tests/util.py | 6 +- 6 files changed, 206 insertions(+), 221 deletions(-) diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index 07e1bb2bc..6ef10baab 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -272,7 +272,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): best_points = 0 for vhost in self.vhosts: - if target_name in vhost.names: + if target_name in vhost.get_names(): points = 2 elif any(addr.get_addr() == target_name for addr in vhost.addrs): points = 1 @@ -312,7 +312,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): all_names = set() for vhost in self.vhosts: - all_names.update(vhost.names) + all_names.update(vhost.get_names()) for addr in vhost.addrs: name = self.get_name_from_ip(addr) if name: @@ -326,7 +326,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :param addr: IP Address :type addr: ~.common.Addr - :returns: name + :returns: name or empty string if name cannot be determined :rtype: str """ @@ -347,17 +347,21 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :type host: :class:`~letsencrypt_apache.obj.VirtualHost` """ - name_match = self.aug.match(("%s//*[self::directive=~regexp('%s')] | " - "%s//*[self::directive=~regexp('%s')]" % - (host.path, - parser.case_i("ServerName"), - host.path, - parser.case_i("ServerAlias")))) + # Take the final ServerName as each overrides the previous + servername_match = self.parser.find_dir( + "ServerName", None, host.path, exclude=False) + serveralias_match = self.parser.find_dir( + "ServerAlias", None, host.path, exclude=False) - for name in name_match: - args = self.aug.match(name + "/*") - for arg in args: - host.add_name(self.parser.get_arg(arg)) + aliases = [] + for alias in serveralias_match: + aliases.append(self.parser.get_arg(alias)) + + if servername_match: + # Get last ServerName as each overwrites the previous + host.add_names(self.parser.get_arg(servername_match[-1]), aliases) + else: + host.add_names(None, aliases) def _create_vhost(self, path): """Used by get_virtual_hosts to create vhost objects @@ -700,28 +704,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if "rewrite_module" not in self.parser.modules: self.enable_mod("rewrite") - general_v = self._general_vhost(ssl_vhost) + general_v = self._get_http_vhost(ssl_vhost) if general_v is None: # Add virtual_server with redirect - logger.debug( - "Did not find http version of ssl virtual host... creating") - return self._create_redirect_vhost(ssl_vhost) + logger.debug("Did not find http version of ssl virtual host " + "attempting to create") + redirect_addrs = self._get_redirect_addrs(ssl_vhost) + for vhost in self.vhosts: + if vhost.enabled and vhost.conflicts(redirect_addrs): + raise errors.PluginError( + "Unable to find corresponding HTTP vhost; " + "Unable to create one as intended addresses conflict; " + "Current configuration does not support automated " + "redirection") + self.create_redirect_vhost(redirect_addrs) else: # Check if redirection already exists - exists, code = self._existing_redirect(general_v) - if exists: - if code == 0: - logger.debug("Redirect already added") - logger.info( - "Configuration is already redirecting traffic to HTTPS") - return - else: - logger.info("Unknown redirect exists for this vhost") - raise errors.PluginError( - "Unknown redirect already exists " - "in {}".format(general_v.filep)) + self._verify_no_redirects(vhost) # Add directives to server - self.parser.add_dir(general_v.path, "RewriteEngine", "On") + self.parser.add_dir(general_v.path, "RewriteEngine", "on") self.parser.add_dir(general_v.path, "RewriteRule", constants.REWRITE_HTTPS_ARGS) self.save_notes += ("Redirecting host in %s to ssl vhost in %s\n" % @@ -731,24 +732,16 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): logger.info("Redirecting vhost in %s to ssl vhost in %s", general_v.filep, ssl_vhost.filep) - def _existing_redirect(self, vhost): + def _verify_no_redirects(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_apache.obj.VirtualHost` - :returns: Success, code value... see documentation - :rtype: bool, int + :raises errors.PluginError: When another redirection exists """ rewrite_path = self.parser.find_dir("RewriteRule", None, vhost.path) @@ -756,20 +749,18 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): if redirect_path: # "Existing Redirect directive for virtualhost" - return True, 1 - if not rewrite_path: + raise errors.PluginError("Existing Redirect present on HTTP vhost.") + if rewrite_path: # "No existing redirection for virtualhost" - return False, -1 - if len(rewrite_path) == len(constants.REWRITE_HTTPS_ARGS): - for idx, match in enumerate(rewrite_path): - if (self.aug.get(match) != - constants.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 + if len(rewrite_path) != len(constants.REWRITE_HTTPS_ARGS): + raise errors.PluginError("Unknown Existing RewriteRule") + for match, arg in itertools.izip( + rewrite_path, constants.REWRITE_HTTPS_ARGS): + if self.aug.get(match) != arg: + raise errors.PluginError("Unknown Existing RewriteRule") + raise errors.PluginError( + "Let's Encrypt has already enabled redirection") + def _create_redirect_vhost(self, ssl_vhost): """Creates an http_vhost specifically to redirect for the ssl_vhost. @@ -782,62 +773,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): :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.PluginError( - "Unable to create a redirection vhost - {}".format( - host_or_addrs)) + text = self._get_redirect_config_str(ssl_vhost) - redirect_addrs = host_or_addrs - - # get servernames and serveraliases - serveralias = "" - servername = "" - size_n = len(ssl_vhost.names) - if size_n > 0: - servername = "ServerName " + ssl_vhost.names[0] - if size_n > 1: - serveralias = " ".join(ssl_vhost.names[1:size_n]) - serveralias = "ServerAlias " + serveralias - redirect_file = ("\n" - "%s \n" - "%s \n" - "ServerSignature Off\n" - "\n" - "RewriteEngine On\n" - "RewriteRule %s\n" - "\n" - "ErrorLog /var/log/apache2/redirect.error.log\n" - "LogLevel warn\n" - "\n" - % (servername, serveralias, - " ".join(constants.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) - logger.info("Created redirect file: %s", redirect_filename) + self._write_out_redirect(ssl_vhost, text) self.aug.load() # Make a new vhost data structure and add it to the lists @@ -849,85 +787,77 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): "ssl vhost %s\n" % (new_vhost.filep, ssl_vhost.filep)) - def _conflicting_host(self, ssl_vhost): - """Checks for conflicting HTTP vhost for ssl_vhost. + def _get_redirect_config_str(self, ssl_vhost): + # get servernames and serveraliases + serveralias = "" + servername = "" - Checks for a conflicting host, such that a new port 80 host could not - be created without ruining the apache config - Used with redirection + if ssl_vhost.name is not None: + servername = "ServerName " + ssl_vhost.name + if ssl_vhost.aliases: + serveralias = "ServerAlias " + " ".join(ssl_vhost.aliases) - returns: conflict, host_or_addrs - boolean - if conflict: returns conflicting vhost - if not conflict: returns space separated list of new host addrs + return ("\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" + % (" ".join(self._get_redirect_addrs(ssl_vhost)), + servername, serveralias, + " ".join(constants.REWRITE_HTTPS_ARGS))) - :param ssl_vhost: SSL Vhost to check for possible port 80 redirection - :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + def _write_out_redirect(self, ssl_vhost, text): + # This is the default name + redirect_filename = "le-redirect.conf" - :returns: TODO - :rtype: TODO + # See if a more appropriate name can be applied + if ssl_vhost.name is not None: + # make sure servername doesn't exceed filename length restriction + if len(ssl_vhost.name) < (255 - (len(redirect_filename) + 1)): + redirect_filename = "le-redirect-%s.conf" % ssl_vhost.servername - """ - # 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_filepath = os.path.join( + self.parser.root, "sites-available", redirect_filename) - redirect_addrs = redirect_addrs + ssl_a_vhttp + # 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) - return False, redirect_addrs + # Write out file + with open(redirect_filepath, "w") as redirect_file: + redirect_file.write(text) + logger.info("Created redirect file: %s", redirect_filename) - def _general_vhost(self, ssl_vhost): - """Find appropriate HTTP vhost for ssl_vhost. + return redirect_filepath - Function needs to be thoroughly tested and perhaps improved - Will not do well with malformed configurations - Consider changing this into a dict check + def _get_http_vhost(self, ssl_vhost): + """Find appropriate HTTP vhost for ssl_vhost.""" + # First candidate vhosts filter + candidate_http_vhs = [ + vhost for vhost in self.vhosts if not vhost.ssl + ] - :param ssl_vhost: ssl vhost to check - :type ssl_vhost: :class:`~letsencrypt_apache.obj.VirtualHost` + # Second filter - check addresses + for http_vh in candidate_http_vhs: + if http_vh.same_server(ssl_vhost): + return http_vh - :returns: HTTP vhost or None if unsuccessful - :rtype: :class:`~letsencrypt_apache.obj.VirtualHost` or ``None`` - - """ - # _default_:443 check - # Instead... should look for vhost of the form *:80 - # Should we prompt the user? - ssl_addrs = ssl_vhost.addrs - if ssl_addrs == obj.Addr.fromstring("_default_:443"): - ssl_addrs = [obj.Addr.fromstring("*:443")] - - for vhost in self.vhosts: - found = 0 - # Not the same vhost, and same number of addresses - if vhost != ssl_vhost and len(vhost.addrs) == len(ssl_vhost.addrs): - # Find each address in ssl_host in test_host - for ssl_a in ssl_addrs: - for test_a in vhost.addrs: - if test_a.get_addr() == ssl_a.get_addr(): - # Check if found... - if (test_a.get_port() == "80" or - test_a.get_port() == "" or - test_a.get_port() == "*"): - found += 1 - break - # Check to make sure all addresses were found - # and names are equal - if (found == len(ssl_vhost.addrs) and - vhost.names == ssl_vhost.names): - return vhost return None + def _get_redirect_addrs(self, ssl_vhost): + redirects = set() + for addr in vhost.addrs: + redirects.add(addr.get_addr_obj("80")) + + return redirects + def get_all_certs_keys(self): """Find all existing keys, certs from configuration. @@ -1075,38 +1005,6 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): self.reverter.register_file_creation(False, enabled_path) os.symlink(os.path.join(mods_available, filename), enabled_path) - def mod_loaded(self, module): - """Checks to see if mod_ssl is loaded - - Uses ``apache_ctl`` to get loaded module list. This also effectively - serves as a config_test. - - :returns: If module is loaded. - :rtype: bool - - """ - try: - proc = subprocess.Popen( - [self.conf("ctl"), "-M"], - stdout=subprocess.PIPE, - stderr=subprocess.PIPE) - stdout, stderr = proc.communicate() - - except (OSError, ValueError): - logger.error( - "Error accessing %s for loaded modules!", self.conf("ctl")) - raise errors.MisconfigurationError("Error accessing loaded modules") - # Small errors that do not impede - if proc.returncode != 0: - logger.warn("Error in checking loaded module list: %s", stderr) - raise errors.MisconfigurationError( - "Apache is unable to check whether or not the module is " - "loaded because Apache is misconfigured.") - - if module in stdout: - return True - return False - def restart(self): """Restarts apache server. diff --git a/letsencrypt-apache/letsencrypt_apache/constants.py b/letsencrypt-apache/letsencrypt_apache/constants.py index cb75276b2..7e7e127f5 100644 --- a/letsencrypt-apache/letsencrypt_apache/constants.py +++ b/letsencrypt-apache/letsencrypt_apache/constants.py @@ -20,5 +20,5 @@ MOD_SSL_CONF_SRC = pkg_resources.resource_filename( distribution.""" REWRITE_HTTPS_ARGS = [ - "^.*$", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,R=permanent]"] + "^", "https://%{SERVER_NAME}%{REQUEST_URI}", "[L,QSA,R=permanent]"] """Apache rewrite rule arguments used for redirections to https vhost""" diff --git a/letsencrypt-apache/letsencrypt_apache/obj.py b/letsencrypt-apache/letsencrypt_apache/obj.py index a2df429c3..6895e9039 100644 --- a/letsencrypt-apache/letsencrypt_apache/obj.py +++ b/letsencrypt-apache/letsencrypt_apache/obj.py @@ -1,6 +1,9 @@ """Module contains classes used by the Apache Configurator.""" +import re + from letsencrypt.plugins import common + class Addr(common.Addr): """Represents an Apache address.""" def __eq__(self, other): @@ -45,39 +48,57 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods :ivar str path: Augeas path to virtual host :ivar set addrs: Virtual Host addresses (:class:`set` of :class:`common.Addr`) - :ivar set names: Server names/aliases of vhost + :ivar str name: ServerName of VHost + :ivar list aliases: Server aliases of vhost (:class:`list` of :class:`str`) :ivar bool ssl: SSLEngine on in vhost :ivar bool enabled: Virtual host is enabled + .. todo:: Handle ServerNames appropriately... + """ - def __init__(self, filep, path, addrs, ssl, enabled, names=None): + # ?: is used for not returning enclosed characters + strip_name = re.compile(r"^(?:.+://)?([^ :$]*)") + + def __init__(self, filep, path, addrs, ssl, enabled, name=None, aliases=[]): # 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.name = name + self.aliases = aliases self.ssl = ssl self.enabled = enabled - def add_name(self, name): + def add_names(self, servername, serveralias): """Add name to vhost.""" - self.names.add(name) + self.name = servername + self.aliases = serveralias + + def get_names(self): + all_names = set(self.aliases) + # Strip out any scheme:// and field from servername + if self.name is not None: + all_names.add(VirtualHost.strip_name.findall(self.name)[0]) + + return all_names def __str__(self): return ( "File: {filename}\n" "Vhost path: {vhpath}\n" "Addresses: {addrs}\n" - "Names: {names}\n" + "Name: {name}\n" + "Aliases: {aliases}\n" "TLS Enabled: {tls}\n" "Site Enabled: {active}".format( filename=self.filep, vhpath=self.path, addrs=", ".join(str(addr) for addr in self.addrs), - names=", ".join(name for name in self.names), + name=self.name if self.name is not None else "", + aliases=", ".join(name for name in self.aliases), tls="Yes" if self.ssl else "No", active="Yes" if self.enabled else "No")) @@ -85,7 +106,74 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods 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.get_names() == other.get_names() and self.ssl == other.ssl and self.enabled == other.enabled) return False + + def __ne__(self, other): + return not self.__eq__(other) + + def conflicts(self, addrs): + """See if vhost conflicts with any of the addrs. + + This determines whether or not these addresses would/could overwrite + the vhost addresses. + + :param addrs: Iterable Addresses + :type addrs: Iterable :class:~obj.Addr + + :returns: If addresses conflict with vhost + :rtype: bool + + """ + # TODO: Handle domain name addrs... + for addr in addrs: + if (addr in self.addrs or addr.get_addr_obj("") in self.addrs or + addr.get_addr_obj("*") in self.addrs): + return True + + return False + + def same_server(self, vhost): + """Determines if the vhost is the same 'server'. + + Used in redirection - indicates whether or not the two virtual hosts + serve on the exact same IP combinations, but different ports. + + .. todo:: Handle _default_ + + """ + + if vhost.get_names() != self.get_names(): + return False + + # If equal and set is not empty... assume same server + if self.name is not None: + return True + + # Both sets of names are empty. + + # Make conservative educated guess... this is very restrictive + # Consider adding more safety checks. + if len(vhost.addrs) != len(self.addrs): + return False + + # already_found acts to keep everything very conservative. + # Don't allow multiple ip:ports in same set. + already_found = set() + + for addr in vhost.addrs: + for local_addr in self.addrs: + if (local_addr.get_addr() == addr.get_addr() and + local_addr != addr and + local_addr.get_addr() not in already_found): + + # This intends to make sure we aren't double counting... + # e.g. 127.0.0.1:* + already_found.add(local_addr.get_addr()) + break + else: + return False + + return True \ No newline at end of file diff --git a/letsencrypt-apache/letsencrypt_apache/parser.py b/letsencrypt-apache/letsencrypt_apache/parser.py index 8feb54fa8..ada2db027 100644 --- a/letsencrypt-apache/letsencrypt_apache/parser.py +++ b/letsencrypt-apache/letsencrypt_apache/parser.py @@ -230,8 +230,7 @@ class ApacheParser(object): Directives should be in the form of a case insensitive regex currently .. todo:: arg should probably be a list - .. todo:: Check //* notation for including directories not intended - to be included. + .. todo:: Check //* notation for including directories Note: Augeas is inherently case sensitive while Apache is case insensitive. Augeas 1.0 allows case insensitive regexes like diff --git a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py index e68b21945..101a53a97 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/configurator_test.py @@ -185,7 +185,7 @@ class TwoVhost80Test(util.ApacheTest): "/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.assertEqual(ssl_vhost.name, "encryption-example.demo") self.assertTrue(ssl_vhost.ssl) self.assertFalse(ssl_vhost.enabled) diff --git a/letsencrypt-apache/letsencrypt_apache/tests/util.py b/letsencrypt-apache/letsencrypt_apache/tests/util.py index e0d2f177a..fcec3a6a5 100644 --- a/letsencrypt-apache/letsencrypt_apache/tests/util.py +++ b/letsencrypt-apache/letsencrypt_apache/tests/util.py @@ -105,7 +105,7 @@ def get_vh_truth(temp_dir, config_name): 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"])), + False, True, "encryption-example.demo"), obj.VirtualHost( os.path.join(prefix, "default-ssl.conf"), os.path.join(aug_pre, "default-ssl.conf/IfModule/VirtualHost"), @@ -114,12 +114,12 @@ def get_vh_truth(temp_dir, config_name): 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"])), + "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"])), + "letsencrypt.demo"), ] return vh_truth