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.
- {devnomads-0.6.0 → devnomads-0.6.1}/PKG-INFO +1 -1
- {devnomads-0.6.0 → devnomads-0.6.1}/pyproject.toml +1 -1
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/verify.py +33 -4
- devnomads-0.6.1/tests/test_acme_verify.py +106 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/uv.lock +1 -1
- {devnomads-0.6.0 → devnomads-0.6.1}/.flake8 +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/.gitignore +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/.gitlab-ci.yml +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/Makefile +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/README.md +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/openapi.json +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/__init__.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/__init__.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/challenge_server.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/client.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/dns01.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/errors.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/http01.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/acme/keys.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/__init__.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/_services.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/client.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/credentials.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/api/errors.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/__init__.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/errors.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/names.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/dns/zones.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/src/devnomads/py.typed +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/conftest.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_challenges.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_client.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_acme_keys.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_client.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_credentials.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_dns.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_names.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tests/test_services.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tools/bump_version.py +0 -0
- {devnomads-0.6.0 → devnomads-0.6.1}/tools/generate.py +0 -0
|
@@ -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 =
|
|
97
|
+
challenge = challenge_record_name(fqdn, nameservers)
|
|
69
98
|
base_resolver = _make_resolver(nameservers)
|
|
70
|
-
auth_ns: list[str | None] = list(authoritative_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",
|
|
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
|
+
)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|