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.
Files changed (39) hide show
  1. {devnomads-0.2.3 → devnomads-0.2.5}/PKG-INFO +48 -13
  2. {devnomads-0.2.3 → devnomads-0.2.5}/README.md +46 -11
  3. {devnomads-0.2.3 → devnomads-0.2.5}/pyproject.toml +2 -2
  4. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/__init__.py +12 -1
  5. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/client.py +72 -2
  6. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/credentials.py +8 -8
  7. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_client.py +104 -0
  8. {devnomads-0.2.3 → devnomads-0.2.5}/uv.lock +1 -1
  9. {devnomads-0.2.3 → devnomads-0.2.5}/.flake8 +0 -0
  10. {devnomads-0.2.3 → devnomads-0.2.5}/.gitignore +0 -0
  11. {devnomads-0.2.3 → devnomads-0.2.5}/.gitlab-ci.yml +0 -0
  12. {devnomads-0.2.3 → devnomads-0.2.5}/Makefile +0 -0
  13. {devnomads-0.2.3 → devnomads-0.2.5}/openapi.json +0 -0
  14. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/__init__.py +0 -0
  15. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/challenge_server.py +0 -0
  16. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/dns01.py +0 -0
  17. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/errors.py +0 -0
  18. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/acme/verify.py +0 -0
  21. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/__init__.py +0 -0
  22. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/_services.py +0 -0
  23. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/client.py +0 -0
  24. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/api/errors.py +0 -0
  25. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/__init__.py +0 -0
  26. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/errors.py +0 -0
  27. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/names.py +0 -0
  28. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/dns/zones.py +0 -0
  29. {devnomads-0.2.3 → devnomads-0.2.5}/src/devnomads/py.typed +0 -0
  30. {devnomads-0.2.3 → devnomads-0.2.5}/tests/conftest.py +0 -0
  31. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_challenges.py +0 -0
  32. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_acme_keys.py +0 -0
  33. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_client.py +0 -0
  34. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_credentials.py +0 -0
  35. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_dns.py +0 -0
  36. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_names.py +0 -0
  37. {devnomads-0.2.3 → devnomads-0.2.5}/tests/test_services.py +0 -0
  38. {devnomads-0.2.3 → devnomads-0.2.5}/tools/bump_version.py +0 -0
  39. {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
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: Loek Geleijn <support@devnomads.nl>
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 tools - `dnctl`
25
- and `dnsync` - exposed so you can build against the same primitives
26
- directly instead of reimplementing them.
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, compatible with `dnctl` and `dnsync`.
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/dnctl`, else
139
- `~/.config/dnctl` - i.e. the file `dnctl configure` writes. The profile is
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/dnctl`).
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 CLIs, so a host
508
- configured for `dnctl` works unchanged.
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 tools - `dnctl`
8
- and `dnsync` - exposed so you can build against the same primitives
9
- directly instead of reimplementing them.
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, compatible with `dnctl` and `dnsync`.
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/dnctl`, else
122
- `~/.config/dnctl` - i.e. the file `dnctl configure` writes. The profile is
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/dnctl`).
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 CLIs, so a host
491
- configured for `dnctl` works unchanged.
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.3"
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 = "Loek Geleijn", email = "support@devnomads.nl" }]
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 DEFAULT_DIRECTORY_URL, AcmeClient
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
- DEFAULT_DIRECTORY_URL = "https://acme-v02.api.letsencrypt.org/directory"
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
- new_account = messages.NewRegistration(
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 ``dnctl configure`` writes, so a host already set
4
- up for dnctl works with no extra configuration. Resolution order for the
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 dnctl config dir). The profile is 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 dnctl config directory, matching dnctl's own resolution.
59
+ """The dncli config directory, matching dncli's own resolution.
60
60
 
61
- ``DN_CONFIG_DIR`` wins, then ``$XDG_CONFIG_HOME/dnctl``, then
62
- ``~/.config/dnctl``.
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 / "dnctl"
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 'dnctl configure' to "
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")
@@ -468,7 +468,7 @@ wheels = [
468
468
 
469
469
  [[package]]
470
470
  name = "devnomads"
471
- version = "0.2.3"
471
+ version = "0.2.4"
472
472
  source = { editable = "." }
473
473
  dependencies = [
474
474
  { name = "httpx" },
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