Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package python-certbot-dns-cloudflare for openSUSE:Factory checked in at 2025-06-16 12:26:25 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/python-certbot-dns-cloudflare (Old) and /work/SRC/openSUSE:Factory/.python-certbot-dns-cloudflare.new.19631 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "python-certbot-dns-cloudflare" Mon Jun 16 12:26:25 2025 rev:45 rq:1286008 version:4.1.1 Changes: -------- --- /work/SRC/openSUSE:Factory/python-certbot-dns-cloudflare/python-certbot-dns-cloudflare.changes 2025-04-22 17:29:43.734725420 +0200 +++ /work/SRC/openSUSE:Factory/.python-certbot-dns-cloudflare.new.19631/python-certbot-dns-cloudflare.changes 2025-06-16 12:26:27.068200320 +0200 @@ -1,0 +2,7 @@ +Fri Jun 13 14:50:42 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_cloudflare-4.0.0.tar.gz New: ---- certbot_dns_cloudflare-4.1.1.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ python-certbot-dns-cloudflare.spec ++++++ --- /var/tmp/diff_new_pack.sJarsl/_old 2025-06-16 12:26:27.668225305 +0200 +++ /var/tmp/diff_new_pack.sJarsl/_new 2025-06-16 12:26:27.668225305 +0200 @@ -18,7 +18,7 @@ %{?sle15_python_module_pythons} Name: python-certbot-dns-cloudflare -Version: 4.0.0 +Version: 4.1.1 Release: 0 Summary: Cloudflare Authenticator plugin for Certbot License: Apache-2.0 ++++++ certbot_dns_cloudflare-4.0.0.tar.gz -> certbot_dns_cloudflare-4.1.1.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/MANIFEST.in new/certbot_dns_cloudflare-4.1.1/MANIFEST.in --- old/certbot_dns_cloudflare-4.0.0/MANIFEST.in 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-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_cloudflare/py.typed +include src/certbot_dns_cloudflare/py.typed global-exclude __pycache__ global-exclude *.py[cod] diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/PKG-INFO new/certbot_dns_cloudflare-4.1.1/PKG-INFO --- old/certbot_dns_cloudflare-4.0.0/PKG-INFO 2025-04-08 00:03:36.605529000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/PKG-INFO 2025-06-12 20:08:38.535003200 +0200 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: certbot-dns-cloudflare -Version: 4.0.0 +Version: 4.1.1 Summary: Cloudflare DNS Authenticator plugin for Certbot Home-page: https://github.com/certbot/certbot Author: Certbot Project @@ -24,11 +24,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: cloudflare<2.20,>=2.19 -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_cloudflare-4.0.0/certbot_dns_cloudflare/__init__.py new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/__init__.py --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,124 +0,0 @@ -""" -The `~certbot_dns_cloudflare.dns_cloudflare` plugin automates the process of -completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and -subsequently removing, TXT records using the Cloudflare 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. - -Named Arguments ---------------- - -======================================== ===================================== -``--dns-cloudflare-credentials`` Cloudflare credentials_ INI file. - (Required) -``--dns-cloudflare-propagation-seconds`` The number of seconds to wait for DNS - to propagate before asking the ACME - server to verify the DNS record. - (Default: 10) -======================================== ===================================== - - -Credentials ------------ - -Use of this plugin requires a configuration file containing Cloudflare API -credentials, obtained from your -`Cloudflare dashboard <https://dash.cloudflare.com/?to=/:account/profile/api-tokens>`_. - -Previously, Cloudflare's "Global API Key" was used for authentication, however -this key can access the entire Cloudflare API for all domains in your account, -meaning it could cause a lot of damage if leaked. - -Cloudflare's newer API Tokens can be restricted to specific domains and -operations, and are therefore now the recommended authentication option. - -The Token needed by Certbot requires ``Zone:DNS:Edit`` permissions for only the -zones you need certificates for. - -Using Cloudflare Tokens also requires at least version 2.3.1 of the ``cloudflare`` -Python module. If the version that automatically installed with this plugin is -older than that, and you can't upgrade it on your system, you'll have to stick to -the Global key. - -.. code-block:: ini - :name: certbot_cloudflare_token.ini - :caption: Example credentials file using restricted API Token (recommended): - - # Cloudflare API token used by Certbot - dns_cloudflare_api_token = 0123456789abcdef0123456789abcdef01234567 - -.. code-block:: ini - :name: certbot_cloudflare_key.ini - :caption: Example credentials file using Global API Key (not recommended): - - # Cloudflare API credentials used by Certbot - dns_cloudflare_email = cloudfl...@example.com - dns_cloudflare_api_key = 0123456789abcdef0123456789abcdef01234 - -The path to this file can be provided interactively or using the -``--dns-cloudflare-credentials`` command-line argument. Certbot records the path -to this file for use during renewal, but does not store the file's contents. - -.. caution:: - You should protect these API credentials as you would the password to your - Cloudflare account. Users who can read this file can use these credentials - to issue arbitrary API calls on your behalf. 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 associated domains, - even if those domains aren't being managed by this server. - -Certbot will emit a warning if it detects that the credentials file can be -accessed by other users on your system. The warning reads "Unsafe permissions -on credentials configuration file", followed by the path to the credentials -file. This warning will be emitted each time Certbot uses the credentials file, -including for renewal, and cannot be silenced except by addressing the issue -(e.g., by using a command like ``chmod 600`` to restrict access to the file). - -.. note:: - Please note that the ``cloudflare`` Python module used by the plugin has - additional methods of providing credentials to the module, e.g. environment - variables or the ``cloudflare.cfg`` configuration file. These methods are not - supported by Certbot. If any of those additional methods of providing - credentials is being used, they must provide the same credentials (i.e., - email and API key *or* an API token) as the credentials file provided to - Certbot. If there is a discrepancy, the ``cloudflare`` Python module will - raise an error. Also note that the credentials provided to Certbot will take - precedence over any other method of providing credentials to the ``cloudflare`` - Python module. - - -Examples --------- - -.. code-block:: bash - :caption: To acquire a certificate for ``example.com`` - - certbot certonly \\ - --dns-cloudflare \\ - --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ - -d example.com - -.. code-block:: bash - :caption: To acquire a single certificate for both ``example.com`` and - ``www.example.com`` - - certbot certonly \\ - --dns-cloudflare \\ - --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ - -d example.com \\ - -d www.example.com - -.. code-block:: bash - :caption: To acquire a certificate for ``example.com``, waiting 60 seconds - for DNS propagation - - certbot certonly \\ - --dns-cloudflare \\ - --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ - --dns-cloudflare-propagation-seconds 60 \\ - -d example.com - -""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/__init__.py new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/__init__.py --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -"""Internal implementation of `~certbot_dns_cloudflare.dns_cloudflare` plugin.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/dns_cloudflare.py new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/dns_cloudflare.py --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/dns_cloudflare.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/dns_cloudflare.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,273 +0,0 @@ -"""DNS Authenticator for Cloudflare.""" -import logging -from typing import Any -from typing import Callable -from typing import Dict -from typing import List -from typing import Optional -from typing import cast - -import CloudFlare - -from certbot import errors -from certbot.plugins import dns_common -from certbot.plugins.dns_common import CredentialsConfiguration - -logger = logging.getLogger(__name__) - -ACCOUNT_URL = 'https://dash.cloudflare.com/?to=/:account/profile/api-tokens' - - -class Authenticator(dns_common.DNSAuthenticator): - """DNS Authenticator for Cloudflare - - This Authenticator uses the Cloudflare API to fulfill a dns-01 challenge. - """ - - description = ('Obtain certificates using a DNS TXT record (if you are using Cloudflare for ' - 'DNS).') - ttl = 120 - - def __init__(self, *args: Any, **kwargs: Any) -> None: - super().__init__(*args, **kwargs) - self.credentials: Optional[CredentialsConfiguration] = None - - @classmethod - def add_parser_arguments(cls, add: Callable[..., None], - default_propagation_seconds: int = 10) -> None: - super().add_parser_arguments(add, default_propagation_seconds) - add('credentials', help='Cloudflare credentials INI file.') - - def more_info(self) -> str: - return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ - 'the Cloudflare API.' - - def _validate_credentials(self, credentials: CredentialsConfiguration) -> None: - token = credentials.conf('api-token') - email = credentials.conf('email') - key = credentials.conf('api-key') - if token: - if email or key: - raise errors.PluginError('{}: dns_cloudflare_email and dns_cloudflare_api_key are ' - 'not needed when using an API Token' - .format(credentials.confobj.filename)) - elif email or key: - if not email: - raise errors.PluginError('{}: dns_cloudflare_email is required when using a Global ' - 'API Key. (should be email address associated with ' - 'Cloudflare account)'.format(credentials.confobj.filename)) - if not key: - raise errors.PluginError('{}: dns_cloudflare_api_key is required when using a ' - 'Global API Key. (see {})' - .format(credentials.confobj.filename, ACCOUNT_URL)) - else: - raise errors.PluginError('{}: Either dns_cloudflare_api_token (recommended), or ' - 'dns_cloudflare_email and dns_cloudflare_api_key are required.' - ' (see {})'.format(credentials.confobj.filename, ACCOUNT_URL)) - - def _setup_credentials(self) -> None: - self.credentials = self._configure_credentials( - 'credentials', - 'Cloudflare credentials INI file', - None, - self._validate_credentials - ) - - def _perform(self, domain: str, validation_name: str, validation: str) -> None: - self._get_cloudflare_client().add_txt_record(domain, validation_name, validation, self.ttl) - - def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: - self._get_cloudflare_client().del_txt_record(domain, validation_name, validation) - - 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'): - return _CloudflareClient(api_token = self.credentials.conf('api-token')) - return _CloudflareClient(email = self.credentials.conf('email'), - api_key = self.credentials.conf('api-key')) - - -class _CloudflareClient: - """ - Encapsulates all communication with the Cloudflare API. - """ - - def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, - api_token: Optional[str] = None) -> None: - if email: - # If an email was specified, we're using an email/key combination and not a token. - # 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) - 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) - - def add_txt_record(self, domain: str, record_name: str, record_content: str, - record_ttl: int) -> None: - """ - Add a TXT record using the supplied information. - - :param str domain: The domain to use to look up the Cloudflare zone. - :param str record_name: The record name (typically beginning with '_acme-challenge.'). - :param str record_content: The record content (typically the challenge validation). - :param int record_ttl: The record TTL (number of seconds that the record may be cached). - :raises certbot.errors.PluginError: if an error occurs communicating with the Cloudflare API - """ - - 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) - 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) - raise errors.PluginError('Error communicating with the Cloudflare API: {0}{1}' - .format(e, ' ({0})'.format(hint) if hint else '')) - - record_id = self._find_txt_record_id(zone_id, record_name, record_content) - logger.debug('Successfully added TXT record with record_id: %s', record_id) - - def del_txt_record(self, domain: str, record_name: str, record_content: str) -> None: - """ - Delete a TXT record using the supplied information. - - Note that both the record's name and content are used to ensure that similar records - created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted. - - Failures are logged, but not raised. - - :param str domain: The domain to use to look up the Cloudflare zone. - :param str record_name: The record name (typically beginning with '_acme-challenge.'). - :param str record_content: The record content (typically the challenge validation). - """ - - try: - zone_id = self._find_zone_id(domain) - except errors.PluginError as e: - logger.debug('Encountered error finding zone_id during deletion: %s', e) - return - - if zone_id: - record_id = self._find_txt_record_id(zone_id, record_name, record_content) - if record_id: - try: - # zones | pylint: disable=no-member - self.cf.zones.dns_records.delete(zone_id, record_id) - logger.debug('Successfully deleted TXT record.') - except CloudFlare.exceptions.CloudFlareAPIError as e: - logger.warning('Encountered CloudFlareAPIError deleting TXT record: %s', e) - else: - logger.debug('TXT record not found; no cleanup needed.') - else: - logger.debug('Zone not found; no cleanup needed.') - - def _find_zone_id(self, domain: str) -> str: - """ - Find the zone_id for a given domain. - - :param str domain: The domain for which to find the zone_id. - :returns: The zone_id, if found. - :rtype: str - :raises certbot.errors.PluginError: if no zone_id is found. - """ - - zone_name_guesses = dns_common.base_domain_name_guesses(domain) - zones: List[Dict[str, Any]] = [] - 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) - 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 '')) - elif code == 9103: - hint = 'Did you enter the correct email address and Global key?' - elif code == 9109: - hint = 'Did you enter a valid Cloudflare Token?' - - if hint: - raise errors.PluginError('Error determining zone_id: {0} {1}. Please confirm ' - '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) - - if zones: - zone_id: str = zones[0]['id'] - logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name) - return zone_id - - if msg is not None: - if 'com.cloudflare.api.account.zone.list' in msg: - raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' - '{1}. Please confirm that the domain name has been ' - 'entered correctly and your Cloudflare Token has access ' - 'to the domain.'.format(domain, zone_name_guesses)) - else: - raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' - '{1}. The error from Cloudflare was: {2} {3}.' - .format(domain, zone_name_guesses, code, msg)) - else: - raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' - '{1}. Please confirm that the domain name has been ' - 'entered correctly and is already associated with the ' - 'supplied Cloudflare account.' - .format(domain, zone_name_guesses)) - - def _find_txt_record_id(self, zone_id: str, record_name: str, - record_content: str) -> Optional[str]: - """ - Find the record_id for a TXT record with the given name and content. - - :param str zone_id: The zone_id which contains the record. - :param str record_name: The record name (typically beginning with '_acme-challenge.'). - :param str record_content: The record content (typically the challenge validation). - :returns: The record_id, if found. - :rtype: str - """ - - params = {'type': 'TXT', - 'name': record_name, - 'content': record_content, - 'per_page': 1} - 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 = [] - - if records: - # 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']) - logger.debug('Unable to find TXT record.') - return None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/tests/__init__.py new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/tests/__init__.py --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/tests/__init__.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/tests/__init__.py 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -"""certbot-dns-cloudflare tests""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py 1970-01-01 01:00:00.000000000 +0100 @@ -1,232 +0,0 @@ -"""Tests for certbot_dns_cloudflare._internal.dns_cloudflare.""" - -import sys -import unittest -from unittest import mock - -import CloudFlare -import pytest - -from certbot import errors -from certbot.compat import os -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_TOKEN = 'an-api-token' - -API_KEY = 'an-api-key' -EMAIL = 'exam...@example.com' - - -class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): - - def setUp(self): - from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator - - super().setUp() - - path = os.path.join(self.tempdir, 'file.ini') - dns_test_common.write({"cloudflare_email": EMAIL, "cloudflare_api_key": API_KEY}, path) - - self.config = mock.MagicMock(cloudflare_credentials=path, - cloudflare_propagation_seconds=0) # don't wait during tests - - self.auth = Authenticator(self.config, "cloudflare") - - self.mock_client = mock.MagicMock() - # _get_cloudflare_client | pylint: disable=protected-access - # workaround for wont-fix https://github.com/python/mypy/issues/2427 that works with - # both strict and non-strict mypy - setattr(self.auth, '_get_cloudflare_client', mock.MagicMock(return_value=self.mock_client)) - - @test_util.patch_display_util() - def test_perform(self, unused_mock_get_utility): - self.auth.perform([self.achall]) - - expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] - assert expected == self.mock_client.mock_calls - - def test_cleanup(self): - # _attempt_cleanup | pylint: disable=protected-access - self.auth._attempt_cleanup = True - self.auth.cleanup([self.achall]) - - expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)] - assert expected == self.mock_client.mock_calls - - @test_util.patch_display_util() - def test_api_token(self, unused_mock_get_utility): - dns_test_common.write({"cloudflare_api_token": API_TOKEN}, - self.config.cloudflare_credentials) - self.auth.perform([self.achall]) - - expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] - assert expected == self.mock_client.mock_calls - - def test_no_creds(self): - dns_test_common.write({}, self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - def test_missing_email_or_key(self): - dns_test_common.write({"cloudflare_api_key": API_KEY}, self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - dns_test_common.write({"cloudflare_email": EMAIL}, self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - def test_email_or_key_with_token(self): - dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_email": EMAIL}, - self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_api_key": API_KEY}, - self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_email": EMAIL, - "cloudflare_api_key": API_KEY}, self.config.cloudflare_credentials) - with pytest.raises(errors.PluginError): - self.auth.perform([self.achall]) - - -class CloudflareClientTest(unittest.TestCase): - record_name = "foo" - record_content = "bar" - record_ttl = 42 - zone_id = 1 - record_id = 2 - - def setUp(self): - from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient - - self.cloudflare_client = _CloudflareClient(EMAIL, API_KEY) - - self.cf = mock.MagicMock() - self.cloudflare_client.cf = self.cf - - def test_add_txt_record(self): - self.cf.zones.get.return_value = [{'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'] - - assert 'TXT' == post_data['type'] - assert self.record_name == post_data['name'] - assert self.record_content == post_data['content'] - 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.dns_records.post.side_effect = CloudFlare.exceptions.CloudFlareAPIError(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 - - 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 = [] - - 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, '', '') - 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, '', '') - 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, '', '') - 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', '') - 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.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)] - - assert expected == self.cf.mock_calls - - get_data = self.cf.zones.dns_records.get.call_args[1]['params'] - - 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.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.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)] - - 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.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)] - - 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.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)] - - assert expected == self.cf.mock_calls - - def test_del_txt_record_no_zone(self): - self.cf.zones.get.return_value = [{'id': None}] - - self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) - expected = [mock.call.zones.get(params=mock.ANY)] - - assert expected == self.cf.mock_calls - - -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_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/PKG-INFO new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/PKG-INFO --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/PKG-INFO 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 @@ -1,46 +0,0 @@ -Metadata-Version: 2.4 -Name: certbot-dns-cloudflare -Version: 4.0.0 -Summary: Cloudflare 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 -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: cloudflare<2.20,>=2.19 -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: 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_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/SOURCES.txt new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/SOURCES.txt --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/SOURCES.txt 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.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_cloudflare/__init__.py -certbot_dns_cloudflare/py.typed -certbot_dns_cloudflare.egg-info/PKG-INFO -certbot_dns_cloudflare.egg-info/SOURCES.txt -certbot_dns_cloudflare.egg-info/dependency_links.txt -certbot_dns_cloudflare.egg-info/entry_points.txt -certbot_dns_cloudflare.egg-info/requires.txt -certbot_dns_cloudflare.egg-info/top_level.txt -certbot_dns_cloudflare/_internal/__init__.py -certbot_dns_cloudflare/_internal/dns_cloudflare.py -certbot_dns_cloudflare/_internal/tests/__init__.py -certbot_dns_cloudflare/_internal/tests/dns_cloudflare_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_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/dependency_links.txt new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/dependency_links.txt --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/dependency_links.txt 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.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_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/entry_points.txt new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/entry_points.txt --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/entry_points.txt 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/entry_points.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,2 +0,0 @@ -[certbot.plugins] -dns-cloudflare = certbot_dns_cloudflare._internal.dns_cloudflare:Authenticator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/requires.txt new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/requires.txt --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/requires.txt 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/requires.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1,10 +0,0 @@ -cloudflare<2.20,>=2.19 -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_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/top_level.txt new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/top_level.txt --- old/certbot_dns_cloudflare-4.0.0/certbot_dns_cloudflare.egg-info/top_level.txt 2025-04-08 00:03:36.000000000 +0200 +++ new/certbot_dns_cloudflare-4.1.1/certbot_dns_cloudflare.egg-info/top_level.txt 1970-01-01 01:00:00.000000000 +0100 @@ -1 +0,0 @@ -certbot_dns_cloudflare diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/setup.py new/certbot_dns_cloudflare-4.1.1/setup.py --- old/certbot_dns_cloudflare-4.0.0/setup.py 2025-04-08 00:03:33.000000000 +0200 +++ new/certbot_dns_cloudflare-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 = [ # for now, do not upgrade to cloudflare>=2.20 to avoid deprecation warnings and the breaking @@ -40,7 +40,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', @@ -62,7 +62,8 @@ 'Topic :: Utilities', ], - packages=find_packages(), + packages=find_packages(where='src'), + package_dir={'': 'src'}, include_package_data=True, install_requires=install_requires, extras_require={ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/__init__.py new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/__init__.py --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,124 @@ +""" +The `~certbot_dns_cloudflare.dns_cloudflare` plugin automates the process of +completing a ``dns-01`` challenge (`~acme.challenges.DNS01`) by creating, and +subsequently removing, TXT records using the Cloudflare 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. + +Named Arguments +--------------- + +======================================== ===================================== +``--dns-cloudflare-credentials`` Cloudflare credentials_ INI file. + (Required) +``--dns-cloudflare-propagation-seconds`` The number of seconds to wait for DNS + to propagate before asking the ACME + server to verify the DNS record. + (Default: 10) +======================================== ===================================== + + +Credentials +----------- + +Use of this plugin requires a configuration file containing Cloudflare API +credentials, obtained from your +`Cloudflare dashboard <https://dash.cloudflare.com/?to=/:account/profile/api-tokens>`_. + +Previously, Cloudflare's "Global API Key" was used for authentication, however +this key can access the entire Cloudflare API for all domains in your account, +meaning it could cause a lot of damage if leaked. + +Cloudflare's newer API Tokens can be restricted to specific domains and +operations, and are therefore now the recommended authentication option. + +The Token needed by Certbot requires ``Zone:DNS:Edit`` permissions for only the +zones you need certificates for. + +Using Cloudflare Tokens also requires at least version 2.3.1 of the ``cloudflare`` +Python module. If the version that automatically installed with this plugin is +older than that, and you can't upgrade it on your system, you'll have to stick to +the Global key. + +.. code-block:: ini + :name: certbot_cloudflare_token.ini + :caption: Example credentials file using restricted API Token (recommended): + + # Cloudflare API token used by Certbot + dns_cloudflare_api_token = 0123456789abcdef0123456789abcdef01234567 + +.. code-block:: ini + :name: certbot_cloudflare_key.ini + :caption: Example credentials file using Global API Key (not recommended): + + # Cloudflare API credentials used by Certbot + dns_cloudflare_email = cloudfl...@example.com + dns_cloudflare_api_key = 0123456789abcdef0123456789abcdef01234 + +The path to this file can be provided interactively or using the +``--dns-cloudflare-credentials`` command-line argument. Certbot records the path +to this file for use during renewal, but does not store the file's contents. + +.. caution:: + You should protect these API credentials as you would the password to your + Cloudflare account. Users who can read this file can use these credentials + to issue arbitrary API calls on your behalf. 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 associated domains, + even if those domains aren't being managed by this server. + +Certbot will emit a warning if it detects that the credentials file can be +accessed by other users on your system. The warning reads "Unsafe permissions +on credentials configuration file", followed by the path to the credentials +file. This warning will be emitted each time Certbot uses the credentials file, +including for renewal, and cannot be silenced except by addressing the issue +(e.g., by using a command like ``chmod 600`` to restrict access to the file). + +.. note:: + Please note that the ``cloudflare`` Python module used by the plugin has + additional methods of providing credentials to the module, e.g. environment + variables or the ``cloudflare.cfg`` configuration file. These methods are not + supported by Certbot. If any of those additional methods of providing + credentials is being used, they must provide the same credentials (i.e., + email and API key *or* an API token) as the credentials file provided to + Certbot. If there is a discrepancy, the ``cloudflare`` Python module will + raise an error. Also note that the credentials provided to Certbot will take + precedence over any other method of providing credentials to the ``cloudflare`` + Python module. + + +Examples +-------- + +.. code-block:: bash + :caption: To acquire a certificate for ``example.com`` + + certbot certonly \\ + --dns-cloudflare \\ + --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ + -d example.com + +.. code-block:: bash + :caption: To acquire a single certificate for both ``example.com`` and + ``www.example.com`` + + certbot certonly \\ + --dns-cloudflare \\ + --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ + -d example.com \\ + -d www.example.com + +.. code-block:: bash + :caption: To acquire a certificate for ``example.com``, waiting 60 seconds + for DNS propagation + + certbot certonly \\ + --dns-cloudflare \\ + --dns-cloudflare-credentials ~/.secrets/certbot/cloudflare.ini \\ + --dns-cloudflare-propagation-seconds 60 \\ + -d example.com + +""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/__init__.py new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/__init__.py --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1 @@ +"""Internal implementation of `~certbot_dns_cloudflare.dns_cloudflare` plugin.""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/dns_cloudflare.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,273 @@ +"""DNS Authenticator for Cloudflare.""" +import logging +from typing import Any +from typing import Callable +from typing import Dict +from typing import List +from typing import Optional +from typing import cast + +import CloudFlare + +from certbot import errors +from certbot.plugins import dns_common +from certbot.plugins.dns_common import CredentialsConfiguration + +logger = logging.getLogger(__name__) + +ACCOUNT_URL = 'https://dash.cloudflare.com/?to=/:account/profile/api-tokens' + + +class Authenticator(dns_common.DNSAuthenticator): + """DNS Authenticator for Cloudflare + + This Authenticator uses the Cloudflare API to fulfill a dns-01 challenge. + """ + + description = ('Obtain certificates using a DNS TXT record (if you are using Cloudflare for ' + 'DNS).') + ttl = 120 + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self.credentials: Optional[CredentialsConfiguration] = None + + @classmethod + def add_parser_arguments(cls, add: Callable[..., None], + default_propagation_seconds: int = 10) -> None: + super().add_parser_arguments(add, default_propagation_seconds) + add('credentials', help='Cloudflare credentials INI file.') + + def more_info(self) -> str: + return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \ + 'the Cloudflare API.' + + def _validate_credentials(self, credentials: CredentialsConfiguration) -> None: + token = credentials.conf('api-token') + email = credentials.conf('email') + key = credentials.conf('api-key') + if token: + if email or key: + raise errors.PluginError('{}: dns_cloudflare_email and dns_cloudflare_api_key are ' + 'not needed when using an API Token' + .format(credentials.confobj.filename)) + elif email or key: + if not email: + raise errors.PluginError('{}: dns_cloudflare_email is required when using a Global ' + 'API Key. (should be email address associated with ' + 'Cloudflare account)'.format(credentials.confobj.filename)) + if not key: + raise errors.PluginError('{}: dns_cloudflare_api_key is required when using a ' + 'Global API Key. (see {})' + .format(credentials.confobj.filename, ACCOUNT_URL)) + else: + raise errors.PluginError('{}: Either dns_cloudflare_api_token (recommended), or ' + 'dns_cloudflare_email and dns_cloudflare_api_key are required.' + ' (see {})'.format(credentials.confobj.filename, ACCOUNT_URL)) + + def _setup_credentials(self) -> None: + self.credentials = self._configure_credentials( + 'credentials', + 'Cloudflare credentials INI file', + None, + self._validate_credentials + ) + + def _perform(self, domain: str, validation_name: str, validation: str) -> None: + self._get_cloudflare_client().add_txt_record(domain, validation_name, validation, self.ttl) + + def _cleanup(self, domain: str, validation_name: str, validation: str) -> None: + self._get_cloudflare_client().del_txt_record(domain, validation_name, validation) + + 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'): + return _CloudflareClient(api_token = self.credentials.conf('api-token')) + return _CloudflareClient(email = self.credentials.conf('email'), + api_key = self.credentials.conf('api-key')) + + +class _CloudflareClient: + """ + Encapsulates all communication with the Cloudflare API. + """ + + def __init__(self, email: Optional[str] = None, api_key: Optional[str] = None, + api_token: Optional[str] = None) -> None: + if email: + # If an email was specified, we're using an email/key combination and not a token. + # 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) + 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) + + def add_txt_record(self, domain: str, record_name: str, record_content: str, + record_ttl: int) -> None: + """ + Add a TXT record using the supplied information. + + :param str domain: The domain to use to look up the Cloudflare zone. + :param str record_name: The record name (typically beginning with '_acme-challenge.'). + :param str record_content: The record content (typically the challenge validation). + :param int record_ttl: The record TTL (number of seconds that the record may be cached). + :raises certbot.errors.PluginError: if an error occurs communicating with the Cloudflare API + """ + + 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) + 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) + raise errors.PluginError('Error communicating with the Cloudflare API: {0}{1}' + .format(e, ' ({0})'.format(hint) if hint else '')) + + record_id = self._find_txt_record_id(zone_id, record_name, record_content) + logger.debug('Successfully added TXT record with record_id: %s', record_id) + + def del_txt_record(self, domain: str, record_name: str, record_content: str) -> None: + """ + Delete a TXT record using the supplied information. + + Note that both the record's name and content are used to ensure that similar records + created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted. + + Failures are logged, but not raised. + + :param str domain: The domain to use to look up the Cloudflare zone. + :param str record_name: The record name (typically beginning with '_acme-challenge.'). + :param str record_content: The record content (typically the challenge validation). + """ + + try: + zone_id = self._find_zone_id(domain) + except errors.PluginError as e: + logger.debug('Encountered error finding zone_id during deletion: %s', e) + return + + if zone_id: + record_id = self._find_txt_record_id(zone_id, record_name, record_content) + if record_id: + try: + # zones | pylint: disable=no-member + self.cf.zones.dns_records.delete(zone_id, record_id) + logger.debug('Successfully deleted TXT record.') + except CloudFlare.exceptions.CloudFlareAPIError as e: + logger.warning('Encountered CloudFlareAPIError deleting TXT record: %s', e) + else: + logger.debug('TXT record not found; no cleanup needed.') + else: + logger.debug('Zone not found; no cleanup needed.') + + def _find_zone_id(self, domain: str) -> str: + """ + Find the zone_id for a given domain. + + :param str domain: The domain for which to find the zone_id. + :returns: The zone_id, if found. + :rtype: str + :raises certbot.errors.PluginError: if no zone_id is found. + """ + + zone_name_guesses = dns_common.base_domain_name_guesses(domain) + zones: List[Dict[str, Any]] = [] + 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) + 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 '')) + elif code == 9103: + hint = 'Did you enter the correct email address and Global key?' + elif code == 9109: + hint = 'Did you enter a valid Cloudflare Token?' + + if hint: + raise errors.PluginError('Error determining zone_id: {0} {1}. Please confirm ' + '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) + + if zones: + zone_id: str = zones[0]['id'] + logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name) + return zone_id + + if msg is not None: + if 'com.cloudflare.api.account.zone.list' in msg: + raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' + '{1}. Please confirm that the domain name has been ' + 'entered correctly and your Cloudflare Token has access ' + 'to the domain.'.format(domain, zone_name_guesses)) + else: + raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' + '{1}. The error from Cloudflare was: {2} {3}.' + .format(domain, zone_name_guesses, code, msg)) + else: + raise errors.PluginError('Unable to determine zone_id for {0} using zone names: ' + '{1}. Please confirm that the domain name has been ' + 'entered correctly and is already associated with the ' + 'supplied Cloudflare account.' + .format(domain, zone_name_guesses)) + + def _find_txt_record_id(self, zone_id: str, record_name: str, + record_content: str) -> Optional[str]: + """ + Find the record_id for a TXT record with the given name and content. + + :param str zone_id: The zone_id which contains the record. + :param str record_name: The record name (typically beginning with '_acme-challenge.'). + :param str record_content: The record content (typically the challenge validation). + :returns: The record_id, if found. + :rtype: str + """ + + params = {'type': 'TXT', + 'name': record_name, + 'content': record_content, + 'per_page': 1} + 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 = [] + + if records: + # 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']) + logger.debug('Unable to find TXT record.') + return None diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/tests/__init__.py new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/tests/__init__.py --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/tests/__init__.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/tests/__init__.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1 @@ +"""certbot-dns-cloudflare tests""" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py 2025-06-12 20:08:34.000000000 +0200 @@ -0,0 +1,232 @@ +"""Tests for certbot_dns_cloudflare._internal.dns_cloudflare.""" + +import sys +import unittest +from unittest import mock + +import CloudFlare +import pytest + +from certbot import errors +from certbot.compat import os +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_TOKEN = 'an-api-token' + +API_KEY = 'an-api-key' +EMAIL = 'exam...@example.com' + + +class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest): + + def setUp(self): + from certbot_dns_cloudflare._internal.dns_cloudflare import Authenticator + + super().setUp() + + path = os.path.join(self.tempdir, 'file.ini') + dns_test_common.write({"cloudflare_email": EMAIL, "cloudflare_api_key": API_KEY}, path) + + self.config = mock.MagicMock(cloudflare_credentials=path, + cloudflare_propagation_seconds=0) # don't wait during tests + + self.auth = Authenticator(self.config, "cloudflare") + + self.mock_client = mock.MagicMock() + # _get_cloudflare_client | pylint: disable=protected-access + # workaround for wont-fix https://github.com/python/mypy/issues/2427 that works with + # both strict and non-strict mypy + setattr(self.auth, '_get_cloudflare_client', mock.MagicMock(return_value=self.mock_client)) + + @test_util.patch_display_util() + def test_perform(self, unused_mock_get_utility): + self.auth.perform([self.achall]) + + expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] + assert expected == self.mock_client.mock_calls + + def test_cleanup(self): + # _attempt_cleanup | pylint: disable=protected-access + self.auth._attempt_cleanup = True + self.auth.cleanup([self.achall]) + + expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)] + assert expected == self.mock_client.mock_calls + + @test_util.patch_display_util() + def test_api_token(self, unused_mock_get_utility): + dns_test_common.write({"cloudflare_api_token": API_TOKEN}, + self.config.cloudflare_credentials) + self.auth.perform([self.achall]) + + expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] + assert expected == self.mock_client.mock_calls + + def test_no_creds(self): + dns_test_common.write({}, self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + def test_missing_email_or_key(self): + dns_test_common.write({"cloudflare_api_key": API_KEY}, self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + dns_test_common.write({"cloudflare_email": EMAIL}, self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + def test_email_or_key_with_token(self): + dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_email": EMAIL}, + self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_api_key": API_KEY}, + self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + dns_test_common.write({"cloudflare_api_token": API_TOKEN, "cloudflare_email": EMAIL, + "cloudflare_api_key": API_KEY}, self.config.cloudflare_credentials) + with pytest.raises(errors.PluginError): + self.auth.perform([self.achall]) + + +class CloudflareClientTest(unittest.TestCase): + record_name = "foo" + record_content = "bar" + record_ttl = 42 + zone_id = 1 + record_id = 2 + + def setUp(self): + from certbot_dns_cloudflare._internal.dns_cloudflare import _CloudflareClient + + self.cloudflare_client = _CloudflareClient(EMAIL, API_KEY) + + self.cf = mock.MagicMock() + self.cloudflare_client.cf = self.cf + + def test_add_txt_record(self): + self.cf.zones.get.return_value = [{'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'] + + assert 'TXT' == post_data['type'] + assert self.record_name == post_data['name'] + assert self.record_content == post_data['content'] + 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.dns_records.post.side_effect = CloudFlare.exceptions.CloudFlareAPIError(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 + + 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 = [] + + 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, '', '') + 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, '', '') + 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, '', '') + 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', '') + 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.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)] + + assert expected == self.cf.mock_calls + + get_data = self.cf.zones.dns_records.get.call_args[1]['params'] + + 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.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.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)] + + 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.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)] + + 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.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)] + + assert expected == self.cf.mock_calls + + def test_del_txt_record_no_zone(self): + self.cf.zones.get.return_value = [{'id': None}] + + self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content) + expected = [mock.call.zones.get(params=mock.ANY)] + + assert expected == self.cf.mock_calls + + +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_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/PKG-INFO new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/PKG-INFO --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/PKG-INFO 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/PKG-INFO 2025-06-12 20:08:38.000000000 +0200 @@ -0,0 +1,46 @@ +Metadata-Version: 2.4 +Name: certbot-dns-cloudflare +Version: 4.1.1 +Summary: Cloudflare 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 +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: cloudflare<2.20,>=2.19 +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: 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_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/SOURCES.txt new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/SOURCES.txt --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/SOURCES.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/SOURCES.txt 2025-06-12 20:08:38.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_cloudflare/__init__.py +src/certbot_dns_cloudflare/py.typed +src/certbot_dns_cloudflare.egg-info/PKG-INFO +src/certbot_dns_cloudflare.egg-info/SOURCES.txt +src/certbot_dns_cloudflare.egg-info/dependency_links.txt +src/certbot_dns_cloudflare.egg-info/entry_points.txt +src/certbot_dns_cloudflare.egg-info/requires.txt +src/certbot_dns_cloudflare.egg-info/top_level.txt +src/certbot_dns_cloudflare/_internal/__init__.py +src/certbot_dns_cloudflare/_internal/dns_cloudflare.py +src/certbot_dns_cloudflare/_internal/tests/__init__.py +src/certbot_dns_cloudflare/_internal/tests/dns_cloudflare_test.py \ No newline at end of file diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/dependency_links.txt new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/dependency_links.txt --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/dependency_links.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/dependency_links.txt 2025-06-12 20:08:38.000000000 +0200 @@ -0,0 +1 @@ + diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/entry_points.txt new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/entry_points.txt --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/entry_points.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/entry_points.txt 2025-06-12 20:08:38.000000000 +0200 @@ -0,0 +1,2 @@ +[certbot.plugins] +dns-cloudflare = certbot_dns_cloudflare._internal.dns_cloudflare:Authenticator diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/requires.txt new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/requires.txt --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/requires.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/requires.txt 2025-06-12 20:08:38.000000000 +0200 @@ -0,0 +1,10 @@ +cloudflare<2.20,>=2.19 +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_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/top_level.txt new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/top_level.txt --- old/certbot_dns_cloudflare-4.0.0/src/certbot_dns_cloudflare.egg-info/top_level.txt 1970-01-01 01:00:00.000000000 +0100 +++ new/certbot_dns_cloudflare-4.1.1/src/certbot_dns_cloudflare.egg-info/top_level.txt 2025-06-12 20:08:38.000000000 +0200 @@ -0,0 +1 @@ +certbot_dns_cloudflare