certapi 0.5.1__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.5.1/src/certapi.egg-info → certapi-1.0.0}/PKG-INFO +47 -6
  2. certapi-1.0.0/README.md +93 -0
  3. {certapi-0.5.1 → certapi-1.0.0}/pyproject.toml +0 -1
  4. {certapi-0.5.1 → certapi-1.0.0}/setup.py +1 -1
  5. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/AcmeError.py +21 -15
  6. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/http.py +1 -1
  7. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/errors.py +1 -0
  8. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/http/HttpClientBase.py +6 -5
  9. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/issuers/AcmeCertIssuer.py +10 -0
  10. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/manager/acme_cert_manager.py +11 -3
  11. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/server/api.py +62 -11
  12. {certapi-0.5.1 → certapi-1.0.0/src/certapi.egg-info}/PKG-INFO +47 -6
  13. {certapi-0.5.1 → certapi-1.0.0}/src/certapi.egg-info/SOURCES.txt +1 -0
  14. {certapi-0.5.1 → certapi-1.0.0}/tests/test_cert_issuer_generic.py +4 -4
  15. certapi-1.0.0/tests/test_http_error_handling.py +130 -0
  16. {certapi-0.5.1 → certapi-1.0.0}/tests/test_keystores.py +5 -3
  17. certapi-0.5.1/README.md +0 -52
  18. {certapi-0.5.1 → certapi-1.0.0}/MANIFEST.in +0 -0
  19. {certapi-0.5.1 → certapi-1.0.0}/setup.cfg +0 -0
  20. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/__init__.py +0 -0
  21. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/Acme.py +0 -0
  22. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/Challenge.py +0 -0
  23. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/Order.py +0 -0
  24. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/acme/__init__.py +0 -0
  25. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  26. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  27. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  28. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/__init__.py +0 -0
  29. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  30. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  31. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  32. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  33. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  34. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  35. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  36. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/client/__init__.py +0 -0
  37. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/client/cert_manager_client.py +0 -0
  38. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/crypto/__init__.py +0 -0
  39. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/crypto/crypto.py +0 -0
  40. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/crypto/crypto_classes.py +0 -0
  41. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/http/__init__.py +0 -0
  42. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/http/types.py +0 -0
  43. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  44. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/issuers/__init__.py +0 -0
  45. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/issuers/abstract_certissuer.py +0 -0
  46. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  47. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/KeyStore.py +0 -0
  48. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  49. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  50. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  51. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/keystore/__init__.py +0 -0
  52. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/manager/__init__.py +0 -0
  53. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/server/__init__.py +0 -0
  54. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/server/cert_api.py +0 -0
  55. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/server/key_api.py +0 -0
  56. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/util.py +0 -0
  57. {certapi-0.5.1 → certapi-1.0.0}/src/certapi/utils.py +0 -0
  58. {certapi-0.5.1 → certapi-1.0.0}/src/certapi.egg-info/dependency_links.txt +0 -0
  59. {certapi-0.5.1 → certapi-1.0.0}/src/certapi.egg-info/requires.txt +0 -0
  60. {certapi-0.5.1 → certapi-1.0.0}/src/certapi.egg-info/top_level.txt +0 -0
  61. {certapi-0.5.1 → 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.5.1
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
+ ```
@@ -9,7 +9,6 @@ testpaths = [
9
9
  ]
10
10
  env_files =[".env", ".test.env"]
11
11
  pythonpath = "src"
12
- timeout = 10
13
12
 
14
13
  [build-system]
15
14
  requires = ["setuptools>=42"]
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="0.5.1",
5
+ version="1.0.0",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -61,30 +61,36 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
61
61
  message = err_detail
62
62
  else:
63
63
  validation_record = validation_record[0]
64
- error["hostname"] = validation_record["hostname"]
64
+ error["hostname"] = validation_record.get("hostname", "unknown")
65
65
  error["dns"] = {
66
- "resolved": validation_record["addressesResolved"],
67
- "used": validation_record["addressUsed"],
66
+ "resolved": validation_record.get("addressesResolved"),
67
+ "used": validation_record.get("addressUsed"),
68
68
  }
69
69
  if err_type == "urn:ietf:params:acme:error:connection":
70
70
  if "Timeout during connect" in err_detail:
71
71
  error["connect"] = {"error": "Timeout"}
72
+ address_used = validation_record.get("addressUsed", "unknown")
73
+ port = str(validation_record.get("port", "unknown"))
74
+ hostname = error.get("hostname", "unknown")
72
75
  message = (
73
- error["hostname"]
76
+ str(hostname)
74
77
  + "["
75
- + validation_record["addressUsed"]
78
+ + address_used
76
79
  + ":"
77
- + validation_record["port"]
80
+ + port
78
81
  + "] Connect Timeout (Maybe firewall reasons)"
79
82
  )
80
83
  elif err_detail.endswith("Connection refused"):
81
84
  error["connect"] = {"error": "connection refused"}
85
+ address_used = validation_record.get("addressUsed", "unknown")
86
+ port = str(validation_record.get("port", "unknown"))
87
+ hostname = error.get("hostname", "unknown")
82
88
  message = (
83
- error["hostname"]
89
+ str(hostname)
84
90
  + "["
85
- + validation_record["addressUsed"]
91
+ + address_used
86
92
  + ":"
87
- + validation_record["port"]
93
+ + port
88
94
  + "] Connection Refused (Is http server running?)"
89
95
  )
90
96
  elif err_detail:
@@ -98,12 +104,11 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
98
104
 
99
105
  if match:
100
106
  error["response"] = (match.group(1) if match is not None else err_detail,)
101
- error["status_code"] = (error["status"],)
107
+ error["status_code"] = (error.get("status"),)
108
+ hostname = error.get("hostname", "unknown")
109
+ status = error.get("status", "unknown")
102
110
  message = (
103
- error["hostname"]
104
- + " Status="
105
- + error["status"]
106
- + ": Invalid response in challenge url"
111
+ str(hostname) + " Status=" + str(status) + ": Invalid response in challenge url"
107
112
  )
108
113
  else:
109
114
  message = err_detail
@@ -131,7 +136,8 @@ class AcmeInvaliOrderError(AcmeHttpError):
131
136
  super().__init__(response, step)
132
137
 
133
138
 
134
- class AcmeInvaliNonceError(AcmeHttpError):
139
+ class AcmeInvalidNonceError(AcmeHttpError):
135
140
  def __init__(self, response: requests.Response, step: str):
136
141
  super().__init__(response, step)
137
142
  self.can_retry = True
143
+ self.response = 0
@@ -34,7 +34,7 @@ def request(method, step: str, url: str, json=None, headers=None, throw=True) ->
34
34
  if json_data and json_data.get("type"):
35
35
  errorType = json_data["type"]
36
36
  if errorType == "urn:ietf:params:acme:error:badNonce":
37
- raise AcmeInvaliNonceError(res, step=step)
37
+ raise AcmeInvalidNonceError(res, step=step)
38
38
 
39
39
  if throw:
40
40
  raise AcmeHttpError(res, step=step)
@@ -12,6 +12,7 @@ class CertApiException(Exception):
12
12
  self.detail = detail if detail is not None else {}
13
13
  self.step = step
14
14
  self.can_retry = False
15
+ self.retry_delay = 4 # Default retry delay in seconds
15
16
 
16
17
  def json_obj(self) -> dict:
17
18
  return {"name": self.__class__.__name__, "message": self.message, "step": self.step, "detail": self.detail}
@@ -24,7 +24,7 @@ class HttpClientBase(object):
24
24
  try:
25
25
  res = self.session.request(method, url, json=json_data, data=data, params=params, timeout=timeout)
26
26
  except requests.exceptions.ConnectionError as e:
27
- print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + str(step))
27
+ print("Request [Connection Error] : " + method + " " + url + " step=" + str(step))
28
28
  raise NetworkError(
29
29
  request=e.request,
30
30
  message=f"Network connection error: {e}",
@@ -32,7 +32,7 @@ class HttpClientBase(object):
32
32
  step=f"HTTP Request ({method} {url})" if step is None else step,
33
33
  ) from e
34
34
  except requests.exceptions.Timeout as e:
35
- print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + str(step))
35
+ print("Request [Timeout] : " + method + " " + url + " step=" + str(step))
36
36
  raise NetworkError(
37
37
  request=e.request,
38
38
  message=f"Request timed out: {e}",
@@ -40,7 +40,7 @@ class HttpClientBase(object):
40
40
  step=f"HTTP Request ({method} {url})" if step is None else step,
41
41
  ) from e
42
42
  except requests.exceptions.RequestException as e:
43
- print("Request [" + str(res.status_code) + "] : " + method + " " + url + " step=" + str(step))
43
+ print("Request [Request Exception] : " + method + " " + url + " step=" + str(step))
44
44
  raise NetworkError(
45
45
  request=e.request,
46
46
  message=f"An unexpected network error occurred: {e}",
@@ -80,8 +80,9 @@ 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
- print(f"Retrying {method} {url} in {delay} seconds due to retryable error: {e.message}")
84
- time.sleep(delay)
83
+ retry_delay = e.retry_delay if hasattr(e, "retry_delay") else delay
84
+ print(f"Retrying {method} {url} in {retry_delay} seconds due to retryable error: {e.message}")
85
+ time.sleep(retry_delay)
85
86
  else:
86
87
  e.can_retry = False
87
88
  raise
@@ -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.5.1
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
  ```
@@ -54,4 +54,5 @@ src/certapi/server/cert_api.py
54
54
  src/certapi/server/key_api.py
55
55
  tests/test_cert_issuer_generic.py
56
56
  tests/test_certs_with_key_types.py
57
+ tests/test_http_error_handling.py
57
58
  tests/test_keystores.py
@@ -1,5 +1,5 @@
1
1
  import pytest
2
- from datetime import datetime, timedelta, timezone
2
+ from datetime import UTC, datetime, timedelta, timezone
3
3
  from cryptography import x509
4
4
  from cryptography.x509.oid import NameOID, ExtensionOID
5
5
  from certapi import Key, SelfCertIssuer, Certificate, CertificateSigningRequest, CertificateSigningRequestBuilder
@@ -58,10 +58,10 @@ def test_generate_key_and_cert(self_cert_issuer_instance: SelfCertIssuer, key_ty
58
58
  assert set(san_values) == set(unique_expected_sans)
59
59
 
60
60
  # Verify expiry
61
- now_utc = datetime.now(timezone.utc)
61
+ now_utc = datetime.now(UTC)
62
62
  # Ensure certificate validity dates are timezone-aware UTC for comparison
63
- cert_not_valid_before_utc = cert.not_valid_before.astimezone(timezone.utc)
64
- cert_not_valid_after_utc = cert.not_valid_after.astimezone(timezone.utc)
63
+ cert_not_valid_before_utc = cert.not_valid_before_utc.astimezone(timezone.utc)
64
+ cert_not_valid_after_utc = cert.not_valid_after_utc.astimezone(timezone.utc)
65
65
 
66
66
  assert cert_not_valid_before_utc <= now_utc
67
67
  assert cert_not_valid_after_utc >= now_utc + timedelta(days=expiry_days - 1) # Allow for slight time difference
@@ -0,0 +1,130 @@
1
+ import pytest
2
+ from unittest.mock import Mock, patch
3
+ import requests
4
+ from certapi.http.HttpClientBase import HttpClientBase
5
+ from certapi.errors import NetworkError, CertApiException
6
+
7
+
8
+ class TestHttpErrorHandling:
9
+ """Test suite for HTTP error handling in HttpClientBase"""
10
+
11
+ @pytest.fixture
12
+ def http_client(self):
13
+ """Create a basic HttpClientBase instance for testing"""
14
+ return HttpClientBase(
15
+ base_url="https://example.com", headers={"Content-Type": "application/json"}, auto_retry=True
16
+ )
17
+
18
+ def test_connection_reset_error_handling(self, http_client):
19
+ """Test that ConnectionResetError is properly caught and converted to NetworkError"""
20
+ with patch.object(http_client.session, "request") as mock_request:
21
+ # Simulate a ConnectionResetError wrapped in requests.exceptions.ConnectionError
22
+ connection_reset = ConnectionResetError(104, "Connection reset by peer")
23
+ mock_request.side_effect = requests.exceptions.ConnectionError(connection_reset)
24
+
25
+ with pytest.raises(NetworkError) as exc_info:
26
+ http_client._req("GET", "https://example.com/test", "Test Step")
27
+
28
+ # Verify the NetworkError is properly created
29
+ error = exc_info.value
30
+ assert error.can_retry is True
31
+ assert "Network connection error" in error.message
32
+ assert error.detail["errorType"] == "ConnectionError"
33
+
34
+ def test_connection_error_is_retriable(self, http_client):
35
+ """Test that connection errors are marked as retriable"""
36
+ with patch.object(http_client.session, "request") as mock_request:
37
+ mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
38
+
39
+ with pytest.raises(NetworkError) as exc_info:
40
+ http_client._req("GET", "https://example.com/test", "Test Step")
41
+
42
+ error = exc_info.value
43
+ assert error.can_retry is True
44
+ assert hasattr(error, "retry_delay")
45
+ assert error.retry_delay == 4 # Default retry delay
46
+
47
+ def test_timeout_error_is_retriable(self, http_client):
48
+ """Test that timeout errors are marked as retriable"""
49
+ with patch.object(http_client.session, "request") as mock_request:
50
+ mock_request.side_effect = requests.exceptions.Timeout("Request timed out")
51
+
52
+ with pytest.raises(NetworkError) as exc_info:
53
+ http_client._req("GET", "https://example.com/test", "Test Step")
54
+
55
+ error = exc_info.value
56
+ assert error.can_retry is True
57
+ assert hasattr(error, "retry_delay")
58
+
59
+ def test_retry_uses_exception_delay(self, http_client):
60
+ """Test that retry mechanism uses the delay from the exception"""
61
+ call_count = 0
62
+
63
+ def side_effect(*args, **kwargs):
64
+ nonlocal call_count
65
+ call_count += 1
66
+ if call_count < 3: # Fail first 2 times
67
+ raise requests.exceptions.ConnectionError("Connection failed")
68
+ # Success on 3rd attempt
69
+ mock_response = Mock()
70
+ mock_response.status_code = 200
71
+ return mock_response
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
+
77
+ assert result.status_code == 200
78
+ # Should have slept twice (after 1st and 2nd failures)
79
+ assert mock_sleep.call_count == 2
80
+ # Should use default delay of 4 seconds
81
+ mock_sleep.assert_called_with(4)
82
+
83
+ def test_retry_exhausted_marks_non_retriable(self, http_client):
84
+ """Test that after exhausting retries, error is marked as non-retriable"""
85
+ with patch.object(http_client.session, "request") as mock_request:
86
+ mock_request.side_effect = requests.exceptions.ConnectionError("Connection failed")
87
+
88
+ with pytest.raises(NetworkError) as exc_info:
89
+ http_client._req_with_retry("GET", "https://example.com/test", "Test Step", retries=1)
90
+
91
+ error = exc_info.value
92
+ # After retries are exhausted, can_retry should be False
93
+ assert error.can_retry is False
94
+
95
+ def test_no_retry_when_auto_retry_false(self, http_client):
96
+ """Test that retry doesn't happen when auto_retry is False"""
97
+ http_client.auto_retry = False
98
+ call_count = 0
99
+
100
+ def side_effect(*args, **kwargs):
101
+ nonlocal call_count
102
+ call_count += 1
103
+ raise requests.exceptions.ConnectionError("Connection failed")
104
+
105
+ with patch.object(http_client.session, "request", side_effect=side_effect):
106
+ with pytest.raises(NetworkError):
107
+ http_client._req_with_retry("GET", "https://example.com/test", "Test Step", retries=2)
108
+
109
+ # Should only be called once (no retries)
110
+ assert call_count == 1
111
+
112
+ def test_successful_request_no_error(self, http_client):
113
+ """Test that successful requests don't raise errors"""
114
+ with patch.object(http_client.session, "request") as mock_request:
115
+ mock_response = Mock()
116
+ mock_response.status_code = 200
117
+ mock_request.return_value = mock_response
118
+
119
+ result = http_client._req("GET", "https://example.com/test", "Test Step")
120
+
121
+ assert result.status_code == 200
122
+
123
+ def test_custom_retry_delay(self):
124
+ """Test that custom retry delay can be set on exception"""
125
+ exception = CertApiException("Test error")
126
+ exception.retry_delay = 10
127
+ exception.can_retry = True
128
+
129
+ assert exception.retry_delay == 10
130
+ assert exception.can_retry is True
@@ -1,3 +1,4 @@
1
+ from time import timezone
1
2
  import pytest
2
3
  import os
3
4
  import psycopg2 # Added for PostgreSQL database creation
@@ -7,7 +8,7 @@ from certapi import Key, Certificate
7
8
  from certapi.crypto.crypto import cert_to_pem, certs_to_pem
8
9
  from certapi.keystore import SqliteKeyStore, FileSystemKeyStore, PostgresKeyStore
9
10
  from typing import List, Tuple, Union
10
- from datetime import datetime, timedelta
11
+ from datetime import UTC, datetime, timedelta
11
12
  from certapi import KeyStore, Certificate, Key
12
13
 
13
14
  from cryptography import x509
@@ -163,14 +164,15 @@ def test_get_cert_by_id(keystore: KeyStore, ca_key: Key):
163
164
 
164
165
 
165
166
  def sign_csr(csr: x509.CertificateSigningRequest, issuer_key: Key, days_valid=365) -> Certificate:
167
+ now = datetime.now(UTC)
166
168
  builder = (
167
169
  x509.CertificateBuilder()
168
170
  .subject_name(csr.subject)
169
171
  .issuer_name(x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "certapi.pytest.com")]))
170
172
  .public_key(csr.public_key())
171
173
  .serial_number(x509.random_serial_number())
172
- .not_valid_before(datetime.utcnow())
173
- .not_valid_after(datetime.utcnow() + timedelta(days=days_valid))
174
+ .not_valid_before(now)
175
+ .not_valid_after(now + timedelta(days=days_valid))
174
176
  )
175
177
 
176
178
  # Optionally copy extensions from CSR
certapi-0.5.1/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