certapi 0.6.0__tar.gz → 1.0.0__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.6.0/src/certapi.egg-info → certapi-1.0.0}/PKG-INFO +47 -6
- certapi-1.0.0/README.md +93 -0
- {certapi-0.6.0 → certapi-1.0.0}/setup.py +1 -1
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/AcmeError.py +2 -5
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/http/HttpClientBase.py +1 -1
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/issuers/AcmeCertIssuer.py +10 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/manager/acme_cert_manager.py +11 -3
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/server/api.py +62 -11
- {certapi-0.6.0 → certapi-1.0.0/src/certapi.egg-info}/PKG-INFO +47 -6
- {certapi-0.6.0 → certapi-1.0.0}/tests/test_http_error_handling.py +33 -43
- {certapi-0.6.0 → certapi-1.0.0}/tests/test_keystores.py +1 -1
- certapi-0.6.0/README.md +0 -52
- {certapi-0.6.0 → certapi-1.0.0}/MANIFEST.in +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/pyproject.toml +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/setup.cfg +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/Acme.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/Challenge.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/Order.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/acme/http.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/client/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/client/cert_manager_client.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/crypto/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/crypto/crypto.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/errors.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/http/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/http/types.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/issuers/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/keystore/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/manager/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/server/__init__.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/server/cert_api.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/server/key_api.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/util.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi/utils.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi.egg-info/SOURCES.txt +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-0.6.0 → certapi-1.0.0}/tests/test_certs_with_key_types.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
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
|
|
@@ -27,7 +27,10 @@ Dynamic: summary
|
|
|
27
27
|
CertApi is a Python package for requesting SSL certificates from ACME.
|
|
28
28
|
This is to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
[](https://github.com/mesudip/certapi/actions/workflows/tests.yml)
|
|
31
|
+
[](https://codecov.io/github/mesudip/certapi)
|
|
32
|
+
[](https://pypi.org/project/certapi/)
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
33
36
|
|
|
@@ -39,7 +42,7 @@ You can install CertApi using pip
|
|
|
39
42
|
pip install certapi
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
## Example:
|
|
45
|
+
## Example: Low Leve API : Certificate with Cloudflare
|
|
43
46
|
|
|
44
47
|
```python
|
|
45
48
|
import json
|
|
@@ -51,7 +54,7 @@ from certapi import CertApiException, CloudflareChallengeSolver, Key, AcmeCertIs
|
|
|
51
54
|
challenge_solver = CloudflareChallengeSolver(api_key=None)
|
|
52
55
|
|
|
53
56
|
## initialize cert issuer with a new account key
|
|
54
|
-
cert_issuer = AcmeCertIssuer(Key.generate('
|
|
57
|
+
cert_issuer = AcmeCertIssuer(Key.generate('ecdsa'), challenge_solver)
|
|
55
58
|
|
|
56
59
|
# Preform setup i.e. fetching directory and registering ACME account
|
|
57
60
|
cert_issuer.setup()
|
|
@@ -70,7 +73,45 @@ except CertApiException as e:
|
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
|
|
73
|
-
## Example:
|
|
76
|
+
## Example: High Level API (with AcmeCertManager)
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
The `AcmeCertManager` provides a high-level interface that handles certificate storage, automatic renewal checks, and multi-solver management.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from certapi import (
|
|
82
|
+
AcmeCertManager,
|
|
83
|
+
FileSystemKeyStore,
|
|
84
|
+
AcmeCertIssuer,
|
|
85
|
+
CloudflareChallengeSolver
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 1. Setup KeyStore to persist keys and certificates
|
|
89
|
+
key_store = FileSystemKeyStore("db")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# DNS-01 via Cloudflare (e.g. for wildcard certs or internal domains)
|
|
93
|
+
dns_solver = CloudflareChallengeSolver(api_token="your-cloudflare-token")
|
|
94
|
+
|
|
95
|
+
# 3. Initialize and Setup AcmeCertManager
|
|
96
|
+
# Create cert issuer with the default challenge solver
|
|
97
|
+
cert_issuer = AcmeCertIssuer.with_keystore(key_store, dns_solver)
|
|
98
|
+
|
|
99
|
+
cert_manager = AcmeCertManager(
|
|
100
|
+
key_store=key_store,
|
|
101
|
+
cert_issuer=cert_issuer,
|
|
102
|
+
challenge_solvers=[dns_solver], # other solvers can be used
|
|
103
|
+
renew_threshold_days=7
|
|
104
|
+
)
|
|
105
|
+
cert_manager.setup()
|
|
106
|
+
|
|
107
|
+
# 4. Issue or Reuse Certificate
|
|
108
|
+
# Automatically checks keystore and renews only if necessary
|
|
109
|
+
response = cert_manager.issue_certificate(["example.com", "www.example.com"])
|
|
110
|
+
|
|
111
|
+
for cert_data in response.issued:
|
|
112
|
+
print(f"Newly issued for: {cert_data.domains}")
|
|
113
|
+
print(cert_data.cert)
|
|
114
|
+
|
|
115
|
+
for cert_data in response.existing:
|
|
116
|
+
print(f"Reusing existing for: {cert_data.domains}")
|
|
76
117
|
```
|
certapi-1.0.0/README.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
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
|
+
[](https://github.com/mesudip/certapi/actions/workflows/tests.yml)
|
|
7
|
+
[](https://codecov.io/github/mesudip/certapi)
|
|
8
|
+
[](https://pypi.org/project/certapi/)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
You can install CertApi using pip
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install certapi
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Example: Low Leve API : Certificate with Cloudflare
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import json
|
|
25
|
+
from certapi import CertApiException, CloudflareChallengeSolver, Key, AcmeCertIssuer
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# Initialize the Cloudflare challenge solver
|
|
29
|
+
# The API key is read from the CLOUDFLARE_API_KEY environment variable, or you can set it below.
|
|
30
|
+
challenge_solver = CloudflareChallengeSolver(api_key=None)
|
|
31
|
+
|
|
32
|
+
## initialize cert issuer with a new account key
|
|
33
|
+
cert_issuer = AcmeCertIssuer(Key.generate('ecdsa'), challenge_solver)
|
|
34
|
+
|
|
35
|
+
# Preform setup i.e. fetching directory and registering ACME account
|
|
36
|
+
cert_issuer.setup()
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
# Obtain a certificate for your domain
|
|
40
|
+
(key, cert) = cert_issuer.generate_key_and_cert_for_domain("your-domain.com")
|
|
41
|
+
|
|
42
|
+
print("------ Private Key -----")
|
|
43
|
+
print(key.to_pem())
|
|
44
|
+
print("------- Certificate ------")
|
|
45
|
+
print(cert)
|
|
46
|
+
except CertApiException as e:
|
|
47
|
+
print(f"An error occurred:", json.dumps(e.json_obj(), indent=2))
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
## Example: High Level API (with AcmeCertManager)
|
|
53
|
+
|
|
54
|
+
The `AcmeCertManager` provides a high-level interface that handles certificate storage, automatic renewal checks, and multi-solver management.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from certapi import (
|
|
58
|
+
AcmeCertManager,
|
|
59
|
+
FileSystemKeyStore,
|
|
60
|
+
AcmeCertIssuer,
|
|
61
|
+
CloudflareChallengeSolver
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
# 1. Setup KeyStore to persist keys and certificates
|
|
65
|
+
key_store = FileSystemKeyStore("db")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# DNS-01 via Cloudflare (e.g. for wildcard certs or internal domains)
|
|
69
|
+
dns_solver = CloudflareChallengeSolver(api_token="your-cloudflare-token")
|
|
70
|
+
|
|
71
|
+
# 3. Initialize and Setup AcmeCertManager
|
|
72
|
+
# Create cert issuer with the default challenge solver
|
|
73
|
+
cert_issuer = AcmeCertIssuer.with_keystore(key_store, dns_solver)
|
|
74
|
+
|
|
75
|
+
cert_manager = AcmeCertManager(
|
|
76
|
+
key_store=key_store,
|
|
77
|
+
cert_issuer=cert_issuer,
|
|
78
|
+
challenge_solvers=[dns_solver], # other solvers can be used
|
|
79
|
+
renew_threshold_days=7
|
|
80
|
+
)
|
|
81
|
+
cert_manager.setup()
|
|
82
|
+
|
|
83
|
+
# 4. Issue or Reuse Certificate
|
|
84
|
+
# Automatically checks keystore and renews only if necessary
|
|
85
|
+
response = cert_manager.issue_certificate(["example.com", "www.example.com"])
|
|
86
|
+
|
|
87
|
+
for cert_data in response.issued:
|
|
88
|
+
print(f"Newly issued for: {cert_data.domains}")
|
|
89
|
+
print(cert_data.cert)
|
|
90
|
+
|
|
91
|
+
for cert_data in response.existing:
|
|
92
|
+
print(f"Reusing existing for: {cert_data.domains}")
|
|
93
|
+
```
|
|
@@ -108,10 +108,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
|
|
|
108
108
|
hostname = error.get("hostname", "unknown")
|
|
109
109
|
status = error.get("status", "unknown")
|
|
110
110
|
message = (
|
|
111
|
-
str(hostname)
|
|
112
|
-
+ " Status="
|
|
113
|
-
+ str(status)
|
|
114
|
-
+ ": Invalid response in challenge url"
|
|
111
|
+
str(hostname) + " Status=" + str(status) + ": Invalid response in challenge url"
|
|
115
112
|
)
|
|
116
113
|
else:
|
|
117
114
|
message = err_detail
|
|
@@ -143,4 +140,4 @@ class AcmeInvalidNonceError(AcmeHttpError):
|
|
|
143
140
|
def __init__(self, response: requests.Response, step: str):
|
|
144
141
|
super().__init__(response, step)
|
|
145
142
|
self.can_retry = True
|
|
146
|
-
self.response=0
|
|
143
|
+
self.response = 0
|
|
@@ -80,7 +80,7 @@ class HttpClientBase(object):
|
|
|
80
80
|
return self._req(method, url, step, json_data, data, params, timeout)
|
|
81
81
|
except CertApiException as e:
|
|
82
82
|
if e.can_retry and i < retries:
|
|
83
|
-
retry_delay = e.retry_delay if hasattr(e,
|
|
83
|
+
retry_delay = e.retry_delay if hasattr(e, "retry_delay") else delay
|
|
84
84
|
print(f"Retrying {method} {url} in {retry_delay} seconds due to retryable error: {e.message}")
|
|
85
85
|
time.sleep(retry_delay)
|
|
86
86
|
else:
|
|
@@ -21,6 +21,16 @@ class AcmeCertIssuer(CertIssuer):
|
|
|
21
21
|
self.challenge_solver = challenge_solver
|
|
22
22
|
self.self_verify_challenge = self_verify_challenge
|
|
23
23
|
|
|
24
|
+
@staticmethod
|
|
25
|
+
def with_keystore(
|
|
26
|
+
key_store: "KeyStore",
|
|
27
|
+
challenge_solver: ChallengeSolver,
|
|
28
|
+
account_key_name: str = "acme_account.key",
|
|
29
|
+
acme_url: str = None,
|
|
30
|
+
) -> "AcmeCertIssuer":
|
|
31
|
+
account_key, _ = key_store._get_or_generate_key(account_key_name)
|
|
32
|
+
return AcmeCertIssuer(account_key, challenge_solver, acme_url=acme_url)
|
|
33
|
+
|
|
24
34
|
def setup(self):
|
|
25
35
|
self.acme.setup()
|
|
26
36
|
res: Response = self.acme.register()
|
|
@@ -12,6 +12,8 @@ from ..keystore.KeyStore import KeyStore
|
|
|
12
12
|
from cryptography.x509 import Certificate, CertificateSigningRequest
|
|
13
13
|
from ..crypto import Key, certs_to_pem, cert_to_pem, get_csr_hostnames
|
|
14
14
|
|
|
15
|
+
DEFAULT_RENEW_THRESHOLD_DAYS = 62
|
|
16
|
+
|
|
15
17
|
|
|
16
18
|
class AcmeCertManager:
|
|
17
19
|
def __init__(
|
|
@@ -19,10 +21,14 @@ class AcmeCertManager:
|
|
|
19
21
|
key_store: KeyStore,
|
|
20
22
|
cert_issuer: AcmeCertIssuer,
|
|
21
23
|
challenge_solvers: List[ChallengeSolver] = [],
|
|
24
|
+
renew_threshold_days: int = DEFAULT_RENEW_THRESHOLD_DAYS, # Renewal will be accepted if cert is valid for less than 75 days
|
|
22
25
|
):
|
|
23
26
|
self.key_store: KeyStore = key_store
|
|
24
27
|
self.cert_issuer: AcmeCertIssuer = cert_issuer
|
|
25
28
|
self.challenge_solvers: List[ChallengeSolver] = challenge_solvers
|
|
29
|
+
self.renew_threshold_days: int = (
|
|
30
|
+
DEFAULT_RENEW_THRESHOLD_DAYS if renew_threshold_days is None else renew_threshold_days
|
|
31
|
+
)
|
|
26
32
|
|
|
27
33
|
def setup(self):
|
|
28
34
|
names = [solver.__class__.__name__.replace("ChallengeSolver", "") for solver in self.challenge_solvers]
|
|
@@ -67,6 +73,7 @@ class AcmeCertManager:
|
|
|
67
73
|
locality: Optional[str] = None,
|
|
68
74
|
organization: Optional[str] = None,
|
|
69
75
|
user_id: Optional[str] = None,
|
|
76
|
+
renew_threshold_days: Optional[int] = None,
|
|
70
77
|
) -> CertificateResponse:
|
|
71
78
|
|
|
72
79
|
if type(hosts) == str:
|
|
@@ -77,10 +84,11 @@ class AcmeCertManager:
|
|
|
77
84
|
result = self.key_store.find_key_and_cert_by_domain(h)
|
|
78
85
|
if result is not None:
|
|
79
86
|
# result is (domain_id, key, cert_list)
|
|
80
|
-
cert=result[2][0]
|
|
87
|
+
cert = result[2][0]
|
|
81
88
|
invalid_date = cert.not_valid_after_utc
|
|
82
|
-
# Check if the certificate is still valid for at least
|
|
83
|
-
if
|
|
89
|
+
# Check if the certificate is still valid for at least renew_threshold_days
|
|
90
|
+
threshold = renew_threshold_days if renew_threshold_days is not None else self.renew_threshold_days
|
|
91
|
+
if invalid_date > datetime.now(timezone.utc) + timedelta(days=threshold):
|
|
84
92
|
existing[h] = result
|
|
85
93
|
missing = [h for h in hosts if h not in existing]
|
|
86
94
|
if len(missing) > 0:
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import threading
|
|
3
|
+
import os
|
|
2
4
|
from flask import request, jsonify
|
|
3
5
|
from cryptography.x509 import CertificateSigningRequest
|
|
4
6
|
from flask_restx import Resource, reqparse, fields
|
|
@@ -6,7 +8,48 @@ from flask_restx import Resource, reqparse, fields
|
|
|
6
8
|
from certapi import AcmeCertManager
|
|
7
9
|
|
|
8
10
|
|
|
9
|
-
|
|
11
|
+
class RenewalQueueFullError(Exception):
|
|
12
|
+
"""Exception raised when the renewal queue is full."""
|
|
13
|
+
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class RenewalLockManager:
|
|
18
|
+
def __init__(self, queue_size: int = 5):
|
|
19
|
+
self.queue_size = queue_size
|
|
20
|
+
self._lock = threading.Lock()
|
|
21
|
+
self._condition = threading.Condition(self._lock)
|
|
22
|
+
self._renewing_domains = set()
|
|
23
|
+
self._waiting_threads = 0
|
|
24
|
+
|
|
25
|
+
def acquire(self, domains):
|
|
26
|
+
while True:
|
|
27
|
+
with self._lock:
|
|
28
|
+
# Check if any domain is already being renewed by another thread
|
|
29
|
+
if any(h in self._renewing_domains for h in domains):
|
|
30
|
+
if self._waiting_threads >= self.queue_size:
|
|
31
|
+
raise RenewalQueueFullError("Proper queue is full")
|
|
32
|
+
|
|
33
|
+
self._waiting_threads += 1
|
|
34
|
+
self._condition.wait()
|
|
35
|
+
self._waiting_threads -= 1
|
|
36
|
+
continue # Re-check everything after waking up
|
|
37
|
+
|
|
38
|
+
# Mark as renewing
|
|
39
|
+
for h in domains:
|
|
40
|
+
self._renewing_domains.add(h)
|
|
41
|
+
break
|
|
42
|
+
|
|
43
|
+
def release(self, domains):
|
|
44
|
+
with self._lock:
|
|
45
|
+
for h in domains:
|
|
46
|
+
self._renewing_domains.discard(h)
|
|
47
|
+
self._condition.notify_all()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size: int = 5):
|
|
51
|
+
|
|
52
|
+
lock_manager = RenewalLockManager(queue_size=renew_queue_size)
|
|
10
53
|
|
|
11
54
|
# Models for documentation
|
|
12
55
|
issued_cert_model = api_ns.model(
|
|
@@ -45,6 +88,7 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager):
|
|
|
45
88
|
obtain_parser.add_argument("locality", type=str, help="Locality name")
|
|
46
89
|
obtain_parser.add_argument("organization", type=str, help="Organization name")
|
|
47
90
|
obtain_parser.add_argument("user_id", type=str, help="User ID")
|
|
91
|
+
obtain_parser.add_argument("renew_threshold_days", type=int, help="Threshold in days for certificate reuse")
|
|
48
92
|
|
|
49
93
|
@api_ns.route("/obtain")
|
|
50
94
|
class ObtainCert(Resource):
|
|
@@ -59,16 +103,23 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager):
|
|
|
59
103
|
args = obtain_parser.parse_args()
|
|
60
104
|
hostnames = args["hostname"]
|
|
61
105
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
106
|
+
# Acquire lock for the domains
|
|
107
|
+
lock_manager.acquire(hostnames)
|
|
108
|
+
try:
|
|
109
|
+
data = cert_manager.issue_certificate(
|
|
110
|
+
hostnames,
|
|
111
|
+
key_type=args["key_type"],
|
|
112
|
+
expiry_days=args["expiry_days"],
|
|
113
|
+
country=args["country"],
|
|
114
|
+
state=args["state"],
|
|
115
|
+
locality=args["locality"],
|
|
116
|
+
organization=args["organization"],
|
|
117
|
+
user_id=args["user_id"],
|
|
118
|
+
renew_threshold_days=args.get("renew_threshold_days"),
|
|
119
|
+
)
|
|
120
|
+
finally:
|
|
121
|
+
lock_manager.release(hostnames)
|
|
122
|
+
|
|
72
123
|
print(data)
|
|
73
124
|
print(data.to_json())
|
|
74
125
|
if data:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: certapi
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 1.0.0
|
|
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
|
|
@@ -27,7 +27,10 @@ Dynamic: summary
|
|
|
27
27
|
CertApi is a Python package for requesting SSL certificates from ACME.
|
|
28
28
|
This is to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
|
|
29
29
|
|
|
30
|
-
|
|
30
|
+
[](https://github.com/mesudip/certapi/actions/workflows/tests.yml)
|
|
31
|
+
[](https://codecov.io/github/mesudip/certapi)
|
|
32
|
+
[](https://pypi.org/project/certapi/)
|
|
33
|
+
|
|
31
34
|
|
|
32
35
|
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
33
36
|
|
|
@@ -39,7 +42,7 @@ You can install CertApi using pip
|
|
|
39
42
|
pip install certapi
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
## Example:
|
|
45
|
+
## Example: Low Leve API : Certificate with Cloudflare
|
|
43
46
|
|
|
44
47
|
```python
|
|
45
48
|
import json
|
|
@@ -51,7 +54,7 @@ from certapi import CertApiException, CloudflareChallengeSolver, Key, AcmeCertIs
|
|
|
51
54
|
challenge_solver = CloudflareChallengeSolver(api_key=None)
|
|
52
55
|
|
|
53
56
|
## initialize cert issuer with a new account key
|
|
54
|
-
cert_issuer = AcmeCertIssuer(Key.generate('
|
|
57
|
+
cert_issuer = AcmeCertIssuer(Key.generate('ecdsa'), challenge_solver)
|
|
55
58
|
|
|
56
59
|
# Preform setup i.e. fetching directory and registering ACME account
|
|
57
60
|
cert_issuer.setup()
|
|
@@ -70,7 +73,45 @@ except CertApiException as e:
|
|
|
70
73
|
```
|
|
71
74
|
|
|
72
75
|
|
|
73
|
-
## Example:
|
|
76
|
+
## Example: High Level API (with AcmeCertManager)
|
|
74
77
|
|
|
75
|
-
|
|
78
|
+
The `AcmeCertManager` provides a high-level interface that handles certificate storage, automatic renewal checks, and multi-solver management.
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from certapi import (
|
|
82
|
+
AcmeCertManager,
|
|
83
|
+
FileSystemKeyStore,
|
|
84
|
+
AcmeCertIssuer,
|
|
85
|
+
CloudflareChallengeSolver
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# 1. Setup KeyStore to persist keys and certificates
|
|
89
|
+
key_store = FileSystemKeyStore("db")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# DNS-01 via Cloudflare (e.g. for wildcard certs or internal domains)
|
|
93
|
+
dns_solver = CloudflareChallengeSolver(api_token="your-cloudflare-token")
|
|
94
|
+
|
|
95
|
+
# 3. Initialize and Setup AcmeCertManager
|
|
96
|
+
# Create cert issuer with the default challenge solver
|
|
97
|
+
cert_issuer = AcmeCertIssuer.with_keystore(key_store, dns_solver)
|
|
98
|
+
|
|
99
|
+
cert_manager = AcmeCertManager(
|
|
100
|
+
key_store=key_store,
|
|
101
|
+
cert_issuer=cert_issuer,
|
|
102
|
+
challenge_solvers=[dns_solver], # other solvers can be used
|
|
103
|
+
renew_threshold_days=7
|
|
104
|
+
)
|
|
105
|
+
cert_manager.setup()
|
|
106
|
+
|
|
107
|
+
# 4. Issue or Reuse Certificate
|
|
108
|
+
# Automatically checks keystore and renews only if necessary
|
|
109
|
+
response = cert_manager.issue_certificate(["example.com", "www.example.com"])
|
|
110
|
+
|
|
111
|
+
for cert_data in response.issued:
|
|
112
|
+
print(f"Newly issued for: {cert_data.domains}")
|
|
113
|
+
print(cert_data.cert)
|
|
114
|
+
|
|
115
|
+
for cert_data in response.existing:
|
|
116
|
+
print(f"Reusing existing for: {cert_data.domains}")
|
|
76
117
|
```
|
|
@@ -12,23 +12,19 @@ class TestHttpErrorHandling:
|
|
|
12
12
|
def http_client(self):
|
|
13
13
|
"""Create a basic HttpClientBase instance for testing"""
|
|
14
14
|
return HttpClientBase(
|
|
15
|
-
base_url="https://example.com",
|
|
16
|
-
headers={"Content-Type": "application/json"},
|
|
17
|
-
auto_retry=True
|
|
15
|
+
base_url="https://example.com", headers={"Content-Type": "application/json"}, auto_retry=True
|
|
18
16
|
)
|
|
19
17
|
|
|
20
18
|
def test_connection_reset_error_handling(self, http_client):
|
|
21
19
|
"""Test that ConnectionResetError is properly caught and converted to NetworkError"""
|
|
22
|
-
with patch.object(http_client.session,
|
|
20
|
+
with patch.object(http_client.session, "request") as mock_request:
|
|
23
21
|
# Simulate a ConnectionResetError wrapped in requests.exceptions.ConnectionError
|
|
24
|
-
connection_reset = ConnectionResetError(104,
|
|
25
|
-
mock_request.side_effect = requests.exceptions.ConnectionError(
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
22
|
+
connection_reset = ConnectionResetError(104, "Connection reset by peer")
|
|
23
|
+
mock_request.side_effect = requests.exceptions.ConnectionError(connection_reset)
|
|
24
|
+
|
|
29
25
|
with pytest.raises(NetworkError) as exc_info:
|
|
30
26
|
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
31
|
-
|
|
27
|
+
|
|
32
28
|
# Verify the NetworkError is properly created
|
|
33
29
|
error = exc_info.value
|
|
34
30
|
assert error.can_retry is True
|
|
@@ -37,33 +33,33 @@ class TestHttpErrorHandling:
|
|
|
37
33
|
|
|
38
34
|
def test_connection_error_is_retriable(self, http_client):
|
|
39
35
|
"""Test that connection errors are marked as retriable"""
|
|
40
|
-
with patch.object(http_client.session,
|
|
36
|
+
with patch.object(http_client.session, "request") as mock_request:
|
|
41
37
|
mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
|
42
|
-
|
|
38
|
+
|
|
43
39
|
with pytest.raises(NetworkError) as exc_info:
|
|
44
40
|
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
45
|
-
|
|
41
|
+
|
|
46
42
|
error = exc_info.value
|
|
47
43
|
assert error.can_retry is True
|
|
48
|
-
assert hasattr(error,
|
|
44
|
+
assert hasattr(error, "retry_delay")
|
|
49
45
|
assert error.retry_delay == 4 # Default retry delay
|
|
50
46
|
|
|
51
47
|
def test_timeout_error_is_retriable(self, http_client):
|
|
52
48
|
"""Test that timeout errors are marked as retriable"""
|
|
53
|
-
with patch.object(http_client.session,
|
|
49
|
+
with patch.object(http_client.session, "request") as mock_request:
|
|
54
50
|
mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
|
|
55
|
-
|
|
51
|
+
|
|
56
52
|
with pytest.raises(NetworkError) as exc_info:
|
|
57
53
|
http_client._req("GET", "https://example.com/test", "Test Step")
|
|
58
|
-
|
|
54
|
+
|
|
59
55
|
error = exc_info.value
|
|
60
56
|
assert error.can_retry is True
|
|
61
|
-
assert hasattr(error,
|
|
57
|
+
assert hasattr(error, "retry_delay")
|
|
62
58
|
|
|
63
59
|
def test_retry_uses_exception_delay(self, http_client):
|
|
64
60
|
"""Test that retry mechanism uses the delay from the exception"""
|
|
65
61
|
call_count = 0
|
|
66
|
-
|
|
62
|
+
|
|
67
63
|
def side_effect(*args, **kwargs):
|
|
68
64
|
nonlocal call_count
|
|
69
65
|
call_count += 1
|
|
@@ -73,13 +69,11 @@ class TestHttpErrorHandling:
|
|
|
73
69
|
mock_response = Mock()
|
|
74
70
|
mock_response.status_code = 200
|
|
75
71
|
return mock_response
|
|
76
|
-
|
|
77
|
-
with patch.object(http_client.session,
|
|
78
|
-
with patch(
|
|
79
|
-
result = http_client._req_with_retry(
|
|
80
|
-
|
|
81
|
-
)
|
|
82
|
-
|
|
72
|
+
|
|
73
|
+
with patch.object(http_client.session, "request", side_effect=side_effect):
|
|
74
|
+
with patch("time.sleep") as mock_sleep:
|
|
75
|
+
result = http_client._req_with_retry("GET", "https://example.com/test", "Test Step", retries=2)
|
|
76
|
+
|
|
83
77
|
assert result.status_code == 200
|
|
84
78
|
# Should have slept twice (after 1st and 2nd failures)
|
|
85
79
|
assert mock_sleep.call_count == 2
|
|
@@ -88,14 +82,12 @@ class TestHttpErrorHandling:
|
|
|
88
82
|
|
|
89
83
|
def test_retry_exhausted_marks_non_retriable(self, http_client):
|
|
90
84
|
"""Test that after exhausting retries, error is marked as non-retriable"""
|
|
91
|
-
with patch.object(http_client.session,
|
|
85
|
+
with patch.object(http_client.session, "request") as mock_request:
|
|
92
86
|
mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
|
|
93
|
-
|
|
87
|
+
|
|
94
88
|
with pytest.raises(NetworkError) as exc_info:
|
|
95
|
-
http_client._req_with_retry(
|
|
96
|
-
|
|
97
|
-
)
|
|
98
|
-
|
|
89
|
+
http_client._req_with_retry("GET", "https://example.com/test", "Test Step", retries=1)
|
|
90
|
+
|
|
99
91
|
error = exc_info.value
|
|
100
92
|
# After retries are exhausted, can_retry should be False
|
|
101
93
|
assert error.can_retry is False
|
|
@@ -104,30 +96,28 @@ class TestHttpErrorHandling:
|
|
|
104
96
|
"""Test that retry doesn't happen when auto_retry is False"""
|
|
105
97
|
http_client.auto_retry = False
|
|
106
98
|
call_count = 0
|
|
107
|
-
|
|
99
|
+
|
|
108
100
|
def side_effect(*args, **kwargs):
|
|
109
101
|
nonlocal call_count
|
|
110
102
|
call_count += 1
|
|
111
103
|
raise requests.exceptions.ConnectionError("Connection failed")
|
|
112
|
-
|
|
113
|
-
with patch.object(http_client.session,
|
|
104
|
+
|
|
105
|
+
with patch.object(http_client.session, "request", side_effect=side_effect):
|
|
114
106
|
with pytest.raises(NetworkError):
|
|
115
|
-
http_client._req_with_retry(
|
|
116
|
-
|
|
117
|
-
)
|
|
118
|
-
|
|
107
|
+
http_client._req_with_retry("GET", "https://example.com/test", "Test Step", retries=2)
|
|
108
|
+
|
|
119
109
|
# Should only be called once (no retries)
|
|
120
110
|
assert call_count == 1
|
|
121
111
|
|
|
122
112
|
def test_successful_request_no_error(self, http_client):
|
|
123
113
|
"""Test that successful requests don't raise errors"""
|
|
124
|
-
with patch.object(http_client.session,
|
|
114
|
+
with patch.object(http_client.session, "request") as mock_request:
|
|
125
115
|
mock_response = Mock()
|
|
126
116
|
mock_response.status_code = 200
|
|
127
117
|
mock_request.return_value = mock_response
|
|
128
|
-
|
|
118
|
+
|
|
129
119
|
result = http_client._req("GET", "https://example.com/test", "Test Step")
|
|
130
|
-
|
|
120
|
+
|
|
131
121
|
assert result.status_code == 200
|
|
132
122
|
|
|
133
123
|
def test_custom_retry_delay(self):
|
|
@@ -135,6 +125,6 @@ class TestHttpErrorHandling:
|
|
|
135
125
|
exception = CertApiException("Test error")
|
|
136
126
|
exception.retry_delay = 10
|
|
137
127
|
exception.can_retry = True
|
|
138
|
-
|
|
128
|
+
|
|
139
129
|
assert exception.retry_delay == 10
|
|
140
130
|
assert exception.can_retry is True
|
|
@@ -172,7 +172,7 @@ def sign_csr(csr: x509.CertificateSigningRequest, issuer_key: Key, days_valid=36
|
|
|
172
172
|
.public_key(csr.public_key())
|
|
173
173
|
.serial_number(x509.random_serial_number())
|
|
174
174
|
.not_valid_before(now)
|
|
175
|
-
.not_valid_after(now+ timedelta(days=days_valid))
|
|
175
|
+
.not_valid_after(now + timedelta(days=days_valid))
|
|
176
176
|
)
|
|
177
177
|
|
|
178
178
|
# Optionally copy extensions from CSR
|
certapi-0.6.0/README.md
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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
|
-
For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
|
|
9
|
-
|
|
10
|
-
## Installation
|
|
11
|
-
|
|
12
|
-
You can install CertApi using pip
|
|
13
|
-
|
|
14
|
-
```bash
|
|
15
|
-
pip install certapi
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
## Example: Obtain Certificate with Cloudflare
|
|
19
|
-
|
|
20
|
-
```python
|
|
21
|
-
import json
|
|
22
|
-
from certapi import CertApiException, CloudflareChallengeSolver, Key, AcmeCertIssuer
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# Initialize the Cloudflare challenge solver
|
|
26
|
-
# The API key is read from the CLOUDFLARE_API_KEY environment variable, or you can set it below.
|
|
27
|
-
challenge_solver = CloudflareChallengeSolver(api_key=None)
|
|
28
|
-
|
|
29
|
-
## initialize cert issuer with a new account key
|
|
30
|
-
cert_issuer = AcmeCertIssuer(Key.generate('rsa'), challenge_solver)
|
|
31
|
-
|
|
32
|
-
# Preform setup i.e. fetching directory and registering ACME account
|
|
33
|
-
cert_issuer.setup()
|
|
34
|
-
|
|
35
|
-
try:
|
|
36
|
-
# Obtain a certificate for your domain
|
|
37
|
-
(key, cert) = cert_issuer.generate_key_and_cert_for_domain("your-domain.com")
|
|
38
|
-
|
|
39
|
-
print("------ Private Key -----")
|
|
40
|
-
print(key.to_pem())
|
|
41
|
-
print("------- Certificate ------")
|
|
42
|
-
print(cert)
|
|
43
|
-
except CertApiException as e:
|
|
44
|
-
print(f"An error occurred:", json.dumps(e.json_obj(), indent=2))
|
|
45
|
-
|
|
46
|
-
```
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
## Example: Use High Leve API
|
|
50
|
-
|
|
51
|
-
```
|
|
52
|
-
```
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-0.6.0 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|