certapi 0.6.0__tar.gz → 1.0.0__tar.gz

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