Implement HTTP-01 challenge for Nginx (#5414)

* get http01 challenge working

* support multiple challenge types in configurator.py

* update existing nginx tests

* lint

* refactor NginxHttp01 and NginxTlsSni01 to both now  inherit from NginxChallengePerformer

* remove TODO

* challenges_test tests with both tlssni01 and http01

* Make challenges.py more abstract to make lint happier

* add pylint disables to the tests to make pylint happier about the inheritance and abstraction situation

* no need to cover raise NotImplementedError() lines

* python3 compatibility

* test that http01 perform is called

* only remove ssl from addresses during http01

* Initialize addrs_to_add

* Change Nginx http01 to modify server block so the site doesn't stop serving while getting a cert

* pass existing unit tests

* rename sni --> http01 in unit tests

* lint

* fix configurator test

* select an http block instead of https

* properly test for port number

* use domains that have matching addresses

* remove debugger

* remove access_log and error_log cruft that wasn't being executed

* continue to return None from choose_redirect_vhost when create_if_no_match is False

* add nginx integration test
This commit is contained in:
ohemorange
2018-01-11 17:06:23 -08:00
committed by Brad Warren
parent 5d58a3d847
commit 2cb9d9e2aa
8 changed files with 272 additions and 26 deletions
+25 -10
View File
@@ -26,6 +26,7 @@ from certbot_nginx import constants
from certbot_nginx import nginxparser
from certbot_nginx import parser
from certbot_nginx import tls_sni_01
from certbot_nginx import http_01
logger = logging.getLogger(__name__)
@@ -208,7 +209,8 @@ class NginxConfigurator(common.Installer):
:param str target_name: domain name
:param bool create_if_no_match: If we should create a new vhost from default
when there is no match found
when there is no match found. If we can't choose a default, raise a
MisconfigurationError.
:returns: ssl vhost associated with name
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
@@ -366,7 +368,7 @@ class NginxConfigurator(common.Installer):
return sorted(matches, key=lambda x: x['rank'])
def choose_redirect_vhost(self, target_name, port):
def choose_redirect_vhost(self, target_name, port, create_if_no_match=False):
"""Chooses a single virtual host for redirect enhancement.
Chooses the vhost most closely matching target_name that is
@@ -380,12 +382,19 @@ class NginxConfigurator(common.Installer):
:param str target_name: domain name
:param str port: port number
:param bool create_if_no_match: If we should create a new vhost from default
when there is no match found. If we can't choose a default, raise a
MisconfigurationError.
:returns: vhost associated with name
:rtype: :class:`~certbot_nginx.obj.VirtualHost`
"""
matches = self._get_redirect_ranked_matches(target_name, port)
return self._select_best_name_match(matches)
vhost = self._select_best_name_match(matches)
if not vhost and create_if_no_match:
vhost = self._vhost_from_duplicated_default(target_name)
return vhost
def _get_redirect_ranked_matches(self, target_name, port):
"""Gets a ranked list of plaintextish port-listening vhosts matching target_name
@@ -394,7 +403,7 @@ class NginxConfigurator(common.Installer):
Rank by how well these match target_name.
:param str target_name: The name to match
:param str port: port number
:param str port: port number as a string
:returns: list of dicts containing the vhost, the matching name, and
the numerical rank
:rtype: list
@@ -840,7 +849,7 @@ class NginxConfigurator(common.Installer):
###########################################################################
def get_chall_pref(self, unused_domain): # pylint: disable=no-self-use
"""Return list of challenge preferences."""
return [challenges.TLSSNI01]
return [challenges.TLSSNI01, challenges.HTTP01]
# Entry point in main.py for performing challenges
def perform(self, achalls):
@@ -853,15 +862,20 @@ class NginxConfigurator(common.Installer):
"""
self._chall_out += len(achalls)
responses = [None] * len(achalls)
chall_doer = tls_sni_01.NginxTlsSni01(self)
sni_doer = tls_sni_01.NginxTlsSni01(self)
http_doer = http_01.NginxHttp01(self)
for i, achall in enumerate(achalls):
# Currently also have chall_doer hold associated index of the
# challenge. This helps to put all of the responses back together
# when they are all complete.
chall_doer.add_chall(achall, i)
if isinstance(achall.chall, challenges.HTTP01):
http_doer.add_chall(achall, i)
else: # tls-sni-01
sni_doer.add_chall(achall, i)
sni_response = chall_doer.perform()
sni_response = sni_doer.perform()
http_response = http_doer.perform()
# Must restart in order to activate the challenges.
# Handled here because we may be able to load up other challenge types
self.restart()
@@ -869,8 +883,9 @@ class NginxConfigurator(common.Installer):
# 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[chall_doer.indices[i]] = resp
for chall_response, chall_doer in ((sni_response, sni_doer), (http_response, http_doer)):
for i, resp in enumerate(chall_response):
responses[chall_doer.indices[i]] = resp
return responses
+106
View File
@@ -0,0 +1,106 @@
"""A class that performs HTTP-01 challenges for Nginx"""
import logging
import os
from acme import challenges
from certbot.plugins import common
logger = logging.getLogger(__name__)
class NginxHttp01(common.ChallengePerformer):
"""HTTP-01 authenticator for Nginx
:ivar configurator: NginxConfigurator object
:type configurator: :class:`~nginx.configurator.NginxConfigurator`
:ivar list achalls: Annotated
class:`~certbot.achallenges.KeyAuthorizationAnnotatedChallenge`
challenges
:param list indices: Meant to hold indices of challenges in a
larger array. NginxHttp01 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 http-01 Challenges,
TLS-SNI-01 Challenges belong in the response array. This is an
optional utility.
"""
def perform(self):
"""Perform a challenge on Nginx.
:returns: list of :class:`certbot.acme.challenges.HTTP01Response`
:rtype: list
"""
if not self.achalls:
return []
responses = [x.response(x.account_key) for x in self.achalls]
# Set up the configuration
self._mod_config()
# Save reversible changes
self.configurator.save("HTTP Challenge", True)
return responses
def _add_bucket_directive(self):
"""Modifies Nginx config to include server_names_hash_bucket_size directive."""
root = self.configurator.parser.config_root
bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128']
main = self.configurator.parser.parsed[root]
for line in main:
if line[0] == ['http']:
body = line[1]
found_bucket = False
posn = 0
for inner_line in body:
if inner_line[0] == bucket_directive[1]:
if int(inner_line[1]) < int(bucket_directive[3]):
body[posn] = bucket_directive
found_bucket = True
posn += 1
if not found_bucket:
body.insert(0, bucket_directive)
break
def _mod_config(self):
"""Modifies Nginx config to handle challenges.
"""
self._add_bucket_directive()
for achall in self.achalls:
self._mod_server_block(achall)
def _get_validation_path(self, achall):
return os.sep + os.path.join(challenges.HTTP01.URI_ROOT_PATH, achall.chall.encode("token"))
def _mod_server_block(self, achall):
"""Modifies a server block to respond to a challenge.
:param achall: Annotated HTTP-01 challenge
:type achall:
:class:`certbot.achallenges.KeyAuthorizationAnnotatedChallenge`
"""
vhost = self.configurator.choose_redirect_vhost(achall.domain,
'%i' % self.configurator.config.http01_port, create_if_no_match=True)
validation = achall.validation(achall.account_key)
validation_path = self._get_validation_path(achall)
location_directive = [[['location', ' ', '=', ' ', validation_path],
[['default_type', ' ', 'text/plain'],
['return', ' ', '200', ' ', validation]]]]
self.configurator.parser.add_server_directives(vhost,
location_directive, replace=False)
+2 -2
View File
@@ -524,7 +524,7 @@ def _is_ssl_on_directive(entry):
def _add_directives(directives, replace, block):
"""Adds or replaces directives in a config block.
When replace=False, it's an error to try and add a directive that already
When replace=False, it's an error to try and add a nonrepeatable directive that already
exists in the config block with a conflicting value.
When replace=True and a directive with the same name already exists in the
@@ -545,7 +545,7 @@ def _add_directives(directives, replace, block):
INCLUDE = 'include'
REPEATABLE_DIRECTIVES = set(['server_name', 'listen', INCLUDE])
REPEATABLE_DIRECTIVES = set(['server_name', 'listen', INCLUDE, 'location'])
COMMENT = ' managed by Certbot'
COMMENT_BLOCK = [' ', '#', COMMENT]
@@ -100,7 +100,7 @@ class NginxConfiguratorTest(util.NginxTest):
errors.PluginError, self.config.enhance, 'myhost', 'unknown_enhancement')
def test_get_chall_pref(self):
self.assertEqual([challenges.TLSSNI01],
self.assertEqual([challenges.TLSSNI01, challenges.HTTP01],
self.config.get_chall_pref('myhost'))
def test_save(self):
@@ -291,9 +291,11 @@ class NginxConfiguratorTest(util.NginxTest):
parsed_migration_conf[0])
@mock.patch("certbot_nginx.configurator.tls_sni_01.NginxTlsSni01.perform")
@mock.patch("certbot_nginx.configurator.http_01.NginxHttp01.perform")
@mock.patch("certbot_nginx.configurator.NginxConfigurator.restart")
@mock.patch("certbot_nginx.configurator.NginxConfigurator.revert_challenge_config")
def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_perform):
def test_perform_and_cleanup(self, mock_revert, mock_restart, mock_http_perform,
mock_tls_perform):
# Only tests functionality specific to configurator.perform
# Note: As more challenges are offered this will have to be expanded
achall1 = achallenges.KeyAuthorizationAnnotatedChallenge(
@@ -304,7 +306,7 @@ class NginxConfiguratorTest(util.NginxTest):
), domain="localhost", account_key=self.rsa512jwk)
achall2 = achallenges.KeyAuthorizationAnnotatedChallenge(
challb=messages.ChallengeBody(
chall=challenges.TLSSNI01(token=b"m8TdO1qik4JVFtgPPurJmg"),
chall=challenges.HTTP01(token=b"m8TdO1qik4JVFtgPPurJmg"),
uri="https://ca.org/chall1_uri",
status=messages.Status("pending"),
), domain="example.com", account_key=self.rsa512jwk)
@@ -314,10 +316,12 @@ class NginxConfiguratorTest(util.NginxTest):
achall2.response(self.rsa512jwk),
]
mock_perform.return_value = expected
mock_tls_perform.return_value = expected[:1]
mock_http_perform.return_value = expected[1:]
responses = self.config.perform([achall1, achall2])
self.assertEqual(mock_perform.call_count, 1)
self.assertEqual(mock_tls_perform.call_count, 1)
self.assertEqual(mock_http_perform.call_count, 1)
self.assertEqual(responses, expected)
self.config.cleanup([achall1, achall2])
@@ -0,0 +1,113 @@
"""Tests for certbot_nginx.http_01"""
import unittest
import shutil
import mock
import six
from acme import challenges
from certbot import achallenges
from certbot.plugins import common_test
from certbot.tests import acme_util
from certbot_nginx.tests import util
class HttpPerformTest(util.NginxTest):
"""Test the NginxHttp01 challenge."""
account_key = common_test.AUTH_KEY
achalls = [
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.HTTP01(token=b"kNdwjwOeX0I_A8DXt9Msmg"), "pending"),
domain="www.example.com", account_key=account_key),
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.HTTP01(
token=b"\xba\xa9\xda?<m\xaewmx\xea\xad\xadv\xf4\x02\xc9y"
b"\x80\xe2_X\t\xe7\xc7\xa4\t\xca\xf7&\x945"
), "pending"),
domain="ipv6.com", account_key=account_key),
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.HTTP01(
token=b"\x8c\x8a\xbf_-f\\cw\xee\xd6\xf8/\xa5\xe3\xfd"
b"\xeb9\xf1\xf5\xb9\xefVM\xc9w\xa4u\x9c\xe1\x87\xb4"
), "pending"),
domain="www.example.org", account_key=account_key),
achallenges.KeyAuthorizationAnnotatedChallenge(
challb=acme_util.chall_to_challb(
challenges.HTTP01(token=b"kNdwjxOeX0I_A8DXt9Msmg"), "pending"),
domain="migration.com", account_key=account_key),
]
def setUp(self):
super(HttpPerformTest, self).setUp()
config = util.get_nginx_configurator(
self.config_path, self.config_dir, self.work_dir, self.logs_dir)
from certbot_nginx import http_01
self.http01 = http_01.NginxHttp01(config)
def tearDown(self):
shutil.rmtree(self.temp_dir)
shutil.rmtree(self.config_dir)
shutil.rmtree(self.work_dir)
def test_perform0(self):
responses = self.http01.perform()
self.assertEqual([], responses)
@mock.patch("certbot_nginx.configurator.NginxConfigurator.save")
def test_perform1(self, mock_save):
self.http01.add_chall(self.achalls[0])
response = self.achalls[0].response(self.account_key)
responses = self.http01.perform()
self.assertEqual([response], responses)
self.assertEqual(mock_save.call_count, 1)
def test_perform2(self):
acme_responses = []
for achall in self.achalls:
self.http01.add_chall(achall)
acme_responses.append(achall.response(self.account_key))
sni_responses = self.http01.perform()
self.assertEqual(len(sni_responses), 4)
for i in six.moves.range(4):
self.assertEqual(sni_responses[i], acme_responses[i])
def test_mod_config(self):
self.http01.add_chall(self.achalls[0])
self.http01.add_chall(self.achalls[2])
self.http01._mod_config() # pylint: disable=protected-access
self.http01.configurator.save()
self.http01.configurator.parser.load()
# vhosts = self.http01.configurator.parser.get_vhosts()
# for vhost in vhosts:
# pass
# if the name matches
# check that the location block is in there and is correct
# if vhost.addrs == set(v_addr1):
# response = self.achalls[0].response(self.account_key)
# else:
# response = self.achalls[2].response(self.account_key)
# self.assertEqual(vhost.addrs, set(v_addr2_print))
# self.assertEqual(vhost.names, set([response.z_domain.decode('ascii')]))
if __name__ == "__main__":
unittest.main() # pragma: no cover
@@ -64,6 +64,7 @@ def get_nginx_configurator(
in_progress_dir=os.path.join(backups, "IN_PROGRESS"),
server="https://acme-server.org:443/new",
tls_sni_01_port=5001,
http01_port=80
),
name="nginx",
version=version)
@@ -49,10 +49,10 @@ http {
server {
# IPv4.
listen 8081;
listen 5002;
# IPv6.
listen [::]:8081 default ipv6only=on;
server_name nginx.wtf;
listen [::]:5002 default ipv6only=on;
server_name nginx.wtf nginx2.wtf;
root $root/webroot;
+13 -6
View File
@@ -22,13 +22,20 @@ certbot_test_nginx () {
"$@"
}
certbot_test_nginx --domains nginx.wtf run
echo | openssl s_client -connect localhost:5001 \
| openssl x509 -out $root/nginx.pem
diff -q $root/nginx.pem $root/conf/live/nginx.wtf/cert.pem
test_deployment_and_rollback() {
# Arguments: certname
echo | openssl s_client -connect localhost:5001 \
| openssl x509 -out $root/nginx.pem
diff -q $root/nginx.pem "$root/conf/live/$1/cert.pem"
certbot_test_nginx rollback --checkpoints 9001
diff -q <(echo "$original") $nginx_conf
certbot_test_nginx rollback --checkpoints 9001
diff -q <(echo "$original") $nginx_conf
}
certbot_test_nginx --domains nginx.wtf run
test_deployment_and_rollback nginx.wtf
certbot_test_nginx --domains nginx2.wtf --preferred-challenges http
test_deployment_and_rollback nginx2.wtf
# note: not reached if anything above fails, hence "killall" at the
# top