certapi 1.1.2__tar.gz → 1.1.4__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.2/src/certapi.egg-info → certapi-1.1.4}/PKG-INFO +1 -1
- {certapi-1.1.2 → certapi-1.1.4}/setup.py +1 -1
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/cert_manager_client.py +4 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/renewal_manager.py +37 -15
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/manager/acme_cert_manager.py +27 -2
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/api.py +23 -32
- {certapi-1.1.2 → certapi-1.1.4/src/certapi.egg-info}/PKG-INFO +1 -1
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_acme_cert_manager_batching.py +74 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_obtain_interface.py +76 -1
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_renewal_manager.py +34 -3
- {certapi-1.1.2 → certapi-1.1.4}/MANIFEST.in +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/README.md +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/pyproject.toml +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/setup.cfg +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Acme.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/AcmeError.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Challenge.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Order.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/http.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/cli.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/crypto.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/crypto_classes.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/domain_batching.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/errors.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/HttpClientBase.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/types.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/SelfCertIssuer.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/abstract_certissuer.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/KeyStore.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/RemoteKeyStore.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/SqliteKeyStore.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/manager/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/__init__.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/cert_api.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/key_api.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/util.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi/utils.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/SOURCES.txt +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/dependency_links.txt +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/entry_points.txt +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/requires.txt +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/top_level.txt +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_acme_error_handling.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_cert_issuer_generic.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_certs_with_key_types.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_cli.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_domain_batching_boulder_cases.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_http_error_handling.py +0 -0
- {certapi-1.1.2 → certapi-1.1.4}/tests/test_keystores.py +0 -0
|
@@ -50,6 +50,7 @@ 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,
|
|
53
54
|
) -> CertificateResponse:
|
|
54
55
|
return self.obtain(
|
|
55
56
|
hosts=hosts,
|
|
@@ -63,6 +64,7 @@ class CertManagerClient:
|
|
|
63
64
|
renew_threshold_days=renew_threshold_days,
|
|
64
65
|
skip_failing=skip_failing,
|
|
65
66
|
batch_domains=batch_domains,
|
|
67
|
+
self_verify=self_verify,
|
|
66
68
|
)
|
|
67
69
|
|
|
68
70
|
def obtain(
|
|
@@ -78,6 +80,7 @@ class CertManagerClient:
|
|
|
78
80
|
renew_threshold_days: Optional[int] = None,
|
|
79
81
|
skip_failing: bool = True,
|
|
80
82
|
batch_domains: bool = False,
|
|
83
|
+
self_verify: bool = True,
|
|
81
84
|
) -> CertificateResponse:
|
|
82
85
|
params = {
|
|
83
86
|
"hostname": hosts if isinstance(hosts, str) else hosts,
|
|
@@ -98,6 +101,7 @@ class CertManagerClient:
|
|
|
98
101
|
params["renew_threshold_days"] = renew_threshold_days
|
|
99
102
|
params["skip_failing"] = skip_failing
|
|
100
103
|
params["batch_domains"] = batch_domains
|
|
104
|
+
params["self_verify"] = self_verify
|
|
101
105
|
|
|
102
106
|
data = self._get("/api/obtain", params=params)
|
|
103
107
|
res = CertificateResponse.from_json(data)
|
|
@@ -2,7 +2,7 @@ import os
|
|
|
2
2
|
import threading
|
|
3
3
|
import time
|
|
4
4
|
from datetime import datetime, timezone, timedelta
|
|
5
|
-
from typing import Callable, Dict, Optional, Set, Any, List
|
|
5
|
+
from typing import Callable, Dict, Optional, Set, Any, List, Literal
|
|
6
6
|
|
|
7
7
|
from certapi.crypto import Key, certs_from_pem
|
|
8
8
|
from certapi.client.cert_manager_client import CertManagerClient
|
|
@@ -42,9 +42,27 @@ class RenewalManager:
|
|
|
42
42
|
remote_poll_interval_seconds: int = 30,
|
|
43
43
|
clock_fn: Optional[Callable[[], datetime]] = None,
|
|
44
44
|
sleep_fn: Optional[Callable[[float], None]] = None,
|
|
45
|
+
key_type: Literal["rsa", "ecdsa", "ed25519"] = "ecdsa",
|
|
46
|
+
expiry_days: int = 90,
|
|
47
|
+
batch_domains: bool = False,
|
|
48
|
+
self_verify: bool = True,
|
|
49
|
+
country: Optional[str] = None,
|
|
50
|
+
state: Optional[str] = None,
|
|
51
|
+
locality: Optional[str] = None,
|
|
52
|
+
organization: Optional[str] = None,
|
|
53
|
+
user_id: Optional[str] = None,
|
|
45
54
|
):
|
|
46
55
|
self.cert_manager_client = cert_manager_client
|
|
47
56
|
self.sync_watch_domains = sync_watch_domains
|
|
57
|
+
self.key_type = key_type
|
|
58
|
+
self.expiry_days = expiry_days
|
|
59
|
+
self.batch_domains = batch_domains
|
|
60
|
+
self.self_verify = self_verify
|
|
61
|
+
self.country = country
|
|
62
|
+
self.state = state
|
|
63
|
+
self.locality = locality
|
|
64
|
+
self.organization = organization
|
|
65
|
+
self.user_id = user_id
|
|
48
66
|
self.sleep_slack_seconds = sleep_slack_seconds
|
|
49
67
|
self.max_sleep_seconds = max_sleep_seconds
|
|
50
68
|
self.renew_retry_interval_seconds = renew_retry_interval_seconds
|
|
@@ -293,20 +311,24 @@ class RenewalManager:
|
|
|
293
311
|
|
|
294
312
|
try:
|
|
295
313
|
renew_threshold_days = self.cert_min_renew_threshold_secs // (24 * 3600)
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
314
|
+
obtain_options = {
|
|
315
|
+
"key_type": self.key_type,
|
|
316
|
+
"expiry_days": self.expiry_days,
|
|
317
|
+
"renew_threshold_days": renew_threshold_days,
|
|
318
|
+
"skip_failing": False,
|
|
319
|
+
"batch_domains": self.batch_domains,
|
|
320
|
+
"self_verify": self.self_verify,
|
|
321
|
+
"country": self.country,
|
|
322
|
+
"state": self.state,
|
|
323
|
+
"locality": self.locality,
|
|
324
|
+
"organization": self.organization,
|
|
325
|
+
"user_id": self.user_id,
|
|
326
|
+
}
|
|
327
|
+
res = self._call_certificate_backend(
|
|
328
|
+
self.cert_manager_client.obtain,
|
|
329
|
+
[domain],
|
|
330
|
+
obtain_options,
|
|
331
|
+
)
|
|
310
332
|
if res is None:
|
|
311
333
|
return
|
|
312
334
|
self._update_expiry_cache(res.issued + res.existing)
|
|
@@ -75,6 +75,9 @@ class AcmeCertManager:
|
|
|
75
75
|
organization: Optional[str] = None,
|
|
76
76
|
user_id: Optional[str] = None,
|
|
77
77
|
renew_threshold_days: Optional[int] = None,
|
|
78
|
+
skip_failing: bool = True,
|
|
79
|
+
batch_domains: bool = False,
|
|
80
|
+
self_verify: bool = True,
|
|
78
81
|
) -> CertificateResponse:
|
|
79
82
|
return self.obtain(
|
|
80
83
|
hosts=hosts,
|
|
@@ -86,6 +89,9 @@ class AcmeCertManager:
|
|
|
86
89
|
organization=organization,
|
|
87
90
|
user_id=user_id,
|
|
88
91
|
renew_threshold_days=renew_threshold_days,
|
|
92
|
+
skip_failing=skip_failing,
|
|
93
|
+
batch_domains=batch_domains,
|
|
94
|
+
self_verify=self_verify,
|
|
89
95
|
)
|
|
90
96
|
|
|
91
97
|
def obtain(
|
|
@@ -99,6 +105,9 @@ class AcmeCertManager:
|
|
|
99
105
|
organization: Optional[str] = None,
|
|
100
106
|
user_id: Optional[str] = None,
|
|
101
107
|
renew_threshold_days: Optional[int] = None,
|
|
108
|
+
skip_failing: bool = True,
|
|
109
|
+
batch_domains: bool = False,
|
|
110
|
+
self_verify: bool = True,
|
|
102
111
|
) -> CertificateResponse:
|
|
103
112
|
return self._issue_certificate_internal(
|
|
104
113
|
hosts=hosts,
|
|
@@ -110,7 +119,9 @@ class AcmeCertManager:
|
|
|
110
119
|
organization=organization,
|
|
111
120
|
user_id=user_id,
|
|
112
121
|
renew_threshold_days=renew_threshold_days,
|
|
113
|
-
batch_generator=None,
|
|
122
|
+
batch_generator=create_safe_domain_batches if batch_domains else None,
|
|
123
|
+
skip_failing=skip_failing,
|
|
124
|
+
self_verify=self_verify,
|
|
114
125
|
)
|
|
115
126
|
|
|
116
127
|
def issue_certificate_in_batches(
|
|
@@ -125,6 +136,8 @@ class AcmeCertManager:
|
|
|
125
136
|
user_id: Optional[str] = None,
|
|
126
137
|
renew_threshold_days: Optional[int] = None,
|
|
127
138
|
batch_generator: Callable[[List[str]], List[List[str]]] = create_safe_domain_batches,
|
|
139
|
+
skip_failing: bool = True,
|
|
140
|
+
self_verify: bool = True,
|
|
128
141
|
) -> CertificateResponse:
|
|
129
142
|
return self._issue_certificate_internal(
|
|
130
143
|
hosts=hosts,
|
|
@@ -137,6 +150,8 @@ class AcmeCertManager:
|
|
|
137
150
|
user_id=user_id,
|
|
138
151
|
renew_threshold_days=renew_threshold_days,
|
|
139
152
|
batch_generator=batch_generator,
|
|
153
|
+
skip_failing=skip_failing,
|
|
154
|
+
self_verify=self_verify,
|
|
140
155
|
)
|
|
141
156
|
|
|
142
157
|
def _issue_certificate_internal(
|
|
@@ -151,6 +166,8 @@ class AcmeCertManager:
|
|
|
151
166
|
user_id: Optional[str] = None,
|
|
152
167
|
renew_threshold_days: Optional[int] = None,
|
|
153
168
|
batch_generator: Optional[Callable[[List[str]], List[List[str]]]] = None,
|
|
169
|
+
skip_failing: bool = True,
|
|
170
|
+
self_verify: bool = True,
|
|
154
171
|
) -> CertificateResponse:
|
|
155
172
|
if isinstance(hosts, str):
|
|
156
173
|
hosts = [hosts]
|
|
@@ -174,7 +191,12 @@ class AcmeCertManager:
|
|
|
174
191
|
for host in missing:
|
|
175
192
|
found_store = None
|
|
176
193
|
for store in self.challenge_solvers:
|
|
177
|
-
|
|
194
|
+
supports_domain = (
|
|
195
|
+
store.supports_domain_strict(host)
|
|
196
|
+
if self_verify and hasattr(store, "supports_domain_strict")
|
|
197
|
+
else store.supports_domain(host)
|
|
198
|
+
)
|
|
199
|
+
if supports_domain:
|
|
178
200
|
found_store = store
|
|
179
201
|
break
|
|
180
202
|
if found_store is not None:
|
|
@@ -184,6 +206,9 @@ class AcmeCertManager:
|
|
|
184
206
|
else:
|
|
185
207
|
print(f"Warning: No challenge solver found that supports domain: {host}. Skipping.")
|
|
186
208
|
|
|
209
|
+
if len(domains_by_store) == 0 and not skip_failing:
|
|
210
|
+
raise ValueError("None of the domains are owned by this machine or could be verified")
|
|
211
|
+
|
|
187
212
|
for store, domains_to_issue in domains_by_store.items():
|
|
188
213
|
issuance_batches = (
|
|
189
214
|
batch_generator(domains_to_issue) if batch_generator is not None else [domains_to_issue]
|
|
@@ -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):
|
|
@@ -116,40 +122,25 @@ def create_api_resources(api_ns, cert_manager: AcmeCertManager, renew_queue_size
|
|
|
116
122
|
def get(self):
|
|
117
123
|
args = obtain_parser.parse_args()
|
|
118
124
|
hostnames = args["hostname"]
|
|
119
|
-
skip_failing = args.get("skip_failing", False)
|
|
120
125
|
|
|
121
126
|
with lock_manager:
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
if args.get("batch_domains")
|
|
140
|
-
else cert_manager.issue_certificate
|
|
141
|
-
)
|
|
142
|
-
data = issue_fn(
|
|
143
|
-
hostnames,
|
|
144
|
-
key_type=args["key_type"],
|
|
145
|
-
expiry_days=args["expiry_days"],
|
|
146
|
-
country=args["country"],
|
|
147
|
-
state=args["state"],
|
|
148
|
-
locality=args["locality"],
|
|
149
|
-
organization=args["organization"],
|
|
150
|
-
user_id=args["user_id"],
|
|
151
|
-
renew_threshold_days=args.get("renew_threshold_days"),
|
|
152
|
-
)
|
|
127
|
+
try:
|
|
128
|
+
data = cert_manager.obtain(
|
|
129
|
+
hostnames,
|
|
130
|
+
key_type=args["key_type"],
|
|
131
|
+
expiry_days=args["expiry_days"],
|
|
132
|
+
country=args["country"],
|
|
133
|
+
state=args["state"],
|
|
134
|
+
locality=args["locality"],
|
|
135
|
+
organization=args["organization"],
|
|
136
|
+
user_id=args["user_id"],
|
|
137
|
+
renew_threshold_days=args.get("renew_threshold_days"),
|
|
138
|
+
skip_failing=args.get("skip_failing", False),
|
|
139
|
+
batch_domains=args.get("batch_domains", False),
|
|
140
|
+
self_verify=args.get("self_verify", True),
|
|
141
|
+
)
|
|
142
|
+
except ValueError as e:
|
|
143
|
+
api_ns.abort(400, message=str(e))
|
|
153
144
|
|
|
154
145
|
print(data)
|
|
155
146
|
if data:
|
|
@@ -22,6 +22,9 @@ class DummySolver:
|
|
|
22
22
|
def supports_domain(self, _domain):
|
|
23
23
|
return True
|
|
24
24
|
|
|
25
|
+
def supports_domain_strict(self, _domain):
|
|
26
|
+
return True
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
class DummyIssuer:
|
|
27
30
|
def __init__(self):
|
|
@@ -93,3 +96,74 @@ def test_issue_certificate_in_batches_uses_custom_batch_generator():
|
|
|
93
96
|
]
|
|
94
97
|
assert len(response.issued) == 3
|
|
95
98
|
assert [issued.domains for issued in response.issued] == issuer.calls
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_obtain_batch_domains_uses_safe_batches():
|
|
102
|
+
key_store = DummyKeyStore()
|
|
103
|
+
issuer = DummyIssuer()
|
|
104
|
+
solver = DummySolver()
|
|
105
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
106
|
+
|
|
107
|
+
domains = [
|
|
108
|
+
"x.abc.def.ghi.example.com",
|
|
109
|
+
"root.example.com",
|
|
110
|
+
]
|
|
111
|
+
response = manager.obtain(hosts=domains, batch_domains=True)
|
|
112
|
+
|
|
113
|
+
assert issuer.calls == [
|
|
114
|
+
["root.example.com"],
|
|
115
|
+
["x.abc.def"],
|
|
116
|
+
["ghi.example.com"],
|
|
117
|
+
]
|
|
118
|
+
assert len(response.issued) == 3
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def test_issue_certificate_self_verify_false_skips_strict_solver_check():
|
|
122
|
+
class StrictFailingSolver(DummySolver):
|
|
123
|
+
def supports_domain_strict(self, _domain):
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
key_store = DummyKeyStore()
|
|
127
|
+
issuer = DummyIssuer()
|
|
128
|
+
solver = StrictFailingSolver()
|
|
129
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
130
|
+
|
|
131
|
+
manager.issue_certificate(hosts=["force.example.com"], self_verify=False)
|
|
132
|
+
|
|
133
|
+
assert issuer.calls == [["force.example.com"]]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_issue_certificate_self_verify_true_uses_strict_solver_check():
|
|
137
|
+
class StrictFailingSolver(DummySolver):
|
|
138
|
+
def supports_domain_strict(self, _domain):
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
key_store = DummyKeyStore()
|
|
142
|
+
issuer = DummyIssuer()
|
|
143
|
+
solver = StrictFailingSolver()
|
|
144
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
145
|
+
|
|
146
|
+
response = manager.issue_certificate(hosts=["force.example.com"])
|
|
147
|
+
|
|
148
|
+
assert issuer.calls == []
|
|
149
|
+
assert response.issued == []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_obtain_self_verify_true_raises_when_all_domains_fail_and_skip_failing_false():
|
|
153
|
+
class StrictFailingSolver(DummySolver):
|
|
154
|
+
def supports_domain_strict(self, _domain):
|
|
155
|
+
return False
|
|
156
|
+
|
|
157
|
+
key_store = DummyKeyStore()
|
|
158
|
+
issuer = DummyIssuer()
|
|
159
|
+
solver = StrictFailingSolver()
|
|
160
|
+
manager = AcmeCertManager(key_store=key_store, cert_issuer=issuer, challenge_solvers=[solver])
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
manager.obtain(hosts=["force.example.com"], skip_failing=False)
|
|
164
|
+
except ValueError as e:
|
|
165
|
+
assert "None of the domains are owned" in str(e)
|
|
166
|
+
else:
|
|
167
|
+
raise AssertionError("expected strict obtain failure")
|
|
168
|
+
|
|
169
|
+
assert issuer.calls == []
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
from unittest.mock import Mock
|
|
2
2
|
|
|
3
|
+
from flask import Flask
|
|
4
|
+
from flask_restx import Api, Namespace
|
|
5
|
+
|
|
3
6
|
from certapi.client.cert_manager_client import CertManagerClient
|
|
7
|
+
from certapi.domain_batching import create_safe_domain_batches
|
|
4
8
|
from certapi.http.types import CertificateResponse
|
|
5
9
|
from certapi.manager.acme_cert_manager import AcmeCertManager
|
|
10
|
+
from certapi.server.api import create_api_resources
|
|
6
11
|
|
|
7
12
|
|
|
8
13
|
def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
@@ -25,6 +30,9 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
25
30
|
organization="Org",
|
|
26
31
|
user_id="u1",
|
|
27
32
|
renew_threshold_days=25,
|
|
33
|
+
skip_failing=False,
|
|
34
|
+
batch_domains=True,
|
|
35
|
+
self_verify=False,
|
|
28
36
|
)
|
|
29
37
|
|
|
30
38
|
assert res is expected
|
|
@@ -37,6 +45,9 @@ def test_acme_cert_manager_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
37
45
|
assert captured["organization"] == "Org"
|
|
38
46
|
assert captured["user_id"] == "u1"
|
|
39
47
|
assert captured["renew_threshold_days"] == 25
|
|
48
|
+
assert captured["skip_failing"] is False
|
|
49
|
+
assert captured["batch_domains"] is True
|
|
50
|
+
assert captured["self_verify"] is False
|
|
40
51
|
|
|
41
52
|
|
|
42
53
|
def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
|
|
@@ -54,6 +65,9 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
|
|
|
54
65
|
key_type="ecdsa",
|
|
55
66
|
expiry_days=90,
|
|
56
67
|
renew_threshold_days=30,
|
|
68
|
+
skip_failing=False,
|
|
69
|
+
batch_domains=True,
|
|
70
|
+
self_verify=False,
|
|
57
71
|
)
|
|
58
72
|
|
|
59
73
|
assert res is expected
|
|
@@ -61,7 +75,9 @@ def test_acme_cert_manager_obtain_uses_internal_issue_path(monkeypatch):
|
|
|
61
75
|
assert captured["key_type"] == "ecdsa"
|
|
62
76
|
assert captured["expiry_days"] == 90
|
|
63
77
|
assert captured["renew_threshold_days"] == 30
|
|
64
|
-
assert captured["batch_generator"] is
|
|
78
|
+
assert captured["batch_generator"] is create_safe_domain_batches
|
|
79
|
+
assert captured["skip_failing"] is False
|
|
80
|
+
assert captured["self_verify"] is False
|
|
65
81
|
|
|
66
82
|
|
|
67
83
|
def test_cert_manager_client_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
@@ -86,6 +102,7 @@ def test_cert_manager_client_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
86
102
|
renew_threshold_days=15,
|
|
87
103
|
skip_failing=False,
|
|
88
104
|
batch_domains=True,
|
|
105
|
+
self_verify=False,
|
|
89
106
|
)
|
|
90
107
|
|
|
91
108
|
assert res is expected
|
|
@@ -100,6 +117,7 @@ def test_cert_manager_client_issue_certificate_delegates_to_obtain(monkeypatch):
|
|
|
100
117
|
assert captured["renew_threshold_days"] == 15
|
|
101
118
|
assert captured["skip_failing"] is False
|
|
102
119
|
assert captured["batch_domains"] is True
|
|
120
|
+
assert captured["self_verify"] is False
|
|
103
121
|
|
|
104
122
|
|
|
105
123
|
def test_cert_manager_client_obtain_calls_api_obtain(monkeypatch):
|
|
@@ -118,6 +136,7 @@ def test_cert_manager_client_obtain_calls_api_obtain(monkeypatch):
|
|
|
118
136
|
renew_threshold_days=12,
|
|
119
137
|
skip_failing=False,
|
|
120
138
|
batch_domains=True,
|
|
139
|
+
self_verify=False,
|
|
121
140
|
)
|
|
122
141
|
|
|
123
142
|
assert isinstance(res, CertificateResponse)
|
|
@@ -127,3 +146,59 @@ def test_cert_manager_client_obtain_calls_api_obtain(monkeypatch):
|
|
|
127
146
|
assert captured["params"]["renew_threshold_days"] == 12
|
|
128
147
|
assert captured["params"]["skip_failing"] is False
|
|
129
148
|
assert captured["params"]["batch_domains"] is True
|
|
149
|
+
assert captured["params"]["self_verify"] is False
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_remote_obtain_self_verify_false_skips_server_prefilter():
|
|
153
|
+
app = Flask(__name__)
|
|
154
|
+
api = Api(app)
|
|
155
|
+
api_ns = Namespace("api")
|
|
156
|
+
api.add_namespace(api_ns)
|
|
157
|
+
|
|
158
|
+
class Solver:
|
|
159
|
+
def supports_domain_strict(self, _domain):
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
class Manager:
|
|
163
|
+
challenge_solvers = [Solver()]
|
|
164
|
+
|
|
165
|
+
def __init__(self):
|
|
166
|
+
self.calls = []
|
|
167
|
+
|
|
168
|
+
def obtain(self, hosts, **kwargs):
|
|
169
|
+
self.calls.append({"hosts": hosts, "kwargs": kwargs})
|
|
170
|
+
return CertificateResponse()
|
|
171
|
+
|
|
172
|
+
manager = Manager()
|
|
173
|
+
create_api_resources(api_ns, manager)
|
|
174
|
+
|
|
175
|
+
response = app.test_client().get("/api/obtain?hostname=force.example.com&self_verify=false")
|
|
176
|
+
|
|
177
|
+
assert response.status_code == 200
|
|
178
|
+
assert manager.calls[0]["hosts"] == ["force.example.com"]
|
|
179
|
+
assert manager.calls[0]["kwargs"]["self_verify"] is False
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_remote_obtain_self_verify_true_delegates_strict_failure_to_manager():
|
|
183
|
+
app = Flask(__name__)
|
|
184
|
+
api = Api(app)
|
|
185
|
+
api_ns = Namespace("api")
|
|
186
|
+
api.add_namespace(api_ns)
|
|
187
|
+
|
|
188
|
+
class Solver:
|
|
189
|
+
def supports_domain_strict(self, _domain):
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
class Manager:
|
|
193
|
+
challenge_solvers = [Solver()]
|
|
194
|
+
|
|
195
|
+
def obtain(self, hosts, **kwargs):
|
|
196
|
+
assert hosts == ["force.example.com"]
|
|
197
|
+
assert kwargs["self_verify"] is True
|
|
198
|
+
raise ValueError("None of the domains are owned by this machine or could be verified")
|
|
199
|
+
|
|
200
|
+
create_api_resources(api_ns, Manager())
|
|
201
|
+
|
|
202
|
+
response = app.test_client().get("/api/obtain?hostname=force.example.com")
|
|
203
|
+
|
|
204
|
+
assert response.status_code == 400
|
|
@@ -23,7 +23,7 @@ class DummyClient:
|
|
|
23
23
|
def set_handler(self, domain, handler):
|
|
24
24
|
self._handlers[domain] = handler
|
|
25
25
|
|
|
26
|
-
def
|
|
26
|
+
def obtain(self, hosts, **kwargs):
|
|
27
27
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
28
28
|
self.calls.append({"host": host, "kwargs": kwargs})
|
|
29
29
|
handler = self._handlers.get(host)
|
|
@@ -35,6 +35,9 @@ class DummyClient:
|
|
|
35
35
|
return handler(host, kwargs)
|
|
36
36
|
return handler
|
|
37
37
|
|
|
38
|
+
def issue_certificate(self, hosts, **kwargs):
|
|
39
|
+
return self.obtain(hosts, **kwargs)
|
|
40
|
+
|
|
38
41
|
|
|
39
42
|
class DummyClientWithObtain(DummyClient):
|
|
40
43
|
def __init__(self):
|
|
@@ -45,12 +48,12 @@ class DummyClientWithObtain(DummyClient):
|
|
|
45
48
|
def obtain(self, hosts, **kwargs):
|
|
46
49
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
47
50
|
self.obtain_calls.append({"host": host, "kwargs": kwargs})
|
|
48
|
-
return super().
|
|
51
|
+
return super().obtain(hosts, **kwargs)
|
|
49
52
|
|
|
50
53
|
def issue_certificate(self, hosts, **kwargs):
|
|
51
54
|
host = hosts[0] if isinstance(hosts, list) else hosts
|
|
52
55
|
self.issue_calls.append({"host": host, "kwargs": kwargs})
|
|
53
|
-
return super().
|
|
56
|
+
return super().obtain(hosts, **kwargs)
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
class DummyRemoteClient(CertManagerClient):
|
|
@@ -408,6 +411,34 @@ def test_renewal_manager_prefers_obtain_when_available():
|
|
|
408
411
|
assert len(client.issue_calls) == 0
|
|
409
412
|
|
|
410
413
|
|
|
414
|
+
def test_renewal_manager_passes_typed_obtain_options_into_renewal_calls():
|
|
415
|
+
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
416
|
+
client = DummyClient()
|
|
417
|
+
cert = _make_cert_pem("batch.example.com", now, valid_for_days=60)
|
|
418
|
+
client.set_handler(
|
|
419
|
+
"batch.example.com",
|
|
420
|
+
CertificateResponse(issued=[IssuedCert(cert=cert, domains=["batch.example.com"])], existing=[]),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
mgr = RenewalManager(
|
|
424
|
+
client,
|
|
425
|
+
renew_threshold_days=30,
|
|
426
|
+
clock_fn=lambda: now,
|
|
427
|
+
batch_domains=True,
|
|
428
|
+
self_verify=False,
|
|
429
|
+
organization="certapi-tests",
|
|
430
|
+
)
|
|
431
|
+
mgr.set_watch_domains(["batch.example.com"])
|
|
432
|
+
mgr.trigger_now()
|
|
433
|
+
|
|
434
|
+
assert len(client.calls) == 1
|
|
435
|
+
assert client.calls[0]["kwargs"]["batch_domains"] is True
|
|
436
|
+
assert client.calls[0]["kwargs"]["self_verify"] is False
|
|
437
|
+
assert client.calls[0]["kwargs"]["skip_failing"] is False
|
|
438
|
+
assert client.calls[0]["kwargs"]["renew_threshold_days"] == 30
|
|
439
|
+
assert client.calls[0]["kwargs"]["organization"] == "certapi-tests"
|
|
440
|
+
|
|
441
|
+
|
|
411
442
|
def test_local_keystore_seed_prevents_remote_call_when_fresh():
|
|
412
443
|
now = datetime(2026, 1, 1, tzinfo=UTC)
|
|
413
444
|
key = Key.generate("ecdsa")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|