devnomads 0.2.3__tar.gz → 0.2.5__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-0.2.3 → devnomads-0.2.5}/PKG-INFO +48 -13
- {devnomads-0.2.3 → devnomads-0.2.5}/README.md +46 -11
- {devnomads-0.2.3 → devnomads-0.2.5}/pyproject.toml +2 -2
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/__init__.py +12 -1
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/client.py +72 -2
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/credentials.py +8 -8
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_client.py +104 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/uv.lock +1 -1
- {devnomads-0.2.3 → devnomads-0.2.5}/.flake8 +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/.gitignore +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/.gitlab-ci.yml +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/Makefile +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/openapi.json +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/__init__.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/errors.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/verify.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/client.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/py.typed +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/conftest.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_keys.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_client.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_credentials.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_dns.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_names.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_services.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tools/bump_version.py +0 -0
- {devnomads-0.2.3 → devnomads-0.2.5}/tools/generate.py +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: devnomads
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.5
|
|
4
4
|
Summary: Python client library for the DevNomads API (transport, DNS, ACME)
|
|
5
5
|
Project-URL: Homepage, https://devnomads.nl
|
|
6
|
-
Author-email:
|
|
6
|
+
Author-email: DevNomads <support@devnomads.nl>
|
|
7
7
|
License: MIT
|
|
8
8
|
Keywords: acme,api,devnomads,dns,powerdns
|
|
9
9
|
Requires-Python: >=3.10
|
|
@@ -21,9 +21,9 @@ Python client library for the [DevNomads](https://devnomads.nl) API:
|
|
|
21
21
|
HTTP transport, a generated resource SDK covering the whole API, DNS
|
|
22
22
|
zone/record management, and ACME certificate issuance.
|
|
23
23
|
|
|
24
|
-
It is the shared foundation of the DevNomads command-line
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
It is the shared foundation of the DevNomads command-line tool `dncli`,
|
|
25
|
+
exposed so you can build against the same primitives directly instead of
|
|
26
|
+
reimplementing them.
|
|
27
27
|
|
|
28
28
|
## Contents
|
|
29
29
|
|
|
@@ -54,7 +54,7 @@ directly instead of reimplementing them.
|
|
|
54
54
|
wildcards and per-identifier challenge mixing in a single order.
|
|
55
55
|
- Two HTTP-01 strategies: write challenge files to a web root, or answer
|
|
56
56
|
from a built-in standalone server.
|
|
57
|
-
- One credential scheme,
|
|
57
|
+
- One credential scheme, shared with the `dncli` command-line tool.
|
|
58
58
|
- Typed exceptions, standard-library logging, full type hints
|
|
59
59
|
(`py.typed`). No printing, no `sys.exit`.
|
|
60
60
|
|
|
@@ -135,8 +135,8 @@ The API key is resolved as:
|
|
|
135
135
|
|
|
136
136
|
The credentials file is `DN_CREDENTIALS_FILE` if set, otherwise the first
|
|
137
137
|
of `/etc/devnomads/credentials` and `<config dir>/credentials`. The config
|
|
138
|
-
directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/
|
|
139
|
-
`~/.config/
|
|
138
|
+
directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/dncli`, else
|
|
139
|
+
`~/.config/dncli` - i.e. the file `dncli configure` writes. The profile is
|
|
140
140
|
`DN_PROFILE`, else `default`.
|
|
141
141
|
|
|
142
142
|
### Credentials file
|
|
@@ -156,7 +156,7 @@ api_url = https://api.devnomads.nl
|
|
|
156
156
|
- `DN_API_URL` / `DEVNOMADS_API_URL` - base URL override.
|
|
157
157
|
- `DN_PROFILE` / `DEVNOMADS_PROFILE` - profile name (default `default`).
|
|
158
158
|
- `DN_CREDENTIALS_FILE` - explicit credentials file path.
|
|
159
|
-
- `DN_CONFIG_DIR` - config directory (default `~/.config/
|
|
159
|
+
- `DN_CONFIG_DIR` - config directory (default `~/.config/dncli`).
|
|
160
160
|
|
|
161
161
|
`config_dir()` and `credentials_path()` expose the resolved paths.
|
|
162
162
|
|
|
@@ -315,14 +315,21 @@ AcmeClient(
|
|
|
315
315
|
account_key_path,
|
|
316
316
|
*,
|
|
317
317
|
directory_url=DEFAULT_DIRECTORY_URL, # Let's Encrypt production
|
|
318
|
-
account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384
|
|
318
|
+
account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384/ec521
|
|
319
319
|
contact_email=None,
|
|
320
320
|
preferred_chain=None, # match alternate chain by CN
|
|
321
321
|
recursive_nameservers=None, # resolvers for propagation
|
|
322
322
|
user_agent="devnomads",
|
|
323
|
+
eab_kid=None, # external account binding key id
|
|
324
|
+
eab_hmac_key=None, # external account binding HMAC key
|
|
325
|
+
eab_hmac_alg="HS256", # HS256/HS384/HS512
|
|
323
326
|
)
|
|
324
327
|
```
|
|
325
328
|
|
|
329
|
+
Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
|
|
330
|
+
`directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
|
|
331
|
+
also need `eab_kid` and `eab_hmac_key`.
|
|
332
|
+
|
|
326
333
|
The account key at `account_key_path` is loaded if present and created
|
|
327
334
|
(mode 0600) otherwise.
|
|
328
335
|
|
|
@@ -419,6 +426,34 @@ acme = AcmeClient(
|
|
|
419
426
|
)
|
|
420
427
|
```
|
|
421
428
|
|
|
429
|
+
### Providers (Let's Encrypt, ZeroSSL)
|
|
430
|
+
|
|
431
|
+
`for_provider` resolves a named CA from `CA_DIRECTORIES`
|
|
432
|
+
(`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
|
|
433
|
+
remember directory URLs. Let's Encrypt stays the default everywhere else.
|
|
434
|
+
|
|
435
|
+
```python
|
|
436
|
+
from devnomads.acme import AcmeClient
|
|
437
|
+
|
|
438
|
+
# Let's Encrypt (default CA) - no EAB needed.
|
|
439
|
+
acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
|
|
440
|
+
|
|
441
|
+
# ZeroSSL - requires External Account Binding credentials from your
|
|
442
|
+
# ZeroSSL account (Developer -> EAB Credentials, or the REST API).
|
|
443
|
+
acme = AcmeClient.for_provider(
|
|
444
|
+
"zerossl",
|
|
445
|
+
"/etc/devnomads/zerossl-account.key",
|
|
446
|
+
contact_email="ops@example.com",
|
|
447
|
+
eab_kid="...", # EAB key id
|
|
448
|
+
eab_hmac_key="...", # base64url EAB HMAC key
|
|
449
|
+
)
|
|
450
|
+
```
|
|
451
|
+
|
|
452
|
+
Issuance is otherwise identical - the same `obtain_certificate` calls,
|
|
453
|
+
DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
|
|
454
|
+
directory URL and EAB fields can also be passed straight to the
|
|
455
|
+
`AcmeClient(...)` constructor if you prefer not to use the alias.
|
|
456
|
+
|
|
422
457
|
### Key helpers
|
|
423
458
|
|
|
424
459
|
```python
|
|
@@ -429,7 +464,7 @@ from devnomads.acme import (
|
|
|
429
464
|
load_or_create_account_key,
|
|
430
465
|
)
|
|
431
466
|
|
|
432
|
-
key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384
|
|
467
|
+
key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384/ec521
|
|
433
468
|
pem = serialize_key(key) # unencrypted PEM bytes
|
|
434
469
|
csr = build_csr(key, ["example.com", "www.example.com"])
|
|
435
470
|
jwk = load_or_create_account_key("/etc/devnomads/account.key", "ec256")
|
|
@@ -504,8 +539,8 @@ logging.getLogger("devnomads").setLevel(logging.INFO)
|
|
|
504
539
|
presentation.
|
|
505
540
|
- **Layered by dependency weight.** DNS-only consumers never pull the
|
|
506
541
|
ACME stack; the heavy dependencies live behind the `acme` extra.
|
|
507
|
-
- **One credential scheme** shared with the DevNomads
|
|
508
|
-
configured for `
|
|
542
|
+
- **One credential scheme** shared with the DevNomads CLI, so a host
|
|
543
|
+
configured for `dncli` works unchanged.
|
|
509
544
|
|
|
510
545
|
## Development
|
|
511
546
|
|
|
@@ -4,9 +4,9 @@ Python client library for the [DevNomads](https://devnomads.nl) API:
|
|
|
4
4
|
HTTP transport, a generated resource SDK covering the whole API, DNS
|
|
5
5
|
zone/record management, and ACME certificate issuance.
|
|
6
6
|
|
|
7
|
-
It is the shared foundation of the DevNomads command-line
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
It is the shared foundation of the DevNomads command-line tool `dncli`,
|
|
8
|
+
exposed so you can build against the same primitives directly instead of
|
|
9
|
+
reimplementing them.
|
|
10
10
|
|
|
11
11
|
## Contents
|
|
12
12
|
|
|
@@ -37,7 +37,7 @@ directly instead of reimplementing them.
|
|
|
37
37
|
wildcards and per-identifier challenge mixing in a single order.
|
|
38
38
|
- Two HTTP-01 strategies: write challenge files to a web root, or answer
|
|
39
39
|
from a built-in standalone server.
|
|
40
|
-
- One credential scheme,
|
|
40
|
+
- One credential scheme, shared with the `dncli` command-line tool.
|
|
41
41
|
- Typed exceptions, standard-library logging, full type hints
|
|
42
42
|
(`py.typed`). No printing, no `sys.exit`.
|
|
43
43
|
|
|
@@ -118,8 +118,8 @@ The API key is resolved as:
|
|
|
118
118
|
|
|
119
119
|
The credentials file is `DN_CREDENTIALS_FILE` if set, otherwise the first
|
|
120
120
|
of `/etc/devnomads/credentials` and `<config dir>/credentials`. The config
|
|
121
|
-
directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/
|
|
122
|
-
`~/.config/
|
|
121
|
+
directory is `DN_CONFIG_DIR`, else `$XDG_CONFIG_HOME/dncli`, else
|
|
122
|
+
`~/.config/dncli` - i.e. the file `dncli configure` writes. The profile is
|
|
123
123
|
`DN_PROFILE`, else `default`.
|
|
124
124
|
|
|
125
125
|
### Credentials file
|
|
@@ -139,7 +139,7 @@ api_url = https://api.devnomads.nl
|
|
|
139
139
|
- `DN_API_URL` / `DEVNOMADS_API_URL` - base URL override.
|
|
140
140
|
- `DN_PROFILE` / `DEVNOMADS_PROFILE` - profile name (default `default`).
|
|
141
141
|
- `DN_CREDENTIALS_FILE` - explicit credentials file path.
|
|
142
|
-
- `DN_CONFIG_DIR` - config directory (default `~/.config/
|
|
142
|
+
- `DN_CONFIG_DIR` - config directory (default `~/.config/dncli`).
|
|
143
143
|
|
|
144
144
|
`config_dir()` and `credentials_path()` expose the resolved paths.
|
|
145
145
|
|
|
@@ -298,14 +298,21 @@ AcmeClient(
|
|
|
298
298
|
account_key_path,
|
|
299
299
|
*,
|
|
300
300
|
directory_url=DEFAULT_DIRECTORY_URL, # Let's Encrypt production
|
|
301
|
-
account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384
|
|
301
|
+
account_key_algorithm="ec256", # rsa2048/rsa4096/ec256/ec384/ec521
|
|
302
302
|
contact_email=None,
|
|
303
303
|
preferred_chain=None, # match alternate chain by CN
|
|
304
304
|
recursive_nameservers=None, # resolvers for propagation
|
|
305
305
|
user_agent="devnomads",
|
|
306
|
+
eab_kid=None, # external account binding key id
|
|
307
|
+
eab_hmac_key=None, # external account binding HMAC key
|
|
308
|
+
eab_hmac_alg="HS256", # HS256/HS384/HS512
|
|
306
309
|
)
|
|
307
310
|
```
|
|
308
311
|
|
|
312
|
+
Let's Encrypt remains the default CA. Any ACME v2 CA works by passing its
|
|
313
|
+
`directory_url`; CAs that mandate External Account Binding (such as ZeroSSL)
|
|
314
|
+
also need `eab_kid` and `eab_hmac_key`.
|
|
315
|
+
|
|
309
316
|
The account key at `account_key_path` is loaded if present and created
|
|
310
317
|
(mode 0600) otherwise.
|
|
311
318
|
|
|
@@ -402,6 +409,34 @@ acme = AcmeClient(
|
|
|
402
409
|
)
|
|
403
410
|
```
|
|
404
411
|
|
|
412
|
+
### Providers (Let's Encrypt, ZeroSSL)
|
|
413
|
+
|
|
414
|
+
`for_provider` resolves a named CA from `CA_DIRECTORIES`
|
|
415
|
+
(`letsencrypt`, `letsencrypt-staging`, `zerossl`) so you do not have to
|
|
416
|
+
remember directory URLs. Let's Encrypt stays the default everywhere else.
|
|
417
|
+
|
|
418
|
+
```python
|
|
419
|
+
from devnomads.acme import AcmeClient
|
|
420
|
+
|
|
421
|
+
# Let's Encrypt (default CA) - no EAB needed.
|
|
422
|
+
acme = AcmeClient.for_provider("letsencrypt", "/etc/devnomads/account.key")
|
|
423
|
+
|
|
424
|
+
# ZeroSSL - requires External Account Binding credentials from your
|
|
425
|
+
# ZeroSSL account (Developer -> EAB Credentials, or the REST API).
|
|
426
|
+
acme = AcmeClient.for_provider(
|
|
427
|
+
"zerossl",
|
|
428
|
+
"/etc/devnomads/zerossl-account.key",
|
|
429
|
+
contact_email="ops@example.com",
|
|
430
|
+
eab_kid="...", # EAB key id
|
|
431
|
+
eab_hmac_key="...", # base64url EAB HMAC key
|
|
432
|
+
)
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Issuance is otherwise identical - the same `obtain_certificate` calls,
|
|
436
|
+
DNS-01/HTTP-01 solvers, and chain selection apply to every provider. The
|
|
437
|
+
directory URL and EAB fields can also be passed straight to the
|
|
438
|
+
`AcmeClient(...)` constructor if you prefer not to use the alias.
|
|
439
|
+
|
|
405
440
|
### Key helpers
|
|
406
441
|
|
|
407
442
|
```python
|
|
@@ -412,7 +447,7 @@ from devnomads.acme import (
|
|
|
412
447
|
load_or_create_account_key,
|
|
413
448
|
)
|
|
414
449
|
|
|
415
|
-
key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384
|
|
450
|
+
key = generate_key("rsa4096") # rsa2048/rsa4096/ec256/ec384/ec521
|
|
416
451
|
pem = serialize_key(key) # unencrypted PEM bytes
|
|
417
452
|
csr = build_csr(key, ["example.com", "www.example.com"])
|
|
418
453
|
jwk = load_or_create_account_key("/etc/devnomads/account.key", "ec256")
|
|
@@ -487,8 +522,8 @@ logging.getLogger("devnomads").setLevel(logging.INFO)
|
|
|
487
522
|
presentation.
|
|
488
523
|
- **Layered by dependency weight.** DNS-only consumers never pull the
|
|
489
524
|
ACME stack; the heavy dependencies live behind the `acme` extra.
|
|
490
|
-
- **One credential scheme** shared with the DevNomads
|
|
491
|
-
configured for `
|
|
525
|
+
- **One credential scheme** shared with the DevNomads CLI, so a host
|
|
526
|
+
configured for `dncli` works unchanged.
|
|
492
527
|
|
|
493
528
|
## Development
|
|
494
529
|
|
|
@@ -4,12 +4,12 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devnomads"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.5"
|
|
8
8
|
description = "Python client library for the DevNomads API (transport, DNS, ACME)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
|
-
authors = [{ name = "
|
|
12
|
+
authors = [{ name = "DevNomads", email = "support@devnomads.nl" }]
|
|
13
13
|
keywords = ["devnomads", "dns", "acme", "powerdns", "api"]
|
|
14
14
|
dependencies = ["httpx>=0.27"]
|
|
15
15
|
|
|
@@ -17,7 +17,14 @@ _EXTRA_MODULES = {"acme", "josepy", "cryptography", "dns"}
|
|
|
17
17
|
|
|
18
18
|
try:
|
|
19
19
|
from .challenge_server import ChallengeServer
|
|
20
|
-
from .client import
|
|
20
|
+
from .client import (
|
|
21
|
+
CA_DIRECTORIES,
|
|
22
|
+
DEFAULT_DIRECTORY_URL,
|
|
23
|
+
LETSENCRYPT_DIRECTORY_URL,
|
|
24
|
+
LETSENCRYPT_STAGING_DIRECTORY_URL,
|
|
25
|
+
ZEROSSL_DIRECTORY_URL,
|
|
26
|
+
AcmeClient,
|
|
27
|
+
)
|
|
21
28
|
from .dns01 import DevNomadsDnsProvider, DnsProvider
|
|
22
29
|
from .errors import AcmeError
|
|
23
30
|
from .http01 import Http01Solver, StandaloneSolver, WebrootSolver
|
|
@@ -40,6 +47,10 @@ except ImportError as exc: # pragma: no cover - exercised via packaging
|
|
|
40
47
|
__all__ = [
|
|
41
48
|
"AcmeClient",
|
|
42
49
|
"DEFAULT_DIRECTORY_URL",
|
|
50
|
+
"LETSENCRYPT_DIRECTORY_URL",
|
|
51
|
+
"LETSENCRYPT_STAGING_DIRECTORY_URL",
|
|
52
|
+
"ZEROSSL_DIRECTORY_URL",
|
|
53
|
+
"CA_DIRECTORIES",
|
|
43
54
|
"AcmeError",
|
|
44
55
|
"DnsProvider",
|
|
45
56
|
"DevNomadsDnsProvider",
|
|
@@ -25,7 +25,25 @@ from .verify import verify_txt_record
|
|
|
25
25
|
|
|
26
26
|
log = logging.getLogger("devnomads.acme")
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
LETSENCRYPT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
|
|
29
|
+
LETSENCRYPT_STAGING_DIRECTORY_URL = (
|
|
30
|
+
"https://acme-staging-v02.api.letsencrypt.org/directory"
|
|
31
|
+
)
|
|
32
|
+
ZEROSSL_DIRECTORY_URL = "https://acme.zerossl.com/v2/DV90"
|
|
33
|
+
|
|
34
|
+
# Let's Encrypt remains the default CA everywhere it is not specified.
|
|
35
|
+
DEFAULT_DIRECTORY_URL = LETSENCRYPT_DIRECTORY_URL
|
|
36
|
+
|
|
37
|
+
# Named ACME providers, resolvable via :meth:`AcmeClient.for_provider`.
|
|
38
|
+
CA_DIRECTORIES = {
|
|
39
|
+
"letsencrypt": LETSENCRYPT_DIRECTORY_URL,
|
|
40
|
+
"letsencrypt-staging": LETSENCRYPT_STAGING_DIRECTORY_URL,
|
|
41
|
+
"zerossl": ZEROSSL_DIRECTORY_URL,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Providers that mandate External Account Binding (RFC 8555 §7.3.4) for new
|
|
45
|
+
# account registration.
|
|
46
|
+
_EAB_REQUIRED_PROVIDERS = frozenset({"zerossl"})
|
|
29
47
|
|
|
30
48
|
|
|
31
49
|
def _challenge_for_authz(challenge_types_map: dict[str, str], authz_body) -> str | None:
|
|
@@ -61,7 +79,14 @@ class AcmeClient:
|
|
|
61
79
|
preferred_chain: str | None = None,
|
|
62
80
|
recursive_nameservers: list[str] | None = None,
|
|
63
81
|
user_agent: str = "devnomads",
|
|
82
|
+
eab_kid: str | None = None,
|
|
83
|
+
eab_hmac_key: str | None = None,
|
|
84
|
+
eab_hmac_alg: str = "HS256",
|
|
64
85
|
) -> None:
|
|
86
|
+
if bool(eab_kid) != bool(eab_hmac_key):
|
|
87
|
+
raise AcmeError(
|
|
88
|
+
"external account binding requires both eab_kid and eab_hmac_key"
|
|
89
|
+
)
|
|
65
90
|
self.account_key_path = account_key_path
|
|
66
91
|
self.directory_url = directory_url
|
|
67
92
|
self.account_key_algorithm = account_key_algorithm
|
|
@@ -69,6 +94,39 @@ class AcmeClient:
|
|
|
69
94
|
self.preferred_chain = preferred_chain
|
|
70
95
|
self.recursive_nameservers = recursive_nameservers
|
|
71
96
|
self.user_agent = user_agent
|
|
97
|
+
self.eab_kid = eab_kid
|
|
98
|
+
self.eab_hmac_key = eab_hmac_key
|
|
99
|
+
self.eab_hmac_alg = eab_hmac_alg
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def for_provider(
|
|
103
|
+
cls, provider: str, account_key_path: str, **kwargs
|
|
104
|
+
) -> "AcmeClient":
|
|
105
|
+
"""Build a client for a named CA from :data:`CA_DIRECTORIES`.
|
|
106
|
+
|
|
107
|
+
``provider`` is one of ``"letsencrypt"`` (the default CA),
|
|
108
|
+
``"letsencrypt-staging"`` or ``"zerossl"``. Providers that mandate
|
|
109
|
+
External Account Binding (e.g. ZeroSSL) require ``eab_kid`` and
|
|
110
|
+
``eab_hmac_key`` keyword arguments. Any other keyword is forwarded to
|
|
111
|
+
:class:`AcmeClient`.
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
directory_url = CA_DIRECTORIES[provider]
|
|
116
|
+
except KeyError:
|
|
117
|
+
raise AcmeError(
|
|
118
|
+
f"unknown ACME provider {provider!r}; "
|
|
119
|
+
f"known providers: {', '.join(sorted(CA_DIRECTORIES))}"
|
|
120
|
+
) from None
|
|
121
|
+
if provider in _EAB_REQUIRED_PROVIDERS and not (
|
|
122
|
+
kwargs.get("eab_kid") and kwargs.get("eab_hmac_key")
|
|
123
|
+
):
|
|
124
|
+
raise AcmeError(
|
|
125
|
+
f"provider {provider!r} requires external account binding; "
|
|
126
|
+
"pass eab_kid and eab_hmac_key"
|
|
127
|
+
)
|
|
128
|
+
kwargs.pop("directory_url", None)
|
|
129
|
+
return cls(account_key_path, directory_url=directory_url, **kwargs)
|
|
72
130
|
|
|
73
131
|
# -- account / client setup -------------------------------------------
|
|
74
132
|
|
|
@@ -84,11 +142,23 @@ class AcmeClient:
|
|
|
84
142
|
directory = messages.Directory.from_json(net.get(self.directory_url).json())
|
|
85
143
|
client = acme_client.ClientV2(directory, net)
|
|
86
144
|
|
|
87
|
-
|
|
145
|
+
registration_kwargs = dict(
|
|
88
146
|
contact=(f"mailto:{self.contact_email}",) if self.contact_email else (),
|
|
89
147
|
terms_of_service_agreed=True,
|
|
90
148
|
only_return_existing=False,
|
|
91
149
|
)
|
|
150
|
+
if self.eab_kid and self.eab_hmac_key:
|
|
151
|
+
log.debug("attaching external account binding (kid=%s)", self.eab_kid)
|
|
152
|
+
registration_kwargs["external_account_binding"] = (
|
|
153
|
+
messages.ExternalAccountBinding.from_data(
|
|
154
|
+
account_key.public_key(),
|
|
155
|
+
self.eab_kid,
|
|
156
|
+
self.eab_hmac_key,
|
|
157
|
+
directory,
|
|
158
|
+
hmac_alg=self.eab_hmac_alg,
|
|
159
|
+
)
|
|
160
|
+
)
|
|
161
|
+
new_account = messages.NewRegistration(**registration_kwargs)
|
|
92
162
|
|
|
93
163
|
account = None
|
|
94
164
|
try:
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"""Credential resolution shared by every DevNomads tool.
|
|
2
2
|
|
|
3
|
-
The scheme matches what ``
|
|
4
|
-
up for
|
|
3
|
+
The scheme matches what ``dncli configure`` writes, so a host already set
|
|
4
|
+
up for dncli works with no extra configuration. Resolution order for the
|
|
5
5
|
API key:
|
|
6
6
|
|
|
7
7
|
1. an explicit value passed by the caller,
|
|
@@ -10,7 +10,7 @@ API key:
|
|
|
10
10
|
|
|
11
11
|
The credentials file is found at ``DN_CREDENTIALS_FILE`` if set, otherwise
|
|
12
12
|
the first that exists of ``/etc/devnomads/credentials`` and
|
|
13
|
-
``<config dir>/credentials`` (the
|
|
13
|
+
``<config dir>/credentials`` (the dncli config dir). The profile is the
|
|
14
14
|
caller's argument, else ``DN_PROFILE``, else ``default``.
|
|
15
15
|
"""
|
|
16
16
|
|
|
@@ -56,10 +56,10 @@ def _first_env(names: tuple[str, ...]) -> str | None:
|
|
|
56
56
|
|
|
57
57
|
|
|
58
58
|
def config_dir() -> Path:
|
|
59
|
-
"""The
|
|
59
|
+
"""The dncli config directory, matching dncli's own resolution.
|
|
60
60
|
|
|
61
|
-
``DN_CONFIG_DIR`` wins, then ``$XDG_CONFIG_HOME/
|
|
62
|
-
``~/.config/
|
|
61
|
+
``DN_CONFIG_DIR`` wins, then ``$XDG_CONFIG_HOME/dncli``, then
|
|
62
|
+
``~/.config/dncli``.
|
|
63
63
|
"""
|
|
64
64
|
|
|
65
65
|
override = os.environ.get(ENV_CONFIG_DIR)
|
|
@@ -67,7 +67,7 @@ def config_dir() -> Path:
|
|
|
67
67
|
return Path(override)
|
|
68
68
|
xdg = os.environ.get("XDG_CONFIG_HOME")
|
|
69
69
|
base = Path(xdg) if xdg else Path.home() / ".config"
|
|
70
|
-
return base / "
|
|
70
|
+
return base / "dncli"
|
|
71
71
|
|
|
72
72
|
|
|
73
73
|
def credentials_path() -> Path:
|
|
@@ -119,7 +119,7 @@ def resolve(
|
|
|
119
119
|
if not resolved_key:
|
|
120
120
|
searched = ", ".join(str(p) for p in candidates)
|
|
121
121
|
raise ConfigError(
|
|
122
|
-
"no API key found - set DN_API_KEY or run '
|
|
122
|
+
"no API key found - set DN_API_KEY or run 'dncli configure' to "
|
|
123
123
|
f"create a credentials file (searched: {searched})"
|
|
124
124
|
)
|
|
125
125
|
return Credentials(api_key=resolved_key.strip(), api_url=resolved_url)
|
|
@@ -122,4 +122,108 @@ def test_init_client_registers_with_new_registration(monkeypatch, tmp_path):
|
|
|
122
122
|
registration = captured["registration"]
|
|
123
123
|
assert isinstance(registration, messages.NewRegistration)
|
|
124
124
|
assert registration.contact == ("mailto:ops@example.com",)
|
|
125
|
+
# without EAB credentials, no external account binding is attached
|
|
126
|
+
assert not registration.external_account_binding
|
|
125
127
|
assert client.net.account is not None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _patch_fake_network(monkeypatch, client_mod, account_key, captured, directory):
|
|
131
|
+
from types import SimpleNamespace
|
|
132
|
+
|
|
133
|
+
class FakeResponse:
|
|
134
|
+
def json(self):
|
|
135
|
+
return {}
|
|
136
|
+
|
|
137
|
+
class FakeNetwork:
|
|
138
|
+
def __init__(self, *args, **kwargs):
|
|
139
|
+
self.account = None
|
|
140
|
+
|
|
141
|
+
def get(self, url):
|
|
142
|
+
return FakeResponse()
|
|
143
|
+
|
|
144
|
+
class FakeClientV2:
|
|
145
|
+
def __init__(self, directory, net):
|
|
146
|
+
self.net = net
|
|
147
|
+
|
|
148
|
+
def new_account(self, registration):
|
|
149
|
+
captured["registration"] = registration
|
|
150
|
+
return SimpleNamespace(uri="https://acme.example/acct/1")
|
|
151
|
+
|
|
152
|
+
monkeypatch.setattr(
|
|
153
|
+
client_mod, "load_or_create_account_key", lambda *a, **k: account_key
|
|
154
|
+
)
|
|
155
|
+
monkeypatch.setattr(client_mod.acme_client, "ClientNetwork", FakeNetwork)
|
|
156
|
+
monkeypatch.setattr(client_mod.acme_client, "ClientV2", FakeClientV2)
|
|
157
|
+
monkeypatch.setattr(
|
|
158
|
+
client_mod.messages.Directory, "from_json", lambda data: directory
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_init_client_attaches_external_account_binding(monkeypatch, tmp_path):
|
|
163
|
+
"""When EAB credentials are supplied, _init_client must attach an
|
|
164
|
+
externalAccountBinding to the NewRegistration (required by ZeroSSL)."""
|
|
165
|
+
|
|
166
|
+
from acme import messages
|
|
167
|
+
from cryptography.hazmat.backends import default_backend
|
|
168
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
169
|
+
from josepy import jwk
|
|
170
|
+
|
|
171
|
+
from devnomads.acme import client as client_mod
|
|
172
|
+
|
|
173
|
+
captured = {}
|
|
174
|
+
account_key = jwk.JWKEC(
|
|
175
|
+
key=ec.generate_private_key(ec.SECP256R1(), backend=default_backend())
|
|
176
|
+
)
|
|
177
|
+
directory = messages.Directory.from_json(
|
|
178
|
+
{"newAccount": "https://acme.example/new-acct"}
|
|
179
|
+
)
|
|
180
|
+
_patch_fake_network(monkeypatch, client_mod, account_key, captured, directory)
|
|
181
|
+
|
|
182
|
+
ac = client_mod.AcmeClient(
|
|
183
|
+
str(tmp_path / "account.pem"),
|
|
184
|
+
directory_url=client_mod.ZEROSSL_DIRECTORY_URL,
|
|
185
|
+
eab_kid="kid-123",
|
|
186
|
+
# HMAC key must be valid base64; this is "hmac-secret" encoded.
|
|
187
|
+
eab_hmac_key="aG1hYy1zZWNyZXQ",
|
|
188
|
+
)
|
|
189
|
+
ac._init_client()
|
|
190
|
+
|
|
191
|
+
registration = captured["registration"]
|
|
192
|
+
assert registration.external_account_binding # signed JWS dict
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_for_provider_resolves_directory_urls(tmp_path):
|
|
196
|
+
from devnomads.acme import client as client_mod
|
|
197
|
+
|
|
198
|
+
le = client_mod.AcmeClient.for_provider("letsencrypt", str(tmp_path / "a.pem"))
|
|
199
|
+
assert le.directory_url == client_mod.LETSENCRYPT_DIRECTORY_URL
|
|
200
|
+
|
|
201
|
+
zs = client_mod.AcmeClient.for_provider(
|
|
202
|
+
"zerossl",
|
|
203
|
+
str(tmp_path / "b.pem"),
|
|
204
|
+
eab_kid="kid-123",
|
|
205
|
+
eab_hmac_key="aG1hYy1zZWNyZXQ",
|
|
206
|
+
)
|
|
207
|
+
assert zs.directory_url == client_mod.ZEROSSL_DIRECTORY_URL
|
|
208
|
+
assert zs.eab_kid == "kid-123"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def test_for_provider_unknown_raises(tmp_path):
|
|
212
|
+
from devnomads.acme import client as client_mod
|
|
213
|
+
|
|
214
|
+
with pytest.raises(AcmeError):
|
|
215
|
+
client_mod.AcmeClient.for_provider("notaca", str(tmp_path / "a.pem"))
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def test_for_provider_zerossl_requires_eab(tmp_path):
|
|
219
|
+
from devnomads.acme import client as client_mod
|
|
220
|
+
|
|
221
|
+
with pytest.raises(AcmeError):
|
|
222
|
+
client_mod.AcmeClient.for_provider("zerossl", str(tmp_path / "a.pem"))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def test_partial_eab_credentials_raise(tmp_path):
|
|
226
|
+
from devnomads.acme import client as client_mod
|
|
227
|
+
|
|
228
|
+
with pytest.raises(AcmeError):
|
|
229
|
+
client_mod.AcmeClient(str(tmp_path / "a.pem"), eab_kid="kid-only")
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|