From cb4558c0885e590242db92cc6f45f91c1bdb68c7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 21 Apr 2021 19:10:34 -1000 Subject: [PATCH] Autodetect zeroconf interface selection when not set (#49529) --- homeassistant/components/zeroconf/__init__.py | 61 ++++++++++++-- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/zeroconf/test_init.py | 80 +++++++++++++++++++ 6 files changed, 144 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index d2eaa6ca766..7b13c7fd753 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -5,10 +5,12 @@ from contextlib import suppress import fnmatch from functools import partial import ipaddress +from ipaddress import ip_address import logging import socket -from typing import Any, TypedDict +from typing import Any, Iterable, TypedDict, cast +from pyroute2 import IPRoute import voluptuous as vol from zeroconf import ( Error as ZeroconfError, @@ -32,6 +34,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.singleton import singleton from homeassistant.loader import async_get_homekit, async_get_zeroconf +from homeassistant.util.network import is_loopback from .models import HaServiceBrowser, HaZeroconf from .usage import install_multiple_zeroconf_catcher @@ -55,6 +58,8 @@ DEFAULT_IPV6 = True HOMEKIT_PAIRED_STATUS_FLAG = "sf" HOMEKIT_MODEL = "md" +MDNS_TARGET_IP = "224.0.0.251" + # Property key=value has a max length of 255 # so we use 230 to leave space for key= MAX_PROPERTY_VALUE_LEN = 230 @@ -66,9 +71,7 @@ CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { - vol.Optional( - CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE - ): cv.boolean, + vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean, vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean, } ) @@ -110,11 +113,59 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaZeroconf: return zeroconf +def _get_ip_route(dst_ip: str) -> Any: + """Get ip next hop.""" + return IPRoute().route("get", dst=dst_ip) + + +def _first_ip_nexthop_from_route(routes: Iterable) -> None | str: + """Find the first RTA_PREFSRC in the routes.""" + _LOGGER.debug("Routes: %s", routes) + for route in routes: + for key, value in route["attrs"]: + if key == "RTA_PREFSRC": + return cast(str, value) + return None + + +async def async_detect_interfaces_setting(hass: HomeAssistant) -> InterfaceChoice: + """Auto detect the interfaces setting when unset.""" + routes = [] + try: + routes = await hass.async_add_executor_job(_get_ip_route, MDNS_TARGET_IP) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.debug( + "The system could not auto detect routing data on your operating system; Zeroconf will broadcast on all interfaces", + exc_info=ex, + ) + return InterfaceChoice.All + + if not (first_ip := _first_ip_nexthop_from_route(routes)): + _LOGGER.debug( + "The system could not auto detect the nexthop for %s on your operating system; Zeroconf will broadcast on all interfaces", + MDNS_TARGET_IP, + ) + return InterfaceChoice.All + + if is_loopback(ip_address(first_ip)): + _LOGGER.debug( + "The next hop for %s is %s; Zeroconf will broadcast on all interfaces", + MDNS_TARGET_IP, + first_ip, + ) + return InterfaceChoice.All + + return InterfaceChoice.Default + + async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" zc_config = config.get(DOMAIN, {}) zc_args: dict = {} - if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE): + + if CONF_DEFAULT_INTERFACE not in zc_config: + zc_args["interfaces"] = await async_detect_interfaces_setting(hass) + elif zc_config[CONF_DEFAULT_INTERFACE]: zc_args["interfaces"] = InterfaceChoice.Default if not zc_config.get(CONF_IPV6, DEFAULT_IPV6): zc_args["ip_version"] = IPVersion.V4Only diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 149033c4acb..6e0c50e0683 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,7 +2,7 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.29.0"], + "requirements": ["zeroconf==0.29.0","pyroute2==0.5.18"], "dependencies": ["api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f6700ff2ef..1761a9de4f6 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -23,6 +23,7 @@ netdisco==2.8.2 paho-mqtt==1.5.1 pillow==8.1.2 pip>=8.0.3,<20.3 +pyroute2==0.5.18 python-slugify==4.0.1 pytz>=2021.1 pyyaml==5.4.1 diff --git a/requirements_all.txt b/requirements_all.txt index b52b8415755..b06589bac1b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1672,6 +1672,9 @@ pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.2 +# homeassistant.components.zeroconf +pyroute2==0.5.18 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0b64af7159..33f110af521 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -920,6 +920,9 @@ pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.2 +# homeassistant.components.zeroconf +pyroute2==0.5.18 + # homeassistant.components.ruckus_unleashed pyruckus==0.12 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index e7a30abc73f..61b444a5784 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -31,6 +31,27 @@ PROPERTIES = { HOMEKIT_STATUS_UNPAIRED = b"1" HOMEKIT_STATUS_PAIRED = b"0" +_ROUTE_NO_LOOPBACK = ( + { + "attrs": [ + ("RTA_TABLE", 254), + ("RTA_DST", "224.0.0.251"), + ("RTA_OIF", 4), + ("RTA_PREFSRC", "192.168.1.5"), + ], + }, +) +_ROUTE_LOOPBACK = ( + { + "attrs": [ + ("RTA_TABLE", 254), + ("RTA_DST", "224.0.0.251"), + ("RTA_OIF", 4), + ("RTA_PREFSRC", "127.0.0.1"), + ], + }, +) + def service_update_mock(zeroconf, services, handlers, *, limit_service=None): """Call service update handler.""" @@ -611,3 +632,62 @@ async def test_removed_ignored(hass, mock_zeroconf): assert len(mock_zeroconf.get_service_info.mock_calls) == 2 assert mock_zeroconf.get_service_info.mock_calls[0][1][0] == "_service.added" assert mock_zeroconf.get_service_info.mock_calls[1][1][0] == "_service.updated" + + +async def test_async_detect_interfaces_setting_non_loopback_route(hass, mock_zeroconf): + """Test without default interface config and the route returns a non-loopback address.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.IPRoute.route", + return_value=_ROUTE_NO_LOOPBACK, + ): + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + 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_zeroconf.called_with(interface_choice=InterfaceChoice.Default) + + +async def test_async_detect_interfaces_setting_loopback_route(hass, mock_zeroconf): + """Test without default interface config and the route returns a loopback address.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.IPRoute.route", return_value=_ROUTE_LOOPBACK + ): + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + 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_zeroconf.called_with(interface_choice=InterfaceChoice.All) + + +async def test_async_detect_interfaces_setting_empty_route(hass, mock_zeroconf): + """Test without default interface config and the route returns nothing.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaServiceBrowser", side_effect=service_update_mock + ), patch("homeassistant.components.zeroconf.IPRoute.route", return_value=[]): + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + 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_zeroconf.called_with(interface_choice=InterfaceChoice.All) + + +async def test_async_detect_interfaces_setting_exception(hass, mock_zeroconf): + """Test without default interface config and the route throws an exception.""" + with patch.object(hass.config_entries.flow, "async_init"), patch.object( + zeroconf, "HaServiceBrowser", side_effect=service_update_mock + ), patch( + "homeassistant.components.zeroconf.IPRoute.route", side_effect=AttributeError + ): + mock_zeroconf.get_service_info.side_effect = get_service_info_mock + 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_zeroconf.called_with(interface_choice=InterfaceChoice.All)