From 994a6ce114ca344b7e9716b2dce3b1541ca3b9bc Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Sun, 8 Oct 2017 23:54:59 +0300 Subject: [PATCH] Nginx IPv6 support --- certbot-nginx/certbot_nginx/configurator.py | 39 +++++++++++++++++- certbot-nginx/certbot_nginx/obj.py | 45 +++++++++++++++------ certbot-nginx/certbot_nginx/tls_sni_01.py | 15 ++++++- certbot/plugins/common.py | 2 +- 4 files changed, 84 insertions(+), 17 deletions(-) diff --git a/certbot-nginx/certbot_nginx/configurator.py b/certbot-nginx/certbot_nginx/configurator.py index 7e55ff0ea..1638d7e11 100644 --- a/certbot-nginx/certbot_nginx/configurator.py +++ b/certbot-nginx/certbot_nginx/configurator.py @@ -247,6 +247,24 @@ class NginxConfigurator(common.Installer): return vhost + def ipv6_info(self): + """Returns tuple of booleans (ipv6_active, ipv6only_present) + ipv6_active is true if any server block has an active ipv6 address. + ipv6only_present is true if ipv6only=on option exists in configuration. + + :rtype: tuple of type (bool, bool) + """ + vhosts = self.parser.get_vhosts() + ipv6_active = False + ipv6only_present = False + for vh in vhosts: + for addr in vh.addrs: + if addr.ipv6: + ipv6_active = True + if addr.ipv6only: + ipv6only_present = True + return (ipv6_active, ipv6only_present) + def _get_ranked_matches(self, target_name): """Returns a ranked list of vhosts that match target_name. The ranking gives preference to SSL vhosts. @@ -405,9 +423,12 @@ class NginxConfigurator(common.Installer): all_names.add(host) elif not common.private_ips_regex.match(host): # If it isn't a private IP, do a reverse DNS lookup - # TODO: IPv6 support try: - socket.inet_aton(host) + if addr.ipv6: + host = addr.get_ipv6_exploded() + socket.inet_pton(socket.AF_INET6, host) + else: + socket.inet_pton(socket.AF_INET, host) all_names.add(socket.gethostbyaddr(host)[0]) except (socket.error, socket.herror, socket.timeout): continue @@ -443,15 +464,29 @@ class NginxConfigurator(common.Installer): :type vhost: :class:`~certbot_nginx.obj.VirtualHost` """ + ipv6info = self.ipv6_info() + # If the vhost was implicitly listening on the default Nginx port, # have it continue to do so. if len(vhost.addrs) == 0: listen_block = [['\n ', 'listen', ' ', self.DEFAULT_LISTEN_PORT]] self.parser.add_server_directives(vhost, listen_block, replace=False) + ipv6_block = [''] + if vhost.ipv6_enabled(): + ipv6_block = ['\n ', + 'listen', + ' ', + '[::]:{} ssl'.format(self.config.tls_sni_01_port)] + if not ipv6info[1]: + # ipv6only=on is absent in global config + ipv6_block.append(' ') + ipv6_block.append('ipv6only=on') + snakeoil_cert, snakeoil_key = self._get_snakeoil_paths() ssl_block = ([ + ipv6_block, ['\n ', 'listen', ' ', '{0} ssl'.format(self.config.tls_sni_01_port)], ['\n ', 'ssl_certificate', ' ', snakeoil_cert], ['\n ', 'ssl_certificate_key', ' ', snakeoil_key], diff --git a/certbot-nginx/certbot_nginx/obj.py b/certbot-nginx/certbot_nginx/obj.py index 849cefe1f..336cba26a 100644 --- a/certbot-nginx/certbot_nginx/obj.py +++ b/certbot-nginx/certbot_nginx/obj.py @@ -34,10 +34,12 @@ class Addr(common.Addr): UNSPECIFIED_IPV4_ADDRESSES = ('', '*', '0.0.0.0') CANONICAL_UNSPECIFIED_ADDRESS = UNSPECIFIED_IPV4_ADDRESSES[0] - def __init__(self, host, port, ssl, default): + def __init__(self, host, port, ssl, default, ipv6, ipv6only): super(Addr, self).__init__((host, port)) self.ssl = ssl self.default = default + self.ipv6 = ipv6 + self.ipv6only = ipv6only self.unspecified_address = host in self.UNSPECIFIED_IPV4_ADDRESSES @classmethod @@ -46,6 +48,8 @@ class Addr(common.Addr): parts = str_addr.split(' ') ssl = False default = False + ipv6 = False + ipv6only = False host = '' port = '' @@ -56,15 +60,25 @@ class Addr(common.Addr): if addr.startswith('unix:'): return None - tup = addr.partition(':') - if re.match(r'^\d+$', tup[0]): - # This is a bare port, not a hostname. E.g. listen 80 - host = '' - port = tup[0] + # IPv6 check + ipv6_match = re.match(r'\[.*\]', addr) + if ipv6_match: + ipv6 = True + # IPv6 handling + host = ipv6_match.group() + # The rest of the addr string will be the port, if any + port = addr[ipv6_match.end()+1:] else: - # This is a host-port tuple. E.g. listen 127.0.0.1:* - host = tup[0] - port = tup[2] + # IPv4 handling + tup = addr.partition(':') + if re.match(r'^\d+$', tup[0]): + # This is a bare port, not a hostname. E.g. listen 80 + host = '' + port = tup[0] + else: + # This is a host-port tuple. E.g. listen 127.0.0.1:* + host = tup[0] + port = tup[2] # The rest of the parts are options; we only care about ssl and default while len(parts) > 0: @@ -73,8 +87,10 @@ class Addr(common.Addr): ssl = True elif nextpart == 'default_server': default = True + elif nextpart == "ipv6only=on": + ipv6only = True - return cls(host, port, ssl, default) + return cls(host, port, ssl, default, ipv6, ipv6only) def to_string(self, include_default=True): """Return string representation of Addr""" @@ -114,8 +130,6 @@ class Addr(common.Addr): self.tup[1]), self.ipv6) == \ common.Addr((other.CANONICAL_UNSPECIFIED_ADDRESS, other.tup[1]), other.ipv6) - # Nginx plugin currently doesn't support IPv6 but this will - # future-proof it return super(Addr, self).__eq__(other) def __eq__(self, other): @@ -195,6 +209,13 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods return True return False + def ipv6_enabled(self): + """Return true if one or more of the listen directives in vhost supports + IPv6""" + for a in self.addrs: + if a.ipv6: + return True + def _find_directive(directives, directive_name): """Find a directive of type directive_name in directives """ diff --git a/certbot-nginx/certbot_nginx/tls_sni_01.py b/certbot-nginx/certbot_nginx/tls_sni_01.py index 48e117bba..261179faf 100644 --- a/certbot-nginx/certbot_nginx/tls_sni_01.py +++ b/certbot-nginx/certbot_nginx/tls_sni_01.py @@ -51,6 +51,8 @@ class NginxTlsSni01(common.TLSSNI01): default_addr = "{0} ssl".format( self.configurator.config.tls_sni_01_port) + ipv6info = self.configurator.ipv6_info() + for achall in self.achalls: vhost = self.configurator.choose_vhost(achall.domain) if vhost is None: @@ -63,7 +65,17 @@ class NginxTlsSni01(common.TLSSNI01): if vhost.addrs: addresses.append(list(vhost.addrs)) else: - addresses.append([obj.Addr.fromstring(default_addr)]) + if ipv6info[0]: + # If IPv6 is active in Nginx configuration + ipv6_addr = "[::]:{} ssl".format( + self.configurator.config.tls_sni_01_port) + if not ipv6info[1]: + # If ipv6only=on is not already present in the config + ipv6_addr = ipv6_addr + " ipv6only=on" + addresses.append([obj.Addr.fromstring(default_addr), + obj.Addr.fromstring(ipv6_addr)]) + else: + addresses.append([obj.Addr.fromstring(default_addr)]) # Create challenge certs responses = [self._setup_challenge_cert(x) for x in self.achalls] @@ -117,7 +129,6 @@ class NginxTlsSni01(common.TLSSNI01): raise errors.MisconfigurationError( 'LetsEncrypt could not find an HTTP block to include ' 'TLS-SNI-01 challenges in %s.' % root) - config = [self._make_server_block(pair[0], pair[1]) for pair in six.moves.zip(self.achalls, ll_addrs)] config = nginxparser.UnspacedList(config) diff --git a/certbot/plugins/common.py b/certbot/plugins/common.py index f605eb751..420d15679 100644 --- a/certbot/plugins/common.py +++ b/certbot/plugins/common.py @@ -251,7 +251,7 @@ class Addr(object): """Normalized representation of addr/port tuple """ if self.ipv6: - return (self._normalize_ipv6(self.tup[0]), self.tup[1]) + return (self.get_ipv6_exploded(), self.tup[1]) return self.tup def __eq__(self, other):