Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-certbot-dns-route53 for openSUSE:Factory checked in at 2025-06-16 12:26:31 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-certbot-dns-route53 (Old) and /work/SRC/openSUSE:Factory/.python-certbot-dns-route53.new.19631 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-certbot-dns-route53" Mon Jun 16 12:26:31 2025 rev:46 rq:1286017 version:4.1.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-certbot-dns-route53/python-certbot-dns-route53.changes 2025-04-22 17:29:58.171331834 +0200 +++ /work/SRC/openSUSE:Factory/.python-certbot-dns-route53.new.19631/python-certbot-dns-route53.changes 2025-06-16 12:26:38.100659717 +0200 @@ -1,0 +2,7 @@ +Fri Jun 13 14:52:31 UTC 2025 - Markéta Machová <mmach...@suse.com> + +- update to version 4.1.1 + * Switched to src-layout from flat-layout to accommodate PEP 517 pip + editable installs + +------------------------------------------------------------------- Old: ---- certbot_dns_route53-4.0.0.tar.gz New: ---- certbot_dns_route53-4.1.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-certbot-dns-route53.spec ++++++ --- /var/tmp/diff_new_pack.yh9D1L/_old 2025-06-16 12:26:38.696684536 +0200 +++ /var/tmp/diff_new_pack.yh9D1L/_new 2025-06-16 12:26:38.696684536 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-certbot-dns-route53 -Version: 4.0.0 +Version: 4.1.1 Release: 0 Summary: Route53 DNS Authenticator plugin for Certbot License: Apache-2.0 ++++++ certbot_dns_route53-4.0.0.tar.gz -> certbot_dns_route53-4.1.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/MANIFEST.in new/certbot_dns_route53-4.1.1/MANIFEST.in --- old/certbot_dns_route53-4.0.0/MANIFEST.in 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/MANIFEST.in 2025-06-12 20:08:34.000000000 +0200 @@ -1,6 +1,6 @@ include LICENSE.txt include README.rst recursive-include docs * -include certbot_dns_route53/py.typed +include src/certbot_dns_route53/py.typed global-exclude __pycache__ global-exclude *.py[cod] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/PKG-INFO new/certbot_dns_route53-4.1.1/PKG-INFO --- old/certbot_dns_route53-4.0.0/PKG-INFO 2025-04-08 00:03:41.034472200 +0200 +++ new/certbot_dns_route53-4.1.1/PKG-INFO 2025-06-12 20:08:43.940638000 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: certbot-dns-route53 -Version: 4.0.0 +Version: 4.1.1 Summary: Route53 DNS Authenticator plugin for Certbot Home-page: https://github.com/certbot/certbot Author: Certbot Project @@ -25,11 +25,11 @@ Classifier: Topic :: System :: Networking Classifier: Topic :: System :: Systems Administration Classifier: Topic :: Utilities -Requires-Python: >=3.9 +Requires-Python: >=3.9.2 License-File: LICENSE.txt Requires-Dist: boto3>=1.15.15 -Requires-Dist: acme>=4.0.0 -Requires-Dist: certbot>=4.0.0 +Requires-Dist: acme>=4.1.1 +Requires-Dist: certbot>=4.1.1 Provides-Extra: docs Requires-Dist: Sphinx>=1.0; extra == "docs" Requires-Dist: sphinx_rtd_theme; extra == "docs" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53/__init__.py new/certbot_dns_route53-4.1.1/certbot_dns_route53/__init__.py --- old/certbot_dns_route53-4.0.0/certbot_dns_route53/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,105 +0,0 @@ -""" -The `~certbot_dns_route53.dns_route53` plugin automates the process of -completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and -subsequently removing, TXT records using the Amazon Web Services Route 53 API. - -.. note:: - The plugin is not installed by default. It can be installed by heading to - `certbot.eff.org <https://certbot.eff.org/instructions#wildcard>`_, choosing your system and - selecting the Wildcard tab. - -Credentials ------------ -Use of this plugin requires a configuration file containing Amazon Web Sevices -API credentials for an account with the following permissions: - -* ``route53:ListHostedZones`` -* ``route53:GetChange`` -* ``route53:ChangeResourceRecordSets`` - -These permissions can be captured in an AWS policy like the one below. Amazon -provides `information about managing access <https://docs.aws.amazon.com/Route53 -/latest/DeveloperGuide/access-control-overview.html>`_ and `information about -the required permissions <https://docs.aws.amazon.com/Route53/latest -/DeveloperGuide/r53-api-permissions-ref.html>`_ - -.. code-block:: json - :name: sample-aws-policy.json - :caption: Example AWS policy file: - - { - "Version": "2012-10-17", - "Id": "certbot-dns-route53 sample policy", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "route53:ListHostedZones", - "route53:GetChange" - ], - "Resource": [ - "*" - ] - }, - { - "Effect" : "Allow", - "Action" : [ - "route53:ChangeResourceRecordSets" - ], - "Resource" : [ - "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID" - ] - } - ] - } - -The `access keys <https://docs.aws.amazon.com/general/latest/gr -/aws-sec-cred-types.html#access-keys-and-secret-access-keys>`_ for an account -with these permissions must be supplied in one of the following ways, which are -discussed in more detail in the Boto3 library's documentation about `configuring -credentials <https://boto3.readthedocs.io/en/latest/guide/configuration.html -#best-practices-for-configuring-credentials>`_. - -* Using the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment - variables. -* Using a credentials configuration file at the default location, - ``~/.aws/credentials``. If you're running on sudo, the credentials - will be picked up from the root home. -* Using a credentials configuration file at a path supplied using the - ``AWS_CONFIG_FILE`` environment variable. - -.. code-block:: ini - :name: config.ini - :caption: Example credentials config file: - - [default] - aws_access_key_id=AKIAIOSFODNN7EXAMPLE - aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY - -.. caution:: - You should protect these API credentials as you would a password. Users who - can read this file can use these credentials to issue some types of API calls - on your behalf, limited by the permissions assigned to the account. Users who - can cause Certbot to run using these credentials can complete a ``dns-01`` - challenge to acquire new certificates or revoke existing certificates for - domains these credentials are authorized to manage. - - -Examples --------- -.. code-block:: bash - :caption: To acquire a certificate for ``example.com`` - - certbot certonly \\ - --dns-route53 \\ - -d example.com - -.. code-block:: bash - :caption: To acquire a single certificate for both ``example.com`` and - ``www.example.com`` - - certbot certonly \\ - --dns-route53 \\ - -d example.com \\ - -d www.example.com -""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/__init__.py new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/__init__.py --- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -"""Internal implementation of `~certbot_dns_route53.dns_route53` plugin.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/dns_route53.py new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/dns_route53.py --- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/dns_route53.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/dns_route53.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,191 +0,0 @@ -"""Certbot Route53 authenticator plugin.""" -import collections -import logging -import time -from typing import Any -from typing import Callable -from typing import DefaultDict -from typing import Dict -from typing import Iterable -from typing import List -from typing import Type -from typing import cast - -import boto3 -from botocore.exceptions import ClientError -from botocore.exceptions import NoCredentialsError - -from acme import challenges -from certbot import achallenges -from certbot import errors -from certbot import interfaces -from certbot.achallenges import AnnotatedChallenge -from certbot.plugins import common - -logger = logging.getLogger(__name__) - -INSTRUCTIONS = ( - "To use certbot-dns-route53, configure credentials as described at " - "https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials " # pylint: disable=line-too-long - "and add the necessary permissions for Route53 access.") - - -class Authenticator(common.Plugin, interfaces.Authenticator): - """Route53 Authenticator - - This authenticator solves a DNS01 challenge by uploading the answer to AWS - Route53. - """ - - description = ("Obtain certificates using a DNS TXT record (if you are using AWS Route53 for " - "DNS).") - ttl = 10 - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.r53 = boto3.client("route53") - self._attempt_cleanup = False - self._resource_records: DefaultDict[str, List[Dict[str, str]]] = \ - collections.defaultdict(list) - - def more_info(self) -> str: - return "Solve a DNS01 challenge using AWS Route53" - - @classmethod - def add_parser_arguments(cls, add: Callable[..., None]) -> None: - # This authenticator currently adds no extra arguments. - pass - - def auth_hint(self, failed_achalls: List[achallenges.AnnotatedChallenge]) -> str: - return ( - 'The Certificate Authority failed to verify the DNS TXT records created by ' - '--dns-route53. Ensure the above domains have their DNS hosted by AWS Route53.' - ) - - def prepare(self) -> None: - pass - - def get_chall_pref(self, unused_domain: str) -> Iterable[Type[challenges.Challenge]]: - return [challenges.DNS01] - - def perform(self, achalls: List[AnnotatedChallenge]) -> List[challenges.ChallengeResponse]: - self._attempt_cleanup = True - - try: - change_ids = [ - self._change_txt_record("UPSERT", - achall.validation_domain_name(achall.domain), - achall.validation(achall.account_key)) - for achall in achalls - ] - - for change_id in change_ids: - self._wait_for_change(change_id) - except (NoCredentialsError, ClientError) as e: - logger.debug('Encountered error during perform: %s', e, exc_info=True) - raise errors.PluginError("\n".join([str(e), INSTRUCTIONS])) - return [achall.response(achall.account_key) for achall in achalls] - - def cleanup(self, achalls: List[achallenges.AnnotatedChallenge]) -> None: - if self._attempt_cleanup: - for achall in achalls: - domain = achall.domain - validation_domain_name = achall.validation_domain_name(domain) - validation = achall.validation(achall.account_key) - - self._cleanup(validation_domain_name, validation) - - def _cleanup(self, validation_name: str, validation: str) -> None: - try: - self._change_txt_record("DELETE", validation_name, validation) - except (NoCredentialsError, ClientError) as e: - logger.debug('Encountered error during cleanup: %s', e, exc_info=True) - - def _find_zone_id_for_domain(self, domain: str) -> str: - """Find the zone id responsible a given FQDN. - - That is, the id for the zone whose name is the longest parent of the - domain. - """ - paginator = self.r53.get_paginator("list_hosted_zones") - zones: list[tuple[str, str]] = [] - target_labels = domain.rstrip(".").split(".") - for page in paginator.paginate(): - for zone in page["HostedZones"]: - if zone["Config"]["PrivateZone"]: - continue - - candidate_labels = zone["Name"].rstrip(".").split(".") - if candidate_labels == target_labels[-len(candidate_labels):]: - zones.append((zone["Name"], zone["Id"])) - - if not zones: - raise errors.PluginError( - "Unable to find a Route53 hosted zone for {0}".format(domain) - ) - - # Order the zones that are suffixes for our desired to domain by - # length, this puts them in an order like: - # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"] - # And then we choose the first one, which will be the most specific. - zones.sort(key=lambda z: len(z[0]), reverse=True) - 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) - - rrecords = self._resource_records[validation_domain_name] - challenge = {"Value": '"{0}"'.format(validation)} - if action == "DELETE": - # Remove the record being deleted from the list of tracked records - rrecords.remove(challenge) - if rrecords: - # Need to update instead, as we're not deleting the rrset - action = "UPSERT" - else: - # Create a new list containing the record to use with DELETE - rrecords = [challenge] - else: - rrecords.append(challenge) - - response = self.r53.change_resource_record_sets( - HostedZoneId=zone_id, - ChangeBatch={ - "Comment": "certbot-dns-route53 certificate validation " + action, - "Changes": [ - { - "Action": action, - "ResourceRecordSet": { - "Name": validation_domain_name, - "Type": "TXT", - "TTL": self.ttl, - "ResourceRecords": rrecords, - } - } - ] - } - ) - return cast(str, response["ChangeInfo"]["Id"]) - - def _wait_for_change(self, change_id: str) -> None: - """Wait for a change to be propagated to all Route53 DNS servers. - https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html - """ - for unused_n in range(0, 120): - response = self.r53.get_change(Id=change_id) - if response["ChangeInfo"]["Status"] == "INSYNC": - return - time.sleep(5) - raise errors.PluginError( - "Timed out waiting for Route53 change. Current status: %s" % - response["ChangeInfo"]["Status"]) - - -# Our route53 plugin was initially a 3rd party plugin named `certbot-route53:auth` as described at -# https://github.com/certbot/certbot/issues/4688. This shim exists to allow installations using the -# old plugin name of `certbot-route53:auth` to continue to work without cluttering things like -# Certbot's help output with two route53 plugins. -class HiddenAuthenticator(Authenticator): - """A hidden shim around certbot-dns-route53 for backwards compatibility.""" - - hidden = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/__init__.py new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/__init__.py --- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -"""certbot-dns-route53 tests""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/dns_route53_test.py new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/dns_route53_test.py --- old/certbot_dns_route53-4.0.0/certbot_dns_route53/_internal/tests/dns_route53_test.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53/_internal/tests/dns_route53_test.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,273 +0,0 @@ -"""Tests for certbot_dns_route53._internal.dns_route53.Authenticator""" - -import sys -import unittest -from unittest import mock - -from botocore.exceptions import ClientError -from botocore.exceptions import NoCredentialsError -import josepy as jose -import pytest - -from acme import challenges -from certbot import achallenges -from certbot import errors -from certbot.compat import os -from certbot.plugins.dns_test_common import DOMAIN -from certbot.tests import acme_util -from certbot.tests import util as test_util - -DOMAIN = 'example.com' -KEY = jose.jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) - - -class AuthenticatorTest(unittest.TestCase): - # pylint: disable=protected-access - - achall = achallenges.KeyAuthorizationAnnotatedChallenge( - challb=acme_util.DNS01, domain=DOMAIN, account_key=KEY) - - def setUp(self): - from certbot_dns_route53._internal.dns_route53 import Authenticator - - super().setUp() - - self.config = mock.MagicMock() - - # Set up dummy credentials for testing - os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" - os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key" - - self.auth = Authenticator(self.config, "route53") - - def tearDown(self): - # Remove the dummy credentials from env vars - del os.environ["AWS_ACCESS_KEY_ID"] - del os.environ["AWS_SECRET_ACCESS_KEY"] - - def test_more_info(self) -> None: - self.assertTrue(isinstance(self.auth.more_info(), str)) - - def test_get_chall_pref(self) -> None: - self.assertEqual(self.auth.get_chall_pref("example.org"), [challenges.DNS01]) - - def test_perform(self): - self.auth._change_txt_record = mock.MagicMock() # type: ignore[method-assign, unused-ignore] - self.auth._wait_for_change = mock.MagicMock() # type: ignore [method-assign, unused-ignore] - - self.auth.perform([self.achall]) - - self.auth._change_txt_record.assert_called_once_with("UPSERT", - '_acme-challenge.' + DOMAIN, - mock.ANY) - assert self.auth._wait_for_change.call_count == 1 - - def test_perform_no_credentials_error(self): - self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] - side_effect=NoCredentialsError) - - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - def test_perform_client_error(self): - self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] - side_effect=ClientError({"Error": {"Code": "foo"}}, "bar")) - - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - def test_cleanup(self): - self.auth._attempt_cleanup = True - - self.auth._change_txt_record = mock.MagicMock() # type: ignore[method-assign, unused-ignore] - - self.auth.cleanup([self.achall]) - - self.auth._change_txt_record.assert_called_once_with("DELETE", - '_acme-challenge.'+DOMAIN, - mock.ANY) - - def test_cleanup_no_credentials_error(self): - self.auth._attempt_cleanup = True - - self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] - side_effect=NoCredentialsError) - - self.auth.cleanup([self.achall]) - - def test_cleanup_client_error(self): - self.auth._attempt_cleanup = True - - self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] - side_effect=ClientError({"Error": {"Code": "foo"}}, "bar")) - - self.auth.cleanup([self.achall]) - - -class ClientTest(unittest.TestCase): - # pylint: disable=protected-access - - PRIVATE_ZONE = { - "Id": "BAD-PRIVATE", - "Name": "example.com", - "Config": { - "PrivateZone": True - } - } - - EXAMPLE_NET_ZONE = { - "Id": "BAD-WRONG-TLD", - "Name": "example.net", - "Config": { - "PrivateZone": False - } - } - - EXAMPLE_COM_ZONE = { - "Id": "EXAMPLE", - "Name": "example.com", - "Config": { - "PrivateZone": False - } - } - - FOO_EXAMPLE_COM_ZONE = { - "Id": "FOO", - "Name": "foo.example.com", - "Config": { - "PrivateZone": False - } - } - - def setUp(self): - from certbot_dns_route53._internal.dns_route53 import Authenticator - - self.config = mock.MagicMock() - - # Set up dummy credentials for testing - os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" - os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key" - - self.client = Authenticator(self.config, "route53") - - def tearDown(self): - # Remove the dummy credentials from env vars - del os.environ["AWS_ACCESS_KEY_ID"] - del os.environ["AWS_SECRET_ACCESS_KEY"] - - def test_find_zone_id_for_domain(self): - self.client.r53.get_paginator = mock.MagicMock() - self.client.r53.get_paginator().paginate.return_value = [ - { - "HostedZones": [ - self.EXAMPLE_NET_ZONE, - self.EXAMPLE_COM_ZONE, - ] - } - ] - - result = self.client._find_zone_id_for_domain("foo.example.com") - assert result == "EXAMPLE" - - def test_find_zone_id_for_domain_pagination(self): - self.client.r53.get_paginator = mock.MagicMock() - self.client.r53.get_paginator().paginate.return_value = [ - { - "HostedZones": [ - self.PRIVATE_ZONE, - self.EXAMPLE_COM_ZONE, - ] - }, - { - "HostedZones": [ - self.PRIVATE_ZONE, - self.FOO_EXAMPLE_COM_ZONE, - ] - } - ] - - result = self.client._find_zone_id_for_domain("foo.example.com") - assert result == "FOO" - - def test_find_zone_id_for_domain_no_results(self): - self.client.r53.get_paginator = mock.MagicMock() - self.client.r53.get_paginator().paginate.return_value = [] - - with pytest.raises(errors.PluginError): - self.client._find_zone_id_for_domain("foo.example.com") - - def test_find_zone_id_for_domain_no_correct_results(self): - self.client.r53.get_paginator = mock.MagicMock() - self.client.r53.get_paginator().paginate.return_value = [ - { - "HostedZones": [ - self.PRIVATE_ZONE, - self.EXAMPLE_NET_ZONE, - ] - }, - ] - - with pytest.raises(errors.PluginError): - self.client._find_zone_id_for_domain("foo.example.com") - - def test_change_txt_record(self): - 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("FOO", DOMAIN, "foo") - - call_count = self.client.r53.change_resource_record_sets.call_count - assert call_count == 1 - - def test_change_txt_record_delete(self): - 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}}) - - validation = "some-value" - validation_record = {"Value": '"{0}"'.format(validation)} - self.client._resource_records[DOMAIN] = [validation_record] - - self.client._change_txt_record("DELETE", DOMAIN, validation) - - call_count = self.client.r53.change_resource_record_sets.call_count - assert call_count == 1 - call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1] - call_args_batch = call_args["ChangeBatch"]["Changes"][0] - assert call_args_batch["Action"] == "DELETE" - assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \ - [validation_record] - - def test_change_txt_record_multirecord(self): - self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore [method-assign, unused-ignore] - self.client._resource_records[DOMAIN] = [ - {"Value": "\"pre-existing-value\""}, - {"Value": "\"pre-existing-value-two\""}, - ] - self.client.r53.change_resource_record_sets = mock.MagicMock( - return_value={"ChangeInfo": {"Id": 1}}) - - self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value") - - call_count = self.client.r53.change_resource_record_sets.call_count - call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1] - call_args_batch = call_args["ChangeBatch"]["Changes"][0] - assert call_args_batch["Action"] == "UPSERT" - assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \ - [{"Value": "\"pre-existing-value-two\""}] - - assert call_count == 1 - - def test_wait_for_change(self): - self.client.r53.get_change = mock.MagicMock( - side_effect=[{"ChangeInfo": {"Status": "PENDING"}}, - {"ChangeInfo": {"Status": "INSYNC"}}]) - - self.client._wait_for_change("1") - - assert self.client.r53.get_change.called - - -if __name__ == "__main__": - sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/PKG-INFO new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/PKG-INFO --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/PKG-INFO 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,48 +0,0 @@ -Metadata-Version: 2.4 -Name: certbot-dns-route53 -Version: 4.0.0 -Summary: Route53 DNS Authenticator plugin for Certbot -Home-page: https://github.com/certbot/certbot -Author: Certbot Project -Author-email: certbot-...@eff.org -License: Apache License 2.0 -Keywords: certbot,route53,aws -Classifier: Development Status :: 5 - Production/Stable -Classifier: Environment :: Plugins -Classifier: Intended Audience :: System Administrators -Classifier: License :: OSI Approved :: Apache Software License -Classifier: Operating System :: POSIX :: Linux -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.9 -Classifier: Programming Language :: Python :: 3.10 -Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: 3.12 -Classifier: Programming Language :: Python :: 3.13 -Classifier: Topic :: Internet :: WWW/HTTP -Classifier: Topic :: Security -Classifier: Topic :: System :: Installation/Setup -Classifier: Topic :: System :: Networking -Classifier: Topic :: System :: Systems Administration -Classifier: Topic :: Utilities -Requires-Python: >=3.9 -License-File: LICENSE.txt -Requires-Dist: boto3>=1.15.15 -Requires-Dist: acme>=4.0.0 -Requires-Dist: certbot>=4.0.0 -Provides-Extra: docs -Requires-Dist: Sphinx>=1.0; extra == "docs" -Requires-Dist: sphinx_rtd_theme; extra == "docs" -Provides-Extra: test -Requires-Dist: pytest; extra == "test" -Dynamic: author -Dynamic: author-email -Dynamic: classifier -Dynamic: home-page -Dynamic: keywords -Dynamic: license -Dynamic: license-file -Dynamic: provides-extra -Dynamic: requires-dist -Dynamic: requires-python -Dynamic: summary diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/SOURCES.txt new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/SOURCES.txt --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/SOURCES.txt 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/SOURCES.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,22 +0,0 @@ -LICENSE.txt -MANIFEST.in -README.rst -setup.py -certbot_dns_route53/__init__.py -certbot_dns_route53/py.typed -certbot_dns_route53.egg-info/PKG-INFO -certbot_dns_route53.egg-info/SOURCES.txt -certbot_dns_route53.egg-info/dependency_links.txt -certbot_dns_route53.egg-info/entry_points.txt -certbot_dns_route53.egg-info/requires.txt -certbot_dns_route53.egg-info/top_level.txt -certbot_dns_route53/_internal/__init__.py -certbot_dns_route53/_internal/dns_route53.py -certbot_dns_route53/_internal/tests/__init__.py -certbot_dns_route53/_internal/tests/dns_route53_test.py -docs/.gitignore -docs/Makefile -docs/api.rst -docs/conf.py -docs/index.rst -docs/make.bat \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/dependency_links.txt new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/dependency_links.txt --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/dependency_links.txt 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/dependency_links.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ - diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/entry_points.txt new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/entry_points.txt --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/entry_points.txt 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/entry_points.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,3 +0,0 @@ -[certbot.plugins] -certbot-route53:auth = certbot_dns_route53._internal.dns_route53:HiddenAuthenticator -dns-route53 = certbot_dns_route53._internal.dns_route53:Authenticator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/requires.txt new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/requires.txt --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/requires.txt 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/requires.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,10 +0,0 @@ -boto3>=1.15.15 -acme>=4.0.0 -certbot>=4.0.0 - -[docs] -Sphinx>=1.0 -sphinx_rtd_theme - -[test] -pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/top_level.txt new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/top_level.txt --- old/certbot_dns_route53-4.0.0/certbot_dns_route53.egg-info/top_level.txt 2025-04-08 00:03:41.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/certbot_dns_route53.egg-info/top_level.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -certbot_dns_route53 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/setup.py new/certbot_dns_route53-4.1.1/setup.py --- old/certbot_dns_route53-4.0.0/setup.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_route53-4.1.1/setup.py 2025-06-12 20:08:35.000000000 +0200 @@ -4,7 +4,7 @@ from setuptools import find_packages from setuptools import setup -version = '4.0.0' +version = '4.1.1' install_requires = [ 'boto3>=1.15.15', @@ -38,7 +38,7 @@ author="Certbot Project", author_email='certbot-...@eff.org', license='Apache License 2.0', - python_requires='>=3.9', + python_requires='>=3.9.2', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Plugins', @@ -59,7 +59,8 @@ 'Topic :: System :: Systems Administration', 'Topic :: Utilities', ], - packages=find_packages(), + packages=find_packages(where='src'), + package_dir={'': 'src'}, include_package_data=True, install_requires=install_requires, keywords=['certbot', 'route53', 'aws'], diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/__init__.py new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/__init__.py --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,105 @@ +""" +The `~certbot_dns_route53.dns_route53` plugin automates the process of +completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and +subsequently removing, TXT records using the Amazon Web Services Route 53 API. + +.. note:: + The plugin is not installed by default. It can be installed by heading to + `certbot.eff.org <https://certbot.eff.org/instructions#wildcard>`_, choosing your system and + selecting the Wildcard tab. + +Credentials +----------- +Use of this plugin requires a configuration file containing Amazon Web Sevices +API credentials for an account with the following permissions: + +* ``route53:ListHostedZones`` +* ``route53:GetChange`` +* ``route53:ChangeResourceRecordSets`` + +These permissions can be captured in an AWS policy like the one below. Amazon +provides `information about managing access <https://docs.aws.amazon.com/Route53 +/latest/DeveloperGuide/access-control-overview.html>`_ and `information about +the required permissions <https://docs.aws.amazon.com/Route53/latest +/DeveloperGuide/r53-api-permissions-ref.html>`_ + +.. code-block:: json + :name: sample-aws-policy.json + :caption: Example AWS policy file: + + { + "Version": "2012-10-17", + "Id": "certbot-dns-route53 sample policy", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "route53:ListHostedZones", + "route53:GetChange" + ], + "Resource": [ + "*" + ] + }, + { + "Effect" : "Allow", + "Action" : [ + "route53:ChangeResourceRecordSets" + ], + "Resource" : [ + "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID" + ] + } + ] + } + +The `access keys <https://docs.aws.amazon.com/general/latest/gr +/aws-sec-cred-types.html#access-keys-and-secret-access-keys>`_ for an account +with these permissions must be supplied in one of the following ways, which are +discussed in more detail in the Boto3 library's documentation about `configuring +credentials <https://boto3.readthedocs.io/en/latest/guide/configuration.html +#best-practices-for-configuring-credentials>`_. + +* Using the ``AWS_ACCESS_KEY_ID`` and ``AWS_SECRET_ACCESS_KEY`` environment + variables. +* Using a credentials configuration file at the default location, + ``~/.aws/credentials``. If you're running on sudo, the credentials + will be picked up from the root home. +* Using a credentials configuration file at a path supplied using the + ``AWS_CONFIG_FILE`` environment variable. + +.. code-block:: ini + :name: config.ini + :caption: Example credentials config file: + + [default] + aws_access_key_id=AKIAIOSFODNN7EXAMPLE + aws_secret_access_key=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY + +.. caution:: + You should protect these API credentials as you would a password. Users who + can read this file can use these credentials to issue some types of API calls + on your behalf, limited by the permissions assigned to the account. Users who + can cause Certbot to run using these credentials can complete a ``dns-01`` + challenge to acquire new certificates or revoke existing certificates for + domains these credentials are authorized to manage. + + +Examples +-------- +.. code-block:: bash + :caption: To acquire a certificate for ``example.com`` + + certbot certonly \\ + --dns-route53 \\ + -d example.com + +.. code-block:: bash + :caption: To acquire a single certificate for both ``example.com`` and + ``www.example.com`` + + certbot certonly \\ + --dns-route53 \\ + -d example.com \\ + -d www.example.com +""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/__init__.py new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/__init__.py --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1 @@ +"""Internal implementation of `~certbot_dns_route53.dns_route53` plugin.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/dns_route53.py new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/dns_route53.py --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/dns_route53.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/dns_route53.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,191 @@ +"""Certbot Route53 authenticator plugin.""" +import collections +import logging +import time +from typing import Any +from typing import Callable +from typing import DefaultDict +from typing import Dict +from typing import Iterable +from typing import List +from typing import Type +from typing import cast + +import boto3 +from botocore.exceptions import ClientError +from botocore.exceptions import NoCredentialsError + +from acme import challenges +from certbot import achallenges +from certbot import errors +from certbot import interfaces +from certbot.achallenges import AnnotatedChallenge +from certbot.plugins import common + +logger = logging.getLogger(__name__) + +INSTRUCTIONS = ( + "To use certbot-dns-route53, configure credentials as described at " + "https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials " # pylint: disable=line-too-long + "and add the necessary permissions for Route53 access.") + + +class Authenticator(common.Plugin, interfaces.Authenticator): + """Route53 Authenticator + + This authenticator solves a DNS01 challenge by uploading the answer to AWS + Route53. + """ + + description = ("Obtain certificates using a DNS TXT record (if you are using AWS Route53 for " + "DNS).") + ttl = 10 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.r53 = boto3.client("route53") + self._attempt_cleanup = False + self._resource_records: DefaultDict[str, List[Dict[str, str]]] = \ + collections.defaultdict(list) + + def more_info(self) -> str: + return "Solve a DNS01 challenge using AWS Route53" + + @classmethod + def add_parser_arguments(cls, add: Callable[..., None]) -> None: + # This authenticator currently adds no extra arguments. + pass + + def auth_hint(self, failed_achalls: List[achallenges.AnnotatedChallenge]) -> str: + return ( + 'The Certificate Authority failed to verify the DNS TXT records created by ' + '--dns-route53. Ensure the above domains have their DNS hosted by AWS Route53.' + ) + + def prepare(self) -> None: + pass + + def get_chall_pref(self, unused_domain: str) -> Iterable[Type[challenges.Challenge]]: + return [challenges.DNS01] + + def perform(self, achalls: List[AnnotatedChallenge]) -> List[challenges.ChallengeResponse]: + self._attempt_cleanup = True + + try: + change_ids = [ + self._change_txt_record("UPSERT", + achall.validation_domain_name(achall.domain), + achall.validation(achall.account_key)) + for achall in achalls + ] + + for change_id in change_ids: + self._wait_for_change(change_id) + except (NoCredentialsError, ClientError) as e: + logger.debug('Encountered error during perform: %s', e, exc_info=True) + raise errors.PluginError("\n".join([str(e), INSTRUCTIONS])) + return [achall.response(achall.account_key) for achall in achalls] + + def cleanup(self, achalls: List[achallenges.AnnotatedChallenge]) -> None: + if self._attempt_cleanup: + for achall in achalls: + domain = achall.domain + validation_domain_name = achall.validation_domain_name(domain) + validation = achall.validation(achall.account_key) + + self._cleanup(validation_domain_name, validation) + + def _cleanup(self, validation_name: str, validation: str) -> None: + try: + self._change_txt_record("DELETE", validation_name, validation) + except (NoCredentialsError, ClientError) as e: + logger.debug('Encountered error during cleanup: %s', e, exc_info=True) + + def _find_zone_id_for_domain(self, domain: str) -> str: + """Find the zone id responsible a given FQDN. + + That is, the id for the zone whose name is the longest parent of the + domain. + """ + paginator = self.r53.get_paginator("list_hosted_zones") + zones: list[tuple[str, str]] = [] + target_labels = domain.rstrip(".").split(".") + for page in paginator.paginate(): + for zone in page["HostedZones"]: + if zone["Config"]["PrivateZone"]: + continue + + candidate_labels = zone["Name"].rstrip(".").split(".") + if candidate_labels == target_labels[-len(candidate_labels):]: + zones.append((zone["Name"], zone["Id"])) + + if not zones: + raise errors.PluginError( + "Unable to find a Route53 hosted zone for {0}".format(domain) + ) + + # Order the zones that are suffixes for our desired to domain by + # length, this puts them in an order like: + # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"] + # And then we choose the first one, which will be the most specific. + zones.sort(key=lambda z: len(z[0]), reverse=True) + 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) + + rrecords = self._resource_records[validation_domain_name] + challenge = {"Value": '"{0}"'.format(validation)} + if action == "DELETE": + # Remove the record being deleted from the list of tracked records + rrecords.remove(challenge) + if rrecords: + # Need to update instead, as we're not deleting the rrset + action = "UPSERT" + else: + # Create a new list containing the record to use with DELETE + rrecords = [challenge] + else: + rrecords.append(challenge) + + response = self.r53.change_resource_record_sets( + HostedZoneId=zone_id, + ChangeBatch={ + "Comment": "certbot-dns-route53 certificate validation " + action, + "Changes": [ + { + "Action": action, + "ResourceRecordSet": { + "Name": validation_domain_name, + "Type": "TXT", + "TTL": self.ttl, + "ResourceRecords": rrecords, + } + } + ] + } + ) + return cast(str, response["ChangeInfo"]["Id"]) + + def _wait_for_change(self, change_id: str) -> None: + """Wait for a change to be propagated to all Route53 DNS servers. + https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html + """ + for unused_n in range(0, 120): + response = self.r53.get_change(Id=change_id) + if response["ChangeInfo"]["Status"] == "INSYNC": + return + time.sleep(5) + raise errors.PluginError( + "Timed out waiting for Route53 change. Current status: %s" % + response["ChangeInfo"]["Status"]) + + +# Our route53 plugin was initially a 3rd party plugin named `certbot-route53:auth` as described at +# https://github.com/certbot/certbot/issues/4688. This shim exists to allow installations using the +# old plugin name of `certbot-route53:auth` to continue to work without cluttering things like +# Certbot's help output with two route53 plugins. +class HiddenAuthenticator(Authenticator): + """A hidden shim around certbot-dns-route53 for backwards compatibility.""" + + hidden = True diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/__init__.py new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/__init__.py --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1 @@ +"""certbot-dns-route53 tests""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/dns_route53_test.py new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/dns_route53_test.py --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53/_internal/tests/dns_route53_test.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53/_internal/tests/dns_route53_test.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,273 @@ +"""Tests for certbot_dns_route53._internal.dns_route53.Authenticator""" + +import sys +import unittest +from unittest import mock + +from botocore.exceptions import ClientError +from botocore.exceptions import NoCredentialsError +import josepy as jose +import pytest + +from acme import challenges +from certbot import achallenges +from certbot import errors +from certbot.compat import os +from certbot.plugins.dns_test_common import DOMAIN +from certbot.tests import acme_util +from certbot.tests import util as test_util + +DOMAIN = 'example.com' +KEY = jose.jwk.JWKRSA.load(test_util.load_vector("rsa512_key.pem")) + + +class AuthenticatorTest(unittest.TestCase): + # pylint: disable=protected-access + + achall = achallenges.KeyAuthorizationAnnotatedChallenge( + challb=acme_util.DNS01, domain=DOMAIN, account_key=KEY) + + def setUp(self): + from certbot_dns_route53._internal.dns_route53 import Authenticator + + super().setUp() + + self.config = mock.MagicMock() + + # Set up dummy credentials for testing + os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" + os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key" + + self.auth = Authenticator(self.config, "route53") + + def tearDown(self): + # Remove the dummy credentials from env vars + del os.environ["AWS_ACCESS_KEY_ID"] + del os.environ["AWS_SECRET_ACCESS_KEY"] + + def test_more_info(self) -> None: + self.assertTrue(isinstance(self.auth.more_info(), str)) + + def test_get_chall_pref(self) -> None: + self.assertEqual(self.auth.get_chall_pref("example.org"), [challenges.DNS01]) + + def test_perform(self): + self.auth._change_txt_record = mock.MagicMock() # type: ignore[method-assign, unused-ignore] + self.auth._wait_for_change = mock.MagicMock() # type: ignore [method-assign, unused-ignore] + + self.auth.perform([self.achall]) + + self.auth._change_txt_record.assert_called_once_with("UPSERT", + '_acme-challenge.' + DOMAIN, + mock.ANY) + assert self.auth._wait_for_change.call_count == 1 + + def test_perform_no_credentials_error(self): + self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] + side_effect=NoCredentialsError) + + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + def test_perform_client_error(self): + self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] + side_effect=ClientError({"Error": {"Code": "foo"}}, "bar")) + + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + def test_cleanup(self): + self.auth._attempt_cleanup = True + + self.auth._change_txt_record = mock.MagicMock() # type: ignore[method-assign, unused-ignore] + + self.auth.cleanup([self.achall]) + + self.auth._change_txt_record.assert_called_once_with("DELETE", + '_acme-challenge.'+DOMAIN, + mock.ANY) + + def test_cleanup_no_credentials_error(self): + self.auth._attempt_cleanup = True + + self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] + side_effect=NoCredentialsError) + + self.auth.cleanup([self.achall]) + + def test_cleanup_client_error(self): + self.auth._attempt_cleanup = True + + self.auth._change_txt_record = mock.MagicMock( # type: ignore [method-assign, unused-ignore] + side_effect=ClientError({"Error": {"Code": "foo"}}, "bar")) + + self.auth.cleanup([self.achall]) + + +class ClientTest(unittest.TestCase): + # pylint: disable=protected-access + + PRIVATE_ZONE = { + "Id": "BAD-PRIVATE", + "Name": "example.com", + "Config": { + "PrivateZone": True + } + } + + EXAMPLE_NET_ZONE = { + "Id": "BAD-WRONG-TLD", + "Name": "example.net", + "Config": { + "PrivateZone": False + } + } + + EXAMPLE_COM_ZONE = { + "Id": "EXAMPLE", + "Name": "example.com", + "Config": { + "PrivateZone": False + } + } + + FOO_EXAMPLE_COM_ZONE = { + "Id": "FOO", + "Name": "foo.example.com", + "Config": { + "PrivateZone": False + } + } + + def setUp(self): + from certbot_dns_route53._internal.dns_route53 import Authenticator + + self.config = mock.MagicMock() + + # Set up dummy credentials for testing + os.environ["AWS_ACCESS_KEY_ID"] = "dummy_access_key" + os.environ["AWS_SECRET_ACCESS_KEY"] = "dummy_secret_access_key" + + self.client = Authenticator(self.config, "route53") + + def tearDown(self): + # Remove the dummy credentials from env vars + del os.environ["AWS_ACCESS_KEY_ID"] + del os.environ["AWS_SECRET_ACCESS_KEY"] + + def test_find_zone_id_for_domain(self): + self.client.r53.get_paginator = mock.MagicMock() + self.client.r53.get_paginator().paginate.return_value = [ + { + "HostedZones": [ + self.EXAMPLE_NET_ZONE, + self.EXAMPLE_COM_ZONE, + ] + } + ] + + result = self.client._find_zone_id_for_domain("foo.example.com") + assert result == "EXAMPLE" + + def test_find_zone_id_for_domain_pagination(self): + self.client.r53.get_paginator = mock.MagicMock() + self.client.r53.get_paginator().paginate.return_value = [ + { + "HostedZones": [ + self.PRIVATE_ZONE, + self.EXAMPLE_COM_ZONE, + ] + }, + { + "HostedZones": [ + self.PRIVATE_ZONE, + self.FOO_EXAMPLE_COM_ZONE, + ] + } + ] + + result = self.client._find_zone_id_for_domain("foo.example.com") + assert result == "FOO" + + def test_find_zone_id_for_domain_no_results(self): + self.client.r53.get_paginator = mock.MagicMock() + self.client.r53.get_paginator().paginate.return_value = [] + + with pytest.raises(errors.PluginError): + self.client._find_zone_id_for_domain("foo.example.com") + + def test_find_zone_id_for_domain_no_correct_results(self): + self.client.r53.get_paginator = mock.MagicMock() + self.client.r53.get_paginator().paginate.return_value = [ + { + "HostedZones": [ + self.PRIVATE_ZONE, + self.EXAMPLE_NET_ZONE, + ] + }, + ] + + with pytest.raises(errors.PluginError): + self.client._find_zone_id_for_domain("foo.example.com") + + def test_change_txt_record(self): + 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("FOO", DOMAIN, "foo") + + call_count = self.client.r53.change_resource_record_sets.call_count + assert call_count == 1 + + def test_change_txt_record_delete(self): + 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}}) + + validation = "some-value" + validation_record = {"Value": '"{0}"'.format(validation)} + self.client._resource_records[DOMAIN] = [validation_record] + + self.client._change_txt_record("DELETE", DOMAIN, validation) + + call_count = self.client.r53.change_resource_record_sets.call_count + assert call_count == 1 + call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1] + call_args_batch = call_args["ChangeBatch"]["Changes"][0] + assert call_args_batch["Action"] == "DELETE" + assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \ + [validation_record] + + def test_change_txt_record_multirecord(self): + self.client._find_zone_id_for_domain = mock.MagicMock() # type: ignore [method-assign, unused-ignore] + self.client._resource_records[DOMAIN] = [ + {"Value": "\"pre-existing-value\""}, + {"Value": "\"pre-existing-value-two\""}, + ] + self.client.r53.change_resource_record_sets = mock.MagicMock( + return_value={"ChangeInfo": {"Id": 1}}) + + self.client._change_txt_record("DELETE", DOMAIN, "pre-existing-value") + + call_count = self.client.r53.change_resource_record_sets.call_count + call_args = self.client.r53.change_resource_record_sets.call_args_list[0][1] + call_args_batch = call_args["ChangeBatch"]["Changes"][0] + assert call_args_batch["Action"] == "UPSERT" + assert call_args_batch["ResourceRecordSet"]["ResourceRecords"] == \ + [{"Value": "\"pre-existing-value-two\""}] + + assert call_count == 1 + + def test_wait_for_change(self): + self.client.r53.get_change = mock.MagicMock( + side_effect=[{"ChangeInfo": {"Status": "PENDING"}}, + {"ChangeInfo": {"Status": "INSYNC"}}]) + + self.client._wait_for_change("1") + + assert self.client.r53.get_change.called + + +if __name__ == "__main__": + sys.exit(pytest.main(sys.argv[1:] + [__file__])) # pragma: no cover diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/PKG-INFO new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/PKG-INFO --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/PKG-INFO 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1,48 @@ +Metadata-Version: 2.4 +Name: certbot-dns-route53 +Version: 4.1.1 +Summary: Route53 DNS Authenticator plugin for Certbot +Home-page: https://github.com/certbot/certbot +Author: Certbot Project +Author-email: certbot-...@eff.org +License: Apache License 2.0 +Keywords: certbot,route53,aws +Classifier: Development Status :: 5 - Production/Stable +Classifier: Environment :: Plugins +Classifier: Intended Audience :: System Administrators +Classifier: License :: OSI Approved :: Apache Software License +Classifier: Operating System :: POSIX :: Linux +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: Programming Language :: Python :: 3.9 +Classifier: Programming Language :: Python :: 3.10 +Classifier: Programming Language :: Python :: 3.11 +Classifier: Programming Language :: Python :: 3.12 +Classifier: Programming Language :: Python :: 3.13 +Classifier: Topic :: Internet :: WWW/HTTP +Classifier: Topic :: Security +Classifier: Topic :: System :: Installation/Setup +Classifier: Topic :: System :: Networking +Classifier: Topic :: System :: Systems Administration +Classifier: Topic :: Utilities +Requires-Python: >=3.9.2 +License-File: LICENSE.txt +Requires-Dist: boto3>=1.15.15 +Requires-Dist: acme>=4.1.1 +Requires-Dist: certbot>=4.1.1 +Provides-Extra: docs +Requires-Dist: Sphinx>=1.0; extra == "docs" +Requires-Dist: sphinx_rtd_theme; extra == "docs" +Provides-Extra: test +Requires-Dist: pytest; extra == "test" +Dynamic: author +Dynamic: author-email +Dynamic: classifier +Dynamic: home-page +Dynamic: keywords +Dynamic: license +Dynamic: license-file +Dynamic: provides-extra +Dynamic: requires-dist +Dynamic: requires-python +Dynamic: summary diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/SOURCES.txt new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/SOURCES.txt --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/SOURCES.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/SOURCES.txt 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1,22 @@ +LICENSE.txt +MANIFEST.in +README.rst +setup.py +docs/.gitignore +docs/Makefile +docs/api.rst +docs/conf.py +docs/index.rst +docs/make.bat +src/certbot_dns_route53/__init__.py +src/certbot_dns_route53/py.typed +src/certbot_dns_route53.egg-info/PKG-INFO +src/certbot_dns_route53.egg-info/SOURCES.txt +src/certbot_dns_route53.egg-info/dependency_links.txt +src/certbot_dns_route53.egg-info/entry_points.txt +src/certbot_dns_route53.egg-info/requires.txt +src/certbot_dns_route53.egg-info/top_level.txt +src/certbot_dns_route53/_internal/__init__.py +src/certbot_dns_route53/_internal/dns_route53.py +src/certbot_dns_route53/_internal/tests/__init__.py +src/certbot_dns_route53/_internal/tests/dns_route53_test.py \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/dependency_links.txt new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/dependency_links.txt --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/dependency_links.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/dependency_links.txt 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1 @@ + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/entry_points.txt new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/entry_points.txt --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/entry_points.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/entry_points.txt 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1,3 @@ +[certbot.plugins] +certbot-route53:auth = certbot_dns_route53._internal.dns_route53:HiddenAuthenticator +dns-route53 = certbot_dns_route53._internal.dns_route53:Authenticator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/requires.txt new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/requires.txt --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/requires.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/requires.txt 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1,10 @@ +boto3>=1.15.15 +acme>=4.1.1 +certbot>=4.1.1 + +[docs] +Sphinx>=1.0 +sphinx_rtd_theme + +[test] +pytest diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/top_level.txt new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/top_level.txt --- old/certbot_dns_route53-4.0.0/src/certbot_dns_route53.egg-info/top_level.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_route53-4.1.1/src/certbot_dns_route53.egg-info/top_level.txt 2025-06-12 20:08:43.000000000 +0200 @@ -0,0 +1 @@ +certbot_dns_route53