340 lines
12 KiB
Python
340 lines
12 KiB
Python
|
#!/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()
|