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.
This commit is contained in:
Felix Fontein
2026-04-01 19:46:59 +02:00
committed by GitHub
parent 4c5962788d
commit f3b43185bf
17 changed files with 1148 additions and 22 deletions

View File

@@ -0,0 +1,177 @@
# Copyright (c) 2026, 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 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.<domain>).
- 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.<domain> 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.<domain> 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.<domain>) 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,
}

View File

@@ -0,0 +1,163 @@
# Copyright (c) 2026, 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 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.<domain>).
- 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.<domain> 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.<domain> 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.<domain>) 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,
}