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.
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/PKG-INFO +30 -4
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/README.md +28 -2
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/PKG-INFO +30 -4
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/requires.txt +1 -1
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/dncli.py +249 -65
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/pyproject.toml +2 -2
- devnomads_cli-0.5.0/tests/test_cert.py +414 -0
- devnomads_cli-0.4.0/tests/test_cert.py +0 -204
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/LICENSE +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/SOURCES.txt +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/dependency_links.txt +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/entry_points.txt +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/devnomads_cli.egg-info/top_level.txt +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/setup.cfg +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_cli.py +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_config.py +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_generate.py +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_generated_cli.py +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_helpers.py +0 -0
- {devnomads_cli-0.4.0 → devnomads_cli-0.5.0}/tests/test_hook.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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`:
|
|
@@ -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
|
|
1380
|
-
return
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
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=
|
|
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
|
-
|
|
1502
|
-
|
|
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",
|
|
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
|
-
|
|
1517
|
-
|
|
1518
|
-
if
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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=
|
|
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
|
-
|
|
1568
|
-
|
|
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.
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|