devnomads-cli 0.4.0__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 (21) hide show
  1. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/PKG-INFO +30 -4
  2. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/README.md +28 -2
  3. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/PKG-INFO +30 -4
  4. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/requires.txt +1 -1
  5. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/dncli.py +249 -65
  6. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/pyproject.toml +2 -2
  7. devnomads_cli-0.5.0/tests/test_cert.py +414 -0
  8. devnomads_cli-0.4.0/tests/test_cert.py +0 -204
  9. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/LICENSE +0 -0
  10. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
  11. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
  12. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
  13. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/top_level.txt +0 -0
  14. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/setup.cfg +0 -0
  15. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_cli.py +0 -0
  16. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_config.py +0 -0
  17. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_generate.py +0 -0
  18. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_generated_cli.py +0 -0
  19. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_helpers.py +0 -0
  20. {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_hook.py +0 -0
  21. {devnomads_cli-0.4.0 → 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.0
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
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.12
11
11
  Requires-Dist: httpx>=0.27
12
12
  Requires-Dist: rich>=13
13
13
  Requires-Dist: cryptography>=42
14
- Requires-Dist: devnomads[acme]>=0.2.1
14
+ Requires-Dist: devnomads[acme]>=0.2.3
15
15
  Dynamic: license-file
16
16
 
17
17
  # dncli
@@ -126,10 +126,36 @@ 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
- dncli cert issue example.com -d "*.example.com"
132
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
133
+ ```
134
+
135
+ The first argument is the primary domain (the certificate CN); add
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).
146
+
147
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
148
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
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
133
159
  ```
134
160
 
135
161
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
@@ -110,10 +110,36 @@ 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
- dncli cert issue example.com -d "*.example.com"
116
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
117
+ ```
118
+
119
+ The first argument is the primary domain (the certificate CN); add
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).
130
+
131
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
132
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
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
117
143
  ```
118
144
 
119
145
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devnomads-cli
3
- Version: 0.4.0
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
@@ -11,7 +11,7 @@ Requires-Dist: typer>=0.12
11
11
  Requires-Dist: httpx>=0.27
12
12
  Requires-Dist: rich>=13
13
13
  Requires-Dist: cryptography>=42
14
- Requires-Dist: devnomads[acme]>=0.2.1
14
+ Requires-Dist: devnomads[acme]>=0.2.3
15
15
  Dynamic: license-file
16
16
 
17
17
  # dncli
@@ -126,10 +126,36 @@ 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
- dncli cert issue example.com -d "*.example.com"
132
+ dncli cert issue example.com -d www.example.com -d "*.example.com"
133
+ ```
134
+
135
+ The first argument is the primary domain (the certificate CN); add
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).
146
+
147
+ Keys are ECDSA P-384 by default. Pick another with `--key-type`
148
+ (`ecdsa256`, `ecdsa384`, `ecdsa521`, `rsa2048`, `rsa4096`).
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
133
159
  ```
134
160
 
135
161
  A dehydrated-compatible DNS-01 hook ships as `dncli-dns-hook`:
@@ -2,4 +2,4 @@ typer>=0.12
2
2
  httpx>=0.27
3
3
  rich>=13
4
4
  cryptography>=42
5
- devnomads[acme]>=0.2.1
5
+ devnomads[acme]>=0.2.3
@@ -1360,6 +1360,28 @@ 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
+ )
1375
+
1376
+
1377
+ class KeyType(str, Enum):
1378
+ """Certificate key types. ECDSA is the default; RSA on request."""
1379
+
1380
+ ecdsa256 = "ecdsa256"
1381
+ ecdsa384 = "ecdsa384"
1382
+ ecdsa521 = "ecdsa521"
1383
+ rsa2048 = "rsa2048"
1384
+ rsa4096 = "rsa4096"
1363
1385
 
1364
1386
 
1365
1387
  def _load_acme() -> Any:
@@ -1376,14 +1398,67 @@ def _load_acme() -> Any:
1376
1398
  return acme
1377
1399
 
1378
1400
 
1379
- def _cert_out_dir(out: Path | None, domain: str) -> Path:
1380
- 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
1381
1403
 
1382
1404
 
1383
1405
  def _account_key_path() -> Path:
1384
1406
  return config_dir() / "acme" / "account.pem"
1385
1407
 
1386
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
+
1387
1462
  def _write_cert_files(
1388
1463
  out_dir: Path,
1389
1464
  *,
@@ -1402,22 +1477,70 @@ def _write_cert_files(
1402
1477
  (out_dir / name).write_text(content)
1403
1478
 
1404
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
+
1405
1528
  def _issue_certificate(
1406
1529
  state: AppState,
1407
1530
  domain: str,
1408
1531
  *,
1409
1532
  sans: list[str],
1410
- use_http01: bool,
1411
- webroot: str | None,
1412
- standalone: bool,
1413
1533
  email: str | None,
1414
1534
  key_type: str,
1415
1535
  staging: bool,
1416
1536
  out_dir: Path,
1537
+ export_path: Path | None = None,
1417
1538
  ) -> None:
1418
- """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``."""
1419
1541
 
1420
1542
  acme = _load_acme()
1543
+ _assert_dns_authority(state, [domain, *sans])
1421
1544
 
1422
1545
  directory_url = LE_STAGING_DIRECTORY if staging else acme.DEFAULT_DIRECTORY_URL
1423
1546
  client = acme.AcmeClient(
@@ -1430,46 +1553,37 @@ def _issue_certificate(
1430
1553
  except acme.AcmeError as exc:
1431
1554
  raise CliError(str(exc)) from exc
1432
1555
 
1433
- dns_provider = None
1434
- http01_solver = None
1435
- if use_http01:
1436
- if webroot:
1437
- http01_solver = acme.WebrootSolver(webroot)
1438
- else:
1439
- http01_solver = acme.StandaloneSolver()
1440
- challenge = "http-01"
1441
- else:
1442
- dns_provider = acme.DevNomadsDnsProvider(Dns(get_client(state).api))
1443
- challenge = "dns-01"
1444
-
1556
+ dns_provider = acme.DevNomadsDnsProvider(Dns(get_client(state).api))
1445
1557
  err_console.print(
1446
- f"issuing {challenge} certificate for {domain}"
1558
+ f"issuing dns-01 certificate for {domain}"
1447
1559
  + (f" (+{len(sans)} SAN(s))" if sans else "")
1448
1560
  + (" [staging]" if staging else "")
1449
1561
  )
1450
1562
  try:
1451
- with http01_solver or nullcontext() as solver:
1452
- leaf, fullchain, chain, key_pem = client.obtain_certificate(
1453
- domain,
1454
- challenge,
1455
- domain_key,
1456
- sans=sans or None,
1457
- dns_provider=dns_provider,
1458
- http01_solver=solver if use_http01 else None,
1459
- )
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
+ )
1460
1570
  except acme.AcmeError as exc:
1461
1571
  raise CliError(str(exc)) from exc
1462
1572
  except DevNomadsError as exc:
1463
1573
  raise CliError(str(exc)) from exc
1464
1574
 
1575
+ key_text = key_pem.decode() if isinstance(key_pem, bytes) else key_pem
1465
1576
  _write_cert_files(
1466
1577
  out_dir,
1467
- privkey=key_pem.decode() if isinstance(key_pem, bytes) else key_pem,
1578
+ privkey=key_text,
1468
1579
  cert=leaf,
1469
1580
  fullchain=fullchain,
1470
1581
  chain=chain,
1471
1582
  )
1472
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}")
1473
1587
 
1474
1588
 
1475
1589
  @cert_app.command("issue")
@@ -1480,57 +1594,61 @@ def cert_issue(
1480
1594
  list[str] | None,
1481
1595
  typer.Option("--san", "-d", help="Additional SAN; repeat for more."),
1482
1596
  ] = None,
1483
- dns_01: Annotated[
1484
- bool, typer.Option("--dns-01", help="Use the DNS-01 challenge (default).")
1485
- ] = False,
1486
- http_01: Annotated[
1487
- bool, typer.Option("--http-01", help="Use the HTTP-01 challenge.")
1488
- ] = False,
1489
- webroot: Annotated[
1490
- str | None,
1491
- typer.Option("--webroot", help="HTTP-01 webroot directory to write into."),
1492
- ] = None,
1493
- standalone: Annotated[
1494
- bool,
1495
- typer.Option("--standalone", help="HTTP-01 via a built-in server on :80."),
1496
- ] = False,
1497
1597
  email: Annotated[
1498
1598
  str | None, typer.Option("--email", help="ACME account contact email.")
1499
1599
  ] = None,
1500
1600
  key_type: Annotated[
1501
- str, typer.Option("--key-type", help="Certificate key type.")
1502
- ] = "ec256",
1601
+ KeyType,
1602
+ typer.Option(
1603
+ "--key-type",
1604
+ help="Certificate key type "
1605
+ "(ecdsa256/ecdsa384/ecdsa521/rsa2048/rsa4096).",
1606
+ ),
1607
+ ] = KeyType.ecdsa384,
1503
1608
  staging: Annotated[
1504
1609
  bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1505
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,
1506
1619
  out: Annotated[
1507
1620
  Path | None,
1508
1621
  typer.Option(
1509
- "--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.",
1510
1625
  ),
1511
1626
  ] = None,
1512
1627
  ) -> None:
1513
- """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)."""
1514
1629
 
1515
1630
  state = state_from(ctx)
1516
- if dns_01 and http_01:
1517
- raise CliError("choose only one of --dns-01 / --http-01")
1518
- if webroot and standalone:
1519
- raise CliError("choose only one of --webroot / --standalone")
1520
- use_http01 = http_01 or bool(webroot) or standalone
1521
- if dns_01 and use_http01:
1522
- 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
1523
1643
  _issue_certificate(
1524
1644
  state,
1525
1645
  domain,
1526
1646
  sans=list(san or []),
1527
- use_http01=use_http01,
1528
- webroot=webroot,
1529
- standalone=standalone,
1530
1647
  email=email,
1531
- key_type=key_type,
1648
+ key_type=key_type.value,
1532
1649
  staging=staging,
1533
- out_dir=_cert_out_dir(out, domain),
1650
+ out_dir=out_dir,
1651
+ export_path=out,
1534
1652
  )
1535
1653
 
1536
1654
 
@@ -1564,8 +1682,13 @@ def cert_renew(
1564
1682
  str | None, typer.Option("--email", help="ACME account contact email.")
1565
1683
  ] = None,
1566
1684
  key_type: Annotated[
1567
- str, typer.Option("--key-type", help="Certificate key type.")
1568
- ] = "ec256",
1685
+ KeyType,
1686
+ typer.Option(
1687
+ "--key-type",
1688
+ help="Certificate key type "
1689
+ "(ecdsa256/ecdsa384/ecdsa521/rsa2048/rsa4096).",
1690
+ ),
1691
+ ] = KeyType.ecdsa384,
1569
1692
  staging: Annotated[
1570
1693
  bool, typer.Option("--staging", help="Use the Let's Encrypt staging CA.")
1571
1694
  ] = False,
@@ -1593,16 +1716,77 @@ def cert_renew(
1593
1716
  state,
1594
1717
  name,
1595
1718
  sans=[],
1596
- use_http01=False,
1597
- webroot=None,
1598
- standalone=False,
1599
1719
  email=email,
1600
- key_type=key_type,
1720
+ key_type=key_type.value,
1601
1721
  staging=staging,
1602
1722
  out_dir=out_dir,
1603
1723
  )
1604
1724
 
1605
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
+
1606
1790
  # ---------------------------------------------------------------------------
1607
1791
  # Generated commands. tools/generate.py renders one thin command per
1608
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.0"
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"
@@ -11,7 +11,7 @@ dependencies = [
11
11
  "httpx>=0.27",
12
12
  "rich>=13",
13
13
  "cryptography>=42",
14
- "devnomads[acme]>=0.2.1",
14
+ "devnomads[acme]>=0.2.3",
15
15
  ]
16
16
 
17
17
  [project.scripts]
@@ -0,0 +1,414 @@
1
+ """Certificate issuance: arg wiring, solver/provider selection, output."""
2
+
3
+ import stat
4
+
5
+ import pytest
6
+ from typer.testing import CliRunner
7
+
8
+ import dncli
9
+ from dncli import app
10
+
11
+ runner = CliRunner()
12
+
13
+ LEAF = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
14
+ CHAIN = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----\n"
15
+ FULLCHAIN = LEAF + CHAIN
16
+ KEY = b"-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
17
+
18
+
19
+ @pytest.fixture
20
+ def configured(write_profile):
21
+ write_profile(api_key="testkey-1234567890", api_url="https://api.test")
22
+
23
+
24
+ @pytest.fixture
25
+ def captured(monkeypatch):
26
+ """Stub AcmeClient.obtain_certificate; record how it was called."""
27
+
28
+ import devnomads.acme as acme
29
+
30
+ calls = {}
31
+
32
+ def fake_obtain(self, domain, challenge, domain_key, **kwargs):
33
+ calls["domain"] = domain
34
+ calls["challenge"] = challenge
35
+ calls["kwargs"] = kwargs
36
+ calls["directory_url"] = self.directory_url
37
+ return LEAF, FULLCHAIN, CHAIN, KEY
38
+
39
+ monkeypatch.setattr(acme.AcmeClient, "obtain_certificate", fake_obtain)
40
+ # generate_key touches no network but is slow; keep a tiny stub.
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)
45
+ return calls
46
+
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
+
89
+ def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
90
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
91
+ assert result.exit_code == 0, result.output
92
+ assert captured["challenge"] == "dns-01"
93
+ assert captured["kwargs"]["dns_provider"] is not None
94
+ out_dir = dncli.config_dir() / "certs" / "example.com"
95
+ assert (out_dir / "cert.pem").read_text() == LEAF
96
+ assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
97
+ assert (out_dir / "chain.pem").read_text() == CHAIN
98
+ privkey = out_dir / "privkey.pem"
99
+ assert privkey.read_bytes() == KEY
100
+ assert stat.S_IMODE(privkey.stat().st_mode) == 0o600
101
+
102
+
103
+ def _spy_generate_key(monkeypatch, seen):
104
+ import devnomads.acme as acme
105
+
106
+ def spy(algo):
107
+ seen["algo"] = algo
108
+ return object()
109
+
110
+ monkeypatch.setattr(acme, "generate_key", spy)
111
+
112
+
113
+ def test_issue_default_key_type_is_ecdsa384(
114
+ configured, captured, isolated_config, monkeypatch
115
+ ):
116
+ seen = {}
117
+ _spy_generate_key(monkeypatch, seen)
118
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
119
+ assert result.exit_code == 0, result.output
120
+ assert seen["algo"] == "ecdsa384"
121
+
122
+
123
+ def test_issue_key_type_ecdsa521(configured, captured, isolated_config, monkeypatch):
124
+ seen = {}
125
+ _spy_generate_key(monkeypatch, seen)
126
+ result = runner.invoke(
127
+ app, ["cert", "issue", "example.com", "--key-type", "ecdsa521"]
128
+ )
129
+ assert result.exit_code == 0, result.output
130
+ assert seen["algo"] == "ecdsa521"
131
+
132
+
133
+ def test_issue_key_type_is_selectable(
134
+ configured, captured, isolated_config, monkeypatch
135
+ ):
136
+ seen = {}
137
+ _spy_generate_key(monkeypatch, seen)
138
+ result = runner.invoke(
139
+ app, ["cert", "issue", "example.com", "--key-type", "rsa4096"]
140
+ )
141
+ assert result.exit_code == 0, result.output
142
+ assert seen["algo"] == "rsa4096"
143
+
144
+
145
+ def test_issue_rejects_unknown_key_type():
146
+ result = runner.invoke(
147
+ app, ["cert", "issue", "example.com", "--key-type", "ed25519"]
148
+ )
149
+ assert result.exit_code != 0
150
+ assert "ed25519" in result.output
151
+
152
+
153
+ def test_issue_is_always_dns01(configured, captured, isolated_config):
154
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
155
+ assert result.exit_code == 0, result.output
156
+ assert captured["challenge"] == "dns-01"
157
+ assert captured["kwargs"]["dns_provider"] is not None
158
+
159
+
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}"
165
+
166
+
167
+ def test_issue_passes_sans(configured, captured, isolated_config):
168
+ result = runner.invoke(
169
+ app,
170
+ [
171
+ "cert",
172
+ "issue",
173
+ "example.com",
174
+ "-d",
175
+ "www.example.com",
176
+ "-d",
177
+ "a.example.com",
178
+ ],
179
+ )
180
+ assert result.exit_code == 0, result.output
181
+ assert captured["kwargs"]["sans"] == ["www.example.com", "a.example.com"]
182
+
183
+
184
+ def test_issue_staging_switches_directory(configured, captured, isolated_config):
185
+ result = runner.invoke(app, ["cert", "issue", "example.com", "--staging"])
186
+ assert result.exit_code == 0, result.output
187
+ assert captured["directory_url"] == dncli.LE_STAGING_DIRECTORY
188
+
189
+
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,
234
+ )
235
+ dncli._assert_dns_authority(object(), ["example.com", "www.example.com"])
236
+
237
+
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"])
256
+
257
+
258
+ def test_load_acme_import_error_message(monkeypatch):
259
+ import builtins
260
+
261
+ real_import = builtins.__import__
262
+
263
+ def fake_import(name, *args, **kwargs):
264
+ if name == "devnomads.acme":
265
+ raise ImportError("No module named 'acme'")
266
+ return real_import(name, *args, **kwargs)
267
+
268
+ monkeypatch.setattr(builtins, "__import__", fake_import)
269
+ result = runner.invoke(app, ["cert", "issue", "example.com"])
270
+ assert result.exit_code == 1
271
+ assert "devnomads.acme" in result.output
272
+
273
+
274
+ def test_renew_skips_valid_cert(configured, captured, isolated_config):
275
+ import datetime
276
+
277
+ from cryptography import x509
278
+ from cryptography.hazmat.primitives import hashes, serialization
279
+ from cryptography.hazmat.primitives.asymmetric import ec
280
+ from cryptography.x509.oid import NameOID
281
+
282
+ # write a cert valid for ~60 days so renew should skip it
283
+ key = ec.generate_private_key(ec.SECP256R1())
284
+ now = datetime.datetime.now(datetime.timezone.utc)
285
+ cert = (
286
+ x509.CertificateBuilder()
287
+ .subject_name(
288
+ x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
289
+ )
290
+ .issuer_name(
291
+ x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
292
+ )
293
+ .public_key(key.public_key())
294
+ .serial_number(x509.random_serial_number())
295
+ .not_valid_before(now - datetime.timedelta(days=1))
296
+ .not_valid_after(now + datetime.timedelta(days=60))
297
+ .sign(key, hashes.SHA256())
298
+ )
299
+ out_dir = dncli.config_dir() / "certs" / "example.com"
300
+ out_dir.mkdir(parents=True)
301
+ (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
302
+
303
+ result = runner.invoke(app, ["cert", "renew", "example.com"])
304
+ assert result.exit_code == 0, result.output
305
+ assert "still valid" in result.output
306
+ assert "challenge" not in captured # obtain_certificate was not called
307
+
308
+
309
+ def test_renew_reissues_expiring_cert(configured, captured, isolated_config):
310
+ import datetime
311
+
312
+ from cryptography import x509
313
+ from cryptography.hazmat.primitives import hashes, serialization
314
+ from cryptography.hazmat.primitives.asymmetric import ec
315
+ from cryptography.x509.oid import NameOID
316
+
317
+ key = ec.generate_private_key(ec.SECP256R1())
318
+ now = datetime.datetime.now(datetime.timezone.utc)
319
+ cert = (
320
+ x509.CertificateBuilder()
321
+ .subject_name(
322
+ x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
323
+ )
324
+ .issuer_name(
325
+ x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
326
+ )
327
+ .public_key(key.public_key())
328
+ .serial_number(x509.random_serial_number())
329
+ .not_valid_before(now - datetime.timedelta(days=80))
330
+ .not_valid_after(now + datetime.timedelta(days=5))
331
+ .sign(key, hashes.SHA256())
332
+ )
333
+ out_dir = dncli.config_dir() / "certs" / "example.com"
334
+ out_dir.mkdir(parents=True)
335
+ (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
336
+
337
+ result = runner.invoke(app, ["cert", "renew", "example.com"])
338
+ assert result.exit_code == 0, result.output
339
+ assert captured["challenge"] == "dns-01"
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
@@ -1,204 +0,0 @@
1
- """Certificate issuance: arg wiring, solver/provider selection, output."""
2
-
3
- import stat
4
-
5
- import pytest
6
- from typer.testing import CliRunner
7
-
8
- import dncli
9
- from dncli import app
10
-
11
- runner = CliRunner()
12
-
13
- LEAF = "-----BEGIN CERTIFICATE-----\nLEAF\n-----END CERTIFICATE-----\n"
14
- CHAIN = "-----BEGIN CERTIFICATE-----\nCHAIN\n-----END CERTIFICATE-----\n"
15
- FULLCHAIN = LEAF + CHAIN
16
- KEY = b"-----BEGIN PRIVATE KEY-----\nKEY\n-----END PRIVATE KEY-----\n"
17
-
18
-
19
- @pytest.fixture
20
- def configured(write_profile):
21
- write_profile(api_key="testkey-1234567890", api_url="https://api.test")
22
-
23
-
24
- @pytest.fixture
25
- def captured(monkeypatch):
26
- """Stub AcmeClient.obtain_certificate; record how it was called."""
27
-
28
- import devnomads.acme as acme
29
-
30
- calls = {}
31
-
32
- def fake_obtain(self, domain, challenge, domain_key, **kwargs):
33
- calls["domain"] = domain
34
- calls["challenge"] = challenge
35
- calls["kwargs"] = kwargs
36
- calls["directory_url"] = self.directory_url
37
- return LEAF, FULLCHAIN, CHAIN, KEY
38
-
39
- monkeypatch.setattr(acme.AcmeClient, "obtain_certificate", fake_obtain)
40
- # generate_key touches no network but is slow; keep a tiny stub.
41
- monkeypatch.setattr(acme, "generate_key", lambda algo: object())
42
- return calls
43
-
44
-
45
- def test_issue_dns01_default_uses_dns_provider(configured, captured, isolated_config):
46
- result = runner.invoke(app, ["cert", "issue", "example.com"])
47
- assert result.exit_code == 0, result.output
48
- assert captured["challenge"] == "dns-01"
49
- assert captured["kwargs"]["dns_provider"] is not None
50
- assert captured["kwargs"]["http01_solver"] is None
51
- out_dir = dncli.config_dir() / "certs" / "example.com"
52
- assert (out_dir / "cert.pem").read_text() == LEAF
53
- assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
54
- assert (out_dir / "chain.pem").read_text() == CHAIN
55
- privkey = out_dir / "privkey.pem"
56
- assert privkey.read_bytes() == KEY
57
- assert stat.S_IMODE(privkey.stat().st_mode) == 0o600
58
-
59
-
60
- def test_issue_http01_webroot_uses_webroot_solver(
61
- configured, captured, isolated_config, tmp_path
62
- ):
63
- import devnomads.acme as acme
64
-
65
- webroot = tmp_path / "web"
66
- result = runner.invoke(
67
- app, ["cert", "issue", "example.com", "--webroot", str(webroot)]
68
- )
69
- assert result.exit_code == 0, result.output
70
- assert captured["challenge"] == "http-01"
71
- assert captured["kwargs"]["dns_provider"] is None
72
- assert isinstance(captured["kwargs"]["http01_solver"], acme.WebrootSolver)
73
-
74
-
75
- def test_issue_standalone_uses_standalone_solver(configured, captured, isolated_config):
76
- import devnomads.acme as acme
77
-
78
- result = runner.invoke(app, ["cert", "issue", "example.com", "--standalone"])
79
- assert result.exit_code == 0, result.output
80
- assert captured["challenge"] == "http-01"
81
- assert isinstance(captured["kwargs"]["http01_solver"], acme.StandaloneSolver)
82
-
83
-
84
- def test_issue_passes_sans(configured, captured, isolated_config):
85
- result = runner.invoke(
86
- app,
87
- [
88
- "cert",
89
- "issue",
90
- "example.com",
91
- "-d",
92
- "www.example.com",
93
- "-d",
94
- "a.example.com",
95
- ],
96
- )
97
- assert result.exit_code == 0, result.output
98
- assert captured["kwargs"]["sans"] == ["www.example.com", "a.example.com"]
99
-
100
-
101
- def test_issue_staging_switches_directory(configured, captured, isolated_config):
102
- result = runner.invoke(app, ["cert", "issue", "example.com", "--staging"])
103
- assert result.exit_code == 0, result.output
104
- assert captured["directory_url"] == dncli.LE_STAGING_DIRECTORY
105
-
106
-
107
- def test_issue_rejects_both_challenges(configured, isolated_config):
108
- result = runner.invoke(
109
- app, ["cert", "issue", "example.com", "--dns-01", "--http-01"]
110
- )
111
- assert result.exit_code == 1
112
- assert "only one" in result.output
113
-
114
-
115
- def test_issue_out_dir_override(configured, captured, isolated_config, tmp_path):
116
- out = tmp_path / "mycerts"
117
- result = runner.invoke(app, ["cert", "issue", "example.com", "--out", str(out)])
118
- assert result.exit_code == 0, result.output
119
- assert (out / "fullchain.pem").read_text() == FULLCHAIN
120
-
121
-
122
- def test_load_acme_import_error_message(monkeypatch):
123
- import builtins
124
-
125
- real_import = builtins.__import__
126
-
127
- def fake_import(name, *args, **kwargs):
128
- if name == "devnomads.acme":
129
- raise ImportError("No module named 'acme'")
130
- return real_import(name, *args, **kwargs)
131
-
132
- monkeypatch.setattr(builtins, "__import__", fake_import)
133
- result = runner.invoke(app, ["cert", "issue", "example.com"])
134
- assert result.exit_code == 1
135
- assert "devnomads.acme" in result.output
136
-
137
-
138
- def test_renew_skips_valid_cert(configured, captured, isolated_config):
139
- import datetime
140
-
141
- from cryptography import x509
142
- from cryptography.hazmat.primitives import hashes, serialization
143
- from cryptography.hazmat.primitives.asymmetric import ec
144
- from cryptography.x509.oid import NameOID
145
-
146
- # write a cert valid for ~60 days so renew should skip it
147
- key = ec.generate_private_key(ec.SECP256R1())
148
- now = datetime.datetime.now(datetime.timezone.utc)
149
- cert = (
150
- x509.CertificateBuilder()
151
- .subject_name(
152
- x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
153
- )
154
- .issuer_name(
155
- x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
156
- )
157
- .public_key(key.public_key())
158
- .serial_number(x509.random_serial_number())
159
- .not_valid_before(now - datetime.timedelta(days=1))
160
- .not_valid_after(now + datetime.timedelta(days=60))
161
- .sign(key, hashes.SHA256())
162
- )
163
- out_dir = dncli.config_dir() / "certs" / "example.com"
164
- out_dir.mkdir(parents=True)
165
- (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
166
-
167
- result = runner.invoke(app, ["cert", "renew", "example.com"])
168
- assert result.exit_code == 0, result.output
169
- assert "still valid" in result.output
170
- assert "challenge" not in captured # obtain_certificate was not called
171
-
172
-
173
- def test_renew_reissues_expiring_cert(configured, captured, isolated_config):
174
- import datetime
175
-
176
- from cryptography import x509
177
- from cryptography.hazmat.primitives import hashes, serialization
178
- from cryptography.hazmat.primitives.asymmetric import ec
179
- from cryptography.x509.oid import NameOID
180
-
181
- key = ec.generate_private_key(ec.SECP256R1())
182
- now = datetime.datetime.now(datetime.timezone.utc)
183
- cert = (
184
- x509.CertificateBuilder()
185
- .subject_name(
186
- x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
187
- )
188
- .issuer_name(
189
- x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "example.com")])
190
- )
191
- .public_key(key.public_key())
192
- .serial_number(x509.random_serial_number())
193
- .not_valid_before(now - datetime.timedelta(days=80))
194
- .not_valid_after(now + datetime.timedelta(days=5))
195
- .sign(key, hashes.SHA256())
196
- )
197
- out_dir = dncli.config_dir() / "certs" / "example.com"
198
- out_dir.mkdir(parents=True)
199
- (out_dir / "cert.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))
200
-
201
- result = runner.invoke(app, ["cert", "renew", "example.com"])
202
- assert result.exit_code == 0, result.output
203
- assert captured["challenge"] == "dns-01"
204
- assert (out_dir / "fullchain.pem").read_text() == FULLCHAIN
File without changes
File without changes