devnomads-cli 0.8.0__tar.gz → 0.9.1__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 (20) hide show
  1. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/PKG-INFO +19 -11
  2. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/README.md +18 -10
  3. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/PKG-INFO +19 -11
  4. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/dncli.py +118 -7
  5. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/pyproject.toml +1 -1
  6. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_cert.py +203 -33
  7. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/LICENSE +0 -0
  8. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/setup.cfg +0 -0
  14. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_cli.py +0 -0
  15. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -148,11 +148,13 @@ dncli services list | jq -r '.[].entity'
148
148
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
149
149
 
150
150
  ```sh
151
- dncli cert issue example.com -d www.example.com -d "*.example.com"
151
+ dncli certs issue example.com -d www.example.com --wildcard
152
152
  ```
153
153
 
154
154
  The first argument is the primary domain (the certificate CN); add
155
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
155
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
156
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
157
+ accepted as the CN itself. Every name must live in
156
158
  one of your DevNomads DNS zones, and that zone must be delegated to the
157
159
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
158
160
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -176,37 +178,43 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
176
178
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
177
179
 
178
180
  ```sh
179
- dncli cert issue example.com --provider zerossl \
181
+ dncli certs issue example.com --provider zerossl \
180
182
  --eab-kid <kid> --eab-hmac-key <hmac>
181
183
  ```
182
184
 
