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.
- {certapi-0.4.0 → certapi-0.4.2}/PKG-INFO +2 -2
- {certapi-0.4.0 → certapi-0.4.2}/README.md +1 -1
- {certapi-0.4.0 → certapi-0.4.2}/setup.py +1 -1
- certapi-0.4.2/src/certapi/__init__.py +23 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Acme.py +3 -2
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Challenge.py +12 -15
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/Order.py +3 -2
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/__init__.py +1 -1
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/http.py +1 -2
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/ChallengeStore.py +3 -1
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/__init__.py +2 -3
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/dns/__init__.py +1 -1
- certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +59 -0
- certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_client.py +138 -0
- certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +57 -0
- certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_client.py +85 -0
- certapi-0.4.2/src/certapi/client/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/client/cert_manager_client.py +29 -11
- certapi-0.4.2/src/certapi/crypto/__init__.py +2 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/crypto/crypto.py +4 -3
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/crypto/crypto_classes.py +5 -5
- certapi-0.4.2/src/certapi/http/__init__.py +0 -0
- certapi-0.4.2/src/certapi/http/client.py +0 -0
- certapi-0.4.2/src/certapi/http/types.py +75 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/AcmeCertIssuer.py +28 -17
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/SelfCertIssuer.py +3 -2
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/__init__.py +1 -1
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/issuers/abstract_certissuer.py +22 -14
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/FileSystemKeystore.py +7 -7
- certapi-0.4.2/src/certapi/keystore/KeyStore.py +71 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/PostgresqlKeyStore.py +12 -5
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/RemoteKeyStore.py +7 -3
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/SqliteKeyStore.py +7 -4
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/keystore/__init__.py +1 -1
- certapi-0.4.2/src/certapi/manager/__init__.py +0 -0
- certapi-0.4.2/src/certapi/manager/acme_cert_manager.py +161 -0
- certapi-0.4.2/src/certapi/server/__init__.py +0 -0
- certapi-0.4.2/src/certapi/server/api.py +103 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/server/cert_api.py +48 -36
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/server/key_api.py +25 -28
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/utils.py +0 -1
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/PKG-INFO +2 -2
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/SOURCES.txt +11 -1
- certapi-0.4.0/src/certapi/__init__.py +0 -9
- certapi-0.4.0/src/certapi/challenge_store/FilesystemChallengeStore.py +0 -38
- certapi-0.4.0/src/certapi/crypto/__init__.py +0 -2
- certapi-0.4.0/src/certapi/keystore/KeyStore.py +0 -66
- certapi-0.4.0/src/certapi/server/api.py +0 -89
- {certapi-0.4.0 → certapi-0.4.2}/MANIFEST.in +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/pyproject.toml +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/setup.cfg +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/FileSystemChallengeStore.py +1 -1
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/challenge_store/InmemoryChallengeStore.py +0 -0
- {certapi-0.4.0/src/certapi/client → certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare}/__init__.py +0 -0
- {certapi-0.4.0/src/certapi/server → certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean}/__init__.py +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi/util.py +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.4.0 → certapi-0.4.2}/src/certapi.egg-info/requires.txt +0 -0
- {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.
|
|
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/
|
|
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/
|
|
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()
|
|
@@ -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 =
|
|
36
|
-
value =
|
|
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,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(
|
|
15
|
-
|
|
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:
|
|
36
|
-
|
|
37
|
-
if
|
|
38
|
-
|
|
39
|
-
if
|
|
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(
|
|
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(
|
|
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:
|
|
98
|
-
|
|
114
|
+
if name:
|
|
115
|
+
data["name"] = name
|
|
116
|
+
return self._post("/certs", data=data)
|