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.
Files changed (20) hide show
  1. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/PKG-INFO +14 -6
  2. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/README.md +13 -5
  3. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/PKG-INFO +14 -6
  4. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/dncli.py +179 -13
  5. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/pyproject.toml +1 -1
  6. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_cert.py +151 -15
  7. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_helpers.py +34 -3
  8. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/LICENSE +0 -0
  9. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  10. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  11. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/entry_points.txt +0 -0
  12. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/requires.txt +0 -0
  13. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/devnomads_cli.egg-info/top_level.txt +0 -0
  14. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/setup.cfg +0 -0
  15. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_config.py +0 -0
  17. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_generate.py +0 -0
  18. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.5.0 → devnomads_cli-0.5.2}/tests/test_hook.py +0 -0
  20. {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.0
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 (key,
145
- then certificate, then intermediate, in that order).
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 single PEM
154
- bundle without re-issuing:
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 # omit --out to print
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 (key,
129
- then certificate, then intermediate, in that order).
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 single PEM
138
- bundle without re-issuing:
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 # omit --out to print
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.0
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 (key,
145
- then certificate, then intermediate, in that order).
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 single PEM
154
- bundle without re-issuing:
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 # omit --out to print
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 _write_private(path: Path, content: str) -> None:
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, "w") as fh:
115
- fh.write(content)
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, list) and not any(
215
- isinstance(item, (dict, list)) for item in value
216
- ):
217
- return ", ".join(str(item) for item in value)
218
- if isinstance(value, (dict, list)):
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, then intermediate(s)."""
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
- _assert_dns_authority(state, [domain, *sans])
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 PEM (key + cert + intermediate)."""
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}")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.5.0"
3
+ version = "0.5.2"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -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
- CHAIN = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----\n"
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("CHAINPEM\n")
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
- # ...and the combined file has key, then leaf, then intermediate, in order.
200
- assert out.read_text() == KEY.decode() + LEAF + CHAIN
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 test_export_combined_pem_order_and_mode(tmp_path):
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(out, privkey="KEY\n", leaf="LEAF\n", chain="CHAIN\n")
216
- assert out.read_text() == "KEY\nLEAF\nCHAIN\n"
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
- text.index("EC PRIVATE KEY")
395
- < text.index("CERTIFICATE")
396
- < text.index("CHAINPEM")
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 "CHAINPEM" in result.stdout
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
- assert _cell({"a": 1}) == '{"a": 1}'
171
- # scalar lists read as comma-separated values, nested ones stay JSON
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
- assert _cell([{"a": 1}]) == '[{"a": 1}]'
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