From d4c16f51be642d1e4d5773a7d48841efc96530fa Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Mon, 30 Jul 2018 20:10:17 +0200 Subject: [PATCH] New acme_* integration test using ACME test docker container (#41626) * Using ACME test container for acme_account integration test. * Removing dependency on setup_openssl. Waiting for controller and Pebble. * More tinkering. * Reducing number of tries. * One more try. * Another try. * Added acme_certificate tests. * Removed double key. * Added tests for acme_certificate_revoke. * Making task names more meaningful (during certificate generation). * Using newer test container which integrates letsencrypt/pebble#137. Adding test for revoking certificate by its private key. * Using new version of Pebble which limits the random auth delay. * Simplifying certificates for revocation tests. * Reworking acme_certificate tests (there are now more, but they are faster). * Test whether account_key_content works. * Preparing TLS-ALPN-01 support. * Using official Ansible image of testing container on quay.io. * Bumping version. * Bumping version of test container to 1.1.0. * Adjusting to new CI group names. * Pass ACME simulator IP as playbook variable. * Let test plugin wait for controller and CA endpoints to become active. * Refactor common setup parts of tests to setup_acme. * _ -> dummy * Moving common obtain-cert.yml to setup_acme. --- test/integration/targets/acme_account/aliases | 5 +- .../targets/acme_account/meta/main.yml | 2 +- .../targets/acme_account/tasks/main.yml | 107 ++++++--- .../targets/acme_account/tests/validate.yml | 44 +++- .../targets/acme_certificate/aliases | 2 + .../targets/acme_certificate/meta/main.yml | 2 + .../targets/acme_certificate/tasks/main.yml | 217 ++++++++++++++++++ .../acme_certificate/tasks/obtain-cert.yml | 1 + .../acme_certificate/tests/validate.yml | 64 ++++++ .../targets/acme_certificate_revoke/aliases | 2 + .../acme_certificate_revoke/meta/main.yml | 2 + .../acme_certificate_revoke/tasks/main.yml | 90 ++++++++ .../tasks/obtain-cert.yml | 1 + .../tests/validate.yml | 16 ++ .../targets/setup_acme/tasks/main.yml | 10 + .../targets/setup_acme/tasks/obtain-cert.yml | 153 ++++++++++++ test/runner/lib/cloud/acme.py | 193 ++++++++++++++++ 17 files changed, 863 insertions(+), 48 deletions(-) create mode 100644 test/integration/targets/acme_certificate/aliases create mode 100644 test/integration/targets/acme_certificate/meta/main.yml create mode 100644 test/integration/targets/acme_certificate/tasks/main.yml create mode 120000 test/integration/targets/acme_certificate/tasks/obtain-cert.yml create mode 100644 test/integration/targets/acme_certificate/tests/validate.yml create mode 100644 test/integration/targets/acme_certificate_revoke/aliases create mode 100644 test/integration/targets/acme_certificate_revoke/meta/main.yml create mode 100644 test/integration/targets/acme_certificate_revoke/tasks/main.yml create mode 120000 test/integration/targets/acme_certificate_revoke/tasks/obtain-cert.yml create mode 100644 test/integration/targets/acme_certificate_revoke/tests/validate.yml create mode 100644 test/integration/targets/setup_acme/tasks/main.yml create mode 100644 test/integration/targets/setup_acme/tasks/obtain-cert.yml create mode 100644 test/runner/lib/cloud/acme.py diff --git a/test/integration/targets/acme_account/aliases b/test/integration/targets/acme_account/aliases index b159f9e7601..d7936330302 100644 --- a/test/integration/targets/acme_account/aliases +++ b/test/integration/targets/acme_account/aliases @@ -1,3 +1,2 @@ -shippable/posix/group1 -destructive -disabled +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_account/meta/main.yml b/test/integration/targets/acme_account/meta/main.yml index 800aff64284..81d1e7e77a5 100644 --- a/test/integration/targets/acme_account/meta/main.yml +++ b/test/integration/targets/acme_account/meta/main.yml @@ -1,2 +1,2 @@ dependencies: - - setup_openssl + - setup_acme diff --git a/test/integration/targets/acme_account/tasks/main.yml b/test/integration/targets/acme_account/tasks/main.yml index 83b74d9c866..8879ddce55c 100644 --- a/test/integration/targets/acme_account/tasks/main.yml +++ b/test/integration/targets/acme_account/tasks/main.yml @@ -1,7 +1,5 @@ --- - block: - - debug: var=openssl_version.stdout - - name: Generate account key command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/accountkey.pem @@ -12,7 +10,8 @@ acme_account: account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: present allow_creation: no ignore_errors: yes @@ -22,7 +21,8 @@ acme_account: account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: present allow_creation: yes terms_agreed: yes @@ -32,9 +32,10 @@ - name: Change email address acme_account: - account_key_src: "{{ output_dir }}/accountkey.pem" + account_key_content: "{{ lookup('file', output_dir ~ '/accountkey.pem') }}" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: present # allow_creation: no contact: @@ -45,7 +46,8 @@ acme_account: account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: present # allow_creation: no contact: @@ -58,52 +60,91 @@ - name: Parse account key (to ease debugging some test failures) command: openssl ec -in {{ output_dir }}/accountkey2.pem -noout -text - - name: Change account key - acme_account: - account_key_src: "{{ output_dir }}/accountkey.pem" - acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory - new_account_key_src: "{{ output_dir }}/accountkey2.pem" - state: changed_key - contact: - - mailto:example@example.com - register: account_change_key +# Note that pebble has no change key endpoint implemented yet! +# When it has (and the container was updated), uncomment the +# uncomment the following tests, and delete the ones below the +# out-commented ones. + +# - name: Change account key +# acme_account: +# account_key_src: "{{ output_dir }}/accountkey.pem" +# acme_version: 2 +# acme_directory: https://{{ acme_host }}:14000/dir +# validate_certs: no +# new_account_key_src: "{{ output_dir }}/accountkey2.pem" +# state: changed_key +# contact: +# - mailto:example@example.com +# register: account_change_key + +# - name: Deactivate account +# acme_account: +# account_key_src: "{{ output_dir }}/accountkey2.pem" +# acme_version: 2 +# acme_directory: https://{{ acme_host }}:14000/dir +# validate_certs: no +# state: absent +# register: account_deactivate + +# - name: Deactivate account (idempotent) +# acme_account: +# account_key_src: "{{ output_dir }}/accountkey2.pem" +# acme_version: 2 +# acme_directory: https://{{ acme_host }}:14000/dir +# validate_certs: no +# state: absent +# register: account_deactivate_idempotent + +# - name: Do not try to create account II +# acme_account: +# account_key_src: "{{ output_dir }}/accountkey2.pem" +# acme_version: 2 +# acme_directory: https://{{ acme_host }}:14000/dir +# validate_certs: no +# state: present +# allow_creation: no +# ignore_errors: yes +# register: account_not_created_2 + +# - name: Do not try to create account III +# acme_account: +# account_key_src: "{{ output_dir }}/accountkey.pem" +# acme_version: 2 +# acme_directory: https://{{ acme_host }}:14000/dir +# validate_certs: no +# state: present +# allow_creation: no +# ignore_errors: yes +# register: account_not_created_3 - name: Deactivate account acme_account: - account_key_src: "{{ output_dir }}/accountkey2.pem" + account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: absent register: account_deactivate - name: Deactivate account (idempotent) acme_account: - account_key_src: "{{ output_dir }}/accountkey2.pem" + account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: absent register: account_deactivate_idempotent - name: Do not try to create account II - acme_account: - account_key_src: "{{ output_dir }}/accountkey2.pem" - acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory - state: present - allow_creation: no - ignore_errors: yes - register: account_not_created_2 - - - name: Do not try to create account III acme_account: account_key_src: "{{ output_dir }}/accountkey.pem" acme_version: 2 - acme_directory: https://acme-staging-v02.api.letsencrypt.org/directory + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no state: present allow_creation: no ignore_errors: yes - register: account_not_created_3 + register: account_not_created_2 # Old 0.9.8 versions have insufficient CLI support for signing with EC keys when: openssl_version.stdout is version('1.0.0', '>=') diff --git a/test/integration/targets/acme_account/tests/validate.yml b/test/integration/targets/acme_account/tests/validate.yml index cb0edd8f608..70bba0d6954 100644 --- a/test/integration/targets/acme_account/tests/validate.yml +++ b/test/integration/targets/acme_account/tests/validate.yml @@ -22,11 +22,38 @@ - account_modified_idempotent is not changed - account_modified_idempotent.account_uri is not none -- name: Validate that the account key was changed - assert: - that: - - account_change_key is changed - - account_change_key.account_uri is not none +# Note that pebble has no change key endpoint implemented yet! +# When it has (and the container was updated), uncomment the +# following validations, and delete the ones below the +# out-commented ones: + +#- name: Validate that the account key was changed +# assert: +# that: +# - account_change_key is changed +# - account_change_key.account_uri is not none +# +#- name: Validate that the account was deactivated +# assert: +# that: +# - account_deactivate is changed +# - account_deactivate.account_uri is not none +# +#- name: Validate that the account was really deactivated (idempotency) +# assert: +# that: +# - account_deactivate_idempotent is not changed +# - account_deactivate_idempotent.account_uri is not none +# +#- name: Validate that the account is gone (new account key) +# assert: +# that: +# - account_not_created_2 is failed +# +#- name: Validate that the account is gone (old account key) +# assert: +# that: +# - account_not_created_3 is failed - name: Validate that the account was deactivated assert: @@ -40,12 +67,7 @@ - account_deactivate_idempotent is not changed - account_deactivate_idempotent.account_uri is not none -- name: Validate that the account is gone (new account key) +- name: Validate that the account is gone assert: that: - account_not_created_2 is failed - -- name: Validate that the account is gone (old account key) - assert: - that: - - account_not_created_3 is failed diff --git a/test/integration/targets/acme_certificate/aliases b/test/integration/targets/acme_certificate/aliases new file mode 100644 index 00000000000..d7936330302 --- /dev/null +++ b/test/integration/targets/acme_certificate/aliases @@ -0,0 +1,2 @@ +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_certificate/meta/main.yml b/test/integration/targets/acme_certificate/meta/main.yml new file mode 100644 index 00000000000..81d1e7e77a5 --- /dev/null +++ b/test/integration/targets/acme_certificate/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_acme diff --git a/test/integration/targets/acme_certificate/tasks/main.yml b/test/integration/targets/acme_certificate/tasks/main.yml new file mode 100644 index 00000000000..88a3068954a --- /dev/null +++ b/test/integration/targets/acme_certificate/tasks/main.yml @@ -0,0 +1,217 @@ +--- +- block: + ## SET UP ACCOUNT KEYS ######################################################################## + - name: Create ECC256 account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem + - name: Create ECC384 account key + command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem + - name: Create RSA-2048 account key + command: openssl genrsa -out {{ output_dir }}/account-rsa2048.pem 2048 + ## SET UP ACCOUNTS ############################################################################ + - name: Make sure ECC256 account hasn't been created yet + acme_account: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key_src: "{{ output_dir }}/account-ec256.pem" + state: absent + - name: Create ECC384 account + acme_account: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}" + state: present + allow_creation: yes + terms_agreed: yes + contact: + - mailto:example@example.org + - mailto:example@example.com + - name: Create RSA-2048 account + acme_account: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key_src: "{{ output_dir }}/account-rsa2048.pem" + state: present + allow_creation: yes + terms_agreed: yes + contact: [] + ## OBTAIN CERTIFICATES ######################################################################## + - name: Obtain cert 1 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 1 + certificate_name: cert-1 + key_type: rsa + rsa_bits: 2048 + subject_alt_name: "DNS:example.com" + subject_alt_name_critical: no + account_key: account-ec256 + challenge: http-01 + modify_account: yes + deactivate_authzs: no + force: no + remaining_days: 10 + terms_agreed: yes + account_email: "example@example.org" + - name: Obtain cert 2 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 2 + certificate_name: cert-2 + key_type: ec256 + subject_alt_name: "DNS:*.example.com,DNS:example.com" + subject_alt_name_critical: yes + account_key: account-ec384 + challenge: dns-01 + modify_account: no + deactivate_authzs: yes + force: no + remaining_days: 10 + terms_agreed: no + account_email: "" + - name: Obtain cert 3 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 3 + certificate_name: cert-3 + key_type: ec384 + subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com" + subject_alt_name_critical: no + account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa2048.pem') }}" + challenge: dns-01 + modify_account: no + deactivate_authzs: no + force: no + remaining_days: 10 + terms_agreed: no + account_email: "" + - name: Obtain cert 4 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 4 + certificate_name: cert-4 + key_type: rsa + rsa_bits: 2048 + subject_alt_name: "DNS:example.com,DNS:t1.example.com,DNS:test.t2.example.com,DNS:example.org,DNS:test.example.org" + subject_alt_name_critical: no + account_key: account-rsa2048 + challenge: http-01 + modify_account: no + deactivate_authzs: yes + force: yes + remaining_days: 10 + terms_agreed: no + account_email: "" + - name: Obtain cert 5 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 5, Iteration 1/4 + certificate_name: cert-5 + key_type: ec521 + subject_alt_name: "DNS:t2.example.com" + subject_alt_name_critical: no + account_key: account-ec384 + challenge: http-01 + modify_account: no + deactivate_authzs: yes + force: yes + remaining_days: 10 + terms_agreed: no + account_email: "" + - name: Obtain cert 5 (should not, since already there and valid for more than 10 days) + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 5, Iteration 2/4 + certificate_name: cert-5 + key_type: ec521 + subject_alt_name: "DNS:t2.example.com" + subject_alt_name_critical: no + account_key: account-ec384 + challenge: http-01 + modify_account: no + deactivate_authzs: yes + force: no + remaining_days: 10 + terms_agreed: no + account_email: "" + - set_fact: + cert_5_recreate_1: "{{ challenge_data is changed }}" + - name: Obtain cert 5 (should again by less days) + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 5, Iteration 3/4 + certificate_name: cert-5 + key_type: ec521 + subject_alt_name: "DNS:t2.example.com" + subject_alt_name_critical: no + account_key: account-ec384 + challenge: http-01 + modify_account: no + deactivate_authzs: yes + force: yes + remaining_days: 1000 + terms_agreed: no + account_email: "" + - set_fact: + cert_5_recreate_2: "{{ challenge_data is changed }}" + - name: Obtain cert 5 (should again by force) + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 5, Iteration 4/4 + certificate_name: cert-5 + key_type: ec521 + subject_alt_name: "DNS:t2.example.com" + subject_alt_name_critical: no + account_key_content: "{{ lookup('file', output_dir ~ '/account-ec384.pem') }}" + challenge: http-01 + modify_account: no + deactivate_authzs: yes + force: yes + remaining_days: 10 + terms_agreed: no + account_email: "" + - set_fact: + cert_5_recreate_3: "{{ challenge_data is changed }}" + ## DISSECT CERTIFICATES ####################################################################### + # Make sure certificates are valid. Root certificate for Pebble equals the chain certificate. + - name: Verifying cert 1 + command: openssl verify -CAfile "{{ output_dir }}/cert-1-chain.pem" "{{ output_dir }}/cert-1.pem" + ignore_errors: yes + register: cert_1_valid + - name: Verifying cert 2 + command: openssl verify -CAfile "{{ output_dir }}/cert-2-chain.pem" "{{ output_dir }}/cert-2.pem" + ignore_errors: yes + register: cert_2_valid + - name: Verifying cert 3 + command: openssl verify -CAfile "{{ output_dir }}/cert-3-chain.pem" "{{ output_dir }}/cert-3.pem" + ignore_errors: yes + register: cert_3_valid + - name: Verifying cert 4 + command: openssl verify -CAfile "{{ output_dir }}/cert-4-chain.pem" "{{ output_dir }}/cert-4.pem" + ignore_errors: yes + register: cert_4_valid + - name: Verifying cert 5 + command: openssl verify -CAfile "{{ output_dir }}/cert-5-chain.pem" "{{ output_dir }}/cert-5.pem" + ignore_errors: yes + register: cert_5_valid + # Dump certificate info + - name: Dumping cert 1 + command: openssl x509 -in "{{ output_dir }}/cert-1.pem" -noout -text + register: cert_1_text + - name: Dumping cert 2 + command: openssl x509 -in "{{ output_dir }}/cert-2.pem" -noout -text + register: cert_2_text + - name: Dumping cert 3 + command: openssl x509 -in "{{ output_dir }}/cert-3.pem" -noout -text + register: cert_3_text + - name: Dumping cert 4 + command: openssl x509 -in "{{ output_dir }}/cert-4.pem" -noout -text + register: cert_4_text + - name: Dumping cert 5 + command: openssl x509 -in "{{ output_dir }}/cert-5.pem" -noout -text + register: cert_5_text + + # Old 0.9.8 versions have insufficient CLI support for signing with EC keys + when: openssl_version.stdout is version('1.0.0', '>=') diff --git a/test/integration/targets/acme_certificate/tasks/obtain-cert.yml b/test/integration/targets/acme_certificate/tasks/obtain-cert.yml new file mode 120000 index 00000000000..532df9452ea --- /dev/null +++ b/test/integration/targets/acme_certificate/tasks/obtain-cert.yml @@ -0,0 +1 @@ +../../setup_acme/tasks/obtain-cert.yml \ No newline at end of file diff --git a/test/integration/targets/acme_certificate/tests/validate.yml b/test/integration/targets/acme_certificate/tests/validate.yml new file mode 100644 index 00000000000..6d1954067cd --- /dev/null +++ b/test/integration/targets/acme_certificate/tests/validate.yml @@ -0,0 +1,64 @@ +--- +- name: Check that certificate 1 is valid + assert: + that: + - cert_1_valid is not failed +- name: Check that certificate 1 contains correct SANs + assert: + that: + - "'DNS:example.com' in cert_1_text.stdout" + +- name: Check that certificate 2 is valid + assert: + that: + - cert_2_valid is not failed +- name: Check that certificate 2 contains correct SANs + assert: + that: + - "'DNS:*.example.com' in cert_2_text.stdout" + - "'DNS:example.com' in cert_2_text.stdout" + +- name: Check that certificate 3 is valid + assert: + that: + - cert_3_valid is not failed +- name: Check that certificate 3 contains correct SANs + assert: + that: + - "'DNS:*.example.com' in cert_3_text.stdout" + - "'DNS:example.org' in cert_3_text.stdout" + - "'DNS:t1.example.com' in cert_3_text.stdout" + +- name: Check that certificate 4 is valid + assert: + that: + - cert_4_valid is not failed +- name: Check that certificate 4 contains correct SANs + assert: + that: + - "'DNS:example.com' in cert_4_text.stdout" + - "'DNS:t1.example.com' in cert_4_text.stdout" + - "'DNS:test.t2.example.com' in cert_4_text.stdout" + - "'DNS:example.org' in cert_4_text.stdout" + - "'DNS:test.example.org' in cert_4_text.stdout" + +- name: Check that certificate 5 is valid + assert: + that: + - cert_5_valid is not failed +- name: Check that certificate 5 contains correct SANs + assert: + that: + - "'DNS:t2.example.com' in cert_5_text.stdout" +- name: Check that certificate 5 was not recreated on the first try + assert: + that: + - cert_5_recreate_1 == False +- name: Check that certificate 5 was recreated on the second try + assert: + that: + - cert_5_recreate_2 == True +- name: Check that certificate 5 was recreated on the third try + assert: + that: + - cert_5_recreate_3 == True diff --git a/test/integration/targets/acme_certificate_revoke/aliases b/test/integration/targets/acme_certificate_revoke/aliases new file mode 100644 index 00000000000..d7936330302 --- /dev/null +++ b/test/integration/targets/acme_certificate_revoke/aliases @@ -0,0 +1,2 @@ +shippable/cloud/group1 +cloud/acme diff --git a/test/integration/targets/acme_certificate_revoke/meta/main.yml b/test/integration/targets/acme_certificate_revoke/meta/main.yml new file mode 100644 index 00000000000..81d1e7e77a5 --- /dev/null +++ b/test/integration/targets/acme_certificate_revoke/meta/main.yml @@ -0,0 +1,2 @@ +dependencies: + - setup_acme diff --git a/test/integration/targets/acme_certificate_revoke/tasks/main.yml b/test/integration/targets/acme_certificate_revoke/tasks/main.yml new file mode 100644 index 00000000000..affd839af5c --- /dev/null +++ b/test/integration/targets/acme_certificate_revoke/tasks/main.yml @@ -0,0 +1,90 @@ +--- +- block: + ## SET UP ACCOUNT KEYS ######################################################################## + - name: Create ECC256 account key + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/account-ec256.pem + - name: Create ECC384 account key + command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/account-ec384.pem + - name: Create RSA-2048 account key + command: openssl genrsa -out {{ output_dir }}/account-rsa2048.pem 2048 + ## CREATE ACCOUNTS AND OBTAIN CERTIFICATES #################################################### + - name: Obtain cert 1 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 1 for revocation + certificate_name: cert-1 + key_type: rsa + rsa_bits: 2048 + subject_alt_name: "DNS:example.com" + subject_alt_name_critical: no + account_key_content: "{{ lookup('file', output_dir ~ '/account-ec256.pem') }}" + challenge: http-01 + modify_account: yes + deactivate_authzs: no + force: no + remaining_days: 10 + terms_agreed: yes + account_email: "example@example.org" + - name: Obtain cert 2 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 2 for revocation + certificate_name: cert-2 + key_type: ec256 + subject_alt_name: "DNS:*.example.com" + subject_alt_name_critical: yes + account_key: account-ec384 + challenge: dns-01 + modify_account: yes + deactivate_authzs: yes + force: no + remaining_days: 10 + terms_agreed: yes + account_email: "example@example.org" + - name: Obtain cert 3 + include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 3 for revocation + certificate_name: cert-3 + key_type: ec384 + subject_alt_name: "DNS:t1.example.com" + subject_alt_name_critical: no + account_key: account-rsa2048 + challenge: dns-01 + modify_account: yes + deactivate_authzs: no + force: no + remaining_days: 10 + terms_agreed: yes + account_email: "example@example.org" + ## REVOKE CERTIFICATES ######################################################################## + - name: Revoke certificate 1 via account key + acme_certificate_revoke: + account_key_src: "{{ output_dir }}/account-ec256.pem" + certificate: "{{ output_dir }}/cert-1.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + ignore_errors: yes + register: cert_1_revoke + - name: Revoke certificate 2 via certificate private key + acme_certificate_revoke: + private_key_src: "{{ output_dir }}/cert-2.key" + certificate: "{{ output_dir }}/cert-2.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + ignore_errors: yes + register: cert_2_revoke + - name: Revoke certificate 3 via account key (fullchain) + acme_certificate_revoke: + account_key_content: "{{ lookup('file', output_dir ~ '/account-rsa2048.pem') }}" + certificate: "{{ output_dir }}/cert-3-fullchain.pem" + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + ignore_errors: yes + register: cert_3_revoke + + # Old 0.9.8 versions have insufficient CLI support for signing with EC keys + when: openssl_version.stdout is version('1.0.0', '>=') diff --git a/test/integration/targets/acme_certificate_revoke/tasks/obtain-cert.yml b/test/integration/targets/acme_certificate_revoke/tasks/obtain-cert.yml new file mode 120000 index 00000000000..532df9452ea --- /dev/null +++ b/test/integration/targets/acme_certificate_revoke/tasks/obtain-cert.yml @@ -0,0 +1 @@ +../../setup_acme/tasks/obtain-cert.yml \ No newline at end of file diff --git a/test/integration/targets/acme_certificate_revoke/tests/validate.yml b/test/integration/targets/acme_certificate_revoke/tests/validate.yml new file mode 100644 index 00000000000..7f8f9c54386 --- /dev/null +++ b/test/integration/targets/acme_certificate_revoke/tests/validate.yml @@ -0,0 +1,16 @@ +--- +- name: Check that certificate 1 was revoked + assert: + that: + - cert_1_revoke is changed + - cert_1_revoke is not failed +- name: Check that certificate 2 was revoked + assert: + that: + - cert_2_revoke is changed + - cert_2_revoke is not failed +- name: Check that certificate 3 was revoked + assert: + that: + - cert_3_revoke is changed + - cert_3_revoke is not failed diff --git a/test/integration/targets/setup_acme/tasks/main.yml b/test/integration/targets/setup_acme/tasks/main.yml new file mode 100644 index 00000000000..670a4fb8e22 --- /dev/null +++ b/test/integration/targets/setup_acme/tasks/main.yml @@ -0,0 +1,10 @@ +--- +- name: register openssl version + shell: "openssl version | cut -d' ' -f2" + register: openssl_version + +- name: register cryptography version + command: python -c 'import cryptography; print(cryptography.__version__)' + register: cryptography_version + +- debug: msg="ACME test container IP is {{ acme_host }}; OpenSSL version is {{ openssl_version.stdout }}; cryptography version is {{ cryptography_version.stdout }}" diff --git a/test/integration/targets/setup_acme/tasks/obtain-cert.yml b/test/integration/targets/setup_acme/tasks/obtain-cert.yml new file mode 100644 index 00000000000..cc3a1067fd8 --- /dev/null +++ b/test/integration/targets/setup_acme/tasks/obtain-cert.yml @@ -0,0 +1,153 @@ +--- +## PRIVATE KEY ################################################################################ +- name: ({{ certgen_title }}) Create cert private key (RSA) + command: "openssl genrsa -out {{ output_dir }}/{{ certificate_name }}.key {{ rsa_bits if key_type == 'rsa' else 2048 }}" + when: "key_type == 'rsa'" +- name: ({{ certgen_title }}) Create cert private key (ECC 256) + command: openssl ecparam -name prime256v1 -genkey -out {{ output_dir }}/{{ certificate_name }}.key + when: "key_type == 'ec256'" +- name: ({{ certgen_title }}) Create cert private key (ECC 384) + command: openssl ecparam -name secp384r1 -genkey -out {{ output_dir }}/{{ certificate_name }}.key + when: "key_type == 'ec384'" +- name: ({{ certgen_title }}) Create cert private key (ECC 512) + command: openssl ecparam -name secp521r1 -genkey -out {{ output_dir }}/{{ certificate_name }}.key + when: "key_type == 'ec521'" +## CSR ######################################################################################## +- name: ({{ certgen_title }}) Create cert CSR + openssl_csr: + path: "{{ output_dir }}/{{ certificate_name }}.csr" + privatekey_path: "{{ output_dir }}/{{ certificate_name }}.key" + subject_alt_name: "{{ subject_alt_name }}" + subject_alt_name_critical: "{{ subject_alt_name_critical }}" +## ACME STEP 1 ################################################################################ +- name: ({{ certgen_title }}) Obtain cert, step 1 + acme_certificate: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key: "{{ output_dir }}/{{ account_key }}.pem" + modify_account: "{{ modify_account }}" + csr: "{{ output_dir }}/{{ certificate_name }}.csr" + dest: "{{ output_dir }}/{{ certificate_name }}.pem" + fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" + chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem" + challenge: "{{ challenge }}" + deactivate_authzs: "{{ deactivate_authzs }}" + force: "{{ force }}" + remaining_days: "{{ remaining_days }}" + terms_agreed: "{{ terms_agreed }}" + account_email: "{{ account_email }}" + register: challenge_data + when: account_key_content is not defined +- name: ({{ certgen_title }}) Obtain cert, step 1 (using account key data) + acme_certificate: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key_content: "{{ account_key_content }}" + modify_account: "{{ modify_account }}" + csr: "{{ output_dir }}/{{ certificate_name }}.csr" + dest: "{{ output_dir }}/{{ certificate_name }}.pem" + fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" + chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem" + challenge: "{{ challenge }}" + deactivate_authzs: "{{ deactivate_authzs }}" + force: "{{ force }}" + remaining_days: "{{ remaining_days }}" + terms_agreed: "{{ terms_agreed }}" + account_email: "{{ account_email }}" + register: challenge_data_content + when: account_key_content is defined +- name: ({{ certgen_title }}) Copy challenge data (when using account key data) + set_fact: + challenge_data: "{{ challenge_data_content }}" + when: account_key_content is defined +- name: ({{ certgen_title }}) Print challenge data + debug: + var: challenge_data +- name: ({{ certgen_title }}) Create HTTP challenges + uri: + url: "http://{{ acme_host }}:5000/http/{{ item.key }}/{{ item.value['http-01'].resource[('.well-known/acme-challenge/'|length):] }}" + method: PUT + body_format: raw + body: "{{ item.value['http-01'].resource_value }}" + headers: + content-type: "application/octet-stream" + with_dict: "{{ challenge_data.challenge_data }}" + when: "challenge_data is changed and challenge == 'http-01'" +- name: ({{ certgen_title }}) Create DNS challenges + uri: + url: "http://{{ acme_host }}:5000/dns/{{ item.key }}" + method: PUT + body_format: json + body: "{{ item.value }}" + with_dict: "{{ challenge_data.challenge_data_dns }}" + when: "challenge_data is changed and challenge == 'dns-01'" +- name: ({{ certgen_title }}) Create TLS ALPN challenges + uri: + url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.value['tls-alpn-01'].resource }}" + method: PUT + body_format: raw + body: "{{ item.value['tls-alpn-01'].resource_value | b64encode }}" + headers: + content-type: "application/octet-stream" + with_dict: "{{ challenge_data.challenge_data }}" + when: "challenge_data is changed and challenge == 'tls-alpn-01'" +## ACME STEP 2 ################################################################################ +- name: ({{ certgen_title }}) Obtain cert, step 2 + acme_certificate: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key: "{{ output_dir }}/{{ account_key }}.pem" + modify_account: "{{ modify_account }}" + csr: "{{ output_dir }}/{{ certificate_name }}.csr" + dest: "{{ output_dir }}/{{ certificate_name }}.pem" + fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" + chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem" + challenge: "{{ challenge }}" + deactivate_authzs: "{{ deactivate_authzs }}" + force: "{{ force }}" + remaining_days: "{{ remaining_days }}" + terms_agreed: "{{ terms_agreed }}" + account_email: "{{ account_email }}" + data: "{{ challenge_data }}" + when: challenge_data is changed and account_key_content is not defined +- name: ({{ certgen_title }}) Obtain cert, step 2 (using account key data) + acme_certificate: + acme_version: 2 + acme_directory: https://{{ acme_host }}:14000/dir + validate_certs: no + account_key_content: "{{ account_key_content }}" + modify_account: "{{ modify_account }}" + csr: "{{ output_dir }}/{{ certificate_name }}.csr" + dest: "{{ output_dir }}/{{ certificate_name }}.pem" + fullchain_dest: "{{ output_dir }}/{{ certificate_name }}-fullchain.pem" + chain_dest: "{{ output_dir }}/{{ certificate_name }}-chain.pem" + challenge: "{{ challenge }}" + deactivate_authzs: "{{ deactivate_authzs }}" + force: "{{ force }}" + remaining_days: "{{ remaining_days }}" + terms_agreed: "{{ terms_agreed }}" + account_email: "{{ account_email }}" + data: "{{ challenge_data }}" + when: challenge_data is changed and account_key_content is defined +- name: ({{ certgen_title }}) Deleting HTTP challenges + uri: + url: "http://{{ acme_host }}:5000/http/{{ item.key }}/{{ item.value['http-01'].resource[('.well-known/acme-challenge/'|length):] }}" + method: DELETE + with_dict: "{{ challenge_data.challenge_data }}" + when: "challenge_data is changed and challenge == 'http-01'" +- name: ({{ certgen_title }}) Deleting DNS challenges + uri: + url: "http://{{ acme_host }}:5000/dns/{{ item.key }}" + method: DELETE + with_dict: "{{ challenge_data.challenge_data_dns }}" + when: "challenge_data is changed and challenge == 'dns-01'" +- name: ({{ certgen_title }}) Deleting TLS ALPN challenges + uri: + url: "http://{{ acme_host }}:5000/tls-alpn/{{ item.value['tls-alpn-01'].resource }}" + method: DELETE + with_dict: "{{ challenge_data.challenge_data }}" + when: "challenge_data is changed and challenge == 'tls-alpn-01'" +############################################################################################### diff --git a/test/runner/lib/cloud/acme.py b/test/runner/lib/cloud/acme.py new file mode 100644 index 00000000000..f7db4b78568 --- /dev/null +++ b/test/runner/lib/cloud/acme.py @@ -0,0 +1,193 @@ +"""ACME plugin for integration tests.""" +from __future__ import absolute_import, print_function + +import os +import time + +from lib.cloud import ( + CloudProvider, + CloudEnvironment, +) + +from lib.util import ( + find_executable, + display, + ApplicationError, + SubprocessError, +) + +from lib.http import ( + HttpClient, +) + +from lib.docker_util import ( + docker_run, + docker_rm, + docker_inspect, + docker_pull, + get_docker_container_id, +) + +try: + # noinspection PyPep8Naming + import ConfigParser as configparser +except ImportError: + # noinspection PyUnresolvedReferences + import configparser + + +class ACMEProvider(CloudProvider): + """ACME plugin. Sets up cloud resources for tests.""" + DOCKER_SIMULATOR_NAME = 'acme-simulator' + + def __init__(self, args): + """ + :type args: TestConfig + """ + super(ACMEProvider, self).__init__(args, config_extension='.ini') + + # The simulator must be pinned to a specific version to guarantee CI passes with the version used. + if os.environ.get('ANSIBLE_ACME_CONTAINER'): + self.image = os.environ.get('ANSIBLE_ACME_CONTAINER') + else: + self.image = 'quay.io/ansible/acme-test-container:1.1.0' + self.container_name = '' + + def _wait_for_service(self, protocol, acme_host, port, local_part, name): + """Wait for an endpoint to accept connections.""" + if self.args.explain: + return + + client = HttpClient(self.args, always=True, insecure=True) + endpoint = '%s://%s:%d/%s' % (protocol, acme_host, port, local_part) + + for dummy in range(1, 30): + display.info('Waiting for %s: %s' % (name, endpoint), verbosity=1) + + try: + client.get(endpoint) + return + except SubprocessError: + pass + + time.sleep(1) + + raise ApplicationError('Timeout waiting for %s.' % name) + + def filter(self, targets, exclude): + """Filter out the cloud tests when the necessary config and resources are not available. + :type targets: tuple[TestTarget] + :type exclude: list[str] + """ + docker = find_executable('docker', required=False) + + if docker: + return + + skip = 'cloud/%s/' % self.platform + skipped = [target.name for target in targets if skip in target.aliases] + + if skipped: + exclude.append(skip) + display.warning('Excluding tests marked "%s" which require the "docker" command: %s' + % (skip.rstrip('/'), ', '.join(skipped))) + + def setup(self): + """Setup the cloud resource before delegation and register a cleanup callback.""" + super(ACMEProvider, self).setup() + + if self._use_static_config(): + self._setup_static() + else: + self._setup_dynamic() + + def get_docker_run_options(self): + """Get any additional options needed when delegating tests to a docker container. + :rtype: list[str] + """ + if self.managed: + return ['--link', self.DOCKER_SIMULATOR_NAME] + + return [] + + def cleanup(self): + """Clean up the cloud resource and any temporary configuration files after tests complete.""" + if self.container_name: + docker_rm(self.args, self.container_name) + + super(ACMEProvider, self).cleanup() + + def _setup_dynamic(self): + """Create a ACME test container using docker.""" + container_id = get_docker_container_id() + + if container_id: + display.info('Running in docker container: %s' % container_id, verbosity=1) + + self.container_name = self.DOCKER_SIMULATOR_NAME + + results = docker_inspect(self.args, self.container_name) + + if results and not results[0].get('State', {}).get('Running'): + docker_rm(self.args, self.container_name) + results = [] + + if results: + display.info('Using the existing ACME docker test container.', verbosity=1) + else: + display.info('Starting a new ACME docker test container.', verbosity=1) + + if not self.args.docker and not container_id: + # publish the simulator ports when not running inside docker + publish_ports = [ + '-p', '5000:5000', # control port for flask app in container + '-p', '14000:14000', # Pebble ACME CA + ] + else: + publish_ports = [] + + if not os.environ.get('ANSIBLE_ACME_CONTAINER'): + docker_pull(self.args, self.image) + + docker_run( + self.args, + self.image, + ['-d', '--name', self.container_name] + publish_ports, + ) + + if self.args.docker: + acme_host = self.DOCKER_SIMULATOR_NAME + acme_host_ip = self._get_simulator_address() + elif container_id: + acme_host = self._get_simulator_address() + acme_host_ip = acme_host + display.info('Found ACME test container address: %s' % acme_host, verbosity=1) + else: + acme_host = 'localhost' + acme_host_ip = acme_host + + self._set_cloud_config('acme_host', acme_host) + + self._wait_for_service('http', acme_host_ip, 5000, '', 'ACME controller') + self._wait_for_service('https', acme_host_ip, 14000, 'dir', 'ACME CA endpoint') + + def _get_simulator_address(self): + results = docker_inspect(self.args, self.container_name) + ipaddress = results[0]['NetworkSettings']['IPAddress'] + return ipaddress + + def _setup_static(self): + raise NotImplementedError() + + +class ACMEEnvironment(CloudEnvironment): + """ACME environment plugin. Updates integration test environment after delegation.""" + def configure_environment(self, env, cmd): + """ + :type env: dict[str, str] + :type cmd: list[str] + """ + + # Send the container IP down to the integration test(s) + cmd.append('-e') + cmd.append('acme_host=%s' % self._get_cloud_config('acme_host'))