Skip to content

Commit bc9e9cf

Browse files
authored
Implement NSEC record parsing (#903)
- This is needed for negative responses https://datatracker.ietf.org/doc/html/rfc6762#section-6.1
1 parent 9399c57 commit bc9e9cf

7 files changed

Lines changed: 111 additions & 7 deletions

File tree

tests/test_asyncio.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ async def test_async_with_sync_passed_in_closed_in_async() -> None:
104104
@pytest.mark.asyncio
105105
async def test_sync_within_event_loop_executor() -> None:
106106
"""Test sync version still works from an executor within an event loop."""
107+
107108
def sync_code():
108109
zc = Zeroconf(interfaces=['127.0.0.1'])
109110
assert zc.get_service_info("_neverused._tcp.local.", "xneverused._neverused._tcp.local.", 10) is None

tests/test_dns.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,31 @@ def test_dns_service_record_hashablity():
288288
assert len(record_set) == 4
289289

290290

291+
def test_dns_nsec_record_hashablity():
292+
"""Test DNSNsec are hashable."""
293+
nsec1 = r.DNSNsec(
294+
'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2, 3]
295+
)
296+
nsec2 = r.DNSNsec(
297+
'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2]
298+
)
299+
300+
record_set = set([nsec1, nsec2])
301+
assert len(record_set) == 2
302+
303+
record_set.add(nsec1)
304+
assert len(record_set) == 2
305+
306+
nsec2_dupe = r.DNSNsec(
307+
'irrelevant', const._TYPE_PTR, const._CLASS_IN, const._DNS_OTHER_TTL, 'irrelevant', [1, 2]
308+
)
309+
assert nsec2 == nsec2_dupe
310+
assert nsec2.__hash__() == nsec2_dupe.__hash__()
311+
312+
record_set.add(nsec2_dupe)
313+
assert len(record_set) == 2
314+
315+
291316
def test_rrset_does_not_consider_ttl():
292317
"""Test DNSRRSet does not consider the ttl in the hash."""
293318

tests/test_protocol.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -722,6 +722,21 @@ def test_qu_packet_parser():
722722
assert ",QU," in str(parsed.questions[0])
723723

724724

725+
def test_parse_packet_with_nsec_record():
726+
"""Test we can parse a packet with an NSEC record."""
727+
nsec_packet = (
728+
b"\x00\x00\x84\x00\x00\x00\x00\x01\x00\x00\x00\x03\x08_meshcop\x04_udp\x05local\x00\x00\x0c\x00"
729+
b"\x01\x00\x00\x11\x94\x00\x0f\x0cMyHome54 (2)\xc0\x0c\xc0+\x00\x10\x80\x01\x00\x00\x11\x94\x00"
730+
b")\x0bnn=MyHome54\x13xp=695034D148CC4784\x08tv=0.0.0\xc0+\x00!\x80\x01\x00\x00\x00x\x00\x15\x00"
731+
b"\x00\x00\x00\xc0'\x0cMaster-Bed-2\xc0\x1a\xc0+\x00/\x80\x01\x00\x00\x11\x94\x00\t\xc0+\x00\x05"
732+
b"\x00\x00\x80\x00@"
733+
)
734+
parsed = DNSIncoming(nsec_packet)
735+
nsec_record = parsed.answers[3]
736+
assert "nsec," in str(nsec_record)
737+
assert nsec_record.rdtypes == [16, 33]
738+
739+
725740
def test_records_same_packet_share_fate():
726741
"""Test records in the same packet all have the same created time."""
727742
out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA)

zeroconf/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
DNSAddress,
2929
DNSEntry,
3030
DNSHinfo,
31+
DNSNsec,
3132
DNSPointer,
3233
DNSQuestion,
3334
DNSRecord,

zeroconf/_dns.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222

2323
import enum
2424
import socket
25-
from typing import Any, Dict, Iterable, Optional, TYPE_CHECKING, Tuple, Union, cast
25+
from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Tuple, Union, cast
2626

2727
from ._exceptions import AbstractMethodException
2828
from ._utils.net import _is_v6_address
@@ -116,11 +116,7 @@ class DNSQuestion(DNSEntry):
116116

117117
def answered_by(self, rec: 'DNSRecord') -> bool:
118118
"""Returns true if the question is answered by the record"""
119-
return (
120-
self.class_ == rec.class_
121-
and (self.type == rec.type or self.type == _TYPE_ANY)
122-
and self.name == rec.name
123-
)
119+
return self.class_ == rec.class_ and self.type in (rec.type, _TYPE_ANY) and self.name == rec.name
124120

