Add new ACME modules for working with orders. (#757)
parent
072318466e
commit
49354f2121
|
@ -89,6 +89,10 @@ If you use the Ansible package and do not update collections independently, use
|
||||||
- acme_ari_info module
|
- acme_ari_info module
|
||||||
- acme_certificate module
|
- acme_certificate module
|
||||||
- acme_certificate_deactivate_authz 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_certificate_revoke module
|
||||||
- acme_challenge_cert_helper module
|
- acme_challenge_cert_helper module
|
||||||
- acme_inspect module
|
- acme_inspect module
|
||||||
|
|
|
@ -8,9 +8,13 @@ requires_ansible: '>=2.9.10'
|
||||||
action_groups:
|
action_groups:
|
||||||
acme:
|
acme:
|
||||||
- acme_inspect
|
- acme_inspect
|
||||||
- acme_certificate_deactivate_authz
|
|
||||||
- acme_certificate_revoke
|
|
||||||
- acme_certificate
|
- 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
|
||||||
- acme_account_info
|
- acme_account_info
|
||||||
|
|
||||||
|
|
|
@ -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))
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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()
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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', '>=')
|
|
@ -6,6 +6,9 @@
|
||||||
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
|
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
|
||||||
docs/docsite/rst/guide_selfsigned.rst rstcheck
|
docs/docsite/rst/guide_selfsigned.rst rstcheck
|
||||||
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
|
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/acme_challenge_cert_helper.py validate-modules:return-syntax-error
|
||||||
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
||||||
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
||||||
|
|
|
@ -5,6 +5,9 @@
|
||||||
.azure-pipelines/scripts/publish-codecov.py future-import-boilerplate
|
.azure-pipelines/scripts/publish-codecov.py future-import-boilerplate
|
||||||
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
|
.azure-pipelines/scripts/publish-codecov.py metaclass-boilerplate
|
||||||
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
|
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/acme_challenge_cert_helper.py validate-modules:return-syntax-error
|
||||||
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
||||||
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
.azure-pipelines/scripts/publish-codecov.py replace-urlopen
|
||||||
plugins/modules/acme_account_info.py validate-modules:return-syntax-error
|
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/acme_challenge_cert_helper.py validate-modules:return-syntax-error
|
||||||
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
plugins/modules/ecs_certificate.py validate-modules:invalid-documentation
|
||||||
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
plugins/modules/get_certificate.py validate-modules:invalid-documentation
|
||||||
|
|
Loading…
Reference in New Issue