8000 fix: address non-working socket configuration (#1563) · python-zeroconf/python-zeroconf@cc0f835 · GitHub
[go: up one dir, main page]

Skip to content

Commit cc0f835

Browse files
agnersbdraco
andauthored
fix: address non-working socket configuration (#1563)
Co-authored-by: J. Nick Koston <nick@koston.org>
1 parent cb2f5b1 commit cc0f835

File tree

3 files changed

+148
-67
lines changed

3 files changed

+148
-67
lines changed

src/zeroconf/_utils/net.py

Lines changed: 58 additions & 39 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,14 +280,12 @@ 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
275286
s.setsockopt(socket.SOL_SOCKET, 0x1104, 1)
276287

288+
# Bind expects (address, port) for AF_INET and (address, port, flowinfo, scope_id) for AF_INET6
277289
bind_tup = (bind_addr[0], port, *bind_addr[1:])
278290
try:
279291
s.bind(bind_tup)
@@ -392,15 +404,27 @@ def add_multicast_member(
392404
def new_respond_socket(
393405
interface: str | tuple[tuple[str, int, int], int],
394406
apple_p2p: bool = False,
407+
unicast: bool = False,
395408
) -> socket.socket | None:
409+
"""Create interface specific socket for responding to multicast queries."""
396410
is_v6 = isinstance(interface, tuple)
411+
412+
# For response sockets:
413+
# - Bind explicitly to the interface address
414+
# - Use ephemeral ports if in unicast mode
415+
# - Create socket according to the interface IP type (IPv4 or IPv6)
397416
respond_socket = new_socket(
417+
bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),),
418+
port=0 if unicast else _MDNS_PORT,
398419
ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only),
399420
apple_p2p=apple_p2p,
400-
bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),),
401421
)
422+
if unicast:
423+
return respond_socket
424+
402425
if not respond_socket:
403426
return None
427+
404428
log.debug("Configuring socket %s with multicast interface %s", respond_socket, interface)
405429
if is_v6:
406430
iface_bin = struct.pack("@I", cast(int, interface[1]))
@@ -411,6 +435,7 @@ def new_respond_socket(
411435
socket.IP_MULTICAST_IF,
412436
socket.inet_aton(cast(str, interface)),
413437
)
438+
set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only)
414439
return respond_socket
415440

416441

@@ -423,33 +448,27 @@ def create_sockets(
423448
if unicast:
424449
listen_socket = None
425450
else:
426-
listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",))
451+
listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p)
427452

428453
normalized_interfaces = normalize_interface_choice(interfaces, ip_version)
429454

430-
# 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
431456
# a single socket to listen and respond.
432-
if not unicast and interfaces is InterfaceChoice.Default:
433-
for i in normalized_interfaces:
434-
add_multicast_member(cast(socket.socket, listen_socket), i)
457+
if not unicast and interfaces is InterfaceChoice.Default and ip_version != IPVersion.All:
458+
for interface in normalized_interfaces:
459+
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)
435462
return listen_socket, [cast(socket.socket, listen_socket)]
436463

437464
respond_sockets = []
438465

439-
for i in normalized_interfaces:
440-
if not unicast:
441-
if add_multicast_member(cast(socket.socket, listen_socket), i):
442-
respond_socket = new_respond_socket(i, apple_p2p=apple_p2p)
443-
else:
444-
respond_socket = None
445-
else:
446-
is_v6 = isinstance(i, tuple)
447-
respond_socket = new_socket(
448-
port=0,
449-
ip_version=IPVersion.V6Only if is_v6 else IPVersion.V4Only,
450-
apple_p2p=apple_p2p,
451-
bind_addr=cast(tuple[tuple[str, int, int], int], i)[0] if is_v6 else (cast(str, i),),
452-
)
466+
for interface in normalized_interfaces:
467+
# Only create response socket if unicast or becoming multicast member was successful
468+
if not unicast and not add_multicast_member(cast(socket.socket, listen_socket), interface):
469+
continue
470+
471+
respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=unicast)
453472

454473
if respond_socket is not None:
455474
respond_sockets.append(respond_socket)

tests/test_core.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import time
1212
import unittest
1313
import unittest.mock
14+
import warnings
1415
from typing import cast
1516
from unittest.mock import AsyncMock, Mock, patch
1617

@@ -87,16 +88,26 @@ def test_close_multiple_times(self):
8788
def test_launch_and_close_v4_v6(self):
8889
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All)
8990
rv.close()
90-
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
91-
rv.close()
91+
with warnings.catch_warnings(record=True) as warned:
92+
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
93+
rv.close()
94+
first_warning = warned[0]
95+
assert "IPv6 multicast requests can't be sent using default interface" in str(
96+
first_warning.message
97+
)
9298

9399
@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
94100
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
95101
def test_launch_and_close_v6_only(self):
96102
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only)
97103
rv.close()
98-
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
99-
rv.close()
104+
with warnings.catch_warnings(record=True) as warned:
105+
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
106+
rv.close()
107+
first_warning = warned[0]
108+
assert "IPv6 multicast requests can't be sent using default interface" in str(
109+
first_warning.message
110+
)
100111

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

tests/utils/test_net.py

Lines changed: 75 additions & 24 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,20 @@ 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)
207+
# Should raise on EINVAL always
208+
with (
209+
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
210+
pytest.raises(OSError),
< 10000 /td>
211+
patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)),
212+
):
213+
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only)
221214

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)
215+
with pytest.raises(RuntimeError):
216+
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.All)
225217

226218

227219
def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None:
@@ -352,8 +344,8 @@ def test_new_respond_socket_new_socket_returns_none():
352344
assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type]
353345

354346

355-
def test_create_sockets():
356-
"""Test create_sockets with unicast and IPv4."""
347+
def test_create_sockets_interfaces_all_unicast():
348+
"""Test create_sockets with unicast."""
357349

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

0 commit comments

Comments
 (0)
0