community.crypto/plugins/modules/acme_certificate_order_vali...

340 lines
12 KiB
Python
Raw Normal View History

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later
from __future__ import absolute_import, division, print_function
__metaclass__ = type
DOCUMENTATION = '''
---
module: acme_certificate_order_validate
author: Felix Fontein (@felixfontein)
version_added: 2.24.0
short_description: Validate authorizations of an ACME v2 order
description:
- Validates pending authorizations of an ACME v2 order.
This is the second to last step of obtaining a new certificate with the
L(ACME protocol,https://tools.ietf.org/html/rfc8555) from a Certificate
Authority such as L(Let's Encrypt,https://letsencrypt.org/) or
L(Buypass,https://www.buypass.com/). This module does not support ACME v1, the
original version of the ACME protocol before standardization.
- This module needs to be used in conjunction with the
M(community.crypto.acme_certificate_order_create) and
M(community.crypto.acme_certificate_order_finalize) modules.
seealso:
- module: community.crypto.acme_certificate_order_create
description: Create an ACME order.
- module: community.crypto.acme_certificate_order_finalize
description: Finalize an ACME order after satisfying the challenges.
- module: community.crypto.acme_certificate_order_info
description: Obtain information for an ACME order.
- name: The Let's Encrypt documentation
description: Documentation for the Let's Encrypt Certification Authority.
Provides useful information for example on rate limits.
link: https://letsencrypt.org/docs/
- name: Buypass Go SSL
description: Documentation for the Buypass Certification Authority.
Provides useful information for example on rate limits.
link: https://www.buypass.com/ssl/products/acme
- name: Automatic Certificate Management Environment (ACME)
description: The specification of the ACME protocol (RFC 8555).
link: https://tools.ietf.org/html/rfc8555
- name: ACME TLS ALPN Challenge Extension
description: The specification of the V(tls-alpn-01) challenge (RFC 8737).
link: https://www.rfc-editor.org/rfc/rfc8737.html
- module: community.crypto.acme_challenge_cert_helper
description: Helps preparing V(tls-alpn-01) challenges.
- module: community.crypto.acme_inspect
description: Allows to debug problems.
- module: community.crypto.acme_certificate_deactivate_authz
description: Allows to deactivate (invalidate) ACME v2 orders.
extends_documentation_fragment:
- community.crypto.acme.basic
- community.crypto.acme.account
- community.crypto.attributes
- community.crypto.attributes.actiongroup_acme
- community.crypto.attributes.files
attributes:
check_mode:
support: none
diff_mode:
support: none
safe_file_operations:
support: full
idempotent:
support: full
options:
challenge:
description:
- The challenge to be performed for every pending authorization.
- Must be provided if there is at least one pending authorization.
- In case of authorization reuse, or in case of CAs which use External Account Binding
and other means of validating certificate assurance, it might not be necessary
to provide this option.
type: str
choices:
- 'http-01'
- 'dns-01'
- 'tls-alpn-01'
order_uri:
description:
- The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri).
type: str
required: true
deactivate_authzs:
description:
- "Deactivate authentication objects (authz) in case an error happens."
- "Authentication objects are bound to an account key and remain valid
for a certain amount of time, and can be used to issue certificates
without having to re-authenticate the domain. This can be a security
concern."
type: bool
default: true
'''
EXAMPLES = r'''
### Example with HTTP-01 challenge ###
- name: Create a challenge for sample.com using a account key from a variable
community.crypto.acme_certificate_order_create:
account_key_content: "{{ account_private_key }}"
csr: /etc/pki/cert/csr/sample.com.csr
register: sample_com_challenge
# Alternative first step:
- name: Create a challenge for sample.com using a account key from Hashi Vault
community.crypto.acme_certificate_order_create:
account_key_content: >-
{{ lookup('community.hashi_vault.hashi_vault', 'secret=secret/account_private_key:value') }}
csr: /etc/pki/cert/csr/sample.com.csr
register: sample_com_challenge
# Alternative first step:
- name: Create a challenge for sample.com using a account key file
community.crypto.acme_certificate_order_create:
account_key_src: /etc/pki/cert/private/account.key
csr_content: "{{ lookup('file', '/etc/pki/cert/csr/sample.com.csr') }}"
register: sample_com_challenge
# Perform the necessary steps to fulfill the challenge. For example:
#
# - name: Copy http-01 challenges
# ansible.builtin.copy:
# dest: /var/www/{{ item.identifier }}/{{ item.challenges['http-01'].resource }}
# content: "{{ item.challenges['http-01'].resource_value }}"
# loop: "{{ sample_com_challenge.challenge_data }}"
# when: "'http-01' in item.challenges"
- name: Let the challenge be validated
community.crypto.acme_certificate_order_validate:
account_key_src: /etc/pki/cert/private/account.key
order_uri: "{{ sample_com_challenge.order_uri }}"
challenge: http-01
- name: Retrieve the cert and intermediate certificate
community.crypto.acme_certificate_order_finalize:
account_key_src: /etc/pki/cert/private/account.key
csr: /etc/pki/cert/csr/sample.com.csr
order_uri: "{{ sample_com_challenge.order_uri }}"
cert_dest: /etc/httpd/ssl/sample.com.crt
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt
### Example with DNS challenge against production ACME server ###
- name: Create a challenge for sample.com using a account key file.
community.crypto.acme_certificate_order_create:
acme_directory: https://acme-v01.api.letsencrypt.org/directory
acme_version: 2
account_key_src: /etc/pki/cert/private/account.key
csr: /etc/pki/cert/csr/sample.com.csr
register: sample_com_challenge
# Perform the necessary steps to fulfill the challenge. For example:
#
# - name: Create DNS records for dns-01 challenges
# community.aws.route53:
# zone: sample.com
# record: "{{ item.key }}"
# type: TXT
# ttl: 60
# state: present
# wait: true
# # Note: item.value is a list of TXT entries, and route53
# # requires every entry to be enclosed in quotes
# value: "{{ item.value | map('community.dns.quote_txt', always_quote=true) | list }}"
# loop: "{{ sample_com_challenge.challenge_data_dns | dict2items }}"
- name: Let the challenge be validated
community.crypto.acme_certificate_order_validate:
acme_directory: https://acme-v01.api.letsencrypt.org/directory
acme_version: 2
account_key_src: /etc/pki/cert/private/account.key
order_uri: "{{ sample_com_challenge.order_uri }}"
challenge: dns-01
- name: Retrieve the cert and intermediate certificate
community.crypto.acme_certificate_order_finalize:
acme_directory: https://acme-v01.api.letsencrypt.org/directory
acme_version: 2
account_key_src: /etc/pki/cert/private/account.key
csr: /etc/pki/cert/csr/sample.com.csr
order_uri: "{{ sample_com_challenge.order_uri }}"
cert_dest: /etc/httpd/ssl/sample.com.crt
fullchain_dest: /etc/httpd/ssl/sample.com-fullchain.crt
chain_dest: /etc/httpd/ssl/sample.com-intermediate.crt
'''
RETURN = '''
account_uri:
description: ACME account URI.
returned: success
type: str
validating_challenges:
description: List of challenges whose validation was triggered.
returned: success
type: list
elements: dict
contains:
identifier:
description:
- The identifier the challenge is for.
type: str
returned: always
identifier_type:
description:
- The identifier's type for the challenge.
type: str
returned: always
choices:
- dns
- ip
authz_url:
description:
- The URL of the authorization object for this challenge.
type: str
returned: always
challenge_type:
description:
- The challenge's type.
type: str
returned: always
choices:
- http-01
- dns-01
- tls-alpn-01
challenge_url:
description:
- The URL of the challenge object.
type: str
returned: always
'''
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
create_backend,
create_default_argspec,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.certificate import (
ACMECertificateClient,
)
def main():
argument_spec = create_default_argspec(with_certificate=False)
argument_spec.update_argspec(
order_uri=dict(type='str', required=True),
challenge=dict(type='str', choices=['http-01', 'dns-01', 'tls-alpn-01']),
deactivate_authzs=dict(type='bool', default=True),
)
module = argument_spec.create_ansible_module()
if module.params['acme_version'] == 1:
module.fail_json('The module does not support acme_version=1')
backend = create_backend(module, False)
try:
client = ACMECertificateClient(module, backend)
done = False
order = None
try:
# Step 1: load order
order = client.load_order()
client.check_that_authorizations_can_be_used(order)
# Step 2: find all pending authorizations
pending_authzs = client.collect_pending_authzs(order)
# Step 3: figure out challenges to use
challenges = {}
for authz in pending_authzs:
challenges[authz.combined_identifier] = module.params['challenge']
missing_challenge_authzs = [k for k, v in challenges.items() if v is None]
if missing_challenge_authzs:
raise ModuleFailException(
'The challenge parameter must be supplied if there are pending authorizations.'
' The following authorizations are pending: {missing_challenge_authzs}'.format(
missing_challenge_authzs=', '.join(sorted(missing_challenge_authzs)),
)
)
bad_challenge_authzs = [
authz.combined_identifier for authz in pending_authzs
if authz.find_challenge(challenges[authz.combined_identifier]) is None
]
if bad_challenge_authzs:
raise ModuleFailException(
'The following authorizations do not support the selected challenges: {authz_challenges_pairs}'.format(
authz_challenges_pairs=', '.join(sorted(
'{authz} with {challenge}'.format(authz=authz, challenge=challenges[authz])
for authz in bad_challenge_authzs
)),
)
)
really_pending_authzs = [
authz for authz in pending_authzs
if authz.find_challenge(challenges[authz.combined_identifier]).status == 'pending'
]
# Step 4: validate pending authorizations
authzs_with_challenges_to_wait_for = client.call_validate(
really_pending_authzs,
get_challenge=lambda authz: challenges[authz.combined_identifier],
wait=False,
)
done = True
finally:
if order and module.params['deactivate_authzs'] and not done:
client.deactivate_authzs(order)
module.exit_json(
changed=len(authzs_with_challenges_to_wait_for) > 0,
account_uri=client.client.account_uri,
validating_challenges=[
dict(
identifier=authz.identifier,
identifier_type=authz.identifier_type,
authz_url=authz.url,
challenge_type=challenge_type,
challenge_url=challenge.url,
)
for authz, challenge_type, challenge in authzs_with_challenges_to_wait_for
],
)
except ModuleFailException as e:
e.do_fail(module)
if __name__ == '__main__':
main()