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.
- {certapi-0.4.2 → certapi-0.4.4}/PKG-INFO +4 -4
- {certapi-0.4.2 → certapi-0.4.4}/README.md +3 -3
- {certapi-0.4.2 → certapi-0.4.4}/setup.py +1 -1
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/__init__.py +8 -8
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Acme.py +9 -6
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/AcmeError.py +6 -10
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Challenge.py +8 -6
- certapi-0.4.2/src/certapi/challenge_store/ChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/ChallengeSolver.py +9 -5
- certapi-0.4.2/src/certapi/challenge_store/FileSystemChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/FileSystemChallengeSolver.py +15 -4
- certapi-0.4.2/src/certapi/challenge_store/InmemoryChallengeStore.py → certapi-0.4.4/src/certapi/challenge_solver/InmemoryChallengeSolver.py +7 -3
- certapi-0.4.4/src/certapi/challenge_solver/__init__.py +24 -0
- certapi-0.4.4/src/certapi/challenge_solver/dns/__init__.py +2 -0
- certapi-0.4.4/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +92 -0
- {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/cloudflare/cloudflare_client.py +43 -13
- certapi-0.4.4/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +88 -0
- {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/digitalocean/digitalocean_client.py +35 -10
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/crypto_classes.py +36 -8
- certapi-0.4.4/src/certapi/errors.py +17 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/AcmeCertIssuer.py +37 -30
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/abstract_certissuer.py +17 -16
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/FileSystemKeystore.py +4 -1
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/KeyStore.py +3 -3
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/manager/acme_cert_manager.py +14 -23
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/PKG-INFO +4 -4
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/SOURCES.txt +12 -11
- certapi-0.4.2/src/certapi/challenge_store/__init__.py +0 -24
- certapi-0.4.2/src/certapi/challenge_store/dns/__init__.py +0 -2
- certapi-0.4.2/src/certapi/challenge_store/dns/cloudflare/cloudflare_challenge_store.py +0 -59
- certapi-0.4.2/src/certapi/challenge_store/dns/digitalocean/digitalocean_challenge_store.py +0 -57
- {certapi-0.4.2 → certapi-0.4.4}/MANIFEST.in +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/pyproject.toml +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/setup.cfg +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/Order.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/acme/http.py +0 -0
- {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/cloudflare/__init__.py +0 -0
- {certapi-0.4.2/src/certapi/challenge_store → certapi-0.4.4/src/certapi/challenge_solver}/dns/digitalocean/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/client/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/crypto/crypto.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/client.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/http/types.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/issuers/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/keystore/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/manager/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/__init__.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/api.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/cert_api.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/server/key_api.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/util.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi/utils.py +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.4.2 → certapi-0.4.4}/src/certapi.egg-info/requires.txt +0 -0
- {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.
|
|
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
|
|
33
|
+
from certapi import FileSystemChallengeSolver, FilesystemKeyStore, CertAuthority
|
|
34
34
|
|
|
35
35
|
key_store = FilesystemKeyStore("data")
|
|
36
|
-
|
|
36
|
+
challenge_solver = FileSystemChallengeSolver("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
37
37
|
|
|
38
|
-
certAuthority = CertAuthority(
|
|
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
|
|
20
|
+
from certapi import FileSystemChallengeSolver, FilesystemKeyStore, CertAuthority
|
|
21
21
|
|
|
22
22
|
key_store = FilesystemKeyStore("data")
|
|
23
|
-
|
|
23
|
+
challenge_solver = FileSystemChallengeSolver("./acme-challenges") # this should be where your web server hosts the .well-known/
|
|
24
24
|
|
|
25
|
-
certAuthority = CertAuthority(
|
|
25
|
+
certAuthority = CertAuthority(challenge_solver, key_store)
|
|
26
26
|
certAuthority.setup()
|
|
27
27
|
|
|
28
28
|
(response,_) = certAuthority.obtainCert("example.com")
|
|
@@ -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 .
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
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:
|
|
26
|
-
self.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(
|
|
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":
|
|
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(
|
|
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(
|
|
6
|
+
class AcmeError(CertApiException):
|
|
5
7
|
def __init__(self, message, detail, step):
|
|
6
|
-
super().__init__(
|
|
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
|
|
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(
|
|
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="
|
|
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, "
|
|
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:{
|
|
86
|
+
f"'{type}' not found in challenges for {self.domain}, available: {','.join(ch_types)}",
|
|
85
87
|
{"response": self._data["challenges"]},
|
|
86
|
-
"
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from .ChallengeSolver import ChallengeSolver
|
|
2
3
|
import os
|
|
3
4
|
|
|
4
5
|
|
|
5
|
-
class
|
|
6
|
+
class FilesystemChallengeSolver(ChallengeSolver):
|
|
6
7
|
"""
|
|
7
|
-
Filesystem implementation of the
|
|
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
|
|
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
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from .ChallengeSolver import ChallengeSolver
|
|
2
3
|
|
|
3
4
|
|
|
4
|
-
class
|
|
5
|
+
class InMemoryChallengeSolver(ChallengeSolver):
|
|
5
6
|
"""
|
|
6
|
-
In-memory implementation of the
|
|
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,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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|