| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -168,8 +168,17 @@ def normalize_interface_choice( | |||
| 168 | 168 | result: list[str | tuple[tuple[str, int, int], int]] = [] | |
| 169 | 169 | if choice is InterfaceChoice.Default: | |
| 170 | 170 | 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)) | ||
| 173 | 182 | if ip_version != IPVersion.V6Only: | |
| 174 | 183 | result.append("0.0.0.0") | |
| 175 | 184 | elif choice is InterfaceChoice.All: | |
@@ -220,28 +229,33 @@ def set_so_reuseport_if_available(s: socket.socket) -> None: | |||
| 220 | 229 | raise | |
| 221 | 230 | ||
| 222 | 231 | ||
| 223 | - def set_mdns_port_socket_options_for_ip_version( | ||
| 232 | + def set_respond_socket_multicast_options( | ||
| 224 | 233 | s: socket.socket, | |
| 225 | - bind_addr: tuple[str] | tuple[str, int, int], | ||
| 226 | 234 | ip_version: IPVersion, | |
| 227 | 235 | ) -> 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: | ||
| 232 | 238 | # OpenBSD needs the ttl and loop values for the IP_MULTICAST_TTL and | |
| 233 | 239 | # 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: | ||
| 242 | 245 | # However, char doesn't work here (at least on Linux) | |
| 243 | 246 | s.setsockopt(_IPPROTO_IPV6, socket.IPV6_MULTICAST_HOPS, 255) | |
| 244 | 247 | 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") | ||
| 245 | 259 | ||
| 246 | 260 | ||
| 247 | 261 | def new_socket( | |
@@ -266,14 +280,12 @@ def new_socket( | |||
| 266 | 280 | s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) | |
| 267 | 281 | set_so_reuseport_if_available(s) | |
| 268 | 282 | ||
| 269 | - if port == _MDNS_PORT: | ||
| 270 | - set_mdns_port_socket_options_for_ip_version(s, bind_addr, ip_version) | ||
| 271 | - | ||
| 272 | 283 | if apple_p2p: | |
| 273 | 284 | # SO_RECV_ANYIF = 0x1104 | |
| 274 | 285 | # https://opensource.apple.com/source/xnu/xnu-4570.41.2/bsd/sys/socket.h | |
| 275 | 286 | s.setsockopt(socket.SOL_SOCKET, 0x1104, 1) | |
| 276 | 287 | ||
| 288 | + # Bind expects (address, port) for AF_INET and (address, port, flowinfo, scope_id) for AF_INET6 | ||
| 277 | 289 | bind_tup = (bind_addr[0], port, *bind_addr[1:]) | |
| 278 | 290 | try: | |
| 279 | 291 | s.bind(bind_tup) | |
@@ -392,15 +404,27 @@ def add_multicast_member( | |||
| 392 | 404 | def new_respond_socket( | |
| 393 | 405 | interface: str | tuple[tuple[str, int, int], int], | |
| 394 | 406 | apple_p2p: bool = False, | |
| 407 | + unicast: bool = False, | ||
| 395 | 408 | ) -> socket.socket | None: | |
| 409 | + """Create interface specific socket for responding to multicast queries.""" | ||
| 396 | 410 | 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) | ||
| 397 | 416 | 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, | ||
| 398 | 419 | ip_version=(IPVersion.V6Only if is_v6 else IPVersion.V4Only), | |
| 399 | 420 | apple_p2p=apple_p2p, | |
| 400 | - bind_addr=cast(tuple[tuple[str, int, int], int], interface)[0] if is_v6 else (cast(str, interface),), | ||
| 401 | 421 | ) | |
| 422 | + if unicast: | ||
| 423 | + return respond_socket | ||
| 424 | + | ||
| 402 | 425 | if not respond_socket: | |
| 403 | 426 | return None | |
| 427 | + | ||
| 404 | 428 | log.debug("Configuring socket %s with multicast interface %s", respond_socket, interface) | |
| 405 | 429 | if is_v6: | |
| 406 | 430 | iface_bin = struct.pack("@I", cast(int, interface[1])) | |
@@ -411,6 +435,7 @@ def new_respond_socket( | |||
| 411 | 435 | socket.IP_MULTICAST_IF, | |
| 412 | 436 | socket.inet_aton(cast(str, interface)), | |
| 413 | 437 | ) | |
| 438 | + set_respond_socket_multicast_options(respond_socket, IPVersion.V6Only if is_v6 else IPVersion.V4Only) | ||
| 414 | 439 | return respond_socket | |
| 415 | 440 | ||
| 416 | 441 | ||
@@ -423,33 +448,27 @@ def create_sockets( | |||
| 423 | 448 | if unicast: | |
| 424 | 449 | listen_socket = None | |
| 425 | 450 | 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) | ||
| 427 | 452 | ||
| 428 | 453 | normalized_interfaces = normalize_interface_choice(interfaces, ip_version) | |
| 429 | 454 | ||
| 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 | ||
| 431 | 456 | # 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) | ||
| 435 | 462 | return listen_socket, [cast(socket.socket, listen_socket)] | |
| 436 | 463 | ||
| 437 | 464 | respond_sockets = [] | |
| 438 | 465 | ||
| 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) | ||
| 453 | 472 | ||
| 454 | 473 | if respond_socket is not None: | |
| 455 | 474 | respond_sockets.append(respond_socket) | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -11,6 +11,7 @@ | |||
| 11 | 11 | import time | |
| 12 | 12 | import unittest | |
| 13 | 13 | import unittest.mock | |
| 14 | + import warnings | ||
| 14 | 15 | from typing import cast | |
| 15 | 16 | from unittest.mock import AsyncMock, Mock, patch | |
| 16 | 17 | ||
@@ -87,16 +88,26 @@ def test_close_multiple_times(self): | |||
| 87 | 88 | def test_launch_and_close_v4_v6(self): | |
| 88 | 89 | rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.All) | |
| 89 | 90 | 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 | + ) | ||
| 92 | 98 | ||
| 93 | 99 | @unittest.skipIf(not has_working_ipv6(), "Requires IPv6") | |
| 94 | 100 | @unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled") | |
| 95 | 101 | def test_launch_and_close_v6_only(self): | |
| 96 | 102 | rv = r.Zeroconf(interfaces=r.InterfaceChoice.All, ip_version=r.IPVersion.V6Only) | |
| 97 | 103 | 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 | + ) | ||
| 100 | 111 | ||
| 101 | 112 | @unittest.skipIf(sys.platform == "darwin", reason="apple_p2p failure path not testable on mac") | |
| 102 | 113 | def test_launch_and_close_apple_p2p_not_mac(self): | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -7,7 +7,7 @@ | |||
| 7 | 7 | import sys | |
| 8 | 8 | import unittest | |
| 9 | 9 | import warnings | |
| 10 | - from unittest.mock import MagicMock, Mock, patch | ||
| 10 | + from unittest.mock import MagicMock, Mock, call, patch | ||
| 11 | 11 | ||
| 12 | 12 | import ifaddr | |
| 13 | 13 | import pytest | |
@@ -20,11 +20,11 @@ | |||
| 20 | 20 | def _generate_mock_adapters(): | |
| 21 | 21 | mock_lo0 = Mock(spec=ifaddr.Adapter) | |
| 22 | 22 | 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")] | ||
| 24 | 24 | mock_lo0.index = 0 | |
| 25 | 25 | mock_eth0 = Mock(spec=ifaddr.Adapter) | |
| 26 | 26 | 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")] | ||
| 28 | 28 | mock_eth0.index = 1 | |
| 29 | 29 | mock_eth1 = Mock(spec=ifaddr.Adapter) | |
| 30 | 30 | mock_eth1.nice_name = "eth1" | |
@@ -65,7 +65,7 @@ def test_get_all_addresses_v6() -> None: | |||
| 65 | 65 | ): | |
| 66 | 66 | addresses = get_all_addresses_v6() | |
| 67 | 67 | assert isinstance(addresses, list) | |
| 68 | - assert len(addresses) == 1 | ||
| 68 | + assert len(addresses) == 3 | ||
| 69 | 69 | assert len(warned) == 1 | |
| 70 | 70 | first_warning = warned[0] | |
| 71 | 71 | 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(): | |||
| 200 | 200 | netutils.set_so_reuseport_if_available(sock) | |
| 201 | 201 | ||
| 202 | 202 | ||
| 203 | - def test_set_mdns_port_socket_options_for_ip_version(): | ||
| 203 | + def test_set_respond_socket_multicast_options(): | ||
| 204 | 204 | """Test OSError with errno with EINVAL and bind address ''. | |
| 205 | 205 | ||
| 206 | 206 | 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), | ||
| 211 | + patch("socket.socket.setsockopt", side_effect=OSError(errno.EINVAL, None)), | ||
| 212 | + ): | ||
| 213 | + netutils.set_respond_socket_multicast_options(sock, r.IPVersion.V4Only) | ||
| 221 | 214 | ||
| 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) | ||
| 225 | 217 | ||
| 226 | 218 | ||
| 227 | 219 | def test_add_multicast_member(caplog: pytest.LogCaptureFixture) -> None: | |
@@ -352,8 +344,8 @@ def test_new_respond_socket_new_socket_returns_none(): | |||
| 352 | 344 | assert netutils.new_respond_socket(("0.0.0.0", 0)) is None # type: ignore[arg-type] | |
| 353 | 345 | ||
| 354 | 346 | ||
| 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.""" | ||
| 357 | 349 | ||
| 358 | 350 | with ( | |
| 359 | 351 | patch("zeroconf._utils.net.new_socket") as mock_new_socket, | |
@@ -382,3 +374,62 @@ def test_create_sockets(): | |||
| 382 | 374 | apple_p2p=False, | |
| 383 | 375 | bind_addr=("192.168.1.5",), | |
| 384 | 376 | ) | |
| 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 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments