Add new ACME modules for working with orders. (#757)

pull/838/head
Felix Fontein 2025-01-12 17:10:58 +01:00 committed by GitHub
parent 072318466e
commit 49354f2121
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 2280 additions and 2 deletions

View File

@ -89,6 +89,10 @@ If you use the Ansible package and do not update collections independently, use
- acme_ari_info module
- acme_certificate module
- acme_certificate_deactivate_authz module
- acme_certificate_order_create module
- acme_certificate_order_finalize module
- acme_certificate_order_info module
- acme_certificate_order_validate module
- acme_certificate_revoke module
- acme_challenge_cert_helper module
- acme_inspect module

View File

@ -8,9 +8,13 @@ requires_ansible: '>=2.9.10'
action_groups:
acme:
- acme_inspect
- acme_certificate_deactivate_authz
- acme_certificate_revoke
- acme_certificate
- acme_certificate_deactivate_authz
- acme_certificate_order_create
- acme_certificate_order_finalize
- acme_certificate_order_info
- acme_certificate_order_validate
- acme_certificate_revoke
- acme_account
- acme_account_info

View File

@ -0,0 +1,258 @@
# -*- 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
import os
from ansible_collections.community.crypto.plugins.module_utils.acme.acme import (
ACMEClient,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.account import (
ACMEAccount,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.challenges import (
wait_for_validation,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.certificates import (
CertificateChain,
Criterium,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.errors import (
ModuleFailException,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.orders import (
Order,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.io import (
write_file,
)
from ansible_collections.community.crypto.plugins.module_utils.acme.utils import (
pem_to_der,
)
class ACMECertificateClient(object):
'''
ACME v2 client class. Uses an ACME account object and a CSR to
start and validate ACME challenges and download the respective
certificates.
'''
def __init__(self, module, backend, client=None, account=None):
self.module = module
self.version = module.params['acme_version']
self.csr = module.params.get('csr')
self.csr_content = module.params.get('csr_content')
if client is None:
client = ACMEClient(module, backend)
self.client = client
if account is None:
account = ACMEAccount(self.client)
self.account = account
self.order_uri = module.params.get('order_uri')
# Make sure account exists
dummy, account_data = self.account.setup_account(allow_creation=False)
if account_data is None:
raise ModuleFailException(msg='Account does not exist or is deactivated.')
if self.csr is not None and not os.path.exists(self.csr):
raise ModuleFailException("CSR %s not found" % (self.csr))
# Extract list of identifiers from CSR
if self.csr is not None or self.csr_content is not None:
self.identifiers = self.client.backend.get_ordered_csr_identifiers(csr_filename=self.csr, csr_content=self.csr_content)
else:
self.identifiers = None
def parse_select_chain(self, select_chain):
select_chain_matcher = []
if select_chain:
for criterium_idx, criterium in enumerate(select_chain):
try:
select_chain_matcher.append(
self.client.backend.create_chain_matcher(Criterium(criterium, index=criterium_idx)))
except ValueError as exc:
self.module.warn('Error while parsing criterium: {error}. Ignoring criterium.'.format(error=exc))
return select_chain_matcher
def load_order(self):
if not self.order_uri:
raise ModuleFailException('The order URI has not been provided')
order = Order.from_url(self.client, self.order_uri)
order.load_authorizations(self.client)
return order
def create_order(self, replaces_cert_id=None, profile=None):
'''
Create a new order.
'''
if self.identifiers is None:
raise ModuleFailException('No identifiers have been provided')
order = Order.create(self.client, self.identifiers, replaces_cert_id=replaces_cert_id, profile=profile)
self.order_uri = order.url
order.load_authorizations(self.client)
return order
def get_challenges_data(self, order):
'''
Get challenge details.
Return a tuple of generic challenge details, and specialized DNS challenge details.
'''
# Get general challenge data
data = []
for authz in order.authorizations.values():
# Skip valid authentications: their challenges are already valid
# and do not need to be returned
if authz.status == 'valid':
continue
data.append(dict(
identifier=authz.identifier,
identifier_type=authz.identifier_type,
challenges=authz.get_challenge_data(self.client),
))
# Get DNS challenge data
data_dns = {}
dns_challenge_type = 'dns-01'
for entry in data:
dns_challenge = entry['challenges'].get(dns_challenge_type)
if dns_challenge:
values = data_dns.get(dns_challenge['record'])
if values is None:
values = []
data_dns[dns_challenge['record']] = values
values.append(dns_challenge['resource_value'])
return data, data_dns
def check_that_authorizations_can_be_used(self, order):
bad_authzs = []
for authz in order.authorizations.values():
if authz.status not in ('valid', 'pending'):
bad_authzs.append('{authz} (status={status!r})'.format(
authz=authz.combined_identifier,
status=authz.status,
))
if bad_authzs:
raise ModuleFailException(
'Some of the authorizations for the order are in a bad state, so the order'
' can no longer be satisfied: {bad_authzs}'.format(
bad_authzs=', '.join(sorted(bad_authzs)),
),
)
def collect_invalid_authzs(self, order):
return [authz for authz in order.authorizations.values() if authz.status == 'invalid']
def collect_pending_authzs(self, order):
return [authz for authz in order.authorizations.values() if authz.status == 'pending']
def call_validate(self, pending_authzs, get_challenge, wait=True):
authzs_with_challenges_to_wait_for = []
for authz in pending_authzs:
challenge_type = get_challenge(authz)
authz.call_validate(self.client, challenge_type, wait=wait)
authzs_with_challenges_to_wait_for.append((authz, challenge_type, authz.find_challenge(challenge_type)))
return authzs_with_challenges_to_wait_for
def wait_for_validation(self, authzs_to_wait_for):
wait_for_validation(authzs_to_wait_for, self.client)
def _download_alternate_chains(self, cert):
alternate_chains = []
for alternate in cert.alternates:
try:
alt_cert = CertificateChain.download(self.client, alternate)
except ModuleFailException as e:
self.module.warn('Error while downloading alternative certificate {0}: {1}'.format(alternate, e))
continue
if alt_cert.cert is not None:
alternate_chains.append(alt_cert)
else:
self.module.warn('Error while downloading alternative certificate {0}: no certificate found'.format(alternate))
return alternate_chains
def download_certificate(self, order, download_all_chains=True):
'''
Download certificate from a valid oder.
'''
if order.status != 'valid':
raise ModuleFailException('The order must be valid, but has state {state!r}!'.format(state=order.state))
if not order.certificate_uri:
raise ModuleFailException("Order's crtificate URL {url!r} is empty!".format(url=order.certificate_uri))
cert = CertificateChain.download(self.client, order.certificate_uri)
if cert.cert is None:
raise ModuleFailException('Certificate at {url} is empty!'.format(url=order.certificate_uri))
alternate_chains = None
if download_all_chains:
alternate_chains = self._download_alternate_chains(cert)
return cert, alternate_chains
def get_certificate(self, order, download_all_chains=True):
'''
Request a new certificate and downloads it, and optionally all certificate chains.
First verifies whether all authorizations are valid; if not, aborts with an error.
'''
if self.csr is None and self.csr_content is None:
raise ModuleFailException('No CSR has been provided')
for identifier, authz in order.authorizations.items():
if authz.status != 'valid':
authz.raise_error('Status is {status!r} and not "valid"'.format(status=authz.status), module=self.module)
order.finalize(self.client, pem_to_der(self.csr, self.csr_content))
return self.download_certificate(order, download_all_chains=download_all_chains)
def find_matching_chain(self, chains, select_chain_matcher):
for criterium_idx, matcher in enumerate(select_chain_matcher):
for chain in chains:
if matcher.match(chain):
self.module.debug('Found matching chain for criterium {0}'.format(criterium_idx))
return chain
return None
def write_cert_chain(self, cert, cert_dest=None, fullchain_dest=None, chain_dest=None):
changed = False
if cert_dest and write_file(self.module, cert_dest, cert.cert.encode('utf8')):
changed = True
if fullchain_dest and write_file(self.module, fullchain_dest, (cert.cert + "\n".join(cert.chain)).encode('utf8')):
changed = True
if chain_dest and write_file(self.module, chain_dest, ("\n".join(cert.chain)).encode('utf8')):
changed = True
return changed
def deactivate_authzs(self, order):
'''
Deactivates all valid authz's. Does not raise exceptions.
https://community.letsencrypt.org/t/authorization-deactivation/19860/2
https://tools.ietf.org/html/rfc8555#section-7.5.2
'''
for authz in order.authorizations.values():
try:
authz.deactivate(self.client)
except Exception:
# ignore errors
pass
if authz.status != 'deactivated':
self.module.warn(warning='Could not deactivate authz object {0}.'.format(authz.url))

View File

@ -0,0 +1,413 @@
#!/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_create
author: Felix Fontein (@felixfontein)
version_added: 2.24.0
short_description: Create an ACME v2 order
description:
- Creates an ACME v2 order. This is the first 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.
- The current implementation supports the V(http-01), V(dns-01) and V(tls-alpn-01)
challenges.
- This module needs to be used in conjunction with the
M(community.crypto.acme_certificate_order_validate) and.
M(community.crypto.acme_certificate_order_finalize) module.
An order can be effectively deactivated with the
M(community.crypto.acme_certificate_deactivate_authz) module.
Note that both modules require the output RV(order_uri) of this module.
- To create or modify ACME accounts, use the M(community.crypto.acme_account) module.
This module will I(not) create or update ACME accounts.
- Between the call of this module and M(community.crypto.acme_certificate_order_finalize),
you have to fulfill the required steps for the chosen challenge by whatever means necessary.
For V(http-01) that means creating the necessary challenge file on the destination webserver.
For V(dns-01) the necessary dns record has to be created. For V(tls-alpn-01) the necessary
certificate has to be created and served. It is I(not) the responsibility of this module to
perform these steps.
- For details on how to fulfill these challenges, you might have to read through
L(the main ACME specification,https://tools.ietf.org/html/rfc8555#section-8)
and the L(TLS-ALPN-01 specification,https://www.rfc-editor.org/rfc/rfc8737.html#section-3).
Also, consider the examples provided for this module.
- The module includes support for IP identifiers according to
the L(RFC 8738,https://www.rfc-editor.org/rfc/rfc8738.html) ACME extension.
seealso:
- module: community.crypto.acme_certificate_order_validate
description: Validate pending authorizations of 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.
- module: community.crypto.acme_certificate_deactivate_authz
description: Deactivate all authorizations (authz) of an ACME order, effectively deactivating
the order itself.
- module: community.crypto.acme_certificate_renewal_info
description: Determine whether a certificate should be renewed.
- 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.openssl_privatekey
description: Can be used to create private keys (both for certificates and accounts).
- module: community.crypto.openssl_privatekey_pipe
description: Can be used to create private keys without writing it to disk (both for certificates and accounts).
- module: community.crypto.openssl_csr
description: Can be used to create a Certificate Signing Request (CSR).
- module: community.crypto.openssl_csr_pipe
description: Can be used to create a Certificate Signing Request (CSR) without writing it to disk.
- module: community.crypto.acme_account
description: Allows to create, modify or delete an ACME account.
- module: community.crypto.acme_inspect
description: Allows to debug problems.
extends_documentation_fragment:
- community.crypto.acme.basic
- community.crypto.acme.account
- community.crypto.acme.certificate
- community.crypto.attributes
- community.crypto.attributes.actiongroup_acme
attributes:
check_mode:
support: none
diff_mode:
support: none
idempotent:
support: none
options:
deactivate_authzs:
description:
- "Deactivate authentication objects (authz) when issuing the certificate
failed."
- "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
replaces_cert_id:
description:
- If provided, will request the order to replace the certificate identified by this certificate ID
according to L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-5).
- This certificate ID must be computed as specified in
L(the ACME ARI draft 3, https://www.ietf.org/archive/id/draft-ietf-acme-ari-03.html#section-4.1).
It is returned as RV(community.crypto.acme_certificate_renewal_info#module:cert_id) of the
M(community.crypto.acme_certificate_renewal_info) module.
- ACME servers might refuse to create new orders that indicate to replace a certificate for which
an active replacement order already exists. This can happen if this module is used to create an order,
and then the playbook/role fails in case the challenges cannot be set up. If the playbook/role does not
record the order data to continue with the existing order, but tries to create a new one on the next run,
creating the new order might fail. For this reason, this option should only be used if the role/playbook
using it keeps track of order data accross restarts, or if it takes care to deactivate orders whose
processing is aborted. Orders can be deactivated with the
M(community.crypto.acme_certificate_deactivate_authz) module.
type: str
profile:
description:
- Chose a specific profile for certificate selection. The available profiles depend on the CA.
- See L(a blog post by Let's Encrypt, https://letsencrypt.org/2025/01/09/acme-profiles/) and
L(draft-aaron-acme-profiles-00, https://datatracker.ietf.org/doc/draft-aaron-acme-profiles/)
for more information.
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 = '''
challenge_data:
description:
- For every identifier, provides the challenge information.
- Only challenges which are not yet valid are returned.
returned: changed
type: list
elements: dict
contains:
identifier:
description:
- The identifier for this challenge.
type: str
sample: example.com
identifier_type:
description:
- The identifier's type.
- V(dns) for DNS names, and V(ip) for IP addresses.
type: str
choices:
- dns
- ip
sample: dns
challenges:
description:
- Information for different challenge types supported for this identifier.
type: dict
contains:
http-01:
description:
- Information for V(http-01) authorization.
- The server needs to make the path RV(challenge_data[].challenges.http-01.resource)
accessible via HTTP (which might redirect to HTTPS). A C(GET) operation to this path
needs to provide the value from RV(challenge_data[].challenges.http-01.resource_value).
returned: if the identifier supports V(http-01) authorization
type: dict
contains:
resource:
description:
- The path the value has to be provided under.
returned: success
type: str
sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA
resource_value:
description:
- The value the resource has to produce for the validation.
returned: success
type: str
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
dns-01:
description:
- Information for V(dns-01) authorization.
- A DNS TXT record needs to be created with the record name RV(challenge_data[].challenges.dns-01.record)
and value RV(challenge_data[].challenges.dns-01.resource_value).
returned: if the identifier supports V(dns-01) authorization
type: dict
contains:
resource:
description:
- Always contains the string V(_acme-challenge).
type: str
sample: _acme-challenge
resource_value:
description:
- The value the resource has to produce for the validation.
returned: success
type: str
sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA
record:
description: The full DNS record's name for the challenge.
returned: success
type: str
sample: _acme-challenge.example.com
tls-alpn-01:
description:
- Information for V(tls-alpn-01) authorization.
- A certificate needs to be created for the DNS name RV(challenge_data[].challenges.tls-alpn-01.resource)
with acmeValidation X.509 extension of value RV(challenge_data[].challenges.tls-alpn-01.resource_value).
This certificate needs to be served when the application-layer protocol C(acme-tls/1) is negotiated for
a HTTPS connection to port 443 with the SNI extension for the domain name
(RV(challenge_data[].challenges.tls-alpn-01.resource_original)) being validated.
- See U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) for details.
returned: if the identifier supports V(tls-alpn-01) authorization
type: dict
contains:
resource:
description:
- The DNS name for DNS identifiers, and the reverse DNS mapping (RFC1034, RFC3596) for IP addresses.
returned: success
type: str
sample: example.com
resource_original:
description:
- The original identifier including type identifier.
returned: success
type: str
sample: dns:example.com
resource_value:
description:
- The value the resource has to produce for the validation.
- "B(Note:) this return value contains a Base64 encoded version of the correct
binary blob which has to be put into the acmeValidation X.509 extension; see
U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3) for details. To do this,
you might need the P(ansible.builtin.b64decode#filter) Jinja filter to extract
the binary blob from this return value."
returned: success
type: str
sample: AAb=
challenge_data_dns:
description:
- List of TXT values per DNS record for V(dns-01) challenges.
- Only challenges which are not yet valid are returned.
returned: success
type: dict
order_uri:
description: ACME order URI.
returned: success
type: str
account_uri:
description: ACME account URI.
returned: success
type: str
'''
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(
deactivate_authzs=dict(type='bool', default=True),
replaces_cert_id=dict(type='str'),
profile=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)
profile = module.params['profile']
if profile is not None:
meta_profiles = (client.directory.get('meta') or {}).get('profiles') or {}
if not meta_profiles:
raise ModuleFailException(msg='The ACME CA does not support profiles. Please omit the "profile" option.')
if profile not in meta_profiles:
raise ModuleFailException(msg='The ACME CA does not support selected profile {0!r}.'.format(profile))
order = None
done = False
try:
order = client.create_order(replaces_cert_id=module.params['replaces_cert_id'], profile=profile)
client.check_that_authorizations_can_be_used(order)
done = True
finally:
if module.params['deactivate_authzs'] and order and not done:
client.deactivate_authzs(order)
data, data_dns = client.get_challenges_data(order)
module.exit_json(
changed=True,
order_uri=order.url,
account_uri=client.client.account_uri,
challenge_data=data,
challenge_data_dns=data_dns,
)
except ModuleFailException as e:
e.do_fail(module)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,439 @@
#!/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()

View File

@ -0,0 +1,402 @@
#!/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_info
author: Felix Fontein (@felixfontein)
version_added: 2.24.0
short_description: Obtain information for an ACME v2 order
description:
- Obtain information for an ACME v2 order.
This can be used during the process 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),
M(community.crypto.acme_certificate_order_validate), 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_validate
description: Validate pending authorizations of an ACME order.
- module: community.crypto.acme_certificate_order_finalize
description: Finalize an ACME order after satisfying the challenges.
- 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_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.idempotent_not_modify_state
- community.crypto.attributes.info_module
options:
order_uri:
description:
- The order URI provided by RV(community.crypto.acme_certificate_order_create#module:order_uri).
type: str
required: true
'''
EXAMPLES = r'''
- 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: order
- name: Obtain information on the order
community.crypto.acme_certificate_order_info:
account_key_src: /etc/pki/cert/private/account.key
order_uri: "{{ order.order_uri }}"
register: order_info
- name: Show information
ansible.builtin.debug:
var: order_info
'''
RETURN = '''
account_uri:
description: ACME account URI.
returned: success
type: str
order_uri:
description: ACME order URI.
returned: success
type: str
order:
description:
- The order object.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3) for its specification.
returned: success
type: dict
contains:
status:
description:
- The status of this order.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes.
type: str
returned: always
choices:
- pending
- ready
- processing
- valid
- invalid
expires:
description:
- The timestamp after which the server will consider this order invalid.
- Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339).
type: str
returned: if RV(order.status) is V(pending) or V(valid), and sometimes in other situations
identifiers:
description:
- An array of identifier objects that the order pertains to.
returned: always
type: list
elements: dict
contains:
type:
description:
- The type of identifier.
- So far V(dns) and V(ip) are defined values.
type: str
returned: always
sample: dns
choices:
- dns
- ip
value:
description:
- The identifier itself.
type: str
returned: always
sample: example.com
notBefore:
description:
- The requested value of the C(notBefore) field in the certificate.
- Encoded in the date format defined in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339).
type: str
returned: depending on order
notAfter (optional, string):
description:
- The requested value of the C(notAfter) field in the certificate.
- Encoded in the date format defined in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339).
type: str
returned: depending on order
error:
description:
- The error that occurred while processing the order, if any.
- This field is structured as a L(problem document according to RFC 7807, https://www.rfc-editor.org/rfc/rfc7807).
type: dict
returned: sometimes
authorizations:
description:
- For pending orders, the authorizations that the client needs to complete before the
requested certificate can be issued, including unexpired authorizations that the client
has completed in the past for identifiers specified in the order.
- The authorizations required are dictated by server policy; there may not be a 1:1
relationship between the order identifiers and the authorizations required.
- For final orders (in the V(valid) or V(invalid) state), the authorizations that were
completed. Each entry is a URL from which an authorization can be fetched with a POST-as-GET request.
- The authorizations themselves are returned as RV(authorizations_by_identifier).
type: list
elements: str
returned: always
finalize:
description:
- A URL that a CSR must be POSTed to once all of the order's authorizations are satisfied to finalize the
order. The result of a successful finalization will be the population of the certificate URL for the order.
type: str
returned: always
certificate:
description:
- A URL for the certificate that has been issued in response to this order.
type: str
returned: when the certificate has been issued
authorizations_by_identifier:
description:
- A dictionary mapping identifiers to their authorization objects.
returned: success
type: dict
contains:
identifier:
description:
- The keys in this dictionary are the identifiers. C(identifier) is a placeholder used in the documentation.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.4) for how authorization objects look like.
type: dict
contains:
identifier:
description:
- The identifier that the account is authorized to represent.
type: dict
returned: always
contains:
type:
description:
- The type of identifier.
- So far V(dns) and V(ip) are defined values.
type: str
returned: always
sample: dns
choices:
- dns
- ip
value:
description:
- The identifier itself.
type: str
returned: always
sample: example.com
status:
description:
- The status of this authorization.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes.
type: str
choices:
- pending
- valid
- invalid
- deactivated
- expired
- revoked
returned: always
expires:
description:
- The timestamp after which the server will consider this authorization invalid.
- Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339).
type: str
returned: if RV(authorizations_by_identifier.identifier.status=valid), and sometimes in other situations
challenges:
description:
- For pending authorizations, the challenges that the client can fulfill in order to prove
possession of the identifier.
- For valid authorizations, the challenge that was validated.
- For invalid authorizations, the challenge that was attempted and failed.
- Each array entry is an object with parameters required to validate the challenge.
A client should attempt to fulfill one of these challenges, and a server should consider
any one of the challenges sufficient to make the authorization valid.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-8) for the general structure. The structure
of every entry depends on the challenge's type. For C(tls-alpn-01) challenges, the structure is
defined in U(https://www.rfc-editor.org/rfc/rfc8737.html#section-3).
type: list
elements: dict
returned: always
contains:
type:
description:
- The type of challenge encoded in the object.
type: str
returned: always
choices:
- http-01
- dns-01
- tls-alpn-01
url:
description:
- The URL to which a response can be posted.
type: str
returned: always
status:
description:
- The status of this challenge.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes.
type: str
choices:
- pending
- processing
- valid
- invalid
returned: always
validated:
description:
- The time at which the server validated this challenge.
- Encoded in the format specified in L(RFC 3339, https://www.rfc-editor.org/rfc/rfc3339).
type: str
returned: always if RV(authorizations_by_identifier.identifier.challenges[].type=valid), otherwise in some situations
error:
description:
- Error that occurred while the server was validating the challenge, if any.
- This field is structured as a L(problem document according to RFC 7807, https://www.rfc-editor.org/rfc/rfc7807).
type: dict
returned: always if RV(authorizations_by_identifier.identifier.challenges[].type=invalid), otherwise in some situations
wildcard:
description:
- This field B(must) be present and true for authorizations created as a result of a
C(newOrder) request containing a DNS identifier with a value that was a wildcard
domain name. For other authorizations, it B(must) be absent.
- Wildcard domain names are described in U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.3)
of the ACME specification.
type: bool
returned: sometimes
authorizations_by_status:
description:
- For every status, a list of identifiers whose authorizations have this status.
returned: success
type: dict
contains:
pending:
description:
- A list of all identifiers whose authorizations are in the C(pending) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: str
returned: always
invalid:
description:
- A list of all identifiers whose authorizations are in the C(invalid) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: str
returned: always
valid:
description:
- A list of all identifiers whose authorizations are in the C(valid) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: str
returned: always
revoked:
description:
- A list of all identifiers whose authorizations are in the C(revoked) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: str
returned: always
deactivated:
description:
- A list of all identifiers whose authorizations are in the C(deactivated) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: str
returned: always
expired:
description:
- A list of all identifiers whose authorizations are in the C(expired) state.
- See U(https://www.rfc-editor.org/rfc/rfc8555#section-7.1.6) for state changes
of authorizations.
type: list
elements: 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),
)
module = argument_spec.create_ansible_module(supports_check_mode=True)
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)
order = client.load_order()
authorizations_by_identifier = dict()
authorizations_by_status = {
'pending': [],
'invalid': [],
'valid': [],
'revoked': [],
'deactivated': [],
'expired': [],
}
for identifier, authz in order.authorizations.items():
authorizations_by_identifier[identifier] = authz.to_json()
authorizations_by_status[authz.status].append(identifier)
module.exit_json(
changed=False,
account_uri=client.client.account_uri,
order_uri=order.url,
order=order.data,
authorizations_by_identifier=authorizations_by_identifier,
authorizations_by_status=authorizations_by_status,
)
except ModuleFailException as e:
e.do_fail(module)
if __name__ == '__main__':
main()

View File

@ -0,0 +1,339 @@
#!/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()

View File

@ -0,0 +1,16 @@
# Copyright (c) Ansible Project
# 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
# This test tests the following four modules:
acme_certificate_order_create
acme_certificate_order_finalize
acme_certificate_order_info
acme_certificate_order_validate
azp/generic/1
azp/posix/1
cloud/acme
# For some reason connecting to helper containers does not work on the Alpine VMs
skip/alpine

View File

@ -0,0 +1,9 @@
---
# Copyright (c) Ansible Project
# 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
dependencies:
- setup_acme
- setup_remote_tmp_dir
- prepare_jinja2_compat

View File

@ -0,0 +1,349 @@
---
# Copyright (c) Ansible Project
# 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
- name: Generate random domain name
set_fact:
domain_name: "host{{ '%0x' % ((2**32) | random) }}.example.com"
- name: Generate account key
openssl_privatekey:
path: "{{ remote_tmp_dir }}/accountkey.pem"
type: ECC
curve: secp256r1
force: true
- name: Parse account keys (to ease debugging some test failures)
openssl_privatekey_info:
path: "{{ remote_tmp_dir }}/accountkey.pem"
return_private_key_data: true
- name: Create ACME account
acme_account:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
terms_agreed: true
state: present
register: account
- name: Generate certificate key
openssl_privatekey:
path: "{{ remote_tmp_dir }}/cert.key"
type: ECC
curve: secp256r1
force: true
- name: Generate certificate CSR
openssl_csr:
path: "{{ remote_tmp_dir }}/cert.csr"
privatekey_path: "{{ remote_tmp_dir }}/cert.key"
subject:
commonName: "{{ domain_name }}"
return_content: true
register: csr
- name: Create certificate order
acme_certificate_order_create:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
csr: "{{ remote_tmp_dir }}/cert.csr"
register: order
- name: Show order information
debug:
var: order
- name: Check order
assert:
that:
- order is changed
- order.order_uri.startswith('https://' ~ acme_host ~ ':14000/')
- order.challenge_data | length == 1
- order.challenge_data[0].identifier_type == 'dns'
- order.challenge_data[0].identifier == domain_name
- order.challenge_data[0].challenges | length >= 2
- "'http-01' in order.challenge_data[0].challenges"
- "'dns-01' in order.challenge_data[0].challenges"
- order.challenge_data[0].challenges['http-01'].resource.startswith('.well-known/acme-challenge/')
- order.challenge_data[0].challenges['http-01'].resource_value is string
- order.challenge_data[0].challenges['dns-01'].record == '_acme-challenge.' ~ domain_name
- order.challenge_data[0].challenges['dns-01'].resource == '_acme-challenge'
- order.challenge_data[0].challenges['dns-01'].resource_value is string
- order.challenge_data_dns | length == 1
- order.challenge_data_dns['_acme-challenge.' ~ domain_name] | length == 1
- order.account_uri == account.account_uri
- name: Get order information
acme_certificate_order_info:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
register: order_info_1
- name: Show order information
debug:
var: order_info_1
- name: Check order information
assert:
that:
- order_info_1 is not changed
- order_info_1.authorizations_by_identifier | length == 1
- order_info_1.authorizations_by_identifier['dns:' ~ domain_name].identifier.type == 'dns'
- order_info_1.authorizations_by_identifier['dns:' ~ domain_name].identifier.value == domain_name
- order_info_1.authorizations_by_identifier['dns:' ~ domain_name].status == 'pending'
- (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'http-01') | first).status == 'pending'
- (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-01') | first).status == 'pending'
- order_info_1.authorizations_by_status['deactivated'] | length == 0
- order_info_1.authorizations_by_status['expired'] | length == 0
- order_info_1.authorizations_by_status['invalid'] | length == 0
- order_info_1.authorizations_by_status['pending'] | length == 1
- order_info_1.authorizations_by_status['pending'][0] == 'dns:' ~ domain_name
- order_info_1.authorizations_by_status['revoked'] | length == 0
- order_info_1.authorizations_by_status['valid'] | length == 0
- order_info_1.order.authorizations | length == 1
- order_info_1.order.authorizations[0] == order_info_1.authorizations_by_identifier['dns:' ~ domain_name].uri
- "'certificate' not in order_info_1.order"
- order_info_1.order.status == 'pending'
- order_info_1.order_uri == order.order_uri
- order_info_1.account_uri == account.account_uri
- name: Create HTTP challenges
uri:
url: "http://{{ acme_host }}:5000/http/{{ item.identifier }}/{{ item.challenges['http-01'].resource[('.well-known/acme-challenge/'|length):] }}"
method: PUT
body_format: raw
body: "{{ item.challenges['http-01'].resource_value }}"
headers:
content-type: "application/octet-stream"
loop: "{{ order.challenge_data }}"
when: "'http-01' in item.challenges"
- name: Let the challenge be validated
community.crypto.acme_certificate_order_validate:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
challenge: http-01
register: validate_1
- name: Check validation result
assert:
that:
- validate_1 is changed
- validate_1.account_uri == account.account_uri
- name: Wait until we know that the challenges have been validated for ansible-core <= 2.11
pause:
seconds: 5
when: ansible_version.full is version('2.12', '<')
- name: Get order information
acme_certificate_order_info:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
register: order_info_2
- name: Show order information
debug:
var: order_info_2
- name: Check order information
assert:
that:
- order_info_2 is not changed
- order_info_2.authorizations_by_identifier | length == 1
- order_info_2.authorizations_by_identifier['dns:' ~ domain_name].identifier.type == 'dns'
- order_info_2.authorizations_by_identifier['dns:' ~ domain_name].identifier.value == domain_name
- order_info_2.authorizations_by_identifier['dns:' ~ domain_name].status in ['pending', 'valid']
- (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'http-01') | map(attribute='status') | first | default('not there')) in ['processing', 'valid']
- (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-01') | map(attribute='status') | first | default('not there')) in ['pending', 'not there']
- order_info_2.authorizations_by_status['deactivated'] | length == 0
- order_info_2.authorizations_by_status['expired'] | length == 0
- order_info_2.authorizations_by_status['invalid'] | length == 0
- order_info_2.authorizations_by_status['pending'] | length <= 1
- order_info_2.authorizations_by_status['revoked'] | length == 0
- order_info_2.authorizations_by_status['valid'] | length <= 1
- (order_info_2.authorizations_by_status['pending'] | length) + (order_info_2.authorizations_by_status['valid'] | length) == 1
- order_info_2.order.authorizations | length == 1
- order_info_2.order.authorizations[0] == order_info_2.authorizations_by_identifier['dns:' ~ domain_name].uri
- "'certificate' not in order_info_2.order"
- order_info_2.order.status in ['pending', 'ready']
- order_info_2.order_uri == order.order_uri
- order_info_2.account_uri == account.account_uri
- name: Let the challenge be validated (idempotent)
community.crypto.acme_certificate_order_validate:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
challenge: http-01
register: validate_2
- name: Check validation result
assert:
that:
- validate_2 is not changed
- validate_2.account_uri == account.account_uri
- name: Retrieve the cert and intermediate certificate
community.crypto.acme_certificate_order_finalize:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
retrieve_all_alternates: true
csr: "{{ remote_tmp_dir }}/cert.csr"
cert_dest: "{{ remote_tmp_dir }}/cert.pem"
chain_dest: "{{ remote_tmp_dir }}/cert-chain.pem"
fullchain_dest: "{{ remote_tmp_dir }}/cert-fullchain.pem"
register: finalize_1
- name: Check finalization result
assert:
that:
- finalize_1 is changed
- finalize_1.account_uri == account.account_uri
- finalize_1.all_chains | length >= 1
- finalize_1.selected_chain == finalize_1.all_chains[0]
- finalize_1.selected_chain.cert.startswith('-----BEGIN CERTIFICATE-----\nMII')
- finalize_1.selected_chain.chain.startswith('-----BEGIN CERTIFICATE-----\nMII')
- finalize_1.selected_chain.full_chain == finalize_1.selected_chain.cert + finalize_1.selected_chain.chain
- name: Read files from disk
slurp:
src: "{{ remote_tmp_dir }}/{{ item }}.pem"
loop:
- cert
- cert-chain
- cert-fullchain
register: slurp
- name: Compare finalization result with files on disk
assert:
that:
- finalize_1.selected_chain.cert == slurp.results[0].content | b64decode
- finalize_1.selected_chain.chain == slurp.results[1].content | b64decode
- finalize_1.selected_chain.full_chain == slurp.results[2].content | b64decode
- name: Get order information
acme_certificate_order_info:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
register: order_info_3
- name: Show order information
debug:
var: order_info_3
- name: Check order information
assert:
that:
- order_info_3 is not changed
- order_info_3.authorizations_by_identifier['dns:' ~ domain_name].identifier.type == 'dns'
- order_info_3.authorizations_by_identifier['dns:' ~ domain_name].identifier.value == domain_name
- order_info_3.authorizations_by_identifier['dns:' ~ domain_name].status == 'valid'
- (order_info_3.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'http-01') | first).status == 'valid'
- order_info_3.authorizations_by_status['deactivated'] | length == 0
- order_info_3.authorizations_by_status['expired'] | length == 0
- order_info_3.authorizations_by_status['invalid'] | length == 0
- order_info_3.authorizations_by_status['pending'] | length == 0
- order_info_3.authorizations_by_status['revoked'] | length == 0
- order_info_3.authorizations_by_status['valid'] | length == 1
- order_info_3.authorizations_by_status['valid'][0] == 'dns:' ~ domain_name
- order_info_3.order.authorizations | length == 1
- order_info_3.order.authorizations[0] == order_info_3.authorizations_by_identifier['dns:' ~ domain_name].uri
- "'certificate' in order_info_3.order"
- order_info_3.order.status == 'valid'
- order_info_3.order_uri == order.order_uri
- order_info_3.account_uri == account.account_uri
- name: Retrieve the cert and intermediate certificate (idempotent)
community.crypto.acme_certificate_order_finalize:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
deactivate_authzs: on_success
retrieve_all_alternates: true
csr: "{{ remote_tmp_dir }}/cert.csr"
cert_dest: "{{ remote_tmp_dir }}/cert.pem"
chain_dest: "{{ remote_tmp_dir }}/cert-chain.pem"
fullchain_dest: "{{ remote_tmp_dir }}/cert-fullchain.pem"
register: finalize_2
- name: Check finalization result
assert:
that:
- finalize_2 is not changed
- finalize_2.account_uri == account.account_uri
- finalize_2.all_chains | length >= 1
- finalize_2.selected_chain == finalize_2.all_chains[0]
- finalize_2.selected_chain.cert.startswith('-----BEGIN CERTIFICATE-----\nMII')
- finalize_2.selected_chain.chain.startswith('-----BEGIN CERTIFICATE-----\nMII')
- finalize_2.selected_chain.full_chain == finalize_2.selected_chain.cert + finalize_2.selected_chain.chain
- finalize_2.selected_chain == finalize_1.selected_chain
- name: Get order information
acme_certificate_order_info:
acme_directory: https://{{ acme_host }}:14000/dir
acme_version: 2
validate_certs: false
account_key_src: "{{ remote_tmp_dir }}/accountkey.pem"
select_crypto_backend: "{{ select_crypto_backend }}"
order_uri: "{{ order.order_uri }}"
register: order_info_4
- name: Show order information
debug:
var: order_info_4
- name: Check order information
assert:
that:
- order_info_4 is not changed
- order_info_4.authorizations_by_identifier['dns:' ~ domain_name].identifier.type == 'dns'
- order_info_4.authorizations_by_identifier['dns:' ~ domain_name].identifier.value == domain_name
- order_info_4.authorizations_by_identifier['dns:' ~ domain_name].status == 'deactivated'
- (order_info_4.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'http-01') | first).status == 'valid'
- order_info_4.authorizations_by_status['deactivated'] | length == 1
- order_info_4.authorizations_by_status['deactivated'][0] == 'dns:' ~ domain_name
- order_info_4.authorizations_by_status['expired'] | length == 0
- order_info_4.authorizations_by_status['invalid'] | length == 0
- order_info_4.authorizations_by_status['pending'] | length == 0
- order_info_4.authorizations_by_status['revoked'] | length == 0
- order_info_4.authorizations_by_status['valid'] | length == 0
- order_info_4.order.authorizations | length == 1
- order_info_4.order.authorizations[0] == order_info_4.authorizations_by_identifier['dns:' ~ domain_name].uri
- "'certificate' in order_info_4.order"
- order_info_4.order.status == 'deactivated'
- order_info_4.order_uri == order.order_uri
- order_info_4.account_uri == account.account_uri

View File

@ -0,0 +1,36 @@
---
# Copyright (c) Ansible Project
# 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
####################################################################
# WARNING: These are designed specifically for Ansible tests #
# and should not be used as examples of how to write Ansible roles #
####################################################################
- block:
- name: Running tests with OpenSSL backend
include_tasks: impl.yml
vars:
select_crypto_backend: openssl
# Old 0.9.8 versions have insufficient CLI support for signing with EC keys
when: openssl_version.stdout is version('1.0.0', '>=')
- name: Remove output directory
file:
path: "{{ remote_tmp_dir }}"
state: absent
- name: Re-create output directory
file:
path: "{{ remote_tmp_dir }}"
state: directory
- block:
- name: Running tests with cryptography backend
include_tasks: impl.yml
vars:
select_crypto_backend: cryptography
when: cryptography_version.stdout is version('1.5', '>=')

View File

@ -6,6 +6,9 @@
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
docs/docsite/rst/guide_selfsigned.rst rstcheck
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_create.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_validate.py validate-modules:return-syntax-error
plugins/modules/acme_challenge_cert_helper.py validate-modules:return-syntax-error
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
plugins/modules/get_certificate.py validate-modules:invalid-documentation

View File

@ -5,6 +5,9 @@
.azure-pipelines/scripts/publish-codecov.py future-import-boilerplate
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_create.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_validate.py validate-modules:return-syntax-error
plugins/modules/acme_challenge_cert_helper.py validate-modules:return-syntax-error
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
plugins/modules/get_certificate.py validate-modules:invalid-documentation

View File

@ -1,5 +1,8 @@
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_create.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_info.py validate-modules:return-syntax-error
plugins/modules/acme_certificate_order_validate.py validate-modules:return-syntax-error
plugins/modules/acme_challenge_cert_helper.py validate-modules:return-syntax-error
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
plugins/modules/get_certificate.py validate-modules:invalid-documentation