From bda52ec312907283282a2377cd9576e57aaafafe Mon Sep 17 00:00:00 2001 From: Jean Rouge Date: Fri, 24 Oct 2025 12:15:00 -0700 Subject: [PATCH] Adding a --route53-hosted-zone-id flag Because the built-in lookup of zones sometimes fails, see for example https://github.com/certbot/certbot/issues/9966 https://github.com/certbot/certbot/issues/9831 This allows users to tell certbot which route53 to use Also added tests Signed-off-by: Jean Rouge --- .../_internal/dns_route53.py | 9 ++-- .../_internal/tests/dns_route53_test.py | 42 ++++++++++++++++++- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py b/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py index 702d30a4e..1f7704aee 100644 --- a/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py +++ b/certbot-dns-route53/src/certbot_dns_route53/_internal/dns_route53.py @@ -49,8 +49,9 @@ class Authenticator(common.Plugin, interfaces.Authenticator): @classmethod def add_parser_arguments(cls, add: Callable[..., None]) -> None: - # This authenticator currently adds no extra arguments. - pass + add('hosted-zone-id', + type=str, + help='Route 53 zone ID to use to create the verification record') def auth_hint(self, failed_achalls: list[achallenges.AnnotatedChallenge]) -> str: return ( @@ -128,7 +129,9 @@ class Authenticator(common.Plugin, interfaces.Authenticator): return zones[0][1] def _change_txt_record(self, action: str, validation_domain_name: str, validation: str) -> str: - zone_id = self._find_zone_id_for_domain(validation_domain_name) + zone_id = self.conf('hosted-zone-id') + if not zone_id: + zone_id = self._find_zone_id_for_domain(validation_domain_name) rrecords = self._resource_records[validation_domain_name] challenge = {"Value": '"{0}"'.format(validation)} diff --git a/certbot-dns-route53/src/certbot_dns_route53/_internal/tests/dns_route53_test.py b/certbot-dns-route53/src/certbot_dns_route53/_internal/tests/dns_route53_test.py index e6df0c93e..d2bd1a3d4 100644 --- a/certbot-dns-route53/src/certbot_dns_route53/_internal/tests/dns_route53_test.py +++ b/certbot-dns-route53/src/certbot_dns_route53/_internal/tests/dns_route53_test.py @@ -2,6 +2,8 @@ import sys import unittest +from dataclasses import dataclass +from typing import Optional from unittest import mock from botocore.exceptions import ClientError @@ -20,6 +22,11 @@ from certbot.tests import util as test_util KEY = jose.jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) +@dataclass +class Route53TestConfig: + route53_hosted_zone_id: Optional[str] = None + + class AuthenticatorTest(unittest.TestCase): # pylint: disable=protected-access @@ -31,7 +38,7 @@ class AuthenticatorTest(unittest.TestCase): super().setUp() - self.config = mock.MagicMock() + self.config = Route53TestConfig() # Set up dummy credentials for testing os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" @@ -141,7 +148,7 @@ class ClientTest(unittest.TestCase): def setUp(self): from certbot_dns_route53._internal.dns_route53 import Authenticator - self.config = mock.MagicMock() + self.config = Route53TestConfig() # Set up dummy credentials for testing os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" @@ -267,6 +274,37 @@ class ClientTest(unittest.TestCase): assert self.client.r53.get_change.called + def test_change_txt_record_with_explicit_zone_id(self): + self.client.config.route53_hosted_zone_id = "EXPLICIT-ZONE-ID" + + # _find_zone_id_for_domain should NOT be called if the config is present + self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore[method-assign, unused-ignore] + + self.client.r53.change_resource_record_sets = mock.MagicMock( + return_value={"ChangeInfo": {"Id": 1}} + ) + + self.client._change_txt_record("UPSERT", DOMAIN, "val123") + + # we didn't try to auto-detect the zone + assert not self.client._find_zone_id_for_domain.called + + # and we used the right zone ID + call_kwargs = self.client.r53.change_resource_record_sets.call_args.kwargs + assert call_kwargs["HostedZoneId"] == "EXPLICIT-ZONE-ID" + + def test_change_txt_record_without_zone_id_falls_back_to_lookup(self): + self.client._find_zone_id_for_domain = mock.MagicMock(return_value="LOOKED-UP") # type: ignore[method-assign, unused-ignore] + self.client.r53.change_resource_record_sets = mock.MagicMock( + return_value={"ChangeInfo": {"Id": 1}} + ) + + self.client._change_txt_record("UPSERT", DOMAIN, "val456") + + self.client._find_zone_id_for_domain.assert_called_once_with(DOMAIN) + call_kwargs = self.client.r53.change_resource_record_sets.call_args.kwargs + assert call_kwargs["HostedZoneId"] == "LOOKED-UP" + if __name__ == "__main__": sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover