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