community.crypto/plugins/modules/acme_certificate_order_fina...

440 lines
17 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_finalize
author: Felix Fontein (@felixfontein)
version_added: 2.24.0
short_description: Finalize an ACME v2 order
description:
- Finalizes an ACME v2 order and obtains the certificate and certificate chains.
This is the final 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_validate) modules.
seealso:
- module: community.crypto.acme_certificate_order_create
description: Create an ACME order.
- module: community.crypto.acme_certificate_order_validate
description: Validate pending authorizations of an ACME order.
- 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
- module: community.crypto.certificate_complete_chain
description: Allows to find the root certificate for the returned fullchain.
- module: community.crypto.acme_certificate_revoke
description: Allows to revoke certificates.
- 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.acme.certificate
- 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:
order_uri:
description:
- The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri).
type: str
required: true
cert_dest:
description:
- "The destination file for the certificate."
type: path
fullchain_dest:
description:
- "The destination file for the full chain (that is, a certificate followed
by chain of intermediate certificates)."
type: path
chain_dest:
description:
- If specified, the intermediate certificate will be written to this file.
type: path
deactivate_authzs:
description:
- "Deactivate authentication objects (authz) after issuing a certificate,
or when issuing the certificate failed."
- V(never) never deactivates them.
- V(always) always deactivates them in cases of errors or when the certificate was issued.
- V(on_error) only deactivates them in case of errors.
- V(on_success) only deactivates them in case the certificate was successfully issued.
- "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: str
choices:
- never
- on_error
- on_success
- always
default: always
retrieve_all_alternates:
description:
- "When set to V(true), will retrieve all alternate trust chains offered by the ACME CA.
These will not be written to disk, but will be returned together with the main
chain as RV(all_chains). See the documentation for the RV(all_chains) return
value for details."
type: bool
default: false
select_chain:
description:
- "Allows to specify criteria by which an (alternate) trust chain can be selected."
- "The list of criteria will be processed one by one until a chain is found
matching a criterium. If such a chain is found, it will be used by the
module instead of the default chain."
- "If a criterium matches multiple chains, the first one matching will be
returned. The order is determined by the ordering of the C(Link) headers
returned by the ACME server and might not be deterministic."
- "Every criterium can consist of multiple different conditions, like O(select_chain[].issuer)
and O(select_chain[].subject). For the criterium to match a chain, all conditions must apply
to the same certificate in the chain."
- "This option can only be used with the C(cryptography) backend."
type: list
elements: dict
suboptions:
test_certificates:
description:
- "Determines which certificates in the chain will be tested."
- "V(all) tests all certificates in the chain (excluding the leaf, which is
identical in all chains)."
- "V(first) only tests the first certificate in the chain, that is the one which
signed the leaf."
- "V(last) only tests the last certificate in the chain, that is the one furthest
away from the leaf. Its issuer is the root certificate of this chain."
type: str
default: all
choices: [first, last, all]
issuer:
description:
- "Allows to specify parts of the issuer of a certificate in the chain must
have to be selected."
- "If O(select_chain[].issuer) is empty, any certificate will match."
- 'An example value would be V({"commonName": "My Preferred CA Root"}).'
type: dict
subject:
description:
- "Allows to specify parts of the subject of a certificate in the chain must
have to be selected."
- "If O(select_chain[].subject) is empty, any certificate will match."
- 'An example value would be V({"CN": "My Preferred CA Intermediate"})'
type: dict
subject_key_identifier:
description:
- "Checks for the SubjectKeyIdentifier extension. This is an identifier based
on the private key of the intermediate certificate."
- "The identifier must be of the form
V(A8:4A:6A:63:04:7D:DD:BA:E6:D1:39:B7:A6:45:65:EF:F3:A8:EC:A1)."
type: str
authority_key_identifier:
description:
- "Checks for the AuthorityKeyIdentifier extension. This is an identifier based
on the private key of the issuer of the intermediate certificate."
- "The identifier must be of the form
V(C4:A7:B1:A4:7B:2C:71:FA:DB:E1:4B:90:75:FF:C4:15:60:85:89:10)."
type: str
'''
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
all_chains:
description:
- When O(retrieve_all_alternates=true), the module will query the ACME server for
alternate chains. This return value will contain a list of all chains returned,
the first entry being the main chain returned by the server.
- See L(Section 7.4.2 of RFC8555,https://tools.ietf.org/html/rfc8555#section-7.4.2)
for details.
returned: success and O(retrieve_all_alternates=true)
type: list
elements: dict
contains:
cert:
description:
- The leaf certificate itself, in PEM format.
type: str
returned: always
chain:
description:
- The certificate chain, excluding the root, as concatenated PEM certificates.
type: str
returned: always
full_chain:
description:
- The certificate chain, excluding the root, but including the leaf certificate,
as concatenated PEM certificates.
type: str
returned: always
selected_chain:
description:
- The selected certificate chain.
- If O(select_chain) is not specified, this will be the main chain returned by the
ACME server.
returned: success
type: dict
contains:
cert:
description:
- The leaf certificate itself, in PEM format.
type: str
returned: always
chain:
description:
- The certificate chain, excluding the root, as concatenated PEM certificates.
type: str
returned: always
full_chain:
description:
- The certificate chain, excluding the root, but including the leaf certificate,
as concatenated PEM certificates.
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=True)
argument_spec.update_argspec(
order_uri=dict(type='str', required=True),
cert_dest=dict(type='path'),
fullchain_dest=dict(type='path'),
chain_dest=dict(type='path'),
deactivate_authzs=dict(type='str', default='always', choices=['never', 'always', 'on_error', 'on_success']),
retrieve_all_alternates=dict(type='bool', default=False),
select_chain=dict(type='list', elements='dict', options=dict(
test_certificates=dict(type='str', default='all', choices=['first', 'last', 'all']),
issuer=dict(type='dict'),
subject=dict(type='dict'),
subject_key_identifier=dict(type='str'),
authority_key_identifier=dict(type='str'),
)),
)
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)
select_chain_matcher = client.parse_select_chain(module.params['select_chain'])
other = dict()
done = False
order = None
try:
# Step 1: load order
order = client.load_order()
download_all_chains = len(select_chain_matcher) > 0 or module.params['retrieve_all_alternates']
changed = False
if order.status == 'valid':
# Step 2 and 3: download certificate(s) and chain(s)
cert, alternate_chains = client.download_certificate(
order,
download_all_chains=download_all_chains,
)
else:
client.check_that_authorizations_can_be_used(order)
# Step 2: wait for authorizations to validate
pending_authzs = client.collect_pending_authzs(order)
client.wait_for_validation(pending_authzs)
# Step 3: finalize order, wait, then download certificate(s) and chain(s)
cert, alternate_chains = client.get_certificate(
order,
download_all_chains=download_all_chains,
)
changed = True
# Step 4: pick chain, write certificates, and provide return values
if alternate_chains is not None:
# Prepare return value for all alternate chains
if module.params['retrieve_all_alternates']:
all_chains = [cert.to_json()]
for alt_chain in alternate_chains:
all_chains.append(alt_chain.to_json())
other['all_chains'] = all_chains
# Try to select alternate chain depending on criteria
if select_chain_matcher:
matching_chain = client.find_matching_chain([cert] + alternate_chains, select_chain_matcher)
if matching_chain:
cert = matching_chain
else:
module.debug('Found no matching alternative chain')
if client.write_cert_chain(
cert,
cert_dest=module.params['cert_dest'],
fullchain_dest=module.params['fullchain_dest'],
chain_dest=module.params['chain_dest'],
):
changed = True
done = True
finally:
if (
module.params['deactivate_authzs'] == 'always' or
(module.params['deactivate_authzs'] == 'on_success' and done) or
(module.params['deactivate_authzs'] == 'on_error' and not done)
):
if order:
client.deactivate_authzs(order)
module.exit_json(
changed=changed,
account_uri=client.client.account_uri,
selected_chain=cert.to_json(),
**other
)
except ModuleFailException as e:
e.do_fail(module)
if __name__ == '__main__':
main()