10000 fix: address non-working socket configuration by agners · Pull Request #1563 · python-zeroconf/python-zeroconf · GitHub
[go: up one dir, main page]

Skip to content

fix: address non-working socket configuration #1563

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 14, 2025
Merged
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
97 changes: 58 additions & 39 deletions src/zeroconf/_utils/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,8 +168,17 @@ def normalize_interface_choice(
result: list[str | tuple[tuple[str, int, int], int]] = []
if choice is InterfaceChoice.Default:
if ip_version != IPVersion.V4Only:
# IPv6 multicast uses interface 0 to mean the default
result.append((("", 0, 0), 0))
# IPv6 multicast uses interface 0 to mean the default. However,
# the default interface can't be used for outgoing IPv6 multicast
# requests. In a way, interface choice default isn't really working
# with IPv6. Inform the user accordingly.
message = (
"IPv6 multicast requests can't be sent using default interface. "
"Use V4Only, InterfaceChoice.All or an explicit list of interfaces."
)
log.error(message)
warnings.warn(message, DeprecationWarning, stacklevel=2)
result.append((("::", 0, 0), 0))
if ip_version != IPVersion.V6Only:
result.append("0.0.0.0")
elif choice is InterfaceChoice.All:
Expand Down Expand Up @@ -220,28 +229,33 @@ def set_so_reuseport_if_available(s: socket.socket) -> None:
raise


def set_mdns_port_socket_options_for_ip_version(
def set_respond_socket_multicast_options(
s: socket.socket,
bind_addr: tuple[str] | tuple[str, int, int],
ip_version: IPVersion,
) -> None:
"""Set ttl/hops and loop for mdns port."""
if ip_version != IPVersion.V6Only:
ttl = struct.pack(b"B", 255)
loop = struct.pack(b"B", 1)
"""Set ttl/hops and loop for mDNS respond socket."""
if ip_version == IPVersion.V4Only:
# OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and
# IP_MULTICAST_LOOP socket options as an unsigned char.
try:
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
except OSError as e:
if bind_addr[0] != "" or get_errno(e) != errno.EINVAL: # Fails to set on MacOS
raise

if ip_version != IPVersion.V4Only:
ttl = struct.pack(b"B", 255)
loop = struct.pack(b"B", 1)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL, ttl)
s.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_LOOP, loop)
elif ip_version == IPVersion.V6Only:
# However, char doesn't work here (at least on Linux)
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255)
s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_LOOP, True)
else:
# A shared sender socket is not really possible, especially with link-local
# multicast addresses (ff02::/16), the kernel needs to know which interface
# to use for routing.
#
# It seems that macOS even refuses to take IPv4 socket options if this is an
# AF_INET6 socket.
#
# In theory we could reconfigure the socket on each send, but that is not
# really practical for Python Zerconf.
raise RuntimeError("Dual-stack responder socket not supported")


def new_socket(
Expand All @@ -266,14 +280,12 @@ def new_socket(
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
set_so_reuseport_if_available(s)

if port == _MDNS_PORT:
set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version)

if apple_p2p:
# SO_RECV_ANYIF = 0x1104
# https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h
s.setsockopt(socket.SOL_SOCKET, 0x1104, 1)

# Bind expects (address, port) for AF_INET and (address, port, flowinfo, scope_id) for AF_INET6
bind_tup = (bind_addr[0], port, *bind_addr[1:])
try:
s.bind(bind_tup)
Expand Down Expand Up @@ -392,15 +404,27 @@ def add_multicast_member(
def new_respond_socket(
interface: str | tuple[tuple[str, int, int], int],
apple_p2p: bool = False,
unicast: bool = False,
) -> socket.socket | None:
"""Create interface specific socket for responding to multicast queries."""
is_v6 = isinstance(interface, tuple)

# For response sockets:
# - Bind explicitly to the interface address
# - Use ephemeral ports if in unicast mode
# - Create socket according to the interface IP type (IPv4 or IPv6)
respond_socket = new_socket(
bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),),
port=0 if unicast else _MDNS_PORT,
ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only),
apple_p2p=apple_p2p,
bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),),
)
if unicast:
return respond_socket

if not respond_socket:
return None

log.debug("Configuring socket %s with multicast interface %s", respond_socket, interface)
if is_v6:
iface_bin = struct.pack("@I", cast(int, interface[1]))
Expand All @@ -411,6 +435,7 @@ def new_respond_socket(
socket.IP_MULTICAST_IF,
socket.inet_aton(cast(str, interface)),
)
set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only)
return respond_socket


Expand All @@ -423,33 +448,27 @@ def create_sockets(
if unicast:
listen_socket = None
else:
listen_socket = new_socket(ip_version=ip_version, apple_p2p=apple_p2p, bind_addr=("",))
listen_socket = new_socket(bind_addr=("",), ip_version=ip_version, apple_p2p=apple_p2p)

normalized_interfaces = normalize_interface_choice(interfaces, ip_version)

# If we are using InterfaceChoice.Default we can use
# 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:
for i in normalized_interfaces:
add_multicast_member(cast(socket.socket, listen_socket), i)
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)
# 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)]

respond_sockets = []

for i in normalized_interfaces:
if not unicast:
if add_multicast_member(cast(socket.socket, listen_socket), i):
respond_socket = new_respond_socket(i, apple_p2p=apple_p2p)
else:
respond_socket = None
else:
is_v6 = isinstance(i, tuple)
respond_socket = new_socket(
port=0,
ip_version=IPVersion.V6Only if is_v6 else IPVersion.V4Only,
apple_p2p=apple_p2p,
bind_addr=cast(tuple[tuple[str, int, int], int], i)[0] if is_v6 else (cast(str, i),),
)
for interface in normalized_interfaces:
# Only create response socket if unicast or becoming multicast member was successful
if not unicast and not add_multicast_member(cast(socket.socket, listen_socket), interface):
continue

respond_socket = new_respond_socket(interface, apple_p2p=apple_p2p, unicast=unicast)

if respond_socket is not None:
respond_sockets.append(respond_socket)
Expand Down
19 changes: 15 additions & 4 deletions tests/test_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import time
import unittest
import unittest.mock
import warnings
from typing import cast
from unittest.mock import AsyncMock, Mock, patch

Expand Down Expand Up @@ -87,16 +88,26 @@ def test_close_multiple_times(self):
def test_launch_and_close_v4_v6(self):
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All)
rv.close()
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
rv.close()
with warnings.catch_warnings(record=True) as warned:
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.All)
rv.close()
first_warning = warned[0]
assert "IPv6 multicast requests can't be sent using default interface" in str(
first_warning.message
)

@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
def test_launch_and_close_v6_only(self):
rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only)
rv.close()
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
rv.close()
with warnings.catch_warnings(record=True) as warned:
rv = r.Zeroconf(interfaces=r.InterfaceChoice.Default, ip_version=r.IPVersion.V6Only)
rv.close()
first_warning = warned[0]
assert "IPv6 multicast requests can't be sent using default interface" in str(
first_warning.message
)

@unittest.skipIf(sys.platform == "darwin", reason="apple_p2p failure path not testable on mac")
def test_launch_and_close_apple_p2p_not_mac(self):
Expand Down
99 changes: 75 additions & 24 deletions tests/utils/test_net.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import sys
import unittest
import warnings
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, Mock, call, patch

import ifaddr
import pytest
Expand All @@ -20,11 +20,11 @@
def _generate_mock_adapters():
mock_lo0 = Mock(spec=ifaddr.Adapter)
mock_lo0.nice_name = "lo0"
mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0")]
mock_lo0.ips = [ifaddr.IP("127.0.0.1", 8, "lo0"), ifaddr.IP(("::1", 0, 0), 128, "lo")]
mock_lo0.index = 0
mock_eth0 = Mock(spec=ifaddr.Adapter)
mock_eth0.nice_name = "eth0"
mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")]
mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0"), ifaddr.IP(("fd00:db8::", 1, 1), 8, "eth0")]
mock_eth0.index = 1
mock_eth1 = Mock(spec=ifaddr.Adapter)
mock_eth1.nice_name = "eth1"
Expand Down Expand Up @@ -65,7 +65,7 @@ def test_get_all_addresses_v6() -> None:
):
addresses = get_all_addresses_v6()
assert isinstance(addresses, list)
assert len(addresses) == 1
assert len(addresses) == 3
assert len(warned) == 1
first_warning = warned[0]
assert "get_all_addresses_v6 is deprecated" in str(first_warning.message)
Expand Down Expand Up @@ -200,28 +200,20 @@ def test_set_so_reuseport_if_available_not_present():
netutils.set_so_reuseport_if_available(sock)


def test_set_mdns_port_socket_options_for_ip_version():
def test_set_respond_socket_multicast_options():
"""Test OSError with errno with EINVAL and bind address ''.

from setsockopt IP_MULTICAST_TTL does not raise."""
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
# Should raise on EPERM always
with (
pytest.raises(OSError),
patch("socket.socket.setsockopt", side_effect=OSError(errno.EPERM, None)),
):
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)

# Should raise on EINVAL always when bind address is not ''
with (
pytest.raises(OSError),
patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)),
):
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("127.0.0.1",), r.IPVersion.V4Only)
# Should raise on EINVAL always
with (
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
pytest.raises(OSError),
patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)),
):
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only)

# Should not raise on EINVAL when bind address is ''
with patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)):
netutils.set_mdns_port_socket_options_for_ip_version(sock, ("",), r.IPVersion.V4Only)
with pytest.raises(RuntimeError):
netutils.set_respond_socket_multicast_options(sock, r.IPVersion.All)


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


def test_create_sockets():
"""Test create_sockets with unicast and IPv4."""
def test_create_sockets_interfaces_all_unicast():
"""Test create_sockets with unicast."""

with (
patch("zeroconf._utils.net.new_socket") as mock_new_socket,
Expand Down Expand Up @@ -382,3 +374,62 @@ def test_create_sockets():
apple_p2p=False,
bind_addr=("192.168.1.5",),
)


def test_create_sockets_interfaces_all() -> None:
"""Test create_sockets with all interfaces.

Tests if a responder socket is created for every successful multicast
join.
"""
adapters = _generate_mock_adapters()

# Additional IPv6 addresses usually fail to add membership
failure_interface = ("fd00:db8::", 1, 1)

expected_calls = []
for adapter in adapters:
for ip in adapter.ips:
if ip.ip == failure_interface:
continue

if ip.is_IPv4:
bind_addr = (ip.ip,)
ip_version = r.IPVersion.V4Only
else:
bind_addr = ip.ip
ip_version = r.IPVersion.V6Only

expected_calls.append(
call(
port=5353,
ip_version=ip_version,
apple_p2p=False,
bind_addr=bind_addr,
)
)

def _patched_add_multicast_member(sock, interface):
return interface[0] != failure_interface

with (
patch("zeroconf._utils.net.new_socket") as mock_new_socket,
patch(
"zeroconf._utils.net.ifaddr.get_adapters",
return_value=adapters,
),
patch("zeroconf._utils.net.add_multicast_member", side_effect=_patched_add_multicast_member),
):
mock_socket = Mock(spec=socket.socket)
mock_new_socket.return_value = mock_socket

r.create_sockets(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All)

def call_to_tuple(c):
return (c.args, tuple(sorted(c.kwargs.items())))

# Exclude first new_socket call as this is the listen socket
actual_calls_set = {call_to_tuple(c) for c in mock_new_socket.call_args_list[1:]}
expected_calls_set = {call_to_tuple(c) for c in expected_calls}

assert actual_calls_set == expected_calls_set
Loading
0