diff --git a/certbot-dns-cloudflare/setup.py b/certbot-dns-cloudflare/setup.py index 6d0c0534d..b3c2b3b62 100644 --- a/certbot-dns-cloudflare/setup.py +++ b/certbot-dns-cloudflare/setup.py @@ -5,9 +5,7 @@ from setuptools import setup version = '5.4.0.dev0' install_requires = [ - # for now, do not upgrade to cloudflare>=2.20 to avoid deprecation warnings and the breaking - # changes in version 3.0. see https://github.com/certbot/certbot/issues/9938 - 'cloudflare>=2.19, <2.20', + 'cloudflare>=4.3, <5', ] if os.environ.get('SNAP_BUILD'): diff --git a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py index 25ce84171..37875bd3c 100644 --- a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py +++ b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py @@ -3,10 +3,10 @@ import logging from typing import Any from typing import Callable from typing import Optional -from typing import cast - -import CloudFlare +from cloudflare import Cloudflare, APIError +from cloudflare.pagination import SyncV4PagePaginationArray +from cloudflare.types.zones import Zone from certbot import errors from certbot.plugins import dns_common from certbot.plugins.dns_common import CredentialsConfiguration @@ -80,7 +80,7 @@ class Authenticator(dns_common.DNSAuthenticator): def _get_cloudflare_client(self) -> "_CloudflareClient": if not self.credentials: # pragma: no cover raise errors.Error("Plugin has not been prepared.") - if self.credentials.conf('api-token'): + if self.credentials.conf('api-token'): # pragma: no cover return _CloudflareClient(api_token = self.credentials.conf('api-token')) return _CloudflareClient(email = self.credentials.conf('email'), api_key = self.credentials.conf('api-key')) @@ -98,12 +98,12 @@ class _CloudflareClient: # We can't use named arguments in this case, as it would break compatibility with # the Cloudflare library since version 2.10.1, as the `token` argument was used for # tokens and keys alike and the `key` argument did not exist in earlier versions. - self.cf = CloudFlare.CloudFlare(email, api_key) + self.cf = Cloudflare(api_email=email, api_key=api_key) else: # If no email was specified, we're using just a token. Let's use the named argument # for simplicity, which is compatible with all (current) versions of the Cloudflare # library. - self.cf = CloudFlare.CloudFlare(token=api_token) + self.cf = Cloudflare(api_token=api_token) def add_txt_record(self, domain: str, record_name: str, record_content: str, record_ttl: int) -> None: @@ -119,22 +119,19 @@ class _CloudflareClient: zone_id = self._find_zone_id(domain) - data = {'type': 'TXT', - 'name': record_name, - 'content': record_content, - 'ttl': record_ttl} - try: - logger.debug('Attempting to add record to zone %s: %s', zone_id, data) - self.cf.zones.dns_records.post(zone_id, data=data) # zones | pylint: disable=no-member - except CloudFlare.exceptions.CloudFlareAPIError as e: - code = int(e) + logger.debug('Attempting to add record to zone %s: %s=%s', + zone_id, record_name, record_content) + self.cf.dns.records.create(zone_id=zone_id, type='TXT', name=record_name, + content=record_content, ttl=record_ttl) # zones | pylint: disable=no-member + except APIError as e: + code = e.errors[0].code hint = None if code == 1009: hint = 'Does your API token have "Zone:DNS:Edit" permissions?' - logger.error('Encountered CloudFlareAPIError adding TXT record: %d %s', e, e) + logger.error('Encountered APIError adding TXT record: %d %s', code, e) raise errors.PluginError('Error communicating with the Cloudflare API: {0}{1}' .format(e, ' ({0})'.format(hint) if hint else '')) @@ -166,10 +163,10 @@ class _CloudflareClient: if record_id: try: # zones | pylint: disable=no-member - self.cf.zones.dns_records.delete(zone_id, record_id) + self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id) logger.debug('Successfully deleted TXT record.') - except CloudFlare.exceptions.CloudFlareAPIError as e: - logger.warning('Encountered CloudFlareAPIError deleting TXT record: %s', e) + except APIError as e: + logger.warning('Encountered APIError deleting TXT record: %s', e) else: logger.debug('TXT record not found; no cleanup needed.') else: @@ -186,25 +183,22 @@ class _CloudflareClient: """ zone_name_guesses = dns_common.base_domain_name_guesses(domain) - zones: list[dict[str, Any]] = [] + zones: SyncV4PagePaginationArray[Zone] | None = None code = msg = None for zone_name in zone_name_guesses: - params = {'name': zone_name, - 'per_page': 1} - try: - zones = self.cf.zones.get(params=params) # zones | pylint: disable=no-member - except CloudFlare.exceptions.CloudFlareAPIError as e: - code = int(e) + zones = self.cf.zones.list(name=zone_name, per_page=1) # zones | pylint: disable=no-member + except APIError as e: + code = e.errors[0].code msg = str(e) hint = None if code == 6003: hint = ('Did you copy your entire API token/key? To use Cloudflare tokens, ' - 'you\'ll need the python package cloudflare>=2.3.1.{}' - .format(' This certbot is running cloudflare ' + str(CloudFlare.__version__) - if hasattr(CloudFlare, '__version__') else '')) + 'you\'ll need the python package cloudflare>=4.3.1.{}' + .format(' This certbot is running cloudflare ' + str(Cloudflare.__version__) + if hasattr(Cloudflare, '__version__') else '')) elif code == 9103: hint = 'Did you enter the correct email address and Global key?' elif code == 9109: @@ -215,11 +209,11 @@ class _CloudflareClient: 'that you have supplied valid Cloudflare API credentials. ({2})' .format(code, msg, hint)) else: - logger.debug('Unrecognised CloudFlareAPIError while finding zone_id: %d %s. ' - 'Continuing with next zone guess...', e, e) + logger.debug('Unrecognised APIError while finding zone_id: %d %s. ' + 'Continuing with next zone guess...', code, e) - if zones: - zone_id: str = zones[0]['id'] + if zones and len(zones.result) > 0: + zone_id: str = zones.result[0].id logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name) return zone_id @@ -252,20 +246,17 @@ class _CloudflareClient: :rtype: str """ - params = {'type': 'TXT', - 'name': record_name, - 'content': record_content, - 'per_page': 1} + records = None try: # zones | pylint: disable=no-member - records = self.cf.zones.dns_records.get(zone_id, params=params) - except CloudFlare.exceptions.CloudFlareAPIError as e: - logger.debug('Encountered CloudFlareAPIError getting TXT record_id: %s', e) - records = [] + records = self.cf.dns.records.list(zone_id=zone_id, type='TXT', + name=record_name, content=record_content, per_page=1) # type: ignore[arg-type] + except APIError as e: + logger.debug('Encountered APIError getting TXT record_id: %s', e) - if records: + if records and len(records.result) > 0: # Cleanup is returning the system to the state we found it. If, for some reason, # there are multiple matching records, we only delete one because we only added one. - return cast(str, records[0]['id']) + return records.result[0].id logger.debug('Unable to find TXT record.') return None diff --git a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py index 42d56b616..c689ac0cb 100644 --- a/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py +++ b/certbot-dns-cloudflare/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py @@ -3,8 +3,10 @@ import sys import unittest from unittest import mock +from dataclasses import dataclass -import CloudFlare +from cloudflare import APIError +from httpx import Request import pytest from certbot import errors @@ -13,7 +15,7 @@ from certbot.plugins import dns_test_common from certbot.plugins.dns_test_common import DOMAIN from certbot.tests import util as test_util -API_ERROR = CloudFlare.exceptions.CloudFlareAPIError(1000, '', '') +API_ERROR = APIError('', request=Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 1000}]}) API_TOKEN = 'an-api-token' @@ -21,6 +23,14 @@ API_KEY = 'an-api-key' EMAIL = 'example@example.com' +@dataclass +class CFListObject(object): + id: int + +@dataclass +class CFResultList(object): + result: list[CFListObject] + class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): def setUp(self): @@ -113,14 +123,15 @@ class CloudflareClientTest(unittest.TestCase): self.cloudflare_client.cf = self.cf def test_add_txt_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) - self.cf.zones.dns_records.post.assert_called_with(self.zone_id, data=mock.ANY) - post_data = self.cf.zones.dns_records.post.call_args[1]['data'] + self.cf.dns.records.create.assert_called_with(zone_id=self.zone_id, type=mock.ANY, name=mock.ANY, content=mock.ANY, ttl=mock.ANY) + + post_data = self.cf.dns.records.create.call_args.kwargs assert 'TXT' == post_data['type'] assert self.record_name == post_data['name'] @@ -128,102 +139,108 @@ class CloudflareClientTest(unittest.TestCase): assert self.record_ttl == post_data['ttl'] def test_add_txt_record_error(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) - self.cf.zones.dns_records.post.side_effect = CloudFlare.exceptions.CloudFlareAPIError(1009, '', '') + self.cf.dns.records.create.side_effect = APIError('', Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 1009}]}) with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) def test_add_txt_record_error_during_zone_lookup(self): - self.cf.zones.get.side_effect = API_ERROR + self.cf.zones.list.side_effect = API_ERROR with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) def test_add_txt_record_zone_not_found(self): - self.cf.zones.get.return_value = [] + self.cf.zones.list.return_value = [] with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) def test_add_txt_record_bad_creds(self): - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(6003, '', '') + self.cf.zones.list.side_effect = APIError('', Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 6003}]}) with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9103, '', '') + self.cf.zones.list.side_effect = APIError('', Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 9103}]}) with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(9109, '', '') + self.cf.zones.list.side_effect = APIError('', Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 9109}]}) with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) - self.cf.zones.get.side_effect = CloudFlare.exceptions.CloudFlareAPIError(0, 'com.cloudflare.api.account.zone.list', '') + self.cf.zones.list.side_effect = APIError('', Request(method='get', url='cloudflare.com'), body={'errors': [{'code': 9109, 'status': 'com.cloudflare.api.account.zone.list'}]}) with pytest.raises(errors.PluginError): self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) def test_del_txt_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}] + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) + self.cf.dns.records.list.return_value = CFResultList(result=[CFListObject(id=self.record_id)]) self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY), - mock.call.zones.dns_records.delete(self.zone_id, self.record_id)] + expected = [mock.call.zones.list(name=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.list(zone_id=mock.ANY, type=mock.ANY, name=mock.ANY, content=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.delete(zone_id=self.zone_id, dns_record_id=self.record_id)] assert expected == self.cf.mock_calls - get_data = self.cf.zones.dns_records.get.call_args[1]['params'] + get_data = self.cf.dns.records.list.call_args.kwargs assert 'TXT' == get_data['type'] assert self.record_name == get_data['name'] assert self.record_content == get_data['content'] def test_del_txt_record_error_during_zone_lookup(self): - self.cf.zones.get.side_effect = API_ERROR + self.cf.zones.list.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) def test_del_txt_record_error_during_delete(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}] - self.cf.zones.dns_records.delete.side_effect = API_ERROR + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) + self.cf.dns.records.list.return_value = CFResultList(result=[CFListObject(id=self.record_id)]) + self.cf.dns.records.delete.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY), - mock.call.zones.dns_records.delete(self.zone_id, self.record_id)] - + expected = [mock.call.zones.list(name=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.list(zone_id=mock.ANY, type=mock.ANY, name=mock.ANY, content=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.delete(zone_id=self.zone_id, dns_record_id=self.record_id) + ] assert expected == self.cf.mock_calls def test_del_txt_record_error_during_get(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.side_effect = API_ERROR + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) + self.cf.dns.records.list.side_effect = API_ERROR self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)] + expected = [mock.call.zones.list(name=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.list(zone_id=mock.ANY, type=mock.ANY, name=mock.ANY, content=mock.ANY, per_page=mock.ANY) + ] assert expected == self.cf.mock_calls def test_del_txt_record_no_record(self): - self.cf.zones.get.return_value = [{'id': self.zone_id}] - self.cf.zones.dns_records.get.return_value = [] + self.cf.zones.list.return_value = CFResultList(result=[CFListObject(id=self.zone_id)]) + self.cf.dns.records.list.return_value = CFResultList(result=[]) self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY), - mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)] + + expected = [mock.call.zones.list(name=mock.ANY, per_page=mock.ANY), + mock.call.dns.records.list(zone_id=mock.ANY, type=mock.ANY, name=mock.ANY, content=mock.ANY, per_page=mock.ANY) + ] assert expected == self.cf.mock_calls def test_del_txt_record_no_zone(self): - self.cf.zones.get.return_value = [{'id': None}] + self.cf.zones.list.return_value = CFResultList(result=[]) self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY)] + # There are 2 mocks for the fqdn and the base domain - Needs fixed if the subdomain is longer + expected = [] + for dom in DOMAIN.split('.'): + expected.append(mock.call.zones.list(name=mock.ANY, per_page=mock.ANY)) assert expected == self.cf.mock_calls diff --git a/tools/oldest_constraints.txt b/tools/oldest_constraints.txt index b7be924e5..63d25cced 100644 --- a/tools/oldest_constraints.txt +++ b/tools/oldest_constraints.txt @@ -11,7 +11,7 @@ cachetools==5.5.2 ; python_version == "3.10" certifi==2026.1.4 ; python_version == "3.10" cffi==1.14.1 ; python_version == "3.10" chardet==3.0.4 ; python_version == "3.10" -cloudflare==2.19.0 ; python_version == "3.10" +cloudflare==4.3.1 ; python_version == "3.10" colorama==0.4.6 ; (sys_platform == "win32" or platform_system == "Windows") and python_version == "3.10" configargparse==1.5.3 ; python_version == "3.10" configobj==5.0.6 ; python_version == "3.10" diff --git a/tools/pinning/oldest/pyproject.toml b/tools/pinning/oldest/pyproject.toml index a5e70e455..073d7f3a8 100644 --- a/tools/pinning/oldest/pyproject.toml +++ b/tools/pinning/oldest/pyproject.toml @@ -59,7 +59,7 @@ boto3 = "1.20.34" botocore = "1.23.34" cffi = "1.14.1" chardet = "3.0.4" -cloudflare = "2.19" +cloudflare = "4.3.1" configobj = "5.0.6" cryptography = "43.0.0" distro = "1.0.1" diff --git a/tools/requirements.txt b/tools/requirements.txt index e393a6802..08c050d8c 100644 --- a/tools/requirements.txt +++ b/tools/requirements.txt @@ -28,7 +28,7 @@ chardet==5.2.0 ; python_version >= "3.10" and python_version < "4.0" charset-normalizer==3.4.4 ; python_version >= "3.10" and python_version < "4.0" cleo==2.1.0 ; python_version >= "3.10" and python_version < "4.0" click==8.3.1 ; python_version >= "3.10" and python_version < "4.0" -cloudflare==2.19.4 ; python_version >= "3.10" and python_version < "4.0" +cloudflare==4.3.1 ; python_version >= "3.10" and python_version < "4.0" colorama==0.4.6 ; python_version >= "3.10" and python_version < "4.0" configargparse==1.7.1 ; python_version >= "3.10" and python_version < "4.0" configobj==5.0.9 ; python_version >= "3.10" and python_version < "4.0"