devnomads-cli 0.5.0__tar.gz → 0.5.2__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.5.0 → devnomads_cli-0.5.2}/PKG-INFO +14 -6
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/README.md +13 -5
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/PKG-INFO +14 -6
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/dncli.py +179 -13
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/pyproject.toml +1 -1
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_cert.py +151 -15
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_helpers.py +34 -3
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/LICENSE +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/requires.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/setup.cfg +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_cli.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_config.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_generate.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_hook.py +0 -0
- {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_transfer.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -141,8 +141,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
141
141
|
|
|
142
142
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
143
143
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
144
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
145
|
-
|
|
144
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
145
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
146
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
147
|
+
progress.
|
|
146
148
|
|
|
147
149
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
148
150
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -150,14 +152,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
150
152
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
151
153
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
152
154
|
|
|
153
|
-
List what you have issued and re-export any of them as a
|
|
154
|
-
|
|
155
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
156
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
155
157
|
|
|
156
158
|
```sh
|
|
157
159
|
dncli cert list
|
|
158
|
-
dncli cert export example.com --out bundle.pem
|
|
160
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
161
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
162
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
159
163
|
```
|
|
160
164
|
|
|
165
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
166
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
167
|
+
certificate, and the issuing intermediate.
|
|
168
|
+
|
|
161
169
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
162
170
|
|
|
163
171
|
```sh
|
|
@@ -125,8 +125,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
125
125
|
|
|
126
126
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
127
127
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
128
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
129
|
-
|
|
128
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
129
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
130
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
131
|
+
progress.
|
|
130
132
|
|
|
131
133
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
132
134
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -134,14 +136,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
134
136
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
135
137
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
136
138
|
|
|
137
|
-
List what you have issued and re-export any of them as a
|
|
138
|
-
|
|
139
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
140
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
139
141
|
|
|
140
142
|
```sh
|
|
141
143
|
dncli cert list
|
|
142
|
-
dncli cert export example.com --out bundle.pem
|
|
144
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
145
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
146
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
143
147
|
```
|
|
144
148
|
|
|
149
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
150
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
151
|
+
certificate, and the issuing intermediate.
|
|
152
|
+
|
|
145
153
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
146
154
|
|
|
147
155
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads-cli
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.2
|
|
4
4
|
Summary: Manage your DevNomads services from the command line
|
|
5
5
|
Author-email: DevNomads <support@devnomads.nl>
|
|
6
6
|
License: MIT
|
|
@@ -141,8 +141,10 @@ DNS-01 challenge records would not be visible to the CA.
|
|
|
141
141
|
|
|
142
142
|
The certificate is always written to `~/.config/dncli/certs/<domain>/`
|
|
143
143
|
as `cert.pem`, `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600).
|
|
144
|
-
Pass `--out <file>` to additionally export a single PEM bundle
|
|
145
|
-
|
|
144
|
+
Pass `--out <file>` to additionally export a single PEM bundle of
|
|
145
|
+
exactly three blocks - the key, the certificate, and the issuing
|
|
146
|
+
intermediate, in that order. Add `-v`/`--verbose` for detailed ACME
|
|
147
|
+
progress.
|
|
146
148
|
|
|
147
149
|
Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
148
150
|
(`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
|
|
@@ -150,14 +152,20 @@ Keys are ECDSA P-384 by default. Pick another with `--key-type`
|
|
|
150
152
|
Re-running `cert issue` is a no-op while the existing certificate is
|
|
151
153
|
still valid for more than 21 days; pass `--force` to re-issue anyway.
|
|
152
154
|
|
|
153
|
-
List what you have issued and re-export any of them as a
|
|
154
|
-
|
|
155
|
+
List what you have issued and re-export any of them - as a PEM bundle
|
|
156
|
+
or a PKCS#12 (`.pfx`) file - without re-issuing:
|
|
155
157
|
|
|
156
158
|
```sh
|
|
157
159
|
dncli cert list
|
|
158
|
-
dncli cert export example.com --out bundle.pem
|
|
160
|
+
dncli cert export example.com --out bundle.pem # omit --out to print
|
|
161
|
+
dncli cert export example.com --format pfx --out bundle.pfx
|
|
162
|
+
dncli cert export example.com --format pfx --out bundle.pfx --passphrase secret
|
|
159
163
|
```
|
|
160
164
|
|
|
165
|
+
The `.pfx` is unencrypted unless you pass `--passphrase` (alias
|
|
166
|
+
`--password`). Both bundle formats carry the same three items: key,
|
|
167
|
+
certificate, and the issuing intermediate.
|
|
168
|
+
|
|
161
169
|
A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
|
|
162
170
|
|
|
163
171
|
```sh
|
|
@@ -24,6 +24,7 @@ import hashlib
|
|
|
24
24
|
import hmac
|
|
25
25
|
import io
|
|
26
26
|
import json
|
|
27
|
+
import logging
|
|
27
28
|
import os
|
|
28
29
|
import secrets
|
|
29
30
|
import stat
|
|
@@ -107,15 +108,19 @@ def load_credentials() -> configparser.ConfigParser:
|
|
|
107
108
|
return parser
|
|
108
109
|
|
|
109
110
|
|
|
110
|
-
def
|
|
111
|
+
def _write_private_bytes(path: Path, data: bytes) -> None:
|
|
111
112
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
112
113
|
path.parent.chmod(0o700)
|
|
113
114
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
114
|
-
with os.fdopen(fd, "
|
|
115
|
-
fh.write(
|
|
115
|
+
with os.fdopen(fd, "wb") as fh:
|
|
116
|
+
fh.write(data)
|
|
116
117
|
path.chmod(0o600)
|
|
117
118
|
|
|
118
119
|
|
|
120
|
+
def _write_private(path: Path, content: str) -> None:
|
|
121
|
+
_write_private_bytes(path, content.encode())
|
|
122
|
+
|
|
123
|
+
|
|
119
124
|
def save_credentials(parser: configparser.ConfigParser) -> None:
|
|
120
125
|
buffer = io.StringIO()
|
|
121
126
|
parser.write(buffer)
|
|
@@ -206,16 +211,44 @@ def render(
|
|
|
206
211
|
out_console.print(str(data), soft_wrap=True)
|
|
207
212
|
|
|
208
213
|
|
|
214
|
+
def _summarize_item(item: dict[str, Any]) -> str:
|
|
215
|
+
"""Compact one-line ``key=value`` view of an object's scalar fields for a
|
|
216
|
+
table cell. Nested lists/objects collapse to a count so the line stays
|
|
217
|
+
short; None and empty fields are dropped."""
|
|
218
|
+
|
|
219
|
+
parts = []
|
|
220
|
+
for key, val in item.items():
|
|
221
|
+
if val is None or val == "":
|
|
222
|
+
continue
|
|
223
|
+
if isinstance(val, bool):
|
|
224
|
+
parts.append(f"{key}={'yes' if val else 'no'}")
|
|
225
|
+
elif isinstance(val, dict):
|
|
226
|
+
if val:
|
|
227
|
+
parts.append(f"{key}={{{len(val)}}}")
|
|
228
|
+
elif isinstance(val, list):
|
|
229
|
+
if val:
|
|
230
|
+
parts.append(f"{key}=[{len(val)}]")
|
|
231
|
+
else:
|
|
232
|
+
parts.append(f"{key}={val}")
|
|
233
|
+
return " ".join(parts)
|
|
234
|
+
|
|
235
|
+
|
|
209
236
|
def _cell(value: Any) -> str:
|
|
210
237
|
if value is None:
|
|
211
238
|
return ""
|
|
212
239
|
if isinstance(value, bool):
|
|
213
240
|
return "yes" if value else "no"
|
|
214
|
-
if isinstance(value,
|
|
215
|
-
|
|
216
|
-
):
|
|
217
|
-
|
|
218
|
-
|
|
241
|
+
if isinstance(value, dict):
|
|
242
|
+
return _summarize_item(value)
|
|
243
|
+
if isinstance(value, list):
|
|
244
|
+
if not value:
|
|
245
|
+
return ""
|
|
246
|
+
if all(isinstance(item, dict) for item in value):
|
|
247
|
+
# a list of objects (instances, mailboxes, ips, ...) -> one
|
|
248
|
+
# readable line per item instead of an unreadable JSON blob.
|
|
249
|
+
return "\n".join(_summarize_item(item) for item in value)
|
|
250
|
+
if not any(isinstance(item, (dict, list)) for item in value):
|
|
251
|
+
return ", ".join(str(item) for item in value)
|
|
219
252
|
return json.dumps(value)
|
|
220
253
|
return str(value)
|
|
221
254
|
|
|
@@ -1374,6 +1407,26 @@ REQUIRED_NAMESERVERS = (
|
|
|
1374
1407
|
)
|
|
1375
1408
|
|
|
1376
1409
|
|
|
1410
|
+
class _ProgressHandler(logging.Handler):
|
|
1411
|
+
"""Route the devnomads library's log records to stderr in the CLI's
|
|
1412
|
+
muted style, so cert issuance shows what it is doing step by step."""
|
|
1413
|
+
|
|
1414
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
1415
|
+
try:
|
|
1416
|
+
err_console.print(f"[dim]{escape(record.getMessage())}[/]", soft_wrap=True)
|
|
1417
|
+
except Exception: # pragma: no cover - logging must never crash the CLI
|
|
1418
|
+
self.handleError(record)
|
|
1419
|
+
|
|
1420
|
+
|
|
1421
|
+
def _enable_progress_logging(verbose: bool = False) -> None:
|
|
1422
|
+
"""Surface the library's progress on stderr (INFO, or DEBUG when verbose)."""
|
|
1423
|
+
|
|
1424
|
+
logger = logging.getLogger("devnomads")
|
|
1425
|
+
logger.setLevel(logging.DEBUG if verbose else logging.INFO)
|
|
1426
|
+
if not any(isinstance(h, _ProgressHandler) for h in logger.handlers):
|
|
1427
|
+
logger.addHandler(_ProgressHandler())
|
|
1428
|
+
|
|
1429
|
+
|
|
1377
1430
|
class KeyType(str, Enum):
|
|
1378
1431
|
"""Certificate key types. ECDSA is the default; RSA on request."""
|
|
1379
1432
|
|
|
@@ -1384,6 +1437,13 @@ class KeyType(str, Enum):
|
|
|
1384
1437
|
rsa4096 = "rsa4096"
|
|
1385
1438
|
|
|
1386
1439
|
|
|
1440
|
+
class ExportFormat(str, Enum):
|
|
1441
|
+
"""Bundle formats for `cert export`."""
|
|
1442
|
+
|
|
1443
|
+
pem = "pem"
|
|
1444
|
+
pfx = "pfx"
|
|
1445
|
+
|
|
1446
|
+
|
|
1387
1447
|
def _load_acme() -> Any:
|
|
1388
1448
|
"""Import devnomads.acme lazily, so non-cert commands skip the ACME
|
|
1389
1449
|
stack at startup."""
|
|
@@ -1477,10 +1537,26 @@ def _write_cert_files(
|
|
|
1477
1537
|
(out_dir / name).write_text(content)
|
|
1478
1538
|
|
|
1479
1539
|
|
|
1540
|
+
def _first_cert(pem: str) -> str:
|
|
1541
|
+
"""The first PEM certificate block in ``pem`` (the issuing intermediate
|
|
1542
|
+
of a chain), or "" if there is none."""
|
|
1543
|
+
|
|
1544
|
+
begin = "-----BEGIN CERTIFICATE-----"
|
|
1545
|
+
end = "-----END CERTIFICATE-----"
|
|
1546
|
+
start = pem.find(begin)
|
|
1547
|
+
if start == -1:
|
|
1548
|
+
return ""
|
|
1549
|
+
stop = pem.find(end, start)
|
|
1550
|
+
if stop == -1:
|
|
1551
|
+
return ""
|
|
1552
|
+
return pem[start : stop + len(end)] + "\n"
|
|
1553
|
+
|
|
1554
|
+
|
|
1480
1555
|
def _combined_pem(privkey: str, leaf: str, chain: str) -> str:
|
|
1481
|
-
"""One PEM bundle: key, then certificate,
|
|
1556
|
+
"""One PEM bundle of exactly three blocks: key, then leaf certificate,
|
|
1557
|
+
then the single issuing intermediate (the rest of the chain is dropped)."""
|
|
1482
1558
|
|
|
1483
|
-
parts = [privkey, leaf, chain]
|
|
1559
|
+
parts = [privkey, leaf, _first_cert(chain)]
|
|
1484
1560
|
return "".join(p if p.endswith("\n") else p + "\n" for p in parts if p)
|
|
1485
1561
|
|
|
1486
1562
|
|
|
@@ -1490,6 +1566,47 @@ def _export_combined_pem(path: Path, *, privkey: str, leaf: str, chain: str) ->
|
|
|
1490
1566
|
_write_private(path, _combined_pem(privkey, leaf, chain))
|
|
1491
1567
|
|
|
1492
1568
|
|
|
1569
|
+
def _export_pkcs12(
|
|
1570
|
+
path: Path,
|
|
1571
|
+
*,
|
|
1572
|
+
privkey: str,
|
|
1573
|
+
leaf: str,
|
|
1574
|
+
chain: str,
|
|
1575
|
+
name: str,
|
|
1576
|
+
passphrase: str | None = None,
|
|
1577
|
+
) -> None:
|
|
1578
|
+
"""Write a PKCS#12 (.pfx) bundle of key + leaf + single intermediate at
|
|
1579
|
+
0600. Unencrypted unless ``passphrase`` is given."""
|
|
1580
|
+
|
|
1581
|
+
from cryptography import x509
|
|
1582
|
+
from cryptography.hazmat.primitives import serialization
|
|
1583
|
+
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
|
1584
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
1585
|
+
|
|
1586
|
+
try:
|
|
1587
|
+
key = serialization.load_pem_private_key(privkey.encode(), password=None)
|
|
1588
|
+
cert = x509.load_pem_x509_certificate(leaf.encode())
|
|
1589
|
+
intermediate = _first_cert(chain)
|
|
1590
|
+
cas = (
|
|
1591
|
+
[x509.load_pem_x509_certificate(intermediate.encode())]
|
|
1592
|
+
if intermediate
|
|
1593
|
+
else None
|
|
1594
|
+
)
|
|
1595
|
+
except ValueError as exc:
|
|
1596
|
+
raise CliError(f"could not read stored certificate files: {exc}") from exc
|
|
1597
|
+
if not isinstance(key, (rsa.RSAPrivateKey, ec.EllipticCurvePrivateKey)):
|
|
1598
|
+
raise CliError("unsupported private key type for pfx export")
|
|
1599
|
+
encryption = (
|
|
1600
|
+
serialization.BestAvailableEncryption(passphrase.encode())
|
|
1601
|
+
if passphrase
|
|
1602
|
+
else serialization.NoEncryption()
|
|
1603
|
+
)
|
|
1604
|
+
blob = pkcs12.serialize_key_and_certificates(
|
|
1605
|
+
name.encode(), key, cert, cas, encryption
|
|
1606
|
+
)
|
|
1607
|
+
_write_private_bytes(path, blob)
|
|
1608
|
+
|
|
1609
|
+
|
|
1493
1610
|
def _cert_info(cert_path: Path) -> dict[str, Any]:
|
|
1494
1611
|
"""Summarize a stored leaf certificate for `cert list`."""
|
|
1495
1612
|
|
|
@@ -1535,19 +1652,26 @@ def _issue_certificate(
|
|
|
1535
1652
|
staging: bool,
|
|
1536
1653
|
out_dir: Path,
|
|
1537
1654
|
export_path: Path | None = None,
|
|
1655
|
+
verbose: bool = False,
|
|
1538
1656
|
) -> None:
|
|
1539
1657
|
"""Obtain a DNS-01 certificate for ``domain`` and write it to ``out_dir``;
|
|
1540
1658
|
optionally also export a single combined PEM to ``export_path``."""
|
|
1541
1659
|
|
|
1542
1660
|
acme = _load_acme()
|
|
1543
|
-
|
|
1661
|
+
_enable_progress_logging(verbose)
|
|
1662
|
+
|
|
1663
|
+
names = [domain, *sans]
|
|
1664
|
+
err_console.print(f"checking {len(names)} name(s) are delegated to DevNomads")
|
|
1665
|
+
_assert_dns_authority(state, names)
|
|
1544
1666
|
|
|
1545
1667
|
directory_url = LE_STAGING_DIRECTORY if staging else acme.DEFAULT_DIRECTORY_URL
|
|
1668
|
+
err_console.print(f"using ACME directory {directory_url}")
|
|
1546
1669
|
client = acme.AcmeClient(
|
|
1547
1670
|
str(_account_key_path()),
|
|
1548
1671
|
directory_url=directory_url,
|
|
1549
1672
|
contact_email=email,
|
|
1550
1673
|
)
|
|
1674
|
+
err_console.print(f"generating {key_type} certificate key")
|
|
1551
1675
|
try:
|
|
1552
1676
|
domain_key = acme.generate_key(key_type)
|
|
1553
1677
|
except acme.AcmeError as exc:
|
|
@@ -1624,6 +1748,10 @@ def cert_issue(
|
|
|
1624
1748
|
"intermediate) to this file.",
|
|
1625
1749
|
),
|
|
1626
1750
|
] = None,
|
|
1751
|
+
verbose: Annotated[
|
|
1752
|
+
bool,
|
|
1753
|
+
typer.Option("--verbose", "-v", help="Show detailed ACME progress."),
|
|
1754
|
+
] = False,
|
|
1627
1755
|
) -> None:
|
|
1628
1756
|
"""Issue a TLS certificate for a domain via ACME (Let's Encrypt, DNS-01)."""
|
|
1629
1757
|
|
|
@@ -1649,6 +1777,7 @@ def cert_issue(
|
|
|
1649
1777
|
staging=staging,
|
|
1650
1778
|
out_dir=out_dir,
|
|
1651
1779
|
export_path=out,
|
|
1780
|
+
verbose=verbose,
|
|
1652
1781
|
)
|
|
1653
1782
|
|
|
1654
1783
|
|
|
@@ -1692,6 +1821,10 @@ def cert_renew(
|
|
|
1692
1821
|
staging: Annotated[
|
|
1693
1822
|
bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
|
|
1694
1823
|
] = False,
|
|
1824
|
+
verbose: Annotated[
|
|
1825
|
+
bool,
|
|
1826
|
+
typer.Option("--verbose", "-v", help="Show detailed ACME progress."),
|
|
1827
|
+
] = False,
|
|
1695
1828
|
) -> None:
|
|
1696
1829
|
"""Re-issue certificates that expire within 30 days; skip the rest."""
|
|
1697
1830
|
|
|
@@ -1720,6 +1853,7 @@ def cert_renew(
|
|
|
1720
1853
|
key_type=key_type.value,
|
|
1721
1854
|
staging=staging,
|
|
1722
1855
|
out_dir=out_dir,
|
|
1856
|
+
verbose=verbose,
|
|
1723
1857
|
)
|
|
1724
1858
|
|
|
1725
1859
|
|
|
@@ -1762,11 +1896,26 @@ def cert_export(
|
|
|
1762
1896
|
Path | None,
|
|
1763
1897
|
typer.Option(
|
|
1764
1898
|
"--out",
|
|
1765
|
-
help="Write the bundle here (0600); omit to print to stdout.",
|
|
1899
|
+
help="Write the bundle here (0600); omit to print to stdout (pem only).",
|
|
1900
|
+
),
|
|
1901
|
+
] = None,
|
|
1902
|
+
fmt: Annotated[
|
|
1903
|
+
ExportFormat,
|
|
1904
|
+
typer.Option("--format", help="Bundle format: pem or pfx (PKCS#12)."),
|
|
1905
|
+
] = ExportFormat.pem,
|
|
1906
|
+
passphrase: Annotated[
|
|
1907
|
+
str | None,
|
|
1908
|
+
typer.Option(
|
|
1909
|
+
"--passphrase",
|
|
1910
|
+
"--password",
|
|
1911
|
+
help="Encrypt the pfx with this passphrase (default: none).",
|
|
1766
1912
|
),
|
|
1767
1913
|
] = None,
|
|
1768
1914
|
) -> None:
|
|
1769
|
-
"""Re-export an issued certificate as one
|
|
1915
|
+
"""Re-export an issued certificate as one bundle (key + cert + intermediate).
|
|
1916
|
+
|
|
1917
|
+
The default PEM bundle is three blocks; --format pfx writes a PKCS#12 file.
|
|
1918
|
+
"""
|
|
1770
1919
|
|
|
1771
1920
|
cert_dir = _cert_dir(domain)
|
|
1772
1921
|
privkey = cert_dir / "privkey.pem"
|
|
@@ -1780,6 +1929,23 @@ def cert_export(
|
|
|
1780
1929
|
key_text = privkey.read_text()
|
|
1781
1930
|
leaf_text = leaf.read_text()
|
|
1782
1931
|
chain_text = chain.read_text() if chain.is_file() else ""
|
|
1932
|
+
|
|
1933
|
+
if fmt is ExportFormat.pfx:
|
|
1934
|
+
if out is None:
|
|
1935
|
+
raise CliError("--out is required for pfx (it is a binary file)")
|
|
1936
|
+
_export_pkcs12(
|
|
1937
|
+
out,
|
|
1938
|
+
privkey=key_text,
|
|
1939
|
+
leaf=leaf_text,
|
|
1940
|
+
chain=chain_text,
|
|
1941
|
+
name=domain,
|
|
1942
|
+
passphrase=passphrase,
|
|
1943
|
+
)
|
|
1944
|
+
err_console.print(f"exported pkcs12 bundle to {out}")
|
|
1945
|
+
return
|
|
1946
|
+
|
|
1947
|
+
if passphrase is not None:
|
|
1948
|
+
raise CliError("--passphrase only applies to --format pfx")
|
|
1783
1949
|
if out is not None:
|
|
1784
1950
|
_export_combined_pem(out, privkey=key_text, leaf=leaf_text, chain=chain_text)
|
|
1785
1951
|
err_console.print(f"exported combined pem to {out}")
|
|
@@ -11,7 +11,10 @@ from dncli import app
|
|
|
11
11
|
runner = CliRunner()
|
|
12
12
|
|
|
13
13
|
LEAF = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
|
|
14
|
-
|
|
14
|
+
INTERMEDIATE = "-----BEGIN CERTIFICATE-----\nINTER\n-----END CERTIFICATE-----\n"
|
|
15
|
+
ROOT = "-----BEGIN CERTIFICATE-----\nROOT\n-----END CERTIFICATE-----\n"
|
|
16
|
+
# Let's Encrypt returns a multi-cert chain (intermediate + cross-signs/roots).
|
|
17
|
+
CHAIN = INTERMEDIATE + ROOT
|
|
15
18
|
FULLCHAIN = LEAF + CHAIN
|
|
16
19
|
KEY = b"-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
|
|
17
20
|
|
|
@@ -82,7 +85,7 @@ def _write_cert(cert_dir, *, days, with_key_chain=False):
|
|
|
82
85
|
serialization.NoEncryption(),
|
|
83
86
|
)
|
|
84
87
|
)
|
|
85
|
-
(cert_dir / "chain.pem").write_text(
|
|
88
|
+
(cert_dir / "chain.pem").write_text(INTERMEDIATE + ROOT)
|
|
86
89
|
return cert
|
|
87
90
|
|
|
88
91
|
|
|
@@ -193,11 +196,15 @@ def test_issue_out_exports_combined_pem(
|
|
|
193
196
|
out = tmp_path / "bundle.pem"
|
|
194
197
|
result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
|
|
195
198
|
assert result.exit_code == 0, result.output
|
|
196
|
-
# the default per-domain dir is still written...
|
|
199
|
+
# the default per-domain dir is still written (with the full chain)...
|
|
197
200
|
default_dir = dncli.config_dir() / "certs" / "example.com"
|
|
198
201
|
assert (default_dir / "fullchain.pem").read_text() == FULLCHAIN
|
|
199
|
-
|
|
200
|
-
|
|
202
|
+
assert (default_dir / "chain.pem").read_text() == CHAIN
|
|
203
|
+
# ...and the combined file is exactly key + leaf + first intermediate,
|
|
204
|
+
# i.e. the rest of the chain (roots) is dropped.
|
|
205
|
+
assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
|
|
206
|
+
assert ROOT not in out.read_text()
|
|
207
|
+
assert out.read_text().count("BEGIN CERTIFICATE") == 2
|
|
201
208
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
202
209
|
|
|
203
210
|
|
|
@@ -210,10 +217,23 @@ def test_our_zone_for_matches_longest_suffix():
|
|
|
210
217
|
assert dncli._our_zone_for("other.org", zones) is None
|
|
211
218
|
|
|
212
219
|
|
|
213
|
-
def
|
|
220
|
+
def test_first_cert_takes_only_the_intermediate():
|
|
221
|
+
assert dncli._first_cert(INTERMEDIATE + ROOT) == INTERMEDIATE
|
|
222
|
+
assert dncli._first_cert("no cert here") == ""
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_combined_pem_is_three_blocks(tmp_path):
|
|
226
|
+
bundle = dncli._combined_pem(KEY.decode(), LEAF, INTERMEDIATE + ROOT)
|
|
227
|
+
assert bundle == KEY.decode() + LEAF + INTERMEDIATE
|
|
228
|
+
assert bundle.count("BEGIN CERTIFICATE") == 2 # leaf + one intermediate
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def test_export_combined_pem_mode(tmp_path):
|
|
214
232
|
out = tmp_path / "bundle.pem"
|
|
215
|
-
dncli._export_combined_pem(
|
|
216
|
-
|
|
233
|
+
dncli._export_combined_pem(
|
|
234
|
+
out, privkey=KEY.decode(), leaf=LEAF, chain=INTERMEDIATE + ROOT
|
|
235
|
+
)
|
|
236
|
+
assert out.read_text() == KEY.decode() + LEAF + INTERMEDIATE
|
|
217
237
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
218
238
|
|
|
219
239
|
|
|
@@ -389,12 +409,11 @@ def test_cert_export_to_file(configured, isolated_config, tmp_path):
|
|
|
389
409
|
result = runner.invoke(app, ["cert", "export", "example.com", "--out", str(out)])
|
|
390
410
|
assert result.exit_code == 0, result.output
|
|
391
411
|
text = out.read_text()
|
|
392
|
-
# order: private key, then leaf certificate, then intermediate
|
|
393
|
-
assert (
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
)
|
|
412
|
+
# order: private key, then leaf certificate, then the one intermediate
|
|
413
|
+
assert text.index("EC PRIVATE KEY") < text.index("INTER")
|
|
414
|
+
# only the first intermediate (not the root), and exactly three blocks
|
|
415
|
+
assert "ROOT" not in text
|
|
416
|
+
assert text.count("BEGIN CERTIFICATE") == 2
|
|
398
417
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
399
418
|
|
|
400
419
|
|
|
@@ -405,10 +424,127 @@ def test_cert_export_to_stdout(configured, isolated_config):
|
|
|
405
424
|
result = runner.invoke(app, ["cert", "export", "example.com"])
|
|
406
425
|
assert result.exit_code == 0, result.output
|
|
407
426
|
assert "BEGIN CERTIFICATE" in result.stdout
|
|
408
|
-
assert "
|
|
427
|
+
assert "INTER" in result.stdout
|
|
428
|
+
assert "ROOT" not in result.stdout
|
|
409
429
|
|
|
410
430
|
|
|
411
431
|
def test_cert_export_missing(isolated_config):
|
|
412
432
|
result = runner.invoke(app, ["cert", "export", "nope.com"])
|
|
413
433
|
assert result.exit_code != 0
|
|
414
434
|
assert "no certificate" in result.output
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def test_cert_export_passphrase_rejected_for_pem(configured, isolated_config, tmp_path):
|
|
438
|
+
_write_cert(
|
|
439
|
+
dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
|
|
440
|
+
)
|
|
441
|
+
result = runner.invoke(
|
|
442
|
+
app,
|
|
443
|
+
[
|
|
444
|
+
"cert",
|
|
445
|
+
"export",
|
|
446
|
+
"example.com",
|
|
447
|
+
"--passphrase",
|
|
448
|
+
"x",
|
|
449
|
+
"--out",
|
|
450
|
+
str(tmp_path / "b.pem"),
|
|
451
|
+
],
|
|
452
|
+
)
|
|
453
|
+
assert result.exit_code != 0
|
|
454
|
+
assert "pfx" in result.output
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _write_real_chain(cert_dir):
|
|
458
|
+
"""Write a real key + leaf + (intermediate, root) chain so PKCS#12 export,
|
|
459
|
+
which actually parses the certificates, has valid input."""
|
|
460
|
+
|
|
461
|
+
import datetime
|
|
462
|
+
|
|
463
|
+
from cryptography import x509
|
|
464
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
465
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
466
|
+
from cryptography.x509.oid import NameOID
|
|
467
|
+
|
|
468
|
+
def make(cn):
|
|
469
|
+
key = ec.generate_private_key(ec.SECP256R1())
|
|
470
|
+
now = datetime.datetime.now(datetime.timezone.utc)
|
|
471
|
+
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, cn)])
|
|
472
|
+
cert = (
|
|
473
|
+
x509.CertificateBuilder()
|
|
474
|
+
.subject_name(name)
|
|
475
|
+
.issuer_name(name)
|
|
476
|
+
.public_key(key.public_key())
|
|
477
|
+
.serial_number(x509.random_serial_number())
|
|
478
|
+
.not_valid_before(now - datetime.timedelta(days=1))
|
|
479
|
+
.not_valid_after(now + datetime.timedelta(days=30))
|
|
480
|
+
.sign(key, hashes.SHA256())
|
|
481
|
+
)
|
|
482
|
+
return key, cert
|
|
483
|
+
|
|
484
|
+
leaf_key, leaf = make("example.com")
|
|
485
|
+
_, inter = make("Intermediate CA")
|
|
486
|
+
_, root = make("Root CA")
|
|
487
|
+
pem = serialization.Encoding.PEM
|
|
488
|
+
cert_dir.mkdir(parents=True, exist_ok=True)
|
|
489
|
+
(cert_dir / "cert.pem").write_bytes(leaf.public_bytes(pem))
|
|
490
|
+
(cert_dir / "privkey.pem").write_bytes(
|
|
491
|
+
leaf_key.private_bytes(
|
|
492
|
+
pem,
|
|
493
|
+
serialization.PrivateFormat.TraditionalOpenSSL,
|
|
494
|
+
serialization.NoEncryption(),
|
|
495
|
+
)
|
|
496
|
+
)
|
|
497
|
+
(cert_dir / "chain.pem").write_bytes(
|
|
498
|
+
inter.public_bytes(pem) + root.public_bytes(pem)
|
|
499
|
+
)
|
|
500
|
+
return leaf, inter, root
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def test_cert_export_pfx_no_passphrase(configured, isolated_config, tmp_path):
|
|
504
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
505
|
+
|
|
506
|
+
leaf, inter, root = _write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
507
|
+
out = tmp_path / "bundle.pfx"
|
|
508
|
+
result = runner.invoke(
|
|
509
|
+
app, ["cert", "export", "example.com", "--format", "pfx", "--out", str(out)]
|
|
510
|
+
)
|
|
511
|
+
assert result.exit_code == 0, result.output
|
|
512
|
+
key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), None)
|
|
513
|
+
assert key is not None
|
|
514
|
+
assert cert.subject == leaf.subject
|
|
515
|
+
# only the issuing intermediate, not the root
|
|
516
|
+
assert [c.subject for c in cas] == [inter.subject]
|
|
517
|
+
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
|
518
|
+
|
|
519
|
+
|
|
520
|
+
def test_cert_export_pfx_with_passphrase(configured, isolated_config, tmp_path):
|
|
521
|
+
from cryptography.hazmat.primitives.serialization import pkcs12
|
|
522
|
+
|
|
523
|
+
_write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
524
|
+
out = tmp_path / "bundle.pfx"
|
|
525
|
+
result = runner.invoke(
|
|
526
|
+
app,
|
|
527
|
+
[
|
|
528
|
+
"cert",
|
|
529
|
+
"export",
|
|
530
|
+
"example.com",
|
|
531
|
+
"--format",
|
|
532
|
+
"pfx",
|
|
533
|
+
"--out",
|
|
534
|
+
str(out),
|
|
535
|
+
"--password",
|
|
536
|
+
"s3cret",
|
|
537
|
+
],
|
|
538
|
+
)
|
|
539
|
+
assert result.exit_code == 0, result.output
|
|
540
|
+
with pytest.raises(ValueError): # wrong/no password must fail
|
|
541
|
+
pkcs12.load_key_and_certificates(out.read_bytes(), None)
|
|
542
|
+
key, cert, cas = pkcs12.load_key_and_certificates(out.read_bytes(), b"s3cret")
|
|
543
|
+
assert key is not None
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_cert_export_pfx_requires_out(configured, isolated_config):
|
|
547
|
+
_write_real_chain(dncli.config_dir() / "certs" / "example.com")
|
|
548
|
+
result = runner.invoke(app, ["cert", "export", "example.com", "--format", "pfx"])
|
|
549
|
+
assert result.exit_code != 0
|
|
550
|
+
assert "out" in result.output.lower()
|
|
@@ -167,11 +167,42 @@ def test_cell():
|
|
|
167
167
|
assert _cell(True) == "yes"
|
|
168
168
|
assert _cell(False) == "no"
|
|
169
169
|
assert _cell(42) == "42"
|
|
170
|
-
|
|
171
|
-
|
|
170
|
+
# a bare object renders as a compact key=value summary
|
|
171
|
+
assert _cell({"a": 1}) == "a=1"
|
|
172
|
+
# scalar lists read as comma-separated values
|
|
172
173
|
assert _cell(["instance_7:80", "instance_8:80"]) == "instance_7:80, instance_8:80"
|
|
173
174
|
assert _cell([]) == ""
|
|
174
|
-
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_cell_list_of_objects_is_summarized():
|
|
178
|
+
# one readable line per item, not a raw JSON blob
|
|
179
|
+
ips = [
|
|
180
|
+
{"type": "v4", "address": "185.223.163.238"},
|
|
181
|
+
{"type": "v6", "address": "2a10:8c80:0:138::1"},
|
|
182
|
+
]
|
|
183
|
+
assert _cell(ips) == (
|
|
184
|
+
"type=v4 address=185.223.163.238\ntype=v6 address=2a10:8c80:0:138::1"
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def test_cell_summary_drops_empty_and_counts_nested():
|
|
189
|
+
item = {
|
|
190
|
+
"id": 7,
|
|
191
|
+
"image": "geleijn-it/website",
|
|
192
|
+
"ended_at": None,
|
|
193
|
+
"has_pending_changes": 0,
|
|
194
|
+
"volumes": [{"id": 10}],
|
|
195
|
+
"meta": {},
|
|
196
|
+
}
|
|
197
|
+
# None dropped, 0 kept, non-empty nested list shown as a count, empty dict dropped
|
|
198
|
+
assert (
|
|
199
|
+
_cell([item])
|
|
200
|
+
== "id=7 image=geleijn-it/website has_pending_changes=0 volumes=[1]"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_cell_mixed_list_stays_json():
|
|
205
|
+
assert _cell([{"a": 1}, "scalar"]) == '[{"a": 1}, "scalar"]'
|
|
175
206
|
|
|
176
207
|
|
|
177
208
|
def test_flatten_row_merges_nested_objects():
|
|
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
|