diff --git a/letsencrypt/client/plugins/nginx/configurator.py b/letsencrypt/client/plugins/nginx/configurator.py index 2f95f3c58..65a7ebad5 100644 --- a/letsencrypt/client/plugins/nginx/configurator.py +++ b/letsencrypt/client/plugins/nginx/configurator.py @@ -164,7 +164,8 @@ class NginxConfigurator(object): # Vhost parsing methods ####################### def choose_vhost(self, target_name): - """Chooses a virtual host based on the given domain name. + """Chooses a virtual host based on the given domain name. NOTE: This + makes the vhost SSL-enabled if it isn't already. .. todo:: This should maybe return list if no obvious answer is presented. @@ -178,34 +179,66 @@ class NginxConfigurator(object): :rtype: :class:`~letsencrypt.client.plugins.nginx.obj.VirtualHost` """ - # Allows for domain names to be associated with a virtual host + vhost = None + + # If we already found the vhost for the target, use it 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 + vhost = self.assoc[target_name] + else: + matches = self._get_ranked_matches(target_name) + if len(matches) == 0: + # No matches at all :'( + break + elif matches[0]['rank'] in range(2, 6): + # Wildcard match - need to find the longest one + rank = matches[0]['rank'] + wildcards = [x for x in matches if x['rank'] == rank] + vhost = max(wildcards, key=lambda x: len(x['name']))['vhost'] + else: + vhost = matches[0]['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: + if vhost is not None: + self.assoc[target_name] = vhost + if not vhost.ssl: vhost = self._make_vhost_ssl(vhost) - self.assoc[target_name] = vhost - return vhost - # No matches, search for the default + return vhost + + def _get_ranked_matches(self, target_name): + """ + Returns a ranked list of vhosts that match target_name. + + :param str target_name: The name to match + :returns: list of dicts containing the vhost, the matching name, and + the numerical rank + :rtype: list + + """ + # Nginx chooses a matching server name for a request with precedence: + # 1. exact name match + # 2. longest wildcard name starting with * + # 3. longest wildcard name ending with * + # 4. first matching regex in order of appearance in the file + matches = [] for vhost in self.vhosts: - if "_default_:443" in vhost.addrs: - return vhost - return None + name_type, name = parser.get_best_match(target_name, vhost.names) + if name_type == 'exact': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 0 if vhost.ssl else 1}) + elif name_type == 'wildcard_start': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 2 if vhost.ssl else 3}) + elif name_type == 'wildcard_end': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 4 if vhost.ssl else 5}) + elif name_type == 'regex': + matches.append({'vhost': vhost, + 'name': name, + 'rank': 6 if vhost.ssl else 7}) + return sorted(matches, key=lambda x: x['rank'], reverse=True) def get_all_names(self): """Returns all names found in the Nginx Configuration. diff --git a/letsencrypt/client/plugins/nginx/parser.py b/letsencrypt/client/plugins/nginx/parser.py index d05bcf13d..2633b778c 100644 --- a/letsencrypt/client/plugins/nginx/parser.py +++ b/letsencrypt/client/plugins/nginx/parser.py @@ -3,6 +3,7 @@ import glob import logging import os import pyparsing +import re from letsencrypt.client import errors from letsencrypt.client.plugins.nginx import obj @@ -64,7 +65,7 @@ class NginxParser(object): :param str path: The path :returns: The absolute path - :rtype str + :rtype: str """ if not os.path.isabs(path): @@ -114,6 +115,9 @@ class NginxParser(object): self.abs_path(directive[1])) for f in included_files: try: + # Assign instead of append because servers[f] + # should be empty since server blocks cannot + # contain other server blocks. servers[f] = self.parsed[f] except: pass @@ -279,3 +283,90 @@ def do_for_subarray(entry, condition, func): logging.warn("Error in do_for_subarray for %s" % item) else: do_for_subarray(item, condition, func) + + +def get_best_match(target_name, names): + """Finds the best match for target_name out of names using the Nginx + name-matching rules (exact > longest wildcard starting with * > + longest wildcard ending with * > regex). + + :param str target_name: The name to match + :param list names: The candidate server names + :returns: Tuple of (type of match, the name that matched) + :rtype: tuple + + """ + exact = [] + wildcard_start = [] + wildcard_end = [] + regex = [] + + for name in names: + if _exact_match(target_name, name): + exact.append(name) + elif _wildcard_match(target_name, name, True): + wildcard_start.append(name) + elif _wildcard_match(target_name, name, False): + wildcard_end.append(name) + elif _regex_match(target_name, name): + regex.append(name) + + if len(exact) > 0: + # There can be more than one exact match; e.g. eff.org, .eff.org + match = min(exact, key=lambda x: len(x)) + return ('exact', match) + if len(wildcard_start) > 0: + # Return the longest wildcard + match = max(wildcard_start, key=lambda x: len(x)) + return ('wildcard_start', match) + if len(wildcard_end) > 0: + # Return the longest wildcard + match = max(wildcard_end, key=lambda x: len(x)) + return ('wildcard_end', match) + if len(regex) > 0: + # Just return the first one for now + match = regex[0] + return ('regex', match) + + return (None, None) + + +def _exact_match(target_name, name): + return (target_name == name or target_name == '.' + name) + + +def _wildcard_match(target_name, name, start): + parts = target_name.split('.') + match_parts = name.split('.') + + # If the domain ends in a wildcard, do the match procedure in reverse + if not start: + parts.reverse() + match_parts.reverse() + + # The first part must be a wildcard + if match_parts.pop(0) != '*': + return False + + target_name = '.'.join(parts) + name = '.'.join(match_parts) + + # Ex: www.eff.org matches *.eff.org, eff.org does not match *.eff.org + return target_name.endswith('.' + name) + + +def _regex_match(target_name, name): + # Must start with a tilde + if name[0] != '~': + return False + + # After tilde is a perl-compatible regex + try: + regex = re.compile(name[1:]) + if regex.match(target_name): + return True + else: + return False + except: + # perl-compatible regexes are sometimes not recognized by python + return False