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.
- {certapi-1.1.1/src/certapi.egg-info → certapi-1.1.3}/PKG-INFO +1 -1
- {certapi-1.1.1 → certapi-1.1.3}/setup.py +1 -1
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/__init__.py +1 -1
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/client/__init__.py +1 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/client/cert_manager_client.py +32 -0
- certapi-1.1.3/src/certapi/client/renewal_manager.py +454 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/manager/acme_cert_manager.py +38 -2
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/api.py +16 -7
- {certapi-1.1.1 → certapi-1.1.3/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/SOURCES.txt +4 -1
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_acme_cert_manager_batching.py +34 -0
- certapi-1.1.3/tests/test_obtain_interface.py +194 -0
- certapi-1.1.3/tests/test_renewal_manager.py +647 -0
- {certapi-1.1.1 → certapi-1.1.3}/MANIFEST.in +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/README.md +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/pyproject.toml +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/setup.cfg +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/acme/http.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/cli.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/domain_batching.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/errors.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/http/types.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/util.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi/utils.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_acme_error_handling.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_cli.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_domain_batching_boulder_cases.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.1 → certapi-1.1.3}/tests/test_keystores.py +0 -0
|
@@ -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
|
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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
|