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.
Files changed (70) hide show
  1. {certapi-1.1.2/src/certapi.egg-info → certapi-1.1.4}/PKG-INFO +1 -1
  2. {certapi-1.1.2 → certapi-1.1.4}/setup.py +1 -1
  3. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/cert_manager_client.py +4 -0
  4. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/renewal_manager.py +37 -15
  5. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/manager/acme_cert_manager.py +27 -2
  6. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/api.py +23 -32
  7. {certapi-1.1.2 → certapi-1.1.4/src/certapi.egg-info}/PKG-INFO +1 -1
  8. {certapi-1.1.2 → certapi-1.1.4}/tests/test_acme_cert_manager_batching.py +74 -0
  9. {certapi-1.1.2 → certapi-1.1.4}/tests/test_obtain_interface.py +76 -1
  10. {certapi-1.1.2 → certapi-1.1.4}/tests/test_renewal_manager.py +34 -3
  11. {certapi-1.1.2 → certapi-1.1.4}/MANIFEST.in +0 -0
  12. {certapi-1.1.2 → certapi-1.1.4}/README.md +0 -0
  13. {certapi-1.1.2 → certapi-1.1.4}/pyproject.toml +0 -0
  14. {certapi-1.1.2 → certapi-1.1.4}/setup.cfg +0 -0
  15. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/__init__.py +0 -0
  16. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Acme.py +0 -0
  17. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/AcmeError.py +0 -0
  18. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Challenge.py +0 -0
  19. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/Order.py +0 -0
  20. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/__init__.py +0 -0
  21. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/acme/http.py +0 -0
  22. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/ChallengeSolver.py +0 -0
  23. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/FileSystemChallengeSolver.py +0 -0
  24. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/InmemoryChallengeSolver.py +0 -0
  25. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/__init__.py +0 -0
  26. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/__init__.py +0 -0
  27. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/__init__.py +0 -0
  28. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_challenge_solver.py +0 -0
  29. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/cloudflare/cloudflare_client.py +0 -0
  30. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/__init__.py +0 -0
  31. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_challenge_solver.py +0 -0
  32. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/challenge_solver/dns/digitalocean/digitalocean_client.py +0 -0
  33. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/cli.py +0 -0
  34. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/client/__init__.py +0 -0
  35. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/__init__.py +0 -0
  36. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/crypto.py +0 -0
  37. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/crypto/crypto_classes.py +0 -0
  38. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/domain_batching.py +0 -0
  39. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/errors.py +0 -0
  40. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/HttpClientBase.py +0 -0
  41. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/__init__.py +0 -0
  42. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/http/types.py +0 -0
  43. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/AcmeCertIssuer.py +0 -0
  44. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/SelfCertIssuer.py +0 -0
  45. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/__init__.py +0 -0
  46. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/issuers/abstract_certissuer.py +0 -0
  47. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/FileSystemKeyStore.py +0 -0
  48. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/KeyStore.py +0 -0
  49. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/PostgresqlKeyStore.py +0 -0
  50. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/RemoteKeyStore.py +0 -0
  51. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/SqliteKeyStore.py +0 -0
  52. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/keystore/__init__.py +0 -0
  53. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/manager/__init__.py +0 -0
  54. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/__init__.py +0 -0
  55. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/cert_api.py +0 -0
  56. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/server/key_api.py +0 -0
  57. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/util.py +0 -0
  58. {certapi-1.1.2 → certapi-1.1.4}/src/certapi/utils.py +0 -0
  59. {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/SOURCES.txt +0 -0
  60. {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/dependency_links.txt +0 -0
  61. {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/entry_points.txt +0 -0
  62. {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/requires.txt +0 -0
  63. {certapi-1.1.2 → certapi-1.1.4}/src/certapi.egg-info/top_level.txt +0 -0
  64. {certapi-1.1.2 → certapi-1.1.4}/tests/test_acme_error_handling.py +0 -0
  65. {certapi-1.1.2 → certapi-1.1.4}/tests/test_cert_issuer_generic.py +0 -0
  66. {certapi-1.1.2 → certapi-1.1.4}/tests/test_certs_with_key_types.py +0 -0
  67. {certapi-1.1.2 → certapi-1.1.4}/tests/test_cli.py +0 -0
  68. {certapi-1.1.2 → certapi-1.1.4}/tests/test_domain_batching_boulder_cases.py +0 -0
  69. {certapi-1.1.2 → certapi-1.1.4}/tests/test_http_error_handling.py +0 -0
  70. {certapi-1.1.2 → certapi-1.1.4}/tests/test_keystores.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.2
3
+ Version: 1.1.4
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.2",
5
+ version="1.1.4",
6
6
  packages=find_packages(where="src"),
7
7
  package_dir={"": "src"},
8
8
  install_requires=[
@@ -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
- 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
- )
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
- if store.supports_domain(host):
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
- 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)
128
-
129
- hostnames = verified_hostnames
130
-
131
- if not hostnames and not skip_failing:
132
- api_ns.abort(400, message="None of the domains are owned by this machine or could be verified")
133
-
134
- if not hostnames:
135
- return CertificateResponse().to_json()
136
-
137
- issue_fn = (
138
- cert_manager.issue_certificate_in_batches
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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certapi
3
- Version: 1.1.2
3
+ Version: 1.1.4
4
4
  Summary: Python Package for managing keys, request SSL certificates from ACME.
5
5
  Home-page: https://github.com/mesudip/certapi
6
6
  Author: Sudip Bhattarai
@@ -22,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 None
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 issue_certificate(self, hosts, **kwargs):
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().issue_certificate(hosts, **kwargs)
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().issue_certificate(hosts, **kwargs)
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