Skip to content

Commit f265d95

Browse files
committed
feat: add multicast_addresses to bind/multicast independently
iOS and other sandboxed platforms allow joining the mDNS multicast group on a physical interface but forbid binding port 5353 on it because the system mDNS daemon owns the port. The existing ``interfaces`` parameter conflates the two: every entry both joins the multicast group and gets a respond socket bound to that interface's address. Add an optional ``multicast_addresses`` parameter on ``Zeroconf`` / ``AsyncZeroconf`` / ``create_sockets`` for additional addresses that are joined to the listen socket's multicast group without creating a respond socket. The default behavior is unchanged. Fixes #1520
1 parent cb81e67 commit f265d95

5 files changed

Lines changed: 148 additions & 3 deletions

File tree

src/zeroconf/_core.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
import random
2828
import sys
2929
import threading
30-
from collections.abc import Awaitable
30+
from collections.abc import Awaitable, Sequence
3131
from types import TracebackType
3232

3333
from ._cache import DNSCache
@@ -162,6 +162,7 @@ def __init__(
162162
ip_version: IPVersion | None = None,
163163
apple_p2p: bool = False,
164164
use_asyncio: bool | None = None,
165+
multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None,
165166
) -> None:
166167
"""Creates an instance of the Zeroconf class, establishing
167168
multicast communications, listening and reaping threads.
@@ -185,6 +186,13 @@ def __init__(
185186
already has an event loop (e.g. Jupyter) but you want blocking
186187
semantics. ``True`` raises :class:`RuntimeError` immediately if no
187188
running event loop is found, instead of falling back to the thread.
189+
:param multicast_addresses: optional list of additional IP addresses to
190+
add to the listen socket's multicast group. No respond socket is
191+
created for these — they extend multicast membership without
192+
requiring a bind on the corresponding interface. Useful on
193+
sandboxed platforms (e.g. iOS) where port 5353 cannot be bound on
194+
physical interfaces but multicast membership is still needed to
195+
receive queries arriving on them.
188196
"""
189197
if ip_version is None:
190198
ip_version = autodetect_ip_version(interfaces)
@@ -199,7 +207,13 @@ def __init__(
199207

200208
self.unicast = unicast
201209
self._use_asyncio = use_asyncio
202-
listen_socket, respond_sockets = create_sockets(interfaces, unicast, ip_version, apple_p2p=apple_p2p)
210+
listen_socket, respond_sockets = create_sockets(
211+
interfaces,
212+
unicast,
213+
ip_version,
214+
apple_p2p=apple_p2p,
215+
multicast_addresses=multicast_addresses,
216+
)
203217
log.debug("Listen socket %s, respond sockets %s", listen_socket, respond_sockets)
204218

205219
self.engine = AsyncEngine(self, listen_socket, respond_sockets)

src/zeroconf/_utils/net.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,19 +444,37 @@ def create_sockets(
444444
unicast: bool = False,
445445
ip_version: IPVersion = IPVersion.V4Only,
446446
apple_p2p: bool = False,
447+
multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None,
447448
) -> tuple[socket.socket | None, list[socket.socket]]:
449+
"""Create the listen and respond sockets.
450+
451+
``multicast_addresses`` is an optional list of additional addresses that
452+
are added to the listen socket's multicast group without creating respond
453+
sockets for them. This is useful on sandboxed platforms (notably iOS)
454+
where binding port 5353 on a physical interface is blocked by the system
455+
mDNS daemon but multicast membership on that interface is still required
456+
to receive incoming queries.
457+
"""
458+
if multicast_addresses and unicast:
459+
raise ValueError("multicast_addresses is incompatible with unicast=True")
460+
448461
if unicast:
449462
listen_socket = None
450463
else:
451464
listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p)
452465

453466
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
467+
extra_multicast_members = (
468+
normalize_interface_choice(list(multicast_addresses), ip_version) if multicast_addresses else []
469+
)
454470

455471
# If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use
456472
# a single socket to listen and respond.
457473
if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All:
458474
for interface in normalized_interfaces:
459475
add_multicast_member(cast(socket.socket, listen_socket), interface)
476+
for interface in extra_multicast_members:
477+
add_multicast_member(cast(socket.socket, listen_socket), interface)
460478
# Sent responder socket options to the dual-use listen socket
461479
set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version)
462480
return listen_socket, [cast(socket.socket, listen_socket)]
@@ -473,6 +491,9 @@ def create_sockets(
473491
if respond_socket is not None:
474492
respond_sockets.append(respond_socket)
475493

494+
for interface in extra_multicast_members:
495+
add_multicast_member(cast(socket.socket, listen_socket), interface)
496+
476497
return listen_socket, respond_sockets
477498

478499

src/zeroconf/asyncio.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424

2525
import asyncio
2626
import contextlib
27-
from collections.abc import Awaitable, Callable
27+
from collections.abc import Awaitable, Callable, Sequence
2828
from types import TracebackType # used in type hints
2929

3030
from ._core import Zeroconf
@@ -151,6 +151,7 @@ def __init__(
151151
ip_version: IPVersion | None = None,
152152
apple_p2p: bool = False,
153153
zc: Zeroconf | None = None,
154+
multicast_addresses: Sequence[str | int | tuple[tuple[str, int, int], int]] | None = None,
154155
) -> None:
155156
"""Creates an instance of the Zeroconf class, establishing
156157
multicast communications, and listening.
@@ -166,12 +167,16 @@ def __init__(
166167
:param ip_version: IP versions to support. If `choice` is a list, the default is detected
167168
from it. Otherwise defaults to V4 only for backward compatibility.
168169
:param apple_p2p: use AWDL interface (only macOS)
170+
:param multicast_addresses: optional list of additional IP addresses to
171+
add to the listen socket's multicast group; see
172+
:class:`Zeroconf` for details.
169173
"""
170174
self.zeroconf = zc or Zeroconf(
171175
interfaces=interfaces,
172176
unicast=unicast,
173177
ip_version=ip_version,
174178
apple_p2p=apple_p2p,
179+
multicast_addresses=multicast_addresses,
175180
)
176181
self.async_browsers: dict[ServiceListener, AsyncServiceBrowser] = {}
177182

tests/test_core.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,20 @@ def test_use_asyncio_default_starts_thread_without_loop(self):
184184
finally:
185185
zc.close()
186186

187+
def test_multicast_addresses_forwarded_to_create_sockets(self):
188+
"""Zeroconf forwards multicast_addresses to create_sockets unchanged."""
189+
with patch("zeroconf._core.create_sockets", return_value=(None, [])) as mock_create:
190+
zc = r.Zeroconf(
191+
interfaces=["127.0.0.1"],
192+
multicast_addresses=["192.168.1.5"],
193+
unicast=True,
194+
)
195+
try:
196+
_, kwargs = mock_create.call_args
197+
assert kwargs["multicast_addresses"] == ["192.168.1.5"]
198+
finally:
199+
zc.close()
200+
187201
def test_async_updates_from_response(self):
188202
def mock_incoming_msg(
189203
service_state_change: r.ServiceStateChange,

tests/utils/test_net.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,3 +433,94 @@ def call_to_tuple(c):
433433
expected_calls_set = {call_to_tuple(c) for c in expected_calls}
434434

435435
assert actual_calls_set == expected_calls_set
436+
437+
438+
def test_create_sockets_multicast_addresses_v4() -> None:
439+
"""Extra IPv4 addresses join the listen socket multicast group but get no respond socket."""
440+
listen_mock = Mock(spec=socket.socket)
441+
respond_mock = Mock(spec=socket.socket)
442+
443+
def _new_socket(bind_addr, **kwargs):
444+
return listen_mock if bind_addr == ("",) else respond_mock
445+
446+
with (
447+
patch("zeroconf._utils.net.new_socket", side_effect=_new_socket),
448+
patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add,
449+
patch("zeroconf._utils.net.set_respond_socket_multicast_options"),
450+
patch("zeroconf._utils.net.socket.socket.setsockopt"),
451+
):
452+
listen_socket, respond_sockets = r.create_sockets(
453+
interfaces=["127.0.0.1"],
454+
multicast_addresses=["192.168.1.5", "10.0.0.5"],
455+
ip_version=r.IPVersion.V4Only,
456+
)
457+
458+
assert listen_socket is listen_mock
459+
assert respond_sockets == [respond_mock]
460+
joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock]
461+
assert "127.0.0.1" in joined
462+
assert "192.168.1.5" in joined
463+
assert "10.0.0.5" in joined
464+
465+
466+
def test_create_sockets_multicast_addresses_v6() -> None:
467+
"""Extra IPv6 addresses join the listen socket multicast group."""
468+
listen_mock = Mock(spec=socket.socket)
469+
respond_mock = Mock(spec=socket.socket)
470+
471+
def _new_socket(bind_addr, **kwargs):
472+
return listen_mock if bind_addr == ("",) else respond_mock
473+
474+
with (
475+
patch("zeroconf._utils.net.new_socket", side_effect=_new_socket),
476+
patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add,
477+
patch("zeroconf._utils.net.set_respond_socket_multicast_options"),
478+
patch(
479+
"zeroconf._utils.net.ifaddr.get_adapters",
480+
return_value=_generate_mock_adapters(),
481+
),
482+
patch("zeroconf._utils.net.socket.socket.setsockopt"),
483+
):
484+
r.create_sockets(
485+
interfaces=[1],
486+
multicast_addresses=["2001:db8::"],
487+
ip_version=r.IPVersion.V6Only,
488+
)
489+
490+
joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock]
491+
# Both the interface index 1 and the extra multicast address resolve to the
492+
# same adapter tuple — what matters is the listen socket joined that group.
493+
assert (("2001:db8::", 1, 1), 1) in joined
494+
495+
496+
def test_create_sockets_multicast_addresses_unicast_rejected() -> None:
497+
"""multicast_addresses is incompatible with unicast=True (there is no listen socket)."""
498+
with pytest.raises(ValueError):
499+
r.create_sockets(
500+
interfaces=["127.0.0.1"],
501+
multicast_addresses=["192.168.1.5"],
502+
unicast=True,
503+
)
504+
505+
506+
def test_create_sockets_multicast_addresses_default_path() -> None:
507+
"""multicast_addresses also works on the InterfaceChoice.Default fast path."""
508+
listen_mock = Mock(spec=socket.socket)
509+
510+
with (
511+
patch("zeroconf._utils.net.new_socket", return_value=listen_mock),
512+
patch("zeroconf._utils.net.add_multicast_member", return_value=True) as mock_add,
513+
patch("zeroconf._utils.net.set_respond_socket_multicast_options"),
514+
patch("zeroconf._utils.net.socket.socket.setsockopt"),
515+
):
516+
listen_socket, respond_sockets = r.create_sockets(
517+
interfaces=r.InterfaceChoice.Default,
518+
multicast_addresses=["192.168.1.5"],
519+
ip_version=r.IPVersion.V4Only,
520+
)
521+
522+
assert listen_socket is listen_mock
523+
assert respond_sockets == [listen_mock]
524+
joined = [c.args[1] for c in mock_add.call_args_list if c.args[0] is listen_mock]
525+
assert "0.0.0.0" in joined
526+
assert "192.168.1.5" in joined

0 commit comments

Comments
 (0)