From f9a75f854ba347146893bd4b36d74a0e44a97397 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 30 Apr 2026 11:41:56 -0700 Subject: [PATCH 01/23] acme: copy over is_wildcard_domain verbatim This is just wholesale copied from certbot's utils. Maybe we could DRY it up by having certbot rely on acme's, but IMO it's not a complex enough function to justify that. --- acme/src/acme/_internal/tests/util_test.py | 21 +++++++++++++++++++++ acme/src/acme/util.py | 15 +++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/acme/src/acme/_internal/tests/util_test.py b/acme/src/acme/_internal/tests/util_test.py index 4bda9933b..8c3445e76 100644 --- a/acme/src/acme/_internal/tests/util_test.py +++ b/acme/src/acme/_internal/tests/util_test.py @@ -1,5 +1,6 @@ """Tests for acme.util.""" import sys +import unittest import pytest @@ -11,5 +12,25 @@ def test_it(): assert {2: 2, 4: 4} == map_keys({1: 2, 3: 4}, lambda x: x + 1) +class IsWildcardDomainTest(unittest.TestCase): + """Tests for is_wildcard_domain.""" + + def setUp(self): + self.wildcard = u"*.example.org" + self.no_wildcard = u"example.org" + + def _call(self, domain): + from acme.util import is_wildcard_domain + return is_wildcard_domain(domain) + + def test_no_wildcard(self): + assert not self._call(self.no_wildcard) + assert not self._call(self.no_wildcard.encode()) + + def test_wildcard(self): + assert self._call(self.wildcard) + assert self._call(self.wildcard.encode()) + + if __name__ == '__main__': sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff --git a/acme/src/acme/util.py b/acme/src/acme/util.py index d38533c44..27730fb56 100644 --- a/acme/src/acme/util.py +++ b/acme/src/acme/util.py @@ -7,3 +7,18 @@ from typing import Mapping def map_keys(dikt: Mapping[Any, Any], func: Callable[[Any], Any]) -> dict[Any, Any]: """Map dictionary keys.""" return {func(key): value for key, value in dikt.items()} + + +def is_wildcard_domain(domain: str) -> bool: + """"Is domain a wildcard domain? + + :param domain: domain to check + :type domain: `bytes` or `str` + + :returns: True if domain is a wildcard, otherwise, False + :rtype: bool + + """ + if isinstance(domain, str): + return domain.startswith("*.") + return domain.startswith(b"*.") From 8af86a179e8e36bff6a2b4f81552ef5a5dae366c Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Fri, 24 Apr 2026 12:26:18 -0700 Subject: [PATCH 02/23] acme: add DNSPersist01 challenge and response --- .../acme/_internal/tests/challenges_test.py | 58 +++++++++++++++++++ acme/src/acme/challenges.py | 57 ++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/acme/src/acme/_internal/tests/challenges_test.py b/acme/src/acme/_internal/tests/challenges_test.py index cd6043287..d2936b16c 100644 --- a/acme/src/acme/_internal/tests/challenges_test.py +++ b/acme/src/acme/_internal/tests/challenges_test.py @@ -1,4 +1,5 @@ """Tests for acme.challenges.""" +import copy import sys from typing import TYPE_CHECKING import unittest @@ -144,6 +145,63 @@ class DNS01Test(unittest.TestCase): hash(DNS01.from_json(self.jmsg)) +class DNSPersist01Test(unittest.TestCase): + + def setUp(self): + from acme.challenges import DNSPersist01 + self.jmsg = { + 'type': 'dns-persist-01', + 'accounturi': 'https://ca.example/acct/123', + 'issuer-domain-names': ['authority.example', 'ca.example.net'] + } + self.msg = DNSPersist01( + account_uri=self.jmsg['accounturi'], + issuer_domain_names=tuple(self.jmsg['issuer-domain-names'])) + + def test_to_partial_json(self): + expected_json = copy.deepcopy(self.jmsg) + # josepy converts lists into tuples + expected_json['issuer-domain-names'] = tuple(expected_json['issuer-domain-names']) + assert expected_json == self.msg.to_partial_json() + + def test_from_json_hashable(self): + from acme.challenges import DNSPersist01 + hash(DNSPersist01.from_json(self.jmsg)) + + def test_from_json(self): + from acme.challenges import DNSPersist01 + assert self.msg == DNSPersist01.from_json(self.jmsg) + + def test_get_rdata(self): + expected_rdata = 'authority.example; accounturi=https://ca.example/acct/123' + assert expected_rdata == self.msg.get_validation_rdata(False) + assert expected_rdata + '; policy=wildcard' == \ + self.msg.get_validation_rdata(True) + + def test_validation_name(self): + assert '_validation-persist.example.com' == \ + self.msg.validation_domain_name('example.com') + assert '_validation-persist.example.com' == \ + self.msg.validation_domain_name(name='*.example.com') + + def test_response(self): + from acme.challenges import DNSPersist01Response + assert isinstance(self.msg.response(), DNSPersist01Response) + + +class DNSPersist01ResponseTest(unittest.TestCase): + + def setUp(self): + from acme.challenges import DNSPersist01Response + self.response = DNSPersist01Response() + + def test_truthiness(self): + assert self.response + + def test_empty_json(self): + assert {} == self.response + + class HTTP01ResponseTest(unittest.TestCase): def setUp(self): diff --git a/acme/src/acme/challenges.py b/acme/src/acme/challenges.py index 6b979e906..b186e122f 100644 --- a/acme/src/acme/challenges.py +++ b/acme/src/acme/challenges.py @@ -11,6 +11,7 @@ from typing import Optional from typing import TypeVar from typing import Union +from acme import util from cryptography.hazmat.primitives import hashes import josepy as jose import requests @@ -476,3 +477,59 @@ class DNSResponse(ChallengeResponse): """ return chall.check_validation(self.validation, account_public_key) + + +@ChallengeResponse.register +class DNSPersist01Response(ChallengeResponse): + """ACME "dns-persist-01" challenge response.""" + + def __bool__(self) -> bool: + # Because this response yields an empty JSON object whose __len__() == 0, manually set a + # truthy value + return True + + +@Challenge.register +class DNSPersist01(Challenge): + """ACME "dns-persist-01" challenge""" + + typ = "dns-persist-01" + + LABEL = "_validation-persist" + """Label clients prepend to the domain name being validated.""" + + account_uri: str = jose.field("accounturi") + issuer_domain_names: tuple[str] = jose.field("issuer-domain-names") + + def get_validation_rdata(self, is_wildcard: bool) -> str: + """Validation TXT record rdata. + + :param bool is_wildcard: Whether to set policy=wildcard. + :rtype: str + """ + parts = [ + self.issuer_domain_names[0], + "accounturi={0}".format(self.account_uri), + ] + + if is_wildcard: + parts.append("policy=wildcard") + + return '; '.join(parts) + + def validation_domain_name(self, name: str) -> str: + """Domain name for TXT validation record. + + :param str name: Domain name being validated. + :rtype: str + """ + if util.is_wildcard_domain(name): + name = name.removeprefix('*.') + return "{0}.{1}".format(self.LABEL, name) + + def response(self) -> DNSPersist01Response: + """ACME "dns-persist-01" challenge response object. + + :rtype: DNSPersist01Response + """ + return DNSPersist01Response() From 431ee89d3a6914d585585085de8e2337d152908d Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Fri, 24 Apr 2026 12:29:31 -0700 Subject: [PATCH 03/23] certbot: add dns-persist-01 challenge to manual plugin In terms of UX, dns-persist-01 functions nearly identically to dns-01, except once the TXT record has been made, no further action is required from the user. --- certbot/src/certbot/_internal/auth_handler.py | 2 + .../src/certbot/_internal/cli/cli_utils.py | 6 +- .../src/certbot/_internal/plugins/manual.py | 61 +++++++++++++------ .../_internal/tests/plugins/manual_test.py | 49 ++++++++++++--- certbot/src/certbot/achallenges.py | 7 +++ certbot/src/certbot/tests/acme_util.py | 12 ++-- 6 files changed, 106 insertions(+), 31 deletions(-) diff --git a/certbot/src/certbot/_internal/auth_handler.py b/certbot/src/certbot/_internal/auth_handler.py index ad53064c4..c9dc203bb 100644 --- a/certbot/src/certbot/_internal/auth_handler.py +++ b/certbot/src/certbot/_internal/auth_handler.py @@ -383,6 +383,8 @@ def challb_to_achall(challb: messages.ChallengeBody, account_key: josepy.JWK, challb=challb, account_key=account_key, identifier=identifier) elif isinstance(chall, challenges.DNS): return achallenges.DNS(challb=challb, identifier=identifier) + elif isinstance(chall, challenges.DNSPersist01): + return achallenges.DNSPersist(challb=challb, identifier=identifier) else: return achallenges.Other(challb=challb, identifier=identifier) diff --git a/certbot/src/certbot/_internal/cli/cli_utils.py b/certbot/src/certbot/_internal/cli/cli_utils.py index e545a8996..eff60df19 100644 --- a/certbot/src/certbot/_internal/cli/cli_utils.py +++ b/certbot/src/certbot/_internal/cli/cli_utils.py @@ -194,7 +194,11 @@ def parse_preferred_challenges(pref_challs: Iterable[str]) -> list[str]: :raises errors.Error: if pref_challs is invalid """ - aliases = {"dns": "dns-01", "http": "http-01"} + aliases = { + "dns": "dns-01", + "dns-persist": "dns-persist-01", + "http": "http-01", + } challs = [c.strip() for c in pref_challs] challs = [aliases.get(c, c) for c in challs] diff --git a/certbot/src/certbot/_internal/plugins/manual.py b/certbot/src/certbot/_internal/plugins/manual.py index e73664e55..9af898438 100644 --- a/certbot/src/certbot/_internal/plugins/manual.py +++ b/certbot/src/certbot/_internal/plugins/manual.py @@ -37,15 +37,16 @@ class Authenticator(common.Plugin, interfaces.Authenticator): 'When using shell scripts, an authenticator script must be provided. ' 'The environment variables available to this script depend on the ' 'type of challenge. $CERTBOT_IDENTIFIER will always contain the domain or IP address ' - 'being authenticated. For HTTP-01 and DNS-01, $CERTBOT_VALIDATION ' + 'being authenticated. For HTTP-01, DNS-01, and DNS-PERSIST-01, $CERTBOT_VALIDATION ' 'is the validation string, and $CERTBOT_TOKEN is the filename of the ' 'resource requested when performing an HTTP-01 challenge. An additional ' 'cleanup script can also be provided and can use the additional variable ' '$CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth script. ' - 'For both authenticator and cleanup script, on HTTP-01 and DNS-01 challenges, ' - '$CERTBOT_REMAINING_CHALLENGES will be equal to the number of challenges that ' - 'remain after the current one, and $CERTBOT_ALL_IDENTIFIERS contains a comma-separated ' - 'list of all identifiers that are challenged for the current certificate.') + 'For both authenticator and cleanup script, on HTTP-01, DNS-01, and DNS-PERSIST-01 ' + 'challenges, $CERTBOT_REMAINING_CHALLENGES will be equal to the number of challenges ' + 'that remain after the current one, and $CERTBOT_ALL_IDENTIFIERS contains a ' + 'comma-separated list of all identifiers that are challenged for the current ' + 'certificate.') # Include the full stop at the end of the FQDN in the instructions below for the null # label of the DNS root, as stated in section 3.1 of RFC 1035. While not necessary # for most day to day usage of hostnames, when adding FQDNs to a DNS zone editor, this @@ -61,6 +62,11 @@ Please deploy a DNS TXT record under the name: with the following value: {validation} +""" + _DNS_RDATA_TOO_LONG = """ +WARNING: Because the above DNS record's value is longer than 255 bytes, you will +need to either split it into multiple substrings, or verify that your DNS provider +does so automatically. """ _DNS_VERIFY_INSTRUCTIONS = """ Before continuing, verify the TXT record has been deployed. Depending on the DNS @@ -128,13 +134,13 @@ permitted by DNS standards.) 'the user or by performing the setup manually.') def auth_hint(self, failed_achalls: Iterable[achallenges.AnnotatedChallenge]) -> str: - def has_chall(cls: type[challenges.Challenge]) -> bool: + def has_chall(cls: tuple[type[challenges.Challenge], ...]) -> bool: return any(isinstance(achall.chall, cls) for achall in failed_achalls) - has_dns = has_chall(challenges.DNS01) + has_dns = has_chall((challenges.DNS01, challenges.DNSPersist01)) resource_names = { - challenges.DNS01: 'DNS TXT records', - challenges.HTTP01: 'challenge files', + (challenges.DNS01, challenges.DNSPersist01): 'DNS TXT records', + (challenges.HTTP01,): 'challenge files', } resources = ' and '.join(sorted([v for k, v in resource_names.items() if has_chall(k)])) @@ -165,30 +171,45 @@ permitted by DNS standards.) def get_chall_pref(self, identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=unused-argument,missing-function-docstring - return [challenges.HTTP01, challenges.DNS01] + return [challenges.DNSPersist01, challenges.HTTP01, challenges.DNS01] def perform(self, achalls: list[achallenges.AnnotatedChallenge] ) -> list[challenges.ChallengeResponse]: # pylint: disable=missing-function-docstring responses = [] last_dns_achall = 0 for i, achall in enumerate(achalls): - if isinstance(achall.chall, challenges.DNS01): + if isinstance(achall.chall, (challenges.DNS01, challenges.DNSPersist01)): last_dns_achall = i for i, achall in enumerate(achalls): if self.conf('auth-hook'): self._perform_achall_with_script(achall, achalls) else: self._perform_achall_manually(achall, i == last_dns_achall) - responses.append(achall.response(achall.account_key)) + responses.append(self._get_response(achall)) return responses + def _get_response(self, achall: achallenges.AnnotatedChallenge) -> challenges.ChallengeResponse: + if isinstance(achall.chall, (challenges.HTTP01, challenges.DNS01)): + return achall.response(achall.account_key) + else: + assert isinstance(achall.chall, challenges.DNSPersist01) + return achall.response() + + def _get_validation(self, achall: achallenges.AnnotatedChallenge) -> str: + if isinstance(achall.chall, (challenges.HTTP01, challenges.DNS01)): + return achall.validation(achall.account_key) + else: + assert isinstance(achall.chall, challenges.DNSPersist01) + is_wildcard = util.is_wildcard_domain(achall.identifier.value) + return achall.get_validation_rdata(is_wildcard) + def _perform_achall_with_script(self, achall: achallenges.AnnotatedChallenge, achalls: list[achallenges.AnnotatedChallenge]) -> None: identifier_value = achall.identifier.value env = { "CERTBOT_DOMAIN": identifier_value, "CERTBOT_IDENTIFIER": identifier_value, - "CERTBOT_VALIDATION": achall.validation(achall.account_key), + "CERTBOT_VALIDATION": self._get_validation(achall), "CERTBOT_ALL_DOMAINS": ','.join(one_achall.identifier.value for one_achall in achalls), "CERTBOT_ALL_IDENTIFIERS": ','.join(one_achall.identifier.value for one_achall in achalls), @@ -206,29 +227,31 @@ permitted by DNS standards.) def _perform_achall_manually(self, achall: achallenges.AnnotatedChallenge, last_dns_achall: bool = False) -> None: identifier_value = achall.identifier.value - validation = achall.validation(achall.account_key) + validation = self._get_validation(achall) if isinstance(achall.chall, challenges.HTTP01): msg = self._HTTP_INSTRUCTIONS.format( achall=achall, encoded_token=achall.chall.encode('token'), port=self.config.http01_port, uri=achall.chall.uri(identifier_value), validation=validation) else: - assert isinstance(achall.chall, challenges.DNS01) + assert isinstance(achall.chall, (challenges.DNS01, challenges.DNSPersist01)) assert achall.identifier.typ == messages.IDENTIFIER_FQDN msg = self._DNS_INSTRUCTIONS.format( domain=achall.validation_domain_name(identifier_value), validation=validation) - if isinstance(achall.chall, challenges.DNS01): + if len(validation) > 255: + msg += self._DNS_RDATA_TOO_LONG + if isinstance(achall.chall, (challenges.DNS01, challenges.DNSPersist01)): if self.subsequent_dns_challenge: - # 2nd or later dns-01 challenge + # 2nd or later dns challenge msg += self._SUBSEQUENT_DNS_CHALLENGE_INSTRUCTIONS elif self.subsequent_any_challenge: - # 1st dns-01 challenge, but 2nd or later *any* challenge, so + # 1st dns challenge, but 2nd or later *any* challenge, so # instruct user not to remove any previous http-01 challenge msg += self._SUBSEQUENT_CHALLENGE_INSTRUCTIONS self.subsequent_dns_challenge = True if last_dns_achall: - # last dns-01 challenge + # last dns challenge msg += self._DNS_VERIFY_INSTRUCTIONS.format( domain=achall.validation_domain_name(identifier_value)) elif self.subsequent_any_challenge: diff --git a/certbot/src/certbot/_internal/tests/plugins/manual_test.py b/certbot/src/certbot/_internal/tests/plugins/manual_test.py index 7b42719e7..ed263e5dc 100644 --- a/certbot/src/certbot/_internal/tests/plugins/manual_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/manual_test.py @@ -25,7 +25,21 @@ class AuthenticatorTest(test_util.TempDirTestCase): self.http_achall = acme_util.HTTP01_A self.dns_achall = acme_util.DNS01_A self.dns_achall_2 = acme_util.DNS01_A_2 - self.achalls = [self.http_achall, self.dns_achall, self.dns_achall_2] + self.dns_persist_achall = acme_util.DNS_PERSIST_01_A + self.dns_persist_achall_wildcard = acme_util.DNS_PERSIST_01_A_WILDCARD + self.achalls = [ + self.http_achall, + self.dns_achall, + self.dns_achall_2, + self.dns_persist_achall, + self.dns_persist_achall_wildcard, + ] + self.responses: list[challenges.ChallengeResponse] = [] + for achall in self.achalls: + if isinstance(achall.chall, challenges.DNSPersist01): + self.responses.append(achall.response()) + else: + self.responses.append(achall.response(achall.account_key)) for d in ["config_dir", "work_dir", "in_progress"]: filesystem.mkdir(os.path.join(self.tempdir, d)) # "backup_dir" and "temp_checkpoint_dir" get created in @@ -60,7 +74,7 @@ class AuthenticatorTest(test_util.TempDirTestCase): def test_get_chall_pref(self): assert self.auth.get_chall_pref('example.org') == \ - [challenges.HTTP01, challenges.DNS01] + [challenges.DNSPersist01, challenges.HTTP01, challenges.DNS01] def test_script_perform(self): self.config.manual_auth_hook = ( @@ -90,13 +104,32 @@ class AuthenticatorTest(test_util.TempDirTestCase): ','.join(achall.identifier.value for achall in self.achalls), ','.join(achall.identifier.value for achall in self.achalls), len(self.achalls) - self.achalls.index(self.http_achall) - 1) + dns_persist_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + self.dns_persist_achall.identifier.value, + self.dns_persist_achall.identifier.value, + 'notoken', + self.dns_persist_achall.get_validation_rdata(False), + ','.join(achall.identifier.value for achall in self.achalls), + ','.join(achall.identifier.value for achall in self.achalls), + len(self.achalls) - self.achalls.index(self.dns_persist_achall) - 1) + dns_persist_wildcard_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + self.dns_persist_achall_wildcard.identifier.value, + self.dns_persist_achall_wildcard.identifier.value, + 'notoken', + self.dns_persist_achall_wildcard.get_validation_rdata(True), + ','.join(achall.identifier.value for achall in self.achalls), + ','.join(achall.identifier.value for achall in self.achalls), + len(self.achalls) - self.achalls.index(self.dns_persist_achall_wildcard) - 1) - assert self.auth.perform(self.achalls) == \ - [achall.response(achall.account_key) for achall in self.achalls] + assert self.auth.perform(self.achalls) == self.responses assert self.auth.env[self.dns_achall]['CERTBOT_AUTH_OUTPUT'] == \ dns_expected assert self.auth.env[self.http_achall]['CERTBOT_AUTH_OUTPUT'] == \ http_expected + assert self.auth.env[self.dns_persist_achall]['CERTBOT_AUTH_OUTPUT'] == \ + dns_persist_expected + assert self.auth.env[self.dns_persist_achall_wildcard]['CERTBOT_AUTH_OUTPUT'] == \ + dns_persist_wildcard_expected # Successful hook output should be sent to notify assert self.mock_get_display().notification.call_count == len(self.achalls) @@ -105,13 +138,15 @@ class AuthenticatorTest(test_util.TempDirTestCase): assert needle in args[0] def test_manual_perform(self): - assert self.auth.perform(self.achalls) == \ - [achall.response(achall.account_key) for achall in self.achalls] + assert self.auth.perform(self.achalls) == self.responses assert self.mock_get_display().notification.call_count == len(self.achalls) for i, (args, kwargs) in enumerate(self.mock_get_display().notification.call_args_list): achall = self.achalls[i] - assert achall.validation(achall.account_key) in args[0] + if isinstance(achall.chall, challenges.DNSPersist01): + assert achall.validation_domain_name(achall.identifier.value) in args[0] + else: + assert achall.validation(achall.account_key) in args[0] assert kwargs['wrap'] is False def test_cleanup(self): diff --git a/certbot/src/certbot/achallenges.py b/certbot/src/certbot/achallenges.py index 9ce7dcd1a..97138e918 100644 --- a/certbot/src/certbot/achallenges.py +++ b/certbot/src/certbot/achallenges.py @@ -100,6 +100,13 @@ class DNS(AnnotatedChallenge): __slots__ = ('challb', 'domain', 'identifier') # pylint: disable=redefined-slots-in-subclass acme_type = challenges.DNS + +class DNSPersist(AnnotatedChallenge): + """Client annotated "dns-persist" ACME challenge""" + __slots__ = ('challb', 'domain', 'identifier') # pylint: disable=redefined-slots-in-subclass + acme_type = challenges.DNSPersist01 + + class Other(AnnotatedChallenge): """Client annotated ACME challenge of an unknown type.""" __slots__ = ('challb', 'domain', 'identifier') # pylint: disable=redefined-slots-in-subclass diff --git a/certbot/src/certbot/tests/acme_util.py b/certbot/src/certbot/tests/acme_util.py index b27d9f8fe..aa320dd88 100644 --- a/certbot/src/certbot/tests/acme_util.py +++ b/certbot/src/certbot/tests/acme_util.py @@ -18,6 +18,9 @@ HTTP01 = challenges.HTTP01( token=b"evaGxfADs6pSRb2LAv9IZf17Dt3juxGJ+PCt92wr+oA") DNS01 = challenges.DNS01(token=b"17817c66b60ce2e4012dfad92657527a") DNS01_2 = challenges.DNS01(token=b"cafecafecafecafecafecafe0feedbac") +DNS_PERSIST_01 = challenges.DNSPersist01( + issuer_domain_names=('ca.example',), + account_uri='https://ca.example/acct/123') CHALLENGES = [HTTP01, DNS01] @@ -40,8 +43,7 @@ def chall_to_challb(chall: challenges.Challenge, status: messages.Status) -> mes HTTP01_P = chall_to_challb(HTTP01, messages.STATUS_PENDING) DNS01_P = chall_to_challb(DNS01, messages.STATUS_PENDING) DNS01_P_2 = chall_to_challb(DNS01_2, messages.STATUS_PENDING) - -CHALLENGES_P = [HTTP01_P, DNS01_P] +DNS_PERSIST_01_P = chall_to_challb(DNS_PERSIST_01, messages.STATUS_PENDING) # AnnotatedChallenge objects @@ -51,8 +53,10 @@ DNS01_A = auth_handler.challb_to_achall(DNS01_P, JWK, messages.Identifier( typ=messages.IDENTIFIER_FQDN, value="example.org")) DNS01_A_2 = auth_handler.challb_to_achall(DNS01_P_2, JWK, messages.Identifier( typ=messages.IDENTIFIER_FQDN, value="esimerkki.example.org")) - -ACHALLENGES = [HTTP01_A, DNS01_A] +DNS_PERSIST_01_A = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, messages.Identifier( + typ=messages.IDENTIFIER_FQDN, value="example.net")) +DNS_PERSIST_01_A_WILDCARD = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, + messages.Identifier(typ=messages.IDENTIFIER_FQDN, value="*.example.net")) def gen_authzr(authz_status: messages.Status, domain: str, challs: Iterable[challenges.Challenge], From bbcddc12222d3a5009a363534976a43265897d29 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 30 Apr 2026 11:43:17 -0700 Subject: [PATCH 04/23] certbot: update docs for dns-persist-01 --- certbot/docs/using.rst | 63 ++++++++++++++++++++++++++++++------------ 1 file changed, 46 insertions(+), 17 deletions(-) diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index e64fcf9cb..3bb646449 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -70,23 +70,25 @@ standalone_ Y N | Uses a "standalone" webserver to obtain a certificate. |dns_plugs| Y N | This category of plugins automates obtaining a certificate by dns-01_ (53) | modifying DNS records to prove you have control over a | domain. Doing domain validation in this way is - | the only way to obtain wildcard certificates from Let's + | one way to obtain wildcard certificates from Let's | Encrypt. -manual_ Y N | Obtain a certificate by manually following instructions to http-01_ (80) or - | perform domain validation yourself. Certificates created this dns-01_ (53) - | way do not support autorenewal. +manual_ Y N | Obtain a certificate by manually following instructions to http-01_ (80), + | perform domain validation yourself. Certificates created this dns-01_ (53), + | way do not support autorenewal. or dns-persist-01_ (53) | Autorenewal may be enabled by providing an authentication - | hook script to automate the domain validation steps. + | hook script to automate the domain validation steps. Using + | the ``dns-persist-01`` challenge type is another way to + | obtain wildcard certificates from Let's Encrypt. =========== ==== ==== =============================================================== ============================= .. |dns_plugs| replace:: :ref:`DNS plugins ` Under the hood, plugins use one of several ACME protocol challenges_ to -prove you control a domain. The options are http-01_ (which uses port 80) -and dns-01_ (requiring configuration of a DNS server on -port 53, though that's often not the same machine as your webserver). A few -plugins support more than one challenge type, in which case you can choose one -with ``--preferred-challenges``. +prove you control a domain. The challenge options are http-01_ (which uses port 80), +and the two DNS-based challenges: dns-01_, or dns-persist-01_ (which both +require configuration of a DNS server on port 53, though that's often not the +same machine as your webserver). A few plugins support more than one challenge +type, in which case you can choose one with ``--preferred-challenges``. There are also many third-party-plugins_ available. Below we describe in more detail the circumstances in which each plugin can be used, and how to use it. @@ -94,6 +96,7 @@ the circumstances in which each plugin can be used, and how to use it. .. _challenges: https://datatracker.ietf.org/doc/html/rfc8555#section-8 .. _http-01: https://datatracker.ietf.org/doc/html/rfc8555#section-8.3 .. _dns-01: https://datatracker.ietf.org/doc/html/rfc8555#section-8.4 +.. _dns-persist-01: https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/ Apache ------ @@ -230,31 +233,57 @@ the UI, you can use the plugin to obtain a certificate by specifying to copy and paste commands into another terminal session, which may be on a different computer. -The manual plugin can use either the ``http`` or the ``dns`` challenge. You can use the ``--preferred-challenges`` option -to choose the challenge of your preference. +The manual plugin can use either the ``http``, ``dns``, or ``dns-persist`` +challenges. You can use the ``--preferred-challenges`` option to choose the +challenge of your preference. The ``http`` challenge will ask you to place a file with a specific name and specific content in the ``/.well-known/acme-challenge/`` directory directly in the top-level directory (“web root”) containing the files served by your webserver. In essence it's the same as the webroot_ plugin, but not automated. -When using the ``dns`` challenge, ``certbot`` will ask you to place a TXT DNS -record with specific contents under the domain name consisting of the hostname -for which you want a certificate issued, prepended by ``_acme-challenge``. +When using the ``dns`` or ``dns-persist` challenges, ``certbot`` will ask you to +place a TXT DNS record with specific contents under the domain name consisting +of the hostname for which you want a certificate issued, prepended by a +subdomain (either ``_acme-challenge`` or ``_validation-persist``, depending on +the challenge). -For example, for the domain ``example.com``, a zone file entry would look like: +For example, when performing the ``dns`` challenge for the domain +``example.com``, the zone entry file to succeed the challenge might look like: :: _acme-challenge.example.com. 300 IN TXT "gfj9Xq...Rg85nM" +This validation string is only valid for this challenge, and the CA validates +it, the TXT record can be removed. + +On the other hand, the ``dns-persist`` challenge works a bit differently. After +initiating the challenge, the manual plugin will ask you to make a DNS TXT +record like this: + +:: + + _validation-persist.example.com. IN TXT "authority.example; accounturi=https://ca.example/acct/123" + +Once made, this TXT record can remain active indefinitely for future +certificate issuances with the CA. + +The ``dns-persist`` challenge type also supports the issuance of wildcard +certificates: if the manual plugin detects a wildcard domain, you will be +asked to add the appropriate ``policy=wildcard`` record for the parent +fully-qualified domain name (FQDN). + .. _manual-renewal: **Renewal with the manual plugin** Certificates created using ``--manual`` **do not** support automatic renewal unless combined with an `authentication hook script <#hooks>`_ via ``--manual-auth-hook`` -to automatically set up the required HTTP and/or TXT challenges. +to automatically set up the required HTTP and/or TXT challenges. In the case of a +certificate issued via the ``dns-persist`` challenge, once the DNS TXT record is +live, no further action is needed and thus your authentication script doesn't +need to perform any additional actions. If you can use one of the other plugins_ which support autorenewal to create your certificate, doing so is highly recommended. From 8723380611097da0f39e71c28e228d267452fd42 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 30 Apr 2026 11:49:51 -0700 Subject: [PATCH 05/23] Add newsfragment entry --- newsfragments/10549.added | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 newsfragments/10549.added diff --git a/newsfragments/10549.added b/newsfragments/10549.added new file mode 100644 index 000000000..b2872eea3 --- /dev/null +++ b/newsfragments/10549.added @@ -0,0 +1,2 @@ +Add support for [`dns-persist-01`](https://datatracker.ietf.org/doc/draft-ietf-acme-dns-persist/) challenges to certbot's manual plugin. This challenge type allows users to verify control over a domain with a single DNS TXT record which can remain active and unchanged indefinitely, allowing for easy renewals when traditional challenge methods are impractical. `dns-persist-01` also allows for the issuance of wildcard certificates. + From 0e2b0643a95799cc45be8e730628efae79cf3883 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 30 Apr 2026 13:32:57 -0700 Subject: [PATCH 06/23] certbot: demote manual's preference for dns-persist-01 This'll avoid breaking things for users relying on http-01's most-preferred status. --- certbot/src/certbot/_internal/plugins/manual.py | 2 +- certbot/src/certbot/_internal/tests/plugins/manual_test.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/certbot/src/certbot/_internal/plugins/manual.py b/certbot/src/certbot/_internal/plugins/manual.py index 9af898438..1e05c8da4 100644 --- a/certbot/src/certbot/_internal/plugins/manual.py +++ b/certbot/src/certbot/_internal/plugins/manual.py @@ -171,7 +171,7 @@ permitted by DNS standards.) def get_chall_pref(self, identifier: str) -> Iterable[type[challenges.Challenge]]: # pylint: disable=unused-argument,missing-function-docstring - return [challenges.DNSPersist01, challenges.HTTP01, challenges.DNS01] + return [challenges.HTTP01, challenges.DNS01, challenges.DNSPersist01] def perform(self, achalls: list[achallenges.AnnotatedChallenge] ) -> list[challenges.ChallengeResponse]: # pylint: disable=missing-function-docstring diff --git a/certbot/src/certbot/_internal/tests/plugins/manual_test.py b/certbot/src/certbot/_internal/tests/plugins/manual_test.py index ed263e5dc..8af9cbf0c 100644 --- a/certbot/src/certbot/_internal/tests/plugins/manual_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/manual_test.py @@ -74,7 +74,7 @@ class AuthenticatorTest(test_util.TempDirTestCase): def test_get_chall_pref(self): assert self.auth.get_chall_pref('example.org') == \ - [challenges.DNSPersist01, challenges.HTTP01, challenges.DNS01] + [challenges.HTTP01, challenges.DNS01, challenges.DNSPersist01] def test_script_perform(self): self.config.manual_auth_hook = ( From b1a3c488eb1751b037b3b4e65c915d725253c2e9 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Fri, 1 May 2026 12:05:51 -0700 Subject: [PATCH 07/23] certbot: add tests for dns-persist-01 failures/warnings --- .../_internal/tests/plugins/manual_test.py | 27 ++++++++++++------- certbot/src/certbot/tests/acme_util.py | 12 ++++++--- 2 files changed, 26 insertions(+), 13 deletions(-) diff --git a/certbot/src/certbot/_internal/tests/plugins/manual_test.py b/certbot/src/certbot/_internal/tests/plugins/manual_test.py index 8af9cbf0c..e113f2d9e 100644 --- a/certbot/src/certbot/_internal/tests/plugins/manual_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/manual_test.py @@ -26,13 +26,15 @@ class AuthenticatorTest(test_util.TempDirTestCase): self.dns_achall = acme_util.DNS01_A self.dns_achall_2 = acme_util.DNS01_A_2 self.dns_persist_achall = acme_util.DNS_PERSIST_01_A - self.dns_persist_achall_wildcard = acme_util.DNS_PERSIST_01_A_WILDCARD + self.dns_persist_achall_wildcard = acme_util.DNS_PERSIST_01_A_WILDCARD_A + self.dns_persist_achall_long = acme_util.DNS_PERSIST_01_LONG_A self.achalls = [ self.http_achall, self.dns_achall, self.dns_achall_2, self.dns_persist_achall, self.dns_persist_achall_wildcard, + self.dns_persist_achall_long, ] self.responses: list[challenges.ChallengeResponse] = [] for achall in self.achalls: @@ -147,6 +149,8 @@ class AuthenticatorTest(test_util.TempDirTestCase): assert achall.validation_domain_name(achall.identifier.value) in args[0] else: assert achall.validation(achall.account_key) in args[0] + if achall == self.dns_persist_achall_long: + assert "WARNING: Because the above DNS record's value is longer than 255 bytes" in args[0] assert kwargs['wrap'] is False def test_cleanup(self): @@ -169,23 +173,26 @@ class AuthenticatorTest(test_util.TempDirTestCase): else: assert 'CERTBOT_TOKEN' not in os.environ + def test_auth_hint_hook(self): self.config.manual_auth_hook = '/bin/true' - assert self.auth.auth_hint([acme_util.DNS01_A, acme_util.HTTP01_A]) == \ - 'The Certificate Authority failed to verify the DNS TXT records and challenge ' \ - 'files created by the --manual-auth-hook. Ensure that this hook is functioning ' \ - 'correctly and that it waits a sufficient duration of time for DNS propagation. ' \ - 'Refer to "certbot --help manual" and the Certbot User Guide.' + for dns_achall in [acme_util.DNS01_A, acme_util.DNS_PERSIST_01_A]: + assert self.auth.auth_hint([dns_achall, acme_util.HTTP01_A]) == \ + 'The Certificate Authority failed to verify the DNS TXT records and challenge ' \ + 'files created by the --manual-auth-hook. Ensure that this hook is functioning ' \ + 'correctly and that it waits a sufficient duration of time for DNS propagation. ' \ + 'Refer to "certbot --help manual" and the Certbot User Guide.' assert self.auth.auth_hint([acme_util.HTTP01_A]) == \ 'The Certificate Authority failed to verify the challenge files created by the ' \ '--manual-auth-hook. Ensure that this hook is functioning correctly. Refer to ' \ '"certbot --help manual" and the Certbot User Guide.' def test_auth_hint_no_hook(self): - assert self.auth.auth_hint([acme_util.DNS01_A, acme_util.HTTP01_A]) == \ - 'The Certificate Authority failed to verify the manually created DNS TXT records ' \ - 'and challenge files. Ensure that you created these in the correct location, or ' \ - 'try waiting longer for DNS propagation on the next attempt.' + for dns_achall in [acme_util.DNS01_A, acme_util.DNS_PERSIST_01_A]: + assert self.auth.auth_hint([dns_achall, acme_util.HTTP01_A]) == \ + 'The Certificate Authority failed to verify the manually created DNS TXT records ' \ + 'and challenge files. Ensure that you created these in the correct location, or ' \ + 'try waiting longer for DNS propagation on the next attempt.' assert self.auth.auth_hint([acme_util.HTTP01_A, acme_util.HTTP01_A, acme_util.HTTP01_A]) == \ 'The Certificate Authority failed to verify the manually created challenge files. ' \ 'Ensure that you created these in the correct location.' diff --git a/certbot/src/certbot/tests/acme_util.py b/certbot/src/certbot/tests/acme_util.py index aa320dd88..56a965cda 100644 --- a/certbot/src/certbot/tests/acme_util.py +++ b/certbot/src/certbot/tests/acme_util.py @@ -21,6 +21,9 @@ DNS01_2 = challenges.DNS01(token=b"cafecafecafecafecafecafe0feedbac") DNS_PERSIST_01 = challenges.DNSPersist01( issuer_domain_names=('ca.example',), account_uri='https://ca.example/acct/123') +DNS_PERSIST_01_LONG = challenges.DNSPersist01( + issuer_domain_names=('ca.example',), + account_uri=f"https://ca.example/acct/{'a' * 256}") CHALLENGES = [HTTP01, DNS01] @@ -44,6 +47,7 @@ HTTP01_P = chall_to_challb(HTTP01, messages.STATUS_PENDING) DNS01_P = chall_to_challb(DNS01, messages.STATUS_PENDING) DNS01_P_2 = chall_to_challb(DNS01_2, messages.STATUS_PENDING) DNS_PERSIST_01_P = chall_to_challb(DNS_PERSIST_01, messages.STATUS_PENDING) +DNS_PERSIST_01_LONG_P = chall_to_challb(DNS_PERSIST_01_LONG, messages.STATUS_PENDING) # AnnotatedChallenge objects @@ -53,9 +57,11 @@ DNS01_A = auth_handler.challb_to_achall(DNS01_P, JWK, messages.Identifier( typ=messages.IDENTIFIER_FQDN, value="example.org")) DNS01_A_2 = auth_handler.challb_to_achall(DNS01_P_2, JWK, messages.Identifier( typ=messages.IDENTIFIER_FQDN, value="esimerkki.example.org")) -DNS_PERSIST_01_A = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, messages.Identifier( - typ=messages.IDENTIFIER_FQDN, value="example.net")) -DNS_PERSIST_01_A_WILDCARD = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, +DNS_PERSIST_01_A = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, + messages.Identifier(typ=messages.IDENTIFIER_FQDN, value="example.net")) +DNS_PERSIST_01_LONG_A = auth_handler.challb_to_achall(DNS_PERSIST_01_LONG_P, JWK, + messages.Identifier(typ=messages.IDENTIFIER_FQDN, value="example.net")) +DNS_PERSIST_01_A_WILDCARD_A = auth_handler.challb_to_achall(DNS_PERSIST_01_P, JWK, messages.Identifier(typ=messages.IDENTIFIER_FQDN, value="*.example.net")) From 483681ff2f50005dc32c15d65489111f4043389f Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Wed, 6 May 2026 20:13:44 -0700 Subject: [PATCH 08/23] certbot-ci: add integration test for dns-persist This also bumps the pebble version to the current release w/ dns-persist-01 support --- .../certbot_tests/context.py | 52 ++++++++++--------- .../certbot_tests/test_main.py | 16 ++++++ .../utils/pebble_artifacts.py | 2 +- 3 files changed, 45 insertions(+), 25 deletions(-) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index 4547ac713..1e323d4da 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -38,30 +38,34 @@ class IntegrationTestsContext: os.close(probe[0]) self.hook_probe = probe[1] - self.manual_dns_auth_hook = ( - '{0} -c "import os; import requests; import json; ' - "assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail'); " - "data = {{'host':'_acme-challenge.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))," - "'value':os.environ.get('CERTBOT_VALIDATION')}}; " - "request = requests.post('{1}/set-txt', data=json.dumps(data)); " - "request.raise_for_status(); " - '"' - ).format(sys.executable, self.challtestsrv_url) - self.manual_dns_auth_hook_allow_fail = ( - '{0} -c "import os; import requests; import json; ' - "data = {{'host':'_acme-challenge.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))," - "'value':os.environ.get('CERTBOT_VALIDATION')}}; " - "request = requests.post('{1}/set-txt', data=json.dumps(data)); " - "request.raise_for_status(); " - '"' - ).format(sys.executable, self.challtestsrv_url) - self.manual_dns_cleanup_hook = ( - '{0} -c "import os; import requests; import json; ' - "data = {{'host':'_acme-challenge.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))}}; " - "request = requests.post('{1}/clear-txt', data=json.dumps(data)); " - "request.raise_for_status(); " - '"' - ).format(sys.executable, self.challtestsrv_url) + self.manual_dns_auth_hook = self.generate_dns_auth_hook('_acme-challenge', True) + self.manual_dns_auth_hook_allow_fail = self.generate_dns_auth_hook('_acme-challenge', False) + self.manual_dns_persist_auth_hook = self.generate_dns_auth_hook('_validation-persist', True) + self.manual_dns_cleanup_hook = self.generate_dns_cleanup_hook('_acme-challenge') + self.manual_dns_persist_cleanup_hook = self.generate_dns_cleanup_hook('_validation-persist') + + def generate_dns_auth_hook(self, challenge_subdomain: str, fail_on_subdomain: bool) -> str: + """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv URL, + and optionally fails if the subdomain starts with the word "fail" to simulate a faulty script""" + script_lines = ['import os', 'import requests', 'import json'] + if fail_on_subdomain: + script_lines.append("assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail')") + script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(" + "os.environ.get('CERTBOT_DOMAIN')), 'value':" + "os.environ.get('CERTBOT_VALIDATION')}") + script_lines.append(f"request = requests.post('{self.challtestsrv_url}/set-txt', data=json.dumps(data))") + script_lines.append("request.raise_for_status()") + script = '; '.join(script_lines) + return f'{sys.executable} -c "{script}"' + + def generate_dns_cleanup_hook(self, challenge_subdomain: str) -> str: + """Generates a python one-liner script which cleans up the TXT record made by `generate_dns_auth_hook`""" + script_lines = ['import os', 'import requests', 'import json'] + script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))}}") + script_lines.append(f"request = requests.post('{self.challtestsrv_url}/clear-txt', data=json.dumps(data))") + script_lines.append("request.raise_for_status()") + script = '; '.join(script_lines) + return f'{sys.executable} -c "{script}"' def cleanup(self) -> None: """Cleanup the integration test context.""" diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py index afd902f5c..916d575cd 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py @@ -206,6 +206,22 @@ def test_manual_dns_auth(context: IntegrationTestsContext) -> None: assert_cert_count_for_lineage(context.config_dir, certname, 2) +def test_manual_dns_persist_auth(context: IntegrationTestsContext) -> None: + """Test the DNS-PERSIST-01 challenge using manual plugin.""" + certname = context.get_domain('dns-persist') + context.certbot([ + '-a', 'manual', '-d', certname, '--preferred-challenges', 'dns-persist', + 'run', '--cert-name', certname, + '--manual-auth-hook', context.manual_dns_persist_auth_hook, + '--manual-cleanup-hook', context.manual_dns_persist_cleanup_hook, + '--deploy-hook', misc.echo('deploy', context.hook_probe), + ]) + + assert_hook_execution(context.hook_probe, 'deploy') + assert_saved_deploy_hook(context.config_dir, certname) + assert_cert_count_for_lineage(context.config_dir, certname, 1) + + def test_certonly(context: IntegrationTestsContext) -> None: """Test the certonly verb on certbot.""" context.certbot(['certonly', '--cert-name', 'newname', '-d', context.get_domain('newname')]) diff --git a/certbot-ci/src/certbot_integration_tests/utils/pebble_artifacts.py b/certbot-ci/src/certbot_integration_tests/utils/pebble_artifacts.py index de9956847..7d7562b37 100644 --- a/certbot-ci/src/certbot_integration_tests/utils/pebble_artifacts.py +++ b/certbot-ci/src/certbot_integration_tests/utils/pebble_artifacts.py @@ -15,7 +15,7 @@ import requests from certbot_integration_tests.utils.constants import DEFAULT_HTTP_01_PORT from certbot_integration_tests.utils.constants import MOCK_OCSP_SERVER_PORT -PEBBLE_VERSION = 'v2.8.0' +PEBBLE_VERSION = 'v2.10.1' def fetch(workspace: str, http_01_port: int = DEFAULT_HTTP_01_PORT) -> tuple[str, str, str]: From 2349235c15dd1343f14e6b997309456a5c98224b Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Wed, 6 May 2026 21:28:53 -0700 Subject: [PATCH 09/23] certbot: update docs for dns-persist-01 This adds examples for using the dns-persist challenge, and clarifies what needs to be done for automated renewal --- certbot/docs/using.rst | 68 +++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index 3bb646449..46dc43f53 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -248,45 +248,71 @@ of the hostname for which you want a certificate issued, prepended by a subdomain (either ``_acme-challenge`` or ``_validation-persist``, depending on the challenge). -For example, when performing the ``dns`` challenge for the domain -``example.com``, the zone entry file to succeed the challenge might look like: +To perform a ``dns`` challenge with the manual plugin, you'd invoke Certbot like this: + +:: + + certbot certonly --preferred-challenges dns -d example.com --manual + +The CA would then provide Certbot with a single-use validation token, which you (or +your auth hook script) would then use to create a TXT record at the subdomain +``_acme-challenge``, like this: :: _acme-challenge.example.com. 300 IN TXT "gfj9Xq...Rg85nM" -This validation string is only valid for this challenge, and the CA validates -it, the TXT record can be removed. +This validation token is only valid for this challenge, and once the CA +validates it, the TXT record can be removed. -On the other hand, the ``dns-persist`` challenge works a bit differently. After -initiating the challenge, the manual plugin will ask you to make a DNS TXT -record like this: +The ``dns-persist`` challenge is different, and allows you to create a single +long-lived DNS record for certificate issuance and renewals. To perform a +``dns-persist`` challenge with the manual plugin, you'd invoke Certbot like +this: :: - _validation-persist.example.com. IN TXT "authority.example; accounturi=https://ca.example/acct/123" + certbot certonly --preferred-challenges dns-persist -d example.com --manual -Once made, this TXT record can remain active indefinitely for future -certificate issuances with the CA. +Afterward, you (or your auth hook script) will be prompted to create a TXT record like this: + +:: + + __validation-persist.example.com. IN TXT "authority.example; accounturi=https://ca.example/acct/123" + +This record can persist indefinitely, and as long as it's available, any future +certificate issuances at that subdomain will automatically be approved by the +CA. As such, ``dns-persist`` allows for easy automated certificate renewals. + +However, because Certbot's manual plugin requires manual intervention unless an +auth hook script is provided, fully automated renewals with ``dns-persist`` +requires some sort of dummy auth hook, like this: + +:: + + certbot certonly --preferred-challenges dns-persist-01 -d example.com --manual --manual-auth-hook=/bin/true The ``dns-persist`` challenge type also supports the issuance of wildcard -certificates: if the manual plugin detects a wildcard domain, you will be -asked to add the appropriate ``policy=wildcard`` record for the parent -fully-qualified domain name (FQDN). +certificates. If the manual plugin detects that you're issuing a certificate for +a wildcard domain (e.g. ``*.example.com``), you (or your auth hook) will be +instructed to add ``policy=wildcard`` to the TXT record for the wildcard's +parent domain (e.g. ``example.com``). .. _manual-renewal: **Renewal with the manual plugin** -Certificates created using ``--manual`` **do not** support automatic renewal unless -combined with an `authentication hook script <#hooks>`_ via ``--manual-auth-hook`` -to automatically set up the required HTTP and/or TXT challenges. In the case of a -certificate issued via the ``dns-persist`` challenge, once the DNS TXT record is -live, no further action is needed and thus your authentication script doesn't -need to perform any additional actions. +Certificates created using ``--manual`` only support automatic renewal with the +``dns-persist`` challenge, or when combined with an `authentication hook script +<#hooks>`_ via ``--manual-auth-hook`` to automatically set up the required HTTP +and/or TXT challenges. -If you can use one of the other plugins_ which support autorenewal to create -your certificate, doing so is highly recommended. +If your certificate was issued via the ``dns-persist`` challenge, automated renewal +is possible by providing a dummy auth hook, for example: + +:: + + certbot certonly --preferred-challenges dns-persist-01 -d example.com --manual --manual-auth-hook=/bin/true To manually renew a certificate using ``--manual`` without hooks, repeat the same ``certbot --manual`` command you used to create the certificate originally. As this From 2a5903081edfc2e4b075fdbd85cb90657631f18a Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 7 May 2026 10:29:13 -0700 Subject: [PATCH 10/23] certbot-ci: fix lint issues --- .../certbot_tests/context.py | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index 1e323d4da..fadcdb2ff 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -45,24 +45,29 @@ class IntegrationTestsContext: self.manual_dns_persist_cleanup_hook = self.generate_dns_cleanup_hook('_validation-persist') def generate_dns_auth_hook(self, challenge_subdomain: str, fail_on_subdomain: bool) -> str: - """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv URL, - and optionally fails if the subdomain starts with the word "fail" to simulate a faulty script""" - script_lines = ['import os', 'import requests', 'import json'] + """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv + URL, and optionally fails if the subdomain starts with the word "fail" to simulate a faulty + script""" + lines = ['import os', 'import requests', 'import json'] if fail_on_subdomain: - script_lines.append("assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail')") - script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(" - "os.environ.get('CERTBOT_DOMAIN')), 'value':" - "os.environ.get('CERTBOT_VALIDATION')}") - script_lines.append(f"request = requests.post('{self.challtestsrv_url}/set-txt', data=json.dumps(data))") - script_lines.append("request.raise_for_status()") - script = '; '.join(script_lines) + lines.append("assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail')") + lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(" + "os.environ.get('CERTBOT_DOMAIN')), 'value':" + "os.environ.get('CERTBOT_VALIDATION')}") + lines.append(f"request = requests.post('{self.challtestsrv_url}/set-txt', " + "data=json.dumps(data))") + lines.append("request.raise_for_status()") + script = '; '.join(lines) return f'{sys.executable} -c "{script}"' def generate_dns_cleanup_hook(self, challenge_subdomain: str) -> str: - """Generates a python one-liner script which cleans up the TXT record made by `generate_dns_auth_hook`""" + """Generates a python one-liner script which cleans up the TXT record made by + `generate_dns_auth_hook`""" script_lines = ['import os', 'import requests', 'import json'] - script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(os.environ.get('CERTBOT_DOMAIN'))}}") - script_lines.append(f"request = requests.post('{self.challtestsrv_url}/clear-txt', data=json.dumps(data))") + script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'" + ".format(os.environ.get('CERTBOT_DOMAIN'))}}") + script_lines.append(f"request = requests.post('{self.challtestsrv_url}/clear-txt', " + "data=json.dumps(data))") script_lines.append("request.raise_for_status()") script = '; '.join(script_lines) return f'{sys.executable} -c "{script}"' From d0b2a2030a12b5408e602c0cf7b7826f90da16e7 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 7 May 2026 10:52:47 -0700 Subject: [PATCH 11/23] certbot: docs typo --- certbot/docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index 46dc43f53..f21086abf 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -278,7 +278,7 @@ Afterward, you (or your auth hook script) will be prompted to create a TXT recor :: - __validation-persist.example.com. IN TXT "authority.example; accounturi=https://ca.example/acct/123" + _validation-persist.example.com. IN TXT "authority.example; accounturi=https://ca.example/acct/123" This record can persist indefinitely, and as long as it's available, any future certificate issuances at that subdomain will automatically be approved by the From 02d80cbe795ad6209ca7846cbf38359029600abb Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Fri, 8 May 2026 12:13:20 -0700 Subject: [PATCH 12/23] Update certbot/docs/using.rst Co-authored-by: ohemorange --- certbot/docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index f21086abf..3748d1604 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -86,7 +86,7 @@ manual_ Y N | Obtain a certificate by manually following instructions Under the hood, plugins use one of several ACME protocol challenges_ to prove you control a domain. The challenge options are http-01_ (which uses port 80), and the two DNS-based challenges: dns-01_, or dns-persist-01_ (which both -require configuration of a DNS server on port 53, though that's often not the +require configuration of a DNS server on port 53, though that may not be the same machine as your webserver). A few plugins support more than one challenge type, in which case you can choose one with ``--preferred-challenges``. From 0e43050b2c9e727348f715e225f2a07bb0ae2d0e Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Fri, 8 May 2026 17:05:56 -0700 Subject: [PATCH 13/23] Update certbot/docs/using.rst Co-authored-by: ohemorange --- certbot/docs/using.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot/docs/using.rst b/certbot/docs/using.rst index 3748d1604..3b5dda6cc 100644 --- a/certbot/docs/using.rst +++ b/certbot/docs/using.rst @@ -242,7 +242,7 @@ specific content in the ``/.well-known/acme-challenge/`` directory directly in the top-level directory (“web root”) containing the files served by your webserver. In essence it's the same as the webroot_ plugin, but not automated. -When using the ``dns`` or ``dns-persist` challenges, ``certbot`` will ask you to +When using the ``dns`` or ``dns-persist`` challenges, ``certbot`` will ask you to place a TXT DNS record with specific contents under the domain name consisting of the hostname for which you want a certificate issued, prepended by a subdomain (either ``_acme-challenge`` or ``_validation-persist``, depending on From ba9dc2a2e265695ca5c345281903254baeb4711a Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 11 May 2026 13:05:19 -0700 Subject: [PATCH 14/23] certbot-ci: nicer script templates --- .../certbot_tests/context.py | 41 +++++++++++-------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index fadcdb2ff..304275a58 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -3,6 +3,7 @@ import os import shutil import sys import tempfile +import textwrap from typing import Iterable import pytest @@ -40,36 +41,40 @@ class IntegrationTestsContext: self.manual_dns_auth_hook = self.generate_dns_auth_hook('_acme-challenge', True) self.manual_dns_auth_hook_allow_fail = self.generate_dns_auth_hook('_acme-challenge', False) - self.manual_dns_persist_auth_hook = self.generate_dns_auth_hook('_validation-persist', True) self.manual_dns_cleanup_hook = self.generate_dns_cleanup_hook('_acme-challenge') + + self.manual_dns_persist_auth_hook = self.generate_dns_auth_hook('_validation-persist', True) self.manual_dns_persist_cleanup_hook = self.generate_dns_cleanup_hook('_validation-persist') def generate_dns_auth_hook(self, challenge_subdomain: str, fail_on_subdomain: bool) -> str: """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv URL, and optionally fails if the subdomain starts with the word "fail" to simulate a faulty script""" - lines = ['import os', 'import requests', 'import json'] - if fail_on_subdomain: - lines.append("assert not os.environ.get('CERTBOT_DOMAIN').startswith('fail')") - lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'.format(" - "os.environ.get('CERTBOT_DOMAIN')), 'value':" - "os.environ.get('CERTBOT_VALIDATION')}") - lines.append(f"request = requests.post('{self.challtestsrv_url}/set-txt', " - "data=json.dumps(data))") - lines.append("request.raise_for_status()") - script = '; '.join(lines) + script = textwrap.dedent(f"""\ + import os + import requests + import json + domain = os.environ.get('CERTBOT_DOMAIN') + {"assert not domain.startswith('fail')" if fail_on_subdomain else "# no-op"} + validation = os.environ.get('CERTBOT_VALIDATION') + data = {{'host':'{challenge_subdomain}.{{0}}.'.format(domain), 'value': validation}} + request = requests.post('{self.challtestsrv_url}/set-txt', data=json.dumps(data)) + request.raise_for_status() + """) return f'{sys.executable} -c "{script}"' def generate_dns_cleanup_hook(self, challenge_subdomain: str) -> str: """Generates a python one-liner script which cleans up the TXT record made by `generate_dns_auth_hook`""" - script_lines = ['import os', 'import requests', 'import json'] - script_lines.append(f"data = {{'host':'{challenge_subdomain}.{{0}}.'" - ".format(os.environ.get('CERTBOT_DOMAIN'))}}") - script_lines.append(f"request = requests.post('{self.challtestsrv_url}/clear-txt', " - "data=json.dumps(data))") - script_lines.append("request.raise_for_status()") - script = '; '.join(script_lines) + script = textwrap.dedent(f"""\ + import os + import requests + import json + domain = os.environ.get('CERTBOT_DOMAIN') + data = {{'host':'{challenge_subdomain}.{{0}}.'.format(domain)}} + request = requests.post('{self.challtestsrv_url}/clear-txt', data=json.dumps(data)) + request.raise_for_status() + """) return f'{sys.executable} -c "{script}"' def cleanup(self) -> None: From 002a8f30d5f86398f05ddee586fcea7fbed8f4b3 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 11 May 2026 13:05:34 -0700 Subject: [PATCH 15/23] acme: rm unnecessary bytes check --- acme/src/acme/util.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/acme/src/acme/util.py b/acme/src/acme/util.py index 27730fb56..67f4d4cd4 100644 --- a/acme/src/acme/util.py +++ b/acme/src/acme/util.py @@ -13,12 +13,10 @@ def is_wildcard_domain(domain: str) -> bool: """"Is domain a wildcard domain? :param domain: domain to check - :type domain: `bytes` or `str` + :type domain: `str` :returns: True if domain is a wildcard, otherwise, False :rtype: bool """ - if isinstance(domain, str): - return domain.startswith("*.") - return domain.startswith(b"*.") + return domain.startswith("*.") From 219a5966d6fb0c20e67685eeca43cc4e32ece7d0 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 11 May 2026 13:05:55 -0700 Subject: [PATCH 16/23] certbot-ci: add renewal test for dns-persist --- .../certbot_integration_tests/certbot_tests/test_main.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py index 916d575cd..a8d9d7002 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py @@ -221,6 +221,14 @@ def test_manual_dns_persist_auth(context: IntegrationTestsContext) -> None: assert_saved_deploy_hook(context.config_dir, certname) assert_cert_count_for_lineage(context.config_dir, certname, 1) + # test renewal with a no-op auth hook, as per our docs + context.certbot([ + 'renew', '--cert-name', certname, '--authenticator', 'manual', + '--manual-auth-hook', '/bin/true' + ]) + + assert_cert_count_for_lineage(context.config_dir, certname, 2) + def test_certonly(context: IntegrationTestsContext) -> None: """Test the certonly verb on certbot.""" From 38bc4522820f0e04155ed8ee30bdc484bba2885c Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 11 May 2026 13:06:11 -0700 Subject: [PATCH 17/23] certbot: add debug output for dns-persist --- certbot/src/certbot/_internal/auth_handler.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/certbot/src/certbot/_internal/auth_handler.py b/certbot/src/certbot/_internal/auth_handler.py index c9dc203bb..11794fc09 100644 --- a/certbot/src/certbot/_internal/auth_handler.py +++ b/certbot/src/certbot/_internal/auth_handler.py @@ -17,6 +17,7 @@ from certbot import achallenges from certbot import configuration from certbot import errors from certbot import interfaces +from certbot import util from certbot._internal import error_handler from certbot._internal.account import Account from certbot.display import util as display_util @@ -338,25 +339,30 @@ class AuthHandler: if config.verbose_count > 0: msg = [] http01_achalls = {} - dns01_achalls = {} + dns_achalls = {} for achall in achalls: if isinstance(achall.chall, challenges.HTTP01): http01_achalls[achall.chall.uri(achall.identifier.value)] = ( achall.validation(achall.account_key) + "\n" ) if isinstance(achall.chall, challenges.DNS01): - dns01_achalls[achall.validation_domain_name(achall.identifier.value)] = ( + dns_achalls[achall.validation_domain_name(achall.identifier.value)] = ( achall.validation(achall.account_key) + "\n" ) + if isinstance(achall.chall, challenges.DNSPersist01): + is_wildcard = util.is_wildcard_domain(achall.identifier.value) + dns_achalls[achall.validation_domain_name(achall.identifier.value)] = ( + achall.get_validation_rdata(is_wildcard) + "\n" + ) if http01_achalls: msg.append("The following URLs should be accessible from the " "internet and return the value mentioned:\n") for uri, key_authz in http01_achalls.items(): msg.append(f"URL: {uri}\nExpected value: {key_authz}") - if dns01_achalls: + if dns_achalls: msg.append("The following FQDNs should return a TXT resource " "record with the value mentioned:\n") - for fqdn, key_authz_hash in dns01_achalls.items(): + for fqdn, key_authz_hash in dns_achalls.items(): msg.append(f"FQDN: {fqdn}\nExpected value: {key_authz_hash}") return "\n" + "\n".join(msg) else: From c564a5d2221c964b367f7ecd030d0c7048bf85e9 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Wed, 13 May 2026 16:01:52 -0700 Subject: [PATCH 18/23] certbot: allow dns-persist-01 to run with no hooks --- certbot/src/certbot/_internal/plugins/manual.py | 12 +++++++----- .../certbot/_internal/tests/plugins/manual_test.py | 8 ++++++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/certbot/src/certbot/_internal/plugins/manual.py b/certbot/src/certbot/_internal/plugins/manual.py index 1e05c8da4..950eddc63 100644 --- a/certbot/src/certbot/_internal/plugins/manual.py +++ b/certbot/src/certbot/_internal/plugins/manual.py @@ -112,11 +112,6 @@ permitted by DNS standards.) help='Path or command to execute for the cleanup script') def prepare(self) -> None: # pylint: disable=missing-function-docstring - if self.config.noninteractive_mode and not self.conf('auth-hook'): - raise errors.PluginError( - 'An authentication script must be provided with --{0} when ' - 'using the manual plugin non-interactively.'.format( - self.option_name('auth-hook'))) self._validate_hooks() def _validate_hooks(self) -> None: @@ -178,6 +173,13 @@ permitted by DNS standards.) responses = [] last_dns_achall = 0 for i, achall in enumerate(achalls): + # only dns-persist-01 challenges should be both non-interactive and have no auth hook + if not isinstance(achall.chall, challenges.DNSPersist01) \ + and self.config.noninteractive_mode and not self.conf('auth-hook'): + raise errors.PluginError( + 'An authentication script must be provided with --{0} when ' + 'using the manual plugin non-interactively.'.format( + self.option_name('auth-hook'))) if isinstance(achall.chall, (challenges.DNS01, challenges.DNSPersist01)): last_dns_achall = i for i, achall in enumerate(achalls): diff --git a/certbot/src/certbot/_internal/tests/plugins/manual_test.py b/certbot/src/certbot/_internal/tests/plugins/manual_test.py index e113f2d9e..9ab78da14 100644 --- a/certbot/src/certbot/_internal/tests/plugins/manual_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/manual_test.py @@ -60,10 +60,14 @@ class AuthenticatorTest(test_util.TempDirTestCase): from certbot._internal.plugins.manual import Authenticator self.auth = Authenticator(self.config, name='manual') - def test_prepare_no_hook_noninteractive(self): + def test_perform_no_hook_noninteractive(self): self.config.noninteractive_mode = True with pytest.raises(errors.PluginError): - self.auth.prepare() + _ = self.auth.perform(self.achalls) + dns_persist_achalls = [achall for achall in self.achalls \ + if isinstance(achall.chall, challenges.DNSPersist01)] + assert len(dns_persist_achalls) == 3 + _ = self.auth.perform(dns_persist_achalls) def test_prepare_bad_hook(self): self.config.manual_auth_hook = os.path.abspath(os.sep) # is / on UNIX From ce2ed95c2fe36237ac64f1891ab2fac28a45f5bf Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 14 May 2026 13:03:57 -0700 Subject: [PATCH 19/23] acme: rm binary string tests --- acme/src/acme/_internal/tests/util_test.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/acme/src/acme/_internal/tests/util_test.py b/acme/src/acme/_internal/tests/util_test.py index 8c3445e76..af3274efd 100644 --- a/acme/src/acme/_internal/tests/util_test.py +++ b/acme/src/acme/_internal/tests/util_test.py @@ -25,11 +25,9 @@ class IsWildcardDomainTest(unittest.TestCase): def test_no_wildcard(self): assert not self._call(self.no_wildcard) - assert not self._call(self.no_wildcard.encode()) def test_wildcard(self): assert self._call(self.wildcard) - assert self._call(self.wildcard.encode()) if __name__ == '__main__': From 13c867d94bf105aa2c6f54c57ef4be69661accfd Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Thu, 14 May 2026 13:04:05 -0700 Subject: [PATCH 20/23] certbot: add a setup hook arg for dns-persist challenges dns-persist challenges only need their records setup once, making the regularly occurring auth-script used in http and dns challenges a poor fit. Instead, dns-persist challenges will use a separately configurable "setup" hook which isn't persisted to the renewal conf. This way, users can automate DNS TXT record creation with a script on an inital run of certbot using --manual-setup-hook, and then simply omit that argument on future runs for a non-interactive/automated renewal. --- .../src/certbot/_internal/plugins/manual.py | 60 +++++++++++++++---- certbot/src/certbot/_internal/storage.py | 6 ++ .../_internal/tests/plugins/manual_test.py | 51 +++++++++------- .../certbot/_internal/tests/storage_test.py | 9 ++- 4 files changed, 93 insertions(+), 33 deletions(-) diff --git a/certbot/src/certbot/_internal/plugins/manual.py b/certbot/src/certbot/_internal/plugins/manual.py index 950eddc63..35abc6b51 100644 --- a/certbot/src/certbot/_internal/plugins/manual.py +++ b/certbot/src/certbot/_internal/plugins/manual.py @@ -110,13 +110,15 @@ permitted by DNS standards.) help='Path or command to execute for the authentication script') add('cleanup-hook', help='Path or command to execute for the cleanup script') + add('setup-hook', + help='Path or command to execute just this once. Only used for dns-persist challenges') def prepare(self) -> None: # pylint: disable=missing-function-docstring self._validate_hooks() def _validate_hooks(self) -> None: if self.config.validate_hooks: - for name in ('auth-hook', 'cleanup-hook'): + for name in ('auth-hook', 'cleanup-hook', 'setup-hook'): hook = self.conf(name) if hook is not None: hook_prefix = self.option_name(name)[:-len('-hook')] @@ -168,22 +170,54 @@ permitted by DNS standards.) # pylint: disable=unused-argument,missing-function-docstring return [challenges.HTTP01, challenges.DNS01, challenges.DNSPersist01] + def _check_hook_achall_match(self, achalls: list[achallenges.AnnotatedChallenge]) -> None: + """Checks whether the user has provided the appropriate hook type for the given list of + challenges, and emits a helpful warning if not. Raises an exception if we're in + non-interactive mode, have at least 1 non-DNS-PERSIST-01 challenge, and no auth hook + was provided.""" + num_dns_persist_achalls = sum(1 for achall in achalls \ + if isinstance(achall.chall, challenges.DNSPersist01)) + num_other_achalls = len(achalls) - num_dns_persist_achalls + auth_hook_provided = self.conf('auth-hook') + setup_hook_provided = self.conf('setup-hook') + if num_other_achalls > 0 and self.config.noninteractive_mode and not auth_hook_provided: + raise errors.PluginError( + 'An authentication script must be provided with --{0} when using the manual ' + 'plugin non-interactively on HTTP-01 or DNS-01 challenges.'.format( + self.option_name('auth-hook'))) + if num_dns_persist_achalls == 0 and setup_hook_provided: + msg = ('A setup script was provided with --{0}, but because no DNS-PERSIST-01 ' + 'challenges are being attempted, it will be ignored.').format( + self.option_name('setup-hook')) + if not auth_hook_provided: + msg += ' Did you mean to use --{0} instead?'.format( + self.option_name('auth-hook')) + logger.warning(msg) + if num_other_achalls == 0 and auth_hook_provided: + msg = ('An authentication script was provided with --{0}, but because only ' + 'DNS-PERSIST-01 challenges are being attempted, it will be ignored.').format( + self.option_name('auth-hook')) + if not setup_hook_provided: + msg += ' Did you mean to use --{0} instead?'.format( + self.option_name('setup-hook')) + logger.warning(msg) + + def _achall_has_hook(self, achall: achallenges.AnnotatedChallenge) -> bool: + if isinstance(achall.chall, challenges.DNSPersist01): + return self.conf('setup-hook') + else: + return self.conf('auth-hook') + def perform(self, achalls: list[achallenges.AnnotatedChallenge] ) -> list[challenges.ChallengeResponse]: # pylint: disable=missing-function-docstring + self._check_hook_achall_match(achalls) responses = [] last_dns_achall = 0 for i, achall in enumerate(achalls): - # only dns-persist-01 challenges should be both non-interactive and have no auth hook - if not isinstance(achall.chall, challenges.DNSPersist01) \ - and self.config.noninteractive_mode and not self.conf('auth-hook'): - raise errors.PluginError( - 'An authentication script must be provided with --{0} when ' - 'using the manual plugin non-interactively.'.format( - self.option_name('auth-hook'))) if isinstance(achall.chall, (challenges.DNS01, challenges.DNSPersist01)): last_dns_achall = i for i, achall in enumerate(achalls): - if self.conf('auth-hook'): + if self._achall_has_hook(achall): self._perform_achall_with_script(achall, achalls) else: self._perform_achall_manually(achall, i == last_dns_achall) @@ -222,7 +256,11 @@ permitted by DNS standards.) else: os.environ.pop('CERTBOT_TOKEN', None) os.environ.update(env) - _, out = self._execute_hook('auth-hook', identifier_value) + if isinstance(achall.chall, challenges.DNSPersist01): + hook_name = 'setup-hook' + else: + hook_name = 'auth-hook' + _, out = self._execute_hook(hook_name, identifier_value) env['CERTBOT_AUTH_OUTPUT'] = out.strip() self.env[achall] = env @@ -265,6 +303,8 @@ permitted by DNS standards.) def cleanup(self, achalls: Iterable[achallenges.AnnotatedChallenge]) -> None: # pylint: disable=missing-function-docstring if self.conf('cleanup-hook'): for achall in achalls: + if isinstance(achall.chall, challenges.DNSPersist01): + continue env = self.env.pop(achall) if 'CERTBOT_TOKEN' not in env: os.environ.pop('CERTBOT_TOKEN', None) diff --git a/certbot/src/certbot/_internal/storage.py b/certbot/src/certbot/_internal/storage.py index a7f19d0dc..02465ff2d 100644 --- a/certbot/src/certbot/_internal/storage.py +++ b/certbot/src/certbot/_internal/storage.py @@ -280,6 +280,12 @@ def _relevant(namespaces: Iterable[str], option: str) -> bool: """ from certbot._internal import renewal + # an awkward special case: future renewals shouldn't depend on whether the user set + # --manual-setup-hook now, since it's meant to represent a (potentially) one-off script. + # if a user wants it to run again in the future, they must set it explicitly via CLI + if option == "manual_setup_hook": + return False + return (option in renewal.CONFIG_ITEMS or any(option.startswith(namespace) for namespace in namespaces)) diff --git a/certbot/src/certbot/_internal/tests/plugins/manual_test.py b/certbot/src/certbot/_internal/tests/plugins/manual_test.py index 9ab78da14..b7b0f3704 100644 --- a/certbot/src/certbot/_internal/tests/plugins/manual_test.py +++ b/certbot/src/certbot/_internal/tests/plugins/manual_test.py @@ -49,8 +49,8 @@ class AuthenticatorTest(test_util.TempDirTestCase): # initialization. self.config = mock.MagicMock( http01_port=0, manual_auth_hook=None, manual_cleanup_hook=None, - noninteractive_mode=False, validate_hooks=False, - config_dir=os.path.join(self.tempdir, "config_dir"), + manual_setup_hook=None, noninteractive_mode=False, + validate_hooks=False, config_dir=os.path.join(self.tempdir, "config_dir"), work_dir=os.path.join(self.tempdir, "work_dir"), backup_dir=os.path.join(self.tempdir, "backup_dir"), temp_checkpoint_dir=os.path.join( @@ -83,49 +83,55 @@ class AuthenticatorTest(test_util.TempDirTestCase): [challenges.HTTP01, challenges.DNS01, challenges.DNSPersist01] def test_script_perform(self): - self.config.manual_auth_hook = ( - '{0} -c "' - 'from certbot.compat import os;' - 'print(os.environ.get(\'CERTBOT_DOMAIN\'));' - 'print(os.environ.get(\'CERTBOT_IDENTIFIER\'));' - 'print(os.environ.get(\'CERTBOT_TOKEN\', \'notoken\'));' - 'print(os.environ.get(\'CERTBOT_VALIDATION\', \'novalidation\'));' - 'print(os.environ.get(\'CERTBOT_ALL_DOMAINS\'));' - 'print(os.environ.get(\'CERTBOT_ALL_IDENTIFIERS\'));' - 'print(os.environ.get(\'CERTBOT_REMAINING_CHALLENGES\'));"' - .format(sys.executable)) - dns_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + script_template = textwrap.dedent(""" + {0} -c "from certbot.compat import os + print(os.environ.get(\'CERTBOT_DOMAIN\')) + print(os.environ.get(\'CERTBOT_IDENTIFIER\')) + print(os.environ.get(\'CERTBOT_TOKEN\', \'notoken\')) + print(os.environ.get(\'CERTBOT_VALIDATION\', \'novalidation\')) + print(os.environ.get(\'CERTBOT_ALL_DOMAINS\')) + print(os.environ.get(\'CERTBOT_ALL_IDENTIFIERS\')) + print(os.environ.get(\'CERTBOT_REMAINING_CHALLENGES\')) + print('{1}')" + """) + self.config.manual_auth_hook = script_template.format(sys.executable, "auth_hook") + self.config.manual_setup_hook = script_template.format(sys.executable, "setup_hook") + dns_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}\n{7}'.format( self.dns_achall.identifier.value, self.dns_achall.identifier.value, 'notoken', self.dns_achall.validation(self.dns_achall.account_key), ','.join(achall.identifier.value for achall in self.achalls), ','.join(achall.identifier.value for achall in self.achalls), - len(self.achalls) - self.achalls.index(self.dns_achall) - 1) - http_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + len(self.achalls) - self.achalls.index(self.dns_achall) - 1, + "auth_hook") + http_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}\n{7}'.format( self.http_achall.identifier.value, self.http_achall.identifier.value, self.http_achall.chall.encode('token'), self.http_achall.validation(self.http_achall.account_key), ','.join(achall.identifier.value for achall in self.achalls), ','.join(achall.identifier.value for achall in self.achalls), - len(self.achalls) - self.achalls.index(self.http_achall) - 1) - dns_persist_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + len(self.achalls) - self.achalls.index(self.http_achall) - 1, + "auth_hook") + dns_persist_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}\n{7}'.format( self.dns_persist_achall.identifier.value, self.dns_persist_achall.identifier.value, 'notoken', self.dns_persist_achall.get_validation_rdata(False), ','.join(achall.identifier.value for achall in self.achalls), ','.join(achall.identifier.value for achall in self.achalls), - len(self.achalls) - self.achalls.index(self.dns_persist_achall) - 1) - dns_persist_wildcard_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}'.format( + len(self.achalls) - self.achalls.index(self.dns_persist_achall) - 1, + "setup_hook") + dns_persist_wildcard_expected = '{0}\n{1}\n{2}\n{3}\n{4}\n{5}\n{6}\n{7}'.format( self.dns_persist_achall_wildcard.identifier.value, self.dns_persist_achall_wildcard.identifier.value, 'notoken', self.dns_persist_achall_wildcard.get_validation_rdata(True), ','.join(achall.identifier.value for achall in self.achalls), ','.join(achall.identifier.value for achall in self.achalls), - len(self.achalls) - self.achalls.index(self.dns_persist_achall_wildcard) - 1) + len(self.achalls) - self.achalls.index(self.dns_persist_achall_wildcard) - 1, + "setup_hook") assert self.auth.perform(self.achalls) == self.responses assert self.auth.env[self.dns_achall]['CERTBOT_AUTH_OUTPUT'] == \ @@ -164,6 +170,8 @@ class AuthenticatorTest(test_util.TempDirTestCase): self.auth.perform(self.achalls) for achall in self.achalls: + if isinstance(achall.chall, challenges.DNSPersist01): + continue self.auth.cleanup([achall]) assert os.environ['CERTBOT_AUTH_OUTPUT'] == 'foo' assert os.environ['CERTBOT_DOMAIN'] == achall.identifier.value @@ -177,7 +185,6 @@ class AuthenticatorTest(test_util.TempDirTestCase): else: assert 'CERTBOT_TOKEN' not in os.environ - def test_auth_hint_hook(self): self.config.manual_auth_hook = '/bin/true' for dns_achall in [acme_util.DNS01_A, acme_util.DNS_PERSIST_01_A]: diff --git a/certbot/src/certbot/_internal/tests/storage_test.py b/certbot/src/certbot/_internal/tests/storage_test.py index da3bd25ce..409e90336 100644 --- a/certbot/src/certbot/_internal/tests/storage_test.py +++ b/certbot/src/certbot/_internal/tests/storage_test.py @@ -13,7 +13,7 @@ import pytest import certbot from certbot import errors -from certbot._internal.storage import ALL_FOUR +from certbot._internal.storage import ALL_FOUR, relevant_values from certbot._internal import san from certbot.compat import filesystem from certbot.compat import os @@ -112,6 +112,13 @@ class RelevantValuesTest(unittest.TestCase): expected_relevant_values = self.values.copy() assert self._call(self.values) == expected_relevant_values + def test_manual_hooks(self): + self.values["manual_setup_hook"] = '"# setup' + self.values["manual_auth_hook"] = '"# auth' + expected_relevant_values = self.values.copy() + del expected_relevant_values["manual_setup_hook"] + assert self._call(self.values) == expected_relevant_values + class BaseRenewableCertTest(test_util.ConfigTestCase): """Base class for setting up Renewable Cert tests. From 55218f2c8fd4d7cad0310ea176676725fc156831 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 18 May 2026 14:33:57 -0700 Subject: [PATCH 21/23] certbot-ci: fix dns-persist integration tests --- .../src/certbot_integration_tests/certbot_tests/context.py | 3 +-- .../src/certbot_integration_tests/certbot_tests/test_main.py | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index 304275a58..228d1c02b 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -43,8 +43,7 @@ class IntegrationTestsContext: self.manual_dns_auth_hook_allow_fail = self.generate_dns_auth_hook('_acme-challenge', False) self.manual_dns_cleanup_hook = self.generate_dns_cleanup_hook('_acme-challenge') - self.manual_dns_persist_auth_hook = self.generate_dns_auth_hook('_validation-persist', True) - self.manual_dns_persist_cleanup_hook = self.generate_dns_cleanup_hook('_validation-persist') + self.manual_dns_persist_setup_hook = self.generate_dns_auth_hook('_validation-persist', True) def generate_dns_auth_hook(self, challenge_subdomain: str, fail_on_subdomain: bool) -> str: """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py index a8d9d7002..d746ee8b9 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/test_main.py @@ -212,8 +212,7 @@ def test_manual_dns_persist_auth(context: IntegrationTestsContext) -> None: context.certbot([ '-a', 'manual', '-d', certname, '--preferred-challenges', 'dns-persist', 'run', '--cert-name', certname, - '--manual-auth-hook', context.manual_dns_persist_auth_hook, - '--manual-cleanup-hook', context.manual_dns_persist_cleanup_hook, + '--manual-setup-hook', context.manual_dns_persist_setup_hook, '--deploy-hook', misc.echo('deploy', context.hook_probe), ]) From 1fa1a99c9ddd271ebcf5062cb8339782aedae80e Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 18 May 2026 14:51:03 -0700 Subject: [PATCH 22/23] certbot-ci: lint fixups --- .../src/certbot_integration_tests/certbot_tests/context.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py index 228d1c02b..76262fcd4 100644 --- a/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py +++ b/certbot-ci/src/certbot_integration_tests/certbot_tests/context.py @@ -43,7 +43,8 @@ class IntegrationTestsContext: self.manual_dns_auth_hook_allow_fail = self.generate_dns_auth_hook('_acme-challenge', False) self.manual_dns_cleanup_hook = self.generate_dns_cleanup_hook('_acme-challenge') - self.manual_dns_persist_setup_hook = self.generate_dns_auth_hook('_validation-persist', True) + self.manual_dns_persist_setup_hook = self.generate_dns_auth_hook( + '_validation-persist', True) def generate_dns_auth_hook(self, challenge_subdomain: str, fail_on_subdomain: bool) -> str: """Generates a python one-liner script which sets a DNS challenge TXT record challtestsrv From d604fd48c9d3db0179670117dce85f2b23fdc8e8 Mon Sep 17 00:00:00 2001 From: Will Greenberg Date: Mon, 18 May 2026 16:01:57 -0700 Subject: [PATCH 23/23] certbot: lint fixup --- certbot/src/certbot/_internal/tests/storage_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/certbot/src/certbot/_internal/tests/storage_test.py b/certbot/src/certbot/_internal/tests/storage_test.py index 409e90336..2274491ae 100644 --- a/certbot/src/certbot/_internal/tests/storage_test.py +++ b/certbot/src/certbot/_internal/tests/storage_test.py @@ -13,7 +13,7 @@ import pytest import certbot from certbot import errors -from certbot._internal.storage import ALL_FOUR, relevant_values +from certbot._internal.storage import ALL_FOUR from certbot._internal import san from certbot.compat import filesystem from certbot.compat import os