This commit is contained in:
Darkbat91 2026-02-13 20:06:17 -06:00 committed by GitHub
commit cd525c2f6a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 92 additions and 86 deletions

View file

@ -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'):

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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"

View file

@ -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"