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.
Files changed (71) hide show
  1. {certapi-1.1.0/src/certapi.egg-info → certapi-1.1.2}/PKG-INFO +1 -1
  2. {certapi-1.1.0 → certapi-1.1.2}/setup.py +1 -1
  3. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/__init__.py +2 -1
  4. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Acme.py +2 -1
  5. certapi-1.1.2/src/certapi/acme/http.py +69 -0
  6. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/cli.py +57 -11
  7. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/client/__init__.py +1 -0
  8. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/client/cert_manager_client.py +30 -0
  9. certapi-1.1.2/src/certapi/client/renewal_manager.py +454 -0
  10. certapi-1.1.2/src/certapi/domain_batching.py +82 -0
  11. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/manager/acme_cert_manager.py +102 -21
  12. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/api.py +12 -1
  13. {certapi-1.1.0 → certapi-1.1.2/src/certapi.egg-info}/PKG-INFO +1 -1
  14. {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/SOURCES.txt +7 -1
  15. certapi-1.1.2/tests/test_acme_cert_manager_batching.py +95 -0
  16. {certapi-1.1.0 → certapi-1.1.2}/tests/test_acme_error_handling.py +136 -6
  17. {certapi-1.1.0 → certapi-1.1.2}/tests/test_cli.py +23 -3
  18. certapi-1.1.2/tests/test_domain_batching_boulder_cases.py +87 -0
  19. certapi-1.1.2/tests/test_obtain_interface.py +129 -0
  20. certapi-1.1.2/tests/test_renewal_manager.py +647 -0
  21. certapi-1.1.0/src/certapi/acme/http.py +0 -49
  22. {certapi-1.1.0 → certapi-1.1.2}/MANIFEST.in +0 -0
  23. {certapi-1.1.0 → certapi-1.1.2}/README.md +0 -0
  24. {certapi-1.1.0 → certapi-1.1.2}/pyproject.toml +0 -0
  25. {certapi-1.1.0 → certapi-1.1.2}/setup.cfg +0 -0
  26. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/AcmeError.py +0 -0
  27. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Challenge.py +0 -0
  28. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/Order.py +0 -0
  29. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/acme/__init__.py +0 -0
  30. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  31. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  32. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  33. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/__init__.py +0 -0
  34. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  35. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  36. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  37. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  38. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  39. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  40. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  41. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/__init__.py +0 -0
  42. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/crypto.py +0 -0
  43. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/crypto/crypto_classes.py +0 -0
  44. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/errors.py +0 -0
  45. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/HttpClientBase.py +0 -0
  46. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/__init__.py +0 -0
  47. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/http/types.py +0 -0
  48. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  49. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  50. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/__init__.py +0 -0
  51. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/issuers/abstract_certissuer.py +0 -0
  52. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  53. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/KeyStore.py +0 -0
  54. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  55. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  56. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  57. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/keystore/__init__.py +0 -0
  58. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/manager/__init__.py +0 -0
  59. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/__init__.py +0 -0
  60. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/cert_api.py +0 -0
  61. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/server/key_api.py +0 -0
  62. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/util.py +0 -0
  63. {certapi-1.1.0 → certapi-1.1.2}/src/certapi/utils.py +0 -0
  64. {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/dependency_links.txt +0 -0
  65. {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/entry_points.txt +0 -0
  66. {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/requires.txt +0 -0
  67. {certapi-1.1.0 → certapi-1.1.2}/src/certapi.egg-info/top_level.txt +0 -0
  68. {certapi-1.1.0 → certapi-1.1.2}/tests/test_cert_issuer_generic.py +0 -0
  69. {certapi-1.1.0 → certapi-1.1.2}/tests/test_certs_with_key_types.py +0 -0
  70. {certapi-1.1.0 → certapi-1.1.2}/tests/test_http_error_handling.py +0 -0
  71. {certapi-1.1.0 → certapi-1.1.2}/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.2
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -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.2",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -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
- 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)
@@ -1 +1,2 @@
1
1
  from .cert_manager_client import CertManagerClient
2
+ from .renewal_manager import RenewalManager
@@ -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)