125121
def __hash__(self) -> int:
126122
return hash((self.name, self.class_, self.type))
@@ -446,6 +442,44 @@ def __repr__(self) -> str:
446442
return self.to_string("%s:%s" % (self.server, self.port))
447443

448444

445+
class DNSNsec(DNSRecord):
446+
447+
"""A DNS NSEC record"""
448+
449+
__slots__ = ('next', 'rdtypes')
450+
451+
def __init__(
452+
self,
453+
name: str,
454+
type_: int,
455+
class_: int,
456+
ttl: int,
457+
next: str,
458+
rdtypes: List[int],
459+
created: Optional[float] = None,
460+
) -> None:
461+
super().__init__(name, type_, class_, ttl, created)
462+
self.next = next
463+
self.rdtypes = rdtypes
464+
465+
def __eq__(self, other: Any) -> bool:
466+
"""Tests equality on cpu and os"""
467+
return (
468+
isinstance(other, DNSNsec)
469+
and self.next == other.next
470+
and self.rdtypes == other.rdtypes
471+
and DNSEntry.__eq__(self, other)
472+
)
473+
474+
def __hash__(self) -> int:
475+
"""Hash to compare like DNSNSec."""
476+
return hash((*self._entry_tuple(), self.next, *self.rdtypes))
477+
478+
def __repr__(self) -> str:
479+
"""String representation"""
480+
return self.to_string(self.next + "," + "|".join([self.get_type(type_) for type_ in self.rdtypes]))
481+
482+
449483
class DNSRRSet:
450484
"""A set of dns records independent of the ttl."""
451485

zeroconf/_protocol.py

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Tuple, Union, cast
2626

2727

28-
from ._dns import DNSAddress, DNSHinfo, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText
28+
from ._dns import DNSAddress, DNSHinfo, DNSNsec, DNSPointer, DNSQuestion, DNSRecord, DNSService, DNSText
2929
from ._exceptions import IncomingDecodeError, NamePartTooLongException
3030
from ._logger import QuietLogger, log
3131
from ._utils.struct import int2byte
@@ -43,6 +43,7 @@
4343
_TYPE_AAAA,
4444
_TYPE_CNAME,
4545
_TYPE_HINFO,
46+
_TYPE_NSEC,
4647
_TYPE_PTR,
4748
_TYPE_SRV,
4849
_TYPE_TXT,
@@ -201,6 +202,18 @@ def read_others(self) -> None:
201202
rec = DNSAddress(
202203
domain, type_, class_, ttl, self.read_string(16), created=self.now, scope_id=self.scope_id
203204
)
205+
elif type_ == _TYPE_NSEC:
206+
name_start = self.offset
207+
name = self.read_name()
208+
rec = DNSNsec(
209+
domain,
210+
type_,
211+
class_,
212+
ttl,
213+
name,
214+
self.read_bitmap(name_start + length),
215+
self.now,
216+
)
204217
else:
205218
# Try to ignore types we don't know about
206219
# Skip the payload for the resource record so the next
@@ -210,6 +223,19 @@ def read_others(self) -> None:
210223
if rec is not None:
211224
self.answers.append(rec)
212225

226+
def read_bitmap(self, end: int) -> List[int]:
227+
"""Reads an NSEC bitmap from the packet."""
228+
rdtypes = []
229+
while self.offset < end:
230+
window = self.data[self.offset]
231+
bitmap_length = self.data[self.offset + 1]
232+
for i, byte in enumerate(self.data[self.offset + 2 : self.offset + 2 + bitmap_length]):
233+
for bit in range(0, 8):
234+
if byte & (0x80 >> bit):
235+
rdtypes.append(bit + window * 256 + i * 8)
236+
self.offset += 2 + bitmap_length
237+
return rdtypes
238+
213239
def read_name(self) -> str:
214240
"""Reads a domain name from the packet"""
215241
result = ''

zeroconf/const.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
_TYPE_TXT = 16
104104
_TYPE_AAAA = 28
105105
_TYPE_SRV = 33
106+
_TYPE_NSEC = 47
106107
_TYPE_ANY = 255
107108

108109
# Mapping constants to names
@@ -136,6 +137,7 @@
136137
_TYPE_AAAA: "quada",
137138
_TYPE_SRV: "srv",
138139
_TYPE_ANY: "any",
140+
_TYPE_NSEC: "nsec",
139141
}
140142

141143
_HAS_A_TO_Z = re.compile(r'[A-Za-z]')

0 commit comments

Comments
 (0)