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.
Files changed (68) hide show
  1. {certapi-1.1.0/src/certapi.egg-info → certapi-1.1.1}/PKG-INFO +1 -1
  2. {certapi-1.1.0 → certapi-1.1.1}/setup.py +1 -1
  3. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/__init__.py +1 -0
  4. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Acme.py +2 -1
  5. certapi-1.1.1/src/certapi/acme/http.py +69 -0
  6. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/cli.py +57 -11
  7. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/client/cert_manager_client.py +2 -0
  8. certapi-1.1.1/src/certapi/domain_batching.py +82 -0
  9. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/manager/acme_cert_manager.py +77 -20
  10. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/api.py +12 -1
  11. {certapi-1.1.0 → certapi-1.1.1/src/certapi.egg-info}/PKG-INFO +1 -1
  12. {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/SOURCES.txt +3 -0
  13. certapi-1.1.1/tests/test_acme_cert_manager_batching.py +95 -0
  14. {certapi-1.1.0 → certapi-1.1.1}/tests/test_acme_error_handling.py +136 -6
  15. {certapi-1.1.0 → certapi-1.1.1}/tests/test_cli.py +23 -3
  16. certapi-1.1.1/tests/test_domain_batching_boulder_cases.py +87 -0
  17. certapi-1.1.0/src/certapi/acme/http.py +0 -49
  18. {certapi-1.1.0 → certapi-1.1.1}/MANIFEST.in +0 -0
  19. {certapi-1.1.0 → certapi-1.1.1}/README.md +0 -0
  20. {certapi-1.1.0 → certapi-1.1.1}/pyproject.toml +0 -0
  21. {certapi-1.1.0 → certapi-1.1.1}/setup.cfg +0 -0
  22. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/AcmeError.py +0 -0
  23. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Challenge.py +0 -0
  24. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/Order.py +0 -0
  25. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/acme/__init__.py +0 -0
  26. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  27. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  28. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  29. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/__init__.py +0 -0
  30. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  31. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  32. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  33. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  34. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  35. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  36. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  37. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/client/__init__.py +0 -0
  38. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/__init__.py +0 -0
  39. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/crypto.py +0 -0
  40. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/crypto/crypto_classes.py +0 -0
  41. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/errors.py +0 -0
  42. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/HttpClientBase.py +0 -0
  43. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/__init__.py +0 -0
  44. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/http/types.py +0 -0
  45. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  46. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  47. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/__init__.py +0 -0
  48. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/issuers/abstract_certissuer.py +0 -0
  49. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  50. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/KeyStore.py +0 -0
  51. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  52. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  53. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  54. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/keystore/__init__.py +0 -0
  55. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/manager/__init__.py +0 -0
  56. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/__init__.py +0 -0
  57. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/cert_api.py +0 -0
  58. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/server/key_api.py +0 -0
  59. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/util.py +0 -0
  60. {certapi-1.1.0 → certapi-1.1.1}/src/certapi/utils.py +0 -0
  61. {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/dependency_links.txt +0 -0
  62. {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/entry_points.txt +0 -0
  63. {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/requires.txt +0 -0
  64. {certapi-1.1.0 → certapi-1.1.1}/src/certapi.egg-info/top_level.txt +0 -0
  65. {certapi-1.1.0 → certapi-1.1.1}/tests/test_cert_issuer_generic.py +0 -0
  66. {certapi-1.1.0 → certapi-1.1.1}/tests/test_certs_with_key_types.py +0 -0
  67. {certapi-1.1.0 → certapi-1.1.1}/tests/test_http_error_handling.py +0 -0
  68. {certapi-1.1.0 → certapi-1.1.1}/tests/test_keystores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.0
3
+ Version: 1.1.1
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
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.0",
5
+ version="1.1.1",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -22,3 +22,4 @@ from .challenge_solver import (
22
22
  )
23
23
  from .issuers import CertIssuer, SelfCertIssuer, AcmeCertIssuer
24
24
  from .client import CertManagerClient
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
- expiry = leaf_cert.not_valid_after.isoformat() if leaf_cert else "unknown"
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
- parser = argparse.ArgumentParser(prog="certapi")
223
- subparsers = parser.add_subparsers(dest="command")
224
-
225
- verify_parser = subparsers.add_parser("verify", help="Verify certapi installation and environment.")
226
- verify_parser.add_argument("domains", nargs="*", help="Optional domain(s) to verify.")
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
- obtain_parser = subparsers.add_parser("obtain", help="Obtain certificate for domains.")
229
- obtain_parser.add_argument("domains", nargs="+", help="Domain(s) to obtain certificate for.")
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
- if type(hosts) == str:
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
- private_key, fullchain_cert = self.cert_issuer.generate_key_and_cert_for_domains(
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
- if fullchain_cert:
126
- key_id = self.key_store.save_key(private_key, domains_to_issue[0])
127
- self.key_store.save_cert(key_id, fullchain_cert, domains_to_issue)
128
- issued_certs_list.append(IssuedCert(key=private_key, cert=fullchain_cert, domains=domains_to_issue))
129
- else:
130
- print(f"Failed to issue certificate for domains: {domains_to_issue}")
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
- data = cert_manager.issue_certificate(
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"],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.0
3
+ Version: 1.1.1
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
@@ -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
- # First call succeeds (directory fetch), second call fails with side effect
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 True
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
- mock_request.side_effect = [mock_directory_response, error_response]
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
- mock_request.side_effect = [mock_directory_response, error_response]
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, not_valid_after):
58
- self.not_valid_after = not_valid_after
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