183
185
  `--staging` (the Let's Encrypt staging CA) applies only to the
184
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
186
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
185
187
  options.
186
188
 
187
189
  Each CA gets its own ACME account: the key lives in
188
190
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
189
191
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
190
192
 
191
- Re-running `cert issue` is a no-op while the existing certificate is
193
+ Re-running `certs issue` is a no-op while the existing certificate is
192
194
  still valid for more than 21 days; pass `--force` to re-issue anyway.
193
195
 
194
196
  List what you have issued and re-export any of them - as a PEM bundle
195
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
197
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
196
198
  `provider` column showing the issuing CA (recorded at issue time, or
197
199
  inferred from the certificate's issuer for older certificates):
198
200
 
199
201
  ```sh
200
- dncli cert list
201
- dncli cert export example.com --out bundle.pem # omit --out to print
202
- dncli cert export example.com --format pfx --out bundle.pfx
203
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
202
+ dncli certs list
203
+ dncli certs export example.com --out bundle.pem # omit --out to print
204
+ dncli certs export example.com --format pfx --out bundle.pfx
205
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
204
206
  ```
205
207
 
206
208
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
207
209
  `--password`). Both bundle formats carry the same three items: key,
208
210
  certificate, and the issuing intermediate.
209
211
 
212
+ `dncli certs delete <domain>` removes a stored certificate and its
213
+ private key from disk (after confirmation; `--yes` skips it). By
214
+ default it only deletes the local files; add `--revoke` to also revoke
215
+ the certificate at the CA that issued it (the files are kept if
216
+ revocation fails).
217
+
210
218
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
211
219
 
212
220
  ```sh
@@ -132,11 +132,13 @@ dncli services list | jq -r '.[].entity'
132
132
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
133
133
 
134
134
  ```sh
135
- dncli cert issue example.com -d www.example.com -d "*.example.com"
135
+ dncli certs issue example.com -d www.example.com --wildcard
136
136
  ```
137
137
 
138
138
  The first argument is the primary domain (the certificate CN); add
139
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
139
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
140
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
141
+ accepted as the CN itself. Every name must live in
140
142
  one of your DevNomads DNS zones, and that zone must be delegated to the
141
143
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
142
144
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -160,37 +162,43 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
160
162
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
161
163
 
162
164
  ```sh
163
- dncli cert issue example.com --provider zerossl \
165
+ dncli certs issue example.com --provider zerossl \
164
166
  --eab-kid <kid> --eab-hmac-key <hmac>
165
167
  ```
166
168
 
167
169
  `--staging` (the Let's Encrypt staging CA) applies only to the
168
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
170
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
169
171
  options.
170
172
 
171
173
  Each CA gets its own ACME account: the key lives in
172
174
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
173
175
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
174
176
 
175
- Re-running `cert issue` is a no-op while the existing certificate is
177
+ Re-running `certs issue` is a no-op while the existing certificate is
176
178
  still valid for more than 21 days; pass `--force` to re-issue anyway.
177
179
 
178
180
  List what you have issued and re-export any of them - as a PEM bundle
179
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
181
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
180
182
  `provider` column showing the issuing CA (recorded at issue time, or
181
183
  inferred from the certificate's issuer for older certificates):
182
184
 
183
185
  ```sh
184
- dncli cert list
185
- dncli cert export example.com --out bundle.pem # omit --out to print
186
- dncli cert export example.com --format pfx --out bundle.pfx
187
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
186
+ dncli certs list
187
+ dncli certs export example.com --out bundle.pem # omit --out to print
188
+ dncli certs export example.com --format pfx --out bundle.pfx
189
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
188
190
  ```
189
191
 
190
192
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
191
193
  `--password`). Both bundle formats carry the same three items: key,
192
194
  certificate, and the issuing intermediate.
193
195
 
196
+ `dncli certs delete <domain>` removes a stored certificate and its
197
+ private key from disk (after confirmation; `--yes` skips it). By
198
+ default it only deletes the local files; add `--revoke` to also revoke
199
+ the certificate at the CA that issued it (the files are kept if
200
+ revocation fails).
201
+
194
202
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
195
203
 
196
204
  ```sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -148,11 +148,13 @@ dncli services list | jq -r '.[].entity'
148
148
  `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
149
149
 
150
150
  ```sh
151
- dncli cert issue example.com -d www.example.com -d "*.example.com"
151
+ dncli certs issue example.com -d www.example.com --wildcard
152
152
  ```
153
153
 
154
154
  The first argument is the primary domain (the certificate CN); add
155
- extra names (SANs) by repeating `--san`/`-d`. Every name must live in
155
+ extra names (SANs) by repeating `--san`/`-d`. `--wildcard`/`-w` is a
156
+ shortcut that adds `*.<domain>` as a SAN - a wildcard is never
157
+ accepted as the CN itself. Every name must live in
156
158
  one of your DevNomads DNS zones, and that zone must be delegated to the
157
159
  DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
158
160
  `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
@@ -176,37 +178,43 @@ and `--eab-hmac-key` or the `DN_ZEROSSL_EAB_KID` and
176
178
  `DN_ZEROSSL_EAB_HMAC_KEY` environment variables:
177
179
 
178
180
  ```sh
179
- dncli cert issue example.com --provider zerossl \
181
+ dncli certs issue example.com --provider zerossl \
180
182
  --eab-kid <kid> --eab-hmac-key <hmac>
181
183
  ```
182
184
 
183
185
  `--staging` (the Let's Encrypt staging CA) applies only to the
184
- letsencrypt provider. `cert renew` takes the same `--provider` and EAB
186
+ letsencrypt provider. `certs renew` takes the same `--provider` and EAB
185
187
  options.
186
188
 
187
189
  Each CA gets its own ACME account: the key lives in
188
190
  `~/.config/dncli/acme/` as `account.pem` for Let's Encrypt production
189
191
  and `account-<provider>.pem` for ZeroSSL and the staging CA.
190
192
 
191
- Re-running `cert issue` is a no-op while the existing certificate is
193
+ Re-running `certs issue` is a no-op while the existing certificate is
192
194
  still valid for more than 21 days; pass `--force` to re-issue anyway.
193
195
 
194
196
  List what you have issued and re-export any of them - as a PEM bundle
195
- or a PKCS#12 (`.pfx`) file - without re-issuing. `cert list` includes a
197
+ or a PKCS#12 (`.pfx`) file - without re-issuing. `certs list` includes a
196
198
  `provider` column showing the issuing CA (recorded at issue time, or
197
199
  inferred from the certificate's issuer for older certificates):
198
200
 
199
201
  ```sh
200
- dncli cert list
201
- dncli cert export example.com --out bundle.pem # omit --out to print
202
- dncli cert export example.com --format pfx --out bundle.pfx
203
- dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
202
+ dncli certs list
203
+ dncli certs export example.com --out bundle.pem # omit --out to print
204
+ dncli certs export example.com --format pfx --out bundle.pfx
205
+ dncli certs export example.com --format pfx --out bundle.pfx --passphrase secret
204
206
  ```
205
207
 
206
208
  The `.pfx` is unencrypted unless you pass `--passphrase` (alias
207
209
  `--password`). Both bundle formats carry the same three items: key,
208
210
  certificate, and the issuing intermediate.
209
211
 
212
+ `dncli certs delete <domain>` removes a stored certificate and its
213
+ private key from disk (after confirmation; `--yes` skips it). By
214
+ default it only deletes the local files; add `--revoke` to also revoke
215
+ the certificate at the CA that issued it (the files are kept if
216
+ revocation fails).
217
+
210
218
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
211
219
 
212
220
  ```sh
@@ -29,6 +29,7 @@ import logging
29
29
  import os
30
30
  import re
31
31
  import secrets
32
+ import shutil
32
33
  import stat
33
34
  import sys
34
35
  import time
@@ -52,7 +53,7 @@ from rich.markup import escape
52
53
  from rich.table import Table
53
54
  from typer.core import TyperGroup
54
55
 
55
- __version__ = "0.8.0"
56
+ __version__ = "0.9.1"
56
57
 
57
58
  # Distribution names, for reporting versions via `dncli version`.
58
59
  CLI_PACKAGE = "devnomads-cli"
@@ -1725,12 +1726,14 @@ def dns_hook(
1725
1726
 
1726
1727
 
1727
1728
  # ---------------------------------------------------------------------------
1728
- # Certificate issuance (`dncli cert ...`), behind the `cert` extra.
1729
+ # Certificate issuance (`dncli certs ...`), behind the `cert` extra.
1729
1730
 
1730
1731
  cert_app = typer.Typer(
1731
1732
  cls=PrefixGroup, help="Issue and renew TLS certificates.", no_args_is_help=True
1732
1733
  )
1733
- app.add_typer(cert_app, name="cert")
1734
+ # the singular `dncli cert ...` keeps working (hidden from help) because
1735
+ # PrefixGroup resolves it as a unique prefix of `certs`
1736
+ app.add_typer(cert_app, name="certs")
1734
1737
 
1735
1738
  CERT_RENEW_WINDOW_DAYS = 30
1736
1739
  # `cert issue` skips a domain whose current certificate is still valid for
@@ -2046,6 +2049,22 @@ def _cert_provider(cert_path: Path, cert: Any) -> str:
2046
2049
  return _provider_from_cert(cert)
2047
2050
 
2048
2051
 
2052
+ def _cert_sans(cert_path: Path) -> list[str]:
2053
+ """DNS SANs of the stored leaf at ``cert_path`` ([] if unreadable)."""
2054
+
2055
+ from cryptography import x509
2056
+
2057
+ try:
2058
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
2059
+ return list(
2060
+ cert.extensions.get_extension_for_class(
2061
+ x509.SubjectAlternativeName
2062
+ ).value.get_values_for_type(x509.DNSName)
2063
+ )
2064
+ except (OSError, ValueError, x509.ExtensionNotFound):
2065
+ return []
2066
+
2067
+
2049
2068
  def _cert_info(cert_path: Path) -> dict[str, Any]:
2050
2069
  """Summarize a stored leaf certificate for `cert list`."""
2051
2070
 
@@ -2101,6 +2120,13 @@ def _issue_certificate(
2101
2120
  """Obtain a DNS-01 certificate for ``domain`` and write it to ``out_dir``;
2102
2121
  optionally also export a single combined PEM to ``export_path``."""
2103
2122
 
2123
+ if domain.startswith("*."):
2124
+ base = domain[2:]
2125
+ raise CliError(
2126
+ f"a wildcard cannot be the certificate common name; issue for the "
2127
+ f"base domain with the wildcard as a SAN: "
2128
+ f"dncli certs issue {base} --wildcard"
2129
+ )
2104
2130
  acme = _load_acme()
2105
2131
  _enable_progress_logging(verbose)
2106
2132
 
@@ -2177,11 +2203,24 @@ def _issue_certificate(
2177
2203
  @cert_app.command("issue")
2178
2204
  def cert_issue(
2179
2205
  ctx: typer.Context,
2180
- domain: Annotated[str, typer.Argument(help="Primary domain (certificate CN).")],
2206
+ domain: Annotated[
2207
+ str,
2208
+ typer.Argument(
2209
+ help="Primary domain (certificate CN); use --wildcard for *.<domain>."
2210
+ ),
2211
+ ],
2181
2212
  san: Annotated[
2182
2213
  list[str] | None,
2183
2214
  typer.Option("--san", "-d", help="Additional SAN; repeat for more."),
2184
2215
  ] = None,
2216
+ wildcard: Annotated[
2217
+ bool,
2218
+ typer.Option(
2219
+ "--wildcard",
2220
+ "-w",
2221
+ help="Also request *.<domain> as a SAN (shortcut for --san '*.<domain>').",
2222
+ ),
2223
+ ] = False,
2185
2224
  email: Annotated[
2186
2225
  str | None, typer.Option("--email", help="ACME account contact email.")
2187
2226
  ] = None,
@@ -2225,6 +2264,9 @@ def cert_issue(
2225
2264
  default, or ZeroSSL with --provider zerossl)."""
2226
2265
 
2227
2266
  state = state_from(ctx)
2267
+ sans = list(san or [])
2268
+ if wildcard and f"*.{domain}" not in sans:
2269
+ sans.append(f"*.{domain}")
2228
2270
  out_dir = _cert_dir(domain)
2229
2271
  cert_path = out_dir / "cert.pem"
2230
2272
  if (
@@ -2240,7 +2282,7 @@ def cert_issue(
2240
2282
  _issue_certificate(
2241
2283
  state,
2242
2284
  domain,
2243
- sans=list(san or []),
2285
+ sans=sans,
2244
2286
  email=email,
2245
2287
  key_type=key_type.value,
2246
2288
  provider=provider.value,
@@ -2325,7 +2367,7 @@ def cert_renew(
2325
2367
  _issue_certificate(
2326
2368
  state,
2327
2369
  name,
2328
- sans=[],
2370
+ sans=[s for s in _cert_sans(cert_path) if s != name],
2329
2371
  email=email,
2330
2372
  key_type=key_type.value,
2331
2373
  staging=staging,
@@ -2408,7 +2450,7 @@ def cert_export(
2408
2450
  if not (privkey.is_file() and leaf.is_file()):
2409
2451
  raise CliError(
2410
2452
  f"no certificate for {domain} in {cert_dir}; "
2411
- "issue one with `dncli cert issue`"
2453
+ "issue one with `dncli certs issue`"
2412
2454
  )
2413
2455
  key_text = privkey.read_text()
2414
2456
  leaf_text = leaf.read_text()
@@ -2437,6 +2479,75 @@ def cert_export(
2437
2479
  sys.stdout.write(_combined_pem(key_text, leaf_text, chain_text))
2438
2480
 
2439
2481
 
2482
+ def _revoke_certificate(domain: str, cert_dir: Path) -> None:
2483
+ """Revoke the stored leaf for ``domain`` at the CA that issued it."""
2484
+
2485
+ from cryptography import x509
2486
+
2487
+ acme = _load_acme()
2488
+ cert_path = cert_dir / "cert.pem"
2489
+ try:
2490
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
2491
+ except (OSError, ValueError) as exc:
2492
+ raise CliError(f"could not read {cert_path}: {exc}") from exc
2493
+ provider = _cert_provider(cert_path, cert)
2494
+ directory_url = acme.CA_DIRECTORIES.get(provider)
2495
+ if directory_url is None:
2496
+ raise CliError(
2497
+ f"cannot revoke {domain}: unknown issuing CA"
2498
+ + (f" {provider!r}" if provider else "")
2499
+ )
2500
+ # AcmeClient exposes no revoke of its own; drive the underlying ACME v2
2501
+ # client directly, with the same account key that issued the certificate.
2502
+ wrapper = acme.AcmeClient(
2503
+ str(_account_key_path(provider)), directory_url=directory_url
2504
+ )
2505
+ try:
2506
+ client, _ = wrapper._init_client()
2507
+ client.revoke(cert, 0) # reason 0: unspecified
2508
+ except acme.AcmeError as exc:
2509
+ raise CliError(f"revocation failed: {exc}") from exc
2510
+ except Exception as exc:
2511
+ if getattr(exc, "code", None) == "alreadyRevoked":
2512
+ err_console.print(f"{domain}: certificate was already revoked")
2513
+ return
2514
+ raise CliError(f"revocation failed: {exc}") from exc
2515
+ err_console.print(f"revoked {domain} at {provider}")
2516
+
2517
+
2518
+ @cert_app.command("delete")
2519
+ def cert_delete(
2520
+ domain: Annotated[
2521
+ str, typer.Argument(help="Domain of a previously issued certificate.")
2522
+ ],
2523
+ revoke: Annotated[
2524
+ bool,
2525
+ typer.Option(
2526
+ "--revoke",
2527
+ help="Also revoke the certificate at the issuing CA before deleting.",
2528
+ ),
2529
+ ] = False,
2530
+ yes: Annotated[
2531
+ bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
2532
+ ] = False,
2533
+ ) -> None:
2534
+ """Delete a stored certificate and its private key from this machine.
2535
+
2536
+ This only removes the local files; pass --revoke to also revoke the
2537
+ certificate at the CA that issued it.
2538
+ """
2539
+
2540
+ cert_dir = _cert_dir(domain)
2541
+ if not cert_dir.is_dir():
2542
+ raise CliError(f"no certificate for {domain} in {cert_dir}")
2543
+ action = "Revoke and delete" if revoke else "Delete"
2544
+ _confirm(f"{action} the certificate and private key for {domain}?", yes)
2545
+ if revoke:
2546
+ _revoke_certificate(domain, cert_dir)
2547
+ shutil.rmtree(cert_dir)
2548
+ err_console.print(f"deleted {cert_dir}")
2549
+
2550
+
2440
2551
  # ---------------------------------------------------------------------------
2441
2552
  # Generated commands. tools/generate.py renders one thin command per
2442
2553
  # OpenAPI operation between the markers below, from openapi.json plus
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.8.0"
3
+ version = "0.9.1"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -49,7 +49,7 @@ def captured(monkeypatch):
49
49
  return calls
50
50
 
51
51
 
52
- def _write_cert(cert_dir, *, days, with_key_chain=False):
52
+ def _write_cert(cert_dir, *, days, with_key_chain=False, sans=("example.com",)):
53
53
  """Write a self-signed leaf (and optionally key+chain) into ``cert_dir``,
54
54
  valid until ``days`` from now."""
55
55
 
@@ -72,7 +72,8 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
72
72
  .not_valid_before(now - datetime.timedelta(days=1))
73
73
  .not_valid_after(now + datetime.timedelta(days=days))
74
74
  .add_extension(
75
- x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False
75
+ x509.SubjectAlternativeName([x509.DNSName(s) for s in sans]),
76
+ critical=False,
76
77
  )
77
78
  .sign(key, hashes.SHA256())
78
79
  )
@@ -90,10 +91,28 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
90
91
  return cert
91
92
 
92
93
 
93
- def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
94
+ def test_cert_singular_resolves_to_certs(configured, captured, isolated_config):
95
+ # `certs` is the registered name; the singular works as a unique prefix,
96
+ # so this fails if another top-level command starting with `cert` appears
94
97
  result = runner.invoke(app, ["cert", "issue", "example.com"])
95
98
  assert result.exit_code == 0, result.output
96
99
  assert captured["challenge"] == "dns-01"
100
+
101
+
102
+ def test_certs_is_the_advertised_name():
103
+ import re
104
+
105
+ result = runner.invoke(app, ["--help"])
106
+ assert result.exit_code == 0, result.output
107
+ assert "certs" in result.output
108
+ # the singular alias must not be listed as a command of its own
109
+ assert not re.search(r"^\s*│?\s*cert\s", result.output, re.MULTILINE)
110
+
111
+
112
+ def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
113
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
114
+ assert result.exit_code == 0, result.output
115
+ assert captured["challenge"] == "dns-01"
97
116
  assert captured["kwargs"]["dns_provider"] is not None
98
117
  out_dir = dncli.config_dir() / "certs" / "example.com"
99
118
  assert (out_dir / "cert.pem").read_text() == LEAF
@@ -119,7 +138,7 @@ def test_issue_default_key_type_is_ecdsa384(
119
138
  ):
120
139
  seen = {}
121
140
  _spy_generate_key(monkeypatch, seen)
122
- result = runner.invoke(app, ["cert", "issue", "example.com"])
141
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
123
142
  assert result.exit_code == 0, result.output
124
143
  assert seen["algo"] == "ecdsa384"
125
144
 
@@ -128,7 +147,7 @@ def test_issue_key_type_ecdsa521(configured, captured, isolated_config, monkeypa
128
147
  seen = {}
129
148
  _spy_generate_key(monkeypatch, seen)
130
149
  result = runner.invoke(
131
- app, ["cert", "issue", "example.com", "--key-type", "ecdsa521"]
150
+ app, ["certs", "issue", "example.com", "--key-type", "ecdsa521"]
132
151
  )
133
152
  assert result.exit_code == 0, result.output
134
153
  assert seen["algo"] == "ecdsa521"
@@ -140,7 +159,7 @@ def test_issue_key_type_is_selectable(
140
159
  seen = {}
141
160
  _spy_generate_key(monkeypatch, seen)
142
161
  result = runner.invoke(
143
- app, ["cert", "issue", "example.com", "--key-type", "rsa4096"]
162
+ app, ["certs", "issue", "example.com", "--key-type", "rsa4096"]
144
163
  )
145
164
  assert result.exit_code == 0, result.output
146
165
  assert seen["algo"] == "rsa4096"
@@ -148,14 +167,14 @@ def test_issue_key_type_is_selectable(
148
167
 
149
168
  def test_issue_rejects_unknown_key_type():
150
169
  result = runner.invoke(
151
- app, ["cert", "issue", "example.com", "--key-type", "ed25519"]
170
+ app, ["certs", "issue", "example.com", "--key-type", "ed25519"]
152
171
  )
153
172
  assert result.exit_code != 0
154
173
  assert "ed25519" in result.output
155
174
 
156
175
 
157
176
  def test_issue_is_always_dns01(configured, captured, isolated_config):
158
- result = runner.invoke(app, ["cert", "issue", "example.com"])
177
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
159
178
  assert result.exit_code == 0, result.output
160
179
  assert captured["challenge"] == "dns-01"
161
180
  assert captured["kwargs"]["dns_provider"] is not None
@@ -164,7 +183,7 @@ def test_issue_is_always_dns01(configured, captured, isolated_config):
164
183
  def test_issue_rejects_http01_options(configured, isolated_config):
165
184
  # HTTP-01 was removed; these flags must no longer be accepted.
166
185
  for flag in (["--http-01"], ["--standalone"], ["--webroot", "/tmp/x"]):
167
- result = runner.invoke(app, ["cert", "issue", "example.com", *flag])
186
+ result = runner.invoke(app, ["certs", "issue", "example.com", *flag])
168
187
  assert result.exit_code != 0, f"{flag} should be rejected: {result.output}"
169
188
 
170
189
 
@@ -172,7 +191,7 @@ def test_issue_passes_sans(configured, captured, isolated_config):
172
191
  result = runner.invoke(
173
192
  app,
174
193
  [
175
- "cert",
194
+ "certs",
176
195
  "issue",
177
196
  "example.com",
178
197
  "-d",
@@ -185,10 +204,52 @@ def test_issue_passes_sans(configured, captured, isolated_config):
185
204
  assert captured["kwargs"]["sans"] == ["www.example.com", "a.example.com"]
186
205
 
187
206
 
207
+ def test_issue_rejects_wildcard_cn(configured, captured, isolated_config):
208
+ result = runner.invoke(app, ["certs", "issue", "*.example.com"])
209
+ assert result.exit_code != 0
210
+ assert "certs issue example.com --wildcard" in result.output
211
+ assert "challenge" not in captured # obtain_certificate was not called
212
+
213
+
214
+ def test_issue_accepts_wildcard_as_san(configured, captured, isolated_config):
215
+ result = runner.invoke(
216
+ app, ["certs", "issue", "example.com", "--san", "*.example.com"]
217
+ )
218
+ assert result.exit_code == 0, result.output
219
+ assert captured["domain"] == "example.com"
220
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
221
+
222
+
223
+ def test_issue_wildcard_flag_adds_san(configured, captured, isolated_config):
224
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--wildcard"])
225
+ assert result.exit_code == 0, result.output
226
+ assert captured["domain"] == "example.com"
227
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
228
+
229
+
230
+ def test_issue_wildcard_flag_dedupes_explicit_san(
231
+ configured, captured, isolated_config
232
+ ):
233
+ result = runner.invoke(
234
+ app,
235
+ ["certs", "issue", "example.com", "--wildcard", "--san", "*.example.com"],
236
+ )
237
+ assert result.exit_code == 0, result.output
238
+ assert captured["kwargs"]["sans"] == ["*.example.com"]
239
+
240
+
241
+ def test_issue_wildcard_flag_composes_with_sans(configured, captured, isolated_config):
242
+ result = runner.invoke(
243
+ app, ["certs", "issue", "example.com", "-w", "-d", "www.example.com"]
244
+ )
245
+ assert result.exit_code == 0, result.output
246
+ assert captured["kwargs"]["sans"] == ["www.example.com", "*.example.com"]
247
+
248
+
188
249
  def test_issue_staging_switches_directory(configured, captured, isolated_config):
189
250
  import devnomads.acme as acme
190
251
 
191
- result = runner.invoke(app, ["cert", "issue", "example.com", "--staging"])
252
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--staging"])
192
253
  assert result.exit_code == 0, result.output
193
254
  assert captured["directory_url"] == acme.LETSENCRYPT_STAGING_DIRECTORY_URL
194
255
 
@@ -196,7 +257,7 @@ def test_issue_staging_switches_directory(configured, captured, isolated_config)
196
257
  def test_issue_defaults_to_letsencrypt(configured, captured, isolated_config):
197
258
  import devnomads.acme as acme
198
259
 
199
- result = runner.invoke(app, ["cert", "issue", "example.com"])
260
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
200
261
  assert result.exit_code == 0, result.output
201
262
  assert captured["directory_url"] == acme.LETSENCRYPT_DIRECTORY_URL
202
263
  # Let's Encrypt keeps the original account key path for back-compat
@@ -224,7 +285,7 @@ def test_issue_zerossl_uses_directory_and_eab(configured, captured, isolated_con
224
285
  result = runner.invoke(
225
286
  app,
226
287
  [
227
- "cert",
288
+ "certs",
228
289
  "issue",
229
290
  "example.com",
230
291
  "--provider",
@@ -251,7 +312,7 @@ def test_issue_zerossl_reads_eab_from_env(
251
312
  monkeypatch.setenv(dncli.ENV_EAB_KID, "kid-env")
252
313
  monkeypatch.setenv(dncli.ENV_EAB_HMAC_KEY, "hmac-env")
253
314
  result = runner.invoke(
254
- app, ["cert", "issue", "example.com", "--provider", "zerossl"]
315
+ app, ["certs", "issue", "example.com", "--provider", "zerossl"]
255
316
  )
256
317
  assert result.exit_code == 0, result.output
257
318
  assert captured["directory_url"] == acme.ZEROSSL_DIRECTORY_URL
@@ -259,7 +320,7 @@ def test_issue_zerossl_reads_eab_from_env(
259
320
 
260
321
  def test_issue_zerossl_without_eab_errors(configured, captured, isolated_config):
261
322
  result = runner.invoke(
262
- app, ["cert", "issue", "example.com", "--provider", "zerossl"]
323
+ app, ["certs", "issue", "example.com", "--provider", "zerossl"]
263
324
  )
264
325
  assert result.exit_code == 1
265
326
  assert "external account binding" in result.output
@@ -269,7 +330,7 @@ def test_issue_zerossl_without_eab_errors(configured, captured, isolated_config)
269
330
  def test_issue_zerossl_with_staging_errors(configured, captured, isolated_config):
270
331
  result = runner.invoke(
271
332
  app,
272
- ["cert", "issue", "example.com", "--provider", "zerossl", "--staging"],
333
+ ["certs", "issue", "example.com", "--provider", "zerossl", "--staging"],
273
334
  )
274
335
  assert result.exit_code == 1
275
336
  assert "--staging only applies to the letsencrypt provider" in result.output
@@ -279,7 +340,7 @@ def test_issue_out_exports_combined_pem(
279
340
  configured, captured, isolated_config, tmp_path
280
341
  ):
281
342
  out = tmp_path / "bundle.pem"
282
- result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
343
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--out", str(out)])
283
344
  assert result.exit_code == 0, result.output
284
345
  # the default per-domain dir is still written (with the full chain)...
285
346
  default_dir = dncli.config_dir() / "certs" / "example.com"
@@ -371,7 +432,7 @@ def test_load_acme_import_error_message(monkeypatch):
371
432
  return real_import(name, *args, **kwargs)
372
433
 
373
434
  monkeypatch.setattr(builtins, "__import__", fake_import)
374
- result = runner.invoke(app, ["cert", "issue", "example.com"])
435
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
375
436
  assert result.exit_code == 1
376
437
  assert "devnomads.acme" in result.output
377
438
 
@@ -405,7 +466,7 @@ def test_renew_skips_valid_cert(configured, captured, isolated_config):
405
466
  out_dir.mkdir(parents=True)
406
467
  (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
407
468
 
408
- result = runner.invoke(app, ["cert", "renew", "example.com"])
469
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
409
470
  assert result.exit_code == 0, result.output
410
471
  assert "still valid" in result.output
411
472
  assert "challenge" not in captured # obtain_certificate was not called
@@ -439,15 +500,38 @@ def test_renew_reissues_expiring_cert(configured, captured, isolated_config):
439
500
  out_dir.mkdir(parents=True)
440
501
  (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
441
502
 
442
- result = runner.invoke(app, ["cert", "renew", "example.com"])
503
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
443
504
  assert result.exit_code == 0, result.output
444
505
  assert captured["challenge"] == "dns-01"
445
506
  assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
446
507
 
447
508
 
509
+ def test_renew_preserves_sans(configured, captured, isolated_config):
510
+ _write_cert(
511
+ dncli.config_dir() / "certs" / "example.com",
512
+ days=5,
513
+ sans=("example.com", "*.example.com", "www.example.com"),
514
+ )
515
+ result = runner.invoke(app, ["certs", "renew", "example.com"])
516
+ assert result.exit_code == 0, result.output
517
+ assert captured["kwargs"]["sans"] == ["*.example.com", "www.example.com"]
518
+
519
+
520
+ def test_renew_rejects_wildcard_cn(configured, captured, isolated_config):
521
+ # a legacy cert dir issued with a wildcard CN must not renew as-is
522
+ _write_cert(
523
+ dncli.config_dir() / "certs" / "*.example.com",
524
+ days=5,
525
+ sans=("*.example.com",),
526
+ )
527
+ result = runner.invoke(app, ["certs", "renew", "*.example.com"])
528
+ assert result.exit_code != 0
529
+ assert "challenge" not in captured # obtain_certificate was not called
530
+
531
+
448
532
  def test_issue_skips_when_valid_beyond_window(configured, captured, isolated_config):
449
533
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
450
- result = runner.invoke(app, ["cert", "issue", "example.com"])
534
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
451
535
  assert result.exit_code == 0, result.output
452
536
  assert "still valid" in result.output
453
537
  assert "challenge" not in captured # obtain_certificate was not called
@@ -455,14 +539,14 @@ def test_issue_skips_when_valid_beyond_window(configured, captured, isolated_con
455
539
 
456
540
  def test_issue_force_reissues_valid_cert(configured, captured, isolated_config):
457
541
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
458
- result = runner.invoke(app, ["cert", "issue", "example.com", "--force"])
542
+ result = runner.invoke(app, ["certs", "issue", "example.com", "--force"])
459
543
  assert result.exit_code == 0, result.output
460
544
  assert captured["challenge"] == "dns-01"
461
545
 
462
546
 
463
547
  def test_issue_reissues_when_within_window(configured, captured, isolated_config):
464
548
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=10)
465
- result = runner.invoke(app, ["cert", "issue", "example.com"])
549
+ result = runner.invoke(app, ["certs", "issue", "example.com"])
466
550
  assert result.exit_code == 0, result.output
467
551
  assert captured["challenge"] == "dns-01"
468
552
 
@@ -471,7 +555,7 @@ def test_cert_list_shows_issued(configured, isolated_config):
471
555
  import json
472
556
 
473
557
  _write_cert(dncli.config_dir() / "certs" / "example.com", days=45)
474
- result = runner.invoke(app, ["cert", "list", "--out", "json"])
558
+ result = runner.invoke(app, ["certs", "list", "--out", "json"])
475
559
  assert result.exit_code == 0, result.output
476
560
  rows = json.loads(result.output)
477
561
  assert rows[0]["domain"] == "example.com"
@@ -486,7 +570,7 @@ def test_cert_list_shows_recorded_provider(configured, isolated_config):
486
570
  cert_dir = dncli.config_dir() / "certs" / "example.com"
487
571
  _write_cert(cert_dir, days=45)
488
572
  (cert_dir / "provider").write_text("zerossl\n")
489
- result = runner.invoke(app, ["cert", "list", "--out", "json"])
573
+ result = runner.invoke(app, ["certs", "list", "--out", "json"])
490
574
  assert result.exit_code == 0, result.output
491
575
  assert json.loads(result.output)[0]["provider"] == "zerossl"
492
576
 
@@ -523,7 +607,7 @@ def test_provider_inferred_from_issuer_when_unrecorded():
523
607
 
524
608
 
525
609
  def test_cert_list_empty(isolated_config):
526
- result = runner.invoke(app, ["cert", "list"])
610
+ result = runner.invoke(app, ["certs", "list"])
527
611
  assert result.exit_code == 0, result.output
528
612
  assert "no certificates" in result.output
529
613
 
@@ -533,7 +617,7 @@ def test_cert_export_to_file(configured, isolated_config, tmp_path):
533
617
  dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
534
618
  )
535
619
  out = tmp_path / "bundle.pem"
536
- result = runner.invoke(app, ["cert", "export", "example.com", "--out", str(out)])
620
+ result = runner.invoke(app, ["certs", "export", "example.com", "--out", str(out)])
537
621
  assert result.exit_code == 0, result.output
538
622
  text = out.read_text()
539
623
  # order: private key, then leaf certificate, then the one intermediate
@@ -548,7 +632,7 @@ def test_cert_export_to_stdout(configured, isolated_config):
548
632
  _write_cert(
549
633
  dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
550
634
  )
551
- result = runner.invoke(app, ["cert", "export", "example.com"])
635
+ result = runner.invoke(app, ["certs", "export", "example.com"])
552
636
  assert result.exit_code == 0, result.output
553
637
  assert "BEGIN CERTIFICATE" in result.stdout
554
638
  assert "INTER" in result.stdout
@@ -556,7 +640,7 @@ def test_cert_export_to_stdout(configured, isolated_config):
556
640
 
557
641
 
558
642
  def test_cert_export_missing(isolated_config):
559
- result = runner.invoke(app, ["cert", "export", "nope.com"])
643
+ result = runner.invoke(app, ["certs", "export", "nope.com"])
560
644
  assert result.exit_code != 0
561
645
  assert "no certificate" in result.output
562
646
 
@@ -568,7 +652,7 @@ def test_cert_export_passphrase_rejected_for_pem(configured, isolated_config, tm
568
652
  result = runner.invoke(
569
653
  app,
570
654
  [
571
- "cert",
655
+ "certs",
572
656
  "export",
573
657
  "example.com",
574
658
  "--passphrase",
@@ -633,7 +717,7 @@ def test_cert_export_pfx_no_passphrase(configured, isolated_config, tmp_path):
633
717
  leaf, inter, root = _write_real_chain(dncli.config_dir() / "certs" / "example.com")
634
718
  out = tmp_path / "bundle.pfx"
635
719
  result = runner.invoke(
636
- app, ["cert", "export", "example.com", "--format", "pfx", "--out", str(out)]
720
+ app, ["certs", "export", "example.com", "--format", "pfx", "--out", str(out)]
637
721
  )
638
722
  assert result.exit_code == 0, result.output
639
723
  key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), None)
@@ -652,7 +736,7 @@ def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
652
736
  result = runner.invoke(
653
737
  app,
654
738
  [
655
- "cert",
739
+ "certs",
656
740
  "export",
657
741
  "example.com",
658
742
  "--format",
@@ -672,6 +756,92 @@ def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
672
756
 
673
757
  def test_cert_export_pfx_requires_out(configured, isolated_config):
674
758
  _write_real_chain(dncli.config_dir() / "certs" / "example.com")
675
- result = runner.invoke(app, ["cert", "export", "example.com", "--format", "pfx"])
759
+ result = runner.invoke(app, ["certs", "export", "example.com", "--format", "pfx"])
676
760
  assert result.exit_code != 0
677
761
  assert "out" in result.output.lower()
762
+
763
+
764
+ def test_cert_delete_removes_stored_cert(isolated_config):
765
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
766
+ _write_cert(cert_dir, days=45, with_key_chain=True)
767
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--yes"])
768
+ assert result.exit_code == 0, result.output
769
+ assert not cert_dir.exists()
770
+
771
+
772
+ def test_cert_delete_missing(isolated_config):
773
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--yes"])
774
+ assert result.exit_code != 0
775
+ assert "no certificate for example.com" in result.output
776
+
777
+
778
+ def _fake_revocation(monkeypatch, revoke):
779
+ """Replace the ACME account/network setup with a stub whose ``revoke``
780
+ is the given callable."""
781
+
782
+ import devnomads.acme as acme
783
+
784
+ class FakeClient:
785
+ def revoke(self, cert, rsn):
786
+ revoke(cert, rsn)
787
+
788
+ monkeypatch.setattr(
789
+ acme.AcmeClient, "_init_client", lambda self: (FakeClient(), None)
790
+ )
791
+
792
+
793
+ def test_cert_delete_revoke_revokes_then_deletes(isolated_config, monkeypatch):
794
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
795
+ _write_cert(cert_dir, days=45, with_key_chain=True)
796
+ (cert_dir / "provider").write_text("letsencrypt\n")
797
+ calls = {}
798
+
799
+ def revoke(cert, rsn):
800
+ calls["subject"] = cert.subject.rfc4514_string()
801
+ calls["rsn"] = rsn
802
+
803
+ _fake_revocation(monkeypatch, revoke)
804
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
805
+ assert result.exit_code == 0, result.output
806
+ assert calls["rsn"] == 0
807
+ assert "CN=example.com" in calls["subject"]
808
+ assert "revoked example.com at letsencrypt" in result.output
809
+ assert not cert_dir.exists()
810
+
811
+
812
+ def test_cert_delete_revoke_failure_keeps_files(isolated_config, monkeypatch):
813
+ import devnomads.acme as acme
814
+
815
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
816
+ _write_cert(cert_dir, days=45, with_key_chain=True)
817
+ (cert_dir / "provider").write_text("letsencrypt\n")
818
+
819
+ def revoke(cert, rsn):
820
+ raise acme.AcmeError("boom")
821
+
822
+ _fake_revocation(monkeypatch, revoke)
823
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
824
+ assert result.exit_code != 0
825
+ assert "revocation failed" in result.output
826
+ assert (cert_dir / "privkey.pem").is_file()
827
+
828
+
829
+ def test_cert_delete_revoke_unknown_provider(isolated_config):
830
+ # the test cert is self-signed and has no provider marker, so the
831
+ # issuing CA cannot be determined and revocation must refuse early
832
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
833
+ _write_cert(cert_dir, days=45, with_key_chain=True)
834
+ result = runner.invoke(app, ["certs", "delete", "example.com", "--revoke", "--yes"])
835
+ assert result.exit_code != 0
836
+ assert "unknown issuing CA" in result.output
837
+ assert (cert_dir / "privkey.pem").is_file()
838
+
839
+
840
+ def test_cert_delete_requires_confirmation(isolated_config):
841
+ # without --yes and without a TTY the command must refuse and keep the files
842
+ cert_dir = dncli.config_dir() / "certs" / "example.com"
843
+ _write_cert(cert_dir, days=45, with_key_chain=True)
844
+ result = runner.invoke(app, ["certs", "delete", "example.com"])
845
+ assert result.exit_code != 0
846
+ assert "confirmation required" in result.output
847
+ assert (cert_dir / "privkey.pem").is_file()
File without changes
File without changes