certapi 1.1.0__tar.gz → 1.1.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.1.0/src/certapi.egg-info → certapi-1.1.2}/PKG-INFO +1 -1
- {certapi-1.1.0 → certapi-1.1.2}/setup.py +1 -1
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/__init__.py +2 -1
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Acme.py +2 -1
- certapi-1.1.2/src/certapi/acme/http.py +69 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/cli.py +57 -11
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/client/__init__.py +1 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/client/cert_manager_client.py +30 -0
- certapi-1.1.2/src/certapi/client/renewal_manager.py +454 -0
- certapi-1.1.2/src/certapi/domain_batching.py +82 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/manager/acme_cert_manager.py +102 -21
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/api.py +12 -1
- {certapi-1.1.0 → certapi-1.1.2/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/SOURCES.txt +7 -1
- certapi-1.1.2/tests/test_acme_cert_manager_batching.py +95 -0
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_acme_error_handling.py +136 -6
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_cli.py +23 -3
- certapi-1.1.2/tests/test_domain_batching_boulder_cases.py +87 -0
- certapi-1.1.2/tests/test_obtain_interface.py +129 -0
- certapi-1.1.2/tests/test_renewal_manager.py +647 -0
- certapi-1.1.0/src/certapi/acme/http.py +0 -49
- {certapi-1.1.0 → certapi-1.1.2}/MANIFEST.in +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/README.md +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/pyproject.toml +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/setup.cfg +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/errors.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/types.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/util.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi/utils.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.0 → certapi-1.1.2}/tests/test_keystores.py +0 -0
|
@@ -21,4 +21,5 @@ from .challenge_solver import (
|
|
|
21
21
|
DigitalOceanChallengeSolver,
|
|
22
22
|
)
|
|
23
23
|
from .issuers import CertIssuer, SelfCertIssuer, AcmeCertIssuer
|
|
24
|
-
from .client import CertManagerClient
|
|
24
|
+
from .client import CertManagerClient, RenewalManager
|
|
25
|
+
from .domain_batching import create_safe_domain_batches
|
|
@@ -22,6 +22,7 @@ class Acme:
|
|
|
22
22
|
|
|
23
23
|
URL_STAGING = "https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
24
24
|
URL_PROD = "https://acme-v02.api.letsencrypt.org/directory"
|
|
25
|
+
MAX_RETRY_ATTEMPTS = 2
|
|
25
26
|
|
|
26
27
|
def __init__(self, account_key: Key, url=URL_STAGING):
|
|
27
28
|
self.account_key: Key = account_key
|
|
@@ -118,7 +119,7 @@ class Acme:
|
|
|
118
119
|
|
|
119
120
|
response = post(step, url, json=payload, headers={"Content-Type": "application/jose+json"}, throw=throw)
|
|
120
121
|
except AcmeError as e:
|
|
121
|
-
if e.can_retry and depth <= 1:
|
|
122
|
+
if e.can_retry and depth <= self.MAX_RETRY_ATTEMPTS - 1:
|
|
122
123
|
time.sleep(2)
|
|
123
124
|
return self._signed_req(url, req_payload, depth + 1, step, throw)
|
|
124
125
|
else:
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
from .AcmeError import *
|
|
3
|
+
import json as j
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
## I don't like the fact that the http request layer
|
|
7
|
+
## is doing retries internally. This has to be later refactored to be more explicit.
|
|
8
|
+
NETWORK_RETRY_ATTEMPTS = 2
|
|
9
|
+
NETWORK_RETRY_DELAY_SECONDS = 2
|
|
10
|
+
HTTP_RETRY_ATTEMPTS = 2
|
|
11
|
+
HTTP_RETRY_DELAY_SECONDS = 2
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
15
|
+
res = None
|
|
16
|
+
max_attempts = max(NETWORK_RETRY_ATTEMPTS, HTTP_RETRY_ATTEMPTS)
|
|
17
|
+
for attempt in range(max_attempts + 1):
|
|
18
|
+
try:
|
|
19
|
+
res = requests.request(method, url, json=json, headers=headers, timeout=15)
|
|
20
|
+
print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + step)
|
|
21
|
+
if throw and 500 <= res.status_code <= 599 and attempt < HTTP_RETRY_ATTEMPTS:
|
|
22
|
+
time.sleep(HTTP_RETRY_DELAY_SECONDS)
|
|
23
|
+
continue
|
|
24
|
+
break
|
|
25
|
+
except requests.RequestException as e:
|
|
26
|
+
if attempt < NETWORK_RETRY_ATTEMPTS:
|
|
27
|
+
time.sleep(NETWORK_RETRY_DELAY_SECONDS)
|
|
28
|
+
continue
|
|
29
|
+
print("Request : " + str(method) + " " + str(url) + " step=" + str(step))
|
|
30
|
+
acme_error = AcmeNetworkError(
|
|
31
|
+
e.request,
|
|
32
|
+
f"Error communicating with ACME server",
|
|
33
|
+
{
|
|
34
|
+
"errorType": e.__class__.__name__,
|
|
35
|
+
"message": str(e),
|
|
36
|
+
"method": method,
|
|
37
|
+
"url": e.request.url if e.request else None,
|
|
38
|
+
},
|
|
39
|
+
step,
|
|
40
|
+
)
|
|
41
|
+
# Network retries are already exhausted in this layer.
|
|
42
|
+
acme_error.can_retry = False
|
|
43
|
+
raise acme_error
|
|
44
|
+
if 199 <= res.status_code > 299:
|
|
45
|
+
if json:
|
|
46
|
+
print("Request:", j.dumps(json))
|
|
47
|
+
[print(x, y) for (x, y) in res.headers.items()]
|
|
48
|
+
print("Response:", res.text)
|
|
49
|
+
json_data = None
|
|
50
|
+
try:
|
|
51
|
+
json_data = res.json()
|
|
52
|
+
except requests.RequestException as e:
|
|
53
|
+
pass
|
|
54
|
+
if json_data and json_data.get("type"):
|
|
55
|
+
errorType = json_data["type"]
|
|
56
|
+
if errorType == "urn:ietf:params:acme:error:badNonce":
|
|
57
|
+
raise AcmeInvalidNonceError(res, step=step)
|
|
58
|
+
|
|
59
|
+
if throw:
|
|
60
|
+
raise AcmeHttpError(res, step=step)
|
|
61
|
+
return res
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
65
|
+
return request("POST", step, url, json=json, headers=headers, throw=throw)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get(step: str, url, headers=None, throw=True) -> requests.Response:
|
|
69
|
+
return request("GET", step, url, headers=headers, throw=throw)
|
|
@@ -111,7 +111,7 @@ def _ensure_port_80_available() -> None:
|
|
|
111
111
|
sys.exit(1)
|
|
112
112
|
|
|
113
113
|
|
|
114
|
-
def obtain_certificate(domains: List[str], api_key: Optional[str] = None):
|
|
114
|
+
def obtain_certificate(domains: List[str], api_key: Optional[str] = None, keystore_path: str = "/etc/ssl"):
|
|
115
115
|
challenge_solver = None
|
|
116
116
|
server = None
|
|
117
117
|
|
|
@@ -124,7 +124,6 @@ def obtain_certificate(domains: List[str], api_key: Optional[str] = None):
|
|
|
124
124
|
print("Starting HTTP challenge server on port 80...")
|
|
125
125
|
server, _ = _start_http_challenge_server(challenge_solver, port=80)
|
|
126
126
|
|
|
127
|
-
keystore_path = "/etc/ssl"
|
|
128
127
|
key_store = FileSystemKeyStore(keystore_path)
|
|
129
128
|
cert_issuer = AcmeCertIssuer.with_keystore(
|
|
130
129
|
key_store,
|
|
@@ -140,7 +139,8 @@ def obtain_certificate(domains: List[str], api_key: Optional[str] = None):
|
|
|
140
139
|
|
|
141
140
|
cert_chain = certs_from_pem(cert.encode("utf-8"))
|
|
142
141
|
leaf_cert = cert_chain[0] if cert_chain else None
|
|
143
|
-
|
|
142
|
+
expiry_dt = leaf_cert.not_valid_after_utc if leaf_cert else None
|
|
143
|
+
expiry = expiry_dt.isoformat() if expiry_dt else "unknown"
|
|
144
144
|
|
|
145
145
|
key_path = os.path.join(key_store.keys_dir, f"{key_name}.key")
|
|
146
146
|
cert_path = os.path.join(key_store.certs_dir, f"{key_name}.crt")
|
|
@@ -219,14 +219,60 @@ def verify_environment(domains: List[str], api_key: Optional[str] = None) -> Non
|
|
|
219
219
|
|
|
220
220
|
|
|
221
221
|
def main():
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
222
|
+
class HelpFormatter(argparse.ArgumentDefaultsHelpFormatter, argparse.RawDescriptionHelpFormatter):
|
|
223
|
+
pass
|
|
224
|
+
|
|
225
|
+
parser = argparse.ArgumentParser(
|
|
226
|
+
prog="certapi",
|
|
227
|
+
formatter_class=HelpFormatter,
|
|
228
|
+
description=(
|
|
229
|
+
"Issue and verify ACME certificates.\n\n"
|
|
230
|
+
"Challenge mode selection:\n"
|
|
231
|
+
"- If CLOUDFLARE_API_KEY or CLOUDFLARE_API_TOKEN is set, DNS-01 is used.\n"
|
|
232
|
+
"- Otherwise HTTP-01 is used (requires binding to port 80)."
|
|
233
|
+
),
|
|
234
|
+
epilog=(
|
|
235
|
+
"Cloudflare credentials are read from environment variables: "
|
|
236
|
+
"CLOUDFLARE_API_KEY or CLOUDFLARE_API_TOKEN.\n\n"
|
|
237
|
+
"Examples:\n"
|
|
238
|
+
" certapi verify example.com\n"
|
|
239
|
+
" certapi obtain --path ./certs example.com www.example.com\n"
|
|
240
|
+
" CLOUDFLARE_API_TOKEN=<token> certapi obtain --path . example.com"
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
subparsers = parser.add_subparsers(
|
|
244
|
+
dest="command",
|
|
245
|
+
title="commands",
|
|
246
|
+
metavar="{verify,obtain}",
|
|
247
|
+
)
|
|
227
248
|
|
|
228
|
-
|
|
229
|
-
|
|
249
|
+
verify_parser = subparsers.add_parser(
|
|
250
|
+
"verify",
|
|
251
|
+
help="Verify certapi installation and challenge readiness.",
|
|
252
|
+
description=(
|
|
253
|
+
"Verify environment readiness.\n"
|
|
254
|
+
"- With Cloudflare credentials: checks DNS zone ownership for provided domains.\n"
|
|
255
|
+
"- Without credentials: checks local HTTP challenge serving/routing for domains."
|
|
256
|
+
),
|
|
257
|
+
formatter_class=HelpFormatter,
|
|
258
|
+
)
|
|
259
|
+
verify_parser.add_argument("domains", nargs="*", help="Optional domain(s) to validate.")
|
|
260
|
+
|
|
261
|
+
obtain_parser = subparsers.add_parser(
|
|
262
|
+
"obtain",
|
|
263
|
+
help="Issue a certificate for one or more domains.",
|
|
264
|
+
description=(
|
|
265
|
+
"Obtain a certificate and write key/cert files to the selected path.\n"
|
|
266
|
+
"When Cloudflare credentials are set, DNS-01 challenge is used."
|
|
267
|
+
),
|
|
268
|
+
formatter_class=HelpFormatter,
|
|
269
|
+
)
|
|
270
|
+
obtain_parser.add_argument("domains", nargs="+", help="Domain(s) to include in the certificate.")
|
|
271
|
+
obtain_parser.add_argument(
|
|
272
|
+
"--path",
|
|
273
|
+
default="/etc/ssl",
|
|
274
|
+
help="Directory where key/cert files will be written.",
|
|
275
|
+
)
|
|
230
276
|
|
|
231
277
|
args = parser.parse_args()
|
|
232
278
|
|
|
@@ -236,7 +282,7 @@ def main():
|
|
|
236
282
|
sys.exit(0)
|
|
237
283
|
elif args.command == "obtain":
|
|
238
284
|
api_key = _resolve_cloudflare_api_key()
|
|
239
|
-
obtain_certificate(args.domains, api_key=api_key)
|
|
285
|
+
obtain_certificate(args.domains, api_key=api_key, keystore_path=args.path)
|
|
240
286
|
else:
|
|
241
287
|
parser.print_help()
|
|
242
288
|
sys.exit(1)
|
|
@@ -49,6 +49,35 @@ class CertManagerClient:
|
|
|
49
49
|
user_id: Optional[str] = None,
|
|
50
50
|
renew_threshold_days: Optional[int] = None,
|
|
51
51
|
skip_failing: bool = True,
|
|
52
|
+
batch_domains: bool = False,
|
|
53
|
+
) -> CertificateResponse:
|
|
54
|
+
return self.obtain(
|
|
55
|
+
hosts=hosts,
|
|
56
|
+
key_type=key_type,
|
|
57
|
+
expiry_days=expiry_days,
|
|
58
|
+
country=country,
|
|
59
|
+
state=state,
|
|
60
|
+
locality=locality,
|
|
61
|
+
organization=organization,
|
|
62
|
+
user_id=user_id,
|
|
63
|
+
renew_threshold_days=renew_threshold_days,
|
|
64
|
+
skip_failing=skip_failing,
|
|
65
|
+
batch_domains=batch_domains,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def obtain(
|
|
69
|
+
self,
|
|
70
|
+
hosts: Union[str, List[str]],
|
|
71
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
72
|
+
expiry_days: int = 90,
|
|
73
|
+
country: Optional[str] = None,
|
|
74
|
+
state: Optional[str] = None,
|
|
75
|
+
locality: Optional[str] = None,
|
|
76
|
+
organization: Optional[str] = None,
|
|
77
|
+
user_id: Optional[str] = None,
|
|
78
|
+
renew_threshold_days: Optional[int] = None,
|
|
79
|
+
skip_failing: bool = True,
|
|
80
|
+
batch_domains: bool = False,
|
|
52
81
|
) -> CertificateResponse:
|
|
53
82
|
params = {
|
|
54
83
|
"hostname": hosts if isinstance(hosts, str) else hosts,
|
|
@@ -68,6 +97,7 @@ class CertManagerClient:
|
|
|
68
97
|
if renew_threshold_days is not None:
|
|
69
98
|
params["renew_threshold_days"] = renew_threshold_days
|
|
70
99
|
params["skip_failing"] = skip_failing
|
|
100
|
+
params["batch_domains"] = batch_domains
|
|
71
101
|
|
|
72
102
|
data = self._get("/api/obtain", params=params)
|
|
73
103
|
res = CertificateResponse.from_json(data)
|