devnomads-cli 0.4.1__tar.gz → 0.5.0__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.4.1 → devnomads_cli-0.5.0}/PKG-INFO +23 -6
  2. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/README.md +22 -5
  3. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/PKG-INFO +23 -6
  4. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/dncli.py +223 -59
  5. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/pyproject.toml +1 -1
  6. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_cert.py +190 -30
  7. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/LICENSE +0 -0
  8. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  9. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  10. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  11. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/requires.txt +0 -0
  12. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  13. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/setup.cfg +0 -0
  14. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_cli.py +0 -0
  15. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_config.py +0 -0
  16. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_generate.py +0 -0
  17. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_generated_cli.py +0 -0
  18. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_helpers.py +0 -0
  19. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_hook.py +0 -0
  20. {devnomads_cli-0.4.1 → devnomads_cli-0.5.0}/tests/test_transfer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -126,21 +126,38 @@ dncli services list | jq -r '.[].entity'
126
126
 
127
127
  ## Certificates
128
128
 
129
- `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
129
+ `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
130
130
 
131
131
  ```sh
132
132
  dncli cert issue example.com -d www.example.com -d "*.example.com"
133
133
  ```
134
134
 
135
135
  The first argument is the primary domain (the certificate CN); add
136
- extra names (SANs) by repeating `--san`/`-d`. The certificate is
137
- written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
138
- `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
139
- location with `--out`.
136
+ extra names (SANs) by repeating `--san`/`-d`. Every name must live in
137
+ one of your DevNomads DNS zones, and that zone must be delegated to the
138
+ DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
139
+ `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
140
+ DNS-01 challenge records would not be visible to the CA.
141
+
142
+ The certificate is always written to `~/.config/dncli/certs/<domain>/`
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).
140
146
 
141
147
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
142
148
  (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
143
149
 
150
+ Re-running `cert issue` is a no-op while the existing certificate is
151
+ still valid for more than 21 days; pass `--force` to re-issue anyway.
152
+
153
+ List what you have issued and re-export any of them as a single PEM
154
+ bundle without re-issuing:
155
+
156
+ ```sh
157
+ dncli cert list
158
+ dncli cert export example.com --out bundle.pem # omit --out to print
159
+ ```
160
+
144
161
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
145
162
 
146
163
  ```sh
@@ -110,21 +110,38 @@ dncli services list | jq -r '.[].entity'
110
110
 
111
111
  ## Certificates
112
112
 
113
- `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
113
+ `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
114
114
 
115
115
  ```sh
116
116
  dncli cert issue example.com -d www.example.com -d "*.example.com"
117
117
  ```
118
118
 
119
119
  The first argument is the primary domain (the certificate CN); add
120
- extra names (SANs) by repeating `--san`/`-d`. The certificate is
121
- written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
122
- `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
123
- location with `--out`.
120
+ extra names (SANs) by repeating `--san`/`-d`. Every name must live in
121
+ one of your DevNomads DNS zones, and that zone must be delegated to the
122
+ DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
123
+ `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
124
+ DNS-01 challenge records would not be visible to the CA.
125
+
126
+ The certificate is always written to `~/.config/dncli/certs/<domain>/`
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).
124
130
 
125
131
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
126
132
  (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
127
133
 
134
+ Re-running `cert issue` is a no-op while the existing certificate is
135
+ still valid for more than 21 days; pass `--force` to re-issue anyway.
136
+
137
+ List what you have issued and re-export any of them as a single PEM
138
+ bundle without re-issuing:
139
+
140
+ ```sh
141
+ dncli cert list
142
+ dncli cert export example.com --out bundle.pem # omit --out to print
143
+ ```
144
+
128
145
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
129
146
 
130
147
  ```sh
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.4.1
3
+ Version: 0.5.0
4
4
  Summary: Manage your DevNomads services from the command line
5
5
  Author-email: DevNomads <support@devnomads.nl>
6
6
  License: MIT
@@ -126,21 +126,38 @@ dncli services list | jq -r '.[].entity'
126
126
 
127
127
  ## Certificates
128
128
 
129
- `dncli` issues Let's Encrypt certificates over DNS-01 and HTTP-01:
129
+ `dncli` issues Let's Encrypt certificates using the DNS-01 challenge:
130
130
 
131
131
  ```sh
132
132
  dncli cert issue example.com -d www.example.com -d "*.example.com"
133
133
  ```
134
134
 
135
135
  The first argument is the primary domain (the certificate CN); add
136
- extra names (SANs) by repeating `--san`/`-d`. The certificate is
137
- written to `~/.config/dncli/certs/<domain>/` as `cert.pem`,
138
- `fullchain.pem`, `chain.pem`, and `privkey.pem` (0600); override the
139
- location with `--out`.
136
+ extra names (SANs) by repeating `--san`/`-d`. Every name must live in
137
+ one of your DevNomads DNS zones, and that zone must be delegated to the
138
+ DevNomads nameservers (`one.dns.infrapod.nl`, `two.dns.infrapod.nl`,
139
+ `three.dns.infrapod.eu`) - otherwise issuance is refused, since the
140
+ DNS-01 challenge records would not be visible to the CA.
141
+
142
+ The certificate is always written to `~/.config/dncli/certs/<domain>/`
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).
140
146
 
