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
70 changes: 59 additions & 11 deletions src/zeroconf/_utils/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -439,24 +439,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)
Comment on lines 497 to +501
# 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 +515,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 +534,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
13 changes: 13 additions & 0 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
179 changes: 179 additions & 0 deletions tests/utils/test_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -433,3 +433,182 @@ 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_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
Loading