diff --git a/src/zeroconf/_core.py b/src/zeroconf/_core.py index f184b6fa..80de1794 100644 --- a/src/zeroconf/_core.py +++ b/src/zeroconf/_core.py @@ -27,7 +27,7 @@ import random import sys import threading -from collections.abc import Awaitable +from collections.abc import Awaitable, Sequence from types import TracebackType from ._cache import DNSCache @@ -162,6 +162,7 @@ def __init__( ip_version: IPVersion | None = None, apple_p2p: bool = False, use_asyncio: bool | None = None, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, listening and reaping threads. @@ -185,9 +186,16 @@ def __init__( already has an event loop (e.g. Jupyter) but you want blocking semantics. ``True`` raises :class:`RuntimeError` immediately if no running event loop is found, instead of falling back to the thread. + :param multicast_addresses: optional list of additional IP addresses to + add to the listen socket's multicast group. No respond socket is + created for these — they extend multicast membership without + requiring a bind on the corresponding interface. Useful on + sandboxed platforms (e.g. iOS) where port 5353 cannot be bound on + physical interfaces but multicast membership is still needed to + receive queries arriving on them. """ if ip_version is None: - ip_version = autodetect_ip_version(interfaces) + ip_version = autodetect_ip_version(interfaces, multicast_addresses) self.done = False @@ -199,7 +207,13 @@ def __init__( self.unicast = unicast self._use_asyncio = use_asyncio - listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p) + listen_socket, respond_sockets = create_sockets( + interfaces, + unicast, + ip_version, + apple_p2p=apple_p2p, + multicast_addresses=multicast_addresses, + ) log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets) self.engine = AsyncEngine(self, listen_socket, respond_sockets) diff --git a/src/zeroconf/_utils/net.py b/src/zeroconf/_utils/net.py index 01c5b040..c84967bc 100644 --- a/src/zeroconf/_utils/net.py +++ b/src/zeroconf/_utils/net.py @@ -152,6 +152,8 @@ def ip6_addresses_to_indexes( result.append((interface_index_to_ip6_address(adapters, iface), iface)) # type: ignore[arg-type] elif isinstance(iface, str) and ipaddress.ip_address(iface).version == 6: result.append(ip6_to_address_and_index(adapters, iface)) # type: ignore[arg-type] + elif isinstance(iface, tuple): + result.append(iface) return result @@ -439,24 +441,66 @@ def new_respond_socket( return respond_socket +def _entry_ip_version(entry: str | int | tuple[tuple[str, int, int], int]) -> int: + """Return 4 or 6 for the IP version implied by an interface / multicast entry. + + ``int`` (interface index) and ``tuple`` (ifaddr IPv6 adapter tuple) entries + are IPv6 by construction; ``str`` entries are parsed as IP addresses. + """ + if isinstance(entry, str): + return ipaddress.ip_address(entry).version + return 6 + + def create_sockets( interfaces: InterfacesType = InterfaceChoice.All, unicast: bool = False, ip_version: IPVersion = IPVersion.V4Only, apple_p2p: bool = False, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> tuple[socket.socket | None, list[socket.socket]]: + """Create the listen and respond sockets. + + ``multicast_addresses`` is an optional list of additional addresses that + are added to the listen socket's multicast group without creating respond + sockets for them. This is useful on sandboxed platforms (notably iOS) + where binding port 5353 on a physical interface is blocked by the system + mDNS daemon but multicast membership on that interface is still required + to receive incoming queries. + """ + if multicast_addresses and unicast: + raise ValueError("multicast_addresses is incompatible with unicast=True") + + # Reject IP-version-incompatible entries up front so callers get a clear + # error instead of a confusing adapter-lookup or socket-syscall failure. + if multicast_addresses: + if ip_version == IPVersion.V4Only and any(_entry_ip_version(e) == 6 for e in multicast_addresses): + raise ValueError("multicast_addresses contains IPv6 entries but ip_version is V4Only") + if ip_version == IPVersion.V6Only and any(_entry_ip_version(e) == 4 for e in multicast_addresses): + raise ValueError("multicast_addresses contains IPv4 entries but ip_version is V6Only") + if unicast: listen_socket = None else: listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p) normalized_interfaces = normalize_interface_choice(interfaces, ip_version) + if multicast_addresses: + extra_multicast_members = normalize_interface_choice(list(multicast_addresses), ip_version) + # Strip entries already covered by ``interfaces`` so add_multicast_member + # is not called twice for the same membership. + interface_set = set(normalized_interfaces) + extra_multicast_members = [m for m in extra_multicast_members if m not in interface_set] + else: + extra_multicast_members = [] # If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use # a single socket to listen and respond. if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All: for interface in normalized_interfaces: add_multicast_member(cast(socket.socket, listen_socket), interface) + for interface in extra_multicast_members: + add_multicast_member(cast(socket.socket, listen_socket), interface) # Sent responder socket options to the dual-use listen socket set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version) return listen_socket, [cast(socket.socket, listen_socket)] @@ -473,6 +517,9 @@ def create_sockets( if respond_socket is not None: respond_sockets.append(respond_socket) + for interface in extra_multicast_members: + add_multicast_member(cast(socket.socket, listen_socket), interface) + return listen_socket, respond_sockets @@ -489,17 +536,20 @@ def can_send_to(ipv6_socket: bool, address: str) -> bool: return ":" in address if ipv6_socket else ":" not in address -def autodetect_ip_version(interfaces: InterfacesType) -> IPVersion: +def autodetect_ip_version( + interfaces: InterfacesType, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, +) -> IPVersion: """Auto detect the IP version when it is not provided.""" + entries: list[str | int | tuple[tuple[str, int, int], int]] = [] if isinstance(interfaces, list): - has_v6 = any( - isinstance(i, int) or (isinstance(i, str) and ipaddress.ip_address(i).version == 6) - 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: - return IPVersion.V6Only - + entries.extend(interfaces) + if multicast_addresses: + entries.extend(multicast_addresses) + has_v6 = any(_entry_ip_version(e) == 6 for e in entries) + has_v4 = any(_entry_ip_version(e) == 4 for e in entries) + if has_v4 and has_v6: + return IPVersion.All + if has_v6: + return IPVersion.V6Only return IPVersion.V4Only diff --git a/src/zeroconf/asyncio.py b/src/zeroconf/asyncio.py index 45aac67a..fec2e5bb 100644 --- a/src/zeroconf/asyncio.py +++ b/src/zeroconf/asyncio.py @@ -24,7 +24,7 @@ import asyncio import contextlib -from collections.abc import Awaitable, Callable +from collections.abc import Awaitable, Callable, Sequence from types import TracebackType # used in type hints from ._core import Zeroconf @@ -151,6 +151,7 @@ def __init__( ip_version: IPVersion | None = None, apple_p2p: bool = False, zc: Zeroconf | None = None, + multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None, ) -> None: """Creates an instance of the Zeroconf class, establishing multicast communications, and listening. @@ -166,12 +167,16 @@ def __init__( :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) + :param multicast_addresses: optional list of additional IP addresses to + add to the listen socket's multicast group; see + :class:`Zeroconf` for details. """ self.zeroconf = zc or Zeroconf( interfaces=interfaces, unicast=unicast, ip_version=ip_version, apple_p2p=apple_p2p, + multicast_addresses=multicast_addresses, ) self.async_browsers: dict[ServiceListener, AsyncServiceBrowser] = {} diff --git a/tests/test_core.py b/tests/test_core.py index 1bc85b1e..bbcc2620 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -184,6 +184,19 @@ def test_use_asyncio_default_starts_thread_without_loop(self): finally: zc.close() + def test_multicast_addresses_forwarded_to_create_sockets(self): + """Zeroconf forwards multicast_addresses to create_sockets unchanged.""" + with patch("zeroconf._core.create_sockets", return_value=(None, [])) as mock_create: + zc = r.Zeroconf( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + ) + try: + _, kwargs = mock_create.call_args + assert kwargs["multicast_addresses"] == ["192.168.1.5"] + finally: + zc.close() + def test_async_updates_from_response(self): def mock_incoming_msg( service_state_change: r.ServiceStateChange, @@ -699,43 +712,45 @@ def test_tc_bit_defers_last_response_missing(): assert len(packets) == 4 expected_deferred = [] - # Pin per-packet delay to the minimum so each successive fire_at lands - # before the deadline established by the first packet — keeps the - # timer-replacement assertions deterministic under bounded TC-deferral. - min_delay_ms = _TC_DELAY_RANDOM_INTERVAL[0] - with patch("zeroconf._listener.random.randint", return_value=min_delay_ms): - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer1 = protocol._timers[source_ip] - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - timer2 = protocol._timers[source_ip] - assert timer1.cancelled() - assert timer2 != timer1 - - # Send the same packet again to similar multi interfaces - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer3 = protocol._timers[source_ip] - assert not timer3.cancelled() - assert timer3 == timer2 - - next_packet = r.DNSIncoming(packets.pop(0)) - expected_deferred.append(next_packet) - threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) - assert protocol._deferred[source_ip] == expected_deferred - assert source_ip in protocol._timers - timer4 = protocol._timers[source_ip] - assert timer3.cancelled() - assert timer4 != timer3 - - for _ in range(8): + # Widen the per-packet delay so Windows scheduling jitter between + # assertions cannot fire the timer mid-test, while leaving plenty of + # headroom under the first-arrival deadline for timer-replacement to + # remain deterministic. + with patch.object(_listener, "_TC_DELAY_RANDOM_INTERVAL", (1500, 60_000)): + min_delay_ms = _listener._TC_DELAY_RANDOM_INTERVAL[0] + with patch("zeroconf._listener.random.randint", return_value=min_delay_ms): + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer1 = protocol._timers[source_ip] + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + timer2 = protocol._timers[source_ip] + assert timer1.cancelled() + assert timer2 != timer1 + + # Send the same packet again to similar multi interfaces + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer3 = protocol._timers[source_ip] + assert not timer3.cancelled() + assert timer3 == timer2 + + next_packet = r.DNSIncoming(packets.pop(0)) + expected_deferred.append(next_packet) + threadsafe_query(zc, protocol, next_packet, source_ip, const._MDNS_PORT, Mock(), ()) + assert protocol._deferred[source_ip] == expected_deferred + assert source_ip in protocol._timers + timer4 = protocol._timers[source_ip] + assert timer3.cancelled() + assert timer4 != timer3 + + for _ in range(30): time.sleep(0.1) if source_ip not in protocol._timers and source_ip not in protocol._deferred: break diff --git a/tests/utils/test_net.py b/tests/utils/test_net.py index 311e95e6..1aec29de 100644 --- a/tests/utils/test_net.py +++ b/tests/utils/test_net.py @@ -433,3 +433,214 @@ def call_to_tuple(c): expected_calls_set = {call_to_tuple(c) for c in expected_calls} assert actual_calls_set == expected_calls_set + + +def test_create_sockets_multicast_addresses_v4() -> None: + """Extra IPv4 addresses join the listen socket multicast group but get no respond socket.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + listen_socket, respond_sockets = r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5", "10.0.0.5"], + ip_version=r.IPVersion.V4Only, + ) + + assert listen_socket is listen_mock + assert respond_sockets == [respond_mock] + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "127.0.0.1" in joined + assert "192.168.1.5" in joined + assert "10.0.0.5" in joined + + +def test_create_sockets_multicast_addresses_v6() -> None: + """Extra IPv6 addresses join the listen socket multicast group.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=[1], + multicast_addresses=["2001:db8::"], + ip_version=r.IPVersion.V6Only, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + # Both the interface index 1 and the extra multicast address resolve to the + # same adapter tuple — what matters is the listen socket joined that group. + assert (("2001:db8::", 1, 1), 1) in joined + + +def test_create_sockets_multicast_addresses_unicast_rejected() -> None: + """multicast_addresses is incompatible with unicast=True (there is no listen socket).""" + with pytest.raises(ValueError): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + unicast=True, + ) + + +def test_create_sockets_multicast_addresses_default_path() -> None: + """multicast_addresses also works on the InterfaceChoice.Default fast path.""" + listen_mock = Mock(spec=socket.socket) + + with ( + patch("zeroconf._utils.net.new_socket", return_value=listen_mock), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + listen_socket, respond_sockets = r.create_sockets( + interfaces=r.InterfaceChoice.Default, + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.V4Only, + ) + + assert listen_socket is listen_mock + assert respond_sockets == [listen_mock] + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "0.0.0.0" in joined + assert "192.168.1.5" in joined + + +def test_create_sockets_multicast_addresses_v4_rejects_v6_entry() -> None: + """V4Only listen socket rejects IPv6 multicast_addresses entries.""" + with pytest.raises(ValueError, match="IPv6"): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["2001:db8::"], + ip_version=r.IPVersion.V4Only, + ) + + +def test_create_sockets_multicast_addresses_v6_rejects_v4_entry() -> None: + """V6Only listen socket rejects IPv4 multicast_addresses entries.""" + with pytest.raises(ValueError, match="IPv4"): + r.create_sockets( + interfaces=[1], + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.V6Only, + ) + + +def test_create_sockets_multicast_addresses_deduped_against_interfaces() -> None: + """Addresses present in both interfaces and multicast_addresses join only once.""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["127.0.0.1", "192.168.1.5"], + ip_version=r.IPVersion.V4Only, + ) + + joined_127 = [c for c in mock_add.call_args_list if c.args[0] is listen_mock and c.args[1] == "127.0.0.1"] + assert len(joined_127) == 1 + + +def test_autodetect_ip_version_includes_multicast_addresses() -> None: + """autodetect_ip_version sees IPv6 entries from multicast_addresses.""" + assert ( + netutils.autodetect_ip_version(["127.0.0.1"], multicast_addresses=["2001:db8::"]) is r.IPVersion.All + ) + assert netutils.autodetect_ip_version([], multicast_addresses=["2001:db8::"]) is r.IPVersion.V6Only + + +def test_autodetect_ip_version_non_list_interfaces_with_multicast_addresses() -> None: + """autodetect_ip_version handles non-list interfaces (InterfaceChoice) with multicast_addresses.""" + assert ( + netutils.autodetect_ip_version(r.InterfaceChoice.Default, multicast_addresses=["2001:db8::"]) + is r.IPVersion.V6Only + ) + assert ( + netutils.autodetect_ip_version(r.InterfaceChoice.Default, multicast_addresses=["192.168.1.5"]) + is r.IPVersion.V4Only + ) + + +def test_autodetect_ip_version_interface_index_treated_as_v6() -> None: + """Interface-index (int) entries are treated as IPv6 by autodetection.""" + assert netutils.autodetect_ip_version([1]) is r.IPVersion.V6Only + assert netutils.autodetect_ip_version(["1.2.3.4", 1]) is r.IPVersion.All + + +def test_create_sockets_multicast_addresses_ip_version_all() -> None: + """multicast_addresses works with ip_version=All (no V4Only/V6Only validation triggered).""" + listen_mock = Mock(spec=socket.socket) + respond_mock = Mock(spec=socket.socket) + + def _new_socket(bind_addr, **kwargs): + return listen_mock if bind_addr == ("",) else respond_mock + + with ( + patch("zeroconf._utils.net.new_socket", side_effect=_new_socket), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=["127.0.0.1"], + multicast_addresses=["192.168.1.5"], + ip_version=r.IPVersion.All, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert "192.168.1.5" in joined + + +def test_create_sockets_multicast_addresses_preserves_pre_normalized_tuple() -> None: + """Pre-normalized IPv6 adapter tuples pass through to add_multicast_member.""" + listen_mock = Mock(spec=socket.socket) + + pre_normalized = (("2001:db8::", 1, 1), 1) + + with ( + patch("zeroconf._utils.net.new_socket", return_value=listen_mock), + patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add, + patch("zeroconf._utils.net.set_respond_socket_multicast_options"), + patch( + "zeroconf._utils.net.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ), + patch("zeroconf._utils.net.socket.socket.setsockopt"), + ): + r.create_sockets( + interfaces=[], + multicast_addresses=[pre_normalized], + ip_version=r.IPVersion.V6Only, + ) + + joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock] + assert pre_normalized in joined