141
147
  Keys are ECDSA P-384 by default. Pick another with `--key-type`
142
148
  (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
143
149
 
150
+ Re-running `cert issue` is a no-op while the existing certificate is
151
+ still valid for more than 21 days; pass `--force` to re-issue anyway.
152
+
153
+ List what you have issued and re-export any of them as a single PEM
154
+ bundle without re-issuing:
155
+
156
+ ```sh
157
+ dncli cert list
158
+ dncli cert export example.com --out bundle.pem # omit --out to print
159
+ ```
160
+
144
161
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
145
162
 
146
163
  ```sh
@@ -1360,6 +1360,18 @@ app.add_typer(cert_app, name="cert")
1360
1360
 
1361
1361
  LE_STAGING_DIRECTORY = "https://acme-staging-v02.api.letsencrypt.org/directory"
1362
1362
  CERT_RENEW_WINDOW_DAYS = 30
1363
+ # `cert issue` skips a domain whose current certificate is still valid for
1364
+ # longer than this, unless --force is given.
1365
+ CERT_REISSUE_WINDOW_DAYS = 21
1366
+
1367
+ # A domain may only get a certificate if it is delegated to these exact
1368
+ # nameservers, i.e. the world resolves it through DevNomads and the DNS-01
1369
+ # challenge records we write are actually visible to the CA.
1370
+ REQUIRED_NAMESERVERS = (
1371
+ "one.dns.infrapod.nl",
1372
+ "two.dns.infrapod.nl",
1373
+ "three.dns.infrapod.eu",
1374
+ )
1363
1375
 
1364
1376
 
1365
1377
  class KeyType(str, Enum):
@@ -1386,14 +1398,67 @@ def _load_acme() -> Any:
1386
1398
  return acme
1387
1399
 
1388
1400
 
1389
- def _cert_out_dir(out: Path | None, domain: str) -> Path:
1390
- return out if out is not None else config_dir() / "certs" / domain
1401
+ def _cert_dir(domain: str) -> Path:
1402
+ return config_dir() / "certs" / domain
1391
1403
 
1392
1404
 
1393
1405
  def _account_key_path() -> Path:
1394
1406
  return config_dir() / "acme" / "account.pem"
1395
1407
 
1396
1408
 
1409
+ def _our_zone_for(name: str, zone_names: set[str]) -> str | None:
1410
+ """The longest of our zones that the cert identifier ``name`` sits in,
1411
+ or None. A leading ``*.`` wildcard is matched against its base zone."""
1412
+
1413
+ host = name.lstrip("*.").rstrip(".").lower()
1414
+ best: str | None = None
1415
+ for zone in zone_names:
1416
+ if host == zone or host.endswith(f".{zone}"):
1417
+ if best is None or len(zone) > len(best):
1418
+ best = zone
1419
+ return best
1420
+
1421
+
1422
+ def _zone_nameservers(zone: str) -> set[str]:
1423
+ """Authoritative NS names for ``zone`` from public DNS, lowercased and
1424
+ without the trailing dot."""
1425
+
1426
+ import dns.exception
1427
+ import dns.resolver
1428
+
1429
+ try:
1430
+ answers = dns.resolver.resolve(zone, "NS")
1431
+ except dns.exception.DNSException as exc:
1432
+ raise CliError(f"could not look up nameservers for {zone}: {exc}") from exc
1433
+ return {str(rr.target).rstrip(".").lower() for rr in answers}
1434
+
1435
+
1436
+ def _assert_dns_authority(state: AppState, identifiers: list[str]) -> None:
1437
+ """Refuse issuance unless every identifier lives in one of our DevNomads
1438
+ zones and that zone is delegated to the required nameservers."""
1439
+
1440
+ zones = get_client(state).request("GET", "/services/dns/zones")
1441
+ zone_names = {str(z.get("name", "")).rstrip(".").lower() for z in (zones or [])}
1442
+ needed: set[str] = set()
1443
+ for ident in identifiers:
1444
+ zone = _our_zone_for(ident, zone_names)
1445
+ if zone is None:
1446
+ raise CliError(
1447
+ f"'{ident}' is not in any of your DevNomads DNS zones; add the "
1448
+ "zone first (dncli dns zones list shows your zones)"
1449
+ )
1450
+ needed.add(zone)
1451
+ required = set(REQUIRED_NAMESERVERS)
1452
+ for zone in sorted(needed):
1453
+ ns = _zone_nameservers(zone)
1454
+ if ns != required:
1455
+ raise CliError(
1456
+ f"{zone} is not delegated to DevNomads: its nameservers are "
1457
+ f"[{', '.join(sorted(ns)) or 'none'}] but must be exactly "
1458
+ f"[{', '.join(REQUIRED_NAMESERVERS)}]"
1459
+ )
1460
+
1461
+
1397
1462
  def _write_cert_files(
1398
1463
  out_dir: Path,
1399
1464
  *,
@@ -1412,22 +1477,70 @@ def _write_cert_files(
1412
1477
  (out_dir / name).write_text(content)
1413
1478
 
1414
1479
 
1480
+ def _combined_pem(privkey: str, leaf: str, chain: str) -> str:
1481
+ """One PEM bundle: key, then certificate, then intermediate(s)."""
1482
+
1483
+ parts = [privkey, leaf, chain]
1484
+ return "".join(p if p.endswith("\n") else p + "\n" for p in parts if p)
1485
+
1486
+
1487
+ def _export_combined_pem(path: Path, *, privkey: str, leaf: str, chain: str) -> None:
1488
+ """Write the combined PEM at 0600, since it embeds the private key."""
1489
+
1490
+ _write_private(path, _combined_pem(privkey, leaf, chain))
1491
+
1492
+
1493
+ def _cert_info(cert_path: Path) -> dict[str, Any]:
1494
+ """Summarize a stored leaf certificate for `cert list`."""
1495
+
1496
+ from datetime import datetime, timezone
1497
+
1498
+ from cryptography import x509
1499
+ from cryptography.hazmat.primitives.asymmetric import ec, rsa
1500
+
1501
+ cert = x509.load_pem_x509_certificate(cert_path.read_bytes())
1502
+ try:
1503
+ not_after = cert.not_valid_after_utc
1504
+ except AttributeError: # cryptography < 42 fallback
1505
+ not_after = cert.not_valid_after.replace(tzinfo=timezone.utc)
1506
+ days_left = (not_after - datetime.now(timezone.utc)).days
1507
+ try:
1508
+ sans = cert.extensions.get_extension_for_class(
1509
+ x509.SubjectAlternativeName
1510
+ ).value.get_values_for_type(x509.DNSName)
1511
+ except x509.ExtensionNotFound:
1512
+ sans = []
1513
+ pub = cert.public_key()
1514
+ if isinstance(pub, ec.EllipticCurvePublicKey):
1515
+ key_type = f"ecdsa{pub.curve.key_size}"
1516
+ elif isinstance(pub, rsa.RSAPublicKey):
1517
+ key_type = f"rsa{pub.key_size}"
1518
+ else:
1519
+ key_type = type(pub).__name__
1520
+ return {
1521
+ "expires": not_after.strftime("%Y-%m-%d"),
1522
+ "days_left": days_left,
1523
+ "key_type": key_type,
1524
+ "sans": sans,
1525
+ }
1526
+
1527
+
1415
1528
  def _issue_certificate(
1416
1529
  state: AppState,
1417
1530
  domain: str,
1418
1531
  *,
1419
1532
  sans: list[str],
1420
- use_http01: bool,
1421
- webroot: str | None,
1422
- standalone: bool,
1423
1533
  email: str | None,
1424
1534
  key_type: str,
1425
1535
  staging: bool,
1426
1536
  out_dir: Path,
1537
+ export_path: Path | None = None,
1427
1538
  ) -> None:
1428
- """Obtain a certificate for ``domain`` and write it to ``out_dir``."""
1539
+ """Obtain a DNS-01 certificate for ``domain`` and write it to ``out_dir``;
1540
+ optionally also export a single combined PEM to ``export_path``."""
1429
1541
 
1430
1542
  acme = _load_acme()
1543
+ _assert_dns_authority(state, [domain, *sans])
1431
1544
 
1432
1545
  directory_url = LE_STAGING_DIRECTORY if staging else acme.DEFAULT_DIRECTORY_URL
1433
1546
  client = acme.AcmeClient(
@@ -1440,46 +1553,37 @@ def _issue_certificate(
1440
1553
  except acme.AcmeError as exc:
1441
1554
  raise CliError(str(exc)) from exc
1442
1555
 
1443
- dns_provider = None
1444
- http01_solver = None
1445
- if use_http01:
1446
- if webroot:
1447
- http01_solver = acme.WebrootSolver(webroot)
1448
- else:
1449
- http01_solver = acme.StandaloneSolver()
1450
- challenge = "http-01"
1451
- else:
1452
- dns_provider = acme.DevNomadsDnsProvider(Dns(get_client(state).api))
1453
- challenge = "dns-01"
1454
-
1556
+ dns_provider = acme.DevNomadsDnsProvider(Dns(get_client(state).api))
1455
1557
  err_console.print(
1456
- f"issuing {challenge} certificate for {domain}"
1558
+ f"issuing dns-01 certificate for {domain}"
1457
1559
  + (f" (+{len(sans)} SAN(s))" if sans else "")
1458
1560
  + (" [staging]" if staging else "")
1459
1561
  )
1460
1562
  try:
1461
- with http01_solver or nullcontext() as solver:
1462
- leaf, fullchain, chain, key_pem = client.obtain_certificate(
1463
- domain,
1464
- challenge,
1465
- domain_key,
1466
- sans=sans or None,
1467
- dns_provider=dns_provider,
1468
- http01_solver=solver if use_http01 else None,
1469
- )
1563
+ leaf, fullchain, chain, key_pem = client.obtain_certificate(
1564
+ domain,
1565
+ "dns-01",
1566
+ domain_key,
1567
+ sans=sans or None,
1568
+ dns_provider=dns_provider,
1569
+ )
1470
1570
  except acme.AcmeError as exc:
1471
1571
  raise CliError(str(exc)) from exc
1472
1572
  except DevNomadsError as exc:
1473
1573
  raise CliError(str(exc)) from exc
1474
1574
 
1575
+ key_text = key_pem.decode() if isinstance(key_pem, bytes) else key_pem
1475
1576
  _write_cert_files(
1476
1577
  out_dir,
1477
- privkey=key_pem.decode() if isinstance(key_pem, bytes) else key_pem,
1578
+ privkey=key_text,
1478
1579
  cert=leaf,
1479
1580
  fullchain=fullchain,
1480
1581
  chain=chain,
1481
1582
  )
1482
1583
  err_console.print(f"wrote certificate to {out_dir}")
1584
+ if export_path is not None:
1585
+ _export_combined_pem(export_path, privkey=key_text, leaf=leaf, chain=chain)
1586
+ err_console.print(f"exported combined pem to {export_path}")
1483
1587
 
1484
1588
 
1485
1589
  @cert_app.command("issue")
@@ -1490,20 +1594,6 @@ def cert_issue(
1490
1594
  list[str] | None,
1491
1595
  typer.Option("--san", "-d", help="Additional SAN; repeat for more."),
1492
1596
  ] = None,
1493
- dns_01: Annotated[
1494
- bool, typer.Option("--dns-01", help="Use the DNS-01 challenge (default).")
1495
- ] = False,
1496
- http_01: Annotated[
1497
- bool, typer.Option("--http-01", help="Use the HTTP-01 challenge.")
1498
- ] = False,
1499
- webroot: Annotated[
1500
- str | None,
1501
- typer.Option("--webroot", help="HTTP-01 webroot directory to write into."),
1502
- ] = None,
1503
- standalone: Annotated[
1504
- bool,
1505
- typer.Option("--standalone", help="HTTP-01 via a built-in server on :80."),
1506
- ] = False,
1507
1597
  email: Annotated[
1508
1598
  str | None, typer.Option("--email", help="ACME account contact email.")
1509
1599
  ] = None,
@@ -1518,34 +1608,47 @@ def cert_issue(
1518
1608
  staging: Annotated[
1519
1609
  bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1520
1610
  ] = False,
1611
+ force: Annotated[
1612
+ bool,
1613
+ typer.Option(
1614
+ "--force",
1615
+ "-f",
1616
+ help="Re-issue even if the current certificate is still valid.",
1617
+ ),
1618
+ ] = False,
1521
1619
  out: Annotated[
1522
1620
  Path | None,
1523
1621
  typer.Option(
1524
- "--out", help="Output directory (default <config>/certs/<domain>)."
1622
+ "--out",
1623
+ help="Also export a single PEM bundle (key + certificate + "
1624
+ "intermediate) to this file.",
1525
1625
  ),
1526
1626
  ] = None,
1527
1627
  ) -> None:
1528
- """Issue a TLS certificate for a domain via ACME (Let's Encrypt)."""
1628
+ """Issue a TLS certificate for a domain via ACME (Let's Encrypt, DNS-01)."""
1529
1629
 
1530
1630
  state = state_from(ctx)
1531
- if dns_01 and http_01:
1532
- raise CliError("choose only one of --dns-01 / --http-01")
1533
- if webroot and standalone:
1534
- raise CliError("choose only one of --webroot / --standalone")
1535
- use_http01 = http_01 or bool(webroot) or standalone
1536
- if dns_01 and use_http01:
1537
- raise CliError("--dns-01 cannot be combined with HTTP-01 options")
1631
+ out_dir = _cert_dir(domain)
1632
+ cert_path = out_dir / "cert.pem"
1633
+ if (
1634
+ not force
1635
+ and cert_path.is_file()
1636
+ and not _cert_expires_within(cert_path, CERT_REISSUE_WINDOW_DAYS)
1637
+ ):
1638
+ err_console.print(
1639
+ f"{domain}: certificate still valid for more than "
1640
+ f"{CERT_REISSUE_WINDOW_DAYS} days; pass --force to re-issue"
1641
+ )
1642
+ return
1538
1643
  _issue_certificate(
1539
1644
  state,
1540
1645
  domain,
1541
1646
  sans=list(san or []),
1542
- use_http01=use_http01,
1543
- webroot=webroot,
1544
- standalone=standalone,
1545
1647
  email=email,
1546
1648
  key_type=key_type.value,
1547
1649
  staging=staging,
1548
- out_dir=_cert_out_dir(out, domain),
1650
+ out_dir=out_dir,
1651
+ export_path=out,
1549
1652
  )
1550
1653
 
1551
1654
 
@@ -1613,9 +1716,6 @@ def cert_renew(
1613
1716
  state,
1614
1717
  name,
1615
1718
  sans=[],
1616
- use_http01=False,
1617
- webroot=None,
1618
- standalone=False,
1619
1719
  email=email,
1620
1720
  key_type=key_type.value,
1621
1721
  staging=staging,
@@ -1623,6 +1723,70 @@ def cert_renew(
1623
1723
  )
1624
1724
 
1625
1725
 
1726
+ @cert_app.command("list")
1727
+ def cert_list(
1728
+ ctx: typer.Context, sort: SortOption = None, output: OutputOption = None
1729
+ ) -> None:
1730
+ """List the certificates you have issued and when they expire."""
1731
+
1732
+ state = state_from(ctx, output)
1733
+ certs_root = config_dir() / "certs"
1734
+ rows = []
1735
+ for cert_dir in sorted(certs_root.glob("*")):
1736
+ cert_path = cert_dir / "cert.pem"
1737
+ if not cert_path.is_file():
1738
+ continue
1739
+ try:
1740
+ info = _cert_info(cert_path)
1741
+ except (OSError, ValueError):
1742
+ continue
1743
+ rows.append({"domain": cert_dir.name, **info})
1744
+ if not rows:
1745
+ err_console.print("[dim]no certificates issued[/]")
1746
+ return
1747
+ render(
1748
+ state,
1749
+ sort_rows(rows, sort),
1750
+ columns=["domain", "expires", "days_left", "key_type", "sans"],
1751
+ title="Certificates",
1752
+ )
1753
+
1754
+
1755
+ @cert_app.command("export")
1756
+ def cert_export(
1757
+ ctx: typer.Context,
1758
+ domain: Annotated[
1759
+ str, typer.Argument(help="Domain of a previously issued certificate.")
1760
+ ],
1761
+ out: Annotated[
1762
+ Path | None,
1763
+ typer.Option(
1764
+ "--out",
1765
+ help="Write the bundle here (0600); omit to print to stdout.",
1766
+ ),
1767
+ ] = None,
1768
+ ) -> None:
1769
+ """Re-export an issued certificate as one PEM (key + cert + intermediate)."""
1770
+
1771
+ cert_dir = _cert_dir(domain)
1772
+ privkey = cert_dir / "privkey.pem"
1773
+ leaf = cert_dir / "cert.pem"
1774
+ chain = cert_dir / "chain.pem"
1775
+ if not (privkey.is_file() and leaf.is_file()):
1776
+ raise CliError(
1777
+ f"no certificate for {domain} in {cert_dir}; "
1778
+ "issue one with `dncli cert issue`"
1779
+ )
1780
+ key_text = privkey.read_text()
1781
+ leaf_text = leaf.read_text()
1782
+ chain_text = chain.read_text() if chain.is_file() else ""
1783
+ if out is not None:
1784
+ _export_combined_pem(out, privkey=key_text, leaf=leaf_text, chain=chain_text)
1785
+ err_console.print(f"exported combined pem to {out}")
1786
+ else:
1787
+ sys.stdout.write(_combined_pem(key_text, leaf_text, chain_text))
1788
+
1789
+
1626
1790
  # ---------------------------------------------------------------------------
1627
1791
  # Generated commands. tools/generate.py renders one thin command per
1628
1792
  # OpenAPI operation between the markers below, from openapi.json plus
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "devnomads-cli"
3
- version = "0.4.1"
3
+ version = "0.5.0"
4
4
  description = "Manage your DevNomads services from the command line"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -39,15 +39,58 @@ def captured(monkeypatch):
39
39
  monkeypatch.setattr(acme.AcmeClient, "obtain_certificate", fake_obtain)
40
40
  # generate_key touches no network but is slow; keep a tiny stub.
41
41
  monkeypatch.setattr(acme, "generate_key", lambda algo: object())
42
+ # the zone/delegation pre-flight hits the API and public DNS; bypass it
43
+ # here so these tests focus on issuance behaviour (it has its own tests).
44
+ monkeypatch.setattr(dncli, "_assert_dns_authority", lambda state, ids: None)
42
45
  return calls
43
46
 
44
47
 
48
+ def _write_cert(cert_dir, *, days, with_key_chain=False):
49
+ """Write a self-signed leaf (and optionally key+chain) into ``cert_dir``,
50
+ valid until ``days`` from now."""
51
+
52
+ import datetime
53
+
54
+ from cryptography import x509
55
+ from cryptography.hazmat.primitives import hashes, serialization
56
+ from cryptography.hazmat.primitives.asymmetric import ec
57
+ from cryptography.x509.oid import NameOID
58
+
59
+ key = ec.generate_private_key(ec.SECP256R1())
60
+ now = datetime.datetime.now(datetime.timezone.utc)
61
+ name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
62
+ cert = (
63
+ x509.CertificateBuilder()
64
+ .subject_name(name)
65
+ .issuer_name(name)
66
+ .public_key(key.public_key())
67
+ .serial_number(x509.random_serial_number())
68
+ .not_valid_before(now - datetime.timedelta(days=1))
69
+ .not_valid_after(now + datetime.timedelta(days=days))
70
+ .add_extension(
71
+ x509.SubjectAlternativeName([x509.DNSName("example.com")]), critical=False
72
+ )
73
+ .sign(key, hashes.SHA256())
74
+ )
75
+ cert_dir.mkdir(parents=True, exist_ok=True)
76
+ (cert_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
77
+ if with_key_chain:
78
+ (cert_dir / "privkey.pem").write_bytes(
79
+ key.private_bytes(
80
+ serialization.Encoding.PEM,
81
+ serialization.PrivateFormat.TraditionalOpenSSL,
82
+ serialization.NoEncryption(),
83
+ )
84
+ )
85
+ (cert_dir / "chain.pem").write_text("CHAINPEM\n")
86
+ return cert
87
+
88
+
45
89
  def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
46
90
  result = runner.invoke(app, ["cert", "issue", "example.com"])
47
91
  assert result.exit_code == 0, result.output
48
92
  assert captured["challenge"] == "dns-01"
49
93
  assert captured["kwargs"]["dns_provider"] is not None
50
- assert captured["kwargs"]["http01_solver"] is None
51
94
  out_dir = dncli.config_dir() / "certs" / "example.com"
52
95
  assert (out_dir / "cert.pem").read_text() == LEAF
53
96
  assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
@@ -107,28 +150,18 @@ def test_issue_rejects_unknown_key_type():
107
150
  assert "ed25519" in result.output
108
151
 
109
152
 
110
- def test_issue_http01_webroot_uses_webroot_solver(
111
- configured, captured, isolated_config, tmp_path
112
- ):
113
- import devnomads.acme as acme
114
-
115
- webroot = tmp_path / "web"
116
- result = runner.invoke(
117
- app, ["cert", "issue", "example.com", "--webroot", str(webroot)]
118
- )
153
+ def test_issue_is_always_dns01(configured, captured, isolated_config):
154
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
119
155
  assert result.exit_code == 0, result.output
120
- assert captured["challenge"] == "http-01"
121
- assert captured["kwargs"]["dns_provider"] is None
122
- assert isinstance(captured["kwargs"]["http01_solver"], acme.WebrootSolver)
156
+ assert captured["challenge"] == "dns-01"
157
+ assert captured["kwargs"]["dns_provider"] is not None
123
158
 
124
159
 
125
- def test_issue_standalone_uses_standalone_solver(configured, captured, isolated_config):
126
- import devnomads.acme as acme
127
-
128
- result = runner.invoke(app, ["cert", "issue", "example.com", "--standalone"])
129
- assert result.exit_code == 0, result.output
130
- assert captured["challenge"] == "http-01"
131
- assert isinstance(captured["kwargs"]["http01_solver"], acme.StandaloneSolver)
160
+ def test_issue_rejects_http01_options(configured, isolated_config):
161
+ # HTTP-01 was removed; these flags must no longer be accepted.
162
+ for flag in (["--http-01"], ["--standalone"], ["--webroot", "/tmp/x"]):
163
+ result = runner.invoke(app, ["cert", "issue", "example.com", *flag])
164
+ assert result.exit_code != 0, f"{flag} should be rejected: {result.output}"
132
165
 
133
166
 
134
167
  def test_issue_passes_sans(configured, captured, isolated_config):
@@ -154,19 +187,72 @@ def test_issue_staging_switches_directory(configured, captured, isolated_config)
154
187
  assert captured["directory_url"] == dncli.LE_STAGING_DIRECTORY
155
188
 
156
189
 
157
- def test_issue_rejects_both_challenges(configured, isolated_config):
158
- result = runner.invoke(
159
- app, ["cert", "issue", "example.com", "--dns-01", "--http-01"]
190
+ def test_issue_out_exports_combined_pem(
191
+ configured, captured, isolated_config, tmp_path
192
+ ):
193
+ out = tmp_path / "bundle.pem"
194
+ result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
195
+ assert result.exit_code == 0, result.output
196
+ # the default per-domain dir is still written...
197
+ default_dir = dncli.config_dir() / "certs" / "example.com"
198
+ 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
201
+ assert stat.S_IMODE(out.stat().st_mode) == 0o600
202
+
203
+
204
+ def test_our_zone_for_matches_longest_suffix():
205
+ zones = {"example.com", "sub.example.com"}
206
+ assert dncli._our_zone_for("a.sub.example.com", zones) == "sub.example.com"
207
+ assert dncli._our_zone_for("a.example.com", zones) == "example.com"
208
+ assert dncli._our_zone_for("*.example.com", zones) == "example.com"
209
+ assert dncli._our_zone_for("example.com", zones) == "example.com"
210
+ assert dncli._our_zone_for("other.org", zones) is None
211
+
212
+
213
+ def test_export_combined_pem_order_and_mode(tmp_path):
214
+ 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"
217
+ assert stat.S_IMODE(out.stat().st_mode) == 0o600
218
+
219
+
220
+ def _patch_authority(monkeypatch, *, zones, nameservers):
221
+ class FakeClient:
222
+ def request(self, method, path):
223
+ return zones
224
+
225
+ monkeypatch.setattr(dncli, "get_client", lambda state: FakeClient())
226
+ monkeypatch.setattr(dncli, "_zone_nameservers", lambda zone: set(nameservers))
227
+
228
+
229
+ def test_assert_dns_authority_accepts_delegated_zone(monkeypatch):
230
+ _patch_authority(
231
+ monkeypatch,
232
+ zones=[{"name": "example.com."}],
233
+ nameservers=dncli.REQUIRED_NAMESERVERS,
160
234
  )
161
- assert result.exit_code == 1
162
- assert "only one" in result.output
235
+ dncli._assert_dns_authority(object(), ["example.com", "www.example.com"])
163
236
 
164
237
 
165
- def test_issue_out_dir_override(configured, captured, isolated_config, tmp_path):
166
- out = tmp_path / "mycerts"
167
- result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
168
- assert result.exit_code == 0, result.output
169
- assert (out / "fullchain.pem").read_text() == FULLCHAIN
238
+ def test_assert_dns_authority_rejects_foreign_domain(monkeypatch):
239
+ _patch_authority(
240
+ monkeypatch,
241
+ zones=[{"name": "example.com."}],
242
+ nameservers=dncli.REQUIRED_NAMESERVERS,
243
+ )
244
+ with pytest.raises(dncli.CliError):
245
+ dncli._assert_dns_authority(object(), ["notyours.org"])
246
+
247
+
248
+ def test_assert_dns_authority_rejects_wrong_nameservers(monkeypatch):
249
+ _patch_authority(
250
+ monkeypatch,
251
+ zones=[{"name": "example.com."}],
252
+ nameservers={"ns1.other.com", "ns2.other.com"},
253
+ )
254
+ with pytest.raises(dncli.CliError):
255
+ dncli._assert_dns_authority(object(), ["example.com"])
170
256
 
171
257
 
172
258
  def test_load_acme_import_error_message(monkeypatch):
@@ -252,3 +338,77 @@ def test_renew_reissues_expiring_cert(configured, captured, isolated_config):
252
338
  assert result.exit_code == 0, result.output
253
339
  assert captured["challenge"] == "dns-01"
254
340
  assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
341
+
342
+
343
+ def test_issue_skips_when_valid_beyond_window(configured, captured, isolated_config):
344
+ _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
345
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
346
+ assert result.exit_code == 0, result.output
347
+ assert "still valid" in result.output
348
+ assert "challenge" not in captured # obtain_certificate was not called
349
+
350
+
351
+ def test_issue_force_reissues_valid_cert(configured, captured, isolated_config):
352
+ _write_cert(dncli.config_dir() / "certs" / "example.com", days=60)
353
+ result = runner.invoke(app, ["cert", "issue", "example.com", "--force"])
354
+ assert result.exit_code == 0, result.output
355
+ assert captured["challenge"] == "dns-01"
356
+
357
+
358
+ def test_issue_reissues_when_within_window(configured, captured, isolated_config):
359
+ _write_cert(dncli.config_dir() / "certs" / "example.com", days=10)
360
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
361
+ assert result.exit_code == 0, result.output
362
+ assert captured["challenge"] == "dns-01"
363
+
364
+
365
+ def test_cert_list_shows_issued(configured, isolated_config):
366
+ import json
367
+
368
+ _write_cert(dncli.config_dir() / "certs" / "example.com", days=45)
369
+ result = runner.invoke(app, ["cert", "list", "-o", "json"])
370
+ assert result.exit_code == 0, result.output
371
+ rows = json.loads(result.output)
372
+ assert rows[0]["domain"] == "example.com"
373
+ assert rows[0]["days_left"] >= 40
374
+ assert rows[0]["key_type"] == "ecdsa256"
375
+ assert "example.com" in rows[0]["sans"]
376
+
377
+
378
+ def test_cert_list_empty(isolated_config):
379
+ result = runner.invoke(app, ["cert", "list"])
380
+ assert result.exit_code == 0, result.output
381
+ assert "no certificates" in result.output
382
+
383
+
384
+ def test_cert_export_to_file(configured, isolated_config, tmp_path):
385
+ _write_cert(
386
+ dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
387
+ )
388
+ out = tmp_path / "bundle.pem"
389
+ result = runner.invoke(app, ["cert", "export", "example.com", "--out", str(out)])
390
+ assert result.exit_code == 0, result.output
391
+ 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
+ )
398
+ assert stat.S_IMODE(out.stat().st_mode) == 0o600
399
+
400
+
401
+ def test_cert_export_to_stdout(configured, isolated_config):
402
+ _write_cert(
403
+ dncli.config_dir() / "certs" / "example.com", days=30, with_key_chain=True
404
+ )
405
+ result = runner.invoke(app, ["cert", "export", "example.com"])
406
+ assert result.exit_code == 0, result.output
407
+ assert "BEGIN CERTIFICATE" in result.stdout
408
+ assert "CHAINPEM" in result.stdout
409
+
410
+
411
+ def test_cert_export_missing(isolated_config):
412
+ result = runner.invoke(app, ["cert", "export", "nope.com"])
413
+ assert result.exit_code != 0
414
+ assert "no certificate" in result.output
File without changes
File without changes