From 5fc287367c4159afe9faa2ae93097759a893d782 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 6 Jun 2023 12:07:52 -0500 Subject: [PATCH] Add dual IP stack support to HomeKit --- homeassistant/components/homekit/__init__.py | 20 ++-- homeassistant/components/network/__init__.py | 26 +++++ homeassistant/components/zeroconf/__init__.py | 44 +-------- tests/components/network/test_init.py | 98 +++++++++++++++++++ tests/components/zeroconf/test_init.py | 30 +----- 5 files changed, 138 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index 2b56a05682..25da49a338 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -28,7 +28,6 @@ from homeassistant.components.device_automation.trigger import ( ) from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import DOMAIN as HUMIDIFIER_DOMAIN -from homeassistant.components.network import MDNS_TARGET_IP from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( @@ -168,7 +167,9 @@ BRIDGE_SCHEMA = vol.All( ), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), - vol.Optional(CONF_ADVERTISE_IP): vol.All(ipaddress.ip_address, cv.string), + vol.Optional(CONF_ADVERTISE_IP): vol.All( + cv.ensure_list, ipaddress.ip_address, cv.string + ), vol.Optional(CONF_FILTER, default={}): BASE_FILTER_SCHEMA, vol.Optional(CONF_ENTITY_CONFIG, default={}): validate_entity_config, vol.Optional(CONF_DEVICES): cv.ensure_list, @@ -303,9 +304,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # ip_address and advertise_ip are yaml only ip_address = conf.get(CONF_IP_ADDRESS, [None]) - advertise_ip = conf.get( - CONF_ADVERTISE_IP, await network.async_get_source_ip(hass, MDNS_TARGET_IP) - ) + advertise_ips: list[str] = conf.get( + CONF_ADVERTISE_IP + ) or await network.async_get_announced_addresses(hass) + # exclude_accessory_mode is only used for config flow # to indicate that the config entry was setup after # we started creating config entries for entities that @@ -331,7 +333,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: exclude_accessory_mode, entity_config, homekit_mode, - advertise_ip, + advertise_ips, entry.entry_id, entry.title, devices=devices, @@ -508,7 +510,7 @@ class HomeKit: exclude_accessory_mode: bool, entity_config: dict, homekit_mode: str, - advertise_ip: str | None, + advertise_ips: list[str], entry_id: str, entry_title: str, devices: list[str] | None = None, @@ -521,7 +523,7 @@ class HomeKit: self._filter = entity_filter self._config = entity_config self._exclude_accessory_mode = exclude_accessory_mode - self._advertise_ip = advertise_ip + self._advertise_ips = advertise_ips self._entry_id = entry_id self._entry_title = entry_title self._homekit_mode = homekit_mode @@ -547,7 +549,7 @@ class HomeKit: address=self._ip_address, port=self._port, persist_file=persist_file, - advertised_address=self._advertise_ip, + advertised_address=self._advertise_ips, async_zeroconf_instance=async_zeroconf_instance, zeroconf_server=f"{uuid}-hap.local.", loader=get_loader(), diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py index 30ff228040..0b9ec9caad 100644 --- a/homeassistant/components/network/__init__.py +++ b/homeassistant/components/network/__init__.py @@ -119,6 +119,32 @@ async def async_get_ipv4_broadcast_addresses(hass: HomeAssistant) -> set[IPv4Add return broadcast_addresses +async def async_get_announced_addresses(hass: HomeAssistant) -> list[str]: + """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. + """ + adapters = await async_get_adapters(hass) + addresses: list[str] = [] + default_ip: str | None = None + for adapter in adapters: + if not adapter["enabled"]: + continue + for ips in adapter["ipv4"]: + addresses.append(str(IPv4Address(ips["address"]))) + for ips in adapter["ipv6"]: + addresses.append(str(IPv6Address(f"{ips['address']}%{ips['scope_id']}"))) + + # Puts the default IPv4 address first in the list to preserve compatibility, + # because some mDNS implementations ignores anything but the first announced + # address. + if default_ip := await async_get_source_ip(hass, target_ip=MDNS_TARGET_IP): + if default_ip in addresses: + addresses.remove(default_ip) + return [default_ip] + list(addresses) + return list(addresses) + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up network for Home Assistant.""" # Avoid circular issue: http->network->websocket_api->http diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index f12752dc5c..ec39a76860 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -7,10 +7,9 @@ from contextlib import suppress from dataclasses import dataclass from fnmatch import translate from functools import lru_cache -from ipaddress import IPv4Address, IPv6Address, ip_address +from ipaddress import IPv4Address, IPv6Address import logging import re -import socket import sys from typing import Any, Final, cast @@ -25,8 +24,6 @@ from zeroconf.asyncio import AsyncServiceInfo from homeassistant import config_entries from homeassistant.components import network -from homeassistant.components.network import MDNS_TARGET_IP, async_get_source_ip -from homeassistant.components.network.models import Adapter from homeassistant.const import EVENT_HOMEASSISTANT_STOP, __version__ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.data_entry_flow import BaseServiceInfo @@ -243,32 +240,6 @@ def _build_homekit_model_lookups( return homekit_model_lookup, homekit_model_matchers -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 [ - 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 - - def _filter_disallowed_characters(name: str) -> str: """Filter disallowed characters from a string. @@ -307,24 +278,13 @@ 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"] - adapters = await network.async_get_adapters(hass) - - # 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) - address_list = _get_announced_addresses(adapters, host_ip_pton) - _suppress_invalid_properties(params) info = AsyncServiceInfo( ZEROCONF_TYPE, name=f"{valid_location_name}.{ZEROCONF_TYPE}", server=f"{uuid}.local.", - addresses=address_list, + parsed_addresses=await network.async_get_announced_addresses(hass), port=hass.http.server_port, properties=params, ) diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py index a54e649115..513f33f018 100644 --- a/tests/components/network/test_init.py +++ b/tests/components/network/test_init.py @@ -712,3 +712,101 @@ async def test_async_get_source_ip_no_ip_loopback( await hass.async_block_till_done() assert await network.async_get_source_ip(hass) == "127.0.0.1" + + +_ADAPTERS_WITH_MANUAL_CONFIG = [ + { + "auto": True, + "index": 1, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, + { + "address": "fe80::1234:5678:9abc:def0", + "network_prefix": 64, + "flowinfo": 1, + "scope_id": 1, + }, + ], + "name": "eth0", + }, + { + "auto": True, + "index": 2, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": True, + "index": 3, + "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, + "index": 4, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, +] + + +async def test_async_get_announced_addresses(hass: HomeAssistant) -> None: + """Test addresses for mDNS announcement.""" + first_ip = "172.16.1.5" + with patch( + "homeassistant.components.network.async_get_source_ip", + return_value=first_ip, + ), patch( + "homeassistant.components.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + actual = await network.async_get_announced_addresses(hass) + assert actual[0] == first_ip and actual == [ + first_ip, + "2001:db8::%1", + "fe80::1234:5678:9abc:def0%1", + "192.168.1.5", + "fe80::dead:beef:dead:beef%3", + ] + + first_ip = "192.168.1.5" + with patch( + "homeassistant.components.network.async_get_source_ip", + return_value=first_ip, + ), patch( + "homeassistant.components.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, + ): + actual = await network.async_get_announced_addresses(hass) + + assert actual[0] == first_ip and actual == [ + first_ip, + "2001:db8::%1", + "fe80::1234:5678:9abc:def0%1", + "172.16.1.5", + "fe80::dead:beef:dead:beef%3", + ] diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index fea6b27e20..a6d61a2502 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,5 +1,4 @@ """Test Zeroconf component setup process.""" -from ipaddress import ip_address from typing import Any from unittest.mock import call, patch @@ -13,11 +12,7 @@ from zeroconf import ( from zeroconf.asyncio import AsyncServiceInfo from homeassistant.components import zeroconf -from homeassistant.components.zeroconf import ( - CONF_DEFAULT_INTERFACE, - CONF_IPV6, - _get_announced_addresses, -) +from homeassistant.components.zeroconf import CONF_DEFAULT_INTERFACE, CONF_IPV6 from homeassistant.const import ( EVENT_COMPONENT_LOADED, EVENT_HOMEASSISTANT_START, @@ -1204,29 +1199,6 @@ async def test_async_detect_interfaces_setting_empty_route_freebsd( ) -async def test_get_announced_addresses( - hass: HomeAssistant, mock_async_zeroconf: None -) -> None: - """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 - - _ADAPTER_WITH_DEFAULT_ENABLED_AND_IPV6 = [ { "auto": True,