certapi 0.4.0__tar.gz → 0.4.2__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.0 → certapi-0.4.2}/PKG-INFO +2 -2
  2. {certapi-0.4.0 → certapi-0.4.2}/README.md +1 -1
  3. {certapi-0.4.0 → certapi-0.4.2}/setup.py +1 -1
  4. certapi-0.4.2/src/certapi/__init__.py +23 -0
  5. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Acme.py +3 -2
  6. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Challenge.py +12 -15
  7. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Order.py +3 -2
  8. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/__init__.py +1 -1
  9. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/http.py +1 -2
  10. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/ChallengeStore.py +3 -1
  11. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/__init__.py +2 -3
  12. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/dns/__init__.py +1 -1
  13. certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +59 -0
  14. certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py +138 -0
  15. certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +57 -0
  16. certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py +85 -0
  17. certapi-0.4.2/src/certapi/client/__init__.py +0 -0
  18. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/client/cert_manager_client.py +29 -11
  19. certapi-0.4.2/src/certapi/crypto/__init__.py +2 -0
  20. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/crypto/crypto.py +4 -3
  21. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/crypto/crypto_classes.py +5 -5
  22. certapi-0.4.2/src/certapi/http/__init__.py +0 -0
  23. certapi-0.4.2/src/certapi/http/client.py +0 -0
  24. certapi-0.4.2/src/certapi/http/types.py +75 -0
  25. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/AcmeCertIssuer.py +28 -17
  26. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/SelfCertIssuer.py +3 -2
  27. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/__init__.py +1 -1
  28. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/abstract_certissuer.py +22 -14
  29. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/FileSystemKeystore.py +7 -7
  30. certapi-0.4.2/src/certapi/keystore/KeyStore.py +71 -0
  31. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/PostgresqlKeyStore.py +12 -5
  32. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/RemoteKeyStore.py +7 -3
  33. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/SqliteKeyStore.py +7 -4
  34. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/__init__.py +1 -1
  35. certapi-0.4.2/src/certapi/manager/__init__.py +0 -0
  36. certapi-0.4.2/src/certapi/manager/acme_cert_manager.py +161 -0
  37. certapi-0.4.2/src/certapi/server/__init__.py +0 -0
  38. certapi-0.4.2/src/certapi/server/api.py +103 -0
  39. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/server/cert_api.py +48 -36
  40. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/server/key_api.py +25 -28
  41. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/utils.py +0 -1
  42. {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/PKG-INFO +2 -2
  43. {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/SOURCES.txt +11 -1
  44. certapi-0.4.0/src/certapi/__init__.py +0 -9
  45. certapi-0.4.0/src/certapi/challenge_store/FilesystemChallengeStore.py +0 -38
  46. certapi-0.4.0/src/certapi/crypto/__init__.py +0 -2
  47. certapi-0.4.0/src/certapi/keystore/KeyStore.py +0 -66
  48. certapi-0.4.0/src/certapi/server/api.py +0 -89
  49. {certapi-0.4.0 → certapi-0.4.2}/MANIFEST.in +0 -0
  50. {certapi-0.4.0 → certapi-0.4.2}/pyproject.toml +0 -0
  51. {certapi-0.4.0 → certapi-0.4.2}/setup.cfg +0 -0
  52. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/AcmeError.py +0 -0
  53. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/FileSystemChallengeStore.py +1 -1
  54. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/InmemoryChallengeStore.py +0 -0
  55. {certapi-0.4.0/src/certapi/client → certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare}/__init__.py +0 -0
  56. {certapi-0.4.0/src/certapi/server → certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean}/__init__.py +0 -0
  57. {certapi-0.4.0 → certapi-0.4.2}/src/certapi/util.py +0 -0
  58. {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/dependency_links.txt +0 -0
  59. {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/requires.txt +0 -0
  60. {certapi-0.4.0 → certapi-0.4.2}/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.0
3
+ Version: 0.4.2
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
@@ -33,7 +33,7 @@ import json
33
33
  from certapi import FileSystemChallengeStore, 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/acme-challenges.
36
+ challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
37
37
 
38
38
  certAuthority = CertAuthority(challenge_store, key_store)
39
39
  certAuthority.setup()
@@ -20,7 +20,7 @@ import json
20
20
  from certapi import FileSystemChallengeStore, 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/acme-challenges.
23
+ challenge_store = FileSystemChallengeStore("./acme-challenges") # this should be where your web server hosts the .well-known/
24
24
 
25
25
  certAuthority = CertAuthority(challenge_store, key_store)
26
26
  certAuthority.setup()
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.4.0",
5
+ version="0.4.2",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -0,0 +1,23 @@
1
+ from .acme.Acme import Acme, Order, AcmeNetworkError, AcmeHttpError, Challenge
2
+ from .manager.acme_cert_manager import AcmeCertManager
3
+ from .http.types import CertificateResponse, IssuedCert
4
+
5
+ from .crypto import (
6
+ Certificate,
7
+ CertificateSigningRequest,
8
+ CertificateSigningRequestBuilder,
9
+ Key,
10
+ Ed25519Key,
11
+ ECDSAKey,
12
+ Ed25519PrivateKey,
13
+ EllipticCurvePrivateKey,
14
+ )
15
+ from .keystore import FileSystemKeystore, SqliteKeyStore, PostgresKeyStore, KeyStore
16
+ from .challenge_store import (
17
+ ChallengeStore,
18
+ InMemoryChallengeStore,
19
+ FileSystemChallengeStore,
20
+ CloudflareChallengeStore,
21
+ DigitalOceanChallengeStore,
22
+ )
23
+ from .issuers import SelfCertIssuer
@@ -9,13 +9,14 @@ 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 sign, digest_sha256, csr_to_der, jwk, get_algorithm_name, sign_for_jws, Key
13
13
  from certapi.util import b64_encode, b64_string
14
14
  from .AcmeError import *
15
15
  from .http import *
16
16
  from .Challenge import Challenge
17
17
  from .Order import Order
18
18
 
19
+
19
20
  class Acme:
20
21
 
21
22
  URL_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
@@ -23,7 +24,7 @@ class Acme:
23
24
 
24
25
  def __init__(self, account_key: Union[RSAPrivateKey, EllipticCurvePrivateKey | Key], url=URL_STAGING):
25
26
  self.account_key = account_key.key if isinstance(account_key, Key) else account_key
26
-
27
+
27
28
  # json web key format for public key
28
29
  self.jwk = jwk(self.account_key)
29
30
  print(self.jwk)
@@ -1,4 +1,3 @@
1
-
2
1
  import json
3
2
  from typing import Literal, Union
4
3
 
@@ -7,7 +6,8 @@ import requests
7
6
  from certapi.util import b64_encode, b64_string
8
7
  from certapi.crypto import digest_sha256
9
8
  from .AcmeError import *
10
- from .http import post,get
9
+ from .http import post, get
10
+
11
11
 
12
12
  class Challenge:
13
13
  def __init__(self, auth_url, data, acme):
@@ -15,7 +15,7 @@ class Challenge:
15
15
  self._acme = acme
16
16
  self._data = data
17
17
  challenge = self.get_challenge()
18
- self.token:str = challenge["token"]
18
+ self.token: str = challenge["token"]
19
19
  self.verified = challenge["status"] == "valid"
20
20
  self.domain = data["identifier"]["value"] # Add domain attribute
21
21
 
@@ -25,24 +25,21 @@ class Challenge:
25
25
 
26
26
  self.url = "http://{0}/.well-known/acme-challenge/{1}".format(data["identifier"]["value"], self.token)
27
27
 
28
- def as_key_value(self,type:Literal["http-01", "dns-01", "tls-alpn-01"]=None):
29
- challenge=self.get_challenge(type)
28
+ def as_key_value(self, type: Literal["http-01", "dns-01", "tls-alpn-01"] = None):
29
+ challenge = self.get_challenge(type)
30
30
 
31
31
  if challenge["type"] == "dns-01":
32
32
  key = f"_acme-challenge.{self.domain}"
33
33
  value = b64_string(digest_sha256(self.authorization_key.encode("utf8")))
34
34
  else:
35
- key = self.token
36
- value = self.authorization_key
37
-
38
- return (key,value)
35
+ key = self.token
36
+ value = self.authorization_key
39
37
 
38
+ return (key, value)
40
39
 
41
- def verify(self, type:Literal["http-01", "dns-01", "tls-alpn-01"]=None) -> bool:
40
+ def verify(self, type: Literal["http-01", "dns-01", "tls-alpn-01"] = None) -> bool:
42
41
  if not self.verified:
43
- response = self._acme._signed_req(
44
- self.get_challenge(type)["url"], {}, step="Verify Challenge", throw=False
45
- )
42
+ response = self._acme._signed_req(self.get_challenge(type)["url"], {}, step="Verify Challenge", throw=False)
46
43
  if response.status_code == 200 and response.json()["status"] == "valid":
47
44
  self.verified = True
48
45
  return True
@@ -73,9 +70,9 @@ class Challenge:
73
70
  else:
74
71
  return False
75
72
 
76
- def get_challenge(self, type:Literal["http-01", "dns-01", "tls-alpn-01"]=None):
73
+ def get_challenge(self, type: Literal["http-01", "dns-01", "tls-alpn-01"] = None):
77
74
  challenges = self._data["challenges"]
78
- def_key= type if type is not None else "http-01"
75
+ def_key = type if type is not None else "http-01"
79
76
  for method in challenges:
80
77
  if method["type"] == def_key:
81
78
  return method
@@ -1,10 +1,11 @@
1
1
  from typing import List, Tuple
2
- from certapi.crypto import crypto,csr_to_der
2
+ from certapi.crypto import crypto, csr_to_der
3
3
  from certapi.util import b64_string
4
- from cryptography.x509 import CertificateSigningRequest,Certificate
4
+ from cryptography.x509 import CertificateSigningRequest, Certificate
5
5
  from .Challenge import *
6
6
  from .http import get
7
7
 
8
+
8
9
  class Order:
9
10
  def __init__(self, url, data, challenges, acme):
10
11
  self.url = url
@@ -1,4 +1,4 @@
1
1
  from .Challenge import Challenge
2
2
  from .AcmeError import *
3
3
  from .Acme import Acme
4
- from .Order import Order
4
+ from .Order import Order
@@ -1,7 +1,7 @@
1
-
2
1
  import requests
3
2
  from .AcmeError import *
4
3
 
4
+
5
5
  def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
6
6
  res = None
7
7
  try:
@@ -53,4 +53,3 @@ def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.R
53
53
 
54
54
  def get(step: str, url) -> requests.Response:
55
55
  return request("GET", step, url)
56
-
@@ -7,6 +7,7 @@ class ChallengeStore(ABC):
7
7
  """
8
8
  Abstract base class for a challenge store.
9
9
  """
10
+
10
11
  @abstractmethod
11
12
  def supports_domain(self, domain: str) -> bool:
12
13
  pass
@@ -22,6 +23,7 @@ class ChallengeStore(ABC):
22
23
  @abstractmethod
23
24
  def delete_challenge(self, key: str, domain: str = None):
24
25
  pass
26
+
25
27
  def store_details():
26
28
  return {
27
29
  "name": "Abstract Challenge Store",
@@ -31,4 +33,4 @@ class ChallengeStore(ABC):
31
33
  raise NotImplementedError("Must implement `__iter__` method.")
32
34
 
33
35
  def __len__(self):
34
- raise NotImplementedError("Must implement `__len__` method.")
36
+ raise NotImplementedError("Must implement `__len__` method.")
@@ -2,7 +2,8 @@ from .ChallengeStore import ChallengeStore
2
2
  from .InmemoryChallengeStore import InMemoryChallengeStore
3
3
  import os
4
4
  from .FileSystemChallengeStore import FileSystemChallengeStore
5
- from .dns import CloudflareChallengeStore,DigitalOceanChallengeStore
5
+ from .dns import CloudflareChallengeStore, DigitalOceanChallengeStore
6
+
6
7
 
7
8
  def get_challenge_store():
8
9
  """
@@ -21,5 +22,3 @@ def get_challenge_store():
21
22
  return FileSystemChallengeStore(directory)
22
23
  else:
23
24
  raise ValueError(f"Unknown CHALLENGE_STORE_TYPE: {store_type}")
24
-
25
-
@@ -1,2 +1,2 @@
1
1
  from .digitalocean.digitalocean_challenge_store import DigitalOceanChallengeStore
2
- from .cloudflare.cloudflare_challenge_store import CloudflareChallengeStore
2
+ from .cloudflare.cloudflare_challenge_store import CloudflareChallengeStore
@@ -0,0 +1,59 @@
1
+ import os
2
+ import time
3
+ from collections.abc import MutableMapping
4
+ from ...ChallengeStore import ChallengeStore
5
+ from .cloudflare_client import Cloudflare
6
+
7
+
8
+ class CloudflareChallengeStore(ChallengeStore):
9
+ def __init__(self, api_key: str = None):
10
+ self.cloudflare = Cloudflare(api_key)
11
+ self.challenges_map = {}
12
+
13
+ def supports_domain(self, domain: str) -> bool:
14
+ """
15
+ Checks if the Cloudflare account has access to the given domain (or its base domain)
16
+ as a registered zone.
17
+ """
18
+ try:
19
+ self.cloudflare.determine_registered_domain(domain)
20
+ return True
21
+ except Exception:
22
+ return False
23
+
24
+ def save_challenge(self, key: str, value: str, domain=None):
25
+ # key example: _acme-challenge.sub.example.com
26
+ # value example: ACME_CHALLENGE_TOKEN
27
+ base_domain = self.cloudflare.determine_registered_domain(domain)
28
+
29
+ record_id = self.cloudflare.create_record(name=key, data=value, domain=base_domain)
30
+ self.challenges_map[key] = record_id
31
+ print(f"CloudflareChallengeStore: Saved challenge for {key} with record ID {record_id}")
32
+ time.sleep(10) # wait for dns propagation. it may not be immediate
33
+
34
+ def get_challenge(self, key: str, domain: str) -> str:
35
+ base_domain = self.cloudflare.determine_registered_domain(domain)
36
+ records = self.cloudflare.list_txt_records(base_domain, name_filter=key)
37
+ for record in records:
38
+ if record["name"] == key:
39
+ return record["content"]
40
+ return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
41
+
42
+ def delete_challenge(self, key: str, domain: str):
43
+ if key not in self.challenges_map:
44
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
45
+
46
+ record_id = self.challenges_map[key]
47
+ base_domain = self.cloudflare.determine_registered_domain(domain)
48
+ self.cloudflare.delete_record(record=record_id, domain=base_domain)
49
+ del self.challenges_map[key]
50
+ print(f"CloudflareChallengeStore: Deleted challenge for {key} with record ID {record_id}")
51
+
52
+ def __iter__(self):
53
+ # This is tricky as we can't easily iterate all challenges across all domains
54
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
55
+ return iter(self.challenges_map)
56
+
57
+ def __len__(self):
58
+ # Similar to __iter__, this will count challenges managed by this store instance.
59
+ return len(self.challenges_map)
@@ -0,0 +1,138 @@
1
+ import json
2
+ import time
3
+ from os import getenv
4
+ from urllib.request import urlopen, Request
5
+
6
+
7
+ class Cloudflare(object):
8
+ name = "cloudflare"
9
+
10
+ def __init__(self, api_key: str):
11
+ self.token = api_key
12
+ self.api = "https://api.cloudflare.com/client/v4"
13
+ if not self.token:
14
+ self.token = getenv("CLOUDFLARE_API_KEY")
15
+ if not self.token:
16
+ raise Exception("CLOUDFLARE_API_KEY not found in environment")
17
+
18
+ self._zones_cache = None
19
+ self._zones_cache_time = 0 # Unix timestamp of last cache update
20
+
21
+ def _cloudflare_headers(self):
22
+ return {"Content-Type": "application/json", "Authorization": "Bearer " + self.token}
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
+ request_headers = self._cloudflare_headers()
31
+ api_url = "{0}/zones?per_page=50".format(self.api)
32
+ response = urlopen(Request(api_url, headers=request_headers))
33
+ if response.getcode() != 200:
34
+ raise Exception(json.loads(response.read().decode("utf8")))
35
+
36
+ zones = json.loads(response.read().decode("utf8"))["result"]
37
+ self._zones_cache = zones
38
+ self._zones_cache_time = time.time()
39
+ return zones
40
+
41
+ def _get_zone_id(self, domain):
42
+ """Determine Cloudflare Zone ID for a given domain"""
43
+ zones = self._get_zones()
44
+ for zone in zones:
45
+ if zone["name"] == domain:
46
+ return zone["id"]
47
+ raise Exception("No Cloudflare zone found for domain: {0}".format(domain))
48
+
49
+ def determine_registered_domain(self, domain: str) -> str:
50
+ """
51
+ Determine the registered domain in Cloudflare for a given (sub)domain.
52
+ This method iterates through parts of the domain to find a matching Cloudflare zone.
53
+ """
54
+ parts = domain.split(".")
55
+ err = None
56
+ for i in range(len(parts)):
57
+ potential_domain = ".".join(parts[i:])
58
+ try:
59
+ self._get_zone_id(potential_domain)
60
+ return potential_domain
61
+ except Exception as e:
62
+ err = e
63
+ continue
64
+ if err:
65
+ raise err
66
+ else:
67
+ raise Exception("Could not determine Cloudflare registered domain for: {0}".format(domain))
68
+
69
+ def list_txt_records(self, domain: str, name_filter: str = None) -> list:
70
+ """
71
+ Lists TXT records for a given domain, optionally filtered by name.
72
+ Returns a list of dictionaries, each representing a TXT record.
73
+ """
74
+ registered_domain = self.determine_registered_domain(domain)
75
+ zone_id = self._get_zone_id(registered_domain)
76
+ api_url = f"{self.api}/zones/{zone_id}/dns_records?type=TXT"
77
+ if name_filter:
78
+ api_url += f"&name={name_filter}"
79
+
80
+ request_headers = self._cloudflare_headers()
81
+ response = urlopen(Request(api_url, headers=request_headers))
82
+
83
+ if response.getcode() != 200:
84
+ raise Exception(json.loads(response.read().decode("utf8")))
85
+
86
+ result = json.loads(response.read().decode("utf8"))
87
+ if not result.get("success"):
88
+ raise Exception(result.get("errors", "Unknown error listing TXT records"))
89
+
90
+ return result["result"]
91
+
92
+ def create_record(self, name, data, domain):
93
+ """
94
+ Create DNS record
95
+ Params:
96
+ name, string, record name (e.g., _acme-challenge.example.com)
97
+ data, string, record data (e.g., ACME challenge token)
98
+ domain, string, dns domain (e.g., example.com) - This will be used to determine the registered zone.
99
+ Return:
100
+ record_id, string, created record id
101
+ """
102
+ registered_domain = self.determine_registered_domain(domain)
103
+ zone_id = self._get_zone_id(registered_domain)
104
+ api_url = "{0}/zones/{1}/dns_records".format(self.api, zone_id)
105
+ request_headers = self._cloudflare_headers()
106
+ request_data = {
107
+ "type": "TXT",
108
+ "name": name,
109
+ "content": data,
110
+ "ttl": 120, # Cloudflare minimum TTL for TXT is 120 seconds
111
+ "proxied": False,
112
+ }
113
+ 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
+ result = response.read().decode("utf8")
118
+ print("Cloudflare create record", name, result)
119
+ return json.loads(result)["result"]["id"]
120
+
121
+ def delete_record(self, record, domain):
122
+ """
123
+ Delete DNS record
124
+ Params:
125
+ record, string, record id number
126
+ domain, string, dns domain - This will be used to determine the registered zone.
127
+ """
128
+ registered_domain = self.determine_registered_domain(domain)
129
+ zone_id = self._get_zone_id(registered_domain)
130
+ api_url = "{0}/zones/{1}/dns_records/{2}".format(self.api, zone_id, record)
131
+ request_headers = self._cloudflare_headers()
132
+ request = Request(api_url, headers=request_headers)
133
+ request.get_method = lambda: "DELETE"
134
+ response = urlopen(request)
135
+ result = response.read().decode("utf8")
136
+ print(f"Delete dns record [{response.getcode()}]", result)
137
+ if response.getcode() != 200:
138
+ raise Exception(json.loads(response.read().decode("utf8")))
@@ -0,0 +1,57 @@
1
+ import os
2
+ from collections.abc import MutableMapping
3
+ from ...ChallengeStore import ChallengeStore
4
+ from .digitalocean_client import DigitalOcean
5
+
6
+
7
+ class DigitalOceanChallengeStore(ChallengeStore):
8
+ def __init__(self, api_key: str = None):
9
+ self.digitalocean = DigitalOcean(api_key)
10
+ self.challenges_map = {} # Stores key: record_id (needed for deletion)
11
+
12
+ def supports_domain(self, domain: str) -> bool:
13
+ """
14
+ Checks if the DigitalOcean account has access to the given domain (or its base domain)
15
+ as a registered zone.
16
+ """
17
+ try:
18
+ self.digitalocean.determine_domain(domain)
19
+ return True
20
+ except Exception:
21
+ return False
22
+
23
+ def save_challenge(self, key: str, value: str, domain=None):
24
+ # key example: _acme-challenge.sub.example.com
25
+ # value example: ACME_CHALLENGE_TOKEN
26
+ base_domain = self.digitalocean.determine_domain(domain)
27
+
28
+ record_id = self.digitalocean.create_record(name=key, data=value, domain=base_domain)
29
+ self.challenges_map[key] = record_id
30
+ print(f"DigitalOceanChallengeStore: Saved challenge for {key} with record ID {record_id}")
31
+
32
+ def get_challenge(self, key: str, domain: str) -> str:
33
+ base_domain = self.digitalocean.determine_domain(domain)
34
+ records = self.digitalocean.list_records(base_domain, name_filter=key)
35
+ for record in records:
36
+ if record["name"] == key:
37
+ return record["data"] # DigitalOcean API returns 'data' for TXT record content
38
+ return None # Return None if not found, as per ChallengeStore's __getitem__ behavior
39
+
40
+ def delete_challenge(self, key: str, domain: str):
41
+ if key not in self.challenges_map:
42
+ raise KeyError(f"Challenge {key} not found in store (no record_id stored).")
43
+
44
+ record_id = self.challenges_map[key]
45
+ base_domain = self.digitalocean.determine_domain(domain)
46
+ self.digitalocean.delete_record(record=record_id, domain=base_domain)
47
+ del self.challenges_map[key]
48
+ print(f"DigitalOceanChallengeStore: Deleted challenge for {key} with record ID {record_id}")
49
+
50
+ def __iter__(self):
51
+ # This is tricky as we can't easily iterate all challenges across all domains
52
+ # If the user wants a full API-driven iteration, they need to clarify how to get all domains.
53
+ return iter(self.challenges_map)
54
+
55
+ def __len__(self):
56
+ # Similar to __iter__, this will count challenges managed by this store instance.
57
+ return len(self.challenges_map)
@@ -0,0 +1,85 @@
1
+ import json
2
+ from os import getenv
3
+
4
+ from urllib.request import urlopen, Request # Python 3
5
+
6
+
7
+ class DigitalOcean(object):
8
+ def __init__(self, api_key: str = None):
9
+ self.token = api_key
10
+ self.api = "https://api.digitalocean.com/v2/domains"
11
+ if not self.token:
12
+ self.token = getenv("DIGITALOCEAN_API_KEY")
13
+ if not self.token:
14
+ raise Exception("DIGITALOCEAN_API_KEY not found in environment")
15
+
16
+ def determine_domain(self, domain):
17
+ """Determine registered domain in API"""
18
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
19
+ response = urlopen(Request(self.api, headers=request_headers))
20
+ if response.getcode() != 200:
21
+ raise Exception(json.loads(response.read().decode("utf8")))
22
+ domains = json.loads(response.read().decode("utf8"))["domains"]
23
+ for d in domains:
24
+ if d["name"] in domain:
25
+ return d["name"]
26
+
27
+ def create_record(self, name, data, domain):
28
+ """
29
+ Create DNS record
30
+ Params:
31
+ name, string, record name
32
+ data, string, record data
33
+ domain, string, dns domain
34
+ Return:
35
+ record_id, int, created record id
36
+ """
37
+ registered_domain = self.determine_domain(domain)
38
+ api = self.api + "/" + registered_domain + "/records"
39
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
40
+ request_data = {"type": "TXT", "ttl": 300, "name": name, "data": data}
41
+ response = urlopen(Request(api, data=json.dumps(request_data).encode("utf8"), headers=request_headers))
42
+ if response.getcode() != 201:
43
+ raise Exception(json.loads(response.read().decode("utf8")))
44
+ return json.loads(response.read().decode("utf8"))["domain_record"]["id"]
45
+
46
+ def delete_record(self, record, domain):
47
+ """
48
+ Delete DNS record
49
+ Params:
50
+ record, int, record id number
51
+ domain, string, dns domain
52
+ """
53
+ registered_domain = self.determine_domain(domain)
54
+ api = self.api + "/" + registered_domain + "/records/" + str(record)
55
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
56
+ request = Request(api, data=json.dumps({}).encode("utf8"), headers=request_headers)
57
+ request.get_method = lambda: "DELETE"
58
+ response = urlopen(request)
59
+ if response.getcode() != 204:
60
+ raise Exception(json.loads(response.read().decode("utf8")))
61
+
62
+ def list_records(self, domain, name_filter=None):
63
+ """
64
+ List DNS records for a domain, optionally filtered by name.
65
+ Params:
66
+ domain, string, dns domain
67
+ name_filter, string, optional filter for record name
68
+ Return:
69
+ records, list of dicts, matching DNS records
70
+ """
71
+ registered_domain = self.determine_domain(domain)
72
+ api = self.api + "/" + registered_domain + "/records"
73
+ request_headers = {"Content-Type": "application/json", "Authorization": "Bearer {0}".format(self.token)}
74
+ response = urlopen(Request(api, headers=request_headers))
75
+ if response.getcode() != 200:
76
+ raise Exception(json.loads(response.read().decode("utf8")))
77
+
78
+ all_records = json.loads(response.read().decode("utf8"))["domain_records"]
79
+
80
+ if name_filter:
81
+ filtered_records = [r for r in all_records if r["name"] == name_filter and r["type"] == "TXT"]
82
+ else:
83
+ filtered_records = [r for r in all_records if r["type"] == "TXT"]
84
+
85
+ return filtered_records
File without changes
@@ -2,6 +2,7 @@ import requests
2
2
  from typing import List, Union, Dict, Optional, Any
3
3
  from certapi.http.types import CertificateResponse, IssuedCert
4
4
 
5
+
5
6
  class CertManagerClient:
6
7
  def __init__(self, base_url: str):
7
8
  self.base_url = base_url
@@ -11,8 +12,15 @@ class CertManagerClient:
11
12
  response.raise_for_status()
12
13
  return response.json()
13
14
 
14
- def _post(self, path: str, data: Optional[Union[Dict[str, Any], str]] = None, headers: Optional[Dict[str, str]] = None) -> Any:
15
- response = requests.post(f"{self.base_url}{path}", json=data if isinstance(data, dict) else None, data=data if isinstance(data, str) else None, headers=headers)
15
+ def _post(
16
+ self, path: str, data: Optional[Union[Dict[str, Any], str]] = None, headers: Optional[Dict[str, str]] = None
17
+ ) -> Any:
18
+ response = requests.post(
19
+ f"{self.base_url}{path}",
20
+ json=data if isinstance(data, dict) else None,
21
+ data=data if isinstance(data, str) else None,
22
+ headers=headers,
23
+ )
16
24
  response.raise_for_status()
17
25
  return response.json()
18
26
 
@@ -32,17 +40,24 @@ class CertManagerClient:
32
40
  "key_type": key_type,
33
41
  "expiry_days": expiry_days,
34
42
  }
35
- if country: params["country"] = country
36
- if state: params["state"] = state
37
- if locality: params["locality"] = locality
38
- if organization: params["organization"] = organization
39
- if user_id: params["user_id"] = user_id
43
+ if country:
44
+ params["country"] = country
45
+ if state:
46
+ params["state"] = state
47
+ if locality:
48
+ params["locality"] = locality
49
+ if organization:
50
+ params["organization"] = organization
51
+ if user_id:
52
+ params["user_id"] = user_id
40
53
 
41
54
  data = self._get("/obtain", params=params)
42
55
  return CertificateResponse.from_json(data)
43
56
 
44
57
  def sign_csr(self, csr_pem: str) -> str:
45
- response = requests.post(f"{self.base_url}/sign_csr", data=csr_pem, headers={'Content-Type': 'application/x-pem-file'})
58
+ response = requests.post(
59
+ f"{self.base_url}/sign_csr", data=csr_pem, headers={"Content-Type": "application/x-pem-file"}
60
+ )
46
61
  response.raise_for_status()
47
62
  return response.text
48
63
 
@@ -88,11 +103,14 @@ class CertManagerClient:
88
103
  return None
89
104
  raise
90
105
 
91
- def save_cert(self, private_key_id: Union[int, str], cert_pem: str, domains: List[str], name: Optional[str] = None) -> Dict[str, Any]:
106
+ def save_cert(
107
+ self, private_key_id: Union[int, str], cert_pem: str, domains: List[str], name: Optional[str] = None
108
+ ) -> Dict[str, Any]:
92
109
  data = {
93
110
  "private_key_id": private_key_id,
94
111
  "cert": cert_pem,
95
112
  "domains": domains,
96
113
  }
97
- if name: data["name"] = name
98
- return self._post("/certs", data=data)
114
+ if name:
115
+ data["name"] = name
116
+ return self._post("/certs", data=data)
@@ -0,0 +1,2 @@
1
+ from .crypto import *
2
+ from .crypto_classes import *