Skip to content

Commit aefa298

Browse files
committed
fix: reject 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. This change rejects InterfaceChoice.Default IPv6 only or IPv6/IPv4 dual-stack configurations. 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. Since we allow shared listener/responder sockets, with IPv4 only sockets now, the macOS error address 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 754b787 commit aefa298

4 files changed

Lines changed: 41 additions & 32 deletions

File tree

src/zeroconf/_core.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,15 @@ def __init__(
170170
:param apple_p2p: use AWDL interface (only macOS)
171171
"""
172172
if ip_version is None:
173-
ip_version = autodetect_ip_version(interfaces)
173+
if interfaces is InterfaceChoice.Default:
174+
ip_version = IPVersion.V4Only
175+
else:
176+
ip_version = autodetect_ip_version(interfaces)
177+
178+
# IPv6 requires an explicit interface to work. Use InterfaceChoice.All or pass
179+
# a interface list explicitly.
180+
if interfaces is InterfaceChoice.Default and ip_version != IPVersion.V4Only:
181+
raise RuntimeError("`InterfaceChoice.Default` is only supported with IPv4.")
174182

175183
self.done = False
176184

src/zeroconf/_utils/net.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -198,28 +198,33 @@ def set_so_reuseport_if_available(s: socket.socket) -> None:
198198
raise
199199

200200

201-
def set_mdns_port_socket_options_for_ip_version(
201+
def set_respond_socket_multicast_options(
202202
s: socket.socket,
203-
bind_addr: tuple[str] | tuple[str, int, int],
204203
ip_version: IPVersion,
205204
) -> None:
206-
"""Set ttl/hops and loop for mdns port."""
207-
if ip_version != IPVersion.V6Only:
208-
ttl = struct.pack(b"B", 255)
209-
loop = struct.pack(b"B", 1)
205+
"""Set ttl/hops and loop for mDNS respond socket."""
206+
if ip_version == IPVersion.V4Only:
210207
# OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and
211208
# IP_MULTICAST_LOOP socket options as an unsigned char.
212-
try:
213-
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
214-
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
215-
except OSError as e:
216-
if bind_addr[0] != "" or get_errno(e) != errno.EINVAL: # Fails to set on MacOS
217-
raise
218-
219-
if ip_version != IPVersion.V4Only:
209+
ttl = struct.pack(b"B", 255)
210+
loop = struct.pack(b"B", 1)
211+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
212+
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
213+
elif ip_version == IPVersion.V6Only:
220214
# However, char doesn't work here (at least on Linux)
221215
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255)
222216
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True)
217+
else:
218+
# A shared sender socket is not really possible, especially with link-local
219+
# multicast addresses (ff02::/16), the kernel needs to know which interface
220+
# to use for routing.
221+
#
222+
# It seems that macOS even refuses to take IPv4 socket options if this is an
223+
# AF_INET6 socket.
224+
#
225+
# In theory we could reconfigure the socket on each send, but that is not
226+
# really practical for Python Zerconf.
227+
raise RuntimeError("Dual-stack responder socket not supported")
223228

224229

225230
def new_socket(
@@ -244,9 +249,6 @@ def new_socket(
244249
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
245250
set_so_reuseport_if_available(s)
246251

247-
if port == _MDNS_PORT:
248-
set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version)
249-
250252
if apple_p2p:
251253
# SO_RECV_ANYIF = 0x1104
252254
# https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h
@@ -402,6 +404,7 @@ def new_respond_socket(
402404
socket.IP_MULTICAST_IF,
403405
socket.inet_aton(cast(str, interface)),
404406
)
407+
set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only)
405408
return respond_socket
406409

407410

@@ -423,6 +426,8 @@ def create_sockets(
423426
if not unicast and interfaces is InterfaceChoice.Default:
424427
for interface in normalized_interfaces:
425428
add_multicast_member(cast(socket.socket, listen_socket), interface)
429+
# Sent responder socket options to the dual-use listen socket
430+
set_respond_socket_multicast_options(cast(socket.socket, listen_socket), ip_version)
426431
return listen_socket, [cast(socket.socket, listen_socket)]
427432

428433
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: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -162,23 +162,19 @@ def test_set_so_reuseport_if_available_not_present():
162162
netutils.set_so_reuseport_if_available(sock)
163163

164164

165-
def test_set_mdns_port_socket_options_for_ip_version():
165+
def test_set_respond_socket_multicast_options():
166166
"""Test OSError with errno with EINVAL and bind address ''.
167167
168168
from setsockopt IP_MULTICAST_TTL does not raise."""
169169
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
170170

171-
# Should raise on EPERM always
172-
with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)):
173-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)
174-
175-
# Should raise on EINVAL always when bind address is not ''
171+
# Should raise on EINVAL always
176172
with pytest.raises(OSError), patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)):
177-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only)
173+
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only)
178174

179-
# Should not raise on EINVAL when bind address is ''
180-
with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)):
181-
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)
175+
# Should raise on EINVAL always
176+
with pytest.raises(RuntimeError):
177+
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.All)
182178

183179

184180
def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None:

0 commit comments

Comments
 (0)