mirror of
https://github.com/certbot/certbot.git
synced 2026-02-20 00:10:12 -05:00
Merge eba5c3085e into ff281d48a8
This commit is contained in:
commit
cd525c2f6a
6 changed files with 92 additions and 86 deletions
|
|
@ -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'):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue