From 202c9b5f95c8f24303c68cf25a36024dacc84589 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 10:56:42 -0600 Subject: [PATCH 1/6] feat: add simple address resolvers and examples --- src/zeroconf/__init__.py | 3 ++ src/zeroconf/_services/info.py | 72 ++++++++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 12 deletions(-) diff --git a/src/zeroconf/__init__.py b/src/zeroconf/__init__.py index b3361a19..d3e74dfe 100644 --- a/src/zeroconf/__init__.py +++ b/src/zeroconf/__init__.py @@ -58,6 +58,9 @@ from ._services.browser import ServiceBrowser from ._services.info import ( # noqa # import needed for backwards compat ServiceInfo, + AddressResolver, + AddressResolverIPv4, + AddressResolverIPv6, instance_name_from_service_info, ) from ._services.registry import ( # noqa # import needed for backwards compat diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index fd51eee1..889b8435 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -146,6 +146,7 @@ class ServiceInfo(RecordUpdateListener): "_name", "_new_records_futures", "_properties", + "_query_record_types", "host_ttl", "interface_index", "key", @@ -210,6 +211,7 @@ def __init__( self._dns_service_cache: Optional[DNSService] = None self._dns_text_cache: Optional[DNSText] = None self._get_address_and_nsec_records_cache: Optional[Set[DNSRecord]] = None + self._query_record_types = {_TYPE_SRV, _TYPE_TXT, _TYPE_A, _TYPE_AAAA} @property def name(self) -> str: @@ -917,18 +919,22 @@ def _generate_request_query( cache = zc.cache history = zc.question_history qu_question = question_type is QU_QUESTION - self._add_question_with_known_answers( - out, qu_question, history, cache, now, name, _TYPE_SRV, _CLASS_IN, True - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, name, _TYPE_TXT, _CLASS_IN, True - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, server, _TYPE_A, _CLASS_IN, False - ) - self._add_question_with_known_answers( - out, qu_question, history, cache, now, server, _TYPE_AAAA, _CLASS_IN, False - ) + if _TYPE_SRV in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_SRV, _CLASS_IN, True + ) + if _TYPE_TXT in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, name, _TYPE_TXT, _CLASS_IN, True + ) + if _TYPE_A in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_A, _CLASS_IN, False + ) + if _TYPE_AAAA in self._query_record_types: + self._add_question_with_known_answers( + out, qu_question, history, cache, now, server, _TYPE_AAAA, _CLASS_IN, False + ) return out def __repr__(self) -> str: @@ -954,3 +960,45 @@ def __repr__(self) -> str: class AsyncServiceInfo(ServiceInfo): """An async version of ServiceInfo.""" + + +class AddressResolver(ServiceInfo): + """Resolve a host name to an IP address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = {_TYPE_A, _TYPE_AAAA} + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv4_addresses) or bool(self._ipv6_addresses) + + +class AddressResolverIPv6(ServiceInfo): + """Resolve a host name to an IPv6 address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = {_TYPE_AAAA} + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv6_addresses) + + +class AddressResolverIPv4(ServiceInfo): + """Resolve a host name to an IPv4 address.""" + + def __init__(self, server: str) -> None: + """Initialize the AddressResolver.""" + super().__init__(server, server, server=server) + self._query_record_types = {_TYPE_A} + + @property + def _is_complete(self) -> bool: + """The ServiceInfo has all expected properties.""" + return bool(self._ipv4_addresses) From 35ad2b91260175bd489aedd56e333e368bc51d72 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:02:47 -0600 Subject: [PATCH 2/6] feat: optimize construction --- src/zeroconf/_services/info.pxd | 13 +++++++++++++ src/zeroconf/_services/info.py | 10 +++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/zeroconf/_services/info.pxd b/src/zeroconf/_services/info.pxd index 53abe62a..3f65bc0a 100644 --- a/src/zeroconf/_services/info.pxd +++ b/src/zeroconf/_services/info.pxd @@ -22,6 +22,9 @@ from .._utils.ipaddress cimport ( ) from .._utils.time cimport current_time_millis +cdef cython.set _TYPE_AAAA_RECORDS +cdef cython.set _TYPE_A_RECORDS +cdef cython.set _TYPE_A_AAAA_RECORDS cdef object _resolve_all_futures_to_none @@ -75,6 +78,7 @@ cdef class ServiceInfo(RecordUpdateListener): cdef public DNSText _dns_text_cache cdef public cython.list _dns_address_cache cdef public cython.set _get_address_and_nsec_records_cache + cdef public cython.set _query_record_types @cython.locals(record_update=RecordUpdate, update=bint, cache=DNSCache) cpdef void async_update_records(self, object zc, double now, cython.list records) @@ -155,3 +159,12 @@ cdef class ServiceInfo(RecordUpdateListener): cdef double _get_initial_delay(self) cdef double _get_random_delay(self) + +cdef class AddressResolver(ServiceInfo): + pass + +cdef class AddressResolverIPv6(ServiceInfo): + pass + +cdef class AddressResolverIPv4(ServiceInfo): + pass diff --git a/src/zeroconf/_services/info.py b/src/zeroconf/_services/info.py index 889b8435..a6e815b5 100644 --- a/src/zeroconf/_services/info.py +++ b/src/zeroconf/_services/info.py @@ -88,6 +88,10 @@ # the A/AAAA/SRV records for a host. _AVOID_SYNC_DELAY_RANDOM_INTERVAL = (20, 120) +_TYPE_AAAA_RECORDS = {_TYPE_AAAA} +_TYPE_A_RECORDS = {_TYPE_A} +_TYPE_A_AAAA_RECORDS = {_TYPE_A, _TYPE_AAAA} + bytes_ = bytes float_ = float int_ = int @@ -968,7 +972,7 @@ class AddressResolver(ServiceInfo): def __init__(self, server: str) -> None: """Initialize the AddressResolver.""" super().__init__(server, server, server=server) - self._query_record_types = {_TYPE_A, _TYPE_AAAA} + self._query_record_types = _TYPE_A_AAAA_RECORDS @property def _is_complete(self) -> bool: @@ -982,7 +986,7 @@ class AddressResolverIPv6(ServiceInfo): def __init__(self, server: str) -> None: """Initialize the AddressResolver.""" super().__init__(server, server, server=server) - self._query_record_types = {_TYPE_AAAA} + self._query_record_types = _TYPE_AAAA_RECORDS @property def _is_complete(self) -> bool: @@ -996,7 +1000,7 @@ class AddressResolverIPv4(ServiceInfo): def __init__(self, server: str) -> None: """Initialize the AddressResolver.""" super().__init__(server, server, server=server) - self._query_record_types = {_TYPE_A} + self._query_record_types = _TYPE_A_RECORDS @property def _is_complete(self) -> bool: From 89b295372fbcbb72c3b5b1a32b035424a91d2578 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:11:22 -0600 Subject: [PATCH 3/6] chore: coverage --- tests/services/test_info.py | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/services/test_info.py b/tests/services/test_info.py index 1f8924a3..3d4c5302 100644 --- a/tests/services/test_info.py +++ b/tests/services/test_info.py @@ -1797,3 +1797,77 @@ async def test_service_info_nsec_records(): assert nsec_record.type == const._TYPE_NSEC assert nsec_record.ttl == 50 assert nsec_record.rdtypes == [const._TYPE_A, const._TYPE_AAAA] + + +@pytest.mark.asyncio +async def test_address_resolver(): + """Test that the address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolver("address_resolver_test.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test.local.", + const._TYPE_A, + const._CLASS_IN, + 10000, + b"\x7f\x00\x00\x01", + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.addresses == [b"\x7f\x00\x00\x01"] + + +@pytest.mark.asyncio +async def test_address_resolver_ipv4(): + """Test that the IPv4 address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolverIPv4("address_resolver_test_ipv4.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test_ipv4.local.", + const._TYPE_A, + const._CLASS_IN, + 10000, + b"\x7f\x00\x00\x01", + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.addresses == [b"\x7f\x00\x00\x01"] + + +@pytest.mark.asyncio +@unittest.skipIf(not has_working_ipv6(), "Requires IPv6") +@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") +async def test_address_resolver_ipv6(): + """Test that the IPv6 address resolver works.""" + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = r.AddressResolverIPv6("address_resolver_test_ipv6.local.") + resolve_task = asyncio.create_task(resolver.async_request(aiozc.zeroconf, 3000)) + outgoing = r.DNSOutgoing(const._FLAGS_QR_RESPONSE) + outgoing.add_answer_at_time( + r.DNSAddress( + "address_resolver_test_ipv6.local.", + const._TYPE_AAAA, + const._CLASS_IN, + 10000, + socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6"), + ), + 0, + ) + + aiozc.zeroconf.async_send(outgoing) + assert await resolve_task + assert resolver.ip_addresses_by_version(IPVersion.All) == [ip_address("fe80::52e:c2f2:bc5f:e9c6")] From edd86c879ad1eeef1f2a49432221298a7f08a006 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:21:58 -0600 Subject: [PATCH 4/6] chore: improve example --- examples/resolve_address.py | 38 +++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 examples/resolve_address.py diff --git a/examples/resolve_address.py b/examples/resolve_address.py new file mode 100755 index 00000000..c7fc5870 --- /dev/null +++ b/examples/resolve_address.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python + +"""Example of resolving a name to an IPv4 address.""" + +import asyncio +import logging +import sys + +from zeroconf import AddressResolver, IPVersion +from zeroconf.asyncio import AsyncZeroconf + + +async def resolve_name(name: str) -> None: + aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + await aiozc.zeroconf.async_wait_for_start() + resolver = AddressResolver(name) + if await resolver.async_request(aiozc.zeroconf, 3000): + print(f"{name} IP addresses:", resolver.ip_addresses_by_version(IPVersion.All)) + else: + print(f"Name {name} not resolved") + await aiozc.async_close() + + +if __name__ == "__main__": + logging.basicConfig(level=logging.DEBUG) + argv = sys.argv.copy() + if "--debug" in argv: + logging.getLogger("zeroconf").setLevel(logging.DEBUG) + argv.remove("--debug") + + if len(argv) < 2 or not argv[1]: + raise ValueError("Usage: resolve_address.py [--debug] ") + + name = argv[1] + if not name.endswith("."): + name += "." + + asyncio.run(resolve_name(name)) From a1caf445926129f9c8396a5ce84d18789909c1b4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:22:42 -0600 Subject: [PATCH 5/6] chore: improve example --- examples/resolve_address.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/resolve_address.py b/examples/resolve_address.py index c7fc5870..0d8e0e23 100755 --- a/examples/resolve_address.py +++ b/examples/resolve_address.py @@ -11,7 +11,7 @@ async def resolve_name(name: str) -> None: - aiozc = AsyncZeroconf(interfaces=["127.0.0.1"]) + aiozc = AsyncZeroconf() await aiozc.zeroconf.async_wait_for_start() resolver = AddressResolver(name) if await resolver.async_request(aiozc.zeroconf, 3000): From b0ac4749eacfde9a81baa376c12055777ca4385d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 30 Jan 2025 11:23:48 -0600 Subject: [PATCH 6/6] chore: update examples/resolve_address.py --- examples/resolve_address.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/resolve_address.py b/examples/resolve_address.py index 0d8e0e23..eeecfda0 100755 --- a/examples/resolve_address.py +++ b/examples/resolve_address.py @@ -1,6 +1,6 @@ #!/usr/bin/env python -"""Example of resolving a name to an IPv4 address.""" +"""Example of resolving a name to an IP address.""" import asyncio import logging