414 lines
18 KiB
Python
414 lines
18 KiB
Python
#!/usr/bin/python
|
|
# -*- coding: utf-8 -*-
|
|
|
|
# Copyright (c) 2024 Felix Fontein <felix@fontein.de>
|
|
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
from __future__ import absolute_import, division, print_function
|
|
__metaclass__ = type
|
|
|
|
|
|
DOCUMENTATION = '''
|
|
---
|
|
module: acme_certificate_order_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()
|