Skip to content

Commit b685214

Browse files
committed
fix: address non-working socket configuration
For IPv6, a shared listener/responder socket is not really possible when sending to link-local IPv6 multicast addresses (ff02::/16): The kernel needs to know which interface to use for routing. On IPv4, this is historically a bit different, the kernel just uses what it deems the primary/best route interface based on the routing table. But for IPv6, a message is rejected by Linux with OSError no 99 "Cannot assign requested address" and OSError no 65 "No route to host" on macOS. Removing the InterfaceChoice.Default config when IPv6 is enabled would be a major breaking change. Instead, inform the user with an error log message and a DeprecationWarning that this is not a working configuration. As a further cleanup, move the socket options for sending multicast packets out of the common socket creation code. For listen only sockets those settings are not needed. Also don't use a shared listener/responder sockets for dual-stack mode. Using a shared socket for IPv4 and IPv6 is especially problematic on macOS, where the kernel does not support this and even rejects socket options for IPv4 multicast in this case. With a separate IPv4 socket, this actually fixes the IPv4 multicast query sending when using the default interface. The sending will only fail on the IPv6 socket, so IPv4 requests will make it through. With that, the macOS error addressed in #392 is not a problem anymore. Actually, we would like to get an exception in case we get into this combination, so remove the explicit exception handling.
1 parent 2704340 commit b685214

3 files changed

Lines changed: 112 additions & 50 deletions

File tree

src/zeroconf/_utils/net.py

Lines changed: 35 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -168,8 +168,17 @@ def normalize_interface_choice(
168168
result: list[str | tuple[tuple[str, int, int], int]] = []
169169
if choice is InterfaceChoice.Default:
170170
if ip_version != IPVersion.V4Only:
171-
# IPv6 multicast uses interface 0 to mean the default
172-
result.append((("", 0, 0), 0))
171+
# IPv6 multicast uses interface 0 to mean the default. However,
172+
# the default interface can't be used for outgoing IPv6 multicast
173+
# requests. In a way, interface choice default isn't really working
174+
# with IPv6. Inform the user accordingly.
175+
message = (
176+
"IPv6 multicast requests can't be sent using default interface. "
177+
"Use V4Only, InterfaceChoice.All or an explicit list of interfaces."
178+
)
179+
log.error(message)
180+
warnings.warn(message, DeprecationWarning, stacklevel=2)
181+
result.append((("::", 0, 0), 0))
173182
if ip_version != IPVersion.V6Only:
174183
result.append("0.0.0.0")
175184
elif choice is InterfaceChoice.All:
@@ -220,28 +229,33 @@ def set_so_reuseport_if_available(s: socket.socket) -> None:
220229
raise
221230

222231

223-
def set_mdns_port_socket_options_for_ip_version(
232+
def set_respond_socket_multicast_options(
224233
s: socket.socket,
225-
bind_addr: tuple[str] | tuple[str, int, int],
226234
ip_version: IPVersion,
227235
) -> None:
228-
"""Set ttl/hops and loop for mdns port."""
229-
if ip_version != IPVersion.V6Only:
230-
ttl = struct.pack(b"B", 255)
231-
loop = struct.pack(b"B", 1)
236+
"""Set ttl/hops and loop for mDNS respond socket."""
237+
if ip_version == IPVersion.V4Only:
232238
# OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and
233239
# IP_MULTICAST_LOOP socket options as an unsigned char.
234-
try:
235-
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
236-
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
237-
except OSError as e:
238-
if bind_addr[0] != "" or get_errno(e) != errno.EINVAL: # Fails to set on MacOS
239-
raise
240-
241-
if ip_version != IPVersion.V4Only:
240+
ttl = struct.pack(b"B", 255)
241+
loop = struct.pack(b"B", 1)
242+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
243+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
244+
elif ip_version == IPVersion.V6Only:
242245
# However, char doesn't work here (at least on Linux)
243246
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255)
244247
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True)
248+
else:
249+
# A shared sender socket is not really possible, especially with link-local
250+
# multicast addresses (ff02::/16), the kernel needs to know which interface
251+
# to use for routing.
252+
#
253+
# It seems that macOS even refuses to take IPv4 socket options if this is an
254+
# AF_INET6 socket.
255+
#
256+
# In theory we could reconfigure the socket on each send, but that is not
257+
# really practical for Python Zerconf.
258+
raise RuntimeError("Dual-stack responder socket not supported")
245259

246260

247261
def new_socket(
@@ -266,9 +280,6 @@ def new_socket(
266280
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
267281
set_so_reuseport_if_available(s)
268282

269-
if port == _MDNS_PORT:
270-
set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version)
271-
272283
if apple_p2p:
273284
# SO_RECV_ANYIF = 0x1104
274285
# https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h
@@ -424,6 +435,7 @@ def new_respond_socket(
424435
socket.IP_MULTICAST_IF,
425436
socket.inet_aton(cast(str, interface)),
426437
)
438+
set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only)
427439
return respond_socket
428440

429441

@@ -440,11 +452,13 @@ def create_sockets(
440452

441453
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
442454

443-
# If we are using InterfaceChoice.Default we can use
455+
# If we are using InterfaceChoice.Default with only IPv4 or only IPv6, we can use
444456
# a single socket to listen and respond.
445-
if not unicast and interfaces is InterfaceChoice.Default:
457+
if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All:
446458
for interface in normalized_interfaces:
447459
add_multicast_member(cast(socket.socket, listen_socket), interface)
460+
# Sent responder socket options to the dual-use listen socket
461+
set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version)
448462
return listen_socket, [cast(socket.socket, listen_socket)]
449463

450464
respond_sockets = []

tests/test_core.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,16 @@ def test_close_multiple_times(self):
8787
def test_launch_and_close_v4_v6(self):
8888
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All)
8989
rv.close()
90-
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
91-
rv.close()
90+
with pytest.raises(RuntimeError):
91+
r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
9292

9393
@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
9494
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
9595
def test_launch_and_close_v6_only(self):
9696
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only)
9797
rv.close()
98-
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
99-
rv.close()
98+
with pytest.raises(RuntimeError):
99+
r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
100100

101101
@unittest.skipIf(sys.platform == "darwin", reason="apple_p2p failure path not testable on mac")
102102
def test_launch_and_close_apple_p2p_not_mac(self):

tests/utils/test_net.py

Lines changed: 73 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
import sys
88
import unittest
99
import warnings
10-
from unittest.mock import MagicMock, Mock, patch
10+
from unittest.mock import MagicMock, Mock, call, patch
1111

1212
import ifaddr
1313
import pytest
@@ -20,11 +20,11 @@
2020
def _generate_mock_adapters():
2121
mock_lo0 = Mock(spec=ifaddr.Adapter)
2222
mock_lo0.nice_name = "lo0"
23-
mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")]
23+
mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0"), ifaddr.IP(("::1", 0, 0), 128, "lo")]
2424
mock_lo0.index = 0
2525
mock_eth0 = Mock(spec=ifaddr.Adapter)
2626
mock_eth0.nice_name = "eth0"
27-
mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")]
27+
mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0"), ifaddr.IP(("fd00:db8::", 1, 1), 8, "eth0")]
2828
mock_eth0.index = 1
2929
mock_eth1 = Mock(spec=ifaddr.Adapter)
3030
mock_eth1.nice_name = "eth1"
@@ -65,7 +65,7 @@ def test_get_all_addresses_v6() -> None:
6565
):
6666
addresses = get_all_addresses_v6()
6767
assert isinstance(addresses, list)
68-
assert len(addresses) == 1
68+
assert len(addresses) == 3
6969
assert len(warned) == 1
7070
first_warning = warned[0]
7171
assert "get_all_addresses_v6 is deprecated" in str(first_warning.message)
@@ -200,28 +200,17 @@ def test_set_so_reuseport_if_available_not_present():
200200
netutils.set_so_reuseport_if_available(sock)
201201

202202

203-
def test_set_mdns_port_socket_options_for_ip_version():
203+
def test_set_respond_socket_multicast_options():
204204
"""Test OSError with errno with EINVAL and bind address ''.
205205
206206
from setsockopt IP_MULTICAST_TTL does not raise."""
207-
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
208-
# Should raise on EPERM always
209-
with (
210-
pytest.raises(OSError),
211-
patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)),
212-
):
213-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)
214-
215-
# Should raise on EINVAL always when bind address is not ''
216-
with (
217-
pytest.raises(OSError),
218-
patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)),
219-
):
220-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only)
221-
222-
# Should not raise on EINVAL when bind address is ''
223-
with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)):
224-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)
207+
# Should raise on EINVAL always
208+
with (
209+
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
210+
pytest.raises(OSError),
211+
patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)),
212+
):
213+
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only)
225214

226215

227216
def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None:
@@ -352,8 +341,8 @@ def test_new_respond_socket_new_socket_returns_none():
352341
assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type]
353342

354343

355-
def test_create_sockets():
356-
"""Test create_sockets with unicast and IPv4."""
344+
def test_create_sockets_interfaces_all_unicast():
345+
"""Test create_sockets with unicast."""
357346

358347
with (
359348
patch("zeroconf._utils.net.new_socket") as mock_new_socket,
@@ -382,3 +371,62 @@ def test_create_sockets():
382371
apple_p2p=False,
383372
bind_addr=("192.168.1.5",),
384373
)
374+
375+
376+
def test_create_sockets_interfaces_all() -> None:
377+
"""Test create_sockets with all interfaces.
378+
379+
Tests if a responder socket is created for every successful multicast
380+
join.
381+
"""
382+
adapters = _generate_mock_adapters()
383+
384+
# Additional IPv6 addresses usually fail to add membership
385+
failure_interface = ("fd00:db8::", 1, 1)
386+
387+
expected_calls = []
388+
for adapter in adapters:
389+
for ip in adapter.ips:
390+
if ip.ip == failure_interface:
391+
continue
392+
393+
if ip.is_IPv4:
394+
bind_addr = (ip.ip,)
395+
ip_version = r.IPVersion.V4Only
396+
else:
397+
bind_addr = ip.ip
398+
ip_version = r.IPVersion.V6Only
399+
400+
expected_calls.append(
401+
call(
402+
port=5353,
403+
ip_version=ip_version,
404+
apple_p2p=False,
405+
bind_addr=bind_addr,
406+
)
407+
)
408+
409+
def _patched_add_multicast_member(sock, interface):
410+
return interface[0] != failure_interface
411+
412+
with (
413+
patch("zeroconf._utils.net.new_socket") as mock_new_socket,
414+
patch(
415+
"zeroconf._utils.net.ifaddr.get_adapters",
416+
return_value=adapters,
417+
),
418+
patch("zeroconf._utils.net.add_multicast_member", side_effect=_patched_add_multicast_member),
419+
):
420+
mock_socket = Mock(spec=socket.socket)
421+
mock_new_socket.return_value = mock_socket
422+
423+
r.create_sockets(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All)
424+
425+
def call_to_tuple(c):
426+
return (c.args, tuple(sorted(c.kwargs.items())))
427+
428+
# Exclude first new_socket call as this is the listen socket
429+
actual_calls_set = {call_to_tuple(c) for c in mock_new_socket.call_args_list[1:]}
430+
expected_calls_set = {call_to_tuple(c) for c in expected_calls}
431+
432+
assert actual_calls_set == expected_calls_set

0 commit comments

Comments
 (0)