certapi 1.1.0__tar.gz → 1.1.1__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.1}/PKG-INFO +1 -1
- {certapi-1.1.0 → certapi-1.1.1}/setup.py +1 -1
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/__init__.py +1 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Acme.py +2 -1
- certapi-1.1.1/src/certapi/acme/http.py +69 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/cli.py +57 -11
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/client/cert_manager_client.py +2 -0
- certapi-1.1.1/src/certapi/domain_batching.py +82 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/manager/acme_cert_manager.py +77 -20
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/api.py +12 -1
- {certapi-1.1.0 → certapi-1.1.1/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/SOURCES.txt +3 -0
- certapi-1.1.1/tests/test_acme_cert_manager_batching.py +95 -0
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_acme_error_handling.py +136 -6
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_cli.py +23 -3
- certapi-1.1.1/tests/test_domain_batching_boulder_cases.py +87 -0
- certapi-1.1.0/src/certapi/acme/http.py +0 -49
- {certapi-1.1.0 → certapi-1.1.1}/MANIFEST.in +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/README.md +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/pyproject.toml +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/setup.cfg +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/client/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/errors.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/types.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/util.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi/utils.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.0 → certapi-1.1.1}/tests/test_keystores.py +0 -0
|
@@ -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,7 @@ 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,
|
|
52
53
|
) -> CertificateResponse:
|
|
53
54
|
params = {
|
|
54
55
|
"hostname": hosts if isinstance(hosts, str) else hosts,
|
|
@@ -68,6 +69,7 @@ class CertManagerClient:
|
|
|
68
69
|
if renew_threshold_days is not None:
|
|
69
70
|
params["renew_threshold_days"] = renew_threshold_days
|
|
70
71
|
params["skip_failing"] = skip_failing
|
|
72
|
+
params["batch_domains"] = batch_domains
|
|
71
73
|
|
|
72
74
|
data = self._get("/api/obtain", params=params)
|
|
73
75
|
res = CertificateResponse.from_json(data)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
def _normalize_domain(domain: str) -> str:
|
|
2
|
+
domain = domain.strip().lower().rstrip(".")
|
|
3
|
+
if domain.startswith("*."):
|
|
4
|
+
domain = domain[2:]
|
|
5
|
+
return domain
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _split_labels(domain: str) -> list[str]:
|
|
9
|
+
normalized = _normalize_domain(domain)
|
|
10
|
+
return [label for label in normalized.split(".") if label]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def would_trigger(labels: list[str], blocked: set[str]) -> bool:
|
|
14
|
+
"""
|
|
15
|
+
Boulder-style recursive on-demand trigger check used by tests.
|
|
16
|
+
"""
|
|
17
|
+
if len(labels) < 4:
|
|
18
|
+
return False
|
|
19
|
+
|
|
20
|
+
blocked = {label.lower() for label in blocked}
|
|
21
|
+
|
|
22
|
+
consecutive_blocked = 0
|
|
23
|
+
prev_label: str | None = None
|
|
24
|
+
for label in labels:
|
|
25
|
+
label = label.lower()
|
|
26
|
+
is_blocked = label in blocked
|
|
27
|
+
if is_blocked:
|
|
28
|
+
consecutive_blocked += 1
|
|
29
|
+
if prev_label is not None and prev_label == label:
|
|
30
|
+
return True
|
|
31
|
+
if consecutive_blocked >= 3:
|
|
32
|
+
return True
|
|
33
|
+
else:
|
|
34
|
+
consecutive_blocked = 0
|
|
35
|
+
prev_label = label
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def split_domain_to_safe_groups(domain: str, blocked_labels: list[str] | None = None) -> list[list[str]]:
|
|
40
|
+
"""
|
|
41
|
+
Split labels into contiguous chunks of at most 3 labels.
|
|
42
|
+
This mirrors the "3 labels are always safe for this specific LE heuristic"
|
|
43
|
+
invariant, but these label groups are not used directly as issuance domains.
|
|
44
|
+
"""
|
|
45
|
+
labels = _split_labels(domain)
|
|
46
|
+
n = len(labels)
|
|
47
|
+
if n == 0:
|
|
48
|
+
return []
|
|
49
|
+
return [labels[i : i + 3] for i in range(0, n, 3)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def create_safe_domain_batches(domains: list[str], blocked_labels: list[str] | None = None) -> list[list[str]]:
|
|
53
|
+
"""
|
|
54
|
+
Create compact issuance batches without any blocked-label list assumptions.
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Domains with <= 3 labels are grouped together into one compact batch.
|
|
58
|
+
- Longer domains are split into safe label groups and emitted as singleton batches.
|
|
59
|
+
|
|
60
|
+
This avoids guessed label lists and keeps behavior deterministic.
|
|
61
|
+
"""
|
|
62
|
+
compact_batch: list[str] = []
|
|
63
|
+
singleton_batches: list[list[str]] = []
|
|
64
|
+
seen: set[str] = set()
|
|
65
|
+
|
|
66
|
+
for domain in domains:
|
|
67
|
+
normalized = _normalize_domain(domain)
|
|
68
|
+
if not normalized or normalized in seen:
|
|
69
|
+
continue
|
|
70
|
+
seen.add(normalized)
|
|
71
|
+
|
|
72
|
+
labels = [label for label in normalized.split(".") if label]
|
|
73
|
+
if len(labels) <= 3:
|
|
74
|
+
compact_batch.append(normalized)
|
|
75
|
+
continue
|
|
76
|
+
|
|
77
|
+
for i in range(0, len(labels), 3):
|
|
78
|
+
singleton_batches.append([".".join(labels[i : i + 3])])
|
|
79
|
+
|
|
80
|
+
if compact_batch:
|
|
81
|
+
return [compact_batch, *singleton_batches]
|
|
82
|
+
return singleton_batches
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import time
|
|
2
|
-
from typing import List, Literal, Optional, Tuple, Union, Dict
|
|
2
|
+
from typing import Callable, List, Literal, Optional, Tuple, Union, Dict
|
|
3
3
|
from datetime import datetime, timezone, timedelta
|
|
4
4
|
|
|
5
5
|
from certapi import crypto
|
|
@@ -11,6 +11,7 @@ from ..http.types import CertificateResponse, IssuedCert
|
|
|
11
11
|
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
|
+
from ..domain_batching import create_safe_domain_batches
|
|
14
15
|
|
|
15
16
|
DEFAULT_RENEW_THRESHOLD_DAYS = 62
|
|
16
17
|
|
|
@@ -75,8 +76,59 @@ class AcmeCertManager:
|
|
|
75
76
|
user_id: Optional[str] = None,
|
|
76
77
|
renew_threshold_days: Optional[int] = None,
|
|
77
78
|
) -> CertificateResponse:
|
|
79
|
+
return self._issue_certificate_internal(
|
|
80
|
+
hosts=hosts,
|
|
81
|
+
key_type=key_type,
|
|
82
|
+
expiry_days=expiry_days,
|
|
83
|
+
country=country,
|
|
84
|
+
state=state,
|
|
85
|
+
locality=locality,
|
|
86
|
+
organization=organization,
|
|
87
|
+
user_id=user_id,
|
|
88
|
+
renew_threshold_days=renew_threshold_days,
|
|
89
|
+
batch_generator=None,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def issue_certificate_in_batches(
|
|
93
|
+
self,
|
|
94
|
+
hosts: Union[str, List[str]],
|
|
95
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
96
|
+
expiry_days: int = 90,
|
|
97
|
+
country: Optional[str] = None,
|
|
98
|
+
state: Optional[str] = None,
|
|
99
|
+
locality: Optional[str] = None,
|
|
100
|
+
organization: Optional[str] = None,
|
|
101
|
+
user_id: Optional[str] = None,
|
|
102
|
+
renew_threshold_days: Optional[int] = None,
|
|
103
|
+
batch_generator: Callable[[List[str]], List[List[str]]] = create_safe_domain_batches,
|
|
104
|
+
) -> CertificateResponse:
|
|
105
|
+
return self._issue_certificate_internal(
|
|
106
|
+
hosts=hosts,
|
|
107
|
+
key_type=key_type,
|
|
108
|
+
expiry_days=expiry_days,
|
|
109
|
+
country=country,
|
|
110
|
+
state=state,
|
|
111
|
+
locality=locality,
|
|
112
|
+
organization=organization,
|
|
113
|
+
user_id=user_id,
|
|
114
|
+
renew_threshold_days=renew_threshold_days,
|
|
115
|
+
batch_generator=batch_generator,
|
|
116
|
+
)
|
|
78
117
|
|
|
79
|
-
|
|
118
|
+
def _issue_certificate_internal(
|
|
119
|
+
self,
|
|
120
|
+
hosts: Union[str, List[str]],
|
|
121
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
122
|
+
expiry_days: int = 90,
|
|
123
|
+
country: Optional[str] = None,
|
|
124
|
+
state: Optional[str] = None,
|
|
125
|
+
locality: Optional[str] = None,
|
|
126
|
+
organization: Optional[str] = None,
|
|
127
|
+
user_id: Optional[str] = None,
|
|
128
|
+
renew_threshold_days: Optional[int] = None,
|
|
129
|
+
batch_generator: Optional[Callable[[List[str]], List[List[str]]]] = None,
|
|
130
|
+
) -> CertificateResponse:
|
|
131
|
+
if isinstance(hosts, str):
|
|
80
132
|
hosts = [hosts]
|
|
81
133
|
|
|
82
134
|
existing: Dict[str, Tuple[int | str, Key, List[Certificate] | str]] = {}
|
|
@@ -109,25 +161,30 @@ class AcmeCertManager:
|
|
|
109
161
|
print(f"Warning: No challenge solver found that supports domain: {host}. Skipping.")
|
|
110
162
|
|
|
111
163
|
for store, domains_to_issue in domains_by_store.items():
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
domains_to_issue,
|
|
115
|
-
key_type=key_type,
|
|
116
|
-
expiry_days=expiry_days,
|
|
117
|
-
country=country,
|
|
118
|
-
state=state,
|
|
119
|
-
locality=locality,
|
|
120
|
-
organization=organization,
|
|
121
|
-
user_id=user_id,
|
|
122
|
-
challenge_solver=store,
|
|
164
|
+
issuance_batches = (
|
|
165
|
+
batch_generator(domains_to_issue) if batch_generator is not None else [domains_to_issue]
|
|
123
166
|
)
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
self.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
167
|
+
for batch in issuance_batches:
|
|
168
|
+
if not batch:
|
|
169
|
+
continue
|
|
170
|
+
private_key, fullchain_cert = self.cert_issuer.generate_key_and_cert_for_domains(
|
|
171
|
+
batch,
|
|
172
|
+
key_type=key_type,
|
|
173
|
+
expiry_days=expiry_days,
|
|
174
|
+
country=country,
|
|
175
|
+
state=state,
|
|
176
|
+
locality=locality,
|
|
177
|
+
organization=organization,
|
|
178
|
+
user_id=user_id,
|
|
179
|
+
challenge_solver=store,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if fullchain_cert:
|
|
183
|
+
key_id = self.key_store.save_key(private_key, batch[0])
|
|
184
|
+
self.key_store.save_cert(key_id, fullchain_cert, batch)
|
|
185
|
+
issued_certs_list.append(IssuedCert(key=private_key, cert=fullchain_cert, domains=batch))
|
|
186
|
+
else:
|
|
187
|
+
print(f"Failed to issue certificate for domains: {batch}")
|
|
131
188
|
|
|
132
189
|
# self.cert_issuer.challenge_solver = original_challenge_solver # Restore original
|
|
133
190
|
return createExistingResponse(existing, issued_certs_list)
|
|
@@ -97,6 +97,12 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
97
97
|
default=False,
|
|
98
98
|
help="Allow issuance to proceed if some domains fail verification",
|
|
99
99
|
)
|
|
100
|
+
obtain_parser.add_argument(
|
|
101
|
+
"batch_domains",
|
|
102
|
+
type=inputs.boolean,
|
|
103
|
+
default=False,
|
|
104
|
+
help="Issue certificates in separate safe batches instead of one combined order",
|
|
105
|
+
)
|
|
100
106
|
|
|
101
107
|
@api_ns.route("/obtain")
|
|
102
108
|
class ObtainCert(Resource):
|
|
@@ -128,7 +134,12 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
128
134
|
if not hostnames:
|
|
129
135
|
return CertificateResponse().to_json()
|
|
130
136
|
|
|
131
|
-
|
|
137
|
+
issue_fn = (
|
|
138
|
+
cert_manager.issue_certificate_in_batches
|
|
139
|
+
if args.get("batch_domains")
|
|
140
|
+
else cert_manager.issue_certificate
|
|
141
|
+
)
|
|
142
|
+
data = issue_fn(
|
|
132
143
|
hostnames,
|
|
133
144
|
key_type=args["key_type"],
|
|
134
145
|
expiry_days=args["expiry_days"],
|
|
@@ -4,6 +4,7 @@ pyproject.toml
|
|
|
4
4
|
setup.py
|
|
5
5
|
src/certapi/__init__.py
|
|
6
6
|
src/certapi/cli.py
|
|
7
|
+
src/certapi/domain_batching.py
|
|
7
8
|
src/certapi/errors.py
|
|
8
9
|
src/certapi/util.py
|
|
9
10
|
src/certapi/utils.py
|
|
@@ -54,9 +55,11 @@ src/certapi/server/__init__.py
|
|
|
54
55
|
src/certapi/server/api.py
|
|
55
56
|
src/certapi/server/cert_api.py
|
|
56
57
|
src/certapi/server/key_api.py
|
|
58
|
+
tests/test_acme_cert_manager_batching.py
|
|
57
59
|
tests/test_acme_error_handling.py
|
|
58
60
|
tests/test_cert_issuer_generic.py
|
|
59
61
|
tests/test_certs_with_key_types.py
|
|
60
62
|
tests/test_cli.py
|
|
63
|
+
tests/test_domain_batching_boulder_cases.py
|
|
61
64
|
tests/test_http_error_handling.py
|
|
62
65
|
tests/test_keystores.py
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from certapi.domain_batching import create_safe_domain_batches, split_domain_to_safe_groups, would_trigger
|
|
2
|
+
from certapi.manager.acme_cert_manager import AcmeCertManager
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class DummyKeyStore:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.saved_keys = []
|
|
8
|
+
self.saved_certs = []
|
|
9
|
+
|
|
10
|
+
def find_key_and_cert_by_domain(self, _domain):
|
|
11
|
+
return None
|
|
12
|
+
|
|
13
|
+
def save_key(self, key, name):
|
|
14
|
+
self.saved_keys.append((key, name))
|
|
15
|
+
return f"key-{len(self.saved_keys)}"
|
|
16
|
+
|
|
17
|
+
def save_cert(self, key_id, cert, domains):
|
|
18
|
+
self.saved_certs.append((key_id, cert, tuple(domains)))
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DummySolver:
|
|
22
|
+
def supports_domain(self, _domain):
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DummyIssuer:
|
|
27
|
+
def __init__(self):
|
|
28
|
+
self.calls = []
|
|
29
|
+
|
|
30
|
+
def generate_key_and_cert_for_domains(self, domains, **_kwargs):
|
|
31
|
+
self.calls.append(list(domains))
|
|
32
|
+
return "dummy-key", "dummy-cert"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_would_trigger_exact_rule():
|
|
36
|
+
blocked = {"abc", "def", "ghi"}
|
|
37
|
+
assert would_trigger(["x", "abc", "def", "ghi", "example", "com"], blocked) is True
|
|
38
|
+
assert would_trigger(["x", "abc", "def", "example", "com"], blocked) is False
|
|
39
|
+
assert would_trigger(["x", "abc", "abc", "example", "com"], blocked) is True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_split_domain_to_safe_groups_greedy():
|
|
43
|
+
groups = split_domain_to_safe_groups(
|
|
44
|
+
"x.abc.def.ghi.example.com",
|
|
45
|
+
blocked_labels=["abc", "def", "ghi"],
|
|
46
|
+
)
|
|
47
|
+
assert groups == [["x", "abc", "def"], ["ghi", "example", "com"]]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_create_safe_domain_batches_compacts_non_triggering_domains():
|
|
51
|
+
domains = ["x.example.com", "y.example.com", "y.example.com"]
|
|
52
|
+
assert create_safe_domain_batches(domains) == [["x.example.com", "y.example.com"]]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_issue_certificate_default_does_not_batch():
|
|
56
|
+
key_store = DummyKeyStore()
|
|
57
|
+
issuer = DummyIssuer()
|
|
58
|
+
solver = DummySolver()
|
|
59
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
60
|
+
|
|
61
|
+
domains = [
|
|
62
|
+
"xyz.com",
|
|
63
|
+
"abcd.xyz.com",
|
|
64
|
+
"def.abcd.xyz.com",
|
|
65
|
+
]
|
|
66
|
+
manager.issue_certificate(hosts=domains)
|
|
67
|
+
|
|
68
|
+
assert issuer.calls == [domains]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_issue_certificate_in_batches_uses_custom_batch_generator():
|
|
72
|
+
key_store = DummyKeyStore()
|
|
73
|
+
issuer = DummyIssuer()
|
|
74
|
+
solver = DummySolver()
|
|
75
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
76
|
+
|
|
77
|
+
domains = [
|
|
78
|
+
"x.abc.def.ghi.example.com",
|
|
79
|
+
"root.example.com",
|
|
80
|
+
]
|
|
81
|
+
response = manager.issue_certificate_in_batches(
|
|
82
|
+
hosts=domains,
|
|
83
|
+
batch_generator=lambda d: create_safe_domain_batches(
|
|
84
|
+
d,
|
|
85
|
+
blocked_labels=["abc", "def", "ghi"],
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
assert issuer.calls == [
|
|
90
|
+
["root.example.com"],
|
|
91
|
+
["x.abc.def"],
|
|
92
|
+
["ghi.example.com"],
|
|
93
|
+
]
|
|
94
|
+
assert len(response.issued) == 3
|
|
95
|
+
assert [issued.domains for issued in response.issued] == issuer.calls
|
|
@@ -77,13 +77,11 @@ class TestAcmeNetworkErrorHandling:
|
|
|
77
77
|
def test_network_errors(self, acme_client, mock_directory_response, error_side_effect, expected_type, expected_msg):
|
|
78
78
|
"""Test various network-related errors with parametrization"""
|
|
79
79
|
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
80
|
-
#
|
|
80
|
+
# setup() directory succeeds, then register() POST fails all 3 attempts in http.request
|
|
81
81
|
mock_request.side_effect = [
|
|
82
82
|
mock_directory_response,
|
|
83
83
|
error_side_effect,
|
|
84
|
-
mock_directory_response,
|
|
85
84
|
error_side_effect,
|
|
86
|
-
mock_directory_response,
|
|
87
85
|
error_side_effect,
|
|
88
86
|
]
|
|
89
87
|
|
|
@@ -92,7 +90,7 @@ class TestAcmeNetworkErrorHandling:
|
|
|
92
90
|
acme_client.register()
|
|
93
91
|
|
|
94
92
|
error = exc_info.value
|
|
95
|
-
assert error.can_retry is
|
|
93
|
+
assert error.can_retry is False
|
|
96
94
|
assert expected_msg in error.message
|
|
97
95
|
assert error.detail["errorType"] == expected_type
|
|
98
96
|
|
|
@@ -158,7 +156,11 @@ class TestAcmeHttpErrorHandling:
|
|
|
158
156
|
error_response.request = Mock()
|
|
159
157
|
error_response.request.url = "https://acme.example.com/api"
|
|
160
158
|
|
|
161
|
-
|
|
159
|
+
if status_code >= 500:
|
|
160
|
+
# 5xx is retried in http.request, so provide three failing POST responses
|
|
161
|
+
mock_request.side_effect = [mock_directory_response, error_response, error_response, error_response]
|
|
162
|
+
else:
|
|
163
|
+
mock_request.side_effect = [mock_directory_response, error_response]
|
|
162
164
|
|
|
163
165
|
with pytest.raises(AcmeHttpError) as exc_info:
|
|
164
166
|
acme_client.setup()
|
|
@@ -180,7 +182,8 @@ class TestAcmeHttpErrorHandling:
|
|
|
180
182
|
error_response.request = Mock()
|
|
181
183
|
error_response.request.url = "https://acme.example.com/api"
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
# 502 is retried in http.request
|
|
186
|
+
mock_request.side_effect = [mock_directory_response, error_response, error_response, error_response]
|
|
184
187
|
|
|
185
188
|
with pytest.raises(AcmeHttpError) as exc_info:
|
|
186
189
|
acme_client.setup()
|
|
@@ -440,6 +443,133 @@ class TestAcmeRetryLogic:
|
|
|
440
443
|
# So 3 calls total: directory + initial + retry
|
|
441
444
|
assert mock_request.call_count >= 2
|
|
442
445
|
|
|
446
|
+
def test_network_unreachable_retries_with_2_second_delay(self, acme_client, mock_directory_response):
|
|
447
|
+
"""Test that network unreachable errors use 2-second retry delay"""
|
|
448
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
449
|
+
network_error = requests.exceptions.ConnectionError(
|
|
450
|
+
"HTTPSConnectionPool(host='acme-v02.api.letsencrypt.org', port=443): "
|
|
451
|
+
"Max retries exceeded with url: /acme/new-order "
|
|
452
|
+
"(Caused by NewConnectionError('<urllib3.connection.HTTPSConnection object at 0x0>: "
|
|
453
|
+
"Failed to establish a new connection: [Errno 101] Network unreachable'))"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
mock_request.side_effect = [
|
|
457
|
+
mock_directory_response, # setup() directory
|
|
458
|
+
network_error, # post() fail 1
|
|
459
|
+
network_error, # post() fail 2
|
|
460
|
+
network_error, # post() fail 3 -> raise
|
|
461
|
+
]
|
|
462
|
+
|
|
463
|
+
with patch("time.sleep") as mock_sleep:
|
|
464
|
+
with pytest.raises(AcmeNetworkError):
|
|
465
|
+
acme_client.setup()
|
|
466
|
+
acme_client.register()
|
|
467
|
+
|
|
468
|
+
assert mock_sleep.call_count == 2
|
|
469
|
+
mock_sleep.assert_called_with(2)
|
|
470
|
+
|
|
471
|
+
def test_all_network_errors_use_2_second_delay(self, acme_client, mock_directory_response):
|
|
472
|
+
"""Test that any network error uses 2-second retry delay"""
|
|
473
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
474
|
+
network_error = requests.exceptions.ConnectionError("Connection failed")
|
|
475
|
+
|
|
476
|
+
mock_request.side_effect = [
|
|
477
|
+
mock_directory_response, # setup() directory
|
|
478
|
+
network_error, # post() fail 1
|
|
479
|
+
network_error, # post() fail 2
|
|
480
|
+
network_error, # post() fail 3 -> raise
|
|
481
|
+
]
|
|
482
|
+
|
|
483
|
+
with patch("time.sleep") as mock_sleep:
|
|
484
|
+
with pytest.raises(AcmeNetworkError):
|
|
485
|
+
acme_client.setup()
|
|
486
|
+
acme_client.register()
|
|
487
|
+
|
|
488
|
+
assert mock_sleep.call_count == 2
|
|
489
|
+
mock_sleep.assert_called_with(2)
|
|
490
|
+
|
|
491
|
+
def test_network_error_retries_on_get_requests(self, acme_client, mock_directory_response):
|
|
492
|
+
"""Test that network errors on GET requests are retried"""
|
|
493
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
494
|
+
network_error = requests.exceptions.ConnectionError("Connection failed")
|
|
495
|
+
mock_request.side_effect = [
|
|
496
|
+
network_error, # setup() GET fail 1
|
|
497
|
+
network_error, # setup() GET fail 2
|
|
498
|
+
mock_directory_response, # setup() GET success
|
|
499
|
+
]
|
|
500
|
+
|
|
501
|
+
with patch("time.sleep") as mock_sleep:
|
|
502
|
+
acme_client.setup()
|
|
503
|
+
|
|
504
|
+
assert acme_client.directory is not None
|
|
505
|
+
assert mock_sleep.call_count == 2
|
|
506
|
+
mock_sleep.assert_called_with(2)
|
|
507
|
+
|
|
508
|
+
def test_5xx_retries_then_succeeds(self, acme_client, mock_directory_response):
|
|
509
|
+
"""Test that 5xx responses are retried and can recover"""
|
|
510
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
511
|
+
server_error = Mock()
|
|
512
|
+
server_error.status_code = 500
|
|
513
|
+
server_error.json.return_value = {
|
|
514
|
+
"type": "urn:ietf:params:acme:error:serverInternal",
|
|
515
|
+
"detail": "Internal error",
|
|
516
|
+
}
|
|
517
|
+
server_error.headers = {"Replay-Nonce": "test-nonce"}
|
|
518
|
+
server_error.request = Mock()
|
|
519
|
+
server_error.request.url = "https://acme.example.com/new-account"
|
|
520
|
+
|
|
521
|
+
success_response = Mock()
|
|
522
|
+
success_response.status_code = 201
|
|
523
|
+
success_response.json.return_value = {"status": "valid"}
|
|
524
|
+
success_response.headers = {
|
|
525
|
+
"Replay-Nonce": "nonce-789",
|
|
526
|
+
"location": "https://acme.example.com/account/123",
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
mock_request.side_effect = [
|
|
530
|
+
mock_directory_response, # setup() directory
|
|
531
|
+
server_error, # post() fail 1
|
|
532
|
+
server_error, # post() fail 2
|
|
533
|
+
success_response, # post() success on retry 3
|
|
534
|
+
]
|
|
535
|
+
|
|
536
|
+
with patch("time.sleep") as mock_sleep:
|
|
537
|
+
acme_client.setup()
|
|
538
|
+
result = acme_client.register()
|
|
539
|
+
|
|
540
|
+
assert result.status_code == 201
|
|
541
|
+
assert mock_sleep.call_count == 2
|
|
542
|
+
mock_sleep.assert_called_with(2)
|
|
543
|
+
|
|
544
|
+
def test_5xx_retry_exhaustion_raises_once(self, acme_client, mock_directory_response):
|
|
545
|
+
"""Test that 5xx retries are exhausted in HTTP layer without extra nested retries"""
|
|
546
|
+
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
547
|
+
server_error = Mock()
|
|
548
|
+
server_error.status_code = 500
|
|
549
|
+
server_error.json.return_value = {
|
|
550
|
+
"type": "urn:ietf:params:acme:error:serverInternal",
|
|
551
|
+
"detail": "Internal error",
|
|
552
|
+
}
|
|
553
|
+
server_error.headers = {"Replay-Nonce": "test-nonce"}
|
|
554
|
+
server_error.request = Mock()
|
|
555
|
+
server_error.request.url = "https://acme.example.com/new-account"
|
|
556
|
+
|
|
557
|
+
mock_request.side_effect = [
|
|
558
|
+
mock_directory_response, # setup() directory
|
|
559
|
+
server_error, # post() fail 1
|
|
560
|
+
server_error, # post() fail 2
|
|
561
|
+
server_error, # post() fail 3 -> raise
|
|
562
|
+
]
|
|
563
|
+
|
|
564
|
+
with patch("time.sleep") as mock_sleep:
|
|
565
|
+
with pytest.raises(AcmeHttpError):
|
|
566
|
+
acme_client.setup()
|
|
567
|
+
acme_client.register()
|
|
568
|
+
|
|
569
|
+
assert mock_sleep.call_count == 2
|
|
570
|
+
mock_sleep.assert_called_with(2)
|
|
571
|
+
assert mock_request.call_count == 4
|
|
572
|
+
|
|
443
573
|
def test_retry_delay_is_2_seconds(self, acme_client, mock_directory_response):
|
|
444
574
|
"""Test that retry delay is 2 seconds"""
|
|
445
575
|
with patch("certapi.acme.http.requests.request") as mock_request:
|
|
@@ -54,8 +54,8 @@ class FakeAcmeCertIssuer:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
class FakeCert:
|
|
57
|
-
def __init__(self,
|
|
58
|
-
self.
|
|
57
|
+
def __init__(self, not_valid_after_utc):
|
|
58
|
+
self.not_valid_after_utc = not_valid_after_utc
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def test_is_root_true_false(monkeypatch):
|
|
@@ -365,9 +365,10 @@ def test_main_verify(monkeypatch):
|
|
|
365
365
|
def test_main_obtain(monkeypatch):
|
|
366
366
|
called = {}
|
|
367
367
|
|
|
368
|
-
def fake_obtain(domains, api_key=None):
|
|
368
|
+
def fake_obtain(domains, api_key=None, keystore_path="/etc/ssl"):
|
|
369
369
|
called["domains"] = domains
|
|
370
370
|
called["api_key"] = api_key
|
|
371
|
+
called["keystore_path"] = keystore_path
|
|
371
372
|
|
|
372
373
|
monkeypatch.setattr(cli, "obtain_certificate", fake_obtain)
|
|
373
374
|
monkeypatch.setattr(cli, "_resolve_cloudflare_api_key", lambda: None)
|
|
@@ -376,6 +377,25 @@ def test_main_obtain(monkeypatch):
|
|
|
376
377
|
cli.main()
|
|
377
378
|
assert called["domains"] == ["example.com"]
|
|
378
379
|
assert called["api_key"] is None
|
|
380
|
+
assert called["keystore_path"] == "/etc/ssl"
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def test_main_obtain_custom_keystore(monkeypatch):
|
|
384
|
+
called = {}
|
|
385
|
+
|
|
386
|
+
def fake_obtain(domains, api_key=None, keystore_path="/etc/ssl"):
|
|
387
|
+
called["domains"] = domains
|
|
388
|
+
called["api_key"] = api_key
|
|
389
|
+
called["keystore_path"] = keystore_path
|
|
390
|
+
|
|
391
|
+
monkeypatch.setattr(cli, "obtain_certificate", fake_obtain)
|
|
392
|
+
monkeypatch.setattr(cli, "_resolve_cloudflare_api_key", lambda: None)
|
|
393
|
+
monkeypatch.setattr(cli.sys, "argv", ["certapi", "obtain", "--path", ".", "example.com"])
|
|
394
|
+
|
|
395
|
+
cli.main()
|
|
396
|
+
assert called["domains"] == ["example.com"]
|
|
397
|
+
assert called["api_key"] is None
|
|
398
|
+
assert called["keystore_path"] == "."
|
|
379
399
|
|
|
380
400
|
|
|
381
401
|
def test_main_help(monkeypatch):
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from certapi.domain_batching import would_trigger
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _looks_like_recursive_on_demand_request_test_only(idents, blocked):
|
|
5
|
+
if not blocked:
|
|
6
|
+
return False
|
|
7
|
+
|
|
8
|
+
blocked_set = {label.lower() for label in blocked}
|
|
9
|
+
for ident in idents or []:
|
|
10
|
+
if ident.get("type") != "dns":
|
|
11
|
+
continue
|
|
12
|
+
labels = [label for label in ident["value"].lower().split(".") if label]
|
|
13
|
+
if would_trigger(labels, blocked_set):
|
|
14
|
+
return True
|
|
15
|
+
return False
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_looks_like_recursive_on_demand_request_boulder_cases():
|
|
19
|
+
test_cases = [
|
|
20
|
+
{
|
|
21
|
+
"name": "no blocks",
|
|
22
|
+
"idents": [{"type": "dns", "value": "example.com"}],
|
|
23
|
+
"blocked": None,
|
|
24
|
+
"want_err": False,
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"name": "no idents",
|
|
28
|
+
"idents": None,
|
|
29
|
+
"blocked": ["asdf"],
|
|
30
|
+
"want_err": False,
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"name": "no dns idents",
|
|
34
|
+
"idents": [{"type": "ip", "value": "1.2.3.4"}],
|
|
35
|
+
"blocked": ["asdf"],
|
|
36
|
+
"want_err": False,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "short idents",
|
|
40
|
+
"idents": [
|
|
41
|
+
{"type": "dns", "value": "foo.example.com"},
|
|
42
|
+
{"type": "dns", "value": "bar.example.com"},
|
|
43
|
+
],
|
|
44
|
+
"blocked": ["asdf"],
|
|
45
|
+
"want_err": False,
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "long but not blocked ident",
|
|
49
|
+
"idents": [
|
|
50
|
+
{"type": "dns", "value": "foo.example.com"},
|
|
51
|
+
{"type": "dns", "value": "asdf.qwer.zxcv.asdf.example.com"},
|
|
52
|
+
],
|
|
53
|
+
"blocked": ["asdf"],
|
|
54
|
+
"want_err": False,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"name": "two identical blocked labels early",
|
|
58
|
+
"idents": [
|
|
59
|
+
{"type": "dns", "value": "foo.example.com"},
|
|
60
|
+
{"type": "dns", "value": "asdf.asdf.qwer.zxcv.example.com"},
|
|
61
|
+
],
|
|
62
|
+
"blocked": ["asdf"],
|
|
63
|
+
"want_err": True,
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "two identical blocked labels late",
|
|
67
|
+
"idents": [
|
|
68
|
+
{"type": "dns", "value": "foo.example.com"},
|
|
69
|
+
{"type": "dns", "value": "qwer.zxcv.asdf.asdf.example.com"},
|
|
70
|
+
],
|
|
71
|
+
"blocked": ["asdf"],
|
|
72
|
+
"want_err": True,
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
"name": "three blocked labels",
|
|
76
|
+
"idents": [
|
|
77
|
+
{"type": "dns", "value": "foo.example.com"},
|
|
78
|
+
{"type": "dns", "value": "asdf.qwer.zxcv.asdf.example.com"},
|
|
79
|
+
],
|
|
80
|
+
"blocked": ["asdf", "qwer", "zxcv"],
|
|
81
|
+
"want_err": True,
|
|
82
|
+
},
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
for tc in test_cases:
|
|
86
|
+
got = _looks_like_recursive_on_demand_request_test_only(tc["idents"], tc["blocked"])
|
|
87
|
+
assert got is tc["want_err"], f"{tc['name']}: got={got} want={tc['want_err']}"
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import requests
|
|
2
|
-
from .AcmeError import *
|
|
3
|
-
import json as j
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def request(method, step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
7
|
-
res = None
|
|
8
|
-
try:
|
|
9
|
-
res = requests.request(method, url, json=json, headers=headers, timeout=15)
|
|
10
|
-
print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + step)
|
|
11
|
-
except requests.RequestException as e:
|
|
12
|
-
print("Request : " + str(method) + " " + str(url) + " step=" + str(step))
|
|
13
|
-
raise AcmeNetworkError(
|
|
14
|
-
e.request,
|
|
15
|
-
f"Error communicating with ACME server",
|
|
16
|
-
{
|
|
17
|
-
"errorType": e.__class__.__name__,
|
|
18
|
-
"message": str(e),
|
|
19
|
-
"method": method,
|
|
20
|
-
"url": e.request.url if e.request else None,
|
|
21
|
-
},
|
|
22
|
-
step,
|
|
23
|
-
)
|
|
24
|
-
if 199 <= res.status_code > 299:
|
|
25
|
-
if json:
|
|
26
|
-
print("Request:", j.dumps(json))
|
|
27
|
-
[print(x, y) for (x, y) in res.headers.items()]
|
|
28
|
-
print("Response:", res.text)
|
|
29
|
-
json_data = None
|
|
30
|
-
try:
|
|
31
|
-
json_data = res.json()
|
|
32
|
-
except requests.RequestException as e:
|
|
33
|
-
pass
|
|
34
|
-
if json_data and json_data.get("type"):
|
|
35
|
-
errorType = json_data["type"]
|
|
36
|
-
if errorType == "urn:ietf:params:acme:error:badNonce":
|
|
37
|
-
raise AcmeInvalidNonceError(res, step=step)
|
|
38
|
-
|
|
39
|
-
if throw:
|
|
40
|
-
raise AcmeHttpError(res, step=step)
|
|
41
|
-
return res
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def post(step: str, url: str, json=None, headers=None, throw=True) -> requests.Response:
|
|
45
|
-
return request("POST", step, url, json=json, headers=headers, throw=throw)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
def get(step: str, url, headers=None, throw=True) -> requests.Response:
|
|
49
|
-
return request("GET", step, url, headers=headers, throw=throw)
|
|
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-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.1.0 → certapi-1.1.1}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|