diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index a19da8df75f..4c4c81aff32 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -150,14 +150,21 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: if not adapter["enabled"]: continue if ipv4s := adapter["ipv4"]: - interfaces.append(ipv4s[0]["address"]) - elif ipv6s := adapter["ipv6"]: - interfaces.append(ipv6s[0]["scope_id"]) + interfaces.extend( + ipv4["address"] + for ipv4 in ipv4s + if not ipaddress.ip_address(ipv4["address"]).is_loopback + ) + if adapter["ipv6"]: + ifi = socket.if_nametoindex(adapter["name"]) + interfaces.append(ifi) ipv6 = True if not any(adapter["enabled"] and adapter["ipv6"] for adapter in adapters): ipv6 = False zc_args["ip_version"] = IPVersion.V4Only + else: + zc_args["ip_version"] = IPVersion.All aio_zc = await _async_get_instance(hass, **zc_args) zeroconf = cast(HaZeroconf, aio_zc.zeroconf) @@ -190,6 +197,32 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: return True +def _get_announced_addresses( + adapters: list[Adapter], + first_ip: bytes | None = None, +) -> list[bytes]: + """Return a list of IP addresses to announce via zeroconf. + + If first_ip is not None, it will be the first address in the list. + """ + addresses = { + addr.packed + for addr in [ + ipaddress.ip_address(ip["address"]) + for adapter in adapters + if adapter["enabled"] + for ip in cast(list, adapter["ipv6"]) + cast(list, adapter["ipv4"]) + ] + if not (addr.is_unspecified or addr.is_loopback) + } + if first_ip: + address_list = [first_ip] + address_list.extend(addresses - set({first_ip})) + else: + address_list = list(addresses) + return address_list + + async def _async_register_hass_zc_service( hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str ) -> None: @@ -218,12 +251,15 @@ async def _async_register_hass_zc_service( # Set old base URL based on external or internal params["base_url"] = params["external_url"] or params["internal_url"] - host_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) + adapters = await network.async_get_adapters(hass) - try: + # Puts the default IPv4 address first in the list to preserve compatibility, + # because some mDNS implementations ignores anything but the first announced address. + host_ip = await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP) + host_ip_pton = None + if host_ip: host_ip_pton = socket.inet_pton(socket.AF_INET, host_ip) - except OSError: - host_ip_pton = socket.inet_pton(socket.AF_INET6, host_ip) + address_list = _get_announced_addresses(adapters, host_ip_pton) _suppress_invalid_properties(params) @@ -231,7 +267,7 @@ async def _async_register_hass_zc_service( ZEROCONF_TYPE, name=f"{valid_location_name}.{ZEROCONF_TYPE}", server=f"{uuid}.local.", - addresses=[host_ip_pton], + addresses=address_list, port=hass.http.server_port, properties=params, ) diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 68c0785e60b..3b8cf883a13 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,11 +1,16 @@ """Test Zeroconf component setup process.""" +from ipaddress import ip_address from unittest.mock import call, patch from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf -from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 +from homeassistant.components.zeroconf import ( + CONF_DEFAULT_INTERFACE, + CONF_IPV6, + _get_announced_addresses, +) from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, @@ -726,10 +731,16 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ "ipv6": [ { "address": "2001:db8::", - "network_prefix": 8, + "network_prefix": 64, "flowinfo": 1, "scope_id": 1, - } + }, + { + "address": "fe80::1234:5678:9abc:def0", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, ], "name": "eth0", }, @@ -741,6 +752,21 @@ _ADAPTERS_WITH_MANUAL_CONFIG = [ "ipv6": [], "name": "eth1", }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "172.16.1.5", "network_prefix": 23}], + "ipv6": [ + { + "address": "fe80::dead:beef:dead:beef", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 3, + } + ], + "name": "eth2", + }, { "auto": False, "default": False, @@ -764,9 +790,36 @@ async def test_async_detect_interfaces_setting_empty_route(hass, mock_async_zero ), patch( "homeassistant.components.zeroconf.AsyncServiceInfo", side_effect=get_service_info_mock, + ), patch( + "socket.if_nametoindex", + side_effect=lambda iface: {"eth0": 1, "eth1": 2, "eth2": 3, "vtun0": 4}.get( + iface, 0 + ), ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() + assert mock_zc.mock_calls[0] == call( + interfaces=[1, "192.168.1.5", "172.16.1.5", 3], ip_version=IPVersion.All + ) - assert mock_zc.mock_calls[0] == call(interfaces=[1, "192.168.1.5"]) + +async def test_get_announced_addresses(hass, mock_async_zeroconf): + """Test addresses for mDNS announcement.""" + expected = { + ip_address(ip).packed + for ip in [ + "fe80::1234:5678:9abc:def0", + "2001:db8::", + "192.168.1.5", + "fe80::dead:beef:dead:beef", + "172.16.1.5", + ] + } + first_ip = ip_address("172.16.1.5").packed + actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) + assert actual[0] == first_ip and set(actual) == expected + + first_ip = ip_address("192.168.1.5").packed + actual = _get_announced_addresses(_ADAPTERS_WITH_MANUAL_CONFIG, first_ip) + assert actual[0] == first_ip and set(actual) == expected