From 72c649b43b6dac5342535fe851f2485b24616fc2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 16:15:14 -0600 Subject: [PATCH 1/7] feat: introduce InterfaceChoice.AllWithLoopback --- src/zeroconf/_core.py | 5 +++ src/zeroconf/_handlers/query_handler.py | 1 - src/zeroconf/_services/browser.py | 1 - src/zeroconf/_utils/ipaddress.py | 2 -- src/zeroconf/_utils/net.py | 42 ++++++++++++++++++++----- src/zeroconf/asyncio.py | 4 +++ tests/__init__.py | 1 - tests/services/test_browser.py | 2 -- tests/test_core.py | 16 ++++++++-- tests/utils/test_net.py | 5 +-- 10 files changed, 61 insertions(+), 18 deletions(-) diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index 4b29717a..cfdbac5d 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -159,7 +159,12 @@ def __init__( * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on Python versions before 3.8. + * `InterfaceChoice.AllWithLoopback` is the same as `InterfaceChoice.All` + on POSIX systems, but includes the loopback interfaces. This likely + only works on macOS/BSD. + Also listening on loopback (``::1``) doesn't work, use a real address. + :param ip_version: IP versions to support. If `choice` is a list, the default is detected from it. Otherwise defaults to V4 only for backward compatibility. :param apple_p2p: use AWDL interface (only macOS) diff --git a/src/zeroconf/_handlers/query_handler.py b/src/zeroconf/_handlers/query_handler.py index ba9c9e31..abee6432 100644 --- a/src/zeroconf/_handlers/query_handler.py +++ b/src/zeroconf/_handlers/query_handler.py @@ -71,7 +71,6 @@ class _AnswerStrategy: - __slots__ = ("question", "strategy_type", "types", "services") def __init__( diff --git a/src/zeroconf/_services/browser.py b/src/zeroconf/_services/browser.py index 2ff66074..c372b5ba 100644 --- a/src/zeroconf/_services/browser.py +++ b/src/zeroconf/_services/browser.py @@ -105,7 +105,6 @@ class _ScheduledPTRQuery: - __slots__ = ('alias', 'name', 'ttl', 'cancelled', 'expire_time_millis', 'when_millis') def __init__( diff --git a/src/zeroconf/_utils/ipaddress.py b/src/zeroconf/_utils/ipaddress.py index b0b551ff..cbbc274a 100644 --- a/src/zeroconf/_utils/ipaddress.py +++ b/src/zeroconf/_utils/ipaddress.py @@ -33,7 +33,6 @@ class ZeroconfIPv4Address(IPv4Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -59,7 +58,6 @@ def is_unspecified(self) -> bool: class ZeroconfIPv6Address(IPv6Address): - __slots__ = ("_str", "_is_link_local", "_is_unspecified") def __init__(self, *args: Any, **kwargs: Any) -> None: diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index cc4754ab..454c327f 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -22,7 +22,6 @@ import enum import errno -import ipaddress import socket import struct import sys @@ -32,12 +31,14 @@ from .._logger import log from ..const import _IPPROTO_IPV6, _MDNS_ADDR, _MDNS_ADDR6, _MDNS_PORT +from .ipaddress import _cached_ip_addresses @enum.unique class InterfaceChoice(enum.Enum): Default = 1 All = 2 + AllWithLoopback = 3 InterfacesType = Union[Sequence[Union[str, int, Tuple[Tuple[str, int, int], int]]], InterfaceChoice] @@ -85,11 +86,11 @@ def get_all_addresses_v6() -> List[Tuple[Tuple[str, int, int], int]]: def ip6_to_address_and_index(adapters: List[Any], ip: str) -> Tuple[Tuple[str, int, int], int]: if '%' in ip: ip = ip[: ip.index('%')] # Strip scope_id. - ipaddr = ipaddress.ip_address(ip) + ipaddr = _cached_ip_addresses(ip) for adapter in adapters: for adapter_ip in adapter.ips: # IPv6 addresses are represented as tuples - if isinstance(adapter_ip.ip, tuple) and ipaddress.ip_address(adapter_ip.ip[0]) == ipaddr: + if isinstance(adapter_ip.ip, tuple) and _cached_ip_addresses(adapter_ip.ip[0]) == ipaddr: return (cast(Tuple[str, int, int], adapter_ip.ip), cast(int, adapter.index)) raise RuntimeError('No adapter found for IP address %s' % ip) @@ -122,7 +123,9 @@ def ip6_addresses_to_indexes( for iface in interfaces: if isinstance(iface, int): result.append((interface_index_to_ip6_address(adapters, iface), iface)) - elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: + elif ( + isinstance(iface, str) and (ip_address := _cached_ip_addresses(iface)) and ip_address.version == 6 + ): result.append(ip6_to_address_and_index(adapters, iface)) return result @@ -145,6 +148,23 @@ def normalize_interface_choice( if ip_version != IPVersion.V6Only: result.append('0.0.0.0') elif choice is InterfaceChoice.All: + if ip_version != IPVersion.V4Only: + result.extend( + ip + for ip in get_all_addresses_v6() + if (ip_address := _cached_ip_addresses(ip[0])) and not ip_address.is_loopback + ) + if ip_version != IPVersion.V6Only: + result.extend( + ip + for ip in get_all_addresses() + if (ip_address := _cached_ip_addresses(ip[0])) and not ip_address.is_loopback + ) + if not result: + raise RuntimeError( + 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version + ) + elif choice is InterfaceChoice.AllWithLoopback: if ip_version != IPVersion.V4Only: result.extend(get_all_addresses_v6()) if ip_version != IPVersion.V6Only: @@ -155,7 +175,11 @@ def normalize_interface_choice( ) elif isinstance(choice, list): # First, take IPv4 addresses. - result = [i for i in choice if isinstance(i, str) and ipaddress.ip_address(i).version == 4] + result = [ + i + for i in choice + if isinstance(i, str) and (ip_address := _cached_ip_addresses(i)) and ip_address.version == 4 + ] # Unlike IP_ADD_MEMBERSHIP, IPV6_JOIN_GROUP requires interface indexes. result += ip6_addresses_to_indexes(choice) else: @@ -406,10 +430,14 @@ def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: """Auto detect the IP version when it is not provided.""" if isinstance(interfaces, list): has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) + isinstance(i, int) + or (isinstance(i, str) and (ip_address := _cached_ip_addresses(i)) and ip_address.version == 6) + for i in interfaces + ) + has_v4 = any( + isinstance(i, str) and (ip_address := _cached_ip_addresses(i)) and ip_address.version == 4 for i in interfaces ) - has_v4 = any(isinstance(i, str) and ipaddress.ip_address(i).version == 4 for i in interfaces) if has_v4 and has_v6: return IPVersion.All if has_v6: diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index cfe3693e..907c6a18 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -162,6 +162,10 @@ def __init__( * `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on Python versions before 3.8. + * `InterfaceChoice.AllWithLoopback` is the same as `InterfaceChoice.All` + on POSIX systems, but includes the loopback interfaces. This likely + only works on macOS/BSD. + Also listening on loopback (``::1``) doesn't work, use a real address. :param ip_version: IP versions to support. If `choice` is a list, the default is detected from it. Otherwise defaults to V4 only for backward compatibility. diff --git a/tests/__init__.py b/tests/__init__.py index cbba6073..5ff0c0bd 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -99,7 +99,6 @@ def time_changed_millis(millis: Optional[float] = None) -> None: mock_seconds_into_future = loop_time with mock.patch("time.monotonic", return_value=mock_seconds_into_future): - for task in list(loop._scheduled): # type: ignore[attr-defined] if not isinstance(task, asyncio.TimerHandle): continue diff --git a/tests/services/test_browser.py b/tests/services/test_browser.py index 37896ba1..c0718ff6 100644 --- a/tests/services/test_browser.py +++ b/tests/services/test_browser.py @@ -1161,7 +1161,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send so we can capture what is being sent with patch.object(zc, "async_send", send): - query_scheduler.start(loop) original_now = loop.time() @@ -1251,7 +1250,6 @@ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()): # patch the zeroconf send so we can capture what is being sent with patch.object(zc, "async_send", send): - query_scheduler.start(loop) original_now = loop.time() diff --git a/tests/test_core.py b/tests/test_core.py index de4b2ef5..18668186 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -63,21 +63,29 @@ async def make_query(): class Framework(unittest.TestCase): def test_launch_and_close(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback) + rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default) rv.close() def test_launch_and_close_context_manager(self): - with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: + with r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback) as rv: + assert rv.done is False + assert rv.done is True + + with r.Zeroconf(interfaces=r.InterfaceChoice.All) as rv: # type: ignore[unreachable] assert rv.done is False assert rv.done is True - with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: # type: ignore[unreachable] + with r.Zeroconf(interfaces=r.InterfaceChoice.Default) as rv: assert rv.done is False assert rv.done is True def test_launch_and_close_unicast(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback, unicast=True) + rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, unicast=True) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, unicast=True) @@ -91,6 +99,8 @@ def test_close_multiple_times(self): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v4_v6(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.All) + rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All) @@ -99,6 +109,8 @@ def test_launch_and_close_v4_v6(self): @unittest.skipIf(not has_working_ipv6(), 'Requires IPv6') @unittest.skipIf(os.environ.get('SKIP_IPV6'), 'IPv6 tests disabled') def test_launch_and_close_v6_only(self): + rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.V6Only) + rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only) diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 29844d57..b61a6d1f 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -68,12 +68,13 @@ def test_ip6_addresses_to_indexes(): assert netutils.ip6_addresses_to_indexes(interfaces_2) == [(('2001:db8::', 1, 1), 1)] -def test_normalize_interface_choice_errors(): +@pytest.mark.parametrize("interface_choice", (r.InterfaceChoice.All, r.InterfaceChoice.AllWithLoopback)) +def test_normalize_interface_choice_errors(interface_choice: r.InterfaceChoice) -> None: """Test we generate exception on invalid input.""" with patch("zeroconf._utils.net.get_all_addresses", return_value=[]), patch( "zeroconf._utils.net.get_all_addresses_v6", return_value=[] ), pytest.raises(RuntimeError): - netutils.normalize_interface_choice(r.InterfaceChoice.All) + netutils.normalize_interface_choice(interface_choice) with pytest.raises(TypeError): netutils.normalize_interface_choice("1.2.3.4") From 1070dc522efe4192d2fce6c05d1aaa3c48c0f26c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 16:21:24 -0600 Subject: [PATCH 2/7] fix: ipv4 is not a tuple --- src/zeroconf/_utils/net.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 454c327f..8695c813 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -150,15 +150,15 @@ def normalize_interface_choice( elif choice is InterfaceChoice.All: if ip_version != IPVersion.V4Only: result.extend( - ip - for ip in get_all_addresses_v6() - if (ip_address := _cached_ip_addresses(ip[0])) and not ip_address.is_loopback + ip_tuple + for ip_tuple in get_all_addresses_v6() + if (ip_address := _cached_ip_addresses(ip_tuple[0])) and not ip_address.is_loopback ) if ip_version != IPVersion.V6Only: result.extend( ip for ip in get_all_addresses() - if (ip_address := _cached_ip_addresses(ip[0])) and not ip_address.is_loopback + if (ip_address := _cached_ip_addresses(ip)) and not ip_address.is_loopback ) if not result: raise RuntimeError( From b427678d2dec452ab05b184697f598d84f448c2c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 16:27:03 -0600 Subject: [PATCH 3/7] fix: nesting --- src/zeroconf/_utils/net.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 8695c813..2aaf50fa 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -152,7 +152,7 @@ def normalize_interface_choice( result.extend( ip_tuple for ip_tuple in get_all_addresses_v6() - if (ip_address := _cached_ip_addresses(ip_tuple[0])) and not ip_address.is_loopback + if (ip_address := _cached_ip_addresses(ip_tuple[0][0])) and not ip_address.is_loopback ) if ip_version != IPVersion.V6Only: result.extend( From 857a4a8bced95477a23189abc2daca2b8fa8a6d5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 16:31:56 -0600 Subject: [PATCH 4/7] fix: circular import --- src/zeroconf/_dns.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/zeroconf/_dns.py b/src/zeroconf/_dns.py index 66fb5b86..c6a7a1ff 100644 --- a/src/zeroconf/_dns.py +++ b/src/zeroconf/_dns.py @@ -25,7 +25,6 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Union, cast from ._exceptions import AbstractMethodException -from ._utils.net import _is_v6_address from ._utils.time import current_time_millis from .const import _CLASS_MASK, _CLASS_UNIQUE, _CLASSES, _TYPE_ANY, _TYPES @@ -275,9 +274,7 @@ def __repr__(self) -> str: """String representation""" try: return self.to_string( - socket.inet_ntop( - socket.AF_INET6 if _is_v6_address(self.address) else socket.AF_INET, self.address - ) + socket.inet_ntop(socket.AF_INET6 if len(self.address) == 16 else socket.AF_INET, self.address) ) except (ValueError, OSError): return self.to_string(str(self.address)) From 5f07f02003cc926a948850623cc793d14d171583 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 7 Feb 2024 17:10:01 -0600 Subject: [PATCH 5/7] chore: remove unused --- src/zeroconf/_utils/net.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 2aaf50fa..6c4b3afe 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -61,10 +61,6 @@ class IPVersion(enum.Enum): # utility functions -def _is_v6_address(addr: bytes) -> bool: - return len(addr) == 16 - - def _encode_address(address: str) -> bytes: is_ipv6 = ':' in address address_family = socket.AF_INET6 if is_ipv6 else socket.AF_INET From 7eb83cd3a49e7ca71f697d7975dae7665b9d55b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 5 Jul 2024 22:44:13 +0000 Subject: [PATCH 6/7] chore(pre-commit.ci): auto fixes --- src/zeroconf/_utils/net.py | 13 +++++++++---- tests/test_core.py | 8 ++++++-- tests/utils/test_net.py | 4 +++- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 78a6f35f..922d5c4a 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -144,7 +144,9 @@ def ip6_addresses_to_indexes( if isinstance(iface, int): result.append((interface_index_to_ip6_address(adapters, iface), iface)) elif ( - isinstance(iface, str) and (ip_address := _cached_ip_addresses(iface)) and ip_address.version == 6 + isinstance(iface, str) + and (ip_address := _cached_ip_addresses(iface)) + and ip_address.version == 6 ): result.append(ip6_to_address_and_index(adapters, iface)) @@ -172,17 +174,20 @@ def normalize_interface_choice( result.extend( ip_tuple for ip_tuple in get_all_addresses_v6() - if (ip_address := _cached_ip_addresses(ip_tuple[0][0])) and not ip_address.is_loopback + if (ip_address := _cached_ip_addresses(ip_tuple[0][0])) + and not ip_address.is_loopback ) if ip_version != IPVersion.V6Only: result.extend( ip for ip in get_all_addresses() - if (ip_address := _cached_ip_addresses(ip)) and not ip_address.is_loopback + if (ip_address := _cached_ip_addresses(ip)) + and not ip_address.is_loopback ) if not result: raise RuntimeError( - 'No interfaces to listen on, check that any interfaces have IP version %s' % ip_version + "No interfaces to listen on, check that any interfaces have IP version %s" + % ip_version ) elif choice is InterfaceChoice.AllWithLoopback: if ip_version != IPVersion.V4Only: diff --git a/tests/test_core.py b/tests/test_core.py index c14f7219..6277436e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -99,7 +99,9 @@ def test_close_multiple_times(self): @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v4_v6(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.All) + rv = r.Zeroconf( + interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.All + ) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) rv.close() @@ -111,7 +113,9 @@ def test_launch_and_close_v4_v6(self): @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") def test_launch_and_close_v6_only(self): - rv = r.Zeroconf(interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.V6Only) + rv = r.Zeroconf( + interfaces=r.InterfaceChoice.AllWithLoopback, ip_version=r.IPVersion.V6Only + ) rv.close() rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) rv.close() diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 3f0a3570..6840b592 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -85,7 +85,9 @@ def test_ip6_addresses_to_indexes(): ] -@pytest.mark.parametrize("interface_choice", (r.InterfaceChoice.All, r.InterfaceChoice.AllWithLoopback)) +@pytest.mark.parametrize( + "interface_choice", (r.InterfaceChoice.All, r.InterfaceChoice.AllWithLoopback) +) def test_normalize_interface_choice_errors(interface_choice: r.InterfaceChoice) -> None: """Test we generate exception on invalid input.""" with patch("zeroconf._utils.net.get_all_addresses", return_value=[]), patch( From 57cdfcc1ac233870b9f171c1596b6821baf48f33 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Jul 2024 17:45:18 -0500 Subject: [PATCH 7/7] Update src/zeroconf/_utils/net.py --- src/zeroconf/_utils/net.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 922d5c4a..ff249d70 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -23,6 +23,7 @@ import enum import errno import socket +import ipaddress import struct import sys from typing import Any, List, Optional, Sequence, Tuple, Union, cast