certapi 1.0.0__tar.gz → 1.0.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 (63) hide show
  1. {certapi-1.0.0/src/certapi.egg-info → certapi-1.0.2}/PKG-INFO +8 -8
  2. {certapi-1.0.0 → certapi-1.0.2}/README.md +7 -7
  3. {certapi-1.0.0 → certapi-1.0.2}/setup.py +1 -1
  4. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/__init__.py +1 -0
  5. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Acme.py +1 -1
  6. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/AcmeError.py +23 -13
  7. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Challenge.py +1 -1
  8. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/ChallengeSolver.py +3 -0
  9. certapi-1.0.2/src/certapi/challenge_solver/FileSystemChallengeSolver.py +115 -0
  10. certapi-1.0.2/src/certapi/client/__init__.py +1 -0
  11. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/client/cert_manager_client.py +43 -8
  12. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/http/types.py +1 -1
  13. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/AcmeCertIssuer.py +3 -3
  14. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/abstract_certissuer.py +2 -2
  15. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/manager/acme_cert_manager.py +1 -1
  16. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/api.py +57 -37
  17. {certapi-1.0.0 → certapi-1.0.2/src/certapi.egg-info}/PKG-INFO +8 -8
  18. {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/SOURCES.txt +1 -0
  19. certapi-1.0.2/tests/test_acme_error_handling.py +762 -0
  20. certapi-1.0.0/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -49
  21. certapi-1.0.0/src/certapi/server/__init__.py +0 -0
  22. {certapi-1.0.0 → certapi-1.0.2}/MANIFEST.in +0 -0
  23. {certapi-1.0.0 → certapi-1.0.2}/pyproject.toml +0 -0
  24. {certapi-1.0.0 → certapi-1.0.2}/setup.cfg +0 -0
  25. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/Order.py +0 -0
  26. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/__init__.py +0 -0
  27. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/acme/http.py +0 -0
  28. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  29. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/__init__.py +0 -0
  30. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  31. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  32. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  33. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  34. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  35. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  36. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  37. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/__init__.py +0 -0
  38. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/crypto.py +0 -0
  39. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/crypto/crypto_classes.py +0 -0
  40. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/errors.py +0 -0
  41. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/http/HttpClientBase.py +0 -0
  42. {certapi-1.0.0/src/certapi/client → certapi-1.0.2/src/certapi/http}/__init__.py +0 -0
  43. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  44. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/issuers/__init__.py +0 -0
  45. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  46. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/KeyStore.py +0 -0
  47. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  48. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  49. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  50. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/keystore/__init__.py +0 -0
  51. {certapi-1.0.0/src/certapi/http → certapi-1.0.2/src/certapi/manager}/__init__.py +0 -0
  52. {certapi-1.0.0/src/certapi/manager → certapi-1.0.2/src/certapi/server}/__init__.py +0 -0
  53. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/cert_api.py +0 -0
  54. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/server/key_api.py +0 -0
  55. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/util.py +0 -0
  56. {certapi-1.0.0 → certapi-1.0.2}/src/certapi/utils.py +0 -0
  57. {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/dependency_links.txt +0 -0
  58. {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/requires.txt +0 -0
  59. {certapi-1.0.0 → certapi-1.0.2}/src/certapi.egg-info/top_level.txt +0 -0
  60. {certapi-1.0.0 → certapi-1.0.2}/tests/test_cert_issuer_generic.py +0 -0
  61. {certapi-1.0.0 → certapi-1.0.2}/tests/test_certs_with_key_types.py +0 -0
  62. {certapi-1.0.0 → certapi-1.0.2}/tests/test_http_error_handling.py +0 -0
  63. {certapi-1.0.0 → certapi-1.0.2}/tests/test_keystores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.0.0
3
+ Version: 1.0.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
@@ -22,17 +22,18 @@ Dynamic: requires-dist
22
22
  Dynamic: requires-python
23
23
  Dynamic: summary
24
24
 
25
- # CertApi
25
+ CertApi
26
+ =============================
26
27
 
27
- CertApi is a Python package for requesting SSL certificates from ACME.
28
- This is to be used as a base library for building other tools, or to integrate Certificate creation feature in you app.
28
+ Certapi talks with DNS provider and ACME to issue SSL certificates and save it to a keystore.
29
+
30
+ CertApi is a base library for building other tools, or to integrate Certificate creation feature in your app. CertAPI also provides HTTP api server and can be deployed using Docker
29
31
 
30
32
  [![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
33
  [![codecov](https://codecov.io/github/mesudip/certapi/graph/badge.svg?token=NYTNCH29IT)](https://codecov.io/github/mesudip/certapi)
32
34
  [![PyPI version](https://img.shields.io/pypi/v/certapi.svg)](https://pypi.org/project/certapi/)
33
35
 
34
36
 
35
- For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
36
37
 
37
38
  ## Installation
38
39
 
@@ -100,12 +101,11 @@ cert_manager = AcmeCertManager(
100
101
  key_store=key_store,
101
102
  cert_issuer=cert_issuer,
102
103
  challenge_solvers=[dns_solver], # other solvers can be used
103
- renew_threshold_days=7
104
- )
104
+ )
105
105
  cert_manager.setup()
106
106
 
107
107
  # 4. Issue or Reuse Certificate
108
- # Automatically checks keystore and renews only if necessary
108
+ # Automatically checks sand saves to keystore. Renews only if necessary.
109
109
  response = cert_manager.issue_certificate(["example.com", "www.example.com"])
110
110
 
111
111
  for cert_data in response.issued:
@@ -1,14 +1,15 @@
1
- # CertApi
1
+ CertApi
2
+ =============================
2
3
 
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.
4
+ Certapi talks with DNS provider and ACME to issue SSL certificates and save it to a keystore.
5
+
6
+ CertApi is a base library for building other tools, or to integrate Certificate creation feature in your app. CertAPI also provides HTTP api server and can be deployed using Docker
5
7
 
6
8
  [![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
9
  [![codecov](https://codecov.io/github/mesudip/certapi/graph/badge.svg?token=NYTNCH29IT)](https://codecov.io/github/mesudip/certapi)
8
10
  [![PyPI version](https://img.shields.io/pypi/v/certapi.svg)](https://pypi.org/project/certapi/)
9
11
 
10
12
 
11
- For a detailed list of changes, please refer to the [CHANGELOG.md](CHANGELOG.md).
12
13
 
13
14
  ## Installation
14
15
 
@@ -76,12 +77,11 @@ cert_manager = AcmeCertManager(
76
77
  key_store=key_store,
77
78
  cert_issuer=cert_issuer,
78
79
  challenge_solvers=[dns_solver], # other solvers can be used
79
- renew_threshold_days=7
80
- )
80
+ )
81
81
  cert_manager.setup()
82
82
 
83
83
  # 4. Issue or Reuse Certificate
84
- # Automatically checks keystore and renews only if necessary
84
+ # Automatically checks sand saves to keystore. Renews only if necessary.
85
85
  response = cert_manager.issue_certificate(["example.com", "www.example.com"])
86
86
 
87
87
  for cert_data in response.issued:
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.0.0",
5
+ version="1.0.2",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -21,3 +21,4 @@ from .challenge_solver import (
21
21
  DigitalOceanChallengeSolver,
22
22
  )
23
23
  from .issuers import CertIssuer, SelfCertIssuer, AcmeCertIssuer
24
+ from .client import CertManagerClient
@@ -30,7 +30,7 @@ class Acme:
30
30
  self.jwk = account_key.jwk()
31
31
  self.alg_name = account_key.algorithm_name()
32
32
  self.nonce = []
33
- self.acme_url = url if url else os.environ.get("ACME_API_URL", self.URL_STAGING)
33
+ self.acme_url = url if url else os.environ.get("ACME_API_URL", self.URL_PROD)
34
34
  self.key_id = None
35
35
  self.directory = None
36
36
  self._nonce_lock = threading.Lock() # Mutex for safe access to nonce
@@ -28,21 +28,29 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
28
28
 
29
29
  def __init__(self, response: requests.Response, step: str):
30
30
  requests.HTTPError.__init__(self, response=response)
31
- self.response = response
32
31
  (message, detail) = self.extract_acme_response_error()
33
32
  AcmeError.__init__(self, message, detail, step)
33
+ self.response = response
34
34
 
35
35
  def extract_acme_response_error(self):
36
36
  message = None
37
37
  error = None
38
38
  try:
39
39
  res_json = self.response.json()
40
- if res_json["status"] == "invalid":
41
- if res_json["challenges"]:
42
- failed_challenge: dict = [x for x in res_json["challenges"] if x["status"] == "invalid"][0]
43
- error = failed_challenge["error"]
44
- err_detail: str = error.get("detail")
45
- err_type: str = error.get("type")
40
+ if res_json.get("status") == "invalid":
41
+ if res_json.get("challenges"):
42
+ invalid_challenges = [x for x in res_json["challenges"] if x["status"] == "invalid"]
43
+ if not invalid_challenges:
44
+ # Fallback if no invalid challenge found but status is invalid
45
+ return (None, res_json)
46
+
47
+ failed_challenge: dict = invalid_challenges[0]
48
+ error = failed_challenge.get("error")
49
+ if not error:
50
+ return (None, res_json)
51
+
52
+ err_detail: str = error.get("detail", "")
53
+ err_type: str = error.get("type", "")
46
54
  validation_record: dict = failed_challenge.get("validationRecord")
47
55
  error = {
48
56
  "url": failed_challenge["url"],
@@ -51,7 +59,8 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
51
59
  # Search for the pattern and extract the content
52
60
  if err_detail.startswith("DNS problem: NXDOMAIN"):
53
61
  error["dns"] = {"error": "DNS record doesn't exist"}
54
- error["hostname"] = re.findall(r"looking up [A-Z]+ for ([\w.-]+)", err_detail)[0]
62
+ hostnames = re.findall(r"looking up [A-Z]+ for ([\w.-]+)", err_detail)
63
+ error["hostname"] = hostnames[0] if hostnames else "unknown"
55
64
  message = error["hostname"] + " doesn't have a valid DNS record"
56
65
  elif err_detail:
57
66
  error["dns"] = {"error": err_detail}
@@ -60,7 +69,9 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
60
69
  error["dns"] = {"error": error}
61
70
  message = err_detail
62
71
  else:
63
- validation_record = validation_record[0]
72
+ validation_record = (
73
+ validation_record[0] if isinstance(validation_record, list) and validation_record else {}
74
+ )
64
75
  error["hostname"] = validation_record.get("hostname", "unknown")
65
76
  error["dns"] = {
66
77
  "resolved": validation_record.get("addressesResolved"),
@@ -103,8 +114,8 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
103
114
  match = re.search(pattern, err_detail)
104
115
 
105
116
  if match:
106
- error["response"] = (match.group(1) if match is not None else err_detail,)
107
- error["status_code"] = (error.get("status"),)
117
+ error["response"] = match.group(1) if match is not None else err_detail
118
+ error["status_code"] = error.get("status")
108
119
  hostname = error.get("hostname", "unknown")
109
120
  status = error.get("status", "unknown")
110
121
  message = (
@@ -131,7 +142,7 @@ class AcmeHttpError(AcmeError, requests.HTTPError):
131
142
  return (message, error)
132
143
 
133
144
 
134
- class AcmeInvaliOrderError(AcmeHttpError):
145
+ class AcmeInvalidOrderError(AcmeHttpError):
135
146
  def __init__(self, response: requests.Response, step: str):
136
147
  super().__init__(response, step)
137
148
 
@@ -140,4 +151,3 @@ class AcmeInvalidNonceError(AcmeHttpError):
140
151
  def __init__(self, response: requests.Response, step: str):
141
152
  super().__init__(response, step)
142
153
  self.can_retry = True
143
- self.response = 0
@@ -68,7 +68,7 @@ class Challenge:
68
68
  self.verified = True
69
69
  return True
70
70
  elif res_json["status"] == "invalid":
71
- raise AcmeInvaliOrderError(res, "Query Challenge Status")
71
+ raise AcmeInvalidOrderError(res, "Query Challenge Status")
72
72
  else:
73
73
  return False
74
74
 
@@ -13,6 +13,9 @@ class ChallengeSolver(ABC):
13
13
  def supports_domain(self, domain: str) -> bool:
14
14
  pass
15
15
 
16
+ def supports_domain_strict(self, domain: str) -> bool:
17
+ return self.supports_domain(domain)
18
+
16
19
  @abstractmethod
17
20
  def save_challenge(self, key: str, value: str, domain: str = None):
18
21
  pass
@@ -0,0 +1,115 @@
1
+ from typing import Literal
2
+ from .ChallengeSolver import ChallengeSolver
3
+ import os
4
+ import random
5
+ import string
6
+ import requests
7
+ import sys
8
+ import re
9
+
10
+
11
+ class FilesystemChallengeSolver(ChallengeSolver):
12
+ """
13
+ Filesystem implementation of the ChallengeSolver.
14
+ This should never be used, but extended for your webserver e.g. nginx, apache etc.
15
+ """
16
+
17
+ def __init__(self, directory: str):
18
+ self.directory = directory
19
+ os.makedirs(self.directory, exist_ok=True)
20
+
21
+ def supports_domain(self, domain: str) -> bool:
22
+ return "*" not in domain
23
+
24
+ def supports_domain_strict(self, domain: str) -> bool:
25
+ return self.verify_domain(domain) if self.supports_domain(domain) else False
26
+
27
+ def save_challenge(self, key: str, value: str, domain: str = None):
28
+ file_path = os.path.join(self.directory, key)
29
+ with open(file_path, "w") as file:
30
+ file.write(value)
31
+
32
+ def get_challenge(self, key: str, domain: str = None) -> str:
33
+ file_path = os.path.join(self.directory, key)
34
+ if not os.path.exists(file_path):
35
+ return None
36
+ with open(file_path, "r") as file:
37
+ return file.read()
38
+
39
+ def delete_challenge(self, key: str, domain: str = None):
40
+ file_path = os.path.join(self.directory, key)
41
+ if os.path.exists(file_path):
42
+ os.remove(file_path)
43
+
44
+ def supported_challenge_type(self) -> Literal["http-01"]:
45
+ return "http-01"
46
+
47
+ def cleanup_old_challenges(self):
48
+ for filename in os.listdir(self.directory):
49
+ file_path = os.path.join(self.directory, filename)
50
+ if os.path.isfile(file_path):
51
+ os.remove(file_path)
52
+
53
+ def __iter__(self):
54
+ return (f for f in os.listdir(self.directory) if os.path.isfile(os.path.join(self.directory, f)))
55
+
56
+ @staticmethod
57
+ def is_valid_hostname(hostname):
58
+ if not hostname or len(hostname) > 255:
59
+ return False
60
+ if hostname[-1] == ".":
61
+ hostname = hostname[:-1]
62
+
63
+ allowed = re.compile(r"(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
64
+ return all(allowed.match(x) for x in hostname.split("."))
65
+
66
+ def verify_domain(self, _domain: list | str):
67
+ domain = [_domain] if type(_domain) is str else _domain
68
+
69
+ # Simple hostname validation
70
+ domain = [x for x in domain if self.is_valid_hostname(x)]
71
+ success = []
72
+ while True:
73
+ r1 = "".join([random.choice(string.ascii_letters + string.digits) for _ in range(32)])
74
+ file = os.path.join(self.directory, r1)
75
+ if os.path.exists(file):
76
+ continue
77
+ r2 = "".join([random.choice(string.ascii_letters + string.digits) for _ in range(256)])
78
+ with open(file, mode="wt") as file_descriptor:
79
+ file_descriptor.write(r2)
80
+ for d in domain:
81
+ try:
82
+ url = "http://%s/.well-known/acme-challenge/%s" % (d, r1)
83
+ response = requests.get(url, allow_redirects=False, timeout=3)
84
+ if response.status_code == 200:
85
+ if response.content.decode("utf-8") == r2:
86
+ success.append(d)
87
+ continue
88
+ print(
89
+ "[Error] ["
90
+ + d
91
+ + "] Not owned by this machine:"
92
+ + "Status Code["
93
+ + str(response.status_code)
94
+ + "] -> "
95
+ + url,
96
+ file=sys.stderr,
97
+ )
98
+ continue
99
+ except requests.exceptions.RequestException as e:
100
+ error = str(e)
101
+ if error.find("Name does not resolve") > -1:
102
+ print("[Error] [" + d + "] Domain Name could not be resolved", file=sys.stderr)
103
+ elif error.find("Connection refused") > -1:
104
+ print(
105
+ "[Error] [" + d + "] Connection Refused! The port is filtered or not open.", file=sys.stderr
106
+ )
107
+ else:
108
+ print("[ERROR] [" + d + "] Not owned by this machine : " + str(e))
109
+ continue
110
+ os.remove(file)
111
+ break
112
+ if type(_domain) is str:
113
+ return len(success) > 0
114
+ else:
115
+ return success
@@ -0,0 +1 @@
1
+ from .cert_manager_client import CertManagerClient
@@ -1,11 +1,15 @@
1
1
  import requests
2
- from typing import List, Union, Dict, Optional, Any
2
+ from typing import List, Union, Dict, Optional, Any, Literal
3
+ from cryptography import x509
3
4
  from certapi.http.types import CertificateResponse, IssuedCert
5
+ from certapi.keystore.KeyStore import KeyStore
6
+ from certapi.crypto import Key
4
7
 
5
8
 
6
9
  class CertManagerClient:
7
- def __init__(self, base_url: str):
10
+ def __init__(self, base_url: str, key_store: Optional[KeyStore] = None):
8
11
  self.base_url = base_url
12
+ self.key_store = key_store
9
13
 
10
14
  def _get(self, path: str, params: Optional[Dict[str, Any]] = None) -> Any:
11
15
  response = requests.get(f"{self.base_url}{path}", params=params)
@@ -24,19 +28,29 @@ class CertManagerClient:
24
28
  response.raise_for_status()
25
29
  return response.json()
26
30
 
27
- def obtain_certificate(
31
+ def setup(self):
32
+ """
33
+ Check connection to the cert manager server.
34
+ """
35
+ try:
36
+ self._get("/keys")
37
+ except Exception as e:
38
+ print(f"Warning: CertManagerClient could not connect to {self.base_url}: {e}")
39
+
40
+ def issue_certificate(
28
41
  self,
29
- hostnames: Union[str, List[str]],
30
- key_type: str = "ecdsa",
42
+ hosts: Union[str, List[str]],
43
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
31
44
  expiry_days: int = 90,
32
45
  country: Optional[str] = None,
33
46
  state: Optional[str] = None,
34
47
  locality: Optional[str] = None,
35
48
  organization: Optional[str] = None,
36
49
  user_id: Optional[str] = None,
50
+ renew_threshold_days: Optional[int] = None,
37
51
  ) -> CertificateResponse:
38
52
  params = {
39
- "hostname": hostnames if isinstance(hostnames, str) else hostnames,
53
+ "hostname": hosts if isinstance(hosts, str) else hosts,
40
54
  "key_type": key_type,
41
55
  "expiry_days": expiry_days,
42
56
  }
@@ -50,11 +64,32 @@ class CertManagerClient:
50
64
  params["organization"] = organization
51
65
  if user_id:
52
66
  params["user_id"] = user_id
67
+ if renew_threshold_days is not None:
68
+ params["renew_threshold_days"] = renew_threshold_days
53
69
 
54
70
  data = self._get("/obtain", params=params)
55
- return CertificateResponse.from_json(data)
71
+ res = CertificateResponse.from_json(data)
72
+
73
+ if self.key_store:
74
+ for cert_data in res.issued + res.existing:
75
+ if cert_data.privateKey and cert_data.certificate:
76
+ try:
77
+ key = Key.from_pem(cert_data.privateKey.encode("utf-8"))
78
+ key_id = self.key_store.save_key(key, cert_data.domains[0])
79
+ self.key_store.save_cert(key_id, cert_data.certificate, cert_data.domains)
80
+ except Exception as e:
81
+ print(f"Warning: Failed to save certificate for {cert_data.domains} to KeyStore: {e}")
82
+ raise e
83
+ return res
84
+
85
+ def issue_certificate_for_csr(self, csr: Union[str, x509.CertificateSigningRequest]) -> str:
86
+ if not isinstance(csr, str):
87
+ from ..crypto import csr_to_pem
88
+
89
+ csr_pem = csr_to_pem(csr).decode("utf-8")
90
+ else:
91
+ csr_pem = csr
56
92
 
57
- def sign_csr(self, csr_pem: str) -> str:
58
93
  response = requests.post(
59
94
  f"{self.base_url}/sign_csr", data=csr_pem, headers={"Content-Type": "application/x-pem-file"}
60
95
  )
@@ -41,7 +41,7 @@ class IssuedCert:
41
41
  return f"IssuedCert(domains={self.domains})"
42
42
 
43
43
  def __str__(self):
44
- return f"(hosts: {self.domains}, certificate:{self.certificate})"
44
+ return f"(domains: {self.domains}, certificate:{self.certificate})"
45
45
 
46
46
  def to_json(self):
47
47
  return {"privateKey": self.privateKey, "certificate": self.certificate, "domains": self.domains}
@@ -28,7 +28,7 @@ class AcmeCertIssuer(CertIssuer):
28
28
  account_key_name: str = "acme_account.key",
29
29
  acme_url: str = None,
30
30
  ) -> "AcmeCertIssuer":
31
- account_key, _ = key_store._get_or_generate_key(account_key_name)
31
+ account_key, _ = key_store._get_or_generate_key(account_key_name, "rsa")
32
32
  return AcmeCertIssuer(account_key, challenge_solver, acme_url=acme_url)
33
33
 
34
34
  def setup(self):
@@ -103,7 +103,7 @@ class AcmeCertIssuer(CertIssuer):
103
103
  def generate_key_and_cert_for_domains(
104
104
  self,
105
105
  hosts: Union[str, List[str]],
106
- key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
106
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
107
107
  expiry_days: int = 90,
108
108
  country: Optional[str] = None,
109
109
  state: Optional[str] = None,
@@ -130,7 +130,7 @@ class AcmeCertIssuer(CertIssuer):
130
130
  def generate_key_and_cert_for_domain(
131
131
  self,
132
132
  host: str,
133
- key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
133
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
134
134
  expiry_days: int = 90,
135
135
  country: Optional[str] = None,
136
136
  state: Optional[str] = None,
@@ -30,7 +30,7 @@ class CertIssuer(ABC):
30
30
  def generate_key_and_cert_for_domains(
31
31
  self,
32
32
  hosts: Union[str, List[str]],
33
- key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
33
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
34
34
  expiry_days: int = 90,
35
35
  country: Optional[str] = None,
36
36
  state: Optional[str] = None,
@@ -57,7 +57,7 @@ class CertIssuer(ABC):
57
57
  def generate_key_and_cert_for_domain(
58
58
  self,
59
59
  host: str,
60
- key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
60
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
61
61
  expiry_days: int = 90,
62
62
  country: Optional[str] = None,
63
63
  state: Optional[str] = None,
@@ -66,7 +66,7 @@ class AcmeCertManager:
66
66
  def issue_certificate(
67
67
  self,
68
68
  hosts: Union[str, List[str]],
69
- key_type: Literal["rsa", "ecdsa", "ed25519"] = "rsa",
69
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
70
70
  expiry_days: int = 90,
71
71
  country: Optional[str] = None,
72
72
  state: Optional[str] = None,
@@ -3,8 +3,9 @@ import threading
3
3
  import os
4
4
  from flask import request, jsonify
5
5
  from cryptography.x509 import CertificateSigningRequest
6
- from flask_restx import Resource, reqparse, fields
7
-
6
+ from flask_restx import Resource, reqparse, fields, inputs
7
+ from certapi.challenge_solver import FilesystemChallengeSolver
8
+ from certapi.http.types import CertificateResponse
8
9
  from certapi import AcmeCertManager
9
10
 
10
11
 
@@ -15,37 +16,38 @@ class RenewalQueueFullError(Exception):
15
16
 
16
17
 
17
18
  class RenewalLockManager:
18
- def __init__(self, queue_size: int = 5):
19
+ def __init__(self, queue_size: int = 16):
19
20
  self.queue_size = queue_size
20
21
  self._lock = threading.Lock()
21
22
  self._condition = threading.Condition(self._lock)
22
- self._renewing_domains = set()
23
+ self._busy = False
23
24
  self._waiting_threads = 0
24
25
 
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):
26
+ def acquire(self, domains=None):
27
+ with self._lock:
28
+ while self._busy:
29
+ if self._waiting_threads >= self.queue_size:
30
+ raise RenewalQueueFullError("Renewal queue is full, please try again later")
31
+
32
+ self._waiting_threads += 1
33
+ self._condition.wait()
34
+ self._waiting_threads -= 1
35
+
36
+ # Mark as busy
37
+ self._busy = True
38
+
39
+ def release(self, domains=None):
44
40
  with self._lock:
45
- for h in domains:
46
- self._renewing_domains.discard(h)
41
+ self._busy = False
47
42
  self._condition.notify_all()
48
43
 
44
+ def __enter__(self):
45
+ self.acquire()
46
+ return self
47
+
48
+ def __exit__(self, exc_type, exc_val, exc_tb):
49
+ self.release()
50
+
49
51
 
50
52
  def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size: int = 5):
51
53
 
@@ -89,6 +91,12 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
89
91
  obtain_parser.add_argument("organization", type=str, help="Organization name")
90
92
  obtain_parser.add_argument("user_id", type=str, help="User ID")
91
93
  obtain_parser.add_argument("renew_threshold_days", type=int, help="Threshold in days for certificate reuse")
94
+ obtain_parser.add_argument(
95
+ "skip_failing",
96
+ type=inputs.boolean,
97
+ default=False,
98
+ help="Allow issuance to proceed if some domains fail verification",
99
+ )
92
100
 
93
101
  @api_ns.route("/obtain")
94
102
  class ObtainCert(Resource):
@@ -102,10 +110,24 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
102
110
  def get(self):
103
111
  args = obtain_parser.parse_args()
104
112
  hostnames = args["hostname"]
113
+ skip_failing = args.get("skip_failing", False)
114
+
115
+ with lock_manager:
116
+ verified_hostnames = []
117
+ for h in hostnames:
118
+ # Find the first solver that supports this domain
119
+ for solver in reversed(cert_manager.challenge_solvers):
120
+ if solver.supports_domain_strict(h):
121
+ verified_hostnames.append(h)
122
+
123
+ hostnames = verified_hostnames
124
+
125
+ if not hostnames and not skip_failing:
126
+ api_ns.abort(400, message="None of the domains are owned by this machine or could be verified")
127
+
128
+ if not hostnames:
129
+ return CertificateResponse().to_json()
105
130
 
106
- # Acquire lock for the domains
107
- lock_manager.acquire(hostnames)
108
- try:
109
131
  data = cert_manager.issue_certificate(
110
132
  hostnames,
111
133
  key_type=args["key_type"],
@@ -117,15 +139,13 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
117
139
  user_id=args["user_id"],
118
140
  renew_threshold_days=args.get("renew_threshold_days"),
119
141
  )
120
- finally:
121
- lock_manager.release(hostnames)
122
-
123
- print(data)
124
- print(data.to_json())
125
- if data:
126
- return data.to_json()
127
- else:
128
- api_ns.abort(500, message="something went wrong")
142
+
143
+ print(data)
144
+ if data:
145
+ res_json = data.to_json()
146
+ return res_json
147
+ else:
148
+ api_ns.abort(500, message="something went wrong")
129
149
 
130
150
  @api_ns.route("/sign_csr")
131
151
  class SignCsr(Resource):