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.
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/PKG-INFO +5 -3
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/README.md +4 -2
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/PKG-INFO +5 -3
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/dncli.py +50 -3
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/pyproject.toml +1 -1
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_cert.py +62 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/LICENSE +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/setup.cfg +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_cli.py +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_config.py +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_generate.py +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.9.0 → devnomads_cli-0.9.1}/tests/test_hook.py +0 -0
- {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.
|
|
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).
|
|
214
|
-
deletes the local files
|
|
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).
|
|
198
|
-
deletes the local files
|
|
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.
|
|
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).
|
|
214
|
-
deletes the local files
|
|
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.
|
|
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;
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|