certapi 0.4.3__tar.gz → 0.4.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. certapi-0.4.5/PKG-INFO +63 -0
  2. certapi-0.4.5/README.md +50 -0
  3. {certapi-0.4.3 → certapi-0.4.5}/setup.py +1 -1
  4. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/__init__.py +3 -3
  5. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/Acme.py +26 -25
  6. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/AcmeError.py +3 -9
  7. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/Order.py +1 -3
  8. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/http.py +2 -11
  9. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/ChallengeSolver.py +4 -0
  10. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +7 -1
  11. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +1 -1
  12. certapi-0.4.5/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +97 -0
  13. certapi-0.4.5/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +134 -0
  14. certapi-0.4.5/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +95 -0
  15. certapi-0.4.5/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +91 -0
  16. certapi-0.4.5/src/certapi/errors.py +49 -0
  17. certapi-0.4.5/src/certapi/http/HttpClientBase.py +96 -0
  18. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/issuers/AcmeCertIssuer.py +14 -12
  19. certapi-0.4.5/src/certapi/keystore/FileSystemKeyStore.py +102 -0
  20. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/FileSystemKeystore.py +1 -1
  21. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/__init__.py +1 -1
  22. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/manager/acme_cert_manager.py +2 -1
  23. certapi-0.4.5/src/certapi.egg-info/PKG-INFO +63 -0
  24. {certapi-0.4.3 → certapi-0.4.5}/src/certapi.egg-info/SOURCES.txt +3 -1
  25. certapi-0.4.3/PKG-INFO +0 -45
  26. certapi-0.4.3/README.md +0 -32
  27. certapi-0.4.3/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -62
  28. certapi-0.4.3/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -139
  29. certapi-0.4.3/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -61
  30. certapi-0.4.3/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -85
  31. certapi-0.4.3/src/certapi/http/client.py +0 -0
  32. certapi-0.4.3/src/certapi.egg-info/PKG-INFO +0 -45
  33. {certapi-0.4.3 → certapi-0.4.5}/MANIFEST.in +0 -0
  34. {certapi-0.4.3 → certapi-0.4.5}/pyproject.toml +0 -0
  35. {certapi-0.4.3 → certapi-0.4.5}/setup.cfg +0 -0
  36. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/Challenge.py +0 -0
  37. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/acme/__init__.py +0 -0
  38. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/__init__.py +0 -0
  39. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  40. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  41. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  42. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/client/__init__.py +0 -0
  43. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/client/cert_manager_client.py +0 -0
  44. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/crypto/__init__.py +0 -0
  45. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/crypto/crypto.py +0 -0
  46. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/crypto/crypto_classes.py +0 -0
  47. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/http/__init__.py +0 -0
  48. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/http/types.py +0 -0
  49. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  50. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/issuers/__init__.py +0 -0
  51. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/issuers/abstract_certissuer.py +0 -0
  52. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/KeyStore.py +0 -0
  53. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  54. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  55. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  56. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/manager/__init__.py +0 -0
  57. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/server/__init__.py +0 -0
  58. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/server/api.py +0 -0
  59. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/server/cert_api.py +0 -0
  60. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/server/key_api.py +0 -0
  61. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/util.py +0 -0
  62. {certapi-0.4.3 → certapi-0.4.5}/src/certapi/utils.py +0 -0
  63. {certapi-0.4.3 → certapi-0.4.5}/src/certapi.egg-info/dependency_links.txt +0 -0
  64. {certapi-0.4.3 → certapi-0.4.5}/src/certapi.egg-info/requires.txt +0 -0
  65. {certapi-0.4.3 → certapi-0.4.5}/src/certapi.egg-info/top_level.txt +0 -0
certapi-0.4.5/PKG-INFO ADDED
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.1
2
+ Name: certapi
3
+ Version: 0.4.5
4
+ Summary: Python Package for managing keys, request SSL certificates from ACME.
5
+ Home-page: https://github.com/mesudip/certapi
6
+ Author: Sudip Bhattarai
7
+ Author-email: sudipbhattarai100@gmail.com
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: License :: OSI Approved :: MIT License
10
+ Classifier: Operating System :: OS Independent
11
+ Requires-Python: >=3.6
12
+ Description-Content-Type: text/markdown
13
+
14
+ # CertApi
15
+
16
+ CertApi is a Python package for requesting SSL certificates from ACME.
17
+ This is to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
18
+
19
+ > ⚠️ Warning: This project is in beta. Please stay tuned for the LTS `v1.0.0` release.
20
+
21
+ ## Installation
22
+
23
+ You can install CertApi using pip
24
+
25
+ ```bash
26
+ pip install certapi
27
+ ```
28
+
29
+ ## Use Low level API
30
+
31
+ ```python
32
+ import json
33
+ import os
34
+ from certapi import FileSystemKeyStore, CertApiException, CloudflareChallengeSolver, AcmeCertManager, AcmeCertIssuer
35
+
36
+
37
+ # Initialize the key store and create acme account key
38
+ key_store = FileSystemKeyStore("data")
39
+
40
+ acme_key = key_store._get_or_generate_key("acme_account", "rsa")[0]
41
+
42
+ # Initialize the Cloudflare challenge solver
43
+ # The API key is read from the CLOUDFLARE_API_KEY environment variable, or you can set it below.
44
+ challenge_solver = CloudflareChallengeSolver(api_key=None)
45
+
46
+ ## initialize cert issuer
47
+ cert_issuer = AcmeCertIssuer(acme_key, challenge_solver)
48
+
49
+ # Setup ACME account.
50
+ cert_issuer.setup()
51
+
52
+ try:
53
+ # Obtain a certificate for your domain
54
+ (key, cert) = cert_issuer.generate_key_and_cert_for_domain("your-domain.com")
55
+
56
+ print("------ Private Key -----")
57
+ print(key.to_pem())
58
+ print("------- Certificate ------")
59
+ print(cert)
60
+ except CertApiException as e:
61
+ print(f"An error occurred:", json.dumps(e.json_obj(), indent=2))
62
+
63
+ ```
@@ -0,0 +1,50 @@
1
+ # CertApi
2
+
3
+ CertApi is a Python package for requesting SSL certificates from ACME.
4
+ This is to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
5
+
6
+ > ⚠️ Warning: This project is in beta. Please stay tuned for the LTS `v1.0.0` release.
7
+
8
+ ## Installation
9
+
10
+ You can install CertApi using pip
11
+
12
+ ```bash
13
+ pip install certapi
14
+ ```
15
+
16
+ ## Use Low level API
17
+
18
+ ```python
19
+ import json
20
+ import os
21
+ from certapi import FileSystemKeyStore, CertApiException, CloudflareChallengeSolver, AcmeCertManager, AcmeCertIssuer
22
+
23
+
24
+ # Initialize the key store and create acme account key
25
+ key_store = FileSystemKeyStore("data")
26
+
27
+ acme_key = key_store._get_or_generate_key("acme_account", "rsa")[0]
28
+
29
+ # Initialize the Cloudflare challenge solver
30
+ # The API key is read from the CLOUDFLARE_API_KEY environment variable, or you can set it below.
31
+ challenge_solver = CloudflareChallengeSolver(api_key=None)
32
+
33
+ ## initialize cert issuer
34
+ cert_issuer = AcmeCertIssuer(acme_key, challenge_solver)
35
+
36
+ # Setup ACME account.
37
+ cert_issuer.setup()
38
+
39
+ try:
40
+ # Obtain a certificate for your domain
41
+ (key, cert) = cert_issuer.generate_key_and_cert_for_domain("your-domain.com")
42
+
43
+ print("------ Private Key -----")
44
+ print(key.to_pem())
45
+ print("------- Certificate ------")
46
+ print(cert)
47
+ except CertApiException as e:
48
+ print(f"An error occurred:", json.dumps(e.json_obj(), indent=2))
49
+
50
+ ```
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.4.3",
5
+ version="0.4.5",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -1,7 +1,7 @@
1
1
  from .acme.Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
2
2
  from .manager.acme_cert_manager import AcmeCertManager
3
3
  from .http.types import CertificateResponse, IssuedCert
4
-
4
+ from .errors import CertApiException
5
5
  from .crypto import (
6
6
  Certificate,
7
7
  CertificateSigningRequest,
@@ -12,7 +12,7 @@ from .crypto import (
12
12
  Ed25519PrivateKey,
13
13
  EllipticCurvePrivateKey,
14
14
  )
15
- from .keystore import FileSystemKeystore, SqliteKeyStore, PostgresKeyStore, KeyStore
15
+ from .keystore import FileSystemKeyStore, SqliteKeyStore, PostgresKeyStore, KeyStore
16
16
  from .challenge_solver import (
17
17
  ChallengeSolver,
18
18
  InMemoryChallengeSolver,
@@ -20,4 +20,4 @@ from .challenge_solver import (
20
20
  CloudflareChallengeSolver,
21
21
  DigitalOceanChallengeSolver,
22
22
  )
23
- from .issuers import SelfCertIssuer
23
+ from .issuers import CertIssuer, SelfCertIssuer, AcmeCertIssuer
@@ -40,6 +40,32 @@ class Acme:
40
40
  if self.directory is None:
41
41
  self.directory = get("Fetch Acme Directory", self.acme_url).json()
42
42
 
43
+ def register(self):
44
+ """
45
+ Register an ACME Account. Re-registering is idempotent.
46
+ """
47
+ response = self._directory_req("newAccount", {"termsOfServiceAgreed": True})
48
+ if "location" in response.headers:
49
+ self.key_id = response.headers["location"]
50
+ return response
51
+
52
+ def create_order(self, domains: List[str]) -> "Order":
53
+ """
54
+ Makes newOrder request, fetches all challenge details, and returns an Order object.
55
+ You cann then Use functions in Order and Challenge
56
+ """
57
+ payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
58
+ res = self._directory_req("newOrder", payload)
59
+ res_json = res.json()
60
+ challenges = []
61
+ identifiers = res_json["identifiers"]
62
+
63
+ # auth_url means the url for challenge. In case of ACME challenge and authorization means same.
64
+ for auth_url, identifier in zip(res_json["authorizations"], identifiers):
65
+ auth_res = get(f"Get Challenge [{identifier['value']}]", auth_url)
66
+ challenges.append(Challenge(auth_url, auth_res.json(), self))
67
+ return Order(res.headers["location"], res_json, challenges, self)
68
+
43
69
  def _directory(self, key):
44
70
  if not self.directory:
45
71
  self.directory = get("Fetch Acme Directory", self.acme_url).json()
@@ -100,28 +126,3 @@ class Acme:
100
126
  raise e
101
127
 
102
128
  return self.record_nonce(response)
103
-
104
- def register(self):
105
- response = self._directory_req("newAccount", {"termsOfServiceAgreed": True})
106
- if "location" in response.headers:
107
- self.key_id = response.headers["location"]
108
- return response
109
-
110
- def create_authorized_order(self, domains: List[str]) -> "Order":
111
- payload = {"identifiers": [{"type": "dns", "value": d} for d in domains]}
112
- res = self._directory_req("newOrder", payload)
113
- res_json = res.json()
114
- challenges = []
115
- for auth_url in res_json["authorizations"]:
116
- auth_res = self._signed_req(auth_url, None, step="Authorize Created Order")
117
- challenges.append(Challenge(auth_url, auth_res.json(), self))
118
- return Order(res.headers["location"], res_json, challenges, self)
119
-
120
- def authorize_order(self, auth_url):
121
- return self._signed_req(auth_url, None, step="Authorize Created Order")
122
-
123
- def verify_challenge(self, challenge_url):
124
- return self._signed_req(challenge_url, {}, step="Verify Challenge")
125
-
126
- def finalize_order(self):
127
- pass
@@ -1,18 +1,13 @@
1
1
  import re
2
2
  import requests
3
+ from certapi.errors import CertApiException
3
4
 
4
5
 
5
- class AcmeError(Exception):
6
+ class AcmeError(CertApiException):
6
7
  def __init__(self, message, detail, step):
7
- super().__init__(step, message)
8
- self.message: str = message
9
- self.step: str = step
10
- self.detail: dict = detail
8
+ super().__init__(message, detail, step)
11
9
  self.can_retry = False
12
10
 
13
- def json_obj(self) -> dict:
14
- return {"message": self.message, "step": self.step, "detail": self.detail}
15
-
16
11
 
17
12
  class AcmeNetworkError(AcmeError, requests.RequestException):
18
13
  """
@@ -23,7 +18,6 @@ class AcmeNetworkError(AcmeError, requests.RequestException):
23
18
  # Initialize both parent classes
24
19
  requests.RequestException.__init__(self, request=request)
25
20
  AcmeError.__init__(self, message, detail, step)
26
- requests.RequestException.__init__(self, request=request) # Pass message to RequestException
27
21
  self.can_retry = True
28
22
 
29
23
 
@@ -31,9 +31,7 @@ class Order:
31
31
  elif self.status != "valid":
32
32
  raise ValueError("Order not in 'valid' state! Complete challenge and call finalize()")
33
33
 
34
- certificate_res = self._acme._signed_req(
35
- self._data["certificate"], step="Get Certificate from Successful Order"
36
- )
34
+ certificate_res = self._acme._signed_req(self._data["certificate"], step="Download Certificate")
37
35
  return certificate_res.text
38
36
 
39
37
  def finalize(self, csr: CertificateSigningRequest):
@@ -7,15 +7,6 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
7
7
  try:
8
8
  res = requests.request(method, url, json=json, headers=headers, timeout=15)
9
9
  print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + step)
10
- except requests.HTTPError as e:
11
- status = res.status_code if res else None
12
- status = status if status else (e.response.status_code if e.response else None)
13
- if status:
14
- print("Request [" + str(status) + "] : " + method + " " + url + " step=" + step)
15
- else:
16
- print("Request : " + method + " " + url + " step=" + step)
17
-
18
- raise e
19
10
  except requests.RequestException as e:
20
11
  print("Request : " + str(method) + " " + str(url) + " step=" + str(step))
21
12
  raise AcmeNetworkError(
@@ -51,5 +42,5 @@ def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.R
51
42
  return request("POST", step, url, json=json, headers=headers, throw=throw)
52
43
 
53
44
 
54
- def get(step: str, url) -> requests.Response:
55
- return request("GET", step, url)
45
+ def get(step: str, url, headers=None, throw=True) -> requests.Response:
46
+ return request("GET", step, url, headers=headers, throw=throw)
@@ -29,6 +29,10 @@ class ChallengeSolver(ABC):
29
29
  def supported_challenge_type(self) -> Literal["http-01", "dns-01", "tls-alpn-01"]:
30
30
  pass
31
31
 
32
+ @abstractmethod
33
+ def cleanup_old_challenges(self):
34
+ pass
35
+
32
36
  def __iter__(self):
33
37
  raise NotImplementedError("Must implement `__iter__` method.")
34
38
 
@@ -5,7 +5,7 @@ import os
5
5
 
6
6
  class FilesystemChallengeSolver(ChallengeSolver):
7
7
  """
8
- Filesystem implementation of the ChallengeStore.
8
+ Filesystem implementation of the ChallengeSolver.
9
9
  This should never be used, but extended for your webserver e.g. nginx, apache etc.
10
10
  """
11
11
 
@@ -36,6 +36,12 @@ class FilesystemChallengeSolver(ChallengeSolver):
36
36
  def supported_challenge_type(self) -> Literal["http-01"]:
37
37
  return "http-01"
38
38
 
39
+ def cleanup_old_challenges(self):
40
+ for filename in os.listdir(self.directory):
41
+ file_path = os.path.join(self.directory, filename)
42
+ if os.path.isfile(file_path):
43
+ os.remove(file_path)
44
+
39
45
  def __iter__(self):
40
46
  return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
41
47
 
@@ -4,7 +4,7 @@ from .ChallengeSolver import ChallengeSolver
4
4
 
5
5
  class InMemoryChallengeSolver(ChallengeSolver):
6
6
  """
7
- In-memory implementation of the ChallengeStore.
7
+ In-memory implementation of the ChallengeSolver.
8
8
  """
9
9
 
10
10
  def __init__(self):
@@ -0,0 +1,97 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from typing import Literal
4
+ from concurrent.futures import ThreadPoolExecutor
5
+ from ...ChallengeSolver import ChallengeSolver
6
+ from .cloudflare_client import Cloudflare
7
+ from certapi.errors import CertApiException, DomainNotOwnedException, NetworkError
8
+
9
+
10
+ class CloudflareChallengeSolver(ChallengeSolver):
11
+ def __init__(self, api_key: str = None):
12
+ self.cloudflare = Cloudflare(api_key)
13
+ self.challenges_map = {}
14
+
15
+ def supported_challenge_type(self) -> Literal["dns-01"]:
16
+ return "dns-01"
17
+
18
+ def supports_domain(self, domain: str) -> bool:
19
+ """
20
+ Checks if the Cloudflare account has access to the given domain (or its base domain)
21
+ as a registered zone.
22
+ """
23
+ try:
24
+ self.cloudflare.determine_registered_domain(domain)
25
+ return True
26
+ except DomainNotOwnedException as e:
27
+ return False
28
+
29
+ def save_challenge(self, key: str, value: str, domain=None):
30
+
31
+ if domain is None:
32
+ domain = key.replace("_acme-challenge.", "")
33
+
34
+ record_id = self.cloudflare.create_record(name=key, data=value, domain=domain)
35
+ self.challenges_map[key] = record_id
36
+ print(f"CloudflareChallengeSolver[{domain}]: Added Record {key}")
37
+
38
+ def get_challenge(self, key: str, domain: str) -> str:
39
+ if domain is None:
40
+ domain = key.replace("_acme-challenge.", "")
41
+ records = self.cloudflare.list_txt_records(domain, name_filter=key)
42
+ for record in records:
43
+ if record["name"] == key:
44
+ return record["content"]
45
+ return None
46
+
47
+ def delete_challenge(self, key: str, domain: str):
48
+ if key not in self.challenges_map:
49
+ print(f"CloudflareChallengeSolver.delete: Not found Skipping key={key} domain={domain}")
50
+ return
51
+ if domain is None:
52
+ domain = key.replace("_acme-challenge.", "")
53
+
54
+ record_id = self.challenges_map[key]
55
+ self.cloudflare.delete_record(record=record_id, domain=domain)
56
+ del self.challenges_map[key]
57
+ print(f"CloudflareChallengeSolver: Deleted challenge for {key} with record ID {record_id}")
58
+
59
+ def _cleanup_zone_challenges(self, zone):
60
+ zone_name = zone["name"]
61
+ try:
62
+ # List all TXT records in the zone
63
+ records = self.cloudflare.list_txt_records(zone_name)
64
+ for record in records:
65
+ if record["type"] == "TXT" and record["name"].startswith("_acme-challenge"):
66
+ print(f"Cloudflare [{zone_name}\t\t]: Deleting dangling challenge record {record['name']}")
67
+ try:
68
+ self.cloudflare.delete_record(record["id"], zone_name)
69
+ except CertApiException as e:
70
+ print(
71
+ f"CloudflareChallengeSolver: Warning - Failed to delete record {record['name']} in zone {zone_name}: {e.message} - {e.detail}"
72
+ )
73
+ except Exception as e:
74
+ print(
75
+ f"CloudflareChallengeSolver: Warning - An unexpected error occurred while deleting record {record['name']} in zone {zone_name}: {e}"
76
+ )
77
+ except CertApiException as e:
78
+ print(f"CloudflareChallengeSolver: Error listing challenges in zone {zone_name}: {e.message} - {e.detail}")
79
+ except Exception as e:
80
+ print(
81
+ f"CloudflareChallengeSolver: An unexpected error occurred while listing challenges in zone {zone_name}: {e}"
82
+ )
83
+
84
+ def cleanup_old_challenges(self):
85
+ zones = self.cloudflare._get_zones()
86
+ print(f"Cloudflare: Scanning dangling acme challenges and deleting them...")
87
+ with ThreadPoolExecutor(max_workers=len(zones)) as executor:
88
+ executor.map(self._cleanup_zone_challenges, zones)
89
+
90
+ def __iter__(self):
91
+ # This is tricky as we can't easily iterate all challenges across all domains
92
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
93
+ return iter(self.challenges_map)
94
+
95
+ def __len__(self):
96
+ # Similar to __iter__, this will count challenges managed by this store instance.
97
+ return len(self.challenges_map)
@@ -0,0 +1,134 @@
1
+ import json
2
+ import time
3
+ from os import getenv
4
+ from certapi.errors import CertApiException, HttpError, DomainNotOwnedException
5
+ from certapi.http.HttpClientBase import HttpClientBase
6
+ from urllib.parse import urlencode
7
+ from urllib.request import Request
8
+
9
+
10
+ class Cloudflare(HttpClientBase):
11
+ name = "cloudflare"
12
+
13
+ def __init__(self, api_key: str = None):
14
+ if not api_key:
15
+ api_key = getenv("CLOUDFLARE_API_KEY")
16
+ if not api_key:
17
+ raise CertApiException("CLOUDFLARE_API_KEY not found in environment", step="Cloudflare.__init__")
18
+
19
+ headers = {"Content-Type": "application/json", "Authorization": f"Bearer {api_key}"}
20
+ super().__init__("https://api.cloudflare.com/client/v4", headers, auto_retry=True)
21
+ self._zones_cache = None
22
+ self._zones_cache_time = 0 # Unix timestamp of last cache update
23
+
24
+ def _get_zones(self):
25
+ """Fetch and cache Cloudflare zones"""
26
+ # Cache for 1 day (86400 seconds)
27
+ if self._zones_cache and (time.time() - self._zones_cache_time) < 86400:
28
+ return self._zones_cache
29
+
30
+ api_url = f"{self.api_base_url}/zones?per_page=50"
31
+ response = self._get(api_url, step="Cloudflare Get Zones")
32
+
33
+ zones = response.json()["result"]
34
+ self._zones_cache = zones
35
+ self._zones_cache_time = time.time()
36
+ return zones
37
+
38
+ def _get_zone_id_(self, domain):
39
+ """Determine Cloudflare Zone ID for a given domain"""
40
+ zones = self._get_zones()
41
+ for zone in zones:
42
+ if zone["name"] == domain:
43
+ return zone["id"]
44
+
45
+ def _get_zone_id(self, domain):
46
+ """Determine Cloudflare Zone ID for a given domain"""
47
+ zone_id = self._get_zone_id_(domain)
48
+ if zone_id is None:
49
+ raise DomainNotOwnedException(
50
+ "No Cloudflare zone found for domain", detail={"domain": domain}, step="Cloudflare Get Zone ID"
51
+ )
52
+
53
+ def _determine_zone_id(self, domain: str) -> str:
54
+ """
55
+ Determine the registered domain in Cloudflare and return its Zone ID.
56
+ This method encapsulates the logic of finding the correct zone for a given domain.
57
+ """
58
+ registered_domain = self.determine_registered_domain(domain)
59
+ zone_id = self._get_zone_id_(registered_domain)
60
+ return zone_id
61
+
62
+ def determine_registered_domain(self, domain: str) -> str:
63
+ """
64
+ Determine the registered domain in Cloudflare for a given (sub)domain.
65
+ This method iterates through parts of the domain to find a matching Cloudflare zone.
66
+ """
67
+ parts = domain.split(".")
68
+ err = None
69
+ for i in range(len(parts)):
70
+ potential_domain = ".".join(parts[i:])
71
+ if self._get_zone_id_(potential_domain) is not None:
72
+ return potential_domain
73
+
74
+ raise DomainNotOwnedException(
75
+ "No Cloudflare zone found for " + domain, detail={"domain": domain}, step="Cloudflare Get Zone ID"
76
+ )
77
+
78
+ def list_txt_records(self, domain: str, name_filter: str = None) -> list:
79
+ """
80
+ Lists TXT records for a given domain, optionally filtered by name.
81
+ Returns a list of dictionaries, each representing a TXT record.
82
+ """
83
+ zone_id = self._determine_zone_id(domain)
84
+ params = {"type": "TXT"}
85
+ if name_filter:
86
+ params["name"] = name_filter
87
+
88
+ api_url = f"{self.api_base_url}/zones/{zone_id}/dns_records?{urlencode(params)}"
89
+
90
+ response = self._get(api_url, step="Cloudflare List TXT Records")
91
+
92
+ result = response.json()
93
+ if not result.get("success"):
94
+ raise CertApiException(
95
+ "Unknown error listing TXT records",
96
+ detail=result.get("errors", "Unknown error listing TXT records"),
97
+ step="Cloudflare List TXT Records",
98
+ )
99
+
100
+ return result["result"]
101
+
102
+ def create_record(self, name, data, domain):
103
+ """
104
+ Create DNS record
105
+ Params:
106
+ name, string, record name (e.g., _acme-challenge.example.com)
107
+ data, string, record data (e.g., ACME challenge token)
108
+ domain, string, dns domain (e.g., example.com) - This will be used to determine the registered zone.
109
+ Return:
110
+ record_id, string, created record id
111
+ """
112
+ zone_id = self._determine_zone_id(domain)
113
+ api_url = "{0}/zones/{1}/dns_records".format(self.api_base_url, zone_id)
114
+ request_data = {
115
+ "type": "TXT",
116
+ "name": name,
117
+ "content": data,
118
+ "ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
119
+ "proxied": False,
120
+ }
121
+ response = self._post(api_url, json_data=request_data, step="Cloudflare Create Record")
122
+ result = response.json()
123
+ return result["result"]["id"]
124
+
125
+ def delete_record(self, record, domain):
126
+ """
127
+ Delete DNS record
128
+ Params:
129
+ record, string, record id number
130
+ domain, string, dns domain - This will be used to determine the registered zone.
131
+ """
132
+ zone_id = self._determine_zone_id(domain)
133
+ api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api_base_url, zone_id, record)
134
+ self._delete(api_url, step="Cloudflare Delete Record")
@@ -0,0 +1,95 @@
1
+ import os
2
+ import time
3
+ from collections.abc import MutableMapping
4
+ from typing import Literal, Callable, Any
5
+ from ...ChallengeSolver import ChallengeSolver
6
+ from .digitalocean_client import DigitalOcean
7
+ from certapi.errors import CertApiException, NetworkError
8
+
9
+
10
+ class DigitalOceanChallengeSolver(ChallengeSolver):
11
+ def __init__(self, api_key: str = None):
12
+ self.digitalocean = DigitalOcean(api_key)
13
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
14
+
15
+ def supported_challenge_type(self) -> Literal["dns-01"]:
16
+ return "dns-01"
17
+
18
+ def supports_domain(self, domain: str) -> bool:
19
+ """
20
+ Checks if the DigitalOcean account has access to the given domain (or its base domain)
21
+ as a registered zone.
22
+ """
23
+ try:
24
+ self.digitalocean.determine_registered_domain(domain)
25
+ return True
26
+ except CertApiException as e:
27
+ # Log the exception or handle it as needed, but return False as it doesn't support the domain
28
+ print(f"DigitalOceanChallengeSolver.supports_domain: {e.message} - {e.detail}")
29
+ return False
30
+
31
+ def save_challenge(self, key: str, value: str, domain=None):
32
+ # key example: _acme-challenge.sub.example.com
33
+ # value example: ACME_CHALLENGE_TOKEN
34
+ base_domain = self.digitalocean.determine_registered_domain(domain)
35
+
36
+ record_id = self.digitalocean.create_record(name=key, data=value, domain=base_domain)
37
+ self.challenges_map[key] = record_id
38
+ print(f"DigitalOceanChallengeSolver: Saved challenge for {key} with record ID {record_id}")
39
+
40
+ def get_challenge(self, key: str, domain: str) -> str:
41
+ base_domain = self.digitalocean.determine_registered_domain(domain)
42
+ records = self.digitalocean.list_txt_records(base_domain, name_filter=key)
43
+ for record in records:
44
+ if record["name"] == key:
45
+ return record["data"] # DigitalOcean API returns 'data' for TXT record content
46
+ return None # Return None if not found, as per ChallengeSolver's __getitem__ behavior
47
+
48
+ def delete_challenge(self, key: str, domain: str):
49
+ if key not in self.challenges_map:
50
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
51
+
52
+ record_id = self.challenges_map[key]
53
+ base_domain = self.digitalocean.determine_registered_domain(domain)
54
+ self.digitalocean.delete_record(record=record_id, domain=base_domain)
55
+ del self.challenges_map[key]
56
+ print(f"DigitalOceanChallengeSolver: Deleted challenge for {key} with record ID {record_id}")
57
+
58
+ def cleanup_old_challenges(self):
59
+ domains = self.digitalocean._get_domains()
60
+ for domain_obj in domains:
61
+ domain_name = domain_obj["name"]
62
+ try:
63
+ records = self.digitalocean.list_txt_records(domain_name)
64
+ for record in records:
65
+ if record["type"] == "TXT" and record["name"].startswith("_acme-challenge"):
66
+ print(
67
+ f"DigitalOceanChallengeSolver: Deleting old challenge record {record['name']} in domain {domain_name}"
68
+ )
69
+ try:
70
+ self.digitalocean.delete_record(record["id"], domain_name)
71
+ except CertApiException as e:
72
+ print(
73
+ f"DigitalOceanChallengeSolver: Warning - Failed to delete record {record['name']} in domain {domain_name}: {e.message} - {e.detail}"
74
+ )
75
+ except Exception as e:
76
+ print(
77
+ f"DigitalOceanChallengeSolver: Warning - An unexpected error occurred while deleting record {record['name']} in domain {domain_name}: {e}"
78
+ )
79
+ except CertApiException as e:
80
+ print(
81
+ f"DigitalOceanChallengeSolver: Error listing challenges in domain {domain_name}: {e.message} - {e.detail}"
82
+ )
83
+ except Exception as e:
84
+ print(
85
+ f"DigitalOceanChallengeSolver: An unexpected error occurred while listing challenges in domain {domain_name}: {e}"
86
+ )
87
+
88
+ def __iter__(self):
89
+ # This is tricky as we can't easily iterate all challenges across all domains
90
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
91
+ return iter(self.challenges_map)
92
+
93
+ def __len__(self):
94
+ # Similar to __iter__, this will count challenges managed by this store instance.
95
+ return len(self.challenges_map)