certapi 1.1.1__tar.gz → 1.1.3__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 (70) hide show
  1. {certapi-1.1.1/src/certapi.egg-info → certapi-1.1.3}/PKG-INFO +1 -1
  2. {certapi-1.1.1 → certapi-1.1.3}/setup.py +1 -1
  3. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/__init__.py +1 -1
  4. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/client/__init__.py +1 -0
  5. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/client/cert_manager_client.py +32 -0
  6. certapi-1.1.3/src/certapi/client/renewal_manager.py +454 -0
  7. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/manager/acme_cert_manager.py +38 -2
  8. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/api.py +16 -7
  9. {certapi-1.1.1 → certapi-1.1.3/src/certapi.egg-info}/PKG-INFO +1 -1
  10. {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/SOURCES.txt +4 -1
  11. {certapi-1.1.1 → certapi-1.1.3}/tests/test_acme_cert_manager_batching.py +34 -0
  12. certapi-1.1.3/tests/test_obtain_interface.py +194 -0
  13. certapi-1.1.3/tests/test_renewal_manager.py +647 -0
  14. {certapi-1.1.1 → certapi-1.1.3}/MANIFEST.in +0 -0
  15. {certapi-1.1.1 → certapi-1.1.3}/README.md +0 -0
  16. {certapi-1.1.1 → certapi-1.1.3}/pyproject.toml +0 -0
  17. {certapi-1.1.1 → certapi-1.1.3}/setup.cfg +0 -0
  18. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Acme.py +0 -0
  19. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/AcmeError.py +0 -0
  20. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Challenge.py +0 -0
  21. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Order.py +0 -0
  22. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/__init__.py +0 -0
  23. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/http.py +0 -0
  24. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  25. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  26. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  27. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/__init__.py +0 -0
  28. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  29. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  30. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  31. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  32. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  33. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  34. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  35. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/cli.py +0 -0
  36. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/__init__.py +0 -0
  37. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/crypto.py +0 -0
  38. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/crypto_classes.py +0 -0
  39. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/domain_batching.py +0 -0
  40. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/errors.py +0 -0
  41. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/HttpClientBase.py +0 -0
  42. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/__init__.py +0 -0
  43. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/types.py +0 -0
  44. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  45. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  46. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/__init__.py +0 -0
  47. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/abstract_certissuer.py +0 -0
  48. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  49. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/KeyStore.py +0 -0
  50. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  51. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  52. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  53. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/__init__.py +0 -0
  54. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/manager/__init__.py +0 -0
  55. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/__init__.py +0 -0
  56. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/cert_api.py +0 -0
  57. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/key_api.py +0 -0
  58. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/util.py +0 -0
  59. {certapi-1.1.1 → certapi-1.1.3}/src/certapi/utils.py +0 -0
  60. {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/dependency_links.txt +0 -0
  61. {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/entry_points.txt +0 -0
  62. {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/requires.txt +0 -0
  63. {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/top_level.txt +0 -0
  64. {certapi-1.1.1 → certapi-1.1.3}/tests/test_acme_error_handling.py +0 -0
  65. {certapi-1.1.1 → certapi-1.1.3}/tests/test_cert_issuer_generic.py +0 -0
  66. {certapi-1.1.1 → certapi-1.1.3}/tests/test_certs_with_key_types.py +0 -0
  67. {certapi-1.1.1 → certapi-1.1.3}/tests/test_cli.py +0 -0
  68. {certapi-1.1.1 → certapi-1.1.3}/tests/test_domain_batching_boulder_cases.py +0 -0
  69. {certapi-1.1.1 → certapi-1.1.3}/tests/test_http_error_handling.py +0 -0
  70. {certapi-1.1.1 → certapi-1.1.3}/tests/test_keystores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.1
3
+ Version: 1.1.3
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="certapi",
5
- version="1.1.1",
5
+ version="1.1.3",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -21,5 +21,5 @@ from .challenge_solver import (
21
21
  DigitalOceanChallengeSolver,
22
22
  )
23
23
  from .issuers import CertIssuer, SelfCertIssuer, AcmeCertIssuer
24
- from .client import CertManagerClient
24
+ from .client import CertManagerClient, RenewalManager
25
25
  from .domain_batching import create_safe_domain_batches
@@ -1 +1,2 @@
1
1
  from .cert_manager_client import CertManagerClient
2
+ from .renewal_manager import RenewalManager
@@ -50,6 +50,37 @@ class CertManagerClient:
50
50
  renew_threshold_days: Optional[int] = None,
51
51
  skip_failing: bool = True,
52
52
  batch_domains: bool = False,
53
+ self_verify: bool = True,
54
+ ) -> CertificateResponse:
55
+ return self.obtain(
56
+ hosts=hosts,
57
+ key_type=key_type,
58
+ expiry_days=expiry_days,
59
+ country=country,
60
+ state=state,
61
+ locality=locality,
62
+ organization=organization,
63
+ user_id=user_id,
64
+ renew_threshold_days=renew_threshold_days,
65
+ skip_failing=skip_failing,
66
+ batch_domains=batch_domains,
67
+ self_verify=self_verify,
68
+ )
69
+
70
+ def obtain(
71
+ self,
72
+ hosts: Union[str, List[str]],
73
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
74
+ expiry_days: int = 90,
75
+ country: Optional[str] = None,
76
+ state: Optional[str] = None,
77
+ locality: Optional[str] = None,
78
+ organization: Optional[str] = None,
79
+ user_id: Optional[str] = None,
80
+ renew_threshold_days: Optional[int] = None,
81
+ skip_failing: bool = True,
82
+ batch_domains: bool = False,
83
+ self_verify: bool = True,
53
84
  ) -> CertificateResponse:
54
85
  params = {
55
86
  "hostname": hosts if isinstance(hosts, str) else hosts,
@@ -70,6 +101,7 @@ class CertManagerClient:
70
101
  params["renew_threshold_days"] = renew_threshold_days
71
102
  params["skip_failing"] = skip_failing
72
103
  params["batch_domains"] = batch_domains
104
+ params["self_verify"] = self_verify
73
105
 
74
106
  data = self._get("/api/obtain", params=params)
75
107
  res = CertificateResponse.from_json(data)
@@ -0,0 +1,454 @@
1
+ import os
2
+ import threading
3
+ import time
4
+ from datetime import datetime, timezone, timedelta
5
+ from typing import Callable, Dict, Optional, Set, Any, List
6
+
7
+ from certapi.crypto import Key, certs_from_pem
8
+ from certapi.client.cert_manager_client import CertManagerClient
9
+ from certapi.issuers import SelfCertIssuer
10
+ from certapi.manager.acme_cert_manager import DEFAULT_RENEW_THRESHOLD_DAYS
11
+
12
+
13
+ class RenewalManager:
14
+ """
15
+ Background certificate refresh manager.
16
+
17
+ Algorithm:
18
+ - Maintain a watched domain set and cache each domain's certificate expiry.
19
+ - Seed missing cache entries from the local keystore when a local certificate exists.
20
+ - Attempt obtain/renew when a domain has no cached certificate, is forced, or its
21
+ cached certificate expires inside the configured renewal window.
22
+ - On successful obtain/renew, update the expiry cache from returned issued and
23
+ existing certificates.
24
+ - On failure for a new certificate, where no cached or local certificate exists,
25
+ create and save a local self-signed certificate and temporarily blacklist the
26
+ domain to avoid tight retry loops.
27
+ - On failure for renewal, where a cached or local certificate exists, keep reusing
28
+ that existing certificate even if it is expired, defer the next retry, and do not
29
+ replace it with a self-signed certificate.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ cert_manager_client,
35
+ sync_watch_domains: Optional[Callable[[], None]] = None,
36
+ renew_threshold_days: Optional[int] = None,
37
+ min_renew_threshold_days: int = 10,
38
+ sleep_slack_seconds: int = 300,
39
+ max_sleep_seconds: int = 32 * 24 * 3600,
40
+ renew_retry_interval_seconds: int = 24 * 3600,
41
+ blacklist_duration_seconds: int = 180,
42
+ remote_poll_interval_seconds: int = 30,
43
+ clock_fn: Optional[Callable[[], datetime]] = None,
44
+ sleep_fn: Optional[Callable[[float], None]] = None,
45
+ ):
46
+ self.cert_manager_client = cert_manager_client
47
+ self.sync_watch_domains = sync_watch_domains
48
+ self.sleep_slack_seconds = sleep_slack_seconds
49
+ self.max_sleep_seconds = max_sleep_seconds
50
+ self.renew_retry_interval_seconds = renew_retry_interval_seconds
51
+ self.blacklist_duration_seconds = blacklist_duration_seconds
52
+ self.remote_poll_interval_seconds = remote_poll_interval_seconds
53
+ self.clock_fn = clock_fn or (lambda: datetime.now(timezone.utc))
54
+ self.sleep_fn = sleep_fn
55
+
56
+ env_threshold = os.getenv("CERT_RENEW_THRESHOLD_DAYS")
57
+ if renew_threshold_days is None:
58
+ renew_threshold_days = int(env_threshold.strip()) if env_threshold else DEFAULT_RENEW_THRESHOLD_DAYS
59
+
60
+ self.renew_threshold_days = renew_threshold_days
61
+ self.min_renew_threshold_days = min_renew_threshold_days
62
+
63
+ self.update_threshold_secs = self.renew_threshold_days * 24 * 3600
64
+ self.cert_min_renew_threshold_secs = max(
65
+ self.update_threshold_secs,
66
+ self.min_renew_threshold_days * 24 * 3600,
67
+ )
68
+
69
+ self._watch_domains: Set[str] = set()
70
+ self._cache: Dict[str, datetime] = {}
71
+ self._lock = threading.Condition()
72
+ self._thread: Optional[threading.Thread] = None
73
+ self._running = False
74
+ self._force_trigger = False
75
+ self._cycle_running = False
76
+ self._cycle_thread_id: Optional[int] = None
77
+ self._blacklist: Dict[str, datetime] = {}
78
+ self._last_error_message: Optional[str] = None
79
+ self._last_error_timestamp: Optional[datetime] = None
80
+ self._self_signer: Optional[SelfCertIssuer] = None
81
+
82
+ def set_watch_domains(self, domains: List[str]):
83
+ new_watch_set = {x for x in domains if x}
84
+ with self._lock:
85
+ self._watch_domains = new_watch_set
86
+ self._cache = {d: expiry for d, expiry in self._cache.items() if d in self._watch_domains}
87
+ self._blacklist = {d: exp for d, exp in self._blacklist.items() if d in self._watch_domains}
88
+ if self._running and self._cycle_thread_id != threading.get_ident():
89
+ self._force_trigger = True
90
+ self._lock.notify_all()
91
+
92
+ def start(self):
93
+ with self._lock:
94
+ if self._running:
95
+ return
96
+ self._running = True
97
+ self._force_trigger = True
98
+ self._thread = threading.Thread(target=self._worker, name="CertApi-RenewalManager", daemon=True)
99
+ self._thread.start()
100
+
101
+ def stop(self):
102
+ thread = None
103
+ with self._lock:
104
+ self._running = False
105
+ self._lock.notify_all()
106
+ thread = self._thread
107
+
108
+ if thread is not None and thread.is_alive():
109
+ thread.join(timeout=2)
110
+
111
+ def trigger_now(self):
112
+ with self._lock:
113
+ if self._running:
114
+ self._force_trigger = True
115
+ self._lock.notify_all()
116
+ return
117
+ self._run_cycle(force=True)
118
+
119
+ def get_state(self) -> Dict[str, Any]:
120
+ with self._lock:
121
+ next_renewal_time = min(self._cache.values()).isoformat() if self._cache else None
122
+ return {
123
+ "watched_domains": sorted(self._watch_domains),
124
+ "cache_size": len(self._cache),
125
+ "blacklisted_domains": sorted(self._active_blacklisted_domains(self.clock_fn())),
126
+ "next_renewal_time": next_renewal_time,
127
+ "running": self._running,
128
+ "last_error_timestamp": self._last_error_timestamp.isoformat() if self._last_error_timestamp else None,
129
+ "last_error_message": self._last_error_message,
130
+ }
131
+
132
+ def _set_error(self, error: Exception):
133
+ self._last_error_message = str(error)
134
+ self._last_error_timestamp = self.clock_fn()
135
+
136
+ def _worker(self):
137
+ while True:
138
+ with self._lock:
139
+ if not self._running:
140
+ return
141
+ force = self._force_trigger
142
+ self._force_trigger = False
143
+
144
+ attempt_count = self._run_cycle(force=force)
145
+
146
+ with self._lock:
147
+ if not self._running:
148
+ return
149
+ if self._force_trigger:
150
+ continue
151
+ wait_seconds = self._compute_wait_seconds(self.clock_fn())
152
+
153
+ # Avoid tight loops in cases where nothing was attempted and no wait was computed.
154
+ if (wait_seconds is not None and wait_seconds <= 0) and attempt_count == 0:
155
+ wait_seconds = 1
156
+
157
+ self._wait(wait_seconds)
158
+
159
+ def _wait(self, wait_seconds: Optional[float]):
160
+ with self._lock:
161
+ if not self._running:
162
+ return
163
+
164
+ if wait_seconds is None:
165
+ self._lock.wait()
166
+ return
167
+
168
+ if self.sleep_fn is None:
169
+ with self._lock:
170
+ if self._running:
171
+ self._lock.wait(wait_seconds)
172
+ return
173
+
174
+ # Testing hook: allows deterministic no-op sleep behavior.
175
+ self.sleep_fn(wait_seconds)
176
+
177
+ def _sync_watch_domains(self):
178
+ if self.sync_watch_domains is None:
179
+ return
180
+ try:
181
+ self.sync_watch_domains()
182
+ except Exception as e:
183
+ self._set_error(e)
184
+
185
+ def _due_window_secs(self) -> float:
186
+ return max(self.update_threshold_secs, self.cert_min_renew_threshold_secs)
187
+
188
+ def _compute_wait_seconds(self, now: datetime) -> Optional[float]:
189
+ with self._lock:
190
+ if not self._cache:
191
+ return None
192
+ next_ssl_expiry = min(self._cache.values())
193
+
194
+ remaining_seconds = (next_ssl_expiry - now).total_seconds()
195
+ if remaining_seconds > self.update_threshold_secs:
196
+ return min(
197
+ remaining_seconds - self.update_threshold_secs + self.sleep_slack_seconds,
198
+ self.max_sleep_seconds,
199
+ )
200
+ return 0
201
+
202
+ def _run_cycle(self, force: bool = False) -> int:
203
+ with self._lock:
204
+ if self._cycle_running:
205
+ if force:
206
+ self._force_trigger = True
207
+ self._lock.notify_all()
208
+ return 0
209
+ self._cycle_running = True
210
+ self._cycle_thread_id = threading.get_ident()
211
+
212
+ try:
213
+ return self._run_cycle_body(force=force)
214
+ finally:
215
+ with self._lock:
216
+ self._cycle_running = False
217
+ self._cycle_thread_id = None
218
+ self._lock.notify_all()
219
+
220
+ def _run_cycle_body(self, force: bool = False) -> int:
221
+ self._sync_watch_domains()
222
+ self._seed_cache_from_local_keystore()
223
+ now = self.clock_fn()
224
+ self._clean_blacklist(now)
225
+ due_window = self._due_window_secs()
226
+
227
+ with self._lock:
228
+ watched = list(self._watch_domains)
229
+ cached = dict(self._cache)
230
+
231
+ due_domains: List[str] = []
232
+ for domain in watched:
233
+ if self._is_blacklisted(domain, now):
234
+ continue
235
+ expiry = cached.get(domain)
236
+ if expiry is None:
237
+ due_domains.append(domain)
238
+ continue
239
+ if force:
240
+ due_domains.append(domain)
241
+ continue
242
+ if (expiry - now).total_seconds() < due_window:
243
+ due_domains.append(domain)
244
+
245
+ for domain in due_domains:
246
+ self._renew_domain(domain, now)
247
+
248
+ return len(due_domains)
249
+
250
+ def _seed_cache_from_local_keystore(self):
251
+ key_store = getattr(self.cert_manager_client, "key_store", None)
252
+ if key_store is None:
253
+ return
254
+
255
+ with self._lock:
256
+ missing_domains = [d for d in self._watch_domains if d not in self._cache]
257
+
258
+ if not missing_domains:
259
+ return
260
+
261
+ now = self.clock_fn()
262
+ loaded: Dict[str, datetime] = {}
263
+
264
+ for domain in missing_domains:
265
+ try:
266
+ result = key_store.find_key_and_cert_by_domain(domain)
267
+ if result is None:
268
+ continue
269
+ certs = result[2] if len(result) > 2 else None
270
+ if not certs:
271
+ continue
272
+ leaf_cert = certs[0]
273
+ expiry = getattr(leaf_cert, "not_valid_after_utc", None)
274
+ if expiry is None or expiry <= now:
275
+ continue
276
+ loaded[domain] = expiry
277
+ except Exception as e:
278
+ self._set_error(e)
279
+
280
+ if not loaded:
281
+ return
282
+
283
+ with self._lock:
284
+ for domain, expiry in loaded.items():
285
+ if domain in self._watch_domains:
286
+ self._cache[domain] = expiry
287
+ self._lock.notify_all()
288
+
289
+ def _renew_domain(self, domain: str, now: datetime):
290
+ domain_had_cached_entry = False
291
+ with self._lock:
292
+ domain_had_cached_entry = domain in self._cache
293
+
294
+ try:
295
+ renew_threshold_days = self.cert_min_renew_threshold_secs // (24 * 3600)
296
+ obtain_fn = getattr(self.cert_manager_client, "obtain", None)
297
+ if callable(obtain_fn):
298
+ res = self._call_certificate_backend(
299
+ obtain_fn,
300
+ [domain],
301
+ {"renew_threshold_days": renew_threshold_days, "skip_failing": False},
302
+ )
303
+ else:
304
+ # Backward compatibility for clients that only implement issue_certificate.
305
+ res = self._call_certificate_backend(
306
+ self.cert_manager_client.issue_certificate,
307
+ [domain],
308
+ {"renew_threshold_days": renew_threshold_days},
309
+ )
310
+ if res is None:
311
+ return
312
+ self._update_expiry_cache(res.issued + res.existing)
313
+ except Exception as e:
314
+ self._set_error(e)
315
+ self._add_to_blacklist(domain, now)
316
+ if domain_had_cached_entry or self._has_local_certificate(domain):
317
+ self._schedule_existing_certificate_retry(domain, now)
318
+ else:
319
+ self._register_self_signed(domain)
320
+
321
+ def _schedule_existing_certificate_retry(self, domain: str, now: datetime):
322
+ retry_interval_seconds = min(self.renew_retry_interval_seconds, 24 * 3600)
323
+ with self._lock:
324
+ self._cache[domain] = now + timedelta(seconds=self.update_threshold_secs + retry_interval_seconds)
325
+ self._lock.notify_all()
326
+
327
+ def _call_certificate_backend(self, fn, domains: List[str], kwargs: Dict[str, Any]):
328
+ if not isinstance(self.cert_manager_client, CertManagerClient):
329
+ return fn(domains, **kwargs)
330
+
331
+ result = None
332
+ exception = None
333
+ with self._lock:
334
+ stop_when_manager_stops = self._running
335
+
336
+ def worker():
337
+ nonlocal result, exception
338
+ try:
339
+ result = fn(domains, **kwargs)
340
+ except (KeyboardInterrupt, SystemExit):
341
+ raise
342
+ except Exception as e:
343
+ exception = e
344
+
345
+ thread = threading.Thread(target=worker, name="CertApi-RenewalManager-RemoteRequest", daemon=True)
346
+ thread.start()
347
+ print("[Cert API Client] Requesting certificates:", ", ".join(domains))
348
+ start_time = time.time()
349
+ while thread.is_alive():
350
+ thread.join(timeout=self.remote_poll_interval_seconds)
351
+ with self._lock:
352
+ running = self._running
353
+ if stop_when_manager_stops and not running:
354
+ return None
355
+ if thread.is_alive():
356
+ print(f"[Cert API Client] Waiting for response since {int(time.time() - start_time)} seconds")
357
+ if exception:
358
+ raise exception
359
+ return result
360
+
361
+ def _update_expiry_cache(self, certs):
362
+ now = self.clock_fn()
363
+ with self._lock:
364
+ for cert in certs:
365
+ cert_pem = cert.certificate
366
+ if not cert_pem:
367
+ continue
368
+ cert_chain = certs_from_pem(cert_pem.encode("utf-8"))
369
+ if not cert_chain:
370
+ continue
371
+ expiry = cert_chain[0].not_valid_after_utc
372
+ # Guard against stale parse values.
373
+ if expiry <= now:
374
+ continue
375
+ for domain in cert.domains:
376
+ if domain in self._watch_domains:
377
+ self._cache[domain] = expiry
378
+
379
+ self._cache = {d: exp for d, exp in self._cache.items() if d in self._watch_domains}
380
+ self._lock.notify_all()
381
+
382
+ def _add_to_blacklist(self, domain: str, now: datetime):
383
+ with self._lock:
384
+ self._blacklist[domain] = now + timedelta(seconds=self.blacklist_duration_seconds)
385
+
386
+ def _active_blacklisted_domains(self, now: datetime) -> List[str]:
387
+ return [d for d, expiry in self._blacklist.items() if expiry > now]
388
+
389
+ def _clean_blacklist(self, now: datetime):
390
+ with self._lock:
391
+ self._blacklist = {d: exp for d, exp in self._blacklist.items() if exp > now}
392
+
393
+ def _is_blacklisted(self, domain: str, now: datetime) -> bool:
394
+ with self._lock:
395
+ expiry = self._blacklist.get(domain)
396
+ return expiry is not None and expiry > now
397
+
398
+ def _has_local_certificate(self, domain: str) -> bool:
399
+ key_store = getattr(self.cert_manager_client, "key_store", None)
400
+ if key_store is None:
401
+ return False
402
+ try:
403
+ return key_store.find_key_and_cert_by_domain(domain) is not None
404
+ except Exception as e:
405
+ self._set_error(e)
406
+ return False
407
+
408
+ def _get_or_create_self_signer(self) -> Optional[SelfCertIssuer]:
409
+ if self._self_signer is not None:
410
+ return self._self_signer
411
+
412
+ key_store = getattr(self.cert_manager_client, "key_store", None)
413
+ if key_store is None:
414
+ return None
415
+
416
+ try:
417
+ account_key = key_store.find_key_by_name("acme_account")
418
+ if account_key is None:
419
+ account_key = Key.generate("ecdsa")
420
+ key_store.save_key(account_key, "acme_account")
421
+ self._self_signer = SelfCertIssuer(
422
+ account_key,
423
+ "NP",
424
+ "Bagmati",
425
+ "Buddhanagar",
426
+ "certapi-client",
427
+ "local.certapi.client",
428
+ )
429
+ return self._self_signer
430
+ except Exception as e:
431
+ self._set_error(e)
432
+ return None
433
+
434
+ def _register_self_signed(self, domain: str):
435
+ if self._has_local_certificate(domain):
436
+ return
437
+
438
+ key_store = getattr(self.cert_manager_client, "key_store", None)
439
+ if key_store is None:
440
+ return
441
+
442
+ signer = self._get_or_create_self_signer()
443
+ if signer is None:
444
+ return
445
+
446
+ try:
447
+ self_signed_name = domain + ".selfsigned"
448
+ if key_store.find_key_by_name(self_signed_name):
449
+ return
450
+ key, cert = signer.generate_key_and_cert_for_domain(domain, key_type="ecdsa")
451
+ key_id = key_store.save_key(key, self_signed_name)
452
+ key_store.save_cert(key_id, cert, [domain], name=self_signed_name)
453
+ except Exception as e:
454
+ self._set_error(e)
@@ -22,7 +22,7 @@ class AcmeCertManager:
22
22
  key_store: KeyStore,
23
23
  cert_issuer: AcmeCertIssuer,
24
24
  challenge_solvers: List[ChallengeSolver] = [],
25
- renew_threshold_days: int = DEFAULT_RENEW_THRESHOLD_DAYS, # Renewal will be accepted if cert is valid for less than 75 days
25
+ renew_threshold_days: int = DEFAULT_RENEW_THRESHOLD_DAYS,
26
26
  ):
27
27
  self.key_store: KeyStore = key_store
28
28
  self.cert_issuer: AcmeCertIssuer = cert_issuer
@@ -75,6 +75,33 @@ class AcmeCertManager:
75
75
  organization: Optional[str] = None,
76
76
  user_id: Optional[str] = None,
77
77
  renew_threshold_days: Optional[int] = None,
78
+ self_verify: bool = True,
79
+ ) -> CertificateResponse:
80
+ return self.obtain(
81
+ hosts=hosts,
82
+ key_type=key_type,
83
+ expiry_days=expiry_days,
84
+ country=country,
85
+ state=state,
86
+ locality=locality,
87
+ organization=organization,
88
+ user_id=user_id,
89
+ renew_threshold_days=renew_threshold_days,
90
+ self_verify=self_verify,
91
+ )
92
+
93
+ def obtain(
94
+ self,
95
+ hosts: Union[str, List[str]],
96
+ key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
97
+ expiry_days: int = 90,
98
+ country: Optional[str] = None,
99
+ state: Optional[str] = None,
100
+ locality: Optional[str] = None,
101
+ organization: Optional[str] = None,
102
+ user_id: Optional[str] = None,
103
+ renew_threshold_days: Optional[int] = None,
104
+ self_verify: bool = True,
78
105
  ) -> CertificateResponse:
79
106
  return self._issue_certificate_internal(
80
107
  hosts=hosts,
@@ -87,6 +114,7 @@ class AcmeCertManager:
87
114
  user_id=user_id,
88
115
  renew_threshold_days=renew_threshold_days,
89
116
  batch_generator=None,
117
+ self_verify=self_verify,
90
118
  )
91
119
 
92
120
  def issue_certificate_in_batches(
@@ -101,6 +129,7 @@ class AcmeCertManager:
101
129
  user_id: Optional[str] = None,
102
130
  renew_threshold_days: Optional[int] = None,
103
131
  batch_generator: Callable[[List[str]], List[List[str]]] = create_safe_domain_batches,
132
+ self_verify: bool = True,
104
133
  ) -> CertificateResponse:
105
134
  return self._issue_certificate_internal(
106
135
  hosts=hosts,
@@ -113,6 +142,7 @@ class AcmeCertManager:
113
142
  user_id=user_id,
114
143
  renew_threshold_days=renew_threshold_days,
115
144
  batch_generator=batch_generator,
145
+ self_verify=self_verify,
116
146
  )
117
147
 
118
148
  def _issue_certificate_internal(
@@ -127,6 +157,7 @@ class AcmeCertManager:
127
157
  user_id: Optional[str] = None,
128
158
  renew_threshold_days: Optional[int] = None,
129
159
  batch_generator: Optional[Callable[[List[str]], List[List[str]]]] = None,
160
+ self_verify: bool = True,
130
161
  ) -> CertificateResponse:
131
162
  if isinstance(hosts, str):
132
163
  hosts = [hosts]
@@ -150,7 +181,12 @@ class AcmeCertManager:
150
181
  for host in missing:
151
182
  found_store = None
152
183
  for store in self.challenge_solvers:
153
- if store.supports_domain(host):
184
+ supports_domain = (
185
+ store.supports_domain_strict(host)
186
+ if self_verify and hasattr(store, "supports_domain_strict")
187
+ else store.supports_domain(host)
188
+ )
189
+ if supports_domain:
154
190
  found_store = store
155
191
  break
156
192
  if found_store is not None:
@@ -103,6 +103,12 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
103
103
  default=False,
104
104
  help="Issue certificates in separate safe batches instead of one combined order",
105
105
  )
106
+ obtain_parser.add_argument(
107
+ "self_verify",
108
+ type=inputs.boolean,
109
+ default=True,
110
+ help="Verify local challenge reachability/ownership before attempting issuance",
111
+ )
106
112
 
107
113
  @api_ns.route("/obtain")
108
114
  class ObtainCert(Resource):
@@ -117,16 +123,18 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
117
123
  args = obtain_parser.parse_args()
118
124
  hostnames = args["hostname"]
119
125
  skip_failing = args.get("skip_failing", False)
126
+ self_verify = args.get("self_verify", True)
120
127
 
121
128
  with lock_manager:
122
- verified_hostnames = []
123
- for h in hostnames:
124
- # Find the first solver that supports this domain
125
- for solver in reversed(cert_manager.challenge_solvers):
126
- if solver.supports_domain_strict(h):
127
- verified_hostnames.append(h)
129
+ if self_verify:
130
+ verified_hostnames = []
131
+ for h in hostnames:
132
+ # Find the first solver that supports and can verify this domain.
133
+ for solver in reversed(cert_manager.challenge_solvers):
134
+ if solver.supports_domain_strict(h):
135
+ verified_hostnames.append(h)
128
136
 
129
- hostnames = verified_hostnames
137
+ hostnames = verified_hostnames
130
138
 
131
139
  if not hostnames and not skip_failing:
132
140
  api_ns.abort(400, message="None of the domains are owned by this machine or could be verified")
@@ -149,6 +157,7 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
149
157
  organization=args["organization"],
150
158
  user_id=args["user_id"],
151
159
  renew_threshold_days=args.get("renew_threshold_days"),
160
+ self_verify=False,
152
161
  )
153
162
 
154
163
  print(data)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.1
3
+ Version: 1.1.3
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
@@ -33,6 +33,7 @@ src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py
33
33
  src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
34
34
  src/certapi/client/__init__.py
35
35
  src/certapi/client/cert_manager_client.py
36
+ src/certapi/client/renewal_manager.py
36
37
  src/certapi/crypto/__init__.py
37
38
  src/certapi/crypto/crypto.py
38
39
  src/certapi/crypto/crypto_classes.py
@@ -62,4 +63,6 @@ tests/test_certs_with_key_types.py
62
63
  tests/test_cli.py
63
64
  tests/test_domain_batching_boulder_cases.py
64
65
  tests/test_http_error_handling.py
65
- tests/test_keystores.py
66
+ tests/test_keystores.py
67
+ tests/test_obtain_interface.py
68
+ tests/test_renewal_manager.py