devnomads-cli 0.9.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.9.0 → devnomads_cli-0.9.1}/PKG-INFO +5 -3
  2. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/README.md +4 -2
  3. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/PKG-INFO +5 -3
  4. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/dncli.py +50 -3
  5. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/pyproject.toml +1 -1
  6. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_cert.py +62 -0
  7. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/LICENSE +0 -0
  8. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/setup.cfg +0 -0
  14. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_cli.py +0 -0
  15. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.9.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.9.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
@@ -210,8 +210,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
210
210
  certificate, and the issuing intermediate.
211
211
 
212
212
  `dncli certs delete <domain>` removes a stored certificate and its
213
- private key from disk (after confirmation; `--yes` skips it). It only
214
- deletes the local files - the certificate itself is not revoked.
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).
215
217
 
216
218
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
217
219
 
@@ -194,8 +194,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
194
194
  certificate, and the issuing intermediate.
195
195
 
196
196
  `dncli certs delete <domain>` removes a stored certificate and its
197
- private key from disk (after confirmation; `--yes` skips it). It only
198
- deletes the local files - the certificate itself is not revoked.
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).
199
201
 
200
202
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
201
203
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.9.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
@@ -210,8 +210,10 @@ The `.pfx` is unencrypted unless you pass `--passphrase` (alias
210
210
  certificate, and the issuing intermediate.
211
211
 
212
212
  `dncli certs delete <domain>` removes a stored certificate and its
213
- private key from disk (after confirmation; `--yes` skips it). It only
214
- deletes the local files - the certificate itself is not revoked.
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).
215
217
 
216
218
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
217
219
 
@@ -53,7 +53,7 @@ from rich.markup import escape
53
53
  from rich.table import Table
54
54
  from typer.core import TyperGroup
55
55
 
56
- __version__ = "0.9.0"
56
+ __version__ = "0.9.1"
57
57
 
58
58
  # Distribution names, for reporting versions via `dncli version`.
59
59
  CLI_PACKAGE = "devnomads-cli"
@@ -2479,24 +2479,71 @@ def cert_export(
2479
2479
  sys.stdout.write(_combined_pem(key_text, leaf_text, chain_text))
2480
2480
 
2481
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
+
2482
2518
  @cert_app.command("delete")
2483
2519
  def cert_delete(
2484
2520
  domain: Annotated[
2485
2521
  str, typer.Argument(help="Domain of a previously issued certificate.")
2486
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,
2487
2530
  yes: Annotated[
2488
2531
  bool, typer.Option("--yes", "-y", help="Do not ask for confirmation.")
2489
2532
  ] = False,
2490
2533
  ) -> None:
2491
2534
  """Delete a stored certificate and its private key from this machine.
2492
2535
 
2493
- This only removes the local files; the certificate itself is not revoked.
2536
+ This only removes the local files; pass --revoke to also revoke the
2537
+ certificate at the CA that issued it.
2494
2538
  """
2495
2539
 
2496
2540
  cert_dir = _cert_dir(domain)
2497
2541
  if not cert_dir.is_dir():
2498
2542
  raise CliError(f"no certificate for {domain} in {cert_dir}")
2499
- _confirm(f"Delete the certificate and private key for {domain}?", yes)
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)
2500
2547
  shutil.rmtree(cert_dir)
2501
2548
  err_console.print(f"deleted {cert_dir}")
2502
2549
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.9.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"
@@ -775,6 +775,68 @@ def test_cert_delete_missing(isolated_config):
775
775
  assert "no certificate for example.com" in result.output
776
776
 
777
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
+
778
840
  def test_cert_delete_requires_confirmation(isolated_config):
779
841
  # without --yes and without a TTY the command must refuse and keep the files
780
842
  cert_dir = dncli.config_dir() / "certs" / "example.com"
File without changes
File without changes