devnomads 0.6.0__tar.gz → 0.6.1__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 (40) hide show
  1. {devnomads-0.6.0 → devnomads-0.6.1}/PKG-INFO +1 -1
  2. {devnomads-0.6.0 → devnomads-0.6.1}/pyproject.toml +1 -1
  3. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/verify.py +33 -4
  4. devnomads-0.6.1/tests/test_acme_verify.py +106 -0
  5. {devnomads-0.6.0 → devnomads-0.6.1}/uv.lock +1 -1
  6. {devnomads-0.6.0 → devnomads-0.6.1}/.flake8 +0 -0
  7. {devnomads-0.6.0 → devnomads-0.6.1}/.gitignore +0 -0
  8. {devnomads-0.6.0 → devnomads-0.6.1}/.gitlab-ci.yml +0 -0
  9. {devnomads-0.6.0 → devnomads-0.6.1}/Makefile +0 -0
  10. {devnomads-0.6.0 → devnomads-0.6.1}/README.md +0 -0
  11. {devnomads-0.6.0 → devnomads-0.6.1}/openapi.json +0 -0
  12. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/__init__.py +0 -0
  13. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/__init__.py +0 -0
  14. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/challenge_server.py +0 -0
  15. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/client.py +0 -0
  16. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/dns01.py +0 -0
  17. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/errors.py +0 -0
  18. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/http01.py +0 -0
  19. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/keys.py +0 -0
  20. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/__init__.py +0 -0
  21. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/_services.py +0 -0
  22. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/client.py +0 -0
  23. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/credentials.py +0 -0
  24. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/errors.py +0 -0
  25. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/__init__.py +0 -0
  26. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/errors.py +0 -0
  27. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/names.py +0 -0
  28. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/zones.py +0 -0
  29. {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/py.typed +0 -0
  30. {devnomads-0.6.0 → devnomads-0.6.1}/tests/conftest.py +0 -0
  31. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_challenges.py +0 -0
  32. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_client.py +0 -0
  33. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_keys.py +0 -0
  34. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_client.py +0 -0
  35. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_credentials.py +0 -0
  36. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_dns.py +0 -0
  37. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_names.py +0 -0
  38. {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_services.py +0 -0
  39. {devnomads-0.6.0 → devnomads-0.6.1}/tools/bump_version.py +0 -0
  40. {devnomads-0.6.0 → devnomads-0.6.1}/tools/generate.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: devnomads
3
- Version: 0.6.0
3
+ Version: 0.6.1
4
4
  Summary: Python client library for the DevNomads API (transport, DNS, ACME)
5
5
  Project-URL: Homepage, https://devnomads.nl
6
6
  Author-email: DevNomads <support@devnomads.nl>
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "devnomads"
7
- version = "0.6.0"
7
+ version = "0.6.1"
8
8
  description = "Python client library for the DevNomads API (transport, DNS, ACME)"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -15,6 +15,9 @@ import dns.resolver
15
15
 
16
16
  log = logging.getLogger("devnomads.acme")
17
17
 
18
+ # Bounds a CNAME chain at _acme-challenge (and breaks loops).
19
+ _MAX_CNAME_HOPS = 8
20
+
18
21
 
19
22
  def _make_resolver(nameservers: list[str] | None) -> dns.resolver.Resolver:
20
23
  resolver = dns.resolver.Resolver()
@@ -54,6 +57,27 @@ def authoritative_nameservers(
54
57
  return []
55
58
 
56
59
 
60
+ def challenge_record_name(fqdn: str, nameservers: list[str] | None = None) -> str:
61
+ """Return the name that actually holds the TXT record for ``fqdn``.
62
+
63
+ Normally ``_acme-challenge.<fqdn>``. When that name is a CNAME (delegated
64
+ DNS-01: the domain owner points it at a zone the ACME client can write),
65
+ the CA follows the chain, so the record lives at the final target.
66
+ """
67
+
68
+ name = f"_acme-challenge.{fqdn}"
69
+ resolver = _make_resolver(nameservers)
70
+ for _ in range(_MAX_CNAME_HOPS):
71
+ try:
72
+ answers = resolver.resolve(name, "CNAME")
73
+ except dns.exception.DNSException:
74
+ break
75
+ target = str(answers[0].target).rstrip(".")
76
+ log.debug("challenge name %s is a CNAME to %s", name, target)
77
+ name = target
78
+ return name
79
+
80
+
57
81
  def verify_txt_record(
58
82
  fqdn: str,
59
83
  expected_value: str,
@@ -63,13 +87,18 @@ def verify_txt_record(
63
87
  nameservers: list[str] | None = None,
64
88
  ) -> bool:
65
89
  """Poll authoritative nameservers until ``_acme-challenge.<fqdn>`` carries
66
- ``expected_value``. Returns False on timeout."""
90
+ ``expected_value``. Returns False on timeout.
91
+
92
+ A CNAME at ``_acme-challenge.<fqdn>`` is followed: the authoritative
93
+ nameservers of the domain only return the CNAME, so the record is polled
94
+ at the target's nameservers instead.
95
+ """
67
96
 
68
- challenge = f"_acme-challenge.{fqdn}"
97
+ challenge = challenge_record_name(fqdn, nameservers)
69
98
  base_resolver = _make_resolver(nameservers)
70
- auth_ns: list[str | None] = list(authoritative_nameservers(fqdn, nameservers))
99
+ auth_ns: list[str | None] = list(authoritative_nameservers(challenge, nameservers))
71
100
  if not auth_ns:
72
- log.warning("no authoritative NS for %s, using default resolver", fqdn)
101
+ log.warning("no authoritative NS for %s, using default resolver", challenge)
73
102
  auth_ns = [None]
74
103
  else:
75
104
  log.info("polling authoritative nameservers for %s: %s", challenge, auth_ns)
@@ -0,0 +1,106 @@
1
+ """Tests for authoritative DNS-01 TXT verification."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from types import SimpleNamespace
6
+
7
+ import dns.resolver
8
+ import pytest
9
+
10
+ from devnomads.acme import verify
11
+
12
+
13
+ class FakeResolver:
14
+ """Answers from a ``{(name, rdtype): [rdata, ...]}`` table; NXDOMAIN otherwise."""
15
+
16
+ def __init__(self, records):
17
+ self.records = records
18
+ self.nameservers: list[str] = []
19
+
20
+ def resolve(self, name, rdtype):
21
+ try:
22
+ return self.records[(name, rdtype)]
23
+ except KeyError:
24
+ raise dns.resolver.NXDOMAIN from None
25
+
26
+
27
+ def _cname(target):
28
+ return [SimpleNamespace(target=f"{target}.")]
29
+
30
+
31
+ def _ns(*hosts):
32
+ return [SimpleNamespace(target=f"{h}.") for h in hosts]
33
+
34
+
35
+ class _Txt:
36
+ def __init__(self, value):
37
+ self.value = value
38
+
39
+ def __str__(self):
40
+ return f'"{self.value}"'
41
+
42
+
43
+ @pytest.fixture
44
+ def records(monkeypatch):
45
+ table: dict = {}
46
+ monkeypatch.setattr(dns.resolver, "Resolver", lambda: FakeResolver(table))
47
+ monkeypatch.setattr(verify, "_resolve_ns_hostname", lambda host, r: ["192.0.2.1"])
48
+ return table
49
+
50
+
51
+ def test_challenge_record_name_without_cname(records):
52
+ assert verify.challenge_record_name("example.com") == "_acme-challenge.example.com"
53
+
54
+
55
+ def test_challenge_record_name_follows_cname(records):
56
+ records[("_acme-challenge.example.com", "CNAME")] = _cname(
57
+ "example-com.acme.infrapod.nl"
58
+ )
59
+ assert verify.challenge_record_name("example.com") == "example-com.acme.infrapod.nl"
60
+
61
+
62
+ def test_challenge_record_name_follows_chain(records):
63
+ records[("_acme-challenge.example.com", "CNAME")] = _cname("hop.example.net")
64
+ records[("hop.example.net", "CNAME")] = _cname("example-com.acme.infrapod.nl")
65
+ assert verify.challenge_record_name("example.com") == "example-com.acme.infrapod.nl"
66
+
67
+
68
+ def test_challenge_record_name_stops_on_loop(records):
69
+ records[("_acme-challenge.example.com", "CNAME")] = _cname("a.example.net")
70
+ records[("a.example.net", "CNAME")] = _cname("b.example.net")
71
+ records[("b.example.net", "CNAME")] = _cname("a.example.net")
72
+ # Must terminate; the exact name it lands on is irrelevant.
73
+ assert verify.challenge_record_name("example.com") in {
74
+ "a.example.net",
75
+ "b.example.net",
76
+ }
77
+
78
+
79
+ def test_verify_txt_record_polls_cname_target(records):
80
+ """The domain's own nameservers only hold the CNAME; the TXT is checked
81
+ at the target's zone."""
82
+
83
+ records[("_acme-challenge.example.com", "CNAME")] = _cname(
84
+ "example-com.acme.infrapod.nl"
85
+ )
86
+ records[("example.com", "NS")] = _ns("ns1.customer.example")
87
+ records[("acme.infrapod.nl", "NS")] = _ns("one.dns.infrapod.nl")
88
+ records[("example-com.acme.infrapod.nl", "TXT")] = [_Txt("token")]
89
+
90
+ assert verify.verify_txt_record("example.com", "token", timeout=1, interval=0)
91
+
92
+
93
+ def test_verify_txt_record_without_cname(records):
94
+ records[("example.com", "NS")] = _ns("one.dns.infrapod.nl")
95
+ records[("_acme-challenge.example.com", "TXT")] = [_Txt("token")]
96
+
97
+ assert verify.verify_txt_record("example.com", "token", timeout=1, interval=0)
98
+
99
+
100
+ def test_verify_txt_record_times_out_on_missing_value(records):
101
+ records[("example.com", "NS")] = _ns("one.dns.infrapod.nl")
102
+ records[("_acme-challenge.example.com", "TXT")] = [_Txt("other")]
103
+
104
+ assert not verify.verify_txt_record(
105
+ "example.com", "token", timeout=0.05, interval=0
106
+ )
@@ -469,7 +469,7 @@ wheels = [
469
469
 
470
470
  [[package]]
471
471
  name = "devnomads"
472
- version = "0.6.0"
472
+ version = "0.6.1"
473
473
  source = { editable = "." }
474
474
  dependencies = [
475
475
  { 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
File without changes