From 1592d842914824f656d71a3e3e74f66cafb8ee86 Mon Sep 17 00:00:00 2001 From: Adrien Ferrand Date: Wed, 6 Mar 2019 23:09:10 +0100 Subject: [PATCH] Second part: integration tests for certbot core --- .../certbot_tests/assertions.py | 45 ++ .../certbot_tests/conftest.py | 5 + .../certbot_tests/context.py | 91 +++- .../certbot_tests/test_main.py | 468 +++++++++++++++++- .../certbot_integration_tests/utils/misc.py | 154 +++++- certbot-ci/setup.py | 4 + examples/generate-csr.py | 76 +++ tools/dev_constraints.txt | 1 + 8 files changed, 812 insertions(+), 32 deletions(-) create mode 100644 certbot-ci/certbot_integration_tests/certbot_tests/assertions.py create mode 100644 certbot-ci/certbot_integration_tests/certbot_tests/conftest.py create mode 100644 examples/generate-csr.py diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py b/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py new file mode 100644 index 000000000..c579b936b --- /dev/null +++ b/certbot-ci/certbot_integration_tests/certbot_tests/assertions.py @@ -0,0 +1,45 @@ +import os +import grp + + +__all__ = ['assert_hook_execution', 'assert_save_renew_hook', 'assert_certs_count_for_lineage', + 'assert_equals_permissions', 'assert_equals_group_owner', 'assert_world_permissions'] + + +def assert_hook_execution(probe_path, probe_content): + with open(probe_path, 'r') as file: + lines = file.readlines() + + # Comparing pattern to each line avoids to match "pre" for a line with "pre-override" + assert [line for line in lines if line == '{0}{1}'.format(probe_content, os.linesep)] + + +def assert_save_renew_hook(config_dir, lineage): + assert os.path.isfile(os.path.join(config_dir, 'renewal/{0}.conf'.format(lineage))) + + +def assert_certs_count_for_lineage(config_dir, lineage, count): + archive_dir = os.path.join(config_dir, 'archive') + lineage_dir = os.path.join(archive_dir, lineage) + certs = [file for file in os.listdir(lineage_dir) if file.startswith('cert')] + assert len(certs) == count + + +def assert_equals_permissions(file1, file2, mask): + mode_file1 = os.stat(file1).st_mode & mask + mode_file2 = os.stat(file2).st_mode & mask + + assert mode_file1 == mode_file2 + + +def assert_equals_group_owner(file1, file2): + group_owner_file1 = grp.getgrgid(os.stat(file1).st_gid)[0] + group_owner_file2 = grp.getgrgid(os.stat(file2).st_gid)[0] + + assert group_owner_file1 == group_owner_file2 + + +def assert_world_permissions(file, mode): + mode_file_all = os.stat(file).st_mode & 0o007 + + assert mode_file_all == mode diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/conftest.py b/certbot-ci/certbot_integration_tests/certbot_tests/conftest.py new file mode 100644 index 000000000..bb24e8dc6 --- /dev/null +++ b/certbot-ci/certbot_integration_tests/certbot_tests/conftest.py @@ -0,0 +1,5 @@ +import pytest + +# Custom assertions defined in following package needs to be registered to be properly +# displayed in a pytest report when they are failing. +pytest.register_assert_rewrite('certbot_integration_tests.certbot_tests.assertions') \ No newline at end of file diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/certbot_integration_tests/certbot_tests/context.py index 9045cd37d..68bec5cc9 100644 --- a/certbot-ci/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/certbot_integration_tests/certbot_tests/context.py @@ -1,16 +1,105 @@ +import os +import tempfile +import subprocess +import shutil +import sys +from distutils.version import LooseVersion + +from certbot_integration_tests.utils import misc + + class IntegrationTestsContext(object): """General fixture describing a certbot integration tests context""" def __init__(self, request): self.request = request + + self.worker_id = request.config.slaveinput['slaveid'] if hasattr(request.config, 'slaveinput') else 'master' if hasattr(request.config, 'slaveinput'): # Worker node self.worker_id = request.config.slaveinput['slaveid'] self.acme_xdist = request.config.slaveinput['acme_xdist'] else: # Primary node self.worker_id = 'primary' self.acme_xdist = request.config.acme_xdist + + self.acme_server =self.acme_xdist['acme_server'] self.directory_url = self.acme_xdist['directory_url'] self.tls_alpn_01_port = self.acme_xdist['https_port'][self.worker_id] self.http_01_port = self.acme_xdist['http_port'][self.worker_id] + self.challtestsrv_mgt_port = self.acme_xdist['challtestsrv_port'] + + self.certbot_version = misc.get_certbot_version() + + self.workspace = tempfile.mkdtemp() + self.config_dir = os.path.join(self.workspace, 'conf') + self.hook_probe = tempfile.mkstemp(dir=self.workspace)[1] + + self.manual_dns_auth_hook = ( + '{0} -c "import os; import requests; import json; ' + "assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail'); " + "data = {{'host':'_acme-challenge.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))," + "'value':os.environ.get('CERTBOT_VALIDATION')}}; " + "request = requests.post('http://localhost:{1}/set-txt', data=json.dumps(data)); " + "request.raise_for_status(); " + '"' + ).format(sys.executable, self.challtestsrv_mgt_port) + self.manual_dns_cleanup_hook = ( + '{0} -c "import os; import requests; import json; ' + "data = {{'host':'_acme-challenge.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))}}; " + "request = requests.post('http://localhost:{1}/clear-txt', data=json.dumps(data)); " + "request.raise_for_status(); " + '"' + ).format(sys.executable, self.challtestsrv_mgt_port) def cleanup(self): - pass + shutil.rmtree(self.workspace) + + def certbot_test_no_force_renew(self, args): + new_environ = os.environ.copy() + new_environ['TMPDIR'] = self.workspace + + additional_args = [] + if self.certbot_version >= LooseVersion('0.30.0'): + additional_args.append('--no-random-sleep-on-renew') + + command = [ + 'certbot', + '--server', self.directory_url, + '--no-verify-ssl', + '--tls-sni-01-port', str(self.tls_alpn_01_port), + '--http-01-port', str(self.http_01_port), + '--manual-public-ip-logging-ok', + '--config-dir', self.config_dir, + '--work-dir', os.path.join(self.workspace, 'work'), + '--logs-dir', os.path.join(self.workspace, 'logs'), + '--non-interactive', + '--no-redirect', + '--agree-tos', + '--register-unsafely-without-email', + '--debug', + '-vv' + ] + + command.extend(args) + command.extend(additional_args) + + print('Invoke command:\n{0}'.format(subprocess.list2cmdline(command))) + return subprocess.check_output(command, universal_newlines=True, + cwd=self.workspace, env=new_environ) + + def certbot_test(self, args): + command = ['--renew-by-default'] + command.extend(args) + return self.certbot_test_no_force_renew(command) + + def common_no_force_renew(self, args): + command = ['--authenticator', 'standalone', '--installer', 'null'] + command.extend(args) + return self.certbot_test_no_force_renew(command) + + def common(self, args): + command = ['--renew-by-default'] + command.extend(args) + return self.common_no_force_renew(command) + + def wtf(self, prefix='le'): + return '{0}.{1}.wtf'.format(prefix, self.worker_id) diff --git a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py index 5b0981b36..a8fda4023 100644 --- a/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/certbot_integration_tests/certbot_tests/test_main.py @@ -1,9 +1,15 @@ -import requests -import urllib3 +from __future__ import print_function +import subprocess +import shutil +import re +import os +from os.path import join, exists import pytest +from certbot_integration_tests.certbot_tests.assertions import * from certbot_integration_tests.certbot_tests import context as certbot_context +from certbot_integration_tests.utils import misc @pytest.fixture() @@ -16,25 +22,443 @@ def context(request): integration_test_context.cleanup() -def test_hello_1(context): - assert context.http_01_port - assert context.tls_alpn_01_port - try: - response = requests.get(context.directory_url, verify=False) - response.raise_for_status() - assert response.json() - response.close() - except urllib3.exceptions.InsecureRequestWarning: - pass +def test_basic_commands(context): + # TMPDIR env variable is set to workspace for the certbot subprocess. + # So tempdir module will create any temporary files/dirs in workspace, + # and its content can be tested to check correct certbot cleanup. + initial_count_tmpfiles = len(os.listdir(context.workspace)) + + context.common(['--help']) + context.common(['--help', 'all']) + context.common(['--version']) + + with pytest.raises(subprocess.CalledProcessError): + context.common(['--csr']) + + new_count_tmpfiles = len(os.listdir(context.workspace)) + assert initial_count_tmpfiles == new_count_tmpfiles -def test_hello_2(context): - assert context.http_01_port - assert context.tls_alpn_01_port - try: - response = requests.get(context.directory_url, verify=False) - response.raise_for_status() - assert response.json() - response.close() - except urllib3.exceptions.InsecureRequestWarning: - pass +def test_hook_dirs_creation(context): + context.common(['register']) + + for hook_dir in misc.list_renewal_hooks_dirs(context.config_dir): + assert os.path.isdir(hook_dir) + + +def test_registration_override(context): + context.common(['register']) + context.common(['unregister']) + context.common(['register', '--email', 'ex1@domain.org,ex2@domain.org']) + + # TODO: When `certbot register --update-registration` is fully deprecated, + # delete the two following deprecated uses + context.common(['register', '--update-registration', '--email', 'ex1@domain.org']) + context.common(['register', '--update-registration', '--email', 'ex1@domain.org,ex2@domain.org']) + + context.common(['update_account', '--email', 'example@domain.org']) + context.common(['update_account', '--email', 'ex1@domain.org,ex2@domain.org']) + + +def test_prepare_plugins(context): + output = context.common(['plugins', '--init', '--prepare']) + + assert 'webroot' in output + + +def test_http_01(context): + with misc.create_tcp_server(context.tls_alpn_01_port): + certname = context.wtf('le2') + context.common([ + '--domains', certname, '--preferred-challenges', 'http-01', 'run', + '--cert-name', certname, + '--pre-hook', 'echo wtf.pre >> "{0}"'.format(context.hook_probe), + '--post-hook', 'echo wtf.post >> "{0}"'.format(context.hook_probe), + '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe) + ]) + + assert_hook_execution(context.hook_probe, 'deploy') + assert_save_renew_hook(context.config_dir, certname) + + +def test_manual_http_auth(context): + with misc.create_tcp_server(context.http_01_port) as webroot: + manual_http_hooks = misc.manual_http_hooks(webroot) + + certname = context.wtf() + context.common([ + 'certonly', '-a', 'manual', '-d', certname, + '--cert-name', certname, + '--manual-auth-hook', manual_http_hooks[0], + '--manual-cleanup-hook', manual_http_hooks[1], + '--pre-hook', 'echo wtf.pre >> "{0}"'.format(context.hook_probe), + '--post-hook', 'echo wtf.post >> "{0}"'.format(context.hook_probe), + '--renew-hook', 'echo renew >> "{0}"'.format(context.hook_probe) + ]) + + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'renew') + assert_save_renew_hook(context.config_dir, certname) + + +def test_manual_dns_auth(context): + certname = context.wtf('dns') + context.common([ + '-a', 'manual', '-d', certname, '--preferred-challenges', 'dns', + 'run', '--cert-name', certname, + '--manual-auth-hook', context.manual_dns_auth_hook, + '--manual-cleanup-hook', context.manual_dns_cleanup_hook, + '--pre-hook', 'echo wtf.pre >> "{0}"'.format(context.hook_probe), + '--post-hook', 'echo wtf.post >> "{0}"'.format(context.hook_probe), + '--renew-hook', 'echo renew >> "{0}"'.format(context.hook_probe) + ]) + + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'renew') + assert_save_renew_hook(context.config_dir, certname) + + +def test_certonly(context): + context.common(['certonly', '--cert-name', 'newname', '-d', context.wtf('newname')]) + + +def test_auth_and_install_with_csr(context): + certname = context.wtf('le3') + key_path = join(context.workspace, 'key.pem') + csr_path = join(context.workspace, 'csr.der') + + misc.generate_csr([certname], key_path, csr_path) + + cert_path = join(context.workspace, 'csr/cert.pem') + chain_path = join(context.workspace, 'csr/chain.pem') + + context.common([ + 'auth', '--csr', csr_path, + '--cert-path', cert_path, + '--chain-path', chain_path + ]) + + print(misc.read_certificate(cert_path)) + print(misc.read_certificate(chain_path)) + + context.common([ + '--domains', certname, 'install', + '--cert-path', cert_path, + '--key-path', key_path + ]) + + +def test_renew(context): + # First, we create a target certificate, with all hook dirs instantiated. + # We should have a new certificate, with hooks executed. + # Check also file permissions. + certname = context.wtf('renew') + context.common([ + 'certonly', '-d', certname, '--rsa-key-size', '4096', + '--preferred-challenges', 'http-01' + ]) + + assert_certs_count_for_lineage(context.config_dir, certname, 1) + assert_world_permissions( + join(context.config_dir, 'archive/{0}/privkey1.pem'.format(certname)), 0) + + # Second, we force renew, and ensure that renewal hooks files are executed. + # Also check that file permissions are correct. + misc.generate_test_file_hooks(context.config_dir, context.hook_probe) + context.common(['renew']) + + assert_certs_count_for_lineage(context.config_dir, certname, 2) + assert_hook_execution(context.hook_probe, 'deploy') + assert_world_permissions( + join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)), 0) + assert_equals_group_owner( + join(context.config_dir, 'archive/{0}/privkey1.pem'.format(certname)), + join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname))) + assert_equals_permissions( + join(context.config_dir, 'archive/{0}/privkey1.pem'.format(certname)), + join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)), 0o074) + + os.chmod(join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)), 0o444) + + # Third, we try to renew without force. + # It is not time, so no renew should occur, and no hooks should be executed. + open(context.hook_probe, 'w').close() + misc.generate_test_file_hooks(context.config_dir, context.hook_probe) + context.common_no_force_renew(['renew']) + + assert_certs_count_for_lineage(context.config_dir, certname, 2) + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'deploy') + + # Fourth, we modify the time when renew occur to 4 years before expiration. + # When trying renew without force, then renew should occur for this large time. + # Also we specify to not use hooks dir, so no hook should be run during this renew. + # Also this renew should use explicitly a 2048 key size. + # And finally we check the file permissions. + open(context.hook_probe, 'w').close() + with open(join(context.config_dir, 'renewal/{0}.conf'.format(certname)), 'r') as file: + lines = file.readlines() + lines.insert(4, 'renew_before_expiry = 100 years{0}'.format(os.linesep)) + with open(join(context.config_dir, 'renewal/{0}.conf'.format(certname)), 'w') as file: + file.writelines(lines) + context.common_no_force_renew(['renew', '--no-directory-hooks', + '--rsa-key-size', '2048']) + + assert_certs_count_for_lineage(context.config_dir, certname, 3) + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'deploy') + key2 = join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)) + key3 = join(context.config_dir, 'archive/{0}/privkey3.pem'.format(certname)) + assert os.stat(key2).st_size > 3000 # 4096 bits keys takes more than 3000 bytes + assert os.stat(key3).st_size < 1800 # 2048 bits keys takes less than 1800 bytes + + assert_world_permissions( + join(context.config_dir, 'archive/{0}/privkey3.pem'.format(certname)), 4) + assert_equals_group_owner( + join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)), + join(context.config_dir, 'archive/{0}/privkey3.pem'.format(certname))) + assert_equals_permissions( + join(context.config_dir, 'archive/{0}/privkey2.pem'.format(certname)), + join(context.config_dir, 'archive/{0}/privkey3.pem'.format(certname)), 0o074) + + # Fifth, we clean every dir hook, and replace their content by empty dir and empty files. + # Everything should renew correctly. + for hook_dir in misc.list_renewal_hooks_dirs(context.config_dir): + shutil.rmtree(hook_dir) + os.makedirs(join(hook_dir, 'dir')) + open(join(hook_dir, 'file'), 'w').close() + context.common(['renew']) + + assert_certs_count_for_lineage(context.config_dir, certname, 4) + + +def test_hook_override(context): + certname = context.wtf('override') + context.common([ + 'certonly', '-d', certname, + '--preferred-challenges', 'http-01', + '--pre-hook', 'echo pre >> "{0}"'.format(context.hook_probe), + '--post-hook', 'echo post >> "{0}"'.format(context.hook_probe), + '--deploy-hook', 'echo deploy >> "{0}"'.format(context.hook_probe) + ]) + + assert_hook_execution(context.hook_probe, 'pre') + assert_hook_execution(context.hook_probe, 'post') + assert_hook_execution(context.hook_probe, 'deploy') + + open(context.hook_probe, 'w').close() + context.common([ + 'renew', '--cert-name', certname, + '--pre-hook', 'echo pre-override >> "{0}"'.format(context.hook_probe), + '--post-hook', 'echo post-override >> "{0}"'.format(context.hook_probe), + '--deploy-hook', 'echo deploy-override >> "{0}"'.format(context.hook_probe) + ]) + + assert_hook_execution(context.hook_probe, 'pre-override') + assert_hook_execution(context.hook_probe, 'post-override') + assert_hook_execution(context.hook_probe, 'deploy-override') + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'pre') + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'post') + with pytest.raises(AssertionError): + assert_hook_execution(context.hook_probe, 'deploy') + + open(context.hook_probe, 'w').close() + context.common(['renew', '--cert-name', certname]) + + assert_hook_execution(context.hook_probe, 'pre-override') + assert_hook_execution(context.hook_probe, 'post-override') + assert_hook_execution(context.hook_probe, 'deploy-override') + + +def test_invalid_domain_with_dns_challenge(context): + certs = ','.join([context.wtf('dns1'), context.wtf('fail-dns1')]) + context.common([ + '-a', 'manual', '-d', certs, + '--allow-subset-of-names', + '--preferred-challenges', 'dns', + '--manual-auth-hook', context.manual_dns_auth_hook, + '--manual-cleanup-hook', context.manual_dns_cleanup_hook + ]) + + output = context.common(['certificates']) + + assert context.wtf('fail-dns1') not in output + + +def test_reuse_key(context): + certname = context.wtf('reusekey') + context.common(['--domains', certname, '--reuse-key']) + context.common(['renew', '--cert-name', certname]) + + with open(join(context.config_dir, 'archive/{0}/privkey1.pem').format(certname), 'r') as file: + privkey1 = file.read() + with open(join(context.config_dir, 'archive/{0}/privkey2.pem').format(certname), 'r') as file: + privkey2 = file.read() + assert privkey1 == privkey2 + + context.common(['--cert-name', certname, '--domains', certname, '--force-renewal']) + + with open(join(context.config_dir, 'archive/{0}/privkey3.pem').format(certname), 'r') as file: + privkey3 = file.read() + assert privkey2 != privkey3 + + with open(join(context.config_dir, 'archive/{0}/cert1.pem').format(certname), 'r') as file: + cert1 = file.read() + with open(join(context.config_dir, 'archive/{0}/cert2.pem').format(certname), 'r') as file: + cert2 = file.read() + with open(join(context.config_dir, 'archive/{0}/cert3.pem').format(certname), 'r') as file: + cert3 = file.read() + + assert len({cert1, cert2, cert3}) == 3 + + +def test_ecdsa(context): + key_path = join(context.workspace, 'privkey-p384.pem') + csr_path = join(context.workspace, 'csr-p384.der') + cert_path = join(context.workspace, 'cert-p384.pem') + chain_path = join(context.workspace, 'chain-p384.pem') + + misc.generate_csr([context.wtf('ecdsa')], key_path, csr_path, key_type='ECDSA') + context.common(['auth', '--csr', csr_path, '--cert-path', cert_path, '--chain-path', chain_path]) + + certificate = misc.read_certificate(cert_path) + assert 'ASN1 OID: secp384r1' in certificate + + +def test_ocsp_must_staple(context): + certname = context.wtf('must-staple') + context.common(['auth', '--must-staple', '--domains', certname]) + + certificate = misc.read_certificate(join(context.config_dir, + 'live/{0}/cert.pem').format(certname)) + assert 'status_request' in certificate or '1.3.6.1.5.5.7.1.24' + + +def test_revoke_simple(context): + certname = context.wtf() + cert_path = join(context.config_dir, 'live/{0}/cert.pem'.format(certname)) + context.common(['-d', certname]) + context.common(['revoke', '--cert-path', cert_path, '--delete-after-revoke']) + + assert not exists(cert_path) + + certname = context.wtf('le1') + cert_path = join(context.config_dir, 'live/{0}/cert.pem'.format(certname)) + context.common(['-d', certname]) + context.common(['revoke', '--cert-path', cert_path, '--no-delete-after-revoke']) + + assert exists(cert_path) + context.common(['delete', '--cert-name', certname]) + + assert not exists(join(context.config_dir, 'archive/{0}'.format(certname))) + assert not exists(join(context.config_dir, 'live/{0}'.format(certname))) + assert not exists(join(context.config_dir, 'renewal/{0}.conf').format(certname)) + + certname = context.wtf('le2') + key_path = join(context.config_dir, 'live/{0}/privkey.pem'.format(certname)) + cert_path = join(context.config_dir, 'live/{0}/cert.pem'.format(certname)) + context.common(['-d', certname]) + context.common(['revoke', '--cert-path', cert_path, '--key-path', key_path]) + + +def test_revoke_and_unregister(context): + cert1 = context.wtf('le1') + cert2 = context.wtf('le2') + cert3 = context.wtf('le3') + + cert_path1 = join(context.config_dir, 'live/{0}/cert.pem'.format(cert1)) + key_path2 = join(context.config_dir, 'live/{0}/privkey.pem'.format(cert2)) + cert_path2 = join(context.config_dir, 'live/{0}/cert.pem'.format(cert2)) + + context.common(['-d', cert1]) + context.common(['-d', cert2]) + context.common(['-d', cert3]) + + context.common(['revoke', '--cert-path', cert_path1, + '--reason', 'cessationOfOperation']) + context.common(['revoke', '--cert-path', cert_path2, '--key-path', key_path2, + '--reason', 'keyCompromise']) + + context.common(['unregister']) + + output = context.common(['certificates']) + + assert cert1 not in output + assert cert2 not in output + assert cert3 in output + + +def test_revoke_corner_cases(context): + cert1 = context.wtf('le1') + context.common(['-d', cert1]) + with pytest.raises(subprocess.CalledProcessError) as error: + context.common([ + 'revoke', '--cert-name', cert1, + '--cert-path', join(context.config_dir, 'live/{0}/fullchain.pem'.format(cert1)) + ]) + assert 'Exactly one of --cert-path or --cert-name must be specified' in error.out + + assert os.path.isfile(join(context.config_dir, 'renewal/{0}.conf'.format(cert1))) + + cert2 = context.wtf('le2') + context.common(['-d', cert2]) + with open(join(context.config_dir, 'renewal/{0}.conf'.format(cert2)), 'r') as file: + data = file.read() + + data = re.sub('archive_dir = .*{0}'.format(os.linesep), + 'archive_dir = {0}{1}'.format(os.path.normpath( + join(context.config_dir, 'archive/{0}'.format(cert1))), os.linesep), + data) + + with open(join(context.config_dir, 'renewal/{0}.conf'.format(cert2)), 'w') as file: + file.write(data) + + output = context.common([ + 'revoke', '--cert-path', join(context.config_dir, 'live/{0}/cert.pem'.format(cert1)) + ]) + + assert 'Not deleting revoked certs due to overlapping archive dirs' in output + + +def test_wildcard_certificates(context): + if context.acme_server == 'boulder-v1': + pytest.skip('Wildcard certificates are not supported on ACME v1') + + certname = context.wtf('wild') + + context.common([ + '-a', 'manual', '-d', '*.{0},{0}'.format(certname), + '--preferred-challenge', 'dns', + '--manual-auth-hook', context.manual_dns_auth_hook, + '--manual-cleanup-hook', context.manual_dns_cleanup_hook + ]) + + assert exists(join(context.config_dir, 'live/{0}/fullchain.pem'.format(certname))) + + +def test_ocsp_status(context): + if context.acme_server == 'pebble': + pytest.skip('Pebble does not support OCSP status requests.') + + # OCSP 1: Check stale OCSP status + sample_data_path = misc.load_sample_data_path(context.workspace) + output = context.common(['certificates', '--config-dir', sample_data_path]) + + assert output.count('TEST_CERT') == 2, ('Did not find two test certs as expected ({0})' + .format(output.count('TEST_CERT'))) + assert output.count('EXPIRED') == 2, ('Did not find two expired certs as expected ({0})' + .format(output.count('EXPIRED'))) + + # OSCP 2: Check live certificate OCSP status (VALID) + output = context.common(['--domains', 'le-ocsp-check.wtf']) + + assert output.count('VALID') == 1, 'Expected le-ocsp-check.wtf to be VALID' + assert output.count('EXPIRED') == 0, 'Did not expect le-ocsp-check.wtf to be EXPIRED' + + # OSCP 3: Check live certificate OCSP status (REVOKED) + output = context.common(['certificates']) + + assert output.count('INVALID') == 1, 'Expected le-ocsp-check.wtf to be INVALID' + assert output.count('REVOKED') == 1, 'Expected le-ocsp-check.wtf to be REVOKED' diff --git a/certbot-ci/certbot_integration_tests/utils/misc.py b/certbot-ci/certbot_integration_tests/utils/misc.py index a3b134788..cdc051877 100644 --- a/certbot-ci/certbot_integration_tests/utils/misc.py +++ b/certbot-ci/certbot_integration_tests/utils/misc.py @@ -2,10 +2,20 @@ Misc module contains stateless functions that could be used during pytest execution, or outside during setup/teardown of the integration tests environment. """ +import subprocess import os import time import contextlib +import tempfile +import shutil +import multiprocessing +import sys +import stat +import errno +from distutils.version import LooseVersion +from six.moves import socketserver, SimpleHTTPServer +from OpenSSL import crypto import requests @@ -30,16 +40,142 @@ def check_until_timeout(url): raise ValueError('Error, url did not respond after 150 attempts: {0}'.format(url)) +def find_certbot_root_directory(): + script_path = os.path.realpath(__file__) + current_dir = os.path.dirname(script_path) + + while '.git' not in os.listdir(current_dir) and current_dir != os.path.dirname(current_dir): + current_dir = os.path.dirname(current_dir) + + dirs = os.listdir(current_dir) + if '.git' not in dirs: + raise ValueError('Error, could not find certbot sources root directory') + + return current_dir + + +def generate_csr(domains, key_path, csr_path, key_type='RSA'): + certbot_root_directory = find_certbot_root_directory() + script_path = os.path.join(certbot_root_directory, 'examples', 'generate-csr.py') + + command = [ + sys.executable, script_path, '--key-path', key_path, '--csr-path', csr_path, + '--key-type', key_type + ] + command.extend(domains) + subprocess.check_call(command) + + +def load_sample_data_path(workspace): + certbot_root_directory = find_certbot_root_directory() + original = os.path.join(certbot_root_directory, 'tests', 'integration', 'sample-config') + copied = os.path.join(workspace, 'sample-config') + shutil.copytree(original, copied, symlinks=True) + return copied + + +def read_certificate(cert_path): + with open(cert_path, 'r') as file: + data = file.read() + + cert = crypto.load_certificate(crypto.FILETYPE_PEM, data.encode('utf-8')) + return crypto.dump_certificate(crypto.FILETYPE_TEXT, cert).decode('utf-8') + + +class GraceFullTCPServer(socketserver.TCPServer): + allow_reuse_address = True + + @contextlib.contextmanager -def execute_in_given_cwd(cwd): - """ - Context manager that will execute any command in the given cwd after entering context, - and restore current cwd when context is destroyed. - :param str cwd: the path to use as the temporary current workspace for python execution - """ +def create_tcp_server(port): current_cwd = os.getcwd() + webroot = tempfile.mkdtemp() + + def run(): + GraceFullTCPServer(('', port), SimpleHTTPServer.SimpleHTTPRequestHandler).serve_forever() + + process = multiprocessing.Process(target=run) + try: - os.chdir(cwd) - yield + try: + os.chdir(webroot) + process.start() + finally: + os.chdir(current_cwd) + + check_until_timeout('http://localhost:{0}/'.format(port)) + + yield webroot finally: - os.chdir(current_cwd) + try: + if process.is_alive(): + process.terminate() + process.join() # Block until process is effectively terminated + finally: + shutil.rmtree(webroot) + + +def list_renewal_hooks_dirs(config_dir): + renewal_hooks_root = os.path.join(config_dir, 'renewal-hooks') + return [os.path.join(renewal_hooks_root, item) for item in ['pre', 'deploy', 'post']] + + +def generate_test_file_hooks(config_dir, hook_probe): + if sys.platform == 'win32': + extension = 'bat' + else: + extension = 'sh' + + renewal_hooks_dirs = list_renewal_hooks_dirs(config_dir) + + for hook_dir in renewal_hooks_dirs: + try: + os.makedirs(hook_dir) + except OSError as error: + if error.errno != errno.EEXIST: + raise + hook_path = os.path.join(hook_dir, 'hook.{0}'.format(extension)) + if extension == 'sh': + data = '''\ +#!/bin/bash -xe +if [ "$0" == "{0}" ]; then + if [ -z "$RENEWED_DOMAINS" -o -z "$RENEWED_LINEAGE" ]; then + echo "Environment variables not properly set!" >&2 + exit 1 + fi +fi +echo $(basename $(dirname "$0")) >> "{1}"\ +'''.format(hook_path, hook_probe) + else: + data = '''\ + +''' + with open(hook_path, 'w') as file: + file.write(data) + os.chmod(hook_path, os.stat(hook_path).st_mode | stat.S_IEXEC) + + +def manual_http_hooks(http_server_root): + auth = ( + '{0} -c "import os; ' + "challenge_dir = os.path.join('{1}', '.well-known/acme-challenge'); " + 'os.makedirs(challenge_dir); ' + "challenge_file = os.path.join(challenge_dir, os.environ.get('CERTBOT_TOKEN')); " + "open(challenge_file, 'w').write(os.environ.get('CERTBOT_VALIDATION')); " + '"' + ).format(sys.executable, http_server_root) + cleanup = ( + '{0} -c "import os; import shutil; ' + "well_known = os.path.join('{1}', '.well-known'); " + 'shutil.rmtree(well_known); ' + '"' + ).format(sys.executable, http_server_root) + + return auth, cleanup + + +def get_certbot_version(): + output = subprocess.check_output(['certbot', '--version'], universal_newlines=True) + # Typical response is: output = 'certbot 0.31.0.dev0' + # version_str = output.split(' ')[1] + return LooseVersion('0.31.0.dev0') diff --git a/certbot-ci/setup.py b/certbot-ci/setup.py index 595bba69e..aefbd0dca 100644 --- a/certbot-ci/setup.py +++ b/certbot-ci/setup.py @@ -8,8 +8,12 @@ install_requires = [ 'pytest', 'pytest-cov', 'pytest-xdist', + 'pytest-rerunfailures==4.2', 'pytest-sugar', 'coverage', + 'six', + 'pyopenssl', + 'cryptography', 'requests', 'pyyaml', ] diff --git a/examples/generate-csr.py b/examples/generate-csr.py new file mode 100644 index 000000000..e068c962c --- /dev/null +++ b/examples/generate-csr.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# This script generates a simple SAN CSR to be used with Let's Encrypt +# CA. Mostly intended for "auth --csr" testing, but, since it's easily +# auditable, feel free to adjust it and use it on your production web +# server. +import os +import argparse +from OpenSSL import crypto + +try: + from cryptography.hazmat.backends import default_backend + from cryptography.hazmat.primitives.asymmetric import rsa, ec + from cryptography.hazmat.primitives.serialization import Encoding, PrivateFormat, NoEncryption + cryptography=True +except ImportError: + cryptography=False + +CWD = os.getcwd() + + +def generate_parser(): + one_parser = argparse.ArgumentParser(description='Generate a simple SAN CSR') + one_parser.add_argument('domain', nargs='+', help='A FQDN to add in the CSR.') + one_parser.add_argument('--key-path', default=os.path.join(CWD, 'key.pem'), + help='Path for the generated key ' + '(default is key.pem in current directory)') + one_parser.add_argument('--csr-path', default=os.path.join(CWD, 'csr.der'), + help='Path for the generated certificate ' + '(default is csr.der in current directory)') + one_parser.add_argument('--key-type', default='RSA', + choices=['RSA', 'ECDSA'], + help='Key type to use, ECDSA is supported only ' + 'if cryptography module is available') + + return one_parser + + +def main(domains, key_path, csr_path, key_type): + san = ', '.join(['DNS: {0}'.format(item) for item in domains]) + + if key_type == 'RSA': + key = crypto.PKey() + key.generate_key(crypto.TYPE_RSA, 2048) + elif key_type == 'ECDSA': + if not cryptography: + raise ValueError('Error, cryptography module is not installed,' + 'but is required to use ECDSA') + + key = ec.generate_private_key(ec.SECP384R1(), default_backend()) + key = key.private_bytes(encoding=Encoding.PEM, format=PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=NoEncryption()) + key = crypto.load_privatekey(crypto.FILETYPE_PEM, key) + else: + raise ValueError('Invalid key type: {0}'.format(key_type)) + + with open(key_path, 'wb') as file: + file.write(crypto.dump_privatekey(crypto.FILETYPE_PEM, key)) + + req = crypto.X509Req() + san_constraint = crypto.X509Extension(b'subjectAltName', False, san.encode('utf-8')) + req.add_extensions([san_constraint]) + + req.set_pubkey(key) + req.sign(key, 'sha256') + + with open(csr_path, 'wb') as file: + file.write(crypto.dump_certificate_request(crypto.FILETYPE_ASN1, req)) + + print('You can now run: certbot auth --csr {0}'.format(csr_path)) + + +if __name__ == '__main__': + parser = generate_parser() + namespace = parser.parse_args() + main(namespace.domain, namespace.key_path, namespace.csr_path, namespace.key_type) diff --git a/tools/dev_constraints.txt b/tools/dev_constraints.txt index 539791a69..6bfc18ced 100644 --- a/tools/dev_constraints.txt +++ b/tools/dev_constraints.txt @@ -52,6 +52,7 @@ pytest-cov==2.5.1 pytest-forked==0.2 pytest-xdist==1.22.5 pytest-sugar==0.9.2 +pytest-rerunfailures==4.2 python-dateutil==2.6.1 python-digitalocean==1.11 PyYAML==3.13