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