From f3b43185bfdb11a316cba1e65a8fc3ae43f94e60 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Wed, 1 Apr 2026 19:46:59 +0200 Subject: [PATCH] ACME: add dns-persist-01 support (#997) * Add dns-persist-01 DNS TXT record filters. * Refactor parsing and joining CAA issue-values out. * Add basic tests. * Fix bug and add integration tests for filters. * Add dns-persist-01 support to ACME modules. * Add changelog fragment. --- .../fragments/997-acme-dns-persist-01.yml | 4 + plugins/filter/acme_dns_persist_record.py | 177 +++++++++++++++++ .../filter/acme_dns_persist_record_parse.py | 163 ++++++++++++++++ plugins/module_utils/_acme/challenges.py | 49 +++++ plugins/module_utils/_caa.py | 98 ++++++++++ plugins/modules/acme_certificate.py | 37 +++- .../modules/acme_certificate_order_create.py | 37 +++- .../modules/acme_certificate_order_info.py | 7 +- .../acme_certificate_order_validate.py | 11 +- .../targets/acme_certificate/tasks/impl.yml | 74 +++++++ .../acme_certificate/tests/validate.yml | 13 ++ .../acme_certificate_order/tasks/impl.yml | 85 ++++++++- .../filter_acme_dns_persist_record/aliases | 6 + .../tasks/main.yml | 110 +++++++++++ .../aliases | 6 + .../tasks/main.yml | 113 +++++++++++ tests/unit/plugins/module_utils/test__caa.py | 180 ++++++++++++++++++ 17 files changed, 1148 insertions(+), 22 deletions(-) create mode 100644 changelogs/fragments/997-acme-dns-persist-01.yml create mode 100644 plugins/filter/acme_dns_persist_record.py create mode 100644 plugins/filter/acme_dns_persist_record_parse.py create mode 100644 plugins/module_utils/_caa.py create mode 100644 tests/integration/targets/filter_acme_dns_persist_record/aliases create mode 100644 tests/integration/targets/filter_acme_dns_persist_record/tasks/main.yml create mode 100644 tests/integration/targets/filter_acme_dns_persist_record_parse/aliases create mode 100644 tests/integration/targets/filter_acme_dns_persist_record_parse/tasks/main.yml create mode 100644 tests/unit/plugins/module_utils/test__caa.py diff --git a/changelogs/fragments/997-acme-dns-persist-01.yml b/changelogs/fragments/997-acme-dns-persist-01.yml new file mode 100644 index 00000000..f314c70a --- /dev/null +++ b/changelogs/fragments/997-acme-dns-persist-01.yml @@ -0,0 +1,4 @@ +minor_changes: + - acme_* modules - support ``dns-persist-01`` challenge type according to + `acme-dns-persist draft 01 `__ + (https://github.com/ansible-collections/community.crypto/pull/997). diff --git a/plugins/filter/acme_dns_persist_record.py b/plugins/filter/acme_dns_persist_record.py new file mode 100644 index 00000000..be2c7b3d --- /dev/null +++ b/plugins/filter/acme_dns_persist_record.py @@ -0,0 +1,177 @@ +# Copyright (c) 2026, Felix Fontein +# 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 annotations + +DOCUMENTATION = r""" +name: acme_dns_persist_record +short_description: Craft a DNS record for ACME C(dns-persist-01) challenges +author: Felix Fontein (@felixfontein) +version_added: 3.2.0 +description: + - Craft the content for a ACME C(dns-persist-01) DNS TXT record V(_validation-persist.). + - This filter conforms to the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + Note that the supported draft version can change at any time, + and changes will only be considered breaking once the draft reached RFC status. +options: + _input: + description: + - The issuer domain name. + type: string + required: true + account_uri: + description: + - The ACME account URI. + type: string + required: true + policy: + description: + - The validation scope. + type: string + choices: + wildcard: + - If this value is present, the CA MAY consider this validation sufficient for issuing certificates + for the validated FQDN, for specific subdomains of the validated FQDN + (as covered by wildcard scope or specific subdomain validation rules), + and for wildcard certificates (for example V(*.example.com)). See + L(Section 5, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#wildcard-certificate-validation) + and L(Section 6, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#subdomain-certificate-validation) + of the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + persist_until: + description: + - Until when the record is valid. + - Can be specified as a UNIX time stamp (integer), as a Python datetime object, + or as a relative time or absolute timestamp specified as a string. + - Times specified as strings will always be interpreted as UTC. + Valid format is C([+-]timespec | ASN.1 TIME) where timespec can be an integer + + C([w | d | h | m | s]) (for example V(+32w1d2h)). + type: any +seealso: + - module: community.crypto.acme_certificate + - module: community.crypto.acme_certificate_order_create + - module: community.crypto.acme_certificate_order_validate +""" + +EXAMPLES = r""" +--- +- name: Create _validation-persist. TXT record contents + ansible.builtin.debug: + msg: >- + {{ + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234', + policy='wildcard', + persist_until='+1w', + ) + }} + +- name: Create _validation-persist. TXT record for example.com + community.dns.hetzner_dns_record_set: + prefix: _validation-persist + zone_name: example.com + value: + - >- + {{ + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/4321', + persist_until='20190331202428Z', + ) + }} +""" + +RETURN = r""" +_value: + description: + - The content for the V(_validation-persist.) TXT record. + type: string +""" + +import datetime +import typing as t +from collections.abc import Callable + +from ansible.errors import AnsibleFilterError + +from ansible_collections.community.crypto.plugins.module_utils._caa import ( + join_issue_value, +) +from ansible_collections.community.crypto.plugins.module_utils._crypto.basic import ( + OpenSSLObjectError, +) +from ansible_collections.community.crypto.plugins.module_utils._time import ( + get_epoch_seconds, + get_now_datetime, + get_relative_time_option, +) + + +def acme_dns_persist_record( + domain_issuer_name: t.Any, + *, + account_uri: t.Any, + policy: t.Any | None = None, + persist_until: t.Any | None = None, +) -> str: + if not isinstance(domain_issuer_name, str): + raise AnsibleFilterError( + "The input for the community.crypto.acme_dns_persist_record filter" + f" must be a string; got {type(domain_issuer_name)} instead" + ) + if not isinstance(account_uri, str): + raise AnsibleFilterError( + "The account_uri parameter for the community.crypto.acme_dns_persist_record filter" + f" must be a string; got {type(account_uri)} instead" + ) + valid_policies = ("wildcard",) + if policy is not None and policy not in valid_policies: + choices = ", ".join(f'"{vp}"' for vp in valid_policies) + raise AnsibleFilterError( + "The policy parameter for the community.crypto.acme_dns_persist_record filter" + f" must be one of {choices}; got {policy!r} instead" + ) + if persist_until is not None: + if isinstance(persist_until, str): + try: + persist_until = get_relative_time_option( + persist_until, + input_name="persist_until", + with_timezone=True, + now=get_now_datetime(with_timezone=True), + ) + except OpenSSLObjectError as exc: + raise AnsibleFilterError( + "Error parsing persist_until parameter for the community.crypto.acme_dns_persist_record filter:" + f" {exc}" + ) from None + if isinstance(persist_until, int) and not isinstance(persist_until, bool): + pass + elif isinstance(persist_until, datetime.datetime): + persist_until = int(get_epoch_seconds(persist_until)) + else: + raise AnsibleFilterError( + "The persist_until parameter for the community.crypto.acme_dns_persist_record filter" + f" must be an integer, a string, or a datetime object; got {type(persist_until)} instead" + ) + + parts = [("accounturi", account_uri)] + if policy is not None: + parts.append(("policy", policy)) + if persist_until is not None: + parts.append(("persistUntil", str(persist_until))) + try: + return join_issue_value(domain_issuer_name, parts) + except ValueError as exc: + raise AnsibleFilterError( + "Error composing result for the community.crypto.acme_dns_persist_record filter:" + f" {exc}" + ) from exc + + +class FilterModule: + """Ansible jinja2 filters""" + + def filters(self) -> dict[str, Callable]: + return { + "acme_dns_persist_record": acme_dns_persist_record, + } diff --git a/plugins/filter/acme_dns_persist_record_parse.py b/plugins/filter/acme_dns_persist_record_parse.py new file mode 100644 index 00000000..26c710fe --- /dev/null +++ b/plugins/filter/acme_dns_persist_record_parse.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026, Felix Fontein +# 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 annotations + +DOCUMENTATION = r""" +name: acme_dns_persist_record_parse +short_description: Parse a DNS record for ACME C(dns-persist-01) challenges +author: Felix Fontein (@felixfontein) +version_added: 3.2.0 +description: + - Parse the content for a ACME C(dns-persist-01) DNS TXT record V(_validation-persist.). + - This filter conforms to the L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + Note that the supported draft version can change at any time, + and changes will only be considered breaking once the draft reached RFC status. +options: + _input: + description: + - The DNS TXT record entry. + type: string + required: true +seealso: + - module: community.crypto.acme_certificate + - module: community.crypto.acme_certificate_order_create + - module: community.crypto.acme_certificate_order_validate +""" + +EXAMPLES = r""" +--- +- name: Create _validation-persist. TXT record contents + ansible.builtin.debug: + msg: "{{ record | community.crypto.acme_dns_persist_record_parse }}" + var: + record: >- + letsencrypt.org; + accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; + policy=wildcard; + persistUntil=1774813004 + +- name: Create _validation-persist. TXT record for example.com + community.dns.hetzner_dns_record_set: + prefix: _validation-persist + zone_name: example.com + value: + - >- + {{ + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/4321', + persist_until='20190331202428Z', + ) + }} +""" + +RETURN = r""" +_value: + description: + - The content for the V(_validation-persist.) TXT record. + type: dictionary + contains: + issuer_domain_name: + type: string + description: + - The issuer domain name. + sample: letsencrypt.org + account_uri: + type: string + description: + - The ACME account URI. + policy: + description: + - The validation scope. + - Is V(null) if not present. + type: string + persist_until: + description: + - Until when the record is valid. + - This is a UNIX timestamp, that is the number of seconds since January 1st, 1970, in UTC. + - Is V(null) if V(persistUntil) is not present. + type: string + persist_until_str: + description: + - A ASN.1 string representation of RV(_value.persist_until). + - Is V(null) if V(persistUntil) is not present. + type: string +""" + +import typing as t +from collections.abc import Callable + +from ansible.errors import AnsibleFilterError + +from ansible_collections.community.crypto.plugins.module_utils._caa import ( + parse_issue_value, +) +from ansible_collections.community.crypto.plugins.module_utils._time import ( + from_epoch_seconds, +) + +TIMESTAMP_FORMAT = "%Y%m%d%H%M%SZ" + + +def acme_dns_persist_record_parse( + record_value: t.Any, +) -> dict[str, t.Any]: + if not isinstance(record_value, str): + raise AnsibleFilterError( + "The input for the community.crypto.acme_dns_persist_record_parse filter" + f" must be a string; got {type(record_value)} instead" + ) + try: + domain_name, pairs = parse_issue_value(record_value) + except ValueError as exc: + raise AnsibleFilterError( + "community.crypto.acme_dns_persist_record_parse filter could not parse" + f" value: {exc}" + ) from exc + values = dict(pairs) + if domain_name is None: + raise AnsibleFilterError( + "community.crypto.acme_dns_persist_record_parse filter: domain name not present" + ) + try: + account_uri = values.pop("accounturi") + except KeyError: + raise AnsibleFilterError( + "community.crypto.acme_dns_persist_record_parse filter: cannot find account URI" + ) from None + policy = values.pop("policy", None) + if policy is not None: + policy = policy.lower() + # TODO unknown policy + persist_until_v = values.pop("persistUntil", None) + persist_until: int | None = None + persist_until_str: str | None = None + if persist_until_v is not None: + try: + persist_until = int(persist_until_v) + except ValueError as exc: + raise AnsibleFilterError( + f"community.crypto.acme_dns_persist_record_parse filter: error when parsing persistUntil: {exc}" + ) from None + persist_until_str = from_epoch_seconds( + persist_until, with_timezone=True + ).strftime(TIMESTAMP_FORMAT) + result: dict[str, t.Any] = { + "issuer_domain_name": domain_name, + "account_uri": account_uri, + "policy": policy, + "persist_until": persist_until, + "persist_until_str": persist_until_str, + } + # TODO values not empty + return result + + +class FilterModule: + """Ansible jinja2 filters""" + + def filters(self) -> dict[str, Callable]: + return { + "acme_dns_persist_record_parse": acme_dns_persist_record_parse, + } diff --git a/plugins/module_utils/_acme/challenges.py b/plugins/module_utils/_acme/challenges.py index ce7c89ec..8ab1c141 100644 --- a/plugins/module_utils/_acme/challenges.py +++ b/plugins/module_utils/_acme/challenges.py @@ -26,6 +26,9 @@ from ansible_collections.community.crypto.plugins.module_utils._acme.errors impo from ansible_collections.community.crypto.plugins.module_utils._acme.utils import ( nopad_b64, ) +from ansible_collections.community.crypto.plugins.module_utils._caa import ( + _check_domain_name, +) if t.TYPE_CHECKING: from ansible.module_utils.basic import AnsibleModule # pragma: no cover @@ -104,6 +107,52 @@ class Challenge: def get_validation_data( self, *, client: ACMEClient, identifier_type: str, identifier: str ) -> dict[str, t.Any] | None: + if self.type == "dns-persist-01": + # https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html#section-3.1 + account_uri = self.data.get("accounturi") + issuer_domain_names = self.data.get("issuer-domain-names") + if account_uri is None: + # In version 00 of the draft, accounturi isn't present. + # Since that's what Pebble currently implements, + # let's fake the value if we have it. + # (https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-00.html#section-6) + account_uri = client.account_uri + if ( + not isinstance(account_uri, str) + or not isinstance(issuer_domain_names, list) + or not all(isinstance(idn, str) for idn in issuer_domain_names) + ): + return None + if client.account_uri is not None and account_uri != client.account_uri: + # While the RFC doesn't demand this, I think it's a bad sign if the account URIs disagree. + # Better err on the side of caution... + client.module.warn( + f"The dns-persist-01 challenge for DNS:{identifier} has account URI {account_uri!r}," + f" while the client is has account URI {client.account_uri}. Ignoring malformed challenge." + ) + return None + if not (1 <= len(issuer_domain_names) <= 10): + client.module.warn( + f"The dns-persist-01 challenge for DNS:{identifier} has {len(issuer_domain_names)}" + " issuer domain names, which is not in [1, 10]. Ignoring malformed challenge." + ) + return None + for idn in issuer_domain_names: + try: + _check_domain_name(idn) + if idn != idn.lower() or len(idn) > 253: + raise ValueError() + except ValueError: + client.module.warn( + f"The dns-persist-01 challenge for DNS:{identifier} has an invalid" + f" issuer domain name {idn!r}. Ignoring malformed challenge." + ) + return None + return { + "account_uri": account_uri, + "issuer_domain_names": issuer_domain_names, + } + if self.token is None: return None diff --git a/plugins/module_utils/_caa.py b/plugins/module_utils/_caa.py new file mode 100644 index 00000000..14f2ef01 --- /dev/null +++ b/plugins/module_utils/_caa.py @@ -0,0 +1,98 @@ +# Copyright (c) 2020, Felix Fontein +# 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 + +# Note that this module util is **PRIVATE** to the collection. It can have breaking changes at any time. +# Do not use this from other collections or standalone plugins/modules! + +from __future__ import annotations + +import re + +_VALUE_RE = re.compile("^[\x21-\x3a\x3c-\x7e]*$") +_LABEL_RE = re.compile("^[0-9a-zA-Z][0-9a-zA-Z-]*$") + + +def _check_value(value: str) -> None: + if not _VALUE_RE.match(value): + raise ValueError(f"Invalid value {value!r}") + + +def _check_label(label: str, what: str) -> None: + if not _LABEL_RE.match(label): + raise ValueError(f"Invalid {what} {label!r}") + + +def _check_domain_name(value: str) -> None: + for p in value.split("."): + _check_label(p, "label") + + +def parse_issue_value( + value: str, *, check_for_duplicates: bool = True, strict: bool = True +) -> tuple[str | None, list[tuple[str, str]]]: + """ + Given a CAA issue property, parses it according to the syntax defined in RFC 8659. + + More precisely, see https://www.rfc-editor.org/rfc/rfc8659.html#section-4.2. + + If ``check_for_duplicates == True``, duplicate tags are reported as an error. + If ``strict == True``, invalid characters are reported as an error. + """ + parts = [v.strip(" \t") for v in value.split(";")] + if len(parts) > 1 and not parts[-1]: + del parts[-1] + domain_name = parts[0] or None + if domain_name is not None and strict: + _check_domain_name(domain_name) + pairs = [] + previous_tags: set[str] = set() + for part in parts[1:]: + pieces = part.split("=", 1) + if len(pieces) != 2: + raise ValueError(f"{part!r} is not of the form tag=value") + tag, value = pieces[0].rstrip(" \t"), pieces[1].lstrip(" \t") + if strict: + _check_label(tag, "tag") + _check_value(value) + pairs.append((tag, value)) + if check_for_duplicates: + if tag in previous_tags: + raise ValueError(f"Tag {tag!r} appears multiple times") + previous_tags.add(tag) + return domain_name, pairs + + +def join_issue_value( + domain_name: str | None, + pairs: list[tuple[str, str]], + *, + check_for_duplicates: bool = True, + strict: bool = True, +) -> str: + """ + Given a domain name and a list of tag-value pairs, joins them according + to the syntax defined in RFC 8659. + + More precisely, see https://www.rfc-editor.org/rfc/rfc8659.html#section-4.2. + + If ``check_for_duplicates == True``, duplicate tags are reported as an error. + If ``strict == True``, invalid characters are reported as an error. + """ + if domain_name is not None and strict: + _check_domain_name(domain_name) + parts = [domain_name or ""] + previous_tags: set[str] = set() + for tag, value in pairs: + if strict: + _check_label(tag, "tag") + _check_value(value) + parts.append(f"{tag}={value}") + if check_for_duplicates: + if tag in previous_tags: + raise ValueError(f"Tag {tag!r} appears multiple times") + previous_tags.add(tag) + return "; ".join(parts) + + +__all__ = ("parse_issue_value",) diff --git a/plugins/modules/acme_certificate.py b/plugins/modules/acme_certificate.py index ce1e05b7..457745a7 100644 --- a/plugins/modules/acme_certificate.py +++ b/plugins/modules/acme_certificate.py @@ -12,12 +12,13 @@ short_description: Create SSL/TLS certificates with the ACME protocol description: - Create and renew SSL/TLS certificates with a CA supporting the L(ACME protocol,https://tools.ietf.org/html/rfc8555), such as L(Let's Encrypt,https://letsencrypt.org/). - The current implementation supports the V(http-01), V(dns-01), V(dns-account-01) and V(tls-alpn-01) challenges. + The current implementation supports the V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-01), and V(tls-alpn-01) challenges. - To use this module, it has to be executed twice. Either as two different tasks in the same run or during two runs. Note that the output of the first run needs to be recorded and passed to the second run as the module argument O(data). - - Between these two tasks 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) and V(dns-account-01) - the necessary DNS records have to be created. For V(tls-alpn-01) the necessary certificate has to be created and served. + - Between these two tasks 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), V(dns-account-01), and V(dns-persist-01) the necessary DNS records have 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 @@ -27,6 +28,10 @@ description: L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02). Note that the supported draft version can change at any time, and changes will only be considered breaking once the draft reached RFC status. + - The module supports the V(dns-persist-01) challenge type according to + L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + Note that the supported draft version can change at any time, + and changes will only be considered breaking once the draft reached RFC status. notes: - At least one of O(dest) and O(fullchain_dest) must be specified. - This module includes basic account management functionality. If you want to have more control over your ACME account, @@ -119,13 +124,14 @@ options: - If set to V(no challenge), no challenge will be used. This is necessary for some private CAs which use External Account Binding and other means of validating certificate assurance. For example, an account could be allowed to issue certificates for C(foo.example.com) without any further validation for a certain period of time. - - Support for V(dns-account-01) has been added in community.crypto 3.2.0. + - Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0. type: str default: http-01 choices: - http-01 - dns-01 - dns-account-01 + - dns-persist-01 - tls-alpn-01 - no challenge csr: @@ -473,14 +479,14 @@ challenge_data: description: - Data for every challenge type. - The keys in this dictionary are the challenge types. C(challenge-type) is a placeholder used in the documentation. - Possible keys are V(http-01), V(dns-01), V(dns-account-01), and V(tls-alpn-01). + Possible keys are V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-01), and V(tls-alpn-01). - Note that the keys are not valid Jinja2 identifiers. returned: changed type: dict contains: resource: description: The challenge resource that must be created for validation. - returned: changed + returned: changed and challenge is not V(dns-persist-01) type: str sample: .well-known/acme-challenge/evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ-PCt92wr-oA resource_original: @@ -497,7 +503,7 @@ challenge_data: binary blob which has to be put into the acmeValidation x509 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: changed + returned: changed and challenge is not V(dns-persist-01) type: str sample: IlirfxKKXA...17Dt3juxGJ-PCt92wr-oA record: @@ -505,6 +511,20 @@ challenge_data: returned: changed and challenge is V(dns-01) or V(dns-account-01) type: str sample: _acme-challenge.example.com + account_uri: + description: + - The account URI that must be mentioned in the DNS TXT record. + returned: changed and challenge is V(dns-persist-01) + type: str + sample: https://ca.example/acct/123 + issuer_domain_names: + description: + - One of the issuer domain names must be mentioned in the DNS TXT record. + returned: changed and challenge is V(dns-persist-01) + type: list + elements: str + sample: + - letsencrypt.org challenge_data_dns: description: - List of TXT values per DNS record, in case challenge is V(dns-01) or V(dns-account-01). @@ -987,6 +1007,7 @@ def main() -> t.NoReturn: "http-01", "dns-01", "dns-account-01", + "dns-persist-01", "tls-alpn-01", NO_CHALLENGE, ], diff --git a/plugins/modules/acme_certificate_order_create.py b/plugins/modules/acme_certificate_order_create.py index 26bf2a54..93a78da6 100644 --- a/plugins/modules/acme_certificate_order_create.py +++ b/plugins/modules/acme_certificate_order_create.py @@ -16,7 +16,7 @@ description: Authority such as L(Let's Encrypt,https://letsencrypt.org/). 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), V(dns-account-01), + - The current implementation supports the V(http-01), V(dns-01), V(dns-account-01), V(dns-persist-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. @@ -29,9 +29,9 @@ description: - 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) and V(dns-account-01) the necessary DNS records have 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 V(dns-01), V(dns-account-01), and V(dns-persist-01) the necessary DNS records have 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). @@ -42,6 +42,10 @@ description: L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02). Note that the supported draft version can change at any time, and changes will only be considered breaking once the draft reached RFC status. + - The module supports the V(dns-persist-01) challenge type according to + L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + Note that the supported draft version can change at any time, + and changes will only be considered breaking once the draft reached RFC status. seealso: - module: community.crypto.acme_certificate_order_validate description: Validate pending authorizations of an ACME order. @@ -307,6 +311,7 @@ challenge_data: resource: description: - Always contains the string V(_acme-challenge). + returned: success type: str sample: _acme-challenge resource_value: @@ -332,6 +337,7 @@ challenge_data: resource: description: - Always ends with the string V(._acme-challenge). + returned: success type: str sample: _ujmmovf2vn55tgye._acme-challenge resource_value: @@ -345,6 +351,29 @@ challenge_data: returned: success type: str sample: _ujmmovf2vn55tgye._acme-challenge.example.com + dns-persist-01: + description: + - Information for V(dns-persist-01) authorization. + - A DNS TXT record needs to be created with the record name V(_validation-persist.). + See the P(community.crypto.acme_dns_persist_record#filter) for how to create the record's content. + returned: if the identifier supports V(dns-persist-01) authorization + version_added: 3.2.0 + type: dict + contains: + account_uri: + description: + - The account URI that must be mentioned in the DNS TXT record. + returned: success + type: str + sample: https://ca.example/acct/123 + issuer_domain_names: + description: + - One of the issuer domain names must be mentioned in the DNS TXT record. + returned: success + type: list + elements: str + sample: + - letsencrypt.org tls-alpn-01: description: - Information for V(tls-alpn-01) authorization. diff --git a/plugins/modules/acme_certificate_order_info.py b/plugins/modules/acme_certificate_order_info.py index 9241b0da..38f35b9b 100644 --- a/plugins/modules/acme_certificate_order_info.py +++ b/plugins/modules/acme_certificate_order_info.py @@ -24,6 +24,10 @@ description: L(acme-dns-account-label draft 02, https://datatracker.ietf.org/doc/html/draft-ietf-acme-dns-account-label-02). Note that the supported draft version can change at any time, and changes will only be considered breaking once the draft reached RFC status. + - The module supports the V(dns-persist-01) challenge type according to + L(acme-dns-persist draft 01, https://www.ietf.org/archive/id/draft-ietf-acme-dns-persist-01.html). + Note that the supported draft version can change at any time, + and changes will only be considered breaking once the draft reached RFC status. seealso: - module: community.crypto.acme_certificate_order_create description: Create an ACME order. @@ -260,13 +264,14 @@ authorizations_by_identifier: type: description: - The type of challenge encoded in the object. - - Support for V(dns-account-01) has been added in community.crypto 3.2.0. + - Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0. type: str returned: always choices: - http-01 - dns-01 - dns-account-01 + - dns-persist-01 - tls-alpn-01 url: description: diff --git a/plugins/modules/acme_certificate_order_validate.py b/plugins/modules/acme_certificate_order_validate.py index 79d11ed7..e926e550 100644 --- a/plugins/modules/acme_certificate_order_validate.py +++ b/plugins/modules/acme_certificate_order_validate.py @@ -65,12 +65,13 @@ options: - 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. - - Support for V(dns-account-01) has been added in community.crypto 3.2.0. + - Support for V(dns-account-01) and V(dns-persist-01) has been added in community.crypto 3.2.0. type: str choices: - http-01 - dns-01 - dns-account-01 + - dns-persist-01 - tls-alpn-01 order_uri: description: @@ -250,7 +251,13 @@ def main() -> t.NoReturn: order_uri={"type": "str", "required": True}, challenge={ "type": "str", - "choices": ["http-01", "dns-01", "dns-account-01", "tls-alpn-01"], + "choices": [ + "http-01", + "dns-01", + "dns-account-01", + "dns-persist-01", + "tls-alpn-01", + ], }, deactivate_authzs={"type": "bool", "default": True}, ) diff --git a/tests/integration/targets/acme_certificate/tasks/impl.yml b/tests/integration/targets/acme_certificate/tasks/impl.yml index 875bf1a3..96b99aa9 100644 --- a/tests/integration/targets/acme_certificate/tasks/impl.yml +++ b/tests/integration/targets/acme_certificate/tasks/impl.yml @@ -387,6 +387,66 @@ ansible.builtin.set_fact: cert_9_obtain_results: "{{ certificate_obtain_result }}" cert_9_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" +- when: ansible_version.full is version('2.21', '>=') + vars: + validation_record_domains: + - name: example.com + wildcard: true + - name: example.org + persist_until: "+5m" + - name: t1.example.com + issuer_domain_name: pebble.letsencrypt.org + block: + - name: Obtain ACME account + community.crypto.acme_account_info: + acme_directory: "{{ acme_directory_url }}" + acme_version: 2 + validate_certs: false + account_key_src: "{{ remote_tmp_dir }}/account-ec256.pem" + select_crypto_backend: "{{ select_crypto_backend }}" + register: account_info + - name: Create dns-persist-01 DNS entries + ansible.builtin.uri: + url: "http://{{ acme_host }}:5000/dns/_validation-persist.{{ item.name }}" + method: PUT + body_format: json + body: + - >- + {{ + issuer_domain_name + | community.crypto.acme_dns_persist_record( + account_uri=account_info.account_uri, + policy='wildcard' if item.wildcard | default(false) else none, + persist_until=item.persist_until | default(none), + ) + }} + loop: "{{ validation_record_domains }}" + - name: Obtain cert 10 + ansible.builtin.include_tasks: obtain-cert.yml + vars: + certgen_title: Certificate 10 + certificate_name: cert-10 + key_type: ec256 + subject_alt_name: "DNS:*.example.com,DNS:example.org,DNS:t1.example.com" + subject_alt_name_critical: false + account_key: account-ec256 + challenge: dns-persist-01 + modify_account: true + deactivate_authzs: false + force: false + remaining_days: 1 + terms_agreed: true + account_email: "example@example.org" + use_csr_content: true + - name: Store obtain results for cert 10 + ansible.builtin.set_fact: + cert_10_obtain_results: "{{ certificate_obtain_result }}" + cert_10_alternate: "{{ 0 if select_crypto_backend == 'cryptography' else 0 }}" + - name: Remove dns-persist-01 DNS entries + ansible.builtin.uri: + url: "http://{{ acme_host }}:5000/dns/_validation-persist.{{ item.name }}" + method: DELETE + loop: "{{ validation_record_domains }}" ## DISSECT CERTIFICATES ####################################################################### # Make sure certificates are valid. Root certificate for Pebble equals the chain certificate. @@ -429,6 +489,11 @@ ignore_errors: true register: cert_9_valid when: ansible_version.full is version('2.21', '>=') +- name: Verifying cert 10 + ansible.builtin.command: '{{ openssl_binary }} verify -CAfile "{{ remote_tmp_dir }}/cert-10-root.pem" -untrusted "{{ remote_tmp_dir }}/cert-10-chain.pem" "{{ remote_tmp_dir }}/cert-10.pem"' + ignore_errors: true + register: cert_10_valid + when: ansible_version.full is version('2.21', '>=') # Dump certificate info - name: Dumping cert 1 @@ -461,6 +526,10 @@ ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-9.pem" -noout -text' register: cert_9_text when: ansible_version.full is version('2.21', '>=') +- name: Dumping cert 10 + ansible.builtin.command: '{{ openssl_binary }} x509 -in "{{ remote_tmp_dir }}/cert-10.pem" -noout -text' + register: cert_10_text + when: ansible_version.full is version('2.21', '>=') # Dump certificate info - name: Dumping cert 1 @@ -502,6 +571,11 @@ path: "{{ remote_tmp_dir }}/cert-9.pem" register: cert_9_info when: ansible_version.full is version('2.21', '>=') +- name: Dumping cert 10 + community.crypto.x509_certificate_info: + path: "{{ remote_tmp_dir }}/cert-10.pem" + register: cert_10_info + when: ansible_version.full is version('2.21', '>=') ## GET ACCOUNT ORDERS ######################################################################### - name: Don't retrieve orders diff --git a/tests/integration/targets/acme_certificate/tests/validate.yml b/tests/integration/targets/acme_certificate/tests/validate.yml index 21dfc36a..0ac8c114 100644 --- a/tests/integration/targets/acme_certificate/tests/validate.yml +++ b/tests/integration/targets/acme_certificate/tests/validate.yml @@ -173,6 +173,19 @@ - "'DNS:example.org' in cert_9_text.stdout" - "'DNS:t1.example.com' in cert_9_text.stdout" +- when: ansible_version.full is version('2.21', '>=') + block: + - name: Check that certificate 10 is valid + ansible.builtin.assert: + that: + - cert_10_valid is not failed + - name: Check that certificate 10 contains correct SANs + ansible.builtin.assert: + that: + - "'DNS:*.example.com' in cert_10_text.stdout" + - "'DNS:example.org' in cert_10_text.stdout" + - "'DNS:t1.example.com' in cert_10_text.stdout" + - name: Validate that orders were not retrieved ansible.builtin.assert: that: diff --git a/tests/integration/targets/acme_certificate_order/tasks/impl.yml b/tests/integration/targets/acme_certificate_order/tasks/impl.yml index d386ac0f..0fe224ad 100644 --- a/tests/integration/targets/acme_certificate_order/tasks/impl.yml +++ b/tests/integration/targets/acme_certificate_order/tasks/impl.yml @@ -60,7 +60,7 @@ ansible.builtin.debug: var: order_1 -- name: "({{ select_crypto_backend }}) Check order" +- name: "({{ select_crypto_backend }}) Check order (1/2)" ansible.builtin.assert: that: - order_1 is changed @@ -80,6 +80,24 @@ - order_1.challenge_data_dns['_acme-challenge.' ~ domain_name] | length == 1 - order_1.account_uri == account.account_uri +- name: "({{ select_crypto_backend }}) Check order (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - "'dns-account-01' in order_1.challenge_data[0].challenges" + - "'dns-persist-01' in order_1.challenge_data[0].challenges" + - order_1.challenge_data[0].challenges['dns-account-01'].record.endswith('._acme-challenge.' ~ domain_name) + - order_1.challenge_data[0].challenges['dns-account-01'].resource.endswith('._acme-challenge') + - order_1.challenge_data[0].challenges['dns-account-01'].resource_value is string + - "'record' not in order_1.challenge_data[0].challenges['dns-persist-01']" + - "'resource' not in order_1.challenge_data[0].challenges['dns-persist-01']" + - "'resource_value' not in order_1.challenge_data[0].challenges['dns-persist-01']" + - order_1.challenge_data[0].challenges['dns-persist-01'].account_uri == order_1.account_uri + - order_1.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names | length == 2 + - order_1.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names == ["pebble.letsencrypt.org", "ca.example.com"] + - order_1.challenge_data_dns_account | length == 1 + - order_1.challenge_data_dns_account.values() | first | length == 1 + - name: "({{ select_crypto_backend }}) Get order information" community.crypto.acme_certificate_order_info: acme_directory: "{{ acme_directory_url }}" @@ -94,7 +112,7 @@ ansible.builtin.debug: var: order_info_1 -- name: "({{ select_crypto_backend }}) Check order information" +- name: "({{ select_crypto_backend }}) Check order information (1/2)" ansible.builtin.assert: that: - order_info_1 is not changed @@ -119,6 +137,13 @@ - order_info_1.order_uri == order_1.order_uri - order_info_1.account_uri == account.account_uri +- name: "({{ select_crypto_backend }}) Check order information (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending' + - (order_info_1.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending' + - name: "({{ select_crypto_backend }}) Create HTTP challenges" ansible.builtin.uri: url: "http://{{ acme_host }}:5000/http/{{ item.identifier }}/{{ item.challenges['http-01'].resource[('.well-known/acme-challenge/' | length) :] }}" @@ -161,7 +186,7 @@ ansible.builtin.debug: var: order_info_2 -- name: "({{ select_crypto_backend }}) Check order information" +- name: "({{ select_crypto_backend }}) Check order information (1/2)" ansible.builtin.assert: that: - order_info_2 is not changed @@ -186,6 +211,13 @@ - order_info_2.order_uri == order_1.order_uri - order_info_2.account_uri == account.account_uri +- name: "({{ select_crypto_backend }}) Check order information (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | map(attribute='status') | first | default('not there')) in ['pending', 'not there'] + - (order_info_2.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | map(attribute='status') | first | default('not there')) in ['pending', 'not there'] + - name: "({{ select_crypto_backend }}) Let the challenge be validated (idempotent)" community.crypto.acme_certificate_order_validate: acme_directory: "{{ acme_directory_url }}" @@ -411,7 +443,7 @@ - replacement_order_1.account_uri == account.account_uri - replacement_order_1.order_uri not in [order_1.order_uri] - - name: "({{ select_crypto_backend }}) Check replacement order 1 information" + - name: "({{ select_crypto_backend }}) Check replacement order 1 information (1/2)" ansible.builtin.assert: that: - order_info_5 is not changed @@ -436,6 +468,13 @@ - order_info_5.order_uri == replacement_order_1.order_uri - order_info_5.account_uri == account.account_uri + - name: "({{ select_crypto_backend }}) Check replacement order 1 information (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - (order_info_5.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending' + - (order_info_5.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending' + # Right now Pebble does not reject duplicate replacement orders... - when: false # TODO get Pebble improved @@ -509,7 +548,7 @@ - >- ('Stop passing `replaces=' ~ cert_info.cert_id ~ '` due to error 409 urn:ietf:params:acme:error:alreadyReplaced when creating ACME order') in replacement_order_3.warnings - - name: "({{ select_crypto_backend }}) Check replacement order 3 information" + - name: "({{ select_crypto_backend }}) Check replacement order 3 information (1/2)" ansible.builtin.assert: that: - order_info_6 is not changed @@ -534,6 +573,13 @@ - order_info_6.order_uri == replacement_order_3.order_uri - order_info_6.account_uri == account.account_uri + - name: "({{ select_crypto_backend }}) Check replacement order 3 information (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - (order_info_6.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending' + - (order_info_6.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending' + - name: "({{ select_crypto_backend }}) Deactivate authzs for replacement order 3" community.crypto.acme_certificate_deactivate_authz: acme_directory: "{{ acme_directory_url }}" @@ -647,7 +693,7 @@ order_uri: "{{ replacement_order_5.order_uri }}" register: order_info_7 - - name: "({{ select_crypto_backend }}) Check replacement order 5" + - name: "({{ select_crypto_backend }}) Check replacement order 5 (1/2)" ansible.builtin.assert: that: - replacement_order_5 is changed @@ -670,7 +716,25 @@ - >- ('Stop passing `replaces=' ~ cert_info.cert_id ~ '` due to error 409 urn:ietf:params:acme:error:malformed when creating ACME order') in replacement_order_5.warnings - - name: "({{ select_crypto_backend }}) Check replacement order 5 information" + - name: "({{ select_crypto_backend }}) Check replacement order 5 (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - "'dns-account-01' in replacement_order_5.challenge_data[0].challenges" + - "'dns-persist-01' in replacement_order_5.challenge_data[0].challenges" + - replacement_order_5.challenge_data[0].challenges['dns-account-01'].record.endswith('._acme-challenge.' ~ domain_name) + - replacement_order_5.challenge_data[0].challenges['dns-account-01'].resource.endswith('._acme-challenge') + - replacement_order_5.challenge_data[0].challenges['dns-account-01'].resource_value is string + - "'record' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']" + - "'resource' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']" + - "'resource_value' not in replacement_order_5.challenge_data[0].challenges['dns-persist-01']" + - replacement_order_5.challenge_data[0].challenges['dns-persist-01'].account_uri == replacement_order_5.account_uri + - replacement_order_5.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names | length == 2 + - replacement_order_5.challenge_data[0].challenges['dns-persist-01'].issuer_domain_names == ["pebble.letsencrypt.org", "ca.example.com"] + - replacement_order_5.challenge_data_dns_account | length == 1 + - replacement_order_5.challenge_data_dns_account.values() | first | length == 1 + + - name: "({{ select_crypto_backend }}) Check replacement order 5 information (1/2)" ansible.builtin.assert: that: - order_info_7 is not changed @@ -695,6 +759,13 @@ - order_info_7.order_uri == replacement_order_5.order_uri - order_info_7.account_uri == account.account_uri + - name: "({{ select_crypto_backend }}) Check replacement order 5 information (2/2)" + when: ansible_version.full is version('2.21', '>=') + ansible.builtin.assert: + that: + - (order_info_7.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-account-01') | first).status == 'pending' + - (order_info_7.authorizations_by_identifier['dns:' ~ domain_name].challenges | selectattr('type', 'equalto', 'dns-persist-01') | first).status == 'pending' + - name: "({{ select_crypto_backend }}) Deactivate authzs for replacement order 5" community.crypto.acme_certificate_deactivate_authz: acme_directory: "{{ acme_directory_url }}" diff --git a/tests/integration/targets/filter_acme_dns_persist_record/aliases b/tests/integration/targets/filter_acme_dns_persist_record/aliases new file mode 100644 index 00000000..af144115 --- /dev/null +++ b/tests/integration/targets/filter_acme_dns_persist_record/aliases @@ -0,0 +1,6 @@ +# 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 + +azp/generic/1 +azp/posix/1 diff --git a/tests/integration/targets/filter_acme_dns_persist_record/tasks/main.yml b/tests/integration/targets/filter_acme_dns_persist_record/tasks/main.yml new file mode 100644 index 00000000..efff1766 --- /dev/null +++ b/tests/integration/targets/filter_acme_dns_persist_record/tasks/main.yml @@ -0,0 +1,110 @@ +--- +# Copyright (c) 2026 Felix Fontein +# 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: Basic tests + ansible.builtin.assert: + that: + - >- + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234', + ) + == "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234" + - >- + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234', + persist_until=1234, + ) + == "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; persistUntil=1234" + - >- + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234', + policy='wildcard', + ) + == "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard" + - >- + 'letsencrypt.org' | community.crypto.acme_dns_persist_record( + account_uri='https://acme-v02.api.letsencrypt.org/acme/acct/1234', + policy='wildcard', + persist_until=1234, + ) + == "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard; persistUntil=1234" + +- name: Test error (1/N) + ansible.builtin.debug: + msg: "{{ 42 | community.crypto.acme_dns_persist_record(account_uri='') }}" + ignore_errors: true + register: result + +- name: Check error (1/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "The input for the community.crypto.acme_dns_persist_record filter must be a string; got instead" in result.msg + +- name: Test error (2/N) + ansible.builtin.debug: + msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri=42) }}" + ignore_errors: true + register: result + +- name: Check error (2/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "The account_uri parameter for the community.crypto.acme_dns_persist_record filter must be a string; got instead" in result.msg + +- name: Test error (3/N) + ansible.builtin.debug: + msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', policy=42) }}" + ignore_errors: true + register: result + +- name: Check error (3/N) + ansible.builtin.assert: + that: + - result is failed + - >- + 'The policy parameter for the community.crypto.acme_dns_persist_record filter must be one of "wildcard"; got 42 instead' in result.msg + +- name: Test error (4/N) + ansible.builtin.debug: + msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='') }}" + ignore_errors: true + register: result + +- name: Check error (4/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "Error composing result for the community.crypto.acme_dns_persist_record filter: Invalid label ''" in result.msg + +- name: Test error (5/N) + ansible.builtin.debug: + msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', persist_until=true) }}" + ignore_errors: true + register: result + +- name: Check error (5/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "The persist_until parameter for the community.crypto.acme_dns_persist_record filter must be an integer, a string, or a datetime object; got instead" in result.msg + +- name: Test error (6/N) + ansible.builtin.debug: + msg: "{{ '' | community.crypto.acme_dns_persist_record(account_uri='', persist_until='foo bar') }}" + ignore_errors: true + register: result + +- name: Check error (6/N) + ansible.builtin.assert: + that: + - result is failed + - >- + 'Error parsing persist_until parameter for the community.crypto.acme_dns_persist_record filter: The time spec "foo bar" for persist_until is invalid' in result.msg diff --git a/tests/integration/targets/filter_acme_dns_persist_record_parse/aliases b/tests/integration/targets/filter_acme_dns_persist_record_parse/aliases new file mode 100644 index 00000000..af144115 --- /dev/null +++ b/tests/integration/targets/filter_acme_dns_persist_record_parse/aliases @@ -0,0 +1,6 @@ +# 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 + +azp/generic/1 +azp/posix/1 diff --git a/tests/integration/targets/filter_acme_dns_persist_record_parse/tasks/main.yml b/tests/integration/targets/filter_acme_dns_persist_record_parse/tasks/main.yml new file mode 100644 index 00000000..e787fd0b --- /dev/null +++ b/tests/integration/targets/filter_acme_dns_persist_record_parse/tasks/main.yml @@ -0,0 +1,113 @@ +--- +# Copyright (c) 2026 Felix Fontein +# 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: Basic tests + ansible.builtin.assert: + that: + - >- + "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234" + | community.crypto.acme_dns_persist_record_parse + == { + "account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234", + "issuer_domain_name": "letsencrypt.org", + "persist_until": None, + "persist_until_str": None, + "policy": None, + } + - >- + "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard" + | community.crypto.acme_dns_persist_record_parse + == { + "account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234", + "issuer_domain_name": "letsencrypt.org", + "persist_until": None, + "persist_until_str": None, + "policy": "wildcard", + } + - >- + "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; persistUntil=1234" + | community.crypto.acme_dns_persist_record_parse + == { + "account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234", + "issuer_domain_name": "letsencrypt.org", + "persist_until": 1234, + "persist_until_str": "19700101002034Z", + "policy": None, + } + - >- + "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234; policy=wildcard; persistUntil=1234" + | community.crypto.acme_dns_persist_record_parse + == { + "account_uri": "https://acme-v02.api.letsencrypt.org/acme/acct/1234", + "issuer_domain_name": "letsencrypt.org", + "persist_until": 1234, + "persist_until_str": "19700101002034Z", + "policy": "wildcard", + } + +- name: Test error (1/N) + ansible.builtin.debug: + msg: "{{ 42 | community.crypto.acme_dns_persist_record_parse }}" + ignore_errors: true + register: result + +- name: Check error (1/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "The input for the community.crypto.acme_dns_persist_record_parse filter must be a string; got instead" in result.msg + +- name: Test error (2/N) + ansible.builtin.debug: + msg: "{{ ';' | community.crypto.acme_dns_persist_record_parse }}" + ignore_errors: true + register: result + +- name: Check error (2/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "community.crypto.acme_dns_persist_record_parse filter: domain name not present" in result.msg + +- name: Test error (3/N) + ansible.builtin.debug: + msg: "{{ 'lets.encrypt; foo=bar' | community.crypto.acme_dns_persist_record_parse }}" + ignore_errors: true + register: result + +- name: Check error (3/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "community.crypto.acme_dns_persist_record_parse filter: cannot find account URI" in result.msg + +- name: Test error (4/N) + ansible.builtin.debug: + msg: "{{ 'lets.encrypt; accounturi=foo; persistUntil=a' | community.crypto.acme_dns_persist_record_parse }}" + ignore_errors: true + register: result + +- name: Check error (4/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "community.crypto.acme_dns_persist_record_parse filter: error when parsing persistUntil: invalid literal for int() with base 10: 'a'" in result.msg + +- name: Test error (1/N) + ansible.builtin.debug: + msg: "{{ 'a; b' | community.crypto.acme_dns_persist_record_parse }}" + ignore_errors: true + register: result + +- name: Check error (1/N) + ansible.builtin.assert: + that: + - result is failed + - >- + "community.crypto.acme_dns_persist_record_parse filter could not parse value: 'b' is not of the form tag=value" in result.msg diff --git a/tests/unit/plugins/module_utils/test__caa.py b/tests/unit/plugins/module_utils/test__caa.py new file mode 100644 index 00000000..57642df2 --- /dev/null +++ b/tests/unit/plugins/module_utils/test__caa.py @@ -0,0 +1,180 @@ +# 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 + +from __future__ import annotations + +import re +import typing as t + +import pytest + +from ansible_collections.community.crypto.plugins.module_utils._caa import ( + _check_domain_name, + _check_label, + _check_value, + join_issue_value, + parse_issue_value, +) + +TEST_CHECK_VALUE: list[tuple[str, str | None]] = [ + ("", None), + ("a", None), + ( + "!\"#$%&'()*+,-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", + None, + ), + ("a=b", None), + ("\x1f", "Invalid value '\\x1f'"), + (" ", "Invalid value ' '"), + (";", "Invalid value ';'"), + ("\x7f", "Invalid value '\\x7f'"), + ("a b", "Invalid value 'a b'"), +] + + +@pytest.mark.parametrize("value, error", TEST_CHECK_VALUE) +def test_check_value( + value: str, + error: str | None, +) -> None: + if error is None: + _check_value(value) + else: + with pytest.raises(ValueError, match=f"^{re.escape(error)}$"): + _check_value(value) + + +TEST_CHECK_LABEL: list[tuple[str, str | None]] = [ + ("", "Invalid value ''"), + ("a", None), + ("0", None), + ("a-", None), + ("a=", "Invalid value 'a='"), + ("-a", "Invalid value '-a'"), + (" ", "Invalid value ' '"), + ("\t", "Invalid value '\\t'"), + ("a0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-", None), +] + + +@pytest.mark.parametrize("value, error", TEST_CHECK_LABEL) +def test_check_label( + value: str, + error: str | None, +) -> None: + if error is None: + _check_label(value, "value") + else: + with pytest.raises(ValueError, match=f"^{re.escape(error)}$"): + _check_label(value, "value") + + +TEST_CHECK_DOMAIN_NAME: list[tuple[str, str | None]] = [ + ("", "Invalid label ''"), + ("a.", "Invalid label ''"), + (".a", "Invalid label ''"), + ("a.-", "Invalid label '-'"), + ("a.b", None), + ("a.b.c.d.e.f.g.h.i.j.k.l", None), + ("letsencrypt.org", None), +] + + +@pytest.mark.parametrize("value, error", TEST_CHECK_DOMAIN_NAME) +def test_check_domain_name( + value: str, + error: str | None, +) -> None: + if error is None: + _check_domain_name(value) + else: + with pytest.raises(ValueError, match=f"^{re.escape(error)}$"): + _check_domain_name(value) + + +TEST_PARSE_ISSUE_VALUE: list[ + tuple[str, dict[str, t.Any], str | None, list[tuple[str, str]]] +] = [ + ("", {}, None, []), + (";", {}, None, []), + ("a=b", {"strict": False}, "a=b", []), + ("; a=b", {"strict": False}, None, [("a", "b")]), + ("a; a=b", {"strict": False}, "a", [("a", "b")]), + ("a; a=b; c-d=e", {"strict": False}, "a", [("a", "b"), ("c-d", "e")]), + ("ca1.example.net", {}, "ca1.example.net", []), + ("ca1.example.net; account=230123", {}, "ca1.example.net", [("account", "230123")]), +] + + +@pytest.mark.parametrize( + "value, kwargs, expected_domain_name, expected_pairs", TEST_PARSE_ISSUE_VALUE +) +def test_parse_issue_value( + value: str, + kwargs: dict[str, t.Any], + expected_domain_name: str | None, + expected_pairs: list[tuple[str, str]], +) -> None: + assert parse_issue_value(value, **kwargs) == (expected_domain_name, expected_pairs) + + +TEST_PARSE_ISSUE_VALUE_FAIL: list[tuple[str, dict[str, t.Any], str]] = [ + ("a=b", {}, "Invalid label 'a=b'"), + ("a; a.b=b", {}, "Invalid tag 'a.b'"), + ("a; a=b; a=c", {}, "Tag 'a' appears multiple times"), + ("%%%%%", {}, "Invalid label '%%%%%'"), +] + + +@pytest.mark.parametrize("value, kwargs, expected_error", TEST_PARSE_ISSUE_VALUE_FAIL) +def test_parse_issue_value_fail( + value: str, kwargs: dict[str, t.Any], expected_error: str +) -> None: + with pytest.raises(ValueError, match=f"^{re.escape(expected_error)}$"): + parse_issue_value(value, **kwargs) + + +TEST_JOIN_ISSUE_VALUE: list[ + tuple[str | None, list[tuple[str, str]], dict[str, t.Any], str] +] = [ + (None, [], {}, ""), + ("a", [], {}, "a"), + (None, [("a", "b")], {}, "; a=b"), + ("a", [("a", "b")], {}, "a; a=b"), +] + + +@pytest.mark.parametrize( + "domain_name, pairs, kwargs, expected_result", TEST_JOIN_ISSUE_VALUE +) +def test_join_issue_value( + domain_name: str | None, + pairs: list[tuple[str, str]], + kwargs: dict[str, t.Any], + expected_result: str, +) -> None: + assert join_issue_value(domain_name, pairs, **kwargs) == expected_result + + +TEST_JOIN_ISSUE_VALUE_FAIL: list[ + tuple[str | None, list[tuple[str, str]], dict[str, t.Any], str] +] = [ + ("", [], {}, "Invalid label ''"), + (None, [("", "")], {}, "Invalid tag ''"), + (None, [("a", " ")], {}, "Invalid value ' '"), + (None, [("a", "a"), ("a", "b")], {}, "Tag 'a' appears multiple times"), +] + + +@pytest.mark.parametrize( + "domain_name, pairs, kwargs, expected_error", TEST_JOIN_ISSUE_VALUE_FAIL +) +def test_join_issue_value_fail( + domain_name: str | None, + pairs: list[tuple[str, str]], + kwargs: dict[str, t.Any], + expected_error: str, +) -> None: + with pytest.raises(ValueError, match=f"^{re.escape(expected_error)}$"): + join_issue_value(domain_name, pairs, **kwargs)