certapi 1.0.0__tar.gz → 1.0.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-1.0.0/src/certapi.egg-info → certapi-1.0.2}/PKG-INFO +8 -8
- {certapi-1.0.0 → certapi-1.0.2}/README.md +7 -7
- {certapi-1.0.0 → certapi-1.0.2}/setup.py +1 -1
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/__init__.py +1 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Acme.py +1 -1
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/AcmeError.py +23 -13
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Challenge.py +1 -1
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/ChallengeSolver.py +3 -0
- certapi-1.0.2/src/certapi/challenge_solver/FileSystemChallengeSolver.py +115 -0
- certapi-1.0.2/src/certapi/client/__init__.py +1 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/client/cert_manager_client.py +43 -8
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/http/types.py +1 -1
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/AcmeCertIssuer.py +3 -3
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/abstract_certissuer.py +2 -2
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/manager/acme_cert_manager.py +1 -1
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/api.py +57 -37
- {certapi-1.0.0 → certapi-1.0.2/src/certapi.egg-info}/PKG-INFO +8 -8
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/SOURCES.txt +1 -0
- certapi-1.0.2/tests/test_acme_error_handling.py +762 -0
- certapi-1.0.0/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -49
- certapi-1.0.0/src/certapi/server/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/MANIFEST.in +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/pyproject.toml +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/setup.cfg +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Order.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/http.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/errors.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.0.0/src/certapi/client → certapi-1.0.2/src/certapi/http}/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.0.0/src/certapi/http → certapi-1.0.2/src/certapi/manager}/__init__.py +0 -0
- {certapi-1.0.0/src/certapi/manager → certapi-1.0.2/src/certapi/server}/__init__.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/key_api.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/util.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi/utils.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/tests/test_http_error_handling.py +0 -0
- {certapi-1.0.0 → certapi-1.0.2}/tests/test_keystores.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.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
|
|
@@ -22,17 +22,18 @@ Dynamic: requires-dist
|
|
|
22
22
|
Dynamic: requires-python
|
|
23
23
|
Dynamic: summary
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
CertApi
|
|
26
|
+
=============================
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
Certapi talks with DNS provider and ACME to issue SSL certificates and save it to a keystore.
|
|
29
|
+
|
|
30
|
+
CertApi is a base library for building other tools, or to integrate Certificate creation feature in your app. CertAPI also provides HTTP api server and can be deployed using Docker
|
|
29
31
|
|
|
30
32
|
[](https://github.com/mesudip/certapi/actions/workflows/tests.yml)
|
|
31
33
|
[](https://codecov.io/github/mesudip/certapi)
|
|
32
34
|
[](https://pypi.org/project/certapi/)
|
|
33
35
|
|
|
34
36
|
|
|
35
|
-
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
36
37
|
|
|
37
38
|
## Installation
|
|
38
39
|
|
|
@@ -100,12 +101,11 @@ cert_manager = AcmeCertManager(
|
|
|
100
101
|
key_store=key_store,
|
|
101
102
|
cert_issuer=cert_issuer,
|
|
102
103
|
challenge_solvers=[dns_solver], # other solvers can be used
|
|
103
|
-
|
|
104
|
-
)
|
|
104
|
+
)
|
|
105
105
|
cert_manager.setup()
|
|
106
106
|
|
|
107
107
|
# 4. Issue or Reuse Certificate
|
|
108
|
-
# Automatically checks keystore
|
|
108
|
+
# Automatically checks sand saves to keystore. Renews only if necessary.
|
|
109
109
|
response = cert_manager.issue_certificate(["example.com", "www.example.com"])
|
|
110
110
|
|
|
111
111
|
for cert_data in response.issued:
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
CertApi
|
|
2
|
+
=============================
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
4
|
+
Certapi talks with DNS provider and ACME to issue SSL certificates and save it to a keystore.
|
|
5
|
+
|
|
6
|
+
CertApi is a base library for building other tools, or to integrate Certificate creation feature in your app. CertAPI also provides HTTP api server and can be deployed using Docker
|
|
5
7
|
|
|
6
8
|
[](https://github.com/mesudip/certapi/actions/workflows/tests.yml)
|
|
7
9
|
[](https://codecov.io/github/mesudip/certapi)
|
|
8
10
|
[](https://pypi.org/project/certapi/)
|
|
9
11
|
|
|
10
12
|
|
|
11
|
-
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
12
13
|
|
|
13
14
|
## Installation
|
|
14
15
|
|
|
@@ -76,12 +77,11 @@ cert_manager = AcmeCertManager(
|
|
|
76
77
|
key_store=key_store,
|
|
77
78
|
cert_issuer=cert_issuer,
|
|
78
79
|
challenge_solvers=[dns_solver], # other solvers can be used
|
|
79
|
-
|
|
80
|
-
)
|
|
80
|
+
)
|
|
81
81
|
cert_manager.setup()
|
|
82
82
|
|
|
83
83
|
# 4. Issue or Reuse Certificate
|
|
84
|
-
# Automatically checks keystore
|
|
84
|
+
# Automatically checks sand saves to keystore. Renews only if necessary.
|
|
85
85
|
response = cert_manager.issue_certificate(["example.com", "www.example.com"])
|
|
86
86
|
|
|
87
87
|
for cert_data in response.issued:
|
|
@@ -30,7 +30,7 @@ class Acme:
|
|
|
30
30
|
self.jwk = account_key.jwk()
|
|
31
31
|
self.alg_name = account_key.algorithm_name()
|
|
32
32
|
self.nonce = []
|
|
33
|
-
self.acme_url = url if url else os.environ.get("ACME_API_URL", self.
|
|
33
|
+
self.acme_url = url if url else os.environ.get("ACME_API_URL", self.URL_PROD)
|
|
34
34
|
self.key_id = None
|
|
35
35
|
self.directory = None
|
|
36
36
|
self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
|
|
@@ -28,21 +28,29 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
28
28
|
|
|
29
29
|
def __init__(self, response: requests.Response, step: str):
|
|
30
30
|
requests.HTTPError.__init__(self, response=response)
|
|
31
|
-
self.response = response
|
|
32
31
|
(message, detail) = self.extract_acme_response_error()
|
|
33
32
|
AcmeError.__init__(self, message, detail, step)
|
|
33
|
+
self.response = response
|
|
34
34
|
|
|
35
35
|
def extract_acme_response_error(self):
|
|
36
36
|
message = None
|
|
37
37
|
error = None
|
|
38
38
|
try:
|
|
39
39
|
res_json = self.response.json()
|
|
40
|
-
if res_json
|
|
41
|
-
if res_json
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
if res_json.get("status") == "invalid":
|
|
41
|
+
if res_json.get("challenges"):
|
|
42
|
+
invalid_challenges = [x for x in res_json["challenges"] if x["status"] == "invalid"]
|
|
43
|
+
if not invalid_challenges:
|
|
44
|
+
# Fallback if no invalid challenge found but status is invalid
|
|
45
|
+
return (None, res_json)
|
|
46
|
+
|
|
47
|
+
failed_challenge: dict = invalid_challenges[0]
|
|
48
|
+
error = failed_challenge.get("error")
|
|
49
|
+
if not error:
|
|
50
|
+
return (None, res_json)
|
|
51
|
+
|
|
52
|
+
err_detail: str = error.get("detail", "")
|
|
53
|
+
err_type: str = error.get("type", "")
|
|
46
54
|
validation_record: dict = failed_challenge.get("validationRecord")
|
|
47
55
|
error = {
|
|
48
56
|
"url": failed_challenge["url"],
|
|
@@ -51,7 +59,8 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
51
59
|
# Search for the pattern and extract the content
|
|
52
60
|
if err_detail.startswith("DNS problem: NXDOMAIN"):
|
|
53
61
|
error["dns"] = {"error": "DNS record doesn't exist"}
|
|
54
|
-
|
|
62
|
+
hostnames = re.findall(r"looking up [A-Z]+ for ([\w.-]+)", err_detail)
|
|
63
|
+
error["hostname"] = hostnames[0] if hostnames else "unknown"
|
|
55
64
|
message = error["hostname"] + " doesn't have a valid DNS record"
|
|
56
65
|
elif err_detail:
|
|
57
66
|
error["dns"] = {"error": err_detail}
|
|
@@ -60,7 +69,9 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
60
69
|
error["dns"] = {"error": error}
|
|
61
70
|
message = err_detail
|
|
62
71
|
else:
|
|
63
|
-
validation_record =
|
|
72
|
+
validation_record = (
|
|
73
|
+
validation_record[0] if isinstance(validation_record, list) and validation_record else {}
|
|
74
|
+
)
|
|
64
75
|
error["hostname"] = validation_record.get("hostname", "unknown")
|
|
65
76
|
error["dns"] = {
|
|
66
77
|
"resolved": validation_record.get("addressesResolved"),
|
|
@@ -103,8 +114,8 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
103
114
|
match = re.search(pattern, err_detail)
|
|
104
115
|
|
|
105
116
|
if match:
|
|
106
|
-
error["response"] =
|
|
107
|
-
error["status_code"] =
|
|
117
|
+
error["response"] = match.group(1) if match is not None else err_detail
|
|
118
|
+
error["status_code"] = error.get("status")
|
|
108
119
|
hostname = error.get("hostname", "unknown")
|
|
109
120
|
status = error.get("status", "unknown")
|
|
110
121
|
message = (
|
|
@@ -131,7 +142,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
131
142
|
return (message, error)
|
|
132
143
|
|
|
133
144
|
|
|
134
|
-
class
|
|
145
|
+
class AcmeInvalidOrderError(AcmeHttpError):
|
|
135
146
|
def __init__(self, response: requests.Response, step: str):
|
|
136
147
|
super().__init__(response, step)
|
|
137
148
|
|
|
@@ -140,4 +151,3 @@ class AcmeInvalidNonceError(AcmeHttpError):
|
|
|
140
151
|
def __init__(self, response: requests.Response, step: str):
|
|
141
152
|
super().__init__(response, step)
|
|
142
153
|
self.can_retry = True
|
|
143
|
-
self.response = 0
|
|
@@ -13,6 +13,9 @@ class ChallengeSolver(ABC):
|
|
|
13
13
|
def supports_domain(self, domain: str) -> bool:
|
|
14
14
|
pass
|
|
15
15
|
|
|
16
|
+
def supports_domain_strict(self, domain: str) -> bool:
|
|
17
|
+
return self.supports_domain(domain)
|
|
18
|
+
|
|
16
19
|
@abstractmethod
|
|
17
20
|
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
18
21
|
pass
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from typing import Literal
|
|
2
|
+
from .ChallengeSolver import ChallengeSolver
|
|
3
|
+
import os
|
|
4
|
+
import random
|
|
5
|
+
import string
|
|
6
|
+
import requests
|
|
7
|
+
import sys
|
|
8
|
+
import re
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class FilesystemChallengeSolver(ChallengeSolver):
|
|
12
|
+
"""
|
|
13
|
+
Filesystem implementation of the ChallengeSolver.
|
|
14
|
+
This should never be used, but extended for your webserver e.g. nginx, apache etc.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, directory: str):
|
|
18
|
+
self.directory = directory
|
|
19
|
+
os.makedirs(self.directory, exist_ok=True)
|
|
20
|
+
|
|
21
|
+
def supports_domain(self, domain: str) -> bool:
|
|
22
|
+
return "*" not in domain
|
|
23
|
+
|
|
24
|
+
def supports_domain_strict(self, domain: str) -> bool:
|
|
25
|
+
return self.verify_domain(domain) if self.supports_domain(domain) else False
|
|
26
|
+
|
|
27
|
+
def save_challenge(self, key: str, value: str, domain: str = None):
|
|
28
|
+
file_path = os.path.join(self.directory, key)
|
|
29
|
+
with open(file_path, "w") as file:
|
|
30
|
+
file.write(value)
|
|
31
|
+
|
|
32
|
+
def get_challenge(self, key: str, domain: str = None) -> str:
|
|
33
|
+
file_path = os.path.join(self.directory, key)
|
|
34
|
+
if not os.path.exists(file_path):
|
|
35
|
+
return None
|
|
36
|
+
with open(file_path, "r") as file:
|
|
37
|
+
return file.read()
|
|
38
|
+
|
|
39
|
+
def delete_challenge(self, key: str, domain: str = None):
|
|
40
|
+
file_path = os.path.join(self.directory, key)
|
|
41
|
+
if os.path.exists(file_path):
|
|
42
|
+
os.remove(file_path)
|
|
43
|
+
|
|
44
|
+
def supported_challenge_type(self) -> Literal["http-01"]:
|
|
45
|
+
return "http-01"
|
|
46
|
+
|
|
47
|
+
def cleanup_old_challenges(self):
|
|
48
|
+
for filename in os.listdir(self.directory):
|
|
49
|
+
file_path = os.path.join(self.directory, filename)
|
|
50
|
+
if os.path.isfile(file_path):
|
|
51
|
+
os.remove(file_path)
|
|
52
|
+
|
|
53
|
+
def __iter__(self):
|
|
54
|
+
return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def is_valid_hostname(hostname):
|
|
58
|
+
if not hostname or len(hostname) > 255:
|
|
59
|
+
return False
|
|
60
|
+
if hostname[-1] == ".":
|
|
61
|
+
hostname = hostname[:-1]
|
|
62
|
+
|
|
63
|
+
allowed = re.compile(r"(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
|
|
64
|
+
return all(allowed.match(x) for x in hostname.split("."))
|
|
65
|
+
|
|
66
|
+
def verify_domain(self, _domain: list | str):
|
|
67
|
+
domain = [_domain] if type(_domain) is str else _domain
|
|
68
|
+
|
|
69
|
+
# Simple hostname validation
|
|
70
|
+
domain = [x for x in domain if self.is_valid_hostname(x)]
|
|
71
|
+
success = []
|
|
72
|
+
while True:
|
|
73
|
+
r1 = "".join([random.choice(string.ascii_letters + string.digits) for _ in range(32)])
|
|
74
|
+
file = os.path.join(self.directory, r1)
|
|
75
|
+
if os.path.exists(file):
|
|
76
|
+
continue
|
|
77
|
+
r2 = "".join([random.choice(string.ascii_letters + string.digits) for _ in range(256)])
|
|
78
|
+
with open(file, mode="wt") as file_descriptor:
|
|
79
|
+
file_descriptor.write(r2)
|
|
80
|
+
for d in domain:
|
|
81
|
+
try:
|
|
82
|
+
url = "http://%s/.well-known/acme-challenge/%s" % (d, r1)
|
|
83
|
+
response = requests.get(url, allow_redirects=False, timeout=3)
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
if response.content.decode("utf-8") == r2:
|
|
86
|
+
success.append(d)
|
|
87
|
+
continue
|
|
88
|
+
print(
|
|
89
|
+
"[Error] ["
|
|
90
|
+
+ d
|
|
91
|
+
+ "] Not owned by this machine:"
|
|
92
|
+
+ "Status Code["
|
|
93
|
+
+ str(response.status_code)
|
|
94
|
+
+ "] -> "
|
|
95
|
+
+ url,
|
|
96
|
+
file=sys.stderr,
|
|
97
|
+
)
|
|
98
|
+
continue
|
|
99
|
+
except requests.exceptions.RequestException as e:
|
|
100
|
+
error = str(e)
|
|
101
|
+
if error.find("Name does not resolve") > -1:
|
|
102
|
+
print("[Error] [" + d + "] Domain Name could not be resolved", file=sys.stderr)
|
|
103
|
+
elif error.find("Connection refused") > -1:
|
|
104
|
+
print(
|
|
105
|
+
"[Error] [" + d + "] Connection Refused! The port is filtered or not open.", file=sys.stderr
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
print("[ERROR] [" + d + "] Not owned by this machine : " + str(e))
|
|
109
|
+
continue
|
|
110
|
+
os.remove(file)
|
|
111
|
+
break
|
|
112
|
+
if type(_domain) is str:
|
|
113
|
+
return len(success) > 0
|
|
114
|
+
else:
|
|
115
|
+
return success
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .cert_manager_client import CertManagerClient
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import requests
|
|
2
|
-
from typing import List, Union, Dict, Optional, Any
|
|
2
|
+
from typing import List, Union, Dict, Optional, Any, Literal
|
|
3
|
+
from cryptography import x509
|
|
3
4
|
from certapi.http.types import CertificateResponse, IssuedCert
|
|
5
|
+
from certapi.keystore.KeyStore import KeyStore
|
|
6
|
+
from certapi.crypto import Key
|
|
4
7
|
|
|
5
8
|
|
|
6
9
|
class CertManagerClient:
|
|
7
|
-
def __init__(self, base_url: str):
|
|
10
|
+
def __init__(self, base_url: str, key_store: Optional[KeyStore] = None):
|
|
8
11
|
self.base_url = base_url
|
|
12
|
+
self.key_store = key_store
|
|
9
13
|
|
|
10
14
|
def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
|
|
11
15
|
response = requests.get(f"{self.base_url}{path}", params=params)
|
|
@@ -24,19 +28,29 @@ class CertManagerClient:
|
|
|
24
28
|
response.raise_for_status()
|
|
25
29
|
return response.json()
|
|
26
30
|
|
|
27
|
-
def
|
|
31
|
+
def setup(self):
|
|
32
|
+
"""
|
|
33
|
+
Check connection to the cert manager server.
|
|
34
|
+
"""
|
|
35
|
+
try:
|
|
36
|
+
self._get("/keys")
|
|
37
|
+
except Exception as e:
|
|
38
|
+
print(f"Warning: CertManagerClient could not connect to {self.base_url}: {e}")
|
|
39
|
+
|
|
40
|
+
def issue_certificate(
|
|
28
41
|
self,
|
|
29
|
-
|
|
30
|
-
key_type:
|
|
42
|
+
hosts: Union[str, List[str]],
|
|
43
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
31
44
|
expiry_days: int = 90,
|
|
32
45
|
country: Optional[str] = None,
|
|
33
46
|
state: Optional[str] = None,
|
|
34
47
|
locality: Optional[str] = None,
|
|
35
48
|
organization: Optional[str] = None,
|
|
36
49
|
user_id: Optional[str] = None,
|
|
50
|
+
renew_threshold_days: Optional[int] = None,
|
|
37
51
|
) -> CertificateResponse:
|
|
38
52
|
params = {
|
|
39
|
-
"hostname":
|
|
53
|
+
"hostname": hosts if isinstance(hosts, str) else hosts,
|
|
40
54
|
"key_type": key_type,
|
|
41
55
|
"expiry_days": expiry_days,
|
|
42
56
|
}
|
|
@@ -50,11 +64,32 @@ class CertManagerClient:
|
|
|
50
64
|
params["organization"] = organization
|
|
51
65
|
if user_id:
|
|
52
66
|
params["user_id"] = user_id
|
|
67
|
+
if renew_threshold_days is not None:
|
|
68
|
+
params["renew_threshold_days"] = renew_threshold_days
|
|
53
69
|
|
|
54
70
|
data = self._get("/obtain", params=params)
|
|
55
|
-
|
|
71
|
+
res = CertificateResponse.from_json(data)
|
|
72
|
+
|
|
73
|
+
if self.key_store:
|
|
74
|
+
for cert_data in res.issued + res.existing:
|
|
75
|
+
if cert_data.privateKey and cert_data.certificate:
|
|
76
|
+
try:
|
|
77
|
+
key = Key.from_pem(cert_data.privateKey.encode("utf-8"))
|
|
78
|
+
key_id = self.key_store.save_key(key, cert_data.domains[0])
|
|
79
|
+
self.key_store.save_cert(key_id, cert_data.certificate, cert_data.domains)
|
|
80
|
+
except Exception as e:
|
|
81
|
+
print(f"Warning: Failed to save certificate for {cert_data.domains} to KeyStore: {e}")
|
|
82
|
+
raise e
|
|
83
|
+
return res
|
|
84
|
+
|
|
85
|
+
def issue_certificate_for_csr(self, csr: Union[str, x509.CertificateSigningRequest]) -> str:
|
|
86
|
+
if not isinstance(csr, str):
|
|
87
|
+
from ..crypto import csr_to_pem
|
|
88
|
+
|
|
89
|
+
csr_pem = csr_to_pem(csr).decode("utf-8")
|
|
90
|
+
else:
|
|
91
|
+
csr_pem = csr
|
|
56
92
|
|
|
57
|
-
def sign_csr(self, csr_pem: str) -> str:
|
|
58
93
|
response = requests.post(
|
|
59
94
|
f"{self.base_url}/sign_csr", data=csr_pem, headers={"Content-Type": "application/x-pem-file"}
|
|
60
95
|
)
|
|
@@ -41,7 +41,7 @@ class IssuedCert:
|
|
|
41
41
|
return f"IssuedCert(domains={self.domains})"
|
|
42
42
|
|
|
43
43
|
def __str__(self):
|
|
44
|
-
return f"(
|
|
44
|
+
return f"(domains: {self.domains}, certificate:{self.certificate})"
|
|
45
45
|
|
|
46
46
|
def to_json(self):
|
|
47
47
|
return {"privateKey": self.privateKey, "certificate": self.certificate, "domains": self.domains}
|
|
@@ -28,7 +28,7 @@ class AcmeCertIssuer(CertIssuer):
|
|
|
28
28
|
account_key_name: str = "acme_account.key",
|
|
29
29
|
acme_url: str = None,
|
|
30
30
|
) -> "AcmeCertIssuer":
|
|
31
|
-
account_key, _ = key_store._get_or_generate_key(account_key_name)
|
|
31
|
+
account_key, _ = key_store._get_or_generate_key(account_key_name, "rsa")
|
|
32
32
|
return AcmeCertIssuer(account_key, challenge_solver, acme_url=acme_url)
|
|
33
33
|
|
|
34
34
|
def setup(self):
|
|
@@ -103,7 +103,7 @@ class AcmeCertIssuer(CertIssuer):
|
|
|
103
103
|
def generate_key_and_cert_for_domains(
|
|
104
104
|
self,
|
|
105
105
|
hosts: Union[str, List[str]],
|
|
106
|
-
key_type: Literal["rsa", "ecdsa", "ed25519"] = "
|
|
106
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
107
107
|
expiry_days: int = 90,
|
|
108
108
|
country: Optional[str] = None,
|
|
109
109
|
state: Optional[str] = None,
|
|
@@ -130,7 +130,7 @@ class AcmeCertIssuer(CertIssuer):
|
|
|
130
130
|
def generate_key_and_cert_for_domain(
|
|
131
131
|
self,
|
|
132
132
|
host: str,
|
|
133
|
-
key_type: Literal["rsa", "ecdsa", "ed25519"] = "
|
|
133
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
134
134
|
expiry_days: int = 90,
|
|
135
135
|
country: Optional[str] = None,
|
|
136
136
|
state: Optional[str] = None,
|
|
@@ -30,7 +30,7 @@ class CertIssuer(ABC):
|
|
|
30
30
|
def generate_key_and_cert_for_domains(
|
|
31
31
|
self,
|
|
32
32
|
hosts: Union[str, List[str]],
|
|
33
|
-
key_type: Literal["rsa", "ecdsa", "ed25519"] = "
|
|
33
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
34
34
|
expiry_days: int = 90,
|
|
35
35
|
country: Optional[str] = None,
|
|
36
36
|
state: Optional[str] = None,
|
|
@@ -57,7 +57,7 @@ class CertIssuer(ABC):
|
|
|
57
57
|
def generate_key_and_cert_for_domain(
|
|
58
58
|
self,
|
|
59
59
|
host: str,
|
|
60
|
-
key_type: Literal["rsa", "ecdsa", "ed25519"] = "
|
|
60
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
61
61
|
expiry_days: int = 90,
|
|
62
62
|
country: Optional[str] = None,
|
|
63
63
|
state: Optional[str] = None,
|
|
@@ -66,7 +66,7 @@ class AcmeCertManager:
|
|
|
66
66
|
def issue_certificate(
|
|
67
67
|
self,
|
|
68
68
|
hosts: Union[str, List[str]],
|
|
69
|
-
key_type: Literal["rsa", "ecdsa", "ed25519"] = "
|
|
69
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
70
70
|
expiry_days: int = 90,
|
|
71
71
|
country: Optional[str] = None,
|
|
72
72
|
state: Optional[str] = None,
|
|
@@ -3,8 +3,9 @@ import threading
|
|
|
3
3
|
import os
|
|
4
4
|
from flask import request, jsonify
|
|
5
5
|
from cryptography.x509 import CertificateSigningRequest
|
|
6
|
-
from flask_restx import Resource, reqparse, fields
|
|
7
|
-
|
|
6
|
+
from flask_restx import Resource, reqparse, fields, inputs
|
|
7
|
+
from certapi.challenge_solver import FilesystemChallengeSolver
|
|
8
|
+
from certapi.http.types import CertificateResponse
|
|
8
9
|
from certapi import AcmeCertManager
|
|
9
10
|
|
|
10
11
|
|
|
@@ -15,37 +16,38 @@ class RenewalQueueFullError(Exception):
|
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
class RenewalLockManager:
|
|
18
|
-
def __init__(self, queue_size: int =
|
|
19
|
+
def __init__(self, queue_size: int = 16):
|
|
19
20
|
self.queue_size = queue_size
|
|
20
21
|
self._lock = threading.Lock()
|
|
21
22
|
self._condition = threading.Condition(self._lock)
|
|
22
|
-
self.
|
|
23
|
+
self._busy = False
|
|
23
24
|
self._waiting_threads = 0
|
|
24
25
|
|
|
25
|
-
def acquire(self, domains):
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
for h in domains:
|
|
40
|
-
self._renewing_domains.add(h)
|
|
41
|
-
break
|
|
42
|
-
|
|
43
|
-
def release(self, domains):
|
|
26
|
+
def acquire(self, domains=None):
|
|
27
|
+
with self._lock:
|
|
28
|
+
while self._busy:
|
|
29
|
+
if self._waiting_threads >= self.queue_size:
|
|
30
|
+
raise RenewalQueueFullError("Renewal queue is full, please try again later")
|
|
31
|
+
|
|
32
|
+
self._waiting_threads += 1
|
|
33
|
+
self._condition.wait()
|
|
34
|
+
self._waiting_threads -= 1
|
|
35
|
+
|
|
36
|
+
# Mark as busy
|
|
37
|
+
self._busy = True
|
|
38
|
+
|
|
39
|
+
def release(self, domains=None):
|
|
44
40
|
with self._lock:
|
|
45
|
-
|
|
46
|
-
self._renewing_domains.discard(h)
|
|
41
|
+
self._busy = False
|
|
47
42
|
self._condition.notify_all()
|
|
48
43
|
|
|
44
|
+
def __enter__(self):
|
|
45
|
+
self.acquire()
|
|
46
|
+
return self
|
|
47
|
+
|
|
48
|
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
49
|
+
self.release()
|
|
50
|
+
|
|
49
51
|
|
|
50
52
|
def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size: int = 5):
|
|
51
53
|
|
|
@@ -89,6 +91,12 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
89
91
|
obtain_parser.add_argument("organization", type=str, help="Organization name")
|
|
90
92
|
obtain_parser.add_argument("user_id", type=str, help="User ID")
|
|
91
93
|
obtain_parser.add_argument("renew_threshold_days", type=int, help="Threshold in days for certificate reuse")
|
|
94
|
+
obtain_parser.add_argument(
|
|
95
|
+
"skip_failing",
|
|
96
|
+
type=inputs.boolean,
|
|
97
|
+
default=False,
|
|
98
|
+
help="Allow issuance to proceed if some domains fail verification",
|
|
99
|
+
)
|
|
92
100
|
|
|
93
101
|
@api_ns.route("/obtain")
|
|
94
102
|
class ObtainCert(Resource):
|
|
@@ -102,10 +110,24 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
102
110
|
def get(self):
|
|
103
111
|
args = obtain_parser.parse_args()
|
|
104
112
|
hostnames = args["hostname"]
|
|
113
|
+
skip_failing = args.get("skip_failing", False)
|
|
114
|
+
|
|
115
|
+
with lock_manager:
|
|
116
|
+
verified_hostnames = []
|
|
117
|
+
for h in hostnames:
|
|
118
|
+
# Find the first solver that supports this domain
|
|
119
|
+
for solver in reversed(cert_manager.challenge_solvers):
|
|
120
|
+
if solver.supports_domain_strict(h):
|
|
121
|
+
verified_hostnames.append(h)
|
|
122
|
+
|
|
123
|
+
hostnames = verified_hostnames
|
|
124
|
+
|
|
125
|
+
if not hostnames and not skip_failing:
|
|
126
|
+
api_ns.abort(400, message="None of the domains are owned by this machine or could be verified")
|
|
127
|
+
|
|
128
|
+
if not hostnames:
|
|
129
|
+
return CertificateResponse().to_json()
|
|
105
130
|
|
|
106
|
-
# Acquire lock for the domains
|
|
107
|
-
lock_manager.acquire(hostnames)
|
|
108
|
-
try:
|
|
109
131
|
data = cert_manager.issue_certificate(
|
|
110
132
|
hostnames,
|
|
111
133
|
key_type=args["key_type"],
|
|
@@ -117,15 +139,13 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
117
139
|
user_id=args["user_id"],
|
|
118
140
|
renew_threshold_days=args.get("renew_threshold_days"),
|
|
119
141
|
)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
else:
|
|
128
|
-
api_ns.abort(500, message="something went wrong")
|
|
142
|
+
|
|
143
|
+
print(data)
|
|
144
|
+
if data:
|
|
145
|
+
res_json = data.to_json()
|
|
146
|
+
return res_json
|
|
147
|
+
else:
|
|
148
|
+
api_ns.abort(500, message="something went wrong")
|
|
129
149
|
|
|
130
150
|
@api_ns.route("/sign_csr")
|
|
131
151
|
class SignCsr(Resource):
|