From c506480b64e755e51ce28b0e43f6f2ec10ada469 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Mon, 12 Jan 2015 19:03:03 +0100 Subject: [PATCH 01/35] Added basic validator --- letsencrypt/client/validator.py | 55 +++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 letsencrypt/client/validator.py diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py new file mode 100644 index 000000000..22ec85b50 --- /dev/null +++ b/letsencrypt/client/validator.py @@ -0,0 +1,55 @@ +"""Validators to determine the current webserver configuration""" +import zope.interface +import requests +from letsencrypt.client import interfaces + + +class ValidationError(Exception): + pass + + +class Validator(object): + zope.interface.implements(interfaces.IValidator) + + def redirect(self, name): + response = requests.get("http://" + name, allow_redirects=False) + + if response.status_code not in (301, 303): + raise ValidationError("Server did not respond with redirect code.") + + if response.status_code != 301: + raise ValidationError("Server did not redirect with permanent code.") + + redirect_location = response.headers.get("location", "") + if redirect_location.startswith("https://"): + raise ValidationError("Server did not redirect to HTTPS connection.") + + return True + + def https(self, names): + for name in names: + request.get("https://" + name, verify=True) + return True + + def hsts(self, name): + headers = requests.get("https://" + name, verify=False).headers + hsts_header = headers.get("strict-transport-security") + + if not hsts_headers: + raise ValidationError("Server responed with either no or an empty HSTS header.") + + # Split directives following RFC6797, section 6.1 + directives = [d.split("=") for d in hsts_headers.split(";")] + max_age = [d for d in directives if d[0] == "max-age"][0] + + try: + max_age_name, max_age_value = max_age + max_age_value = int(max_age_value) + except ValueError: + raise ValidationError("Server responed with invalid HSTS header field.") + + return True + + def ocsp_stapling(self): + raise NotImplementedError("OCSP checking not yet implemented.") + From afc40443f62503abf6e575335362532f01c8769c Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Mon, 12 Jan 2015 21:18:14 +0100 Subject: [PATCH 02/35] Forgot negation in if-statement checking for https --- letsencrypt/client/validator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 22ec85b50..d7634a727 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -21,7 +21,7 @@ class Validator(object): raise ValidationError("Server did not redirect with permanent code.") redirect_location = response.headers.get("location", "") - if redirect_location.startswith("https://"): + if not redirect_location.startswith("https://"): raise ValidationError("Server did not redirect to HTTPS connection.") return True From 29a72cbd72ba95826188bc5ffb169621617ee97c Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 00:39:17 +0100 Subject: [PATCH 03/35] Added zope inheritance to interface --- letsencrypt/client/interfaces.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 910ec29c8..9b00e1d8e 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -144,7 +144,7 @@ class IDisplay(zope.interface.Interface): pass -class IValidator(object): +class IValidator(zope.interface.Interface): """Configuration validator.""" def redirect(name): From ba1ad6036c6cac6735f371900ff028ddb29c2837 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 00:39:56 +0100 Subject: [PATCH 04/35] Cleaned Validator, implemented ocsp checking --- letsencrypt/client/validator.py | 60 ++++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index d7634a727..ea2a73a83 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,7 +1,15 @@ """Validators to determine the current webserver configuration""" +import subprocess import zope.interface import requests + from letsencrypt.client import interfaces +from subprocess import PIPE + + +OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" +OCSP_OPENSSL_DELIMITER = "OCSP response:" +OCSP_OPENSSL_NO_RESPONSE = "no response sent" class ValidationError(Exception): @@ -15,41 +23,67 @@ class Validator(object): response = requests.get("http://" + name, allow_redirects=False) if response.status_code not in (301, 303): - raise ValidationError("Server did not respond with redirect code.") - - if response.status_code != 301: - raise ValidationError("Server did not redirect with permanent code.") + return False redirect_location = response.headers.get("location", "") if not redirect_location.startswith("https://"): - raise ValidationError("Server did not redirect to HTTPS connection.") + return False + + if response.status_code != 301: + raise ValidationError("Server did not redirect with permanent code.") return True def https(self, names): for name in names: - request.get("https://" + name, verify=True) + requests.get("https://" + name, verify=True) return True def hsts(self, name): - headers = requests.get("https://" + name, verify=False).headers + headers = requests.get("https://" + name).headers hsts_header = headers.get("strict-transport-security") - if not hsts_headers: - raise ValidationError("Server responed with either no or an empty HSTS header.") + if not hsts_header: + return False # Split directives following RFC6797, section 6.1 - directives = [d.split("=") for d in hsts_headers.split(";")] + directives = [d.split("=") for d in hsts_header.split(";")] max_age = [d for d in directives if d[0] == "max-age"][0] try: max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise ValidationError("Server responed with invalid HSTS header field.") + raise ValidationError("Server responded with invalid HSTS header field.") return True - def ocsp_stapling(self): - raise NotImplementedError("OCSP checking not yet implemented.") + def ocsp_stapling(self, name): + command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") + openssl = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) + stdout, stderr = openssl.communicate("QUIT\n") + + if openssl.returncode != 0: + raise ValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + + ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) + return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status + + +if __name__ == '__main__': + print("letsencrypt.org:") + print(Validator().ocsp_stapling("letsencrypt.org")) + print(Validator().hsts("letsencrypt.org")) + print(Validator().https(["letsencrypt.org"])) + print(Validator().redirect("letsencrypt.org")) + print(Validator().ocsp_stapling("letsencrypt.org")) + + print("\ntweakers.net:") + print(Validator().hsts("tweakers.net")) + print(Validator().https(["tweakers.net"])) + print(Validator().redirect("tweakers.net")) + print(Validator().ocsp_stapling("tweakers.net")) + + print("\nnon-existing-domain.net:") + print(Validator().ocsp_stapling("non-existing-domain.net")) From b4ef173245da03cf4b9c9c2615e3f68b76e11398 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 14:41:17 +0100 Subject: [PATCH 05/35] Moved ValidationException, changed https() signature in interface, restructured imports to comply with project guidelines --- letsencrypt/client/errors.py | 4 ++++ letsencrypt/client/interfaces.py | 2 +- letsencrypt/client/validator.py | 26 +++++++++++--------------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/letsencrypt/client/errors.py b/letsencrypt/client/errors.py index dddfc5e4e..413fa67f6 100644 --- a/letsencrypt/client/errors.py +++ b/letsencrypt/client/errors.py @@ -11,3 +11,7 @@ class LetsEncryptConfiguratorError(LetsEncryptClientError): class LetsEncryptDvsniError(LetsEncryptConfiguratorError): """Let's Encrypt DVSNI error.""" + + +class LetsEncryptValidationError(LetsEncryptClientError): + pass \ No newline at end of file diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 9b00e1d8e..46e93c0b9 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -153,7 +153,7 @@ class IValidator(zope.interface.Interface): def ocsp_stapling(name): pass - def https(names): + def https(name): pass def hsts(name): diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index ea2a73a83..2a8359aa6 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,10 +1,11 @@ """Validators to determine the current webserver configuration""" -import subprocess +from subprocess import PIPE, Popen import zope.interface + import requests from letsencrypt.client import interfaces -from subprocess import PIPE +from letsencrypt.client.errors import LetsEncryptValidationError OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" @@ -12,10 +13,6 @@ OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" -class ValidationError(Exception): - pass - - class Validator(object): zope.interface.implements(interfaces.IValidator) @@ -30,13 +27,12 @@ class Validator(object): return False if response.status_code != 301: - raise ValidationError("Server did not redirect with permanent code.") + raise LetsEncryptValidationError("Server did not redirect with permanent code.") return True - def https(self, names): - for name in names: - requests.get("https://" + name, verify=True) + def https(self, name): + requests.get("https://" + name, verify=True) return True def hsts(self, name): @@ -54,17 +50,17 @@ class Validator(object): max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise ValidationError("Server responded with invalid HSTS header field.") + raise LetsEncryptValidationError("Server responded with invalid HSTS header field.") return True def ocsp_stapling(self, name): command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") - openssl = subprocess.Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) + openssl = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, stderr = openssl.communicate("QUIT\n") if openssl.returncode != 0: - raise ValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status @@ -74,13 +70,13 @@ if __name__ == '__main__': print("letsencrypt.org:") print(Validator().ocsp_stapling("letsencrypt.org")) print(Validator().hsts("letsencrypt.org")) - print(Validator().https(["letsencrypt.org"])) + print(Validator().https("letsencrypt.org")) print(Validator().redirect("letsencrypt.org")) print(Validator().ocsp_stapling("letsencrypt.org")) print("\ntweakers.net:") print(Validator().hsts("tweakers.net")) - print(Validator().https(["tweakers.net"])) + print(Validator().https("tweakers.net")) print(Validator().redirect("tweakers.net")) print(Validator().ocsp_stapling("tweakers.net")) From 9e123d963918db51f3b1cf2949e272df0c7ac638 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Tue, 13 Jan 2015 16:03:27 +0100 Subject: [PATCH 06/35] Added spdy to Validator --- letsencrypt/client/validator.py | 60 ++++++++++++++++++++++++++++----- 1 file changed, 51 insertions(+), 9 deletions(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 2a8359aa6..52f951687 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,16 +1,44 @@ """Validators to determine the current webserver configuration""" from subprocess import PIPE, Popen -import zope.interface +import logging import requests +import zope.interface from letsencrypt.client import interfaces from letsencrypt.client.errors import LetsEncryptValidationError +log = logging.getLogger(__name__) -OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443 -tls1 -tlsextdebug -status" +OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443" OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" +PROTOCOLS_OPENSSL_DELIMITER = "Protocols advertised by server:" +SPDY_PROTOCOLS = {"spdy/3.1", "spdy/3"} + +def _openssl(hostname, args, input=None): + """ + Call openssl binary in client mode. + + :raises LetsEncryptValidationError if openssl exits with error-code + :param hostname: server to connect to (on port 443) + :param args: arguments (list) to append to default ones + :param input: stdin to binary + :return: (stdout, stderr) + """ + openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname).split(" ") + list(args) + + log.debug("Calling openssl binary with arguments: " + str(openssl_cmd[1:])) + openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) + + stdout, stderr = openssl.communicate(input=input) + log.debug("OpenSSL stdout: " + stdout) + log.debug("OpenSSL stderr: " + stderr) + + if openssl.returncode != 0: + raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + + return stdout, stderr class Validator(object): @@ -55,16 +83,26 @@ class Validator(object): return True def ocsp_stapling(self, name): - command = OCSP_OPENSSL_CMD.format(hostname=name).split(" ") - openssl = Popen(command, stdin=PIPE, stdout=PIPE, stderr=PIPE) - stdout, stderr = openssl.communicate("QUIT\n") - - if openssl.returncode != 0: - raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) - + stdout, stderr = _openssl(name, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status + def _get_nextgen_protocols(self, name): + """Return a set with all 'nextgen' protocols supported by server (reported by openssl).""" + stdout, stderr = _openssl(name, ["-nextprotoneg", "''"], input="QUIT\n") + delimiter_line = list(line for line in stdout.split("\n") if line.startswith(PROTOCOLS_OPENSSL_DELIMITER)) + + if not delimiter_line: + return set() + + protocols = delimiter_line[0].split(PROTOCOLS_OPENSSL_DELIMITER)[1] + return {p.strip() for p in protocols.split(",")} + + def spdy(self, name): + # SPDY is supported if we recognise at least one protocol + return bool(self._get_nextgen_protocols(name) & SPDY_PROTOCOLS) + + if __name__ == '__main__': print("letsencrypt.org:") @@ -73,12 +111,16 @@ if __name__ == '__main__': print(Validator().https("letsencrypt.org")) print(Validator().redirect("letsencrypt.org")) print(Validator().ocsp_stapling("letsencrypt.org")) + print(Validator().spdy("letsencrypt.org")) + print(Validator()._get_nextgen_protocols("letsencrypt.org")) print("\ntweakers.net:") print(Validator().hsts("tweakers.net")) print(Validator().https("tweakers.net")) print(Validator().redirect("tweakers.net")) print(Validator().ocsp_stapling("tweakers.net")) + print(Validator().spdy("tweakers.net")) + print(Validator()._get_nextgen_protocols("tweakers.net")) print("\nnon-existing-domain.net:") print(Validator().ocsp_stapling("non-existing-domain.net")) From cdf59a8b7f5342836ca2b39602f50bd9f8602f4f Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 16:50:59 +0100 Subject: [PATCH 07/35] Fixed pylint warnings, added test skeleton, implemented regular expression for SPDY test --- letsencrypt/client/tests/validator_test.py | 5 ++ letsencrypt/client/validator.py | 94 +++++++++++----------- 2 files changed, 50 insertions(+), 49 deletions(-) create mode 100644 letsencrypt/client/tests/validator_test.py diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py new file mode 100644 index 000000000..c6561bdf3 --- /dev/null +++ b/letsencrypt/client/tests/validator_test.py @@ -0,0 +1,5 @@ +import unittest + + +class ValidatorTest(unittest.TestCase): + pass diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 52f951687..6a0cd1e81 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -1,6 +1,7 @@ """Validators to determine the current webserver configuration""" from subprocess import PIPE, Popen import logging +import re import requests import zope.interface @@ -14,7 +15,8 @@ OCSP_OPENSSL_CMD = "openssl s_client -connect {hostname}:443" OCSP_OPENSSL_DELIMITER = "OCSP response:" OCSP_OPENSSL_NO_RESPONSE = "no response sent" PROTOCOLS_OPENSSL_DELIMITER = "Protocols advertised by server:" -SPDY_PROTOCOLS = {"spdy/3.1", "spdy/3"} +SPDY_PROTOCOL_RE = re.compile(r"^spdy/\d(\.\d)?$") + def _openssl(hostname, args, input=None): """ @@ -26,26 +28,36 @@ def _openssl(hostname, args, input=None): :param input: stdin to binary :return: (stdout, stderr) """ - openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname).split(" ") + list(args) + openssl_cmd = OCSP_OPENSSL_CMD.format(**locals()).split(" ") + list(args) - log.debug("Calling openssl binary with arguments: " + str(openssl_cmd[1:])) + log.debug("Calling openssl binary with arguments: %s", openssl_cmd[1:]) openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) stdout, stderr = openssl.communicate(input=input) - log.debug("OpenSSL stdout: " + stdout) - log.debug("OpenSSL stderr: " + stderr) + log.debug("OpenSSL stdout: %s", stdout) + log.debug("OpenSSL stderr: %s", stderr) if openssl.returncode != 0: - raise LetsEncryptValidationError("OpenSSL quit with error-code: {openssl.returncode}.".format(openssl=openssl)) + error_msg = "OpenSSL quit with error-code: {openssl.returncode}" + raise LetsEncryptValidationError(error_msg.format(openssl=openssl)) return stdout, stderr +def _filter_startswith(strings, start): + """Yields all strings which start with given string.""" + for string in strings: + if string.startswith(start): + yield string + + class Validator(object): + """Collection of functions to test a live webserver's configuration""" zope.interface.implements(interfaces.IValidator) - def redirect(self, name): - response = requests.get("http://" + name, allow_redirects=False) + def redirect(self, hostname): + """Test whether webserver redirects to secure connection.""" + response = requests.get("http://" + hostname, allow_redirects=False) if response.status_code not in (301, 303): return False @@ -55,73 +67,57 @@ class Validator(object): return False if response.status_code != 301: - raise LetsEncryptValidationError("Server did not redirect with permanent code.") + error_msg = "Server did not redirect with permanent code." + raise LetsEncryptValidationError(error_msg) return True - def https(self, name): - requests.get("https://" + name, verify=True) + def https(self, hostname): + """Test whether webserver supports HTTPS""" + requests.get("https://" + hostname, verify=True) return True - def hsts(self, name): - headers = requests.get("https://" + name).headers + def hsts(self, hostname): + """Test for HTTP Strict Transport Security header""" + headers = requests.get("https://" + hostname).headers hsts_header = headers.get("strict-transport-security") - + if not hsts_header: return False # Split directives following RFC6797, section 6.1 directives = [d.split("=") for d in hsts_header.split(";")] max_age = [d for d in directives if d[0] == "max-age"][0] - + try: max_age_name, max_age_value = max_age max_age_value = int(max_age_value) except ValueError: - raise LetsEncryptValidationError("Server responded with invalid HSTS header field.") + error_msg = "Server responded with invalid HSTS header field." + raise LetsEncryptValidationError(error_msg) return True - def ocsp_stapling(self, name): - stdout, stderr = _openssl(name, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") - ocsp_status = next(line for line in stdout.split("\n") if line.startswith(OCSP_OPENSSL_DELIMITER)) + def ocsp_stapling(self, hostname): + """Test for OCSP stapling. See RFC 6066, section 8.""" + stdout, stderr = _openssl(hostname, ["-tls1", "-tlsextdebug", "-status"], input="QUIT\n") + ocsp_status = next(_filter_startswith(stdout.split("\n"), OCSP_OPENSSL_DELIMITER)) return OCSP_OPENSSL_NO_RESPONSE not in ocsp_status - def _get_nextgen_protocols(self, name): + def _get_nextgen_protocols(self, hostname): """Return a set with all 'nextgen' protocols supported by server (reported by openssl).""" - stdout, stderr = _openssl(name, ["-nextprotoneg", "''"], input="QUIT\n") - delimiter_line = list(line for line in stdout.split("\n") if line.startswith(PROTOCOLS_OPENSSL_DELIMITER)) + stdout, stderr = _openssl(hostname, ["-nextprotoneg", "''"], input="QUIT\n") + delimiter_line = list(_filter_startswith(stdout.split("\n"), PROTOCOLS_OPENSSL_DELIMITER)) if not delimiter_line: return set() protocols = delimiter_line[0].split(PROTOCOLS_OPENSSL_DELIMITER)[1] - return {p.strip() for p in protocols.split(",")} + return set(p.strip() for p in protocols.split(",")) - def spdy(self, name): + def spdy(self, hostname): + """Test for SPDY support""" # SPDY is supported if we recognise at least one protocol - return bool(self._get_nextgen_protocols(name) & SPDY_PROTOCOLS) - - - -if __name__ == '__main__': - print("letsencrypt.org:") - print(Validator().ocsp_stapling("letsencrypt.org")) - print(Validator().hsts("letsencrypt.org")) - print(Validator().https("letsencrypt.org")) - print(Validator().redirect("letsencrypt.org")) - print(Validator().ocsp_stapling("letsencrypt.org")) - print(Validator().spdy("letsencrypt.org")) - print(Validator()._get_nextgen_protocols("letsencrypt.org")) - - print("\ntweakers.net:") - print(Validator().hsts("tweakers.net")) - print(Validator().https("tweakers.net")) - print(Validator().redirect("tweakers.net")) - print(Validator().ocsp_stapling("tweakers.net")) - print(Validator().spdy("tweakers.net")) - print(Validator()._get_nextgen_protocols("tweakers.net")) - - print("\nnon-existing-domain.net:") - print(Validator().ocsp_stapling("non-existing-domain.net")) - + next_gen_protocols = self._get_nextgen_protocols(hostname) + spdy_protocols = filter(SPDY_PROTOCOL_RE.match, next_gen_protocols) + return bool(list(spdy_protocols)) From 93be5486a8051f9ab8bf5299147ed06046de41fc Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 16:51:55 +0100 Subject: [PATCH 08/35] Extended IValidator, renamed arguments --- letsencrypt/client/interfaces.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/interfaces.py b/letsencrypt/client/interfaces.py index 46e93c0b9..b0658b8ac 100644 --- a/letsencrypt/client/interfaces.py +++ b/letsencrypt/client/interfaces.py @@ -147,14 +147,17 @@ class IDisplay(zope.interface.Interface): class IValidator(zope.interface.Interface): """Configuration validator.""" - def redirect(name): + def redirect(hostname): pass - def ocsp_stapling(name): + def ocsp_stapling(hostname): pass - def https(name): + def https(hostname): pass - def hsts(name): + def hsts(hostname): + pass + + def spdy(hostname): pass From d0125f05f36f5ed3f3908801a9568b56f6ca01ba Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Sun, 18 Jan 2015 17:45:42 +0100 Subject: [PATCH 09/35] Added openssl and responses as dependencies, added tests for Validator.{https,redirect} --- README.md | 2 +- letsencrypt/client/tests/validator_test.py | 45 +++++++++++++++++++++- requirements.txt | 1 + 3 files changed, 46 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a59aa7c8d..732c5169b 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ In general: ``` sudo apt-get install python python-setuptools python-virtualenv \ - python-dev gcc swig dialog libaugeas0 libssl-dev + python-dev gcc swig dialog libaugeas0 libssl-dev openssl ``` #### Mac OSX diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py index c6561bdf3..1d3acfc8d 100644 --- a/letsencrypt/client/tests/validator_test.py +++ b/letsencrypt/client/tests/validator_test.py @@ -1,5 +1,48 @@ import unittest +import responses +from requests.exceptions import ConnectionError +from letsencrypt.client.errors import LetsEncryptValidationError + +from letsencrypt.client.validator import Validator + + +def _add(secure=False, **kwargs): + url = "{}://test.com".format("https" if secure else "http") + print(url) + return responses.add(responses.GET, url, **kwargs) + class ValidatorTest(unittest.TestCase): - pass + @responses.activate + def test_succesful_redirect(self): + _add(status=301, adding_headers={"location": "https://test.com"}) + self.assertTrue(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_missing_location(self): + _add(status=301) + self.assertFalse(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_wrong_status_code(self): + _add(status=201, adding_headers={"location": "https://test.com"}) + self.assertFalse(Validator().redirect("test.com")) + + @responses.activate + def test_redirect_wrong_redirect_code(self): + _add(status=303, adding_headers={"location": "https://test.com"}) + self.assertRaises(LetsEncryptValidationError, Validator().redirect, "test.com") + + @responses.activate + def test_https_fail(self): + self.assertRaises(ConnectionError, Validator().https, "test.com") + + @responses.activate + def test_https_success(self): + _add(secure=True, body="blaa") + self.assertTrue(Validator().https("test.com")) + + +if __name__ == '__main__': + unittest.main() diff --git a/requirements.txt b/requirements.txt index a95a0807f..37993ae3c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +responses==0.3.0 M2Crypto==0.22.3 python2-pythondialog jsonschema==2.4.0 From 69e0f27a7733cc97377c93a1a7468b7ac8758c42 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Thu, 22 Jan 2015 12:32:37 +0100 Subject: [PATCH 10/35] Added HSTS age check; included tests --- letsencrypt/client/tests/validator_test.py | 28 ++++++++++++++++++++-- letsencrypt/client/validator.py | 13 ++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/letsencrypt/client/tests/validator_test.py b/letsencrypt/client/tests/validator_test.py index 1d3acfc8d..08074cd54 100644 --- a/letsencrypt/client/tests/validator_test.py +++ b/letsencrypt/client/tests/validator_test.py @@ -3,13 +3,11 @@ import unittest import responses from requests.exceptions import ConnectionError from letsencrypt.client.errors import LetsEncryptValidationError - from letsencrypt.client.validator import Validator def _add(secure=False, **kwargs): url = "{}://test.com".format("https" if secure else "http") - print(url) return responses.add(responses.GET, url, **kwargs) @@ -43,6 +41,32 @@ class ValidatorTest(unittest.TestCase): _add(secure=True, body="blaa") self.assertTrue(Validator().https("test.com")) + @responses.activate + def test_hsts_empty(self): + _add(secure=True, adding_headers={"strict-transport-security": ""}) + self.assertFalse(Validator().hsts("test.com")) + + @responses.activate + def test_hsts_malformed(self): + _add(secure=True, adding_headers={"strict-transport-security": "sdfal"}) + self.assertRaises(LetsEncryptValidationError, Validator().hsts, "test.com") + + @responses.activate + def test_hsts_expire(self): + _add(secure=True, adding_headers={"strict-transport-security": "max-age=3600"}) + self.assertRaises(LetsEncryptValidationError, Validator().hsts, "test.com") + + @responses.activate + def test_hsts(self): + _add(secure=True, adding_headers={"strict-transport-security": "max-age=31536000"}) + self.assertTrue(Validator().hsts("test.com")) + + @responses.activate + def test_hsts(self): + headers = {"strict-transport-security": "max-age=31536000;includeSubDomains"} + _add(secure=True, adding_headers=headers) + self.assertTrue(Validator().hsts("test.com")) + if __name__ == '__main__': unittest.main() diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 6a0cd1e81..4e8a14c6f 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -87,15 +87,24 @@ class Validator(object): # Split directives following RFC6797, section 6.1 directives = [d.split("=") for d in hsts_header.split(";")] - max_age = [d for d in directives if d[0] == "max-age"][0] + max_age = [d for d in directives if d[0] == "max-age"] + + if not max_age: + error_msg = "Server responded with invalid HSTS header field." + raise LetsEncryptValidationError(error_msg) try: - max_age_name, max_age_value = max_age + max_age_name, max_age_value = max_age[0] max_age_value = int(max_age_value) except ValueError: error_msg = "Server responded with invalid HSTS header field." raise LetsEncryptValidationError(error_msg) + # Test whether HSTS does not expire for at least two weeks. + if max_age_value <= (2 * 7 * 24 * 3600): + error_msg = "HSTS should not expire in less than two weeks." + raise LetsEncryptValidationError(error_msg) + return True def ocsp_stapling(self, hostname): From 9cfa5f27b0bc289b149680e4285adff1004d8215 Mon Sep 17 00:00:00 2001 From: Martijn Bastiaan Date: Thu, 22 Jan 2015 12:42:07 +0100 Subject: [PATCH 11/35] Removed **locals() construction --- letsencrypt/client/validator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/letsencrypt/client/validator.py b/letsencrypt/client/validator.py index 4e8a14c6f..7e46f415b 100644 --- a/letsencrypt/client/validator.py +++ b/letsencrypt/client/validator.py @@ -28,7 +28,8 @@ def _openssl(hostname, args, input=None): :param input: stdin to binary :return: (stdout, stderr) """ - openssl_cmd = OCSP_OPENSSL_CMD.format(**locals()).split(" ") + list(args) + openssl_cmd = OCSP_OPENSSL_CMD.format(hostname=hostname) + openssl_cmd = openssl_cmd.split(" ") + list(args) log.debug("Calling openssl binary with arguments: %s", openssl_cmd[1:]) openssl = Popen(openssl_cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE) From 7081c440e7e6daf88835d2bb61f9f4ecd1b9aa5a Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 10 Jul 2015 11:42:10 -0700 Subject: [PATCH 12/35] Started building test suite --- tests/plugins/Dockerfile | 20 ++++++++++++++++ tests/plugins/a2enmod.sh | 29 ++++++++++++++++++++++++ tests/plugins/parser.py | 49 ++++++++++++++++++++++++++++++++++++++++ tests/plugins/test.py | 13 +++++++++++ tests/plugins/util.py | 21 +++++++++++++++++ 5 files changed, 132 insertions(+) create mode 100644 tests/plugins/Dockerfile create mode 100755 tests/plugins/a2enmod.sh create mode 100644 tests/plugins/parser.py create mode 100644 tests/plugins/test.py create mode 100644 tests/plugins/util.py diff --git a/tests/plugins/Dockerfile b/tests/plugins/Dockerfile new file mode 100644 index 000000000..93541408f --- /dev/null +++ b/tests/plugins/Dockerfile @@ -0,0 +1,20 @@ +# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297 +# it is more likely developers will already have ubuntu:trusty rather +# than e.g. debian:jessie and image size differences are negligible +FROM httpd +MAINTAINER Brad Warren + +RUN mkdir /var/run/apache2 && \ + ln -s /usr/local/apache2/conf/mime.types /etc/mime.types + +ENV APACHE_CONFDIR=/tmp/apache2 \ + APACHE_RUN_USER=daemon \ + APACHE_RUN_GROUP=daemon \ + APACHE_PID_FILE=/var/run/apache2/apache2.pid \ + APACHE_RUN_DIR=/var/run/apache2 \ + APACHE_LOCK_DIR=/var/lock \ + APACHE_LOG_DIR=/usr/local/apache2/logs + +COPY a2enmod.sh /usr/local/bin/ + +CMD [ "httpd-foreground" ] diff --git a/tests/plugins/a2enmod.sh b/tests/plugins/a2enmod.sh new file mode 100755 index 000000000..9c36c44cc --- /dev/null +++ b/tests/plugins/a2enmod.sh @@ -0,0 +1,29 @@ +#!/bin/bash +# An extremely simplified version of 'a2enmod' for the httpd docker image + +enable () { + echo "LoadModule "$1"_module /usr/local/apache2/modules/mod_"$1".so" >> \ + $APACHE_CONFDIR"/modules.load" + available_base="/mods-available/"$1".conf" + available_conf=$APACHE_CONFDIR$available_base + enabled_dir=$APACHE_CONFDIR"/mods-enabled" + enabled_conf=$enabled_dir"/"$1".conf" + if [ -e "$available_conf" -a -e "$enabled_dir" -a ! -e "$enabled_conf" ] + then + ln -s "..$available_base" $enabled_conf + fi +} + +if [ $1 == "ssl" ] +then + # Enables ssl and all its dependencies + enable "setenvif" + enable "mime" + enable "socache_shmcb" + enable "ssl" +elif [ $1 == "rewrite" ] +then + enable "rewrite"; +else + exit 1; +fi diff --git a/tests/plugins/parser.py b/tests/plugins/parser.py new file mode 100644 index 000000000..5946f15be --- /dev/null +++ b/tests/plugins/parser.py @@ -0,0 +1,49 @@ +"""Module for parsing command line arguments and config files.""" +import argparse + + +DESCRIPTION = """ +Tests Let's Encrypt plugins against different web servers and configurations +using Docker images. It is assumed that Docker is already installed. + +""" + +def parse_args(): + """Returns parsed command line arguments.""" + parser = argparse.ArgumentParser(description=DESCRIPTION) + + add_args(parser) + args = parser.parse_args() + + if args.redirect: + args.names = True + args.install = True + elif args.install: + args.names = True + + return args + + +def add_args(parser): + """Adds general/program wide arguments to the group.""" + group = parser.add_argument_group("general") + group.add_argument( + "-t", "--tar", default="configs.tar.gz", + help="a gzipped tarball containing server configurations") + group.add_argument( + "-p", "--plugin", default="apache", + help="the plugin to be tested") + group.add_argument( + "-n", "--names", action="store_true", help="tests installer's domain " + "name identification") + group.add_argument( + "-a", "--auth", action="store_true", help="tests authenticators") + group.add_argument( + "-i", "--install", action="store_true", help="tests installer's " + "certificate installation (implicitly includes -d)") + group.add_argument( + "-r", "--redirect", action="store_true", help="tests installer's " + "redirecting HTTP to HTTPS (implicitly includes -di)") + group.add_argument( + "--no-simple-http-tls", action="store_true", help="do not use TLS " + "when solving SimpleHTTP challenges") diff --git a/tests/plugins/test.py b/tests/plugins/test.py new file mode 100644 index 000000000..3fe5e33a3 --- /dev/null +++ b/tests/plugins/test.py @@ -0,0 +1,13 @@ +"""Tests Let's Encrypt plugins against different server configurations.""" +import parser +import util + + +def main(): + """Main test script execution.""" + args = parser.parse_args() + + print util.setup_tmp_dir(args.tar) + +if __name__ == "__main__": + main() diff --git a/tests/plugins/util.py b/tests/plugins/util.py new file mode 100644 index 000000000..07b47cb25 --- /dev/null +++ b/tests/plugins/util.py @@ -0,0 +1,21 @@ +"""Utility functions for Let's Encrypt plugin tests.""" +import os +import tarfile +import tempfile + + +TEMP_DIRECTORY = tempfile.mkdtemp() +# Location of decompressed server root configurations +CONFIGS = os.path.join(TEMP_DIRECTORY, "configs") +SCRIPTS = os.path.join(TEMP_DIRECTORY, "scripts") + + +def setup_tmp_dir(tar_path): + """Sets up a temporary directory for this run and returns its path.""" + tar = tarfile.open(tar_path, "r:gz") + tar.extractall(os.path.join(tmp_dir, SERVER_ROOTS)) + + os.makedirs(os.path.join(tmp_dir, "mnt")) + os.makedirs(os.path.join(tmp_dir, "scripts")) + + return tmp_dir From 22fb1b18c743bf11245af916a53194ffb5355bfb Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 13 Jul 2015 11:56:38 -0700 Subject: [PATCH 13/35] Setup directory structure --- setup.py | 2 ++ tests/__init__.py | 1 + tests/{plugins => compatibility}/Dockerfile | 0 tests/compatibility/__init__.py | 1 + tests/{plugins => compatibility}/a2enmod.sh | 0 tests/{plugins => compatibility}/parser.py | 0 tests/{plugins/test.py => compatibility/plugin_test.py} | 0 tests/{plugins => compatibility}/util.py | 0 8 files changed, 4 insertions(+) create mode 100644 tests/__init__.py rename tests/{plugins => compatibility}/Dockerfile (100%) create mode 100644 tests/compatibility/__init__.py rename tests/{plugins => compatibility}/a2enmod.sh (100%) rename tests/{plugins => compatibility}/parser.py (100%) rename tests/{plugins/test.py => compatibility/plugin_test.py} (100%) rename tests/{plugins => compatibility}/util.py (100%) diff --git a/setup.py b/setup.py index 49ac5a6c0..00d290c05 100644 --- a/setup.py +++ b/setup.py @@ -125,6 +125,7 @@ docs_extras = [ testing_extras = [ 'coverage', + 'docker-py', 'nose', 'nosexcover', 'tox', @@ -171,6 +172,7 @@ setup( entry_points={ 'console_scripts': [ + 'compatibility = tests.compatibility.plugin_test:main [testing]', 'letsencrypt = letsencrypt.cli:main', 'letsencrypt-renewer = letsencrypt.renewer:main', 'jws = letsencrypt.acme.jose.jws:CLI.run', diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..d9db68022 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Tests""" diff --git a/tests/plugins/Dockerfile b/tests/compatibility/Dockerfile similarity index 100% rename from tests/plugins/Dockerfile rename to tests/compatibility/Dockerfile diff --git a/tests/compatibility/__init__.py b/tests/compatibility/__init__.py new file mode 100644 index 000000000..ebf30d2b8 --- /dev/null +++ b/tests/compatibility/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt Compatibility Test""" diff --git a/tests/plugins/a2enmod.sh b/tests/compatibility/a2enmod.sh similarity index 100% rename from tests/plugins/a2enmod.sh rename to tests/compatibility/a2enmod.sh diff --git a/tests/plugins/parser.py b/tests/compatibility/parser.py similarity index 100% rename from tests/plugins/parser.py rename to tests/compatibility/parser.py diff --git a/tests/plugins/test.py b/tests/compatibility/plugin_test.py similarity index 100% rename from tests/plugins/test.py rename to tests/compatibility/plugin_test.py diff --git a/tests/plugins/util.py b/tests/compatibility/util.py similarity index 100% rename from tests/plugins/util.py rename to tests/compatibility/util.py From 6c6ef2bb408eb4a392e95fb810933453bf88c980 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 14 Jul 2015 18:04:43 -0700 Subject: [PATCH 14/35] Started implementation of Apache base --- setup.py | 2 - tests/__init__.py | 2 +- tests/compatibility/Dockerfile | 2 +- tests/compatibility/__init__.py | 2 +- tests/compatibility/configurators/__init__.py | 1 + .../configurators/apache/__init__.py | 1 + .../configurators/apache/common.py | 34 +++++++ tests/compatibility/configurators/common.py | 95 +++++++++++++++++++ tests/compatibility/errors.py | 5 + tests/compatibility/interfaces.py | 53 +++++++++++ tests/compatibility/parser.py | 49 ---------- tests/compatibility/plugin_test.py | 77 +++++++++++++-- tests/compatibility/util.py | 44 ++++++--- tests/setup.py | 23 +++++ 14 files changed, 318 insertions(+), 72 deletions(-) create mode 100644 tests/compatibility/configurators/__init__.py create mode 100644 tests/compatibility/configurators/apache/__init__.py create mode 100644 tests/compatibility/configurators/apache/common.py create mode 100644 tests/compatibility/configurators/common.py create mode 100644 tests/compatibility/errors.py create mode 100644 tests/compatibility/interfaces.py delete mode 100644 tests/compatibility/parser.py create mode 100644 tests/setup.py diff --git a/setup.py b/setup.py index d054303dc..1e0d58a70 100644 --- a/setup.py +++ b/setup.py @@ -60,7 +60,6 @@ docs_extras = [ testing_extras = [ 'coverage', - 'docker-py', 'nose', 'nosexcover', 'tox', @@ -107,7 +106,6 @@ setup( entry_points={ 'console_scripts': [ - 'compatibility = tests.compatibility.plugin_test:main [testing]', 'letsencrypt = letsencrypt.cli:main', 'letsencrypt-renewer = letsencrypt.renewer:main', ], diff --git a/tests/__init__.py b/tests/__init__.py index d9db68022..ea250d700 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Tests""" +"""Let's Encrypt tests""" diff --git a/tests/compatibility/Dockerfile b/tests/compatibility/Dockerfile index 93541408f..6637b9e14 100644 --- a/tests/compatibility/Dockerfile +++ b/tests/compatibility/Dockerfile @@ -15,6 +15,6 @@ ENV APACHE_CONFDIR=/tmp/apache2 \ APACHE_LOCK_DIR=/var/lock \ APACHE_LOG_DIR=/usr/local/apache2/logs -COPY a2enmod.sh /usr/local/bin/ +COPY tests/compatibility/a2enmod.sh /usr/local/bin/ CMD [ "httpd-foreground" ] diff --git a/tests/compatibility/__init__.py b/tests/compatibility/__init__.py index ebf30d2b8..90807863a 100644 --- a/tests/compatibility/__init__.py +++ b/tests/compatibility/__init__.py @@ -1 +1 @@ -"""Let's Encrypt Compatibility Test""" +"""Let's Encrypt compatibility test""" diff --git a/tests/compatibility/configurators/__init__.py b/tests/compatibility/configurators/__init__.py new file mode 100644 index 000000000..bf7b3471f --- /dev/null +++ b/tests/compatibility/configurators/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt compatibility test configurators""" diff --git a/tests/compatibility/configurators/apache/__init__.py b/tests/compatibility/configurators/apache/__init__.py new file mode 100644 index 000000000..9feca23d4 --- /dev/null +++ b/tests/compatibility/configurators/apache/__init__.py @@ -0,0 +1 @@ +"""Let's Encrypt compatibility test Apache configurators""" diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py new file mode 100644 index 000000000..9321981d0 --- /dev/null +++ b/tests/compatibility/configurators/apache/common.py @@ -0,0 +1,34 @@ +"""Provides a common base for Apache tests""" +import mock + +from tests.compatibilty import configurators + +class ApacheConfiguratorCommonTester(configurators.common.ConfiguratorTester): + """A common base for Apache test configurators""" + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + super(ApacheConfiguratorCommonTester, self).__init__(args) + self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') + self._mock = self._patch.start() + self._mock.check_call = self._check_call + self._apache_configurator = None + + def __getattr__(self, name): + """Wraps the Apache Configurator methods""" + method = getattr(self._apache_configurator, name, None) + if callable(method): + return method + else: + raise AttributeError() + + def _check_call(self, command, *args, **kwargs): + """A function to mock the call to subprocess.check_call""" + + def load_config(self): + """Loads the next configuration for the plugin to test""" + raise NotImplementedError() + + def get_test_domain_names(self): + """Returns a list of domain names to test against the plugin""" + raise NotImplementedError() diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py new file mode 100644 index 000000000..935190dd9 --- /dev/null +++ b/tests/compatibility/configurators/common.py @@ -0,0 +1,95 @@ +"""Provides a common base for compatibility test configurators""" +import logging +import multiprocessing +import os + +import docker + +from tests.compatibility import errors +from tests.compatibility import util + + +logger = logging.getLogger(__name__) + + +class ConfiguratorTester(object): + # pylint: disable=too-many-instance-attributes + """A common base for compatibility test configurators""" + + _NOT_ADDED_ARGS = True + + @classmethod + def add_parser_arguments(cls, parser): + """Adds command line arguments needed by the plugin""" + if ConfiguratorTester._NOT_ADDED_ARGS: + group = parser.add_argument_group('docker') + group.add_argument( + '--docker-url', default='unix://var/run/docker.sock', + help='URL of the docker server') + group.add_argument( + '--no-remove', action='store_true', + help='do not delete container on program exit') + ConfiguratorTester._NOT_ADDED_ARGS = False + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + self.temp_dir = util.setup_temp_dir(args.configs) + self.config_dir = os.path.join(self.temp_dir, util.CONFIG_DIR) + self._configs = os.listdir(self.config_dir) + + self.args = args + self._docker_client = docker.Client( + base_url=self.args.docker_url, version='auto') + self.http_port, self.https_port = util.get_two_free_ports() + self._container_id = self._log_process = None + + def has_more_configs(self): + """Returns true if there are more configs to test""" + return bool(self._configs) + + def cleanup_from_tests(self): + """Performs any necessary cleanup from running plugin tests""" + self._docker_client.stop(self._container_id) + self._log_process.join() + if not self.args.no_remove: + self._docker_client.remove_container(self._container_id) + + def get_next_config(self): + """Returns the next config directory to be tested""" + return self._configs.pop() + + def start_docker(self, image_name): + """Creates and runs a Docker container with the specified image""" + for line in self._docker_client.pull(image_name, stream=True): + logger.debug(line) + + host_config = docker.utils.create_host_config( + binds={ + self.config_dir : {'bind' : self.config_dir, 'mode' : 'rw'}}, + port_bindings={ + 80 : ('127.0.0.1', self.http_port), + 443 : ('127.0.0.1', self.https_port)},) + container = self._docker_client.create_container( + image_name, ports=[80, 443], volumes=self.config_dir, + host_config=host_config) + if container['Warnings']: + logger.warning(container['Warnings']) + self._container_id = container['Id'] + self._docker_client.start(self._container_id) + + self._log_process = multiprocessing.Process( + target=self._start_log_thread) + self._log_process.start() + + def execute_in_docker(self, command): + """Executes command inside the running docker image""" + exec_id = self._docker_client.exec_create(self._container_id, command) + output = self._docker_client.exec_start(exec_id) + if self._docker_client.exec_inspect(exec_id)['ExitCode']: + raise errors.Error('Docker command \'{0}\' failed'.format(command)) + return output + + def _start_log_thread(self): + client = docker.Client(base_url=self.args.docker_url, version='auto') + for line in client.logs(self._container_id, stream=True): + logger.debug(line) diff --git a/tests/compatibility/errors.py b/tests/compatibility/errors.py new file mode 100644 index 000000000..3b7eb6911 --- /dev/null +++ b/tests/compatibility/errors.py @@ -0,0 +1,5 @@ +"""Let's Encrypt compatibility test errors""" + + +class Error(Exception): + """Generic Let's Encrypt compatibility test error""" diff --git a/tests/compatibility/interfaces.py b/tests/compatibility/interfaces.py new file mode 100644 index 000000000..035a9f541 --- /dev/null +++ b/tests/compatibility/interfaces.py @@ -0,0 +1,53 @@ +"""Let's Encrypt compatibility test interfaces""" +import zope.interface + +import letsencrypt.interfaces + + +class IPluginTester(zope.interface.Interface): + """Wraps a Let's Encrypt plugin""" + @classmethod + def add_parser_arguments(cls, parser): + """Adds command line arguments needed by the parser""" + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + + def cleanup_from_tests(self): + """Performs any necessary cleanup from running plugin tests. + + This is guarenteed to be called before the program exits. + + """ + + def has_more_configs(self): + """Returns True if there are more configs to test""" + + def load_config(self): + """Loads the next configuration for the plugin to test""" + + +class IConfiguratorBaseTester(IPluginTester): + """Common functionality for authenticator/installer tests""" + http_port = zope.interface.Attribute( + 'The port to connect to on localhost for HTTP traffic') + + https_port = zope.interface.Attribute( + 'The port to connect to on localhost for HTTPS traffic') + + def get_test_domain_names(self): + """Returns a list of domain names to test against the plugin""" + + +class IAuthenticatorTester( + IConfiguratorBaseTester, letsencrypt.interfaces.IAuthenticator): + """Wraps a Let's Encrypt authenticator""" + + +class IInstallerTester( + IConfiguratorBaseTester, letsencrypt.interfaces.IInstaller): + """Wraps a Let's Encrypt installer""" + + +class IConfiguratorTester(IAuthenticatorTester, IInstallerTester): + """Wraps a Let's Encrypt configurator""" diff --git a/tests/compatibility/parser.py b/tests/compatibility/parser.py deleted file mode 100644 index 5946f15be..000000000 --- a/tests/compatibility/parser.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Module for parsing command line arguments and config files.""" -import argparse - - -DESCRIPTION = """ -Tests Let's Encrypt plugins against different web servers and configurations -using Docker images. It is assumed that Docker is already installed. - -""" - -def parse_args(): - """Returns parsed command line arguments.""" - parser = argparse.ArgumentParser(description=DESCRIPTION) - - add_args(parser) - args = parser.parse_args() - - if args.redirect: - args.names = True - args.install = True - elif args.install: - args.names = True - - return args - - -def add_args(parser): - """Adds general/program wide arguments to the group.""" - group = parser.add_argument_group("general") - group.add_argument( - "-t", "--tar", default="configs.tar.gz", - help="a gzipped tarball containing server configurations") - group.add_argument( - "-p", "--plugin", default="apache", - help="the plugin to be tested") - group.add_argument( - "-n", "--names", action="store_true", help="tests installer's domain " - "name identification") - group.add_argument( - "-a", "--auth", action="store_true", help="tests authenticators") - group.add_argument( - "-i", "--install", action="store_true", help="tests installer's " - "certificate installation (implicitly includes -d)") - group.add_argument( - "-r", "--redirect", action="store_true", help="tests installer's " - "redirecting HTTP to HTTPS (implicitly includes -di)") - group.add_argument( - "--no-simple-http-tls", action="store_true", help="do not use TLS " - "when solving SimpleHTTP challenges") diff --git a/tests/compatibility/plugin_test.py b/tests/compatibility/plugin_test.py index 3fe5e33a3..2d35c8a59 100644 --- a/tests/compatibility/plugin_test.py +++ b/tests/compatibility/plugin_test.py @@ -1,13 +1,78 @@ """Tests Let's Encrypt plugins against different server configurations.""" -import parser -import util +import argparse +import logging +import os + +from tests.compatibility.configurators import common + +DESCRIPTION = """ +Tests Let's Encrypt plugins against different server configuratons. It is +assumed that Docker is already installed. + +""" + + +PLUGINS = {'common' : common.ConfiguratorTester} + + +logger = logging.getLogger(__name__) + + +def get_args(): + """Returns parsed command line arguments.""" + parser = argparse.ArgumentParser(description=DESCRIPTION) + + group = parser.add_argument_group('general') + group.add_argument( + '-c', '--configs', default='configs.tar.gz', + help='a directory or tarball containing server configurations') + group.add_argument( + '-p', '--plugin', default='apache', help='the plugin to be tested') + group.add_argument( + '-a', '--auth', action='store_true', + help='tests the plugin as an authenticator') + group.add_argument( + '-i', '--install', action='store_true', + help='tests the plugin as an installer') + group.add_argument( + '-r', '--redirect', action='store_true', help='tests the plugin\'s ' + 'ability to redirect HTTP to HTTPS (implicitly includes installer ' + 'tests)') + + for plugin in PLUGINS.itervalues(): + plugin.add_parser_arguments(parser) + + args = parser.parse_args() + if args.redirect: + args.install = True + elif not (args.auth or args.install): + args.auth = args.install = args.redirect = True + + return args + + +def setup_logging(): + """Prepares logging for the program""" + fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s" + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter(fmt)) + + root_logger = logging.getLogger() + root_logger.setLevel(logging.DEBUG) + root_logger.addHandler(handler) def main(): """Main test script execution.""" - args = parser.parse_args() + setup_logging() + args = get_args() + plugin = PLUGINS[args.plugin](args) + plugin.start_docker('bradmw/apache2.4') + config = os.path.join(plugin.config_dir, 'apache2') + config_file = os.path.join(config, 'apache2.conf') + plugin.execute_in_docker('apachectl -d {0} -f {1} -k restart'.format(config, config_file)) + #plugin.cleanup_from_tests() - print util.setup_tmp_dir(args.tar) -if __name__ == "__main__": - main() +if __name__ == '__main__': + main() diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index 07b47cb25..bcad974e3 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -1,21 +1,41 @@ """Utility functions for Let's Encrypt plugin tests.""" +import contextlib import os +import shutil +import socket import tarfile import tempfile - -TEMP_DIRECTORY = tempfile.mkdtemp() -# Location of decompressed server root configurations -CONFIGS = os.path.join(TEMP_DIRECTORY, "configs") -SCRIPTS = os.path.join(TEMP_DIRECTORY, "scripts") +from tests.compatibility import errors -def setup_tmp_dir(tar_path): - """Sets up a temporary directory for this run and returns its path.""" - tar = tarfile.open(tar_path, "r:gz") - tar.extractall(os.path.join(tmp_dir, SERVER_ROOTS)) +# Paths used in the program relative to the temp directory +CONFIG_DIR = "configs" +LE_CONFIG = os.path.join("letsencrypt", "config") +LE_LOGS = os.path.join("letsencrypt", "logs") - os.makedirs(os.path.join(tmp_dir, "mnt")) - os.makedirs(os.path.join(tmp_dir, "scripts")) - return tmp_dir +def setup_temp_dir(configs): + """Sets up a temporary directory and extracts server configs""" + temp_dir = tempfile.mkdtemp() + config_dir = os.path.join(temp_dir, CONFIG_DIR) + + if os.path.isdir(configs): + shutil.copytree(configs, config_dir, symlinks=True) + elif tarfile.is_tarfile(configs): + with tarfile.open(configs, 'r') as tar: + tar.extractall(config_dir) + else: + raise errors.Error('Unknown configurations file type') + + return temp_dir + + +def get_two_free_ports(): + """Returns two free ports to use for the tests""" + with contextlib.closing(socket.socket()) as sock1: + with contextlib.closing(socket.socket()) as sock2: + sock1.bind(('', 0)) + sock2.bind(('', 0)) + + return sock1.getsockname()[1], sock2.getsockname()[1] diff --git a/tests/setup.py b/tests/setup.py new file mode 100644 index 000000000..af4da9507 --- /dev/null +++ b/tests/setup.py @@ -0,0 +1,23 @@ +from setuptools import setup +from setuptools import find_packages + + +install_requires = [ + 'letsencrypt', + 'letsencrypt-apache', + 'letsencrypt-nginx', + 'docker-py', + 'mock<1.1.0', # py26 + 'zope.interface', +] + +setup( + name='compatibility-test', + packages=find_packages(), + install_requires=install_requires, + entry_points={ + 'console_scripts': [ + 'compatibility-test = compatibility.plugin_test:main', + ], + }, +) From 4b098cdce241913aa3ad9a72ba25d763633ce69e Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 15 Jul 2015 23:00:18 -0700 Subject: [PATCH 15/35] Finished basic Apache2.4 proxy --- tests/compatibility/a2enmod.sh | 4 +- .../configurators/apache/apache24.py | 56 ++++++ .../configurators/apache/common.py | 164 ++++++++++++++++-- tests/compatibility/configurators/common.py | 89 +++++++--- tests/compatibility/interfaces.py | 18 +- tests/compatibility/plugin_test.py | 37 ++-- tests/compatibility/util.py | 42 +++-- 7 files changed, 327 insertions(+), 83 deletions(-) create mode 100644 tests/compatibility/configurators/apache/apache24.py diff --git a/tests/compatibility/a2enmod.sh b/tests/compatibility/a2enmod.sh index 9c36c44cc..364c038c0 100755 --- a/tests/compatibility/a2enmod.sh +++ b/tests/compatibility/a2enmod.sh @@ -3,12 +3,12 @@ enable () { echo "LoadModule "$1"_module /usr/local/apache2/modules/mod_"$1".so" >> \ - $APACHE_CONFDIR"/modules.load" + $APACHE_CONFDIR"/tests.conf" available_base="/mods-available/"$1".conf" available_conf=$APACHE_CONFDIR$available_base enabled_dir=$APACHE_CONFDIR"/mods-enabled" enabled_conf=$enabled_dir"/"$1".conf" - if [ -e "$available_conf" -a -e "$enabled_dir" -a ! -e "$enabled_conf" ] + if [ -e "$available_conf" -a -d "$enabled_dir" -a ! -e "$enabled_conf" ] then ln -s "..$available_base" $enabled_conf fi diff --git a/tests/compatibility/configurators/apache/apache24.py b/tests/compatibility/configurators/apache/apache24.py new file mode 100644 index 000000000..1d2d135b2 --- /dev/null +++ b/tests/compatibility/configurators/apache/apache24.py @@ -0,0 +1,56 @@ +"""Proxies ApacheConfigurator for Apache 2.4 tests""" +from tests.compatibility import errors +from tests.compatibility.configurators.apache import common as apache_common + + +# The docker image doesn't actually have the watchdog module, but unless the +# config uses mod_heartbeat or mod_heartmonitor (which aren't installed and +# therefore the config won't be loaded), I believe this isn't a problem +# http://httpd.apache.org/docs/2.4/mod/mod_watchdog.html +STATIC_MODULES = set(["core", "so", "http", "mpm_event", "watchdog",]) + + +INSTALLED_MODULES = set([ + "log_config", "logio", "version", "unixd", "access_compat", "actions", + "alias", "allowmethods", "auth_basic", "auth_digest", "auth_form", + "authn_anon", "authn_core", "authn_dbd", "authn_dbm", "authn_file", + "authn_socache", "authnz_ldap", "authz_core", "authz_dbd", "authz_dbm", + "authz_groupfile", "authz_host", "authz_owner", "authz_user", "autoindex", + "buffer", "cache", "cache_disk", "cache_socache", "cgid", "dav", "dav_fs", + "dbd", "deflate", "dir", "dumpio", "env", "expires", "ext_filter", + "file_cache", "filter", "headers", "include", "info", "lbmethod_bybusyness", + "lbmethod_byrequests", "lbmethod_bytraffic", "lbmethod_heartbeat", "ldap", + "log_debug", "macro", "mime", "negotiation", "proxy", "proxy_ajp", + "proxy_balancer", "proxy_connect", "proxy_express", "proxy_fcgi", + "proxy_ftp", "proxy_http", "proxy_scgi", "proxy_wstunnel", "ratelimit", + "remoteip", "reqtimeout", "request", "rewrite", "sed", "session", + "session_cookie", "session_crypto", "session_dbd", "setenvif", + "slotmem_shm", "socache_dbm", "socache_memcache", "socache_shmcb", + "speling", "ssl", "status", "substitute", "unique_id", "userdir", + "vhost_alias",]) + + +class Proxy(apache_common.Proxy): + """Wraps the ApacheConfigurator for Apache 2.4 tests""" + + def __init__(self, args): + """Initializes the plugin with the given command line args""" + super(Proxy, self).__init__(args) + self.start_docker("bradmw/apache2.4") + + def preprocess_config(self): + """Prepares the configuration for use in the Docker""" + super(Proxy, self).preprocess_config() + if self.version[1] != 4: + raise errors.Error("Apache version not 2.4") + + with open(self.test_conf, "a") as f: + for module in self.modules: + if module not in STATIC_MODULES: + if module in INSTALLED_MODULES: + f.write( + "LoadModule {0}_module /usr/local/apache2/modules/" + "mod_{0}\n".format(module)) + else: + raise errors.Error( + "Unsupported module {0}".format(module)) diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index 9321981d0..fb5e9c161 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -1,18 +1,41 @@ -"""Provides a common base for Apache tests""" +"""Provides a common base for Apache proxies""" +import logging +import re +import os +import subprocess + import mock -from tests.compatibilty import configurators +from letsencrypt import configuration +from letsencrypt_apache import configurator +from tests.compatibility import errors +from tests.compatibility import util +from tests.compatibility.configurators import common as configurators_common -class ApacheConfiguratorCommonTester(configurators.common.ConfiguratorTester): + +APACHE_VERSION_REGEX = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) + + +logger = logging.getLogger(__name__) + + +class Proxy(configurators_common.Proxy): + # pylint: disable=too-many-instance-attributes """A common base for Apache test configurators""" def __init__(self, args): """Initializes the plugin with the given command line args""" - super(ApacheConfiguratorCommonTester, self).__init__(args) + super(Proxy, self).__init__(args) + self.le_config = util.create_le_config(self.temp_dir) + self.le_config["apache_le_vhost_ext"] = "-le-ssl.conf" + self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') self._mock = self._patch.start() - self._mock.check_call = self._check_call - self._apache_configurator = None + self._mock.check_call = self.check_call_in_docker + self._mock.Popen = self.popen_in_docker + + self.server_root = self.modules = self.version = self.test_conf = None + self._config_file = self._apache_configurator = self._names = None def __getattr__(self, name): """Wraps the Apache Configurator methods""" @@ -22,13 +45,132 @@ class ApacheConfiguratorCommonTester(configurators.common.ConfiguratorTester): else: raise AttributeError() - def _check_call(self, command, *args, **kwargs): - """A function to mock the call to subprocess.check_call""" - def load_config(self): """Loads the next configuration for the plugin to test""" - raise NotImplementedError() + config = self.get_next_config() + logger.debug("Loading configuration: %s", config) + self._parse_config(config) + + self._prepare_configurator() + self.preprocess_config() + + try: + self.check_call_in_docker( + "apachectl -d {0} -f {1} -k restart".format( + self.server_root, self._config_file)) + except errors.Error: + raise errors.Error( + "Apache failed to load {0} before tests started".format( + config)) + + def preprocess_config(self): + # pylint: disable=anomalous-backslash-in-string + """Prepares the configuration for use in the Docker""" + self.test_conf = os.path.join(self.server_root, "test.conf") + open(self.test_conf, "w").close() + subprocess.check_call( + ["sed", "-i", "1iInclude test.conf", self._config_file]) + find = subprocess.Popen( + ["find", self.server_root, "-type", "f"], + stdout=subprocess.PIPE) + subprocess.check_call([ + "xargs", "sed", "-e", + "s/DocumentRoot.*/DocumentRoot \/usr\/local\/apache2\/htdocs/I", + "-e", + "s/SSLPassPhraseDialog.*/SSLPassPhraseDialog builtin/I", + "-i"], stdin=find.stdout) + + def _parse_config(self, config): + """Parses extra information in server config directory""" + self.server_root = _get_server_root(config) + self.modules = _get_modules(config) + self.version = _get_version(config) + self._names = _get_names(config) + + with open(os.path.join(config, "config_file")) as f: + self._config_file = os.path.join(self.server_root, f.readline()) + + def _prepare_configurator(self): + """Prepares the Apache plugin for testing""" + self.le_config["apache_ctl"] = "apachectl -d {0} -f {1}".format( + self.server_root, self._config_file) + self.le_config["a2enmod.sh"] = "a2enmod.sh" + self.le_config["apache_init_script"] = self.le_config["apache_ctl"] + self.le_config["apache_init_script"] += " -k" + + self._apache_configurator = configurator.ApacheConfigurator( + config=configuration.NamespaceConfig(self.le_config), + name="apache") + self._apache_configurator.prepare() def get_test_domain_names(self): """Returns a list of domain names to test against the plugin""" - raise NotImplementedError() + if self._names: + return self._names + else: + raise errors.Error("No configuration file loaded") + + +def _get_server_root(config): + """Returns the server root directory in config""" + subdirs = [ + name for name in os.listdir(config) + if os.path.isdir(os.path.join(config, name))] + + if len(subdirs) != 1: + errors.Error("Malformed configuration directiory {0}".format(config)) + + return subdirs[0] + + +def _get_names(config): + """Returns domains names for config""" + names = set() + with open(os.path.join(config, "vhosts")) as f: + for line in f: + # If parsing a specific vhost + if line[0].isspace(): + words = line.split() + if words[0] == "alias": + names.add(words[1]) + # If for port 80 and not IP vhost + elif words[1] == "80" and not util.IP_REGEX.match(words[3]): + names.add(words[3]) + elif "NameVirtualHost" not in line: + words = line.split() + if ((words[0].endswith("*") or words[0].endswith("80")) and + util.IP_REGEX.match(words[1])): + names.add(words[1]) + + return names + + +def _get_modules(config): + """Returns the list of modules found in module_list""" + modules = [] + with open(os.path.join(config, "modules")) as f: + for line in f: + # Modules list is indented, everything else is headers/footers + if line[0].isspace(): + words = line.split() + # Modules redundantly end in "_module" which we can discard + modules.append(words[-7]) + + return modules + + +def _get_version(config): + """Return version of Apache Server. + + Version is returned as tuple. (ie. 2.4.7 = (2, 4, 7)). Code taken from + the Apache plugin. + + """ + with open(os.path.join(config, "version")) as f: + # Should be on first line of input + matches = APACHE_VERSION_REGEX.findall(f.readline()) + + if len(matches) != 1: + raise errors.Error("Unable to find Apache version") + + return tuple([int(i) for i in matches[0].split(".")]) diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index 935190dd9..4953154b9 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -1,7 +1,8 @@ -"""Provides a common base for compatibility test configurators""" +"""Provides a common base for configurator proxies""" import logging import multiprocessing import os +import tempfile import docker @@ -12,7 +13,7 @@ from tests.compatibility import util logger = logging.getLogger(__name__) -class ConfiguratorTester(object): +class Proxy(object): # pylint: disable=too-many-instance-attributes """A common base for compatibility test configurators""" @@ -21,25 +22,25 @@ class ConfiguratorTester(object): @classmethod def add_parser_arguments(cls, parser): """Adds command line arguments needed by the plugin""" - if ConfiguratorTester._NOT_ADDED_ARGS: - group = parser.add_argument_group('docker') + if Proxy._NOT_ADDED_ARGS: + group = parser.add_argument_group("docker") group.add_argument( - '--docker-url', default='unix://var/run/docker.sock', - help='URL of the docker server') + "--docker-url", default="unix://var/run/docker.sock", + help="URL of the docker server") group.add_argument( - '--no-remove', action='store_true', - help='do not delete container on program exit') - ConfiguratorTester._NOT_ADDED_ARGS = False + "--no-remove", action="store_true", + help="do not delete container on program exit") + Proxy._NOT_ADDED_ARGS = False def __init__(self, args): """Initializes the plugin with the given command line args""" - self.temp_dir = util.setup_temp_dir(args.configs) - self.config_dir = os.path.join(self.temp_dir, util.CONFIG_DIR) + self.temp_dir = tempfile.mkdtemp() + self.config_dir = util.extract_configs(args.configs, self.temp_dir) self._configs = os.listdir(self.config_dir) self.args = args self._docker_client = docker.Client( - base_url=self.args.docker_url, version='auto') + base_url=self.args.docker_url, version="auto") self.http_port, self.https_port = util.get_two_free_ports() self._container_id = self._log_process = None @@ -65,31 +66,65 @@ class ConfiguratorTester(object): host_config = docker.utils.create_host_config( binds={ - self.config_dir : {'bind' : self.config_dir, 'mode' : 'rw'}}, + self.config_dir : {"bind" : self.config_dir, "mode" : "rw"}}, port_bindings={ - 80 : ('127.0.0.1', self.http_port), - 443 : ('127.0.0.1', self.https_port)},) + 80 : ("127.0.0.1", self.http_port), + 443 : ("127.0.0.1", self.https_port)},) container = self._docker_client.create_container( image_name, ports=[80, 443], volumes=self.config_dir, host_config=host_config) - if container['Warnings']: - logger.warning(container['Warnings']) - self._container_id = container['Id'] + if container["Warnings"]: + logger.warning(container["Warnings"]) + self._container_id = container["Id"] self._docker_client.start(self._container_id) self._log_process = multiprocessing.Process( target=self._start_log_thread) self._log_process.start() - def execute_in_docker(self, command): - """Executes command inside the running docker image""" - exec_id = self._docker_client.exec_create(self._container_id, command) - output = self._docker_client.exec_start(exec_id) - if self._docker_client.exec_inspect(exec_id)['ExitCode']: - raise errors.Error('Docker command \'{0}\' failed'.format(command)) - return output - def _start_log_thread(self): - client = docker.Client(base_url=self.args.docker_url, version='auto') + client = docker.Client(base_url=self.args.docker_url, version="auto") for line in client.logs(self._container_id, stream=True): logger.debug(line) + + def check_call_in_docker( + self, command, *args, **kwargs): # pylint: disable=unused-argument + """Simulates a call to check_call but executes the command in the + running docker image + + """ + if self.popen_in_docker(command).returncode: + raise errors.Error( + "{0} exited with a nonzero value".format(command)) + + def popen_in_docker( + self, command, *args, **kwargs): # pylint: disable=unused-argument + """Simulates a call to Popen but executes the command in the + running docker image + + """ + class SimplePopen(object): + # pylint: disable=too-few-public-methods + """Simplified Popen object""" + def __init__(self, returncode, output): + self.returncode = returncode + self._stdout = output + self._stderr = output + + def communicate(self): + """Returns stdout and stderr""" + return self._stdout, self._stderr + + if isinstance(command, list): + command = " ".join(command) + + returncode, output = self._execute_in_docker(command) + return SimplePopen(returncode, output) + + def _execute_in_docker(self, command): + """Executes command inside the running docker image""" + logger.debug("Executing '%s'", command) + exec_id = self._docker_client.exec_create(self._container_id, command) + output = self._docker_client.exec_start(exec_id) + returncode = self._docker_client.exec_inspect(exec_id)["ExitCode"] + return returncode, output diff --git a/tests/compatibility/interfaces.py b/tests/compatibility/interfaces.py index 035a9f541..4e8c68675 100644 --- a/tests/compatibility/interfaces.py +++ b/tests/compatibility/interfaces.py @@ -4,7 +4,7 @@ import zope.interface import letsencrypt.interfaces -class IPluginTester(zope.interface.Interface): +class IPluginProxy(zope.interface.Interface): """Wraps a Let's Encrypt plugin""" @classmethod def add_parser_arguments(cls, parser): @@ -27,27 +27,27 @@ class IPluginTester(zope.interface.Interface): """Loads the next configuration for the plugin to test""" -class IConfiguratorBaseTester(IPluginTester): +class IConfiguratorBaseProxy(IPluginProxy): """Common functionality for authenticator/installer tests""" http_port = zope.interface.Attribute( - 'The port to connect to on localhost for HTTP traffic') + "The port to connect to on localhost for HTTP traffic") https_port = zope.interface.Attribute( - 'The port to connect to on localhost for HTTPS traffic') + "The port to connect to on localhost for HTTPS traffic") def get_test_domain_names(self): """Returns a list of domain names to test against the plugin""" -class IAuthenticatorTester( - IConfiguratorBaseTester, letsencrypt.interfaces.IAuthenticator): +class IAuthenticatorProxy( + IConfiguratorBaseProxy, letsencrypt.interfaces.IAuthenticator): """Wraps a Let's Encrypt authenticator""" -class IInstallerTester( - IConfiguratorBaseTester, letsencrypt.interfaces.IInstaller): +class IInstallerProxy( + IConfiguratorBaseProxy, letsencrypt.interfaces.IInstaller): """Wraps a Let's Encrypt installer""" -class IConfiguratorTester(IAuthenticatorTester, IInstallerTester): +class IConfiguratorProxy(IAuthenticatorProxy, IInstallerProxy): """Wraps a Let's Encrypt configurator""" diff --git a/tests/compatibility/plugin_test.py b/tests/compatibility/plugin_test.py index 2d35c8a59..7e1a3cb50 100644 --- a/tests/compatibility/plugin_test.py +++ b/tests/compatibility/plugin_test.py @@ -3,7 +3,7 @@ import argparse import logging import os -from tests.compatibility.configurators import common +from tests.compatibility.configurators.apache import apache24 DESCRIPTION = """ Tests Let's Encrypt plugins against different server configuratons. It is @@ -12,7 +12,7 @@ assumed that Docker is already installed. """ -PLUGINS = {'common' : common.ConfiguratorTester} +PLUGINS = {"apache" : apache24.Proxy} logger = logging.getLogger(__name__) @@ -22,22 +22,22 @@ def get_args(): """Returns parsed command line arguments.""" parser = argparse.ArgumentParser(description=DESCRIPTION) - group = parser.add_argument_group('general') + group = parser.add_argument_group("general") group.add_argument( - '-c', '--configs', default='configs.tar.gz', - help='a directory or tarball containing server configurations') + "-c", "--configs", default="configs.tar.gz", + help="a directory or tarball containing server configurations") group.add_argument( - '-p', '--plugin', default='apache', help='the plugin to be tested') + "-p", "--plugin", default="apache", help="the plugin to be tested") group.add_argument( - '-a', '--auth', action='store_true', - help='tests the plugin as an authenticator') + "-a", "--auth", action="store_true", + help="tests the plugin as an authenticator") group.add_argument( - '-i', '--install', action='store_true', - help='tests the plugin as an installer') + "-i", "--install", action="store_true", + help="tests the plugin as an installer") group.add_argument( - '-r', '--redirect', action='store_true', help='tests the plugin\'s ' - 'ability to redirect HTTP to HTTPS (implicitly includes installer ' - 'tests)') + "-r", "--redirect", action="store_true", help="tests the plugin's " + "ability to redirect HTTP to HTTPS (implicitly includes installer " + "tests)") for plugin in PLUGINS.itervalues(): plugin.add_parser_arguments(parser) @@ -66,13 +66,12 @@ def main(): """Main test script execution.""" setup_logging() args = get_args() + + if args.plugin not in PLUGINS: + raise errors.Error("Unknown plugin {0}".format(args.plugin)) plugin = PLUGINS[args.plugin](args) - plugin.start_docker('bradmw/apache2.4') - config = os.path.join(plugin.config_dir, 'apache2') - config_file = os.path.join(config, 'apache2.conf') - plugin.execute_in_docker('apachectl -d {0} -f {1} -k restart'.format(config, config_file)) - #plugin.cleanup_from_tests() + plugin.cleanup_from_tests() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index bcad974e3..e6850e2e0 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -1,41 +1,53 @@ -"""Utility functions for Let's Encrypt plugin tests.""" +"""Utility functions for Let"s Encrypt plugin tests.""" +import copy import contextlib import os +import re import shutil import socket import tarfile -import tempfile +from letsencrypt import constants from tests.compatibility import errors -# Paths used in the program relative to the temp directory -CONFIG_DIR = "configs" -LE_CONFIG = os.path.join("letsencrypt", "config") -LE_LOGS = os.path.join("letsencrypt", "logs") +IP_REGEX = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") -def setup_temp_dir(configs): - """Sets up a temporary directory and extracts server configs""" - temp_dir = tempfile.mkdtemp() - config_dir = os.path.join(temp_dir, CONFIG_DIR) +def create_le_config(parent_dir): + """Sets up LE dirs in parent_dir and returns the config dict""" + config = copy.deepcopy(constants.CLI_DEFAULTS) + + le_dir = os.path.join(parent_dir, "letsencrypt") + config["config_dir"] = os.path.join(le_dir, "config") + config["work_dir"] = os.path.join(le_dir, "work") + config["logs_dir"] = os.path.join(le_dir, "logs_dir") + os.makedirs(config["config_dir"]) + os.mkdir(config["work_dir"]) + os.mkdir(config["logs_dir"]) + + return config + +def extract_configs(configs, parent_dir): + """Extracts configs to a new dir under parent_dir and returns it""" + config_dir = os.path.join(parent_dir, "configs") if os.path.isdir(configs): shutil.copytree(configs, config_dir, symlinks=True) elif tarfile.is_tarfile(configs): - with tarfile.open(configs, 'r') as tar: + with tarfile.open(configs, "r") as tar: tar.extractall(config_dir) else: - raise errors.Error('Unknown configurations file type') + raise errors.Error("Unknown configurations file type") - return temp_dir + return config_dir def get_two_free_ports(): """Returns two free ports to use for the tests""" with contextlib.closing(socket.socket()) as sock1: with contextlib.closing(socket.socket()) as sock2: - sock1.bind(('', 0)) - sock2.bind(('', 0)) + sock1.bind(("", 0)) + sock2.bind(("", 0)) return sock1.getsockname()[1], sock2.getsockname()[1] From e8387b10c4aec3a705ef1f634b0bd3a80dd41a2d Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 16 Jul 2015 16:57:47 -0700 Subject: [PATCH 16/35] Finished basic Apache 2.4 Proxy --- tests/compatibility/Dockerfile | 2 +- .../configurators/apache/apache24.py | 5 ++- .../configurators/apache/common.py | 32 +++++++++---------- tests/compatibility/configurators/common.py | 26 +++++++-------- tests/compatibility/plugin_test.py | 32 +++++++++++-------- tests/compatibility/util.py | 3 +- 6 files changed, 55 insertions(+), 45 deletions(-) diff --git a/tests/compatibility/Dockerfile b/tests/compatibility/Dockerfile index 6637b9e14..446234816 100644 --- a/tests/compatibility/Dockerfile +++ b/tests/compatibility/Dockerfile @@ -10,7 +10,7 @@ RUN mkdir /var/run/apache2 && \ ENV APACHE_CONFDIR=/tmp/apache2 \ APACHE_RUN_USER=daemon \ APACHE_RUN_GROUP=daemon \ - APACHE_PID_FILE=/var/run/apache2/apache2.pid \ + APACHE_PID_FILE=/usr/local/apache2/logs/httpd.pid \ APACHE_RUN_DIR=/var/run/apache2 \ APACHE_LOCK_DIR=/var/lock \ APACHE_LOG_DIR=/usr/local/apache2/logs diff --git a/tests/compatibility/configurators/apache/apache24.py b/tests/compatibility/configurators/apache/apache24.py index 1d2d135b2..26e0686c0 100644 --- a/tests/compatibility/configurators/apache/apache24.py +++ b/tests/compatibility/configurators/apache/apache24.py @@ -44,13 +44,16 @@ class Proxy(apache_common.Proxy): if self.version[1] != 4: raise errors.Error("Apache version not 2.4") + self.execute_in_docker( + "bash -c 'export APACHE_CONFDIR={0}'".format(self.config_file)) + with open(self.test_conf, "a") as f: for module in self.modules: if module not in STATIC_MODULES: if module in INSTALLED_MODULES: f.write( "LoadModule {0}_module /usr/local/apache2/modules/" - "mod_{0}\n".format(module)) + "mod_{0}.so\n".format(module)) else: raise errors.Error( "Unsupported module {0}".format(module)) diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index fb5e9c161..cbdc6b845 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -26,8 +26,7 @@ class Proxy(configurators_common.Proxy): def __init__(self, args): """Initializes the plugin with the given command line args""" super(Proxy, self).__init__(args) - self.le_config = util.create_le_config(self.temp_dir) - self.le_config["apache_le_vhost_ext"] = "-le-ssl.conf" + self.le_config.apache_le_vhost_ext = "-le-ssl.conf" self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') self._mock = self._patch.start() @@ -35,7 +34,7 @@ class Proxy(configurators_common.Proxy): self._mock.Popen = self.popen_in_docker self.server_root = self.modules = self.version = self.test_conf = None - self._config_file = self._apache_configurator = self._names = None + self.config_file = self._apache_configurator = self._names = None def __getattr__(self, name): """Wraps the Apache Configurator methods""" @@ -48,16 +47,15 @@ class Proxy(configurators_common.Proxy): def load_config(self): """Loads the next configuration for the plugin to test""" config = self.get_next_config() - logger.debug("Loading configuration: %s", config) + logger.info("Loading configuration: %s", config) self._parse_config(config) - - self._prepare_configurator() self.preprocess_config() + self._prepare_configurator() try: self.check_call_in_docker( "apachectl -d {0} -f {1} -k restart".format( - self.server_root, self._config_file)) + self.server_root, self.config_file)) except errors.Error: raise errors.Error( "Apache failed to load {0} before tests started".format( @@ -69,7 +67,7 @@ class Proxy(configurators_common.Proxy): self.test_conf = os.path.join(self.server_root, "test.conf") open(self.test_conf, "w").close() subprocess.check_call( - ["sed", "-i", "1iInclude test.conf", self._config_file]) + ["sed", "-i", "1iInclude test.conf", self.config_file]) find = subprocess.Popen( ["find", self.server_root, "-type", "f"], stdout=subprocess.PIPE) @@ -88,15 +86,17 @@ class Proxy(configurators_common.Proxy): self._names = _get_names(config) with open(os.path.join(config, "config_file")) as f: - self._config_file = os.path.join(self.server_root, f.readline()) + config_file_base = f.readline().rstrip() + + self.config_file = os.path.join(self.server_root, config_file_base) def _prepare_configurator(self): """Prepares the Apache plugin for testing""" - self.le_config["apache_ctl"] = "apachectl -d {0} -f {1}".format( - self.server_root, self._config_file) - self.le_config["a2enmod.sh"] = "a2enmod.sh" - self.le_config["apache_init_script"] = self.le_config["apache_ctl"] - self.le_config["apache_init_script"] += " -k" + self.le_config.apache_server_root = self.server_root + self.le_config.apache_ctl = "apachectl -d {0} -f {1}".format( + self.server_root, self.config_file) + self.le_config.apache_enmod = "a2enmod.sh" + self.le_config.apache_init = self.le_config.apache_ctl + " -k" self._apache_configurator = configurator.ApacheConfigurator( config=configuration.NamespaceConfig(self.le_config), @@ -120,7 +120,7 @@ def _get_server_root(config): if len(subdirs) != 1: errors.Error("Malformed configuration directiory {0}".format(config)) - return subdirs[0] + return os.path.join(config, subdirs[0].rstrip()) def _get_names(config): @@ -154,7 +154,7 @@ def _get_modules(config): if line[0].isspace(): words = line.split() # Modules redundantly end in "_module" which we can discard - modules.append(words[-7]) + modules.append(words[0][:-7]) return modules diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index 4953154b9..b78046d62 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -1,8 +1,8 @@ """Provides a common base for configurator proxies""" import logging -import multiprocessing import os import tempfile +import threading import docker @@ -34,15 +34,16 @@ class Proxy(object): def __init__(self, args): """Initializes the plugin with the given command line args""" - self.temp_dir = tempfile.mkdtemp() - self.config_dir = util.extract_configs(args.configs, self.temp_dir) + temp_dir = tempfile.mkdtemp() + self.le_config = util.create_le_config(temp_dir) + self.config_dir = util.extract_configs(args.configs, temp_dir) self._configs = os.listdir(self.config_dir) self.args = args self._docker_client = docker.Client( base_url=self.args.docker_url, version="auto") self.http_port, self.https_port = util.get_two_free_ports() - self._container_id = self._log_process = None + self._container_id = self._log_thread = None def has_more_configs(self): """Returns true if there are more configs to test""" @@ -51,13 +52,13 @@ class Proxy(object): def cleanup_from_tests(self): """Performs any necessary cleanup from running plugin tests""" self._docker_client.stop(self._container_id) - self._log_process.join() + self._log_thread.join() if not self.args.no_remove: self._docker_client.remove_container(self._container_id) def get_next_config(self): """Returns the next config directory to be tested""" - return self._configs.pop() + return os.path.join(self.config_dir, self._configs.pop()) def start_docker(self, image_name): """Creates and runs a Docker container with the specified image""" @@ -78,14 +79,13 @@ class Proxy(object): self._container_id = container["Id"] self._docker_client.start(self._container_id) - self._log_process = multiprocessing.Process( - target=self._start_log_thread) - self._log_process.start() + self._log_thread = threading.Thread(target=self._start_log_thread) + self._log_thread.start() def _start_log_thread(self): client = docker.Client(base_url=self.args.docker_url, version="auto") for line in client.logs(self._container_id, stream=True): - logger.debug(line) + logger.info(line.rstrip()) def check_call_in_docker( self, command, *args, **kwargs): # pylint: disable=unused-argument @@ -118,12 +118,12 @@ class Proxy(object): if isinstance(command, list): command = " ".join(command) - returncode, output = self._execute_in_docker(command) + returncode, output = self.execute_in_docker(command) return SimplePopen(returncode, output) - def _execute_in_docker(self, command): + def execute_in_docker(self, command): """Executes command inside the running docker image""" - logger.debug("Executing '%s'", command) + logger.info("Executing '%s'", command) exec_id = self._docker_client.exec_create(self._container_id, command) output = self._docker_client.exec_start(exec_id) returncode = self._docker_client.exec_inspect(exec_id)["ExitCode"] diff --git a/tests/compatibility/plugin_test.py b/tests/compatibility/plugin_test.py index 7e1a3cb50..04cb9da11 100644 --- a/tests/compatibility/plugin_test.py +++ b/tests/compatibility/plugin_test.py @@ -1,13 +1,14 @@ """Tests Let's Encrypt plugins against different server configurations.""" import argparse import logging -import os +from tests.compatibility import errors from tests.compatibility.configurators.apache import apache24 DESCRIPTION = """ Tests Let's Encrypt plugins against different server configuratons. It is -assumed that Docker is already installed. +assumed that Docker is already installed. If no test types is specified, all +tests that the plugin supports are performed. """ @@ -20,7 +21,9 @@ logger = logging.getLogger(__name__) def get_args(): """Returns parsed command line arguments.""" - parser = argparse.ArgumentParser(description=DESCRIPTION) + parser = argparse.ArgumentParser( + description=DESCRIPTION, + formatter_class=argparse.ArgumentDefaultsHelpFormatter) group = parser.add_argument_group("general") group.add_argument( @@ -30,20 +33,19 @@ def get_args(): "-p", "--plugin", default="apache", help="the plugin to be tested") group.add_argument( "-a", "--auth", action="store_true", - help="tests the plugin as an authenticator") + help="tests the challenges the plugin supports") group.add_argument( "-i", "--install", action="store_true", help="tests the plugin as an installer") group.add_argument( - "-r", "--redirect", action="store_true", help="tests the plugin's " - "ability to redirect HTTP to HTTPS (implicitly includes installer " - "tests)") + "-e", "--enhance", action="store_true", help="tests the enhancements " + "the plugin supports (implicitly includes installer tests)") for plugin in PLUGINS.itervalues(): plugin.add_parser_arguments(parser) args = parser.parse_args() - if args.redirect: + if args.enhance: args.install = True elif not (args.auth or args.install): args.auth = args.install = args.redirect = True @@ -53,12 +55,10 @@ def get_args(): def setup_logging(): """Prepares logging for the program""" - fmt = "%(asctime)s:%(levelname)s:%(name)s:%(message)s" handler = logging.StreamHandler() - handler.setFormatter(logging.Formatter(fmt)) root_logger = logging.getLogger() - root_logger.setLevel(logging.DEBUG) + root_logger.setLevel(logging.INFO) root_logger.addHandler(handler) @@ -69,8 +69,14 @@ def main(): if args.plugin not in PLUGINS: raise errors.Error("Unknown plugin {0}".format(args.plugin)) - plugin = PLUGINS[args.plugin](args) - plugin.cleanup_from_tests() + plugin = None + try: + plugin = PLUGINS[args.plugin](args) + plugin.load_config() + assert plugin.get_all_names() == plugin.get_test_domain_names() + finally: + if plugin: + plugin.cleanup_from_tests() if __name__ == "__main__": diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index e6850e2e0..c217bdfa9 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -1,4 +1,5 @@ """Utility functions for Let"s Encrypt plugin tests.""" +import argparse import copy import contextlib import os @@ -26,7 +27,7 @@ def create_le_config(parent_dir): os.mkdir(config["work_dir"]) os.mkdir(config["logs_dir"]) - return config + return argparse.Namespace(**config) # pylint: disable=star-args def extract_configs(configs, parent_dir): """Extracts configs to a new dir under parent_dir and returns it""" From f6936d8412a1f404b4c1094da863d0d39ceef7bc Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 17 Jul 2015 16:21:17 -0700 Subject: [PATCH 17/35] Finished basic Apache wrapper --- .../{ => configurators/apache}/Dockerfile | 8 +- .../{ => configurators/apache}/a2enmod.sh | 12 +- .../configurators/apache/apache24.py | 13 +- .../configurators/apache/common.py | 129 ++++++++++-------- tests/compatibility/configurators/common.py | 14 +- tests/compatibility/interfaces.py | 12 +- .../{plugin_test.py => test_driver.py} | 35 +++-- tests/setup.py | 2 +- 8 files changed, 132 insertions(+), 93 deletions(-) rename tests/compatibility/{ => configurators/apache}/Dockerfile (70%) rename tests/compatibility/{ => configurators/apache}/a2enmod.sh (69%) rename tests/compatibility/{plugin_test.py => test_driver.py} (70%) diff --git a/tests/compatibility/Dockerfile b/tests/compatibility/configurators/apache/Dockerfile similarity index 70% rename from tests/compatibility/Dockerfile rename to tests/compatibility/configurators/apache/Dockerfile index 446234816..8cde44ea6 100644 --- a/tests/compatibility/Dockerfile +++ b/tests/compatibility/configurators/apache/Dockerfile @@ -4,17 +4,15 @@ FROM httpd MAINTAINER Brad Warren -RUN mkdir /var/run/apache2 && \ - ln -s /usr/local/apache2/conf/mime.types /etc/mime.types +RUN mkdir /var/run/apache2 -ENV APACHE_CONFDIR=/tmp/apache2 \ - APACHE_RUN_USER=daemon \ +ENV APACHE_RUN_USER=daemon \ APACHE_RUN_GROUP=daemon \ APACHE_PID_FILE=/usr/local/apache2/logs/httpd.pid \ APACHE_RUN_DIR=/var/run/apache2 \ APACHE_LOCK_DIR=/var/lock \ APACHE_LOG_DIR=/usr/local/apache2/logs -COPY tests/compatibility/a2enmod.sh /usr/local/bin/ +COPY tests/compatibility/configurators/apache/a2enmod.sh /usr/local/bin/ CMD [ "httpd-foreground" ] diff --git a/tests/compatibility/a2enmod.sh b/tests/compatibility/configurators/apache/a2enmod.sh similarity index 69% rename from tests/compatibility/a2enmod.sh rename to tests/compatibility/configurators/apache/a2enmod.sh index 364c038c0..c22adb1fb 100755 --- a/tests/compatibility/a2enmod.sh +++ b/tests/compatibility/configurators/apache/a2enmod.sh @@ -1,9 +1,13 @@ #!/bin/bash -# An extremely simplified version of 'a2enmod' for the httpd docker image +# An extremely simplified (and hacky) version of 'a2enmod' for the httpd +# docker image. First argument is server_root and second argument is the module +# to be enabled. + +APACHE_CONFDIR=$1 enable () { echo "LoadModule "$1"_module /usr/local/apache2/modules/mod_"$1".so" >> \ - $APACHE_CONFDIR"/tests.conf" + $APACHE_CONFDIR"/test.conf" available_base="/mods-available/"$1".conf" available_conf=$APACHE_CONFDIR$available_base enabled_dir=$APACHE_CONFDIR"/mods-enabled" @@ -14,14 +18,14 @@ enable () { fi } -if [ $1 == "ssl" ] +if [ $2 == "ssl" ] then # Enables ssl and all its dependencies enable "setenvif" enable "mime" enable "socache_shmcb" enable "ssl" -elif [ $1 == "rewrite" ] +elif [ $2 == "rewrite" ] then enable "rewrite"; else diff --git a/tests/compatibility/configurators/apache/apache24.py b/tests/compatibility/configurators/apache/apache24.py index 26e0686c0..48fa6714d 100644 --- a/tests/compatibility/configurators/apache/apache24.py +++ b/tests/compatibility/configurators/apache/apache24.py @@ -7,10 +7,10 @@ from tests.compatibility.configurators.apache import common as apache_common # config uses mod_heartbeat or mod_heartmonitor (which aren't installed and # therefore the config won't be loaded), I believe this isn't a problem # http://httpd.apache.org/docs/2.4/mod/mod_watchdog.html -STATIC_MODULES = set(["core", "so", "http", "mpm_event", "watchdog",]) +STATIC_MODULES = {"core", "so", "http", "mpm_event", "watchdog",} -INSTALLED_MODULES = set([ +INSTALLED_MODULES = { "log_config", "logio", "version", "unixd", "access_compat", "actions", "alias", "allowmethods", "auth_basic", "auth_digest", "auth_form", "authn_anon", "authn_core", "authn_dbd", "authn_dbm", "authn_file", @@ -27,7 +27,7 @@ INSTALLED_MODULES = set([ "session_cookie", "session_crypto", "session_dbd", "setenvif", "slotmem_shm", "socache_dbm", "socache_memcache", "socache_shmcb", "speling", "ssl", "status", "substitute", "unique_id", "userdir", - "vhost_alias",]) + "vhost_alias",} class Proxy(apache_common.Proxy): @@ -38,15 +38,12 @@ class Proxy(apache_common.Proxy): super(Proxy, self).__init__(args) self.start_docker("bradmw/apache2.4") - def preprocess_config(self): + def preprocess_config(self, server_root): """Prepares the configuration for use in the Docker""" - super(Proxy, self).preprocess_config() + super(Proxy, self).preprocess_config(server_root) if self.version[1] != 4: raise errors.Error("Apache version not 2.4") - self.execute_in_docker( - "bash -c 'export APACHE_CONFDIR={0}'".format(self.config_file)) - with open(self.test_conf, "a") as f: for module in self.modules: if module not in STATIC_MODULES: diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index cbdc6b845..cf918fa9c 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -1,14 +1,15 @@ """Provides a common base for Apache proxies""" -import logging import re import os import subprocess import mock +import zope.interface from letsencrypt import configuration from letsencrypt_apache import configurator from tests.compatibility import errors +from tests.compatibility import interfaces from tests.compatibility import util from tests.compatibility.configurators import common as configurators_common @@ -16,25 +17,26 @@ from tests.compatibility.configurators import common as configurators_common APACHE_VERSION_REGEX = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) -logger = logging.getLogger(__name__) - - class Proxy(configurators_common.Proxy): # pylint: disable=too-many-instance-attributes """A common base for Apache test configurators""" + zope.interface.implements(interfaces.IConfiguratorProxy) + def __init__(self, args): """Initializes the plugin with the given command line args""" super(Proxy, self).__init__(args) self.le_config.apache_le_vhost_ext = "-le-ssl.conf" self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') - self._mock = self._patch.start() - self._mock.check_call = self.check_call_in_docker - self._mock.Popen = self.popen_in_docker + subprocess_mock = self._patch.start() + subprocess_mock.check_call = self.check_call_in_docker + subprocess_mock.Popen = self.popen_in_docker + self.modules = self.version = self.test_conf = None + self._apache_configurator = None self.server_root = self.modules = self.version = self.test_conf = None - self.config_file = self._apache_configurator = self._names = None + self._apache_configurator = self._all_names = self._test_names = None def __getattr__(self, name): """Wraps the Apache Configurator methods""" @@ -46,56 +48,51 @@ class Proxy(configurators_common.Proxy): def load_config(self): """Loads the next configuration for the plugin to test""" - config = self.get_next_config() - logger.info("Loading configuration: %s", config) - self._parse_config(config) - self.preprocess_config() - self._prepare_configurator() + config = super(Proxy, self).load_config() + self.modules = _get_modules(config) + self.version = _get_version(config) + self._all_names, self._test_names = _get_names(config) + + server_root = _get_server_root(config) + with open(os.path.join(config, "config_file")) as f: + config_file = os.path.join(server_root, f.readline().rstrip()) + self.test_conf = _create_test_conf(server_root, config_file) + + self.preprocess_config(server_root) + self._prepare_configurator(server_root, config_file) try: self.check_call_in_docker( "apachectl -d {0} -f {1} -k restart".format( - self.server_root, self.config_file)) + server_root, config_file)) except errors.Error: raise errors.Error( "Apache failed to load {0} before tests started".format( config)) - def preprocess_config(self): - # pylint: disable=anomalous-backslash-in-string + return config + + def preprocess_config(self, server_root): + # pylint: disable=anomalous-backslash-in-string, no-self-use """Prepares the configuration for use in the Docker""" - self.test_conf = os.path.join(self.server_root, "test.conf") - open(self.test_conf, "w").close() - subprocess.check_call( - ["sed", "-i", "1iInclude test.conf", self.config_file]) find = subprocess.Popen( - ["find", self.server_root, "-type", "f"], + ["find", server_root, "-type", "f"], stdout=subprocess.PIPE) subprocess.check_call([ - "xargs", "sed", "-e", - "s/DocumentRoot.*/DocumentRoot \/usr\/local\/apache2\/htdocs/I", - "-e", - "s/SSLPassPhraseDialog.*/SSLPassPhraseDialog builtin/I", + "xargs", "sed", "-e", "s/DocumentRoot.*/" + "DocumentRoot \/usr\/local\/apache2\/htdocs/I", + "-e", "s/SSLPassPhraseDialog.*/" + "SSLPassPhraseDialog builtin/I", + "-e", "s/TypesConfig.*/" + "TypesConfig \/usr\/local\/apache2\/conf\/mime.types/I", "-i"], stdin=find.stdout) - def _parse_config(self, config): - """Parses extra information in server config directory""" - self.server_root = _get_server_root(config) - self.modules = _get_modules(config) - self.version = _get_version(config) - self._names = _get_names(config) - - with open(os.path.join(config, "config_file")) as f: - config_file_base = f.readline().rstrip() - - self.config_file = os.path.join(self.server_root, config_file_base) - - def _prepare_configurator(self): + def _prepare_configurator(self, server_root, config_file): """Prepares the Apache plugin for testing""" - self.le_config.apache_server_root = self.server_root + self.le_config.apache_server_root = server_root self.le_config.apache_ctl = "apachectl -d {0} -f {1}".format( - self.server_root, self.config_file) - self.le_config.apache_enmod = "a2enmod.sh" + server_root, config_file) + self.le_config.apache_enmod = "a2enmod.sh {0}".format(server_root) self.le_config.apache_init = self.le_config.apache_ctl + " -k" self._apache_configurator = configurator.ApacheConfigurator( @@ -103,13 +100,34 @@ class Proxy(configurators_common.Proxy): name="apache") self._apache_configurator.prepare() - def get_test_domain_names(self): - """Returns a list of domain names to test against the plugin""" - if self._names: - return self._names + def cleanup_from_tests(self): + """Performs any necessary cleanup from running plugin tests""" + super(Proxy, self).cleanup_from_tests() + self._patch.stop() + + def get_testable_domain_names(self): + """Returns the set of domain names that can be tested against""" + if self._test_names: + return self._test_names else: raise errors.Error("No configuration file loaded") + def get_all_names_answer(self): + """Returns the set of domain names that the plugin should find""" + if self._all_names: + return self._all_names + else: + raise errors.Error("No configuration file loaded") + + +def _create_test_conf(server_root, apache_config): + """Creates a test config file and adds it to the Apache config""" + test_conf = os.path.join(server_root, "test.conf") + open(test_conf, "w").close() + subprocess.check_call( + ["sed", "-i", "1iInclude test.conf", apache_config]) + return test_conf + def _get_server_root(config): """Returns the server root directory in config""" @@ -124,25 +142,28 @@ def _get_server_root(config): def _get_names(config): - """Returns domains names for config""" - names = set() + """Returns all and testable domain names in config""" + all_names = set() + non_ip_names = set() with open(os.path.join(config, "vhosts")) as f: for line in f: # If parsing a specific vhost if line[0].isspace(): words = line.split() if words[0] == "alias": - names.add(words[1]) + all_names.add(words[1]) + non_ip_names.add(words[1]) # If for port 80 and not IP vhost elif words[1] == "80" and not util.IP_REGEX.match(words[3]): - names.add(words[3]) + all_names.add(words[3]) + non_ip_names.add(words[3]) elif "NameVirtualHost" not in line: words = line.split() - if ((words[0].endswith("*") or words[0].endswith("80")) and - util.IP_REGEX.match(words[1])): - names.add(words[1]) - - return names + if (words[0].endswith("*") or words[0].endswith("80") and + not util.IP_REGEX.match(words[1]) and + words[1].find(".") != -1): + all_names.add(words[1]) + return all_names, non_ip_names def _get_modules(config): diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index b78046d62..689f1d4a4 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -36,8 +36,8 @@ class Proxy(object): """Initializes the plugin with the given command line args""" temp_dir = tempfile.mkdtemp() self.le_config = util.create_le_config(temp_dir) - self.config_dir = util.extract_configs(args.configs, temp_dir) - self._configs = os.listdir(self.config_dir) + self._config_dir = util.extract_configs(args.configs, temp_dir) + self._configs = os.listdir(self._config_dir) self.args = args self._docker_client = docker.Client( @@ -56,9 +56,9 @@ class Proxy(object): if not self.args.no_remove: self._docker_client.remove_container(self._container_id) - def get_next_config(self): + def load_config(self): """Returns the next config directory to be tested""" - return os.path.join(self.config_dir, self._configs.pop()) + return os.path.join(self._config_dir, self._configs.pop()) def start_docker(self, image_name): """Creates and runs a Docker container with the specified image""" @@ -67,12 +67,12 @@ class Proxy(object): host_config = docker.utils.create_host_config( binds={ - self.config_dir : {"bind" : self.config_dir, "mode" : "rw"}}, + self._config_dir : {"bind" : self._config_dir, "mode" : "rw"}}, port_bindings={ 80 : ("127.0.0.1", self.http_port), 443 : ("127.0.0.1", self.https_port)},) container = self._docker_client.create_container( - image_name, ports=[80, 443], volumes=self.config_dir, + image_name, ports=[80, 443], volumes=self._config_dir, host_config=host_config) if container["Warnings"]: logger.warning(container["Warnings"]) @@ -85,7 +85,7 @@ class Proxy(object): def _start_log_thread(self): client = docker.Client(base_url=self.args.docker_url, version="auto") for line in client.logs(self._container_id, stream=True): - logger.info(line.rstrip()) + logger.debug(line.rstrip()) def check_call_in_docker( self, command, *args, **kwargs): # pylint: disable=unused-argument diff --git a/tests/compatibility/interfaces.py b/tests/compatibility/interfaces.py index 4e8c68675..a4925014f 100644 --- a/tests/compatibility/interfaces.py +++ b/tests/compatibility/interfaces.py @@ -6,8 +6,7 @@ import letsencrypt.interfaces class IPluginProxy(zope.interface.Interface): """Wraps a Let's Encrypt plugin""" - @classmethod - def add_parser_arguments(cls, parser): + def add_parser_arguments(cls, parser): # pylint: disable=no-self-argument """Adds command line arguments needed by the parser""" def __init__(self, args): @@ -24,7 +23,7 @@ class IPluginProxy(zope.interface.Interface): """Returns True if there are more configs to test""" def load_config(self): - """Loads the next configuration for the plugin to test""" + """Loads the next config and returns its name""" class IConfiguratorBaseProxy(IPluginProxy): @@ -35,8 +34,8 @@ class IConfiguratorBaseProxy(IPluginProxy): https_port = zope.interface.Attribute( "The port to connect to on localhost for HTTPS traffic") - def get_test_domain_names(self): - """Returns a list of domain names to test against the plugin""" + def get_testable_domain_names(self): + """Returns the domain names that can be used in testing""" class IAuthenticatorProxy( @@ -48,6 +47,9 @@ class IInstallerProxy( IConfiguratorBaseProxy, letsencrypt.interfaces.IInstaller): """Wraps a Let's Encrypt installer""" + def get_all_names_answer(self): + """Returns all names that should be found by the installer""" + class IConfiguratorProxy(IAuthenticatorProxy, IInstallerProxy): """Wraps a Let's Encrypt configurator""" diff --git a/tests/compatibility/plugin_test.py b/tests/compatibility/test_driver.py similarity index 70% rename from tests/compatibility/plugin_test.py rename to tests/compatibility/test_driver.py index 04cb9da11..5e15ce155 100644 --- a/tests/compatibility/plugin_test.py +++ b/tests/compatibility/test_driver.py @@ -5,6 +5,7 @@ import logging from tests.compatibility import errors from tests.compatibility.configurators.apache import apache24 + DESCRIPTION = """ Tests Let's Encrypt plugins against different server configuratons. It is assumed that Docker is already installed. If no test types is specified, all @@ -31,6 +32,9 @@ def get_args(): help="a directory or tarball containing server configurations") group.add_argument( "-p", "--plugin", default="apache", help="the plugin to be tested") + group.add_argument( + "-v", "--verbose", dest="verbose_count", action="count", + default=0, help="You know how to use this") group.add_argument( "-a", "--auth", action="store_true", help="tests the challenges the plugin supports") @@ -53,30 +57,43 @@ def get_args(): return args -def setup_logging(): +def setup_logging(args): """Prepares logging for the program""" handler = logging.StreamHandler() root_logger = logging.getLogger() - root_logger.setLevel(logging.INFO) + root_logger.setLevel(logging.WARNING - args.verbose_count * 10) root_logger.addHandler(handler) +def test_installer(plugin): + """Tests plugin as an installer""" + if plugin.get_all_names() != plugin.get_all_names_answer(): + raise errors.Error( + "Names found by plugin don't match names found by the wrapper") + + def main(): """Main test script execution.""" - setup_logging() args = get_args() + setup_logging(args) if args.plugin not in PLUGINS: raise errors.Error("Unknown plugin {0}".format(args.plugin)) - plugin = None + + plugin = PLUGINS[args.plugin](args) try: - plugin = PLUGINS[args.plugin](args) - plugin.load_config() - assert plugin.get_all_names() == plugin.get_test_domain_names() + while plugin.has_more_configs(): + try: + print "Loaded configuration: {0}".format(plugin.load_config()) + + if args.install: + test_installer(plugin) + except errors.Error as error: + print "Test failed" + print error finally: - if plugin: - plugin.cleanup_from_tests() + plugin.cleanup_from_tests() if __name__ == "__main__": diff --git a/tests/setup.py b/tests/setup.py index af4da9507..5810b66f9 100644 --- a/tests/setup.py +++ b/tests/setup.py @@ -17,7 +17,7 @@ setup( install_requires=install_requires, entry_points={ 'console_scripts': [ - 'compatibility-test = compatibility.plugin_test:main', + 'compatibility-test = compatibility.test_driver:main', ], }, ) From 124b99342978d584ef1d6e1655271b6067ff40d6 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 17 Jul 2015 17:33:32 -0700 Subject: [PATCH 18/35] Cleanup --- tests/compatibility/configurators/apache/Dockerfile | 3 --- tests/compatibility/configurators/apache/a2enmod.sh | 4 ++-- tests/compatibility/configurators/apache/apache24.py | 4 ++-- tests/compatibility/test_driver.py | 2 +- 4 files changed, 5 insertions(+), 8 deletions(-) diff --git a/tests/compatibility/configurators/apache/Dockerfile b/tests/compatibility/configurators/apache/Dockerfile index 8cde44ea6..8e7ffd0c0 100644 --- a/tests/compatibility/configurators/apache/Dockerfile +++ b/tests/compatibility/configurators/apache/Dockerfile @@ -1,6 +1,3 @@ -# https://github.com/letsencrypt/letsencrypt/pull/431#issuecomment-103659297 -# it is more likely developers will already have ubuntu:trusty rather -# than e.g. debian:jessie and image size differences are negligible FROM httpd MAINTAINER Brad Warren diff --git a/tests/compatibility/configurators/apache/a2enmod.sh b/tests/compatibility/configurators/apache/a2enmod.sh index c22adb1fb..6c33c0597 100755 --- a/tests/compatibility/configurators/apache/a2enmod.sh +++ b/tests/compatibility/configurators/apache/a2enmod.sh @@ -27,7 +27,7 @@ then enable "ssl" elif [ $2 == "rewrite" ] then - enable "rewrite"; + enable "rewrite" else - exit 1; + exit 1 fi diff --git a/tests/compatibility/configurators/apache/apache24.py b/tests/compatibility/configurators/apache/apache24.py index 48fa6714d..81e9b7b1d 100644 --- a/tests/compatibility/configurators/apache/apache24.py +++ b/tests/compatibility/configurators/apache/apache24.py @@ -10,7 +10,7 @@ from tests.compatibility.configurators.apache import common as apache_common STATIC_MODULES = {"core", "so", "http", "mpm_event", "watchdog",} -INSTALLED_MODULES = { +SHARED_MODULES = { "log_config", "logio", "version", "unixd", "access_compat", "actions", "alias", "allowmethods", "auth_basic", "auth_digest", "auth_form", "authn_anon", "authn_core", "authn_dbd", "authn_dbm", "authn_file", @@ -47,7 +47,7 @@ class Proxy(apache_common.Proxy): with open(self.test_conf, "a") as f: for module in self.modules: if module not in STATIC_MODULES: - if module in INSTALLED_MODULES: + if module in SHARED_MODULES: f.write( "LoadModule {0}_module /usr/local/apache2/modules/" "mod_{0}.so\n".format(module)) diff --git a/tests/compatibility/test_driver.py b/tests/compatibility/test_driver.py index 5e15ce155..b0fca6e4e 100644 --- a/tests/compatibility/test_driver.py +++ b/tests/compatibility/test_driver.py @@ -34,7 +34,7 @@ def get_args(): "-p", "--plugin", default="apache", help="the plugin to be tested") group.add_argument( "-v", "--verbose", dest="verbose_count", action="count", - default=0, help="You know how to use this") + default=0, help="you know how to use this") group.add_argument( "-a", "--auth", action="store_true", help="tests the challenges the plugin supports") From 780d5fcbb990c21f3d36ddb60ba0dde7e6e6de57 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 21 Jul 2015 18:14:57 -0700 Subject: [PATCH 19/35] Finished authenticator tests --- .../letsencrypt_apache/configurator.py | 2 +- tests/MANIFEST.in | 1 + .../configurators/apache/Dockerfile | 4 +- .../configurators/apache/apache24.py | 10 +- .../configurators/apache/common.py | 19 ++- tests/compatibility/configurators/common.py | 39 ++++-- tests/compatibility/test_driver.py | 123 ++++++++++++++++-- tests/compatibility/testdata/rsa1024_key.pem | 15 +++ tests/compatibility/util.py | 6 + 9 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 tests/MANIFEST.in create mode 100644 tests/compatibility/testdata/rsa1024_key.pem diff --git a/letsencrypt-apache/letsencrypt_apache/configurator.py b/letsencrypt-apache/letsencrypt_apache/configurator.py index c8083b406..b8af26923 100644 --- a/letsencrypt-apache/letsencrypt_apache/configurator.py +++ b/letsencrypt-apache/letsencrypt_apache/configurator.py @@ -935,7 +935,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator): subprocess.check_call([self.conf("enmod"), mod_name], stdout=open("/dev/null", "w"), stderr=open("/dev/null", "w")) - apache_restart(self.conf("init")) + apache_restart(self.conf("init-script")) except (OSError, subprocess.CalledProcessError): logger.exception("Error enabling mod_%s", mod_name) raise errors.MisconfigurationError( diff --git a/tests/MANIFEST.in b/tests/MANIFEST.in new file mode 100644 index 000000000..7d73674fb --- /dev/null +++ b/tests/MANIFEST.in @@ -0,0 +1 @@ +include compatibility/testdata/rsa1024_key.pem diff --git a/tests/compatibility/configurators/apache/Dockerfile b/tests/compatibility/configurators/apache/Dockerfile index 8e7ffd0c0..092d84ec8 100644 --- a/tests/compatibility/configurators/apache/Dockerfile +++ b/tests/compatibility/configurators/apache/Dockerfile @@ -12,4 +12,6 @@ ENV APACHE_RUN_USER=daemon \ COPY tests/compatibility/configurators/apache/a2enmod.sh /usr/local/bin/ -CMD [ "httpd-foreground" ] +# Note: this only exposes the port to other docker containers. You +# still have to bind to 443@host at runtime. +EXPOSE 443 diff --git a/tests/compatibility/configurators/apache/apache24.py b/tests/compatibility/configurators/apache/apache24.py index 81e9b7b1d..44150e7fe 100644 --- a/tests/compatibility/configurators/apache/apache24.py +++ b/tests/compatibility/configurators/apache/apache24.py @@ -1,5 +1,9 @@ """Proxies ApacheConfigurator for Apache 2.4 tests""" + +import zope.interface + from tests.compatibility import errors +from tests.compatibility import interfaces from tests.compatibility.configurators.apache import common as apache_common @@ -33,10 +37,14 @@ SHARED_MODULES = { class Proxy(apache_common.Proxy): """Wraps the ApacheConfigurator for Apache 2.4 tests""" + zope.interface.implements(interfaces.IConfiguratorProxy) + def __init__(self, args): """Initializes the plugin with the given command line args""" super(Proxy, self).__init__(args) - self.start_docker("bradmw/apache2.4") + # Running init isn't ideal, but the Docker container needs to survive + # Apache restarts + self.start_docker("bradmw/apache2.4", "init") def preprocess_config(self, server_root): """Prepares the configuration for use in the Docker""" diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index cf918fa9c..99d78904a 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -48,6 +48,14 @@ class Proxy(configurators_common.Proxy): def load_config(self): """Loads the next configuration for the plugin to test""" + if hasattr(self.le_config, "apache_init_script"): + try: + self.check_call_in_docker( + [self.le_config.apache_init_script, "stop"]) + except errors.Error: + raise errors.Error( + "Failed to stop previous apache config from running") + config = super(Proxy, self).load_config() self.modules = _get_modules(config) self.version = _get_version(config) @@ -63,7 +71,7 @@ class Proxy(configurators_common.Proxy): try: self.check_call_in_docker( - "apachectl -d {0} -f {1} -k restart".format( + "apachectl -d {0} -f {1} -k start".format( server_root, config_file)) except errors.Error: raise errors.Error( @@ -93,7 +101,7 @@ class Proxy(configurators_common.Proxy): self.le_config.apache_ctl = "apachectl -d {0} -f {1}".format( server_root, config_file) self.le_config.apache_enmod = "a2enmod.sh {0}".format(server_root) - self.le_config.apache_init = self.le_config.apache_ctl + " -k" + self.le_config.apache_init_script = self.le_config.apache_ctl + " -k" self._apache_configurator = configurator.ApacheConfigurator( config=configuration.NamespaceConfig(self.le_config), @@ -119,6 +127,13 @@ class Proxy(configurators_common.Proxy): else: raise errors.Error("No configuration file loaded") + def deploy_cert(self, domain, cert_path, key_path, chain_path=None): + """Installs cert""" + cert_path, key_path, chain_path = self.copy_certs_and_keys( + cert_path, key_path, chain_path) + self._apache_configurator.deploy_cert( + domain, cert_path, key_path, chain_path) + def _create_test_conf(server_root, apache_config): """Creates a test config file and adds it to the Apache config""" diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index 689f1d4a4..549b2f272 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -1,6 +1,7 @@ """Provides a common base for configurator proxies""" import logging import os +import shutil import tempfile import threading @@ -34,10 +35,12 @@ class Proxy(object): def __init__(self, args): """Initializes the plugin with the given command line args""" - temp_dir = tempfile.mkdtemp() - self.le_config = util.create_le_config(temp_dir) - self._config_dir = util.extract_configs(args.configs, temp_dir) - self._configs = os.listdir(self._config_dir) + self._temp_dir = tempfile.mkdtemp() + self.le_config = util.create_le_config(self._temp_dir) + config_dir = util.extract_configs(args.configs, self._temp_dir) + self._configs = [ + os.path.join(config_dir, config) + for config in os.listdir(config_dir)] self.args = args self._docker_client = docker.Client( @@ -58,21 +61,22 @@ class Proxy(object): def load_config(self): """Returns the next config directory to be tested""" - return os.path.join(self._config_dir, self._configs.pop()) + return self._configs.pop() - def start_docker(self, image_name): + def start_docker(self, image_name, command): """Creates and runs a Docker container with the specified image""" + logger.info("Pulling Docker image. This may take a minute.") for line in self._docker_client.pull(image_name, stream=True): logger.debug(line) host_config = docker.utils.create_host_config( binds={ - self._config_dir : {"bind" : self._config_dir, "mode" : "rw"}}, + self._temp_dir : {"bind" : self._temp_dir, "mode" : "rw"}}, port_bindings={ 80 : ("127.0.0.1", self.http_port), 443 : ("127.0.0.1", self.https_port)},) container = self._docker_client.create_container( - image_name, ports=[80, 443], volumes=self._config_dir, + image_name, command, ports=[80, 443], volumes=self._temp_dir, host_config=host_config) if container["Warnings"]: logger.warning(container["Warnings"]) @@ -123,8 +127,25 @@ class Proxy(object): def execute_in_docker(self, command): """Executes command inside the running docker image""" - logger.info("Executing '%s'", command) + logger.debug("Executing '%s'", command) exec_id = self._docker_client.exec_create(self._container_id, command) output = self._docker_client.exec_start(exec_id) returncode = self._docker_client.exec_inspect(exec_id)["ExitCode"] return returncode, output + + def copy_certs_and_keys(self, cert_path, key_path, chain_path=None): + """Copies certs and keys into the temporary directory""" + cert_and_key_dir = os.path.join(self._temp_dir, "certs_and_keys") + os.mkdir(cert_and_key_dir) + + cert = os.path.join(cert_and_key_dir, "cert") + shutil.copy(cert_path, cert) + key = os.path.join(cert_and_key_dir, "key") + shutil.copy(key_path, key) + if chain_path: + chain = os.path.join(cert_and_key_dir, "chain") + shutil.copy(chain_path, chain) + else: + chain = None + + return cert, key, chain diff --git a/tests/compatibility/test_driver.py b/tests/compatibility/test_driver.py index b0fca6e4e..da49868b1 100644 --- a/tests/compatibility/test_driver.py +++ b/tests/compatibility/test_driver.py @@ -1,8 +1,20 @@ """Tests Let's Encrypt plugins against different server configurations.""" import argparse +import filecmp import logging +import os +import shutil +import tempfile +import OpenSSL + +from acme import challenges +from acme import crypto_util +from acme import messages +from letsencrypt import achallenges +from letsencrypt.tests import acme_util from tests.compatibility import errors +from tests.compatibility import util from tests.compatibility.configurators.apache import apache24 @@ -20,6 +32,93 @@ PLUGINS = {"apache" : apache24.Proxy} logger = logging.getLogger(__name__) +def test_authenticator(plugin, config, temp_dir): + """Tests plugin as an authenticator""" + backup = os.path.join(temp_dir, "backup") + shutil.copytree(config, backup, symlinks=True) + + achalls = _create_achalls(plugin) + if achalls: + try: + responses = plugin.perform(achalls) + for i in xrange(len(responses)): + if not responses[i]: + raise errors.Error( + "Plugin returned 'None' or 'False' response to " + "challenge") + elif isinstance(responses[i], challenges.DVSNIResponse): + if responses[i].simple_verify(achalls[i], + achalls[i].domain, + util.JWK.key.public_key(), + host="127.0.0.1", + port=plugin.https_port): + logger.info( + "Verification of DVSNI response for %s succeeded", + achalls[i].domain) + else: + raise errors.Error( + "Verification of DVSNI response for {0} " + "failed".format(achalls[i].domain)) + finally: + plugin.cleanup(achalls) + + if _dirs_are_unequal(config, backup): + raise errors.Error("Challenge cleanup failed") + else: + logger.info("Challenge cleanup succeeded") + + +def _create_achalls(plugin): + """Returns a list of annotated challenges to test on plugin""" + achalls = list() + names = plugin.get_testable_domain_names() + for domain in names: + prefs = plugin.get_chall_pref(domain) + for chall_type in prefs: + if chall_type == challenges.DVSNI: + chall = challenges.DVSNI( + r=os.urandom(challenges.DVSNI.R_SIZE), + nonce=os.urandom(challenges.DVSNI.NONCE_SIZE)) + challb = acme_util.chall_to_challb( + chall, messages.STATUS_PENDING) + achall = achallenges.DVSNI( + challb=challb, domain=domain, key=util.JWK) + achalls.append(achall) + + return achalls + + +def test_installer(plugin, config, temp_dir): + """Tests plugin as an installer""" + backup = os.path.join(temp_dir, "backup") + shutil.copytree(config, backup, symlinks=True) + + if plugin.get_all_names() != plugin.get_all_names_answer(): + raise errors.Error("get_all_names test failed") + else: + logging.info("get_all_names test succeeded") + + domains = list(plugin.get_testable_domain_names()) + cert = crypto_util.gen_ss_cert(util.KEY, domains) + cert_path = os.path.join(temp_dir, "cert.pem") + with open(cert_path, "w") as f: + f.write(OpenSSL.crypto.dump_certificate( + OpenSSL.crypto.FILETYPE_PEM, cert)) + + for domain in domains: + plugin.deploy_cert(domain, cert_path, util.KEY_PATH) + plugin.save() + plugin.restart() + + +def _dirs_are_unequal(dir1, dir2): + """Returns True if dir1 and dir2 are equal""" + dircmp = filecmp.dircmp(dir1, dir2) + + return (dircmp.left_only or dircmp.right_only or + dircmp.diff_files or dircmp.funny_files) + + def get_args(): """Returns parsed command line arguments.""" parser = argparse.ArgumentParser( @@ -62,17 +161,10 @@ def setup_logging(args): handler = logging.StreamHandler() root_logger = logging.getLogger() - root_logger.setLevel(logging.WARNING - args.verbose_count * 10) + root_logger.setLevel(logging.INFO - args.verbose_count * 10) root_logger.addHandler(handler) -def test_installer(plugin): - """Tests plugin as an installer""" - if plugin.get_all_names() != plugin.get_all_names_answer(): - raise errors.Error( - "Names found by plugin don't match names found by the wrapper") - - def main(): """Main test script execution.""" args = get_args() @@ -81,17 +173,20 @@ def main(): if args.plugin not in PLUGINS: raise errors.Error("Unknown plugin {0}".format(args.plugin)) + temp_dir = tempfile.mkdtemp() plugin = PLUGINS[args.plugin](args) try: + plugin.execute_in_docker("mkdir -p /var/log/apache2") while plugin.has_more_configs(): try: - print "Loaded configuration: {0}".format(plugin.load_config()) - - if args.install: - test_installer(plugin) + config = plugin.load_config() + logger.info("Loaded configuration: %s", config) + if args.auth: + test_authenticator(plugin, config, temp_dir) + #if args.install: + #test_installer(plugin, temp_dir) except errors.Error as error: - print "Test failed" - print error + logger.warning("Test failed: %s", error) finally: plugin.cleanup_from_tests() diff --git a/tests/compatibility/testdata/rsa1024_key.pem b/tests/compatibility/testdata/rsa1024_key.pem new file mode 100644 index 000000000..8f82146ba --- /dev/null +++ b/tests/compatibility/testdata/rsa1024_key.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQCsREbM+UcfsgDy2w56AVGyxsO0HVsbEZHHoEzv7qksIwFgRYMp +rowwIxD450RQQqjvw9IoXlMVmr1t5szn5KXn9JRO9T5KNCCy3VPx75WBcp6kzd9Q +2HS1OEOtpilNnDkZ+TJfdgFWPUBYj2o4Md1hPmcvagiIJY5U6speka2bjwIDAQAB +AoGANCMZ9pF/mDUsmlP4Rq69hkkoFAxKdZ/UqkF256so4mXZ1cRUFTpxzWPfkCWW +hGAYdzCiG3uo08IYkPmojIqkN1dk5Hcq5eQAmshaPkQHQCHjmPjjcNvgjIXQoGUf +TpDU2hbY4UAlJlj4ZLh+jGP5Zq8/WrNi8RsI3v9Nagfp/FECQQDgi2q8p1gX0TNh +d1aEKmSXkR3bxkyFk6oS+pBrAG3+yX27ZayN6Rx6DOs/FcBsOu7fX3PYBziDeEWe +Lkf1P743AkEAxGYT/LY3puglSz4iJZZzWmRCrVOg41yhfQ+F1BRX43/2vtoU5GyM +2lUn1vQ2e/rfmnAvfJxc90GeZCIHB1ihaQJBALH8UMLxMtbOMJgVbDKfF9U8ZhqK ++KT5A1q/2jG2yXmoZU1hroFeQgBMtTvwFfK0VBwjIUQflSBA+Y4EyW0Q9ckCQGvd +jHitM1+N/H2YwHRYbz5j9mLvnVuCEod3MQ9LpQGj1Eb5y6OxIqL/RgQ+2HW7UXem +yc3sqvp5pZ5lOesE+JECQETPI64gqxlTIs3nErNMpMynUuTWpaElOcIJTT6icLzB +Xix67kKXjROO5D58GEYkM0Yi5k7YdUPoQBW7MoIrSIA= +-----END RSA PRIVATE KEY----- diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index c217bdfa9..0ba6781ef 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -8,10 +8,16 @@ import shutil import socket import tarfile +from acme import jose +from acme import test_util from letsencrypt import constants from tests.compatibility import errors +_KEY_BASE = "rsa1024_key.pem" +KEY_PATH = test_util.vector_path(_KEY_BASE) +KEY = test_util.load_pyopenssl_private_key(_KEY_BASE) +JWK = jose.JWKRSA(key=test_util.load_rsa_private_key(_KEY_BASE)) IP_REGEX = re.compile(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$") From c927f0c89a4d403a0decc5adfc15d30ac21dc9f4 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 22 Jul 2015 18:25:09 -0700 Subject: [PATCH 20/35] Finished basic Apache test framework --- acme/acme/challenges.py | 2 +- acme/acme/challenges_test.py | 2 +- acme/acme/crypto_util.py | 4 +- acme/acme/crypto_util_test.py | 6 +- letsencrypt/interfaces.py | 51 +++++- letsencrypt/tests/validator_test.py | 57 ++++--- letsencrypt/validator.py | 61 ++++---- .../configurators/apache/common.py | 13 +- tests/compatibility/configurators/common.py | 7 +- tests/compatibility/test_driver.py | 147 +++++++++++++----- 10 files changed, 241 insertions(+), 109 deletions(-) diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 89bc68966..723c51317 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -207,7 +207,7 @@ class DVSNI(DVChallenge): kwargs["name"] = self.nonce_domain # TODO: try different methods? # pylint: disable=protected-access - return crypto_util._probe_sni(**kwargs) + return crypto_util.probe_sni(**kwargs) @ChallengeResponse.register diff --git a/acme/acme/challenges_test.py b/acme/acme/challenges_test.py index e4ec37362..68492fbea 100644 --- a/acme/acme/challenges_test.py +++ b/acme/acme/challenges_test.py @@ -179,7 +179,7 @@ class DVSNITest(unittest.TestCase): jose.DeserializationError, DVSNI.from_json, self.jmsg) @mock.patch('acme.challenges.socket.gethostbyname') - @mock.patch('acme.challenges.crypto_util._probe_sni') + @mock.patch('acme.challenges.crypto_util.probe_sni') def test_probe_cert(self, mock_probe_sni, mock_gethostbyname): mock_gethostbyname.return_value = '127.0.0.1' self.msg.probe_cert('foo.com') diff --git a/acme/acme/crypto_util.py b/acme/acme/crypto_util.py index cb796cb88..624d371e1 100644 --- a/acme/acme/crypto_util.py +++ b/acme/acme/crypto_util.py @@ -69,8 +69,8 @@ def _serve_sni(certs, sock, reuseaddr=True, method=_DEFAULT_DVSNI_SSL_METHOD, raise errors.Error(error) -def _probe_sni(name, host, port=443, timeout=300, - method=_DEFAULT_DVSNI_SSL_METHOD, source_address=('0', 0)): +def probe_sni(name, host, port=443, timeout=300, + method=_DEFAULT_DVSNI_SSL_METHOD, source_address=('0', 0)): """Probe SNI server for SSL certificate. :param bytes name: Byte string to send as the server name in the diff --git a/acme/acme/crypto_util_test.py b/acme/acme/crypto_util_test.py index 10d62fbf5..49aacfa1b 100644 --- a/acme/acme/crypto_util_test.py +++ b/acme/acme/crypto_util_test.py @@ -13,7 +13,7 @@ from acme import test_util class ServeProbeSNITest(unittest.TestCase): - """Tests for acme.crypto_util._serve_sni/_probe_sni.""" + """Tests for acme.crypto_util._serve_sni/probe_sni.""" def setUp(self): self.cert = test_util.load_cert('cert.pem') @@ -45,8 +45,8 @@ class ServeProbeSNITest(unittest.TestCase): self.server.join() def _probe(self, name): - from acme.crypto_util import _probe_sni - return jose.ComparableX509(_probe_sni( + from acme.crypto_util import probe_sni + return jose.ComparableX509(probe_sni( name, host='127.0.0.1', port=self.port)) def test_probe_ok(self): diff --git a/letsencrypt/interfaces.py b/letsencrypt/interfaces.py index 54f0dc92b..52f23ab88 100644 --- a/letsencrypt/interfaces.py +++ b/letsencrypt/interfaces.py @@ -384,17 +384,52 @@ class IDisplay(zope.interface.Interface): class IValidator(zope.interface.Interface): """Configuration validator.""" - def redirect(hostname, port=80, headers=None): - """Verify redirect to HTTPS.""" + def certificate(cert, name, alt_host=None, port=443): + """Verifies the certificate presented at name is cert - def https(hostname, port=443, headers=None): - """Verify HTTPS is enabled for domain.""" + :param OpenSSL.crypto.X509 cert: Expected certificate + :param str name: Server's domain name + :param bytes alt_host: Host to connect to instead of the IP + address of host + :param int port: Port to connect to - def hsts(hostname): - """Verify HSTS header is enabled.""" + :returns: True if the certificate was verified successfully + :rtype: bool - def ocsp_stapling(hostname): - """Verify ocsp stapling for domain.""" + """ + + def redirect(name, port=80, headers=None): + """Verify redirect to HTTPS + + :param str name: Server's domain name + :param int port: Port to connect to + :param dict headers: HTTP headers to include in request + + :returns: True if redirect is successfully enabled + :rtype: bool + + """ + + + def hsts(name): + """Verify HSTS header is enabled + + :param str name: Server's domain name + + :returns: True if HSTS header is successfully enabled + :rtype: bool + + """ + + def ocsp_stapling(name): + """Verify ocsp stapling for domain + + :param str name: Server's domain name + + :returns: True if ocsp stapling is successfully enabled + :rtype: bool + + """ class IReporter(zope.interface.Interface): diff --git a/letsencrypt/tests/validator_test.py b/letsencrypt/tests/validator_test.py index c9cb19ec2..c02a7d865 100644 --- a/letsencrypt/tests/validator_test.py +++ b/letsencrypt/tests/validator_test.py @@ -3,8 +3,9 @@ import requests import unittest import mock +import OpenSSL -from letsencrypt import errors +from acme import errors as acme_errors from letsencrypt import validator @@ -12,12 +13,41 @@ class ValidatorTest(unittest.TestCase): def setUp(self): self.validator = validator.Validator() + @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + def test_certificate_success(self, mock_probe_sni): + cert = OpenSSL.crypto.X509() + mock_probe_sni.return_value = cert + self.assertTrue(self.validator.certificate( + cert, "test.com", "127.0.0.1")) + + @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + def test_certificate_error(self, mock_probe_sni): + cert = OpenSSL.crypto.X509() + mock_probe_sni.side_effect = [acme_errors.Error] + self.assertFalse(self.validator.certificate( + cert, "test.com", "127.0.0.1")) + + @mock.patch("letsencrypt.validator.crypto_util.probe_sni") + def test_certificate_failure(self, mock_probe_sni): + cert = OpenSSL.crypto.X509() + cert.set_serial_number(1337) + mock_probe_sni.return_value = OpenSSL.crypto.X509() + self.assertFalse(self.validator.certificate( + cert, "test.com", "127.0.0.1")) + @mock.patch("letsencrypt.validator.requests.get") def test_succesful_redirect(self, mock_get_request): mock_get_request.return_value = create_response( 301, {"location" : "https://test.com"}) self.assertTrue(self.validator.redirect("test.com")) + @mock.patch("letsencrypt.validator.requests.get") + def test_redirect_with_headers(self, mock_get_request): + mock_get_request.return_value = create_response( + 301, {"location" : "https://test.com"}) + self.assertTrue(self.validator.redirect( + "test.com", headers={"Host" : "test.com"})) + @mock.patch("letsencrypt.validator.requests.get") def test_redirect_missing_location(self, mock_get_request): mock_get_request.return_value = create_response(301) @@ -33,19 +63,7 @@ class ValidatorTest(unittest.TestCase): def test_redirect_wrong_redirect_code(self, mock_get_request): mock_get_request.return_value = create_response( 303, {"location" : "https://test.com"}) - self.assertRaises( - errors.ValidationError, self.validator.redirect, "test.com") - - @mock.patch("letsencrypt.validator.requests.get") - def test_https_fail(self, mock_get_request): - mock_get_request.side_effect = [requests.exceptions.ConnectionError] - self.assertRaises( - requests.exceptions.ConnectionError, self.validator.https, "test.com") - - def test_https_success(self): - with mock.patch("letsencrypt.validator.requests.get"): - self.assertTrue(self.validator.https( - "test.com", headers={"Host" : "test.com"})) + self.assertFalse(self.validator.redirect("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_hsts_empty(self, mock_get_request): @@ -57,22 +75,19 @@ class ValidatorTest(unittest.TestCase): def test_hsts_malformed(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "sdfal"}) - self.assertRaises( - errors.ValidationError, self.validator.hsts, "test.com") + self.assertFalse(self.validator.hsts("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_hsts_bad_max_age(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "max-age=not-an-int"}) - self.assertRaises( - errors.ValidationError, self.validator.hsts, "test.com") + self.assertFalse(self.validator.hsts("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_hsts_expire(self, mock_get_request): mock_get_request.return_value = create_response( headers={"strict-transport-security": "max-age=3600"}) - self.assertRaises( - errors.ValidationError, self.validator.hsts, "test.com") + self.assertFalse(self.validator.hsts("test.com")) @mock.patch("letsencrypt.validator.requests.get") def test_hsts(self, mock_get_request): @@ -103,4 +118,4 @@ def create_response(status_code=200, headers=None): if __name__ == '__main__': - unittest.main() + unittest.main() # pragma: no cover diff --git a/letsencrypt/validator.py b/letsencrypt/validator.py index 6ef88d566..e5386f290 100644 --- a/letsencrypt/validator.py +++ b/letsencrypt/validator.py @@ -1,19 +1,40 @@ """Validators to determine the current webserver configuration""" +import logging +import socket import requests import zope.interface -from letsencrypt import errors +from acme import crypto_util +from acme import errors as acme_errors from letsencrypt import interfaces +logger = logging.getLogger(__name__) + + class Validator(object): # pylint: disable=no-self-use """Collection of functions to test a live webserver's configuration""" zope.interface.implements(interfaces.IValidator) - def redirect(self, hostname, port=80, headers=None): + def certificate(self, cert, name, alt_host=None, port=443): + """Verifies the certificate presented at name is cert""" + host = alt_host if alt_host else socket.gethostbyname(name) + try: + presented_cert = crypto_util.probe_sni(name, host, port) + except acme_errors.Error as error: + logger.exception(error) + return False + + return presented_cert.digest("sha256") == cert.digest("sha256") + + def redirect(self, name, port=80, headers=None): """Test whether webserver redirects to secure connection.""" - response = _get("http", hostname, port, headers) + url = "http://{0}:{1}".format(name, port) + if headers: + response = requests.get(url, headers=headers, allow_redirects=False) + else: + response = requests.get(url, allow_redirects=False) if response.status_code not in (301, 303): return False @@ -23,19 +44,14 @@ class Validator(object): return False if response.status_code != 301: - error_msg = "Server did not redirect with permanent code." - raise errors.ValidationError(error_msg) + logger.error("Server did not redirect with permanent code") + return False return True - def https(self, hostname, port=443, headers=None): - """Test whether webserver supports HTTPS""" - _get("https", hostname, port, headers) - return True - - def hsts(self, hostname): + def hsts(self, name): """Test for HTTP Strict Transport Security header""" - headers = requests.get("https://" + hostname).headers + headers = requests.get("https://" + name).headers hsts_header = headers.get("strict-transport-security") if not hsts_header: @@ -46,32 +62,23 @@ class Validator(object): max_age = [d for d in directives if d[0] == "max-age"] if not max_age: - error_msg = "Server responded with invalid HSTS header field." - raise errors.ValidationError(error_msg) + logger.error("Server responded with invalid HSTS header field") + return False try: _, max_age_value = max_age[0] max_age_value = int(max_age_value) except ValueError: - error_msg = "Server responded with invalid HSTS header field." - raise errors.ValidationError(error_msg) + logger.error("Server responded with invalid HSTS header field") + return False # Test whether HSTS does not expire for at least two weeks. if max_age_value <= (2 * 7 * 24 * 3600): - error_msg = "HSTS should not expire in less than two weeks." - raise errors.ValidationError(error_msg) + logger.error("HSTS should not expire in less than two weeks") + return False return True def ocsp_stapling(self, name): """Verify ocsp stapling for domain.""" raise NotImplementedError() - - -def _get(scheme, hostname, port, headers, **kwargs): - """Makes a GET request for specified resource""" - url = "{0}://{1}:{2}".format(scheme, hostname, port) - if headers: - return requests.get(url, headers=headers, **kwargs) - else: - return requests.get(url, **kwargs) diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index 99d78904a..a3b7ddd95 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -113,13 +113,6 @@ class Proxy(configurators_common.Proxy): super(Proxy, self).cleanup_from_tests() self._patch.stop() - def get_testable_domain_names(self): - """Returns the set of domain names that can be tested against""" - if self._test_names: - return self._test_names - else: - raise errors.Error("No configuration file loaded") - def get_all_names_answer(self): """Returns the set of domain names that the plugin should find""" if self._all_names: @@ -127,6 +120,12 @@ class Proxy(configurators_common.Proxy): else: raise errors.Error("No configuration file loaded") + def get_testable_domain_names(self): + """Returns the set of domain names that can be tested against""" + if self._test_names: + return self._test_names + else: + return {"example.com"} def deploy_cert(self, domain, cert_path, key_path, chain_path=None): """Installs cert""" cert_path, key_path, chain_path = self.copy_certs_and_keys( diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index 549b2f272..e517d71e7 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -7,6 +7,7 @@ import threading import docker +from letsencrypt import constants from tests.compatibility import errors from tests.compatibility import util @@ -61,6 +62,9 @@ class Proxy(object): def load_config(self): """Returns the next config directory to be tested""" + shutil.rmtree(self.le_config.work_dir, ignore_errors=True) + backup = os.path.join(self.le_config.work_dir, constants.BACKUP_DIR) + os.makedirs(backup) return self._configs.pop() def start_docker(self, image_name, command): @@ -136,7 +140,8 @@ class Proxy(object): def copy_certs_and_keys(self, cert_path, key_path, chain_path=None): """Copies certs and keys into the temporary directory""" cert_and_key_dir = os.path.join(self._temp_dir, "certs_and_keys") - os.mkdir(cert_and_key_dir) + if not os.path.isdir(cert_and_key_dir): + os.mkdir(cert_and_key_dir) cert = os.path.join(cert_and_key_dir, "cert") shutil.copy(cert_path, cert) diff --git a/tests/compatibility/test_driver.py b/tests/compatibility/test_driver.py index da49868b1..65ad36f18 100644 --- a/tests/compatibility/test_driver.py +++ b/tests/compatibility/test_driver.py @@ -1,6 +1,7 @@ """Tests Let's Encrypt plugins against different server configurations.""" import argparse import filecmp +import functools import logging import os import shutil @@ -12,6 +13,8 @@ from acme import challenges from acme import crypto_util from acme import messages from letsencrypt import achallenges +from letsencrypt import errors as le_errors +from letsencrypt import validator from letsencrypt.tests import acme_util from tests.compatibility import errors from tests.compatibility import util @@ -34,36 +37,41 @@ logger = logging.getLogger(__name__) def test_authenticator(plugin, config, temp_dir): """Tests plugin as an authenticator""" - backup = os.path.join(temp_dir, "backup") - shutil.copytree(config, backup, symlinks=True) + backup = _create_backup(config, temp_dir) achalls = _create_achalls(plugin) - if achalls: - try: - responses = plugin.perform(achalls) - for i in xrange(len(responses)): - if not responses[i]: - raise errors.Error( - "Plugin returned 'None' or 'False' response to " - "challenge") - elif isinstance(responses[i], challenges.DVSNIResponse): - if responses[i].simple_verify(achalls[i], - achalls[i].domain, - util.JWK.key.public_key(), - host="127.0.0.1", - port=plugin.https_port): - logger.info( - "Verification of DVSNI response for %s succeeded", - achalls[i].domain) - else: - raise errors.Error( - "Verification of DVSNI response for {0} " - "failed".format(achalls[i].domain)) - finally: - plugin.cleanup(achalls) + if not achalls: + return + + try: + responses = plugin.perform(achalls) + for i in xrange(len(responses)): + if not responses[i]: + logger.error( + "Plugin returned `None` or `False` response to challenge " + "for config `%s`", config) + elif isinstance(responses[i], challenges.DVSNIResponse): + if responses[i].simple_verify(achalls[i], + achalls[i].domain, + util.JWK.key.public_key(), + host="127.0.0.1", + port=plugin.https_port): + logger.info( + "Verification of DVSNI response for %s succeeded", + achalls[i].domain) + else: + logger.error( + "Verification of DVSNI response for %s in config '%s' " + "failed ", achalls[i].domain, config) + except le_errors.Error as error: + logger.info( + "Plugin raised %s during authentication with config '%s'", + error, config) + finally: + plugin.cleanup(achalls) if _dirs_are_unequal(config, backup): - raise errors.Error("Challenge cleanup failed") + logger.error("Challenge cleanup failed for config %s", config) else: logger.info("Challenge cleanup succeeded") @@ -88,17 +96,24 @@ def _create_achalls(plugin): return achalls -def test_installer(plugin, config, temp_dir): +def test_installer(args, plugin, config, temp_dir): """Tests plugin as an installer""" - backup = os.path.join(temp_dir, "backup") - shutil.copytree(config, backup, symlinks=True) + backup = _create_backup(config, temp_dir) - if plugin.get_all_names() != plugin.get_all_names_answer(): - raise errors.Error("get_all_names test failed") + if plugin.get_all_names().issubset(plugin.get_all_names_answer()): + logger.info("get_all_names test succeeded") else: - logging.info("get_all_names test succeeded") + logger.error("get_all_names test failed for config `%s`", config) domains = list(plugin.get_testable_domain_names()) + if test_deploy_cert(plugin, temp_dir, domains) and args.enhance: + test_enhancements(plugin, domains) + + test_rollback(plugin, config, backup) + + +def test_deploy_cert(plugin, temp_dir, domains): + """Tests deploy_cert returning True if the tests are successful""" cert = crypto_util.gen_ss_cert(util.KEY, domains) cert_path = os.path.join(temp_dir, "cert.pem") with open(cert_path, "w") as f: @@ -107,9 +122,65 @@ def test_installer(plugin, config, temp_dir): for domain in domains: plugin.deploy_cert(domain, cert_path, util.KEY_PATH) - plugin.save() + plugin.save("deployed") plugin.restart() + verify_cert = validator.Validator().certificate + success = True + for domain in domains: + if not verify_cert(cert, domain, "127.0.0.1", plugin.https_port): + logger.error("Could not verify certificate for domain %s", domain) + success = False + + if success: + logger.info("HTTPS validation succeeded") + + return success + + +def test_enhancements(plugin, domains): + """Tests enhancements supported by the plugin""" + supported = plugin.supported_enhancements() + + if "redirect" not in supported: + return + + for domain in domains: + plugin.enhance(domain, "redirect") + + plugin.save("enhanced") + plugin.restart() + + verify_redirect = functools.partial( + validator.Validator().redirect, "localhost", plugin.http_port) + success = True + for domain in domains: + if not verify_redirect(headers={"Host" : domain}): + logger.error("Improper redirect for domain %s", domain) + success = False + + if success: + logger.info("Enhancments test succeeded") + + +def test_rollback(plugin, config, backup): + """Tests the rollback checkpoints function""" + plugin.rollback_checkpoints(2) + + if _dirs_are_unequal(config, backup): + logger.error("Rollback failed for config `%s`", config) + else: + logger.info("Rollback succeeded") + + +def _create_backup(config, temp_dir): + """Creates a backup of config in temp_dir""" + backup = os.path.join(temp_dir, "backup") + shutil.rmtree(backup, ignore_errors=True) + shutil.copytree(config, backup, symlinks=True) + + return backup + def _dirs_are_unequal(dir1, dir2): """Returns True if dir1 and dir2 are equal""" @@ -151,7 +222,7 @@ def get_args(): if args.enhance: args.install = True elif not (args.auth or args.install): - args.auth = args.install = args.redirect = True + args.auth = args.install = args.enhance = True return args @@ -161,7 +232,7 @@ def setup_logging(args): handler = logging.StreamHandler() root_logger = logging.getLogger() - root_logger.setLevel(logging.INFO - args.verbose_count * 10) + root_logger.setLevel(logging.WARNING - args.verbose_count * 10) root_logger.addHandler(handler) @@ -183,10 +254,10 @@ def main(): logger.info("Loaded configuration: %s", config) if args.auth: test_authenticator(plugin, config, temp_dir) - #if args.install: - #test_installer(plugin, temp_dir) + if args.install: + test_installer(args, plugin, config, temp_dir) except errors.Error as error: - logger.warning("Test failed: %s", error) + logger.error("Tests on config `%s` raised: %s", config, error) finally: plugin.cleanup_from_tests() From 9ccd46c268e948ab3c8bd0f75b860b111ecfa2e3 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 22 Jul 2015 18:31:26 -0700 Subject: [PATCH 21/35] Cleaned up interfaces --- tests/compatibility/interfaces.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/compatibility/interfaces.py b/tests/compatibility/interfaces.py index a4925014f..fde1f2d45 100644 --- a/tests/compatibility/interfaces.py +++ b/tests/compatibility/interfaces.py @@ -3,26 +3,28 @@ import zope.interface import letsencrypt.interfaces +# pylint: disable=no-self-argument,no-method-argument + class IPluginProxy(zope.interface.Interface): """Wraps a Let's Encrypt plugin""" - def add_parser_arguments(cls, parser): # pylint: disable=no-self-argument + def add_parser_arguments(cls, parser): """Adds command line arguments needed by the parser""" - def __init__(self, args): + def __init__(args): """Initializes the plugin with the given command line args""" - def cleanup_from_tests(self): + def cleanup_from_tests(): """Performs any necessary cleanup from running plugin tests. This is guarenteed to be called before the program exits. """ - def has_more_configs(self): + def has_more_configs(): """Returns True if there are more configs to test""" - def load_config(self): + def load_config(): """Loads the next config and returns its name""" @@ -34,7 +36,7 @@ class IConfiguratorBaseProxy(IPluginProxy): https_port = zope.interface.Attribute( "The port to connect to on localhost for HTTPS traffic") - def get_testable_domain_names(self): + def get_testable_domain_names(): """Returns the domain names that can be used in testing""" @@ -47,7 +49,7 @@ class IInstallerProxy( IConfiguratorBaseProxy, letsencrypt.interfaces.IInstaller): """Wraps a Let's Encrypt installer""" - def get_all_names_answer(self): + def get_all_names_answer(): """Returns all names that should be found by the installer""" From 71d12f005dda99e43c954f000bd8301f11175324 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 22 Jul 2015 18:44:43 -0700 Subject: [PATCH 22/35] Removed ValidationError --- letsencrypt/errors.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/letsencrypt/errors.py b/letsencrypt/errors.py index b7f050a92..e1cae19c7 100644 --- a/letsencrypt/errors.py +++ b/letsencrypt/errors.py @@ -68,6 +68,3 @@ class MisconfigurationError(PluginError): class RevokerError(Error): """Let's Encrypt Revoker error.""" - -class ValidationError(Error): - """Let's Encrypt Validation error.""" From 1a1ce7edcfbaff174fb29ba882c9a3cc14589a3d Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 22 Jul 2015 19:02:53 -0700 Subject: [PATCH 23/35] Raised logging level for Docker pull message --- tests/compatibility/configurators/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index e517d71e7..f7385b8bb 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -69,7 +69,7 @@ class Proxy(object): def start_docker(self, image_name, command): """Creates and runs a Docker container with the specified image""" - logger.info("Pulling Docker image. This may take a minute.") + logger.warning("Pulling Docker image. This may take a minute.") for line in self._docker_client.pull(image_name, stream=True): logger.debug(line) From d2e2baa92738812efdb1899ad95f5eff5a48099a Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Wed, 22 Jul 2015 19:51:05 -0700 Subject: [PATCH 24/35] Cleanup --- tests/compatibility/configurators/apache/common.py | 1 + tests/compatibility/util.py | 1 + 2 files changed, 2 insertions(+) diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index a3b7ddd95..036654844 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -126,6 +126,7 @@ class Proxy(configurators_common.Proxy): return self._test_names else: return {"example.com"} + def deploy_cert(self, domain, cert_path, key_path, chain_path=None): """Installs cert""" cert_path, key_path, chain_path = self.copy_certs_and_keys( diff --git a/tests/compatibility/util.py b/tests/compatibility/util.py index 0ba6781ef..728850c75 100644 --- a/tests/compatibility/util.py +++ b/tests/compatibility/util.py @@ -35,6 +35,7 @@ def create_le_config(parent_dir): return argparse.Namespace(**config) # pylint: disable=star-args + def extract_configs(configs, parent_dir): """Extracts configs to a new dir under parent_dir and returns it""" config_dir = os.path.join(parent_dir, "configs") From 9587a4f84d672d068a2b47134e9c217ebdb105f1 Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 23 Jul 2015 14:37:41 +0000 Subject: [PATCH 25/35] Remove tests/__init__.py. --- tests/__init__.py | 1 - 1 file changed, 1 deletion(-) delete mode 100644 tests/__init__.py diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index ea250d700..000000000 --- a/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Let's Encrypt tests""" From 645378d5dff90f6e7077a8b047ee6a894e660cdf Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 23 Jul 2015 14:37:00 +0000 Subject: [PATCH 26/35] Move compat testing files to subpkg directory. --- {tests => letsencrypt-compatibility-test}/MANIFEST.in | 0 .../letsencrypt_compatibility_test}/__init__.py | 0 .../letsencrypt_compatibility_test}/configurators/__init__.py | 0 .../configurators/apache/Dockerfile | 0 .../configurators/apache/__init__.py | 0 .../configurators/apache/a2enmod.sh | 0 .../configurators/apache/apache24.py | 0 .../configurators/apache/common.py | 0 .../letsencrypt_compatibility_test}/configurators/common.py | 0 .../letsencrypt_compatibility_test}/errors.py | 0 .../letsencrypt_compatibility_test}/interfaces.py | 0 .../letsencrypt_compatibility_test}/test_driver.py | 0 .../letsencrypt_compatibility_test}/testdata/rsa1024_key.pem | 0 .../letsencrypt_compatibility_test}/util.py | 0 {tests => letsencrypt-compatibility-test}/setup.py | 0 15 files changed, 0 insertions(+), 0 deletions(-) rename {tests => letsencrypt-compatibility-test}/MANIFEST.in (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/__init__.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/__init__.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/apache/Dockerfile (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/apache/__init__.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/apache/a2enmod.sh (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/apache/apache24.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/apache/common.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/configurators/common.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/errors.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/interfaces.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/test_driver.py (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/testdata/rsa1024_key.pem (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/util.py (100%) rename {tests => letsencrypt-compatibility-test}/setup.py (100%) diff --git a/tests/MANIFEST.in b/letsencrypt-compatibility-test/MANIFEST.in similarity index 100% rename from tests/MANIFEST.in rename to letsencrypt-compatibility-test/MANIFEST.in diff --git a/tests/compatibility/__init__.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/__init__.py similarity index 100% rename from tests/compatibility/__init__.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/__init__.py diff --git a/tests/compatibility/configurators/__init__.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/__init__.py similarity index 100% rename from tests/compatibility/configurators/__init__.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/__init__.py diff --git a/tests/compatibility/configurators/apache/Dockerfile b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile similarity index 100% rename from tests/compatibility/configurators/apache/Dockerfile rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile diff --git a/tests/compatibility/configurators/apache/__init__.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/__init__.py similarity index 100% rename from tests/compatibility/configurators/apache/__init__.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/__init__.py diff --git a/tests/compatibility/configurators/apache/a2enmod.sh b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh similarity index 100% rename from tests/compatibility/configurators/apache/a2enmod.sh rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh diff --git a/tests/compatibility/configurators/apache/apache24.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py similarity index 100% rename from tests/compatibility/configurators/apache/apache24.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py diff --git a/tests/compatibility/configurators/apache/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py similarity index 100% rename from tests/compatibility/configurators/apache/common.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py diff --git a/tests/compatibility/configurators/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py similarity index 100% rename from tests/compatibility/configurators/common.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py diff --git a/tests/compatibility/errors.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/errors.py similarity index 100% rename from tests/compatibility/errors.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/errors.py diff --git a/tests/compatibility/interfaces.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py similarity index 100% rename from tests/compatibility/interfaces.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py diff --git a/tests/compatibility/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py similarity index 100% rename from tests/compatibility/test_driver.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py diff --git a/tests/compatibility/testdata/rsa1024_key.pem b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key.pem similarity index 100% rename from tests/compatibility/testdata/rsa1024_key.pem rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key.pem diff --git a/tests/compatibility/util.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py similarity index 100% rename from tests/compatibility/util.py rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py diff --git a/tests/setup.py b/letsencrypt-compatibility-test/setup.py similarity index 100% rename from tests/setup.py rename to letsencrypt-compatibility-test/setup.py From 34aa3b90f6aad27f673a34561b1f5ff804fb117e Mon Sep 17 00:00:00 2001 From: Jakub Warmuz Date: Thu, 23 Jul 2015 14:38:05 +0000 Subject: [PATCH 27/35] Post-rename subpkg updates. Fix compat testing imports, console script name, subpkg project name, MANIFEST. --- letsencrypt-compatibility-test/MANIFEST.in | 2 +- .../configurators/apache/apache24.py | 6 +++--- .../configurators/apache/common.py | 8 ++++---- .../configurators/common.py | 4 ++-- .../letsencrypt_compatibility_test/test_driver.py | 7 ++++--- .../letsencrypt_compatibility_test/util.py | 3 ++- letsencrypt-compatibility-test/setup.py | 4 ++-- 7 files changed, 18 insertions(+), 16 deletions(-) diff --git a/letsencrypt-compatibility-test/MANIFEST.in b/letsencrypt-compatibility-test/MANIFEST.in index 7d73674fb..29862fc12 100644 --- a/letsencrypt-compatibility-test/MANIFEST.in +++ b/letsencrypt-compatibility-test/MANIFEST.in @@ -1 +1 @@ -include compatibility/testdata/rsa1024_key.pem +include letsencrypt_compatibility_test/testdata/rsa1024_key.pem diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py index 44150e7fe..2ffc44976 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/apache24.py @@ -2,9 +2,9 @@ import zope.interface -from tests.compatibility import errors -from tests.compatibility import interfaces -from tests.compatibility.configurators.apache import common as apache_common +from letsencrypt_compatibility_test import errors +from letsencrypt_compatibility_test import interfaces +from letsencrypt_compatibility_test.configurators.apache import common as apache_common # The docker image doesn't actually have the watchdog module, but unless the diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py index 036654844..995c469bf 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py @@ -8,10 +8,10 @@ import zope.interface from letsencrypt import configuration from letsencrypt_apache import configurator -from tests.compatibility import errors -from tests.compatibility import interfaces -from tests.compatibility import util -from tests.compatibility.configurators import common as configurators_common +from letsencrypt_compatibility_test import errors +from letsencrypt_compatibility_test import interfaces +from letsencrypt_compatibility_test import util +from letsencrypt_compatibility_test.configurators import common as configurators_common APACHE_VERSION_REGEX = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py index f7385b8bb..e5309b770 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/common.py @@ -8,8 +8,8 @@ import threading import docker from letsencrypt import constants -from tests.compatibility import errors -from tests.compatibility import util +from letsencrypt_compatibility_test import errors +from letsencrypt_compatibility_test import util logger = logging.getLogger(__name__) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index 65ad36f18..34b6da689 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -16,9 +16,10 @@ from letsencrypt import achallenges from letsencrypt import errors as le_errors from letsencrypt import validator from letsencrypt.tests import acme_util -from tests.compatibility import errors -from tests.compatibility import util -from tests.compatibility.configurators.apache import apache24 + +from letsencrypt_compatibility_test import errors +from letsencrypt_compatibility_test import util +from letsencrypt_compatibility_test.configurators.apache import apache24 DESCRIPTION = """ diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py index 728850c75..03b15d217 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/util.py @@ -11,7 +11,8 @@ import tarfile from acme import jose from acme import test_util from letsencrypt import constants -from tests.compatibility import errors + +from letsencrypt_compatibility_test import errors _KEY_BASE = "rsa1024_key.pem" diff --git a/letsencrypt-compatibility-test/setup.py b/letsencrypt-compatibility-test/setup.py index 5810b66f9..f02041e55 100644 --- a/letsencrypt-compatibility-test/setup.py +++ b/letsencrypt-compatibility-test/setup.py @@ -12,12 +12,12 @@ install_requires = [ ] setup( - name='compatibility-test', + name='letsencrypt-compatibility-test', packages=find_packages(), install_requires=install_requires, entry_points={ 'console_scripts': [ - 'compatibility-test = compatibility.test_driver:main', + 'letsencrypt-compatibility-test = letsencrypt_compatibility_test.test_driver:main', ], }, ) From 83ad476a6d3e071f223595bc19f3c6e5fdfdb7f6 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 23 Jul 2015 17:09:09 -0700 Subject: [PATCH 28/35] Improved logging and error handling --- acme/acme/challenges.py | 7 +- tests/MANIFEST.in | 2 +- .../configurators/apache/Dockerfile | 2 + .../configurators/apache/common.py | 39 +++-- tests/compatibility/test_driver.py | 153 ++++++++++++------ tests/compatibility/testdata/empty_cert.pem | 13 ++ tests/compatibility/testdata/rsa1024_key2.pem | 15 ++ 7 files changed, 167 insertions(+), 64 deletions(-) create mode 100644 tests/compatibility/testdata/empty_cert.pem create mode 100644 tests/compatibility/testdata/rsa1024_key2.pem diff --git a/acme/acme/challenges.py b/acme/acme/challenges.py index 723c51317..9abdbe833 100644 --- a/acme/acme/challenges.py +++ b/acme/acme/challenges.py @@ -199,10 +199,11 @@ class DVSNI(DVChallenge): def probe_cert(self, domain, **kwargs): """Probe DVSNI challenge certificate.""" - host = socket.gethostbyname(domain) - logging.debug('%s resolved to %s', domain, host) + if "host" not in kwargs: + host = socket.gethostbyname(domain) + logging.debug('%s resolved to %s', domain, host) + kwargs["host"] = host - kwargs.setdefault("host", host) kwargs.setdefault("port", self.PORT) kwargs["name"] = self.nonce_domain # TODO: try different methods? diff --git a/tests/MANIFEST.in b/tests/MANIFEST.in index 7d73674fb..be22c3801 100644 --- a/tests/MANIFEST.in +++ b/tests/MANIFEST.in @@ -1 +1 @@ -include compatibility/testdata/rsa1024_key.pem +recursive-include tests/compatibility/testdata/ * diff --git a/tests/compatibility/configurators/apache/Dockerfile b/tests/compatibility/configurators/apache/Dockerfile index 092d84ec8..da6811485 100644 --- a/tests/compatibility/configurators/apache/Dockerfile +++ b/tests/compatibility/configurators/apache/Dockerfile @@ -11,6 +11,8 @@ ENV APACHE_RUN_USER=daemon \ APACHE_LOG_DIR=/usr/local/apache2/logs COPY tests/compatibility/configurators/apache/a2enmod.sh /usr/local/bin/ +COPY tests/compatibility/testdata/rsa1024_key2.pem /usr/local/apache2/conf/ +COPY tests/compatibility/testdata/empty_cert.pem /usr/local/apache2/conf/ # Note: this only exposes the port to other docker containers. You # still have to bind to 443@host at runtime. diff --git a/tests/compatibility/configurators/apache/common.py b/tests/compatibility/configurators/apache/common.py index 036654844..e21ac61d8 100644 --- a/tests/compatibility/configurators/apache/common.py +++ b/tests/compatibility/configurators/apache/common.py @@ -7,6 +7,7 @@ import mock import zope.interface from letsencrypt import configuration +from letsencrypt import errors as le_errors from letsencrypt_apache import configurator from tests.compatibility import errors from tests.compatibility import interfaces @@ -28,14 +29,22 @@ class Proxy(configurators_common.Proxy): super(Proxy, self).__init__(args) self.le_config.apache_le_vhost_ext = "-le-ssl.conf" - self._patch = mock.patch('letsencrypt_apache.configurator.subprocess') - subprocess_mock = self._patch.start() + self._patches = list() + subprocess_patch = mock.patch( + "letsencrypt_apache.configurator.subprocess") + subprocess_mock = subprocess_patch.start() subprocess_mock.check_call = self.check_call_in_docker subprocess_mock.Popen = self.popen_in_docker + self._patches.append(subprocess_patch) - self.modules = self.version = self.test_conf = None - self._apache_configurator = None - self.server_root = self.modules = self.version = self.test_conf = None + display_patch = mock.patch( + "letsencrypt_apache.configurator.display_ops.select_vhost") + display_mock = display_patch.start() + display_mock.side_effect = le_errors.PluginError( + "Unable to determine vhost") + self._patches.append(display_mock) + + self.modules = self.server_root = self.test_conf = self.version = None self._apache_configurator = self._all_names = self._test_names = None def __getattr__(self, name): @@ -83,16 +92,21 @@ class Proxy(configurators_common.Proxy): def preprocess_config(self, server_root): # pylint: disable=anomalous-backslash-in-string, no-self-use """Prepares the configuration for use in the Docker""" + find = subprocess.Popen( ["find", server_root, "-type", "f"], stdout=subprocess.PIPE) subprocess.check_call([ - "xargs", "sed", "-e", "s/DocumentRoot.*/" - "DocumentRoot \/usr\/local\/apache2\/htdocs/I", - "-e", "s/SSLPassPhraseDialog.*/" - "SSLPassPhraseDialog builtin/I", - "-e", "s/TypesConfig.*/" - "TypesConfig \/usr\/local\/apache2\/conf\/mime.types/I", + "xargs", "sed", "-e", "s/DocumentRoot.*/DocumentRoot " + "\/usr\/local\/apache2\/htdocs/I", + "-e", "s/SSLPassPhraseDialog.*/SSLPassPhraseDialog builtin/I", + "-e", "s/TypesConfig.*/TypesConfig " + "\/usr\/local\/apache2\/conf\/mime.types/I", + "-e", "s/LoadModule/#LoadModule/I", + "-e", "s/SSLCertificateFile.*/SSLCertificateFile " + "\/usr\/local\/apache2\/conf\/empty_cert.pem/I", + "-e", "s/SSLCertificateKeyFile.*/SSLCertificateKeyFile " + "\/usr\/local\/apache2\/conf\/rsa1024_key2.pem/I", "-i"], stdin=find.stdout) def _prepare_configurator(self, server_root, config_file): @@ -111,7 +125,8 @@ class Proxy(configurators_common.Proxy): def cleanup_from_tests(self): """Performs any necessary cleanup from running plugin tests""" super(Proxy, self).cleanup_from_tests() - self._patch.stop() + for patch in self._patches: + patch.stop() def get_all_names_answer(self): """Returns the set of domain names that the plugin should find""" diff --git a/tests/compatibility/test_driver.py b/tests/compatibility/test_driver.py index 65ad36f18..90ad5ec39 100644 --- a/tests/compatibility/test_driver.py +++ b/tests/compatibility/test_driver.py @@ -28,7 +28,6 @@ tests that the plugin supports are performed. """ - PLUGINS = {"apache" : apache24.Proxy} @@ -36,44 +35,57 @@ logger = logging.getLogger(__name__) def test_authenticator(plugin, config, temp_dir): - """Tests plugin as an authenticator""" + """Tests authenticator, returning True if the tests are successful""" backup = _create_backup(config, temp_dir) achalls = _create_achalls(plugin) if not achalls: - return + # Plugin/tests support no common challenge types + return True try: responses = plugin.perform(achalls) - for i in xrange(len(responses)): - if not responses[i]: - logger.error( - "Plugin returned `None` or `False` response to challenge " - "for config `%s`", config) - elif isinstance(responses[i], challenges.DVSNIResponse): - if responses[i].simple_verify(achalls[i], - achalls[i].domain, - util.JWK.key.public_key(), - host="127.0.0.1", - port=plugin.https_port): - logger.info( - "Verification of DVSNI response for %s succeeded", - achalls[i].domain) - else: - logger.error( - "Verification of DVSNI response for %s in config '%s' " - "failed ", achalls[i].domain, config) except le_errors.Error as error: - logger.info( - "Plugin raised %s during authentication with config '%s'", - error, config) - finally: - plugin.cleanup(achalls) + logger.error("Performing challenges on %s caused an error:", config) + logger.exception(error) + return False - if _dirs_are_unequal(config, backup): - logger.error("Challenge cleanup failed for config %s", config) - else: - logger.info("Challenge cleanup succeeded") + success = True + for i in xrange(len(responses)): + if not responses[i]: + logger.error( + "Plugin failed to complete %s for %s in %s", + type(achalls[i]), achalls[i].domain, config) + success = False + elif isinstance(responses[i], challenges.DVSNIResponse): + if responses[i].simple_verify(achalls[i], + achalls[i].domain, + util.JWK.key.public_key(), + host="127.0.0.1", + port=plugin.https_port): + logger.info( + "DVSNI verification for %s succeeded", achalls[i].domain) + else: + logger.error( + "DVSNI verification for %s in %s failed", + achalls[i].domain, config) + success = False + + if success: + try: + plugin.cleanup(achalls) + except le_errors.Error as error: + logger.error("Challenge cleanup for %s caused an error:", config) + logger.exception(error) + success = False + + if _dirs_are_unequal(config, backup): + logger.error("Challenge cleanup failed for %s", config) + return False + else: + logger.info("Challenge cleanup succeeded") + + return success def _create_achalls(plugin): @@ -100,16 +112,20 @@ def test_installer(args, plugin, config, temp_dir): """Tests plugin as an installer""" backup = _create_backup(config, temp_dir) - if plugin.get_all_names().issubset(plugin.get_all_names_answer()): + names_match = plugin.get_all_names() == plugin.get_all_names_answer() + if names_match: logger.info("get_all_names test succeeded") else: - logger.error("get_all_names test failed for config `%s`", config) + logger.error("get_all_names test failed for config %s", config) domains = list(plugin.get_testable_domain_names()) - if test_deploy_cert(plugin, temp_dir, domains) and args.enhance: - test_enhancements(plugin, domains) + success = test_deploy_cert(plugin, temp_dir, domains) - test_rollback(plugin, config, backup) + if success and args.enhance: + success = test_enhancements(plugin, domains) + + good_rollback = test_rollback(plugin, config, backup) + return names_match and success and good_rollback def test_deploy_cert(plugin, temp_dir, domains): @@ -121,9 +137,15 @@ def test_deploy_cert(plugin, temp_dir, domains): OpenSSL.crypto.FILETYPE_PEM, cert)) for domain in domains: - plugin.deploy_cert(domain, cert_path, util.KEY_PATH) - plugin.save("deployed") - plugin.restart() + try: + plugin.deploy_cert(domain, cert_path, util.KEY_PATH) + except le_errors.Error as error: + logger.error("Plugin failed to deploy ceritificate for %s:", domain) + logger.exception(error) + return False + + if not _save_and_restart(plugin, "deployed"): + return False verify_cert = validator.Validator().certificate success = True @@ -139,17 +161,22 @@ def test_deploy_cert(plugin, temp_dir, domains): def test_enhancements(plugin, domains): - """Tests enhancements supported by the plugin""" + """Tests supported enhancements returning True if successful""" supported = plugin.supported_enhancements() if "redirect" not in supported: - return + return True for domain in domains: - plugin.enhance(domain, "redirect") + try: + plugin.enhance(domain, "redirect") + except le_errors.Error as error: + logger.error("Plugin failed to enable redirect for %s:", domain) + logger.exception(error) + return False - plugin.save("enhanced") - plugin.restart() + if not _save_and_restart(plugin, "enhanced"): + return False verify_redirect = functools.partial( validator.Validator().redirect, "localhost", plugin.http_port) @@ -162,15 +189,36 @@ def test_enhancements(plugin, domains): if success: logger.info("Enhancments test succeeded") + return success + + +def _save_and_restart(plugin, title=None): + """Saves and restart the plugin, returning True if no errors occurred""" + try: + plugin.save(title) + plugin.restart() + return True + except le_errors.Error as error: + logger.error("Plugin failed to save and restart server:") + logger.exception(error) + return False + def test_rollback(plugin, config, backup): """Tests the rollback checkpoints function""" - plugin.rollback_checkpoints(2) + try: + plugin.rollback_checkpoints(2) + except le_errors.Error as error: + logger.error("Plugin raised an exception during rollback:") + logger.exception(error) + return False if _dirs_are_unequal(config, backup): logger.error("Rollback failed for config `%s`", config) + return False else: logger.info("Rollback succeeded") + return True def _create_backup(config, temp_dir): @@ -249,15 +297,24 @@ def main(): try: plugin.execute_in_docker("mkdir -p /var/log/apache2") while plugin.has_more_configs(): + success = True + try: config = plugin.load_config() logger.info("Loaded configuration: %s", config) if args.auth: - test_authenticator(plugin, config, temp_dir) - if args.install: - test_installer(args, plugin, config, temp_dir) + success = test_authenticator(plugin, config, temp_dir) + if success and args.install: + success = test_installer(args, plugin, config, temp_dir) except errors.Error as error: - logger.error("Tests on config `%s` raised: %s", config, error) + logger.error("Tests on %s raised:", config) + logger.exception(error) + success = False + + if success: + logger.info("All tests on %s succeeded", config) + else: + logger.error("Tests on %s failed", config) finally: plugin.cleanup_from_tests() diff --git a/tests/compatibility/testdata/empty_cert.pem b/tests/compatibility/testdata/empty_cert.pem new file mode 100644 index 000000000..4ea812a87 --- /dev/null +++ b/tests/compatibility/testdata/empty_cert.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICATCCAWoCCQCvMbKu4FHZ6zANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJB +VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMB4XDTE1MDcyMzIzMjc1MFoXDTE2MDcyMjIzMjc1MFowRTELMAkG +A1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0 +IFdpZGdpdHMgUHR5IEx0ZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAws3o +y46PMLM9Gr68pbex0MhdPr7Cq4rRe9BBpnOuHFdF35Ak0aPrzFwVzLlGOir94U11 +e5JYJDWJi+4FwLBRkOAfanjJ5GJ9BnEHSOdbtO+sv9uhbt+7iYOOUOngKSiJyUrM +i1THAE+B1CenxZ1KHRQCke708zkK8jVuxLeIAOMCAwEAATANBgkqhkiG9w0BAQsF +AAOBgQCC3LUP3MHk+IBmwHHZAZCX+6p4lop9SP6y6rDpWgnqEEeb9oFleHi2Rvzq +7gxl6nS5AsaSzfAygJ3zWKTwVAZyU4GOQ8QTK+nHk3+LO1X4cDbUlQfm5+YuwKDa +4LFKeovmrK6BiMLIc1J+MxUjLfCeVHYSdkZULTVXue0zif0BUA== +-----END CERTIFICATE----- diff --git a/tests/compatibility/testdata/rsa1024_key2.pem b/tests/compatibility/testdata/rsa1024_key2.pem new file mode 100644 index 000000000..03f77d903 --- /dev/null +++ b/tests/compatibility/testdata/rsa1024_key2.pem @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXAIBAAKBgQDCzejLjo8wsz0avrylt7HQyF0+vsKritF70EGmc64cV0XfkCTR +o+vMXBXMuUY6Kv3hTXV7klgkNYmL7gXAsFGQ4B9qeMnkYn0GcQdI51u076y/26Fu +37uJg45Q6eApKInJSsyLVMcAT4HUJ6fFnUodFAKR7vTzOQryNW7Et4gA4wIDAQAB +AoGAKiAU40/krwdTg2ETslJS5W8ums7tkeLnAfs69x+02vQUbA/jpmHoL70KCcdW +5GU/mWUCrsIqxUm+gL/sBosaV/TF256qUBt2qQCZTN8MbDaNSYiiMnucOfbWdIqx +Zgls6GUoXQvPic9RUoFSlgfSjo5ezz6el5ihvRMp+wbk24ECQQD3oz4hN029DSZo +Y3+flmBn77gA0BMUvLa6hmt9b3xT5U/ToCLfbmUvpx7zV1g5era2y9qt/o3UtAbW +1zCVETgzAkEAyWHv/+RnSXp8/D4YwTVWyeWi862uNBPkuLGP/0zASdwBfBK3uBls ++VumfSCtp0kt2AXXmScg1fkHdeAVT6AkkQJBAJb2XRnCrRFiwtdAULzo3zx9Vp6o +OfmaUYrEByMgo5pBYLiSFrA+jFDQgH238YCY3mnxPA517+CLHuA5rtQw+yECQCfm +gL/pyFE1tLfhsdPuNpDwL9YqLl7hJis1+zrxQRQhRCYKK16NoxrQ/u7B38ZKaIvp +tGsC5q2elszTJkXNjBECQCVE9QCVx056vHVdPWM8z3GAeV3sJQ01HLLjebTEEz6G +jH54gk+YYPp4kjCvVUykbnB58BY2n88GQt5Jj5eLuMo= +-----END RSA PRIVATE KEY----- From f0b2c2592d30af651eb14fa4958b10df2fa4b276 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 23 Jul 2015 17:28:07 -0700 Subject: [PATCH 29/35] Speed up exit --- tests/compatibility/configurators/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/compatibility/configurators/common.py b/tests/compatibility/configurators/common.py index f7385b8bb..3e7863c0f 100644 --- a/tests/compatibility/configurators/common.py +++ b/tests/compatibility/configurators/common.py @@ -55,7 +55,7 @@ class Proxy(object): def cleanup_from_tests(self): """Performs any necessary cleanup from running plugin tests""" - self._docker_client.stop(self._container_id) + self._docker_client.stop(self._container_id, 0) self._log_thread.join() if not self.args.no_remove: self._docker_client.remove_container(self._container_id) From c257f5274d8b01191c37a3b0fad36176d115d0b8 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Fri, 24 Jul 2015 15:03:18 -0700 Subject: [PATCH 30/35] More minor fixes --- letsencrypt-compatibility-test/MANIFEST.in | 2 +- .../configurators/apache/Dockerfile | 6 +++--- .../letsencrypt_compatibility_test}/testdata/empty_cert.pem | 0 .../testdata/rsa1024_key2.pem | 0 4 files changed, 4 insertions(+), 4 deletions(-) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/testdata/empty_cert.pem (100%) rename {tests/compatibility => letsencrypt-compatibility-test/letsencrypt_compatibility_test}/testdata/rsa1024_key2.pem (100%) diff --git a/letsencrypt-compatibility-test/MANIFEST.in b/letsencrypt-compatibility-test/MANIFEST.in index 29862fc12..a6aa14443 100644 --- a/letsencrypt-compatibility-test/MANIFEST.in +++ b/letsencrypt-compatibility-test/MANIFEST.in @@ -1 +1 @@ -include letsencrypt_compatibility_test/testdata/rsa1024_key.pem +recursive-include letsencrypt_compatibility_test/testdata * diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile index da6811485..b5a34a4ab 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile @@ -10,9 +10,9 @@ ENV APACHE_RUN_USER=daemon \ APACHE_LOCK_DIR=/var/lock \ APACHE_LOG_DIR=/usr/local/apache2/logs -COPY tests/compatibility/configurators/apache/a2enmod.sh /usr/local/bin/ -COPY tests/compatibility/testdata/rsa1024_key2.pem /usr/local/apache2/conf/ -COPY tests/compatibility/testdata/empty_cert.pem /usr/local/apache2/conf/ +COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh /usr/local/bin/ +COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key2.pem /usr/local/apache2/conf/ +COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/empty_cert.pem /usr/local/apache2/conf/ # Note: this only exposes the port to other docker containers. You # still have to bind to 443@host at runtime. diff --git a/tests/compatibility/testdata/empty_cert.pem b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/empty_cert.pem similarity index 100% rename from tests/compatibility/testdata/empty_cert.pem rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/empty_cert.pem diff --git a/tests/compatibility/testdata/rsa1024_key2.pem b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key2.pem similarity index 100% rename from tests/compatibility/testdata/rsa1024_key2.pem rename to letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key2.pem From 428b89e0cf00a710d21bfd0d19f4a68fb987e788 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 3 Aug 2015 11:38:22 -0700 Subject: [PATCH 31/35] Started incorporating James' feedback --- .../interfaces.py | 21 +++++++------------ .../test_driver.py | 9 ++++---- 2 files changed, 13 insertions(+), 17 deletions(-) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py index fde1f2d45..b0785fa8e 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/interfaces.py @@ -8,6 +8,12 @@ import letsencrypt.interfaces class IPluginProxy(zope.interface.Interface): """Wraps a Let's Encrypt plugin""" + http_port = zope.interface.Attribute( + "The port to connect to on localhost for HTTP traffic") + + https_port = zope.interface.Attribute( + "The port to connect to on localhost for HTTPS traffic") + def add_parser_arguments(cls, parser): """Adds command line arguments needed by the parser""" @@ -27,26 +33,15 @@ class IPluginProxy(zope.interface.Interface): def load_config(): """Loads the next config and returns its name""" - -class IConfiguratorBaseProxy(IPluginProxy): - """Common functionality for authenticator/installer tests""" - http_port = zope.interface.Attribute( - "The port to connect to on localhost for HTTP traffic") - - https_port = zope.interface.Attribute( - "The port to connect to on localhost for HTTPS traffic") - def get_testable_domain_names(): """Returns the domain names that can be used in testing""" -class IAuthenticatorProxy( - IConfiguratorBaseProxy, letsencrypt.interfaces.IAuthenticator): +class IAuthenticatorProxy(IPluginProxy, letsencrypt.interfaces.IAuthenticator): """Wraps a Let's Encrypt authenticator""" -class IInstallerProxy( - IConfiguratorBaseProxy, letsencrypt.interfaces.IInstaller): +class IInstallerProxy(IPluginProxy, letsencrypt.interfaces.IInstaller): """Wraps a Let's Encrypt installer""" def get_all_names_answer(): diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index e50335b4a..9ac8c43ae 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -41,8 +41,9 @@ def test_authenticator(plugin, config, temp_dir): achalls = _create_achalls(plugin) if not achalls: - # Plugin/tests support no common challenge types - return True + logger.error("The plugin and this program support no common " + "challenge types") + return False try: responses = plugin.perform(achalls) @@ -208,7 +209,7 @@ def _save_and_restart(plugin, title=None): def test_rollback(plugin, config, backup): """Tests the rollback checkpoints function""" try: - plugin.rollback_checkpoints(2) + plugin.rollback_checkpoints(1337) except le_errors.Error as error: logger.error("Plugin raised an exception during rollback:") logger.exception(error) @@ -281,7 +282,7 @@ def setup_logging(args): handler = logging.StreamHandler() root_logger = logging.getLogger() - root_logger.setLevel(logging.WARNING - args.verbose_count * 10) + root_logger.setLevel(logging.ERROR - args.verbose_count * 10) root_logger.addHandler(handler) From 1d7a70f3568c5df2a5c0c43da1c9a913687f2a7c Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 3 Aug 2015 11:56:17 -0700 Subject: [PATCH 32/35] No supported enhancements counts as an error --- .../letsencrypt_compatibility_test/test_driver.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index 9ac8c43ae..be8c4679c 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -167,7 +167,9 @@ def test_enhancements(plugin, domains): supported = plugin.supported_enhancements() if "redirect" not in supported: - return True + logger.error("The plugin and this program support no common " + "enhancements") + return False for domain in domains: try: From 0252272430996bc919c3b75957d06b250dfbc0e3 Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Mon, 3 Aug 2015 19:23:01 -0700 Subject: [PATCH 33/35] Fixed directory compare --- .../test_driver.py | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index d7ca87633..a519e844f 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -249,11 +249,35 @@ def _create_backup(config, temp_dir): def _dirs_are_unequal(dir1, dir2): - """Returns True if dir1 and dir2 are equal""" - dircmp = filecmp.dircmp(dir1, dir2) + """Returns True if dir1 and dir2 are unequal""" + dircmps = [filecmp.dircmp(dir1, dir2)] + while len(dircmps): + dircmp = dircmps.pop() + if dircmp.left_only or dircmp.right_only: + logger.error("The following files and directories are only " + "present in one directory") + if dircmp.left_only: + logger.error(dircmp.left_only) + else: + logger.error(dircmp.right_only) + return True + elif dircmp.common_funny or dircmp.funny_files: + logger.error("The following files and directories could not be " + "compared:") + if dircmp.common_funny: + logger.error(dircmp.common_funny) + else: + logger.error(dircmp.funny_files) + return True + elif dircmp.diff_files: + logger.error("The following files differ:") + logger.error(dircmp.diff_files) + return True - return (dircmp.left_only or dircmp.right_only or - dircmp.diff_files or dircmp.funny_files) + for subdir in dircmp.subdirs.itervalues(): + dircmps.append(subdir) + + return False def get_args(): From fb7924577357235fa5fdfe534bf9ed64233083ee Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Tue, 4 Aug 2015 12:18:10 -0700 Subject: [PATCH 34/35] Added a2dismod support --- .../configurators/apache/Dockerfile | 1 + .../configurators/apache/a2dismod.sh | 14 ++++++++++++++ .../configurators/apache/a2enmod.sh | 6 +++--- .../configurators/apache/common.py | 4 ++-- .../letsencrypt_compatibility_test/test_driver.py | 10 +++++++--- 5 files changed, 27 insertions(+), 8 deletions(-) create mode 100755 letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2dismod.sh diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile index b5a34a4ab..392f5efa6 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/Dockerfile @@ -11,6 +11,7 @@ ENV APACHE_RUN_USER=daemon \ APACHE_LOG_DIR=/usr/local/apache2/logs COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh /usr/local/bin/ +COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2dismod.sh /usr/local/bin/ COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/rsa1024_key2.pem /usr/local/apache2/conf/ COPY letsencrypt-compatibility-test/letsencrypt_compatibility_test/testdata/empty_cert.pem /usr/local/apache2/conf/ diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2dismod.sh b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2dismod.sh new file mode 100755 index 000000000..ca96e216f --- /dev/null +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2dismod.sh @@ -0,0 +1,14 @@ +#!/bin/bash +# An extremely simplified version of `a2enmod` for disabling modules in the +# httpd docker image. First argument is the server_root and the second is the +# module to be disabled. + +apache_confdir=$1 +module=$2 + +sed -i "/.*"$module".*/d" "$apache_confdir/test.conf" +enabled_conf="$apache_confdir/mods-enabled/"$module".conf" +if [ -e "$enabled_conf" ] +then + rm $enabled_conf +fi diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh index 6c33c0597..f822a1f7b 100755 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/a2enmod.sh @@ -1,7 +1,7 @@ #!/bin/bash -# An extremely simplified (and hacky) version of 'a2enmod' for the httpd -# docker image. First argument is server_root and second argument is the module -# to be enabled. +# An extremely simplified version of `a2enmod` for enabling modules in the +# httpd docker image. First argument is the server_root and the second is the +# module to be enabled. APACHE_CONFDIR=$1 diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py index ab14f0ea7..0d3dbb1b5 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/configurators/apache/common.py @@ -16,7 +16,7 @@ from letsencrypt_compatibility_test.configurators import common as configurators APACHE_VERSION_REGEX = re.compile(r"Apache/([0-9\.]*)", re.IGNORECASE) -APACHE_COMMANDS = ["apachectl", "a2enmod"] +APACHE_COMMANDS = ["apachectl", "a2enmod", "a2dismod"] class Proxy(configurators_common.Proxy): @@ -148,7 +148,7 @@ class Proxy(configurators_common.Proxy): self.le_config.apache_ctl = "apachectl -d {0} -f {1}".format( server_root, config_file) self.le_config.apache_enmod = "a2enmod.sh {0}".format(server_root) - self.le_config.apache_dismod = self.le_config.apache_enmod + self.le_config.apache_dismod = "a2dismod.sh {0}".format(server_root) self.le_config.apache_init_script = self.le_config.apache_ctl + " -k" self._apache_configurator = configurator.ApacheConfigurator( diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index a519e844f..131ef2b5b 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -177,10 +177,14 @@ def test_enhancements(plugin, domains): for domain in domains: try: plugin.enhance(domain, "redirect") - except le_errors.Error as error: + except le_errors.PluginError as error: # Don't immediately fail because a redirect may already be enabled logger.warning("Plugin failed to enable redirect for %s:", domain) logger.warning("%s", error) + except le_errors.Error as error: + logger.error("An error occurred while enabling redirect for %s:", + domain) + logger.exception(error) if not _save_and_restart(plugin, "enhanced"): return False @@ -199,13 +203,13 @@ def test_enhancements(plugin, domains): return success -def _try_until_true(func, max_tries=3): +def _try_until_true(func, max_tries=5, sleep_time=0.5): """Calls func up to max_tries times until it returns True""" for _ in xrange(0, max_tries): if func(): return True else: - time.sleep(1) + time.sleep(sleep_time) return False From 8f1162ba7e587621d964d7fb18be3fc5b8c0611f Mon Sep 17 00:00:00 2001 From: Brad Warren Date: Thu, 13 Aug 2015 18:44:28 -0700 Subject: [PATCH 35/35] Removed stray debugging statement --- .../letsencrypt_compatibility_test/test_driver.py | 1 - 1 file changed, 1 deletion(-) diff --git a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py index f060de6ed..eac2278bb 100644 --- a/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py +++ b/letsencrypt-compatibility-test/letsencrypt_compatibility_test/test_driver.py @@ -248,7 +248,6 @@ def _create_backup(config, temp_dir): shutil.rmtree(backup, ignore_errors=True) shutil.copytree(config, backup, symlinks=True) - print backup return backup