Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions src/zeroconf/_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand All @@ -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,
)
Comment on lines +210 to +216
log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets)

self.engine = AsyncEngine(self, listen_socket, respond_sockets)
Expand Down
72 changes: 61 additions & 11 deletions src/zeroconf/_utils/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Comment on lines 487 to +491
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)
Comment on lines 499 to +503
# 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)]
Expand All @@ -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


Expand All @@ -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
7 changes: 6 additions & 1 deletion src/zeroconf/asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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] = {}

Expand Down
89 changes: 52 additions & 37 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading