certapi 0.4.2__tar.gz → 0.4.4__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 (60) hide show
  1. {certapi-0.4.2 → certapi-0.4.4}/PKG-INFO +4 -4
  2. {certapi-0.4.2 → certapi-0.4.4}/README.md +3 -3
  3. {certapi-0.4.2 → certapi-0.4.4}/setup.py +1 -1
  4. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/__init__.py +8 -8
  5. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Acme.py +9 -6
  6. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/AcmeError.py +6 -10
  7. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Challenge.py +8 -6
  8. certapi-0.4.2/src/certapi/challenge_store/ChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/ChallengeSolver.py +9 -5
  9. certapi-0.4.2/src/certapi/challenge_store/FileSystemChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/FileSystemChallengeSolver.py +15 -4
  10. certapi-0.4.2/src/certapi/challenge_store/InmemoryChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/InmemoryChallengeSolver.py +7 -3
  11. certapi-0.4.4/src/certapi/challenge_solver/__init__.py +24 -0
  12. certapi-0.4.4/src/certapi/challenge_solver/dns/__init__.py +2 -0
  13. certapi-0.4.4/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +92 -0
  14. {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/cloudflare/cloudflare_client.py +43 -13
  15. certapi-0.4.4/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +88 -0
  16. {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/digitalocean/digitalocean_client.py +35 -10
  17. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/crypto_classes.py +36 -8
  18. certapi-0.4.4/src/certapi/errors.py +17 -0
  19. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/AcmeCertIssuer.py +37 -30
  20. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/abstract_certissuer.py +17 -16
  21. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/FileSystemKeystore.py +4 -1
  22. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/KeyStore.py +3 -3
  23. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/manager/acme_cert_manager.py +14 -23
  24. {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/PKG-INFO +4 -4
  25. {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/SOURCES.txt +12 -11
  26. certapi-0.4.2/src/certapi/challenge_store/__init__.py +0 -24
  27. certapi-0.4.2/src/certapi/challenge_store/dns/__init__.py +0 -2
  28. certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +0 -59
  29. certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +0 -57
  30. {certapi-0.4.2 → certapi-0.4.4}/MANIFEST.in +0 -0
  31. {certapi-0.4.2 → certapi-0.4.4}/pyproject.toml +0 -0
  32. {certapi-0.4.2 → certapi-0.4.4}/setup.cfg +0 -0
  33. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Order.py +0 -0
  34. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/__init__.py +0 -0
  35. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/http.py +0 -0
  36. {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/cloudflare/__init__.py +0 -0
  37. {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/digitalocean/__init__.py +0 -0
  38. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/client/__init__.py +0 -0
  39. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/client/cert_manager_client.py +0 -0
  40. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/__init__.py +0 -0
  41. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/crypto.py +0 -0
  42. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/__init__.py +0 -0
  43. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/client.py +0 -0
  44. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/types.py +0 -0
  45. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  46. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/__init__.py +0 -0
  47. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  48. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  49. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  50. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/__init__.py +0 -0
  51. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/manager/__init__.py +0 -0
  52. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/__init__.py +0 -0
  53. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/api.py +0 -0
  54. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/cert_api.py +0 -0
  55. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/key_api.py +0 -0
  56. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/util.py +0 -0
  57. {certapi-0.4.2 → certapi-0.4.4}/src/certapi/utils.py +0 -0
  58. {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/dependency_links.txt +0 -0
  59. {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/requires.txt +0 -0
  60. {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: certapi
3
- Version: 0.4.2
3
+ Version: 0.4.4
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -30,12 +30,12 @@ pip install certapi
30
30
 
31
31
  ```python
32
32
  import json
33
- from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
33
+ from certapi import FileSystemChallengeSolver, FilesystemKeyStore, CertAuthority
34
34
 
35
35
  key_store = FilesystemKeyStore("data")
36
- challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
36
+ challenge_solver = FileSystemChallengeSolver("./acme-challenges") # this should be where your web server hosts the .well-known/
37
37
 
38
- certAuthority = CertAuthority(challenge_store, key_store)
38
+ certAuthority = CertAuthority(challenge_solver, key_store)
39
39
  certAuthority.setup()
40
40
 
41
41
  (response,_) = certAuthority.obtainCert("example.com")
@@ -17,12 +17,12 @@ pip install certapi
17
17
 
18
18
  ```python
19
19
  import json
20
- from certapi import FileSystemChallengeStore, FilesystemKeyStore, CertAuthority
20
+ from certapi import FileSystemChallengeSolver, FilesystemKeyStore, CertAuthority
21
21
 
22
22
  key_store = FilesystemKeyStore("data")
23
- challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
23
+ challenge_solver = FileSystemChallengeSolver("./acme-challenges") # this should be where your web server hosts the .well-known/
24
24
 
25
- certAuthority = CertAuthority(challenge_store, key_store)
25
+ certAuthority = CertAuthority(challenge_solver, key_store)
26
26
  certAuthority.setup()
27
27
 
28
28
  (response,_) = certAuthority.obtainCert("example.com")
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.4.2",
5
+ version="0.4.4",
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,
@@ -13,11 +13,11 @@ from .crypto import (
13
13
  EllipticCurvePrivateKey,
14
14
  )
15
15
  from .keystore import FileSystemKeystore, SqliteKeyStore, PostgresKeyStore, KeyStore
16
- from .challenge_store import (
17
- ChallengeStore,
18
- InMemoryChallengeStore,
19
- FileSystemChallengeStore,
20
- CloudflareChallengeStore,
21
- DigitalOceanChallengeStore,
16
+ from .challenge_solver import (
17
+ ChallengeSolver,
18
+ InMemoryChallengeSolver,
19
+ FilesystemChallengeSolver,
20
+ CloudflareChallengeSolver,
21
+ DigitalOceanChallengeSolver,
22
22
  )
23
- from .issuers import SelfCertIssuer
23
+ from .issuers import CertIssuer,SelfCertIssuer,AcmeCertIssuer
@@ -9,7 +9,8 @@ from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey
9
9
  from cryptography.x509 import CertificateSigningRequest, Certificate
10
10
  from certapi.crypto import crypto
11
11
  import requests
12
- from certapi.crypto import sign, digest_sha256, csr_to_der, jwk, get_algorithm_name, sign_for_jws, Key
12
+ from certapi.crypto import Key
13
+ from certapi.crypto.crypto_classes import ECDSAKey, RSAKey
13
14
  from certapi.util import b64_encode, b64_string
14
15
  from .AcmeError import *
15
16
  from .http import *
@@ -22,11 +23,12 @@ class Acme:
22
23
  URL_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
23
24
  URL_PROD = "https://acme-v02.api.letsencrypt.org/directory"
24
25
 
25
- def __init__(self, account_key: Union[RSAPrivateKey, EllipticCurvePrivateKey | Key], url=URL_STAGING):
26
- self.account_key = account_key.key if isinstance(account_key, Key) else account_key
26
+ def __init__(self, account_key: Key, url=URL_STAGING):
27
+ self.account_key: Key = account_key
27
28
 
28
29
  # json web key format for public key
29
- self.jwk = jwk(self.account_key)
30
+ self.jwk = account_key.jwk()
31
+ self.alg_name = account_key.algorithm_name()
30
32
  print(self.jwk)
31
33
  self.nonce = []
32
34
  self.acme_url = url if url else os.environ.get("ACME_API_URL", self.URL_STAGING)
@@ -72,7 +74,7 @@ class Acme:
72
74
 
73
75
  protected = {
74
76
  "url": url,
75
- "alg": get_algorithm_name(self.account_key),
77
+ "alg": self.alg_name,
76
78
  "nonce": self.get_nonce(step),
77
79
  }
78
80
 
@@ -80,11 +82,12 @@ class Acme:
80
82
  protected["kid"] = self.key_id
81
83
  else:
82
84
  protected["jwk"] = self.jwk
85
+
83
86
  protectedb64 = b64_encode(protected)
84
87
  payload = {
85
88
  "protected": protectedb64.decode("utf-8"),
86
89
  "payload": payload64.decode("utf-8"),
87
- "signature": b64_string(sign_for_jws(self.account_key, b".".join([protectedb64, payload64]))),
90
+ "signature": b64_string(self.account_key.jws_sign(b".".join([protectedb64, payload64]))),
88
91
  }
89
92
  try:
90
93
 
@@ -1,17 +1,13 @@
1
+ import re
1
2
  import requests
3
+ from certapi.errors import CertApiException
2
4
 
3
5
 
4
- class AcmeError(Exception):
6
+ class AcmeError(CertApiException):
5
7
  def __init__(self, message, detail, step):
6
- super().__init__(step, message)
7
- self.message: str = message
8
- self.step: str = step
9
- self.detail: dict = detail
8
+ super().__init__(message, detail, step)
10
9
  self.can_retry = False
11
10
 
12
- def json_obj(self) -> dict:
13
- return {"message": self.message, "step": self.step, "detail": self.detail}
14
-
15
11
 
16
12
  class AcmeNetworkError(AcmeError, requests.RequestException):
17
13
  """
@@ -22,7 +18,6 @@ class AcmeNetworkError(AcmeError, requests.RequestException):
22
18
  # Initialize both parent classes
23
19
  requests.RequestException.__init__(self, request=request)
24
20
  AcmeError.__init__(self, message, detail, step)
25
- requests.RequestException.__init__(self, request=request) # Pass message to RequestException
26
21
  self.can_retry = True
27
22
 
28
23
 
@@ -47,6 +42,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
47
42
  failed_challenge: dict = [x for x in res_json["challenges"] if x["status"] == "invalid"][0]
48
43
  error = failed_challenge["error"]
49
44
  err_detail: str = error.get("detail")
45
+ err_type: str = error.get("type")
50
46
  validation_record: dict = failed_challenge.get("validationRecord")
51
47
  error = {
52
48
  "url": failed_challenge["url"],
@@ -70,7 +66,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
70
66
  "resolved": validation_record["addressesResolved"],
71
67
  "used": validation_record["addressUsed"],
72
68
  }
73
- if error["type"] == "urn:ietf:params:acme:error:connection":
69
+ if err_type == "urn:ietf:params:acme:error:connection":
74
70
  if "Timeout during connect" in err_detail:
75
71
  error["connect"] = {"error": "Timeout"}
76
72
  message = (
@@ -39,7 +39,9 @@ class Challenge:
39
39
 
40
40
  def verify(self, type: Literal["http-01", "dns-01", "tls-alpn-01"] = None) -> bool:
41
41
  if not self.verified:
42
- response = self._acme._signed_req(self.get_challenge(type)["url"], {}, step="Verify Challenge", throw=False)
42
+ response = self._acme._signed_req(
43
+ self.get_challenge(type)["url"], {}, step=f"Verify Challenge [{self.domain}]", throw=False
44
+ )
43
45
  if response.status_code == 200 and response.json()["status"] == "valid":
44
46
  self.verified = True
45
47
  return True
@@ -49,7 +51,7 @@ class Challenge:
49
51
  def self_verify(self) -> Union[bool, requests.Response]:
50
52
  identifier = self._data["identifier"]
51
53
  if identifier["type"] == "dns":
52
- res = get("Self Domain verification", self.url)
54
+ res = get(f"Self Domain verification [{self.domain}]", self.url)
53
55
  if res.status_code == 200 and res.content == self.token.encode():
54
56
  return True
55
57
  else:
@@ -60,13 +62,13 @@ class Challenge:
60
62
  if self.verified:
61
63
  return True
62
64
  else:
63
- res = self._acme._signed_req(self._auth_url, step="Acme Challenge Verification")
65
+ res = self._acme._signed_req(self._auth_url, step=f"Query Challenge Status [{self.domain}]")
64
66
  res_json = res.json()
65
67
  if res_json["status"] == "valid":
66
68
  self.verified = True
67
69
  return True
68
70
  elif res_json["status"] == "invalid":
69
- raise AcmeInvaliOrderError(res, "Acme Challenge Verification")
71
+ raise AcmeInvaliOrderError(res, "Query Challenge Status")
70
72
  else:
71
73
  return False
72
74
 
@@ -81,7 +83,7 @@ class Challenge:
81
83
 
82
84
  ch_types = [x["type"] for x in self._data["challenges"]]
83
85
  raise AcmeError(
84
- f"'{type}' not found in challenges. available:{str(ch_types)}",
86
+ f"'{type}' not found in challenges for {self.domain}, available: {','.join(ch_types)}",
85
87
  {"response": self._data["challenges"]},
86
- "Acme Challenge Verification",
88
+ "Query Challenge Status",
87
89
  )
@@ -1,9 +1,10 @@
1
1
  from abc import ABC, abstractmethod
2
2
  import os
3
3
  from collections.abc import MutableMapping
4
+ from typing import Literal
4
5
 
5
6
 
6
- class ChallengeStore(ABC):
7
+ class ChallengeSolver(ABC):
7
8
  """
8
9
  Abstract base class for a challenge store.
9
10
  """
@@ -24,10 +25,13 @@ class ChallengeStore(ABC):
24
25
  def delete_challenge(self, key: str, domain: str = None):
25
26
  pass
26
27
 
27
- def store_details():
28
- return {
29
- "name": "Abstract Challenge Store",
30
- }
28
+ @abstractmethod
29
+ def supported_challenge_type(self) -> Literal["http-01", "dns-01", "tls-alpn-01"]:
30
+ pass
31
+
32
+ @abstractmethod
33
+ def cleanup_old_challenges(self):
34
+ pass
31
35
 
32
36
  def __iter__(self):
33
37
  raise NotImplementedError("Must implement `__iter__` method.")
@@ -1,10 +1,12 @@
1
- from .ChallengeStore import ChallengeStore
1
+ from typing import Literal
2
+ from .ChallengeSolver import ChallengeSolver
2
3
  import os
3
4
 
4
5
 
5
- class FileSystemChallengeStore(ChallengeStore):
6
+ class FilesystemChallengeSolver(ChallengeSolver):
6
7
  """
7
- Filesystem implementation of the ChallengeStore.
8
+ Filesystem implementation of the ChallengeSolver.
9
+ This should never be used, but extended for your webserver e.g. nginx, apache etc.
8
10
  """
9
11
 
10
12
  def __init__(self, directory: str):
@@ -12,7 +14,7 @@ class FileSystemChallengeStore(ChallengeStore):
12
14
  os.makedirs(self.directory, exist_ok=True)
13
15
 
14
16
  def supports_domain(self, domain: str) -> bool:
15
- return True
17
+ return "*" not in domain
16
18
 
17
19
  def save_challenge(self, key: str, value: str, domain: str = None):
18
20
  file_path = os.path.join(self.directory, key)
@@ -31,6 +33,15 @@ class FileSystemChallengeStore(ChallengeStore):
31
33
  if os.path.exists(file_path):
32
34
  os.remove(file_path)
33
35
 
36
+ def supported_challenge_type(self) -> Literal["http-01"]:
37
+ return "http-01"
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
+
34
45
  def __iter__(self):
35
46
  return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
36
47
 
@@ -1,14 +1,18 @@
1
- from .ChallengeStore import ChallengeStore
1
+ from typing import Literal
2
+ from .ChallengeSolver import ChallengeSolver
2
3
 
3
4
 
4
- class InMemoryChallengeStore(ChallengeStore):
5
+ class InMemoryChallengeSolver(ChallengeSolver):
5
6
  """
6
- In-memory implementation of the ChallengeStore.
7
+ In-memory implementation of the ChallengeSolver.
7
8
  """
8
9
 
9
10
  def __init__(self):
10
11
  self.challenges = {}
11
12
 
13
+ def supported_challenge_type(self) -> Literal["http-01"]:
14
+ return "http-01"
15
+
12
16
  def save_challenge(self, key: str, value: str, domain: str = None):
13
17
  self.challenges[key] = value
14
18
 
@@ -0,0 +1,24 @@
1
+ from .ChallengeSolver import ChallengeSolver
2
+ from .InmemoryChallengeSolver import InMemoryChallengeSolver
3
+ import os
4
+ from .FileSystemChallengeSolver import FilesystemChallengeSolver
5
+ from .dns import CloudflareChallengeSolver, DigitalOceanChallengeSolver
6
+
7
+
8
+ def get_challenge_solver():
9
+ """
10
+ Factory function to determine the type of store based on environment variables.
11
+
12
+ Environment Variables:
13
+ - `challenge_solver_TYPE`: Can be "memory" or "filesystem".
14
+ - `challenge_solver_DIR`: Directory for filesystem-based store. Defaults to "./challenges".
15
+ """
16
+ store_type = os.getenv("challenge_solver_TYPE", "filesystem").lower()
17
+ directory = os.getenv("challenge_solver_DIR", "./challenges")
18
+
19
+ if store_type == "memory":
20
+ return InMemoryChallengeSolver()
21
+ elif store_type == "filesystem":
22
+ return FilesystemChallengeSolver(directory)
23
+ else:
24
+ raise ValueError(f"Unknown challenge_solver_TYPE: {store_type}")
@@ -0,0 +1,2 @@
1
+ from .digitalocean.digitalocean_challenge_solver import DigitalOceanChallengeSolver
2
+ from .cloudflare.cloudflare_challenge_solver import CloudflareChallengeSolver
@@ -0,0 +1,92 @@
1
+ import os
2
+ import time
3
+ from collections.abc import MutableMapping
4
+ from typing import Literal
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from ...ChallengeSolver import ChallengeSolver
7
+ from .cloudflare_client import Cloudflare
8
+ from certapi.errors import CertApiException
9
+
10
+
11
+ class CloudflareChallengeSolver(ChallengeSolver):
12
+ def __init__(self, api_key: str = None):
13
+ self.cloudflare = Cloudflare(api_key)
14
+ self.challenges_map = {}
15
+
16
+ def supported_challenge_type(self) -> Literal["dns-01"]:
17
+ return "dns-01"
18
+
19
+ def supports_domain(self, domain: str) -> bool:
20
+ """
21
+ Checks if the Cloudflare account has access to the given domain (or its base domain)
22
+ as a registered zone.
23
+ """
24
+ try:
25
+ self.cloudflare.determine_registered_domain(domain)
26
+ return True
27
+ except CertApiException as e:
28
+ # Log the exception or handle it as needed, but return False as it doesn't support the domain
29
+ print(f"CloudflareChallengeSolver.supports_domain: {e.message} - {e.detail}")
30
+ return False
31
+ return False
32
+
33
+ def save_challenge(self, key: str, value: str, domain=None):
34
+ # key example: _acme-challenge.sub.example.com
35
+ # value example: ACME_CHALLENGE_TOKEN
36
+ base_domain = self.cloudflare.determine_registered_domain(domain)
37
+
38
+ record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
39
+ self.challenges_map[key] = record_id
40
+ print(f"CloudflareChallengeSolver[{domain}]: Added Record {key}")
41
+
42
+ def get_challenge(self, key: str, domain: str) -> str:
43
+ base_domain = self.cloudflare.determine_registered_domain(domain)
44
+ records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
45
+ for record in records:
46
+ if record["name"] == key:
47
+ return record["content"]
48
+ return None # Return None if not found, as per ChallengeSolver's __getitem__ behavior
49
+
50
+ def delete_challenge(self, key: str, domain: str):
51
+ if key not in self.challenges_map:
52
+ print(f"CloudflareChallengeSolver.delete: Not found Skipping key={key} record_id={record_id}")
53
+ return
54
+
55
+ record_id = self.challenges_map[key]
56
+ base_domain = self.cloudflare.determine_registered_domain(domain)
57
+ self.cloudflare.delete_record(record=record_id, domain=base_domain)
58
+ del self.challenges_map[key]
59
+ print(f"CloudflareChallengeSolver: Deleted challenge for {key} with record ID {record_id}")
60
+
61
+ def _cleanup_zone_challenges(self, zone):
62
+ zone_name = zone["name"]
63
+ try:
64
+ # List all TXT records in the zone
65
+ records = self.cloudflare.list_txt_records(zone_name)
66
+ for record in records:
67
+ if record["type"] == "TXT" and record["name"].startswith("_acme-challenge"):
68
+ print(f"CloudflareChallengeSolver: Deleting old challenge record {record['name']} in zone {zone_name}")
69
+ try:
70
+ self.cloudflare.delete_record(record["id"], zone_name)
71
+ except CertApiException as e:
72
+ print(f"CloudflareChallengeSolver: Warning - Failed to delete record {record['name']} in zone {zone_name}: {e.message} - {e.detail}")
73
+ except Exception as e:
74
+ print(f"CloudflareChallengeSolver: Warning - An unexpected error occurred while deleting record {record['name']} in zone {zone_name}: {e}")
75
+ except CertApiException as e:
76
+ print(f"CloudflareChallengeSolver: Error listing challenges in zone {zone_name}: {e.message} - {e.detail}")
77
+ except Exception as e:
78
+ print(f"CloudflareChallengeSolver: An unexpected error occurred while listing challenges in zone {zone_name}: {e}")
79
+
80
+ def cleanup_old_challenges(self):
81
+ zones = self.cloudflare._get_zones()
82
+ with ThreadPoolExecutor(max_workers=len(zones)) as executor:
83
+ executor.map(self._cleanup_zone_challenges, zones)
84
+
85
+ def __iter__(self):
86
+ # This is tricky as we can't easily iterate all challenges across all domains
87
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
88
+ return iter(self.challenges_map)
89
+
90
+ def __len__(self):
91
+ # Similar to __iter__, this will count challenges managed by this store instance.
92
+ return len(self.challenges_map)
@@ -2,6 +2,7 @@ import json
2
2
  import time
3
3
  from os import getenv
4
4
  from urllib.request import urlopen, Request
5
+ from certapi.errors import CertApiException
5
6
 
6
7
 
7
8
  class Cloudflare(object):
@@ -13,7 +14,7 @@ class Cloudflare(object):
13
14
  if not self.token:
14
15
  self.token = getenv("CLOUDFLARE_API_KEY")
15
16
  if not self.token:
16
- raise Exception("CLOUDFLARE_API_KEY not found in environment")
17
+ raise CertApiException("CLOUDFLARE_API_KEY not found in environment", step="Cloudflare.__init__")
17
18
 
18
19
  self._zones_cache = None
19
20
  self._zones_cache_time = 0 # Unix timestamp of last cache update
@@ -31,7 +32,11 @@ class Cloudflare(object):
31
32
  api_url = "{0}/zones?per_page=50".format(self.api)
32
33
  response = urlopen(Request(api_url, headers=request_headers))
33
34
  if response.getcode() != 200:
34
- raise Exception(json.loads(response.read().decode("utf8")))
35
+ raise CertApiException(
36
+ "Cloudflare API error",
37
+ detail=json.loads(response.read().decode("utf8")),
38
+ step="Cloudflare._get_zones"
39
+ )
35
40
 
36
41
  zones = json.loads(response.read().decode("utf8"))["result"]
37
42
  self._zones_cache = zones
@@ -44,7 +49,11 @@ class Cloudflare(object):
44
49
  for zone in zones:
45
50
  if zone["name"] == domain:
46
51
  return zone["id"]
47
- raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
52
+ raise CertApiException(
53
+ "No Cloudflare zone found for domain",
54
+ detail={"domain": domain},
55
+ step="Cloudflare._get_zone_id"
56
+ )
48
57
 
49
58
  def determine_registered_domain(self, domain: str) -> str:
50
59
  """
@@ -62,9 +71,13 @@ class Cloudflare(object):
62
71
  err = e
63
72
  continue
64
73
  if err:
65
- raise err
74
+ raise CertApiException(str(err), step="Cloudflare.determine_registered_domain")
66
75
  else:
67
- raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
76
+ raise CertApiException(
77
+ "Could not determine Cloudflare registered domain",
78
+ detail={"domain": domain},
79
+ step="Cloudflare.determine_registered_domain"
80
+ )
68
81
 
69
82
  def list_txt_records(self, domain: str, name_filter: str = None) -> list:
70
83
  """
@@ -81,11 +94,20 @@ class Cloudflare(object):
81
94
  response = urlopen(Request(api_url, headers=request_headers))
82
95
 
83
96
  if response.getcode() != 200:
84
- raise Exception(json.loads(response.read().decode("utf8")))
97
+ raise CertApiException(
98
+ "Cloudflare API error",
99
+ detail=json.loads(response.read().decode("utf8")),
100
+ step="Cloudflare.list_txt_records"
101
+ )
85
102
 
86
103
  result = json.loads(response.read().decode("utf8"))
87
104
  if not result.get("success"):
88
- raise Exception(result.get("errors", "Unknown error listing TXT records"))
105
+ print(f"List TXT record [{response.getcode()}]", result)
106
+ raise CertApiException(
107
+ "Unknown error listing TXT records",
108
+ detail=result.get("errors", "Unknown error listing TXT records"),
109
+ step="Cloudflare.list_txt_records"
110
+ )
89
111
 
90
112
  return result["result"]
91
113
 
@@ -111,11 +133,15 @@ class Cloudflare(object):
111
133
  "proxied": False,
112
134
  }
113
135
  response = urlopen(Request(api_url, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
114
-
115
- if response.getcode() != 200:
116
- raise Exception(json.loads(response.read().decode("utf8")))
117
136
  result = response.read().decode("utf8")
118
- print("Cloudflare create record", name, result)
137
+ if response.getcode() != 200:
138
+ print(f"Create TXT record [{response.getcode()}]", result)
139
+ raise CertApiException(
140
+ "Cloudflare API error",
141
+ detail=json.loads(result),
142
+ step="Cloudflare.create_record"
143
+ )
144
+
119
145
  return json.loads(result)["result"]["id"]
120
146
 
121
147
  def delete_record(self, record, domain):
@@ -133,6 +159,10 @@ class Cloudflare(object):
133
159
  request.get_method = lambda: "DELETE"
134
160
  response = urlopen(request)
135
161
  result = response.read().decode("utf8")
136
- print(f"Delete dns record [{response.getcode()}]", result)
137
162
  if response.getcode() != 200:
138
- raise Exception(json.loads(response.read().decode("utf8")))
163
+ print(f"Delete dns record [{response.getcode()}]", result)
164
+ raise CertApiException(
165
+ "Cloudflare API error",
166
+ detail=json.loads(result),
167
+ step="Cloudflare.delete_record"
168
+ )
@@ -0,0 +1,88 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from typing import Literal
4
+ from ...ChallengeSolver import ChallengeSolver
5
+ from .digitalocean_client import DigitalOcean
6
+ from certapi.errors import CertApiException
7
+
8
+
9
+ class DigitalOceanChallengeSolver(ChallengeSolver):
10
+ def __init__(self, api_key: str = None):
11
+ self.digitalocean = DigitalOcean(api_key)
12
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
13
+
14
+ def supported_challenge_type(self) -> Literal["dns-01"]:
15
+ return "dns-01"
16
+
17
+ def supports_domain(self, domain: str) -> bool:
18
+ """
19
+ Checks if the DigitalOcean account has access to the given domain (or its base domain)
20
+ as a registered zone.
21
+ """
22
+ try:
23
+ self.digitalocean.determine_domain(domain)
24
+ return True
25
+ except CertApiException as e:
26
+ # Log the exception or handle it as needed, but return False as it doesn't support the domain
27
+ print(f"DigitalOceanChallengeSolver.supports_domain: {e.message} - {e.detail}")
28
+ return False
29
+ except Exception as e:
30
+ # Catch any other unexpected exceptions
31
+ print(f"DigitalOceanChallengeSolver.supports_domain: An unexpected error occurred: {e}")
32
+ return False
33
+
34
+ def save_challenge(self, key: str, value: str, domain=None):
35
+ # key example: _acme-challenge.sub.example.com
36
+ # value example: ACME_CHALLENGE_TOKEN
37
+ base_domain = self.digitalocean.determine_domain(domain)
38
+
39
+ record_id = self.digitalocean.create_record(name=key, data=value, domain=base_domain)
40
+ self.challenges_map[key] = record_id
41
+ print(f"DigitalOceanChallengeSolver: Saved challenge for {key} with record ID {record_id}")
42
+
43
+ def get_challenge(self, key: str, domain: str) -> str:
44
+ base_domain = self.digitalocean.determine_domain(domain)
45
+ records = self.digitalocean.list_records(base_domain, name_filter=key)
46
+ for record in records:
47
+ if record["name"] == key:
48
+ return record["data"] # DigitalOcean API returns 'data' for TXT record content
49
+ return None # Return None if not found, as per ChallengeSolver's __getitem__ behavior
50
+
51
+ def delete_challenge(self, key: str, domain: str):
52
+ if key not in self.challenges_map:
53
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
54
+
55
+ record_id = self.challenges_map[key]
56
+ base_domain = self.digitalocean.determine_domain(domain)
57
+ self.digitalocean.delete_record(record=record_id, domain=base_domain)
58
+ del self.challenges_map[key]
59
+ print(f"DigitalOceanChallengeSolver: Deleted challenge for {key} with record ID {record_id}")
60
+
61
+ def cleanup_old_challenges(self):
62
+ domains = self.digitalocean._get_domains()
63
+ for domain_obj in domains:
64
+ domain_name = domain_obj["name"]
65
+ try:
66
+ records = self.digitalocean.list_records(domain_name)
67
+ for record in records:
68
+ if record["type"] == "TXT" and record["name"].startswith("_acme-challenge"):
69
+ print(f"DigitalOceanChallengeSolver: Deleting old challenge record {record['name']} in domain {domain_name}")
70
+ try:
71
+ self.digitalocean.delete_record(record["id"], domain_name)
72
+ except CertApiException as e:
73
+ print(f"DigitalOceanChallengeSolver: Warning - Failed to delete record {record['name']} in domain {domain_name}: {e.message} - {e.detail}")
74
+ except Exception as e:
75
+ print(f"DigitalOceanChallengeSolver: Warning - An unexpected error occurred while deleting record {record['name']} in domain {domain_name}: {e}")
76
+ except CertApiException as e:
77
+ print(f"DigitalOceanChallengeSolver: Error listing challenges in domain {domain_name}: {e.message} - {e.detail}")
78
+ except Exception as e:
79
+ print(f"DigitalOceanChallengeSolver: An unexpected error occurred while listing challenges in domain {domain_name}: {e}")
80
+
81
+ def __iter__(self):
82
+ # This is tricky as we can't easily iterate all challenges across all domains
83
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
84
+ return iter(self.challenges_map)
85
+
86
+ def __len__(self):
87
+ # Similar to __iter__, this will count challenges managed by this store instance.
88
+ return len(self.challenges_map)