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.
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/PKG-INFO +19 -11
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/README.md +18 -10
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/PKG-INFO +19 -11
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/dncli.py +118 -7
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/pyproject.toml +1 -1
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_cert.py +203 -33
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/LICENSE +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/setup.cfg +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_cli.py +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_config.py +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_generate.py +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.8.0 → devnomads_cli-0.9.1}/tests/test_hook.py +0 -0
- {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.
|
|
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
|
|
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`.
|
|
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
|
|
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. `
|
|
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 `
|
|
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. `
|
|
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
|
|
201
|
-
dncli
|
|
202
|
-
dncli
|
|
203
|
-
dncli
|
|
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
|
|
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`.
|
|
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
|
|
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. `
|
|
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 `
|
|
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. `
|
|
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
|
|
185
|
-
dncli
|
|
186
|
-
dncli
|
|
187
|
-
dncli
|
|
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.
|
|
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
|
|
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`.
|
|
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
|
|
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. `
|
|
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 `
|
|
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. `
|
|
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
|
|
201
|
-
dncli
|
|
202
|
-
dncli
|
|
203
|
-
dncli
|
|
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.
|
|
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
|
|
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
|
-
|
|
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[
|
|
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=
|
|
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
|
|
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
|
|
@@ -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(
|
|
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
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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
|
-
"
|
|
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, ["
|
|
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, ["
|
|
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
|
-
"
|
|
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, ["
|
|
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, ["
|
|
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
|
-
["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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, ["
|
|
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
|
-
"
|
|
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, ["
|
|
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
|
-
"
|
|
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, ["
|
|
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
|
|
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
|