From 64661ee2b7b902c09d8de9960e146f6a69831bd9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 26 May 2021 11:06:30 -0500 Subject: [PATCH] Add network configuration integration (#50874) Co-authored-by: Ruslan Sayfutdinov Co-authored-by: Paulus Schoutsen Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + .../components/default_config/manifest.json | 1 + homeassistant/components/network/__init__.py | 91 ++++ homeassistant/components/network/const.py | 27 ++ .../components/network/manifest.json | 10 + homeassistant/components/network/models.py | 31 ++ homeassistant/components/network/network.py | 78 +++ homeassistant/components/network/util.py | 158 +++++++ homeassistant/components/zeroconf/__init__.py | 82 ++-- .../components/zeroconf/manifest.json | 4 +- homeassistant/package_constraints.txt | 2 +- mypy.ini | 11 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- script/hassfest/dependencies.py | 2 + tests/components/network/__init__.py | 1 + tests/components/network/test_init.py | 446 ++++++++++++++++++ tests/components/zeroconf/test_init.py | 100 ++-- tests/test_requirements.py | 4 +- 19 files changed, 955 insertions(+), 106 deletions(-) create mode 100644 homeassistant/components/network/__init__.py create mode 100644 homeassistant/components/network/const.py create mode 100644 homeassistant/components/network/manifest.json create mode 100644 homeassistant/components/network/models.py create mode 100644 homeassistant/components/network/network.py create mode 100644 homeassistant/components/network/util.py create mode 100644 tests/components/network/__init__.py create mode 100644 tests/components/network/test_init.py diff --git a/.strict-typing b/.strict-typing index a72118fa843..00bc3447d22 100644 --- a/.strict-typing +++ b/.strict-typing @@ -45,6 +45,7 @@ homeassistant.components.lock.* homeassistant.components.mailbox.* homeassistant.components.media_player.* homeassistant.components.nam.* +homeassistant.components.network.* homeassistant.components.notify.* homeassistant.components.number.* homeassistant.components.onewire.* diff --git a/homeassistant/components/default_config/manifest.json b/homeassistant/components/default_config/manifest.json index 74c6b228a6f..032a6845340 100644 --- a/homeassistant/components/default_config/manifest.json +++ b/homeassistant/components/default_config/manifest.json @@ -19,6 +19,7 @@ "media_source", "mobile_app", "my", + "network", "person", "scene", "script", diff --git a/homeassistant/components/network/__init__.py b/homeassistant/components/network/__init__.py new file mode 100644 index 00000000000..3f19103acaa --- /dev/null +++ b/homeassistant/components/network/__init__.py @@ -0,0 +1,91 @@ +"""The Network Configuration integration.""" +from __future__ import annotations + +import logging + +import voluptuous as vol + +from homeassistant.components import websocket_api +from homeassistant.components.websocket_api.connection import ActiveConnection +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import bind_hass + +from .const import ( + ATTR_ADAPTERS, + ATTR_CONFIGURED_ADAPTERS, + DOMAIN, + NETWORK_CONFIG_SCHEMA, +) +from .models import Adapter +from .network import Network + +ZEROCONF_DOMAIN = "zeroconf" # cannot import from zeroconf due to circular dep +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_get_adapters(hass: HomeAssistant) -> list[Adapter]: + """Get the network adapter configuration.""" + network: Network = hass.data[DOMAIN] + return network.adapters + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up network for Home Assistant.""" + + hass.data[DOMAIN] = network = Network(hass) + await network.async_setup() + if ZEROCONF_DOMAIN in config: + await network.async_migrate_from_zeroconf(config[ZEROCONF_DOMAIN]) + network.async_configure() + + _LOGGER.debug("Adapters: %s", network.adapters) + + websocket_api.async_register_command(hass, websocket_network_adapters) + websocket_api.async_register_command(hass, websocket_network_adapters_configure) + + return True + + +@websocket_api.require_admin +@websocket_api.websocket_command({vol.Required("type"): "network"}) +@websocket_api.async_response +async def websocket_network_adapters( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Return network preferences.""" + network: Network = hass.data[DOMAIN] + connection.send_result( + msg["id"], + { + ATTR_ADAPTERS: network.adapters, + ATTR_CONFIGURED_ADAPTERS: network.configured_adapters, + }, + ) + + +@websocket_api.require_admin +@websocket_api.websocket_command( + { + vol.Required("type"): "network/configure", + vol.Required("config", default={}): NETWORK_CONFIG_SCHEMA, + } +) +@websocket_api.async_response +async def websocket_network_adapters_configure( + hass: HomeAssistant, + connection: ActiveConnection, + msg: dict, +) -> None: + """Update network config.""" + network: Network = hass.data[DOMAIN] + + await network.async_reconfig(msg["config"]) + + connection.send_result( + msg["id"], + {ATTR_CONFIGURED_ADAPTERS: network.configured_adapters}, + ) diff --git a/homeassistant/components/network/const.py b/homeassistant/components/network/const.py new file mode 100644 index 00000000000..ff69f026fef --- /dev/null +++ b/homeassistant/components/network/const.py @@ -0,0 +1,27 @@ +"""Constants for the network integration.""" +from __future__ import annotations + +from typing import Final + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +DOMAIN: Final = "network" +STORAGE_KEY: Final = "core.network" +STORAGE_VERSION: Final = 1 + +ATTR_ADAPTERS: Final = "adapters" +ATTR_CONFIGURED_ADAPTERS: Final = "configured_adapters" +DEFAULT_CONFIGURED_ADAPTERS: list[str] = [] + +MDNS_TARGET_IP: Final = "224.0.0.251" + + +NETWORK_CONFIG_SCHEMA = vol.Schema( + { + vol.Optional( + ATTR_CONFIGURED_ADAPTERS, default=DEFAULT_CONFIGURED_ADAPTERS + ): vol.Schema(vol.All(cv.ensure_list, [cv.string])), + } +) diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json new file mode 100644 index 00000000000..84e86014036 --- /dev/null +++ b/homeassistant/components/network/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "network", + "name": "Network Configuration", + "documentation": "https://www.home-assistant.io/integrations/network", + "requirements": ["ifaddr==0.1.7"], + "codeowners": [], + "dependencies": ["websocket_api"], + "quality_scale": "internal", + "iot_class": "local_push" +} diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py new file mode 100644 index 00000000000..a007eb8636d --- /dev/null +++ b/homeassistant/components/network/models.py @@ -0,0 +1,31 @@ +"""Models helper class for the network integration.""" +from __future__ import annotations + +from typing import TypedDict + + +class IPv6ConfiguredAddress(TypedDict): + """Represent an IPv6 address.""" + + address: str + flowinfo: int + scope_id: int + network_prefix: int + + +class IPv4ConfiguredAddress(TypedDict): + """Represent an IPv4 address.""" + + address: str + network_prefix: int + + +class Adapter(TypedDict): + """Configured network adapters.""" + + name: str + enabled: bool + auto: bool + default: bool + ipv6: list[IPv6ConfiguredAddress] + ipv4: list[IPv4ConfiguredAddress] diff --git a/homeassistant/components/network/network.py b/homeassistant/components/network/network.py new file mode 100644 index 00000000000..1243ba24774 --- /dev/null +++ b/homeassistant/components/network/network.py @@ -0,0 +1,78 @@ +"""Network helper class for the network integration.""" +from __future__ import annotations + +from typing import Any, cast + +from homeassistant.core import HomeAssistant, callback + +from .const import ( + ATTR_CONFIGURED_ADAPTERS, + DEFAULT_CONFIGURED_ADAPTERS, + NETWORK_CONFIG_SCHEMA, + STORAGE_KEY, + STORAGE_VERSION, +) +from .models import Adapter +from .util import ( + adapters_with_exernal_addresses, + async_load_adapters, + enable_adapters, + enable_auto_detected_adapters, +) + + +class Network: + """Network helper class for the network integration.""" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the Network class.""" + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data: dict[str, Any] = {} + self.adapters: list[Adapter] = [] + + @property + def configured_adapters(self) -> list[str]: + """Return the configured adapters.""" + return self._data.get(ATTR_CONFIGURED_ADAPTERS, DEFAULT_CONFIGURED_ADAPTERS) + + async def async_setup(self) -> None: + """Set up the network config.""" + await self.async_load() + self.adapters = await async_load_adapters() + + async def async_migrate_from_zeroconf(self, zc_config: dict[str, Any]) -> None: + """Migrate configuration from zeroconf.""" + if self._data or not zc_config: + return + + from homeassistant.components.zeroconf import ( # pylint: disable=import-outside-toplevel + CONF_DEFAULT_INTERFACE, + ) + + if zc_config.get(CONF_DEFAULT_INTERFACE) is False: + self._data[ATTR_CONFIGURED_ADAPTERS] = adapters_with_exernal_addresses( + self.adapters + ) + await self._async_save() + + @callback + def async_configure(self) -> None: + """Configure from storage.""" + if not enable_adapters(self.adapters, self.configured_adapters): + enable_auto_detected_adapters(self.adapters) + + async def async_reconfig(self, config: dict[str, Any]) -> None: + """Reconfigure network.""" + config = NETWORK_CONFIG_SCHEMA(config) + self._data[ATTR_CONFIGURED_ADAPTERS] = config[ATTR_CONFIGURED_ADAPTERS] + self.async_configure() + await self._async_save() + + async def async_load(self) -> None: + """Load config.""" + if stored := await self._store.async_load(): + self._data = cast(dict, stored) + + async def _async_save(self) -> None: + """Save preferences.""" + await self._store.async_save(self._data) diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py new file mode 100644 index 00000000000..eece4b38548 --- /dev/null +++ b/homeassistant/components/network/util.py @@ -0,0 +1,158 @@ +"""Network helper class for the network integration.""" +from __future__ import annotations + +from ipaddress import IPv4Address, IPv6Address, ip_address +import logging +import socket +from typing import cast + +import ifaddr + +from homeassistant.core import callback + +from .const import MDNS_TARGET_IP +from .models import Adapter, IPv4ConfiguredAddress, IPv6ConfiguredAddress + +_LOGGER = logging.getLogger(__name__) + + +async def async_load_adapters() -> list[Adapter]: + """Load adapters.""" + source_ip = async_get_source_ip(MDNS_TARGET_IP) + source_ip_address = ip_address(source_ip) if source_ip else None + + ha_adapters: list[Adapter] = [ + _ifaddr_adapter_to_ha(adapter, source_ip_address) + for adapter in ifaddr.get_adapters() + ] + + if not any(adapter["default"] and adapter["auto"] for adapter in ha_adapters): + for adapter in ha_adapters: + if _adapter_has_external_address(adapter): + adapter["auto"] = True + + return ha_adapters + + +def enable_adapters(adapters: list[Adapter], enabled_interfaces: list[str]) -> bool: + """Enable configured adapters.""" + _reset_enabled_adapters(adapters) + + if not enabled_interfaces: + return False + + found_adapter = False + for adapter in adapters: + if adapter["name"] in enabled_interfaces: + adapter["enabled"] = True + found_adapter = True + + return found_adapter + + +def enable_auto_detected_adapters(adapters: list[Adapter]) -> None: + """Enable auto detected adapters.""" + enable_adapters( + adapters, [adapter["name"] for adapter in adapters if adapter["auto"]] + ) + + +def adapters_with_exernal_addresses(adapters: list[Adapter]) -> list[str]: + """Enable all interfaces with an external address.""" + return [ + adapter["name"] + for adapter in adapters + if _adapter_has_external_address(adapter) + ] + + +def _adapter_has_external_address(adapter: Adapter) -> bool: + """Adapter has a non-loopback and non-link-local address.""" + return any( + _has_external_address(v4_config["address"]) for v4_config in adapter["ipv4"] + ) or any( + _has_external_address(v6_config["address"]) for v6_config in adapter["ipv6"] + ) + + +def _has_external_address(ip_str: str) -> bool: + return _ip_address_is_external(ip_address(ip_str)) + + +def _ip_address_is_external(ip_addr: IPv4Address | IPv6Address) -> bool: + return ( + not ip_addr.is_multicast + and not ip_addr.is_loopback + and not ip_addr.is_link_local + ) + + +def _reset_enabled_adapters(adapters: list[Adapter]) -> None: + for adapter in adapters: + adapter["enabled"] = False + + +def _ifaddr_adapter_to_ha( + adapter: ifaddr.Adapter, next_hop_address: None | IPv4Address | IPv6Address +) -> Adapter: + """Convert an ifaddr adapter to ha.""" + ip_v4s: list[IPv4ConfiguredAddress] = [] + ip_v6s: list[IPv6ConfiguredAddress] = [] + default = False + auto = False + + for ip_config in adapter.ips: + if ip_config.is_IPv6: + ip_addr = ip_address(ip_config.ip[0]) + ip_v6s.append(_ip_v6_from_adapter(ip_config)) + else: + ip_addr = ip_address(ip_config.ip) + ip_v4s.append(_ip_v4_from_adapter(ip_config)) + + if ip_addr == next_hop_address: + default = True + if _ip_address_is_external(ip_addr): + auto = True + + return { + "name": adapter.nice_name, + "enabled": False, + "auto": auto, + "default": default, + "ipv4": ip_v4s, + "ipv6": ip_v6s, + } + + +def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: + return { + "address": ip_config.ip[0], + "flowinfo": ip_config.ip[1], + "scope_id": ip_config.ip[2], + "network_prefix": ip_config.network_prefix, + } + + +def _ip_v4_from_adapter(ip_config: ifaddr.IP) -> IPv4ConfiguredAddress: + return { + "address": ip_config.ip, + "network_prefix": ip_config.network_prefix, + } + + +@callback +def async_get_source_ip(target_ip: str) -> str | None: + """Return the source ip that will reach target_ip.""" + test_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + test_sock.setblocking(False) # must be non-blocking for async + try: + test_sock.connect((target_ip, 1)) + return cast(str, test_sock.getsockname()[0]) + except Exception: # pylint: disable=broad-except + _LOGGER.debug( + "The system could not auto detect the source ip for %s on your operating system", + target_ip, + ) + return None + finally: + test_sock.close() diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index dba0c0f6aa8..64d631e2850 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -2,16 +2,14 @@ from __future__ import annotations import asyncio -from collections.abc import Coroutine, Iterable +from collections.abc import Coroutine from contextlib import suppress import fnmatch import ipaddress -from ipaddress import ip_address import logging import socket from typing import Any, TypedDict, cast -from pyroute2 import IPRoute import voluptuous as vol from zeroconf import ( InterfaceChoice, @@ -23,6 +21,8 @@ from zeroconf import ( ) from homeassistant import config_entries, util +from homeassistant.components import network +from homeassistant.components.network.models import Adapter from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, @@ -34,7 +34,6 @@ from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.loader import async_get_homekit, async_get_zeroconf, bind_hass -from homeassistant.util.network import is_loopback from .models import HaAsyncZeroconf, HaServiceBrowser, HaZeroconf from .usage import install_multiple_zeroconf_catcher @@ -69,11 +68,14 @@ MAX_NAME_LEN = 63 CONFIG_SCHEMA = vol.Schema( { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean, - vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean, - } + DOMAIN: vol.All( + cv.deprecated(CONF_DEFAULT_INTERFACE), + vol.Schema( + { + vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean, + vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): cv.boolean, + } + ), ) }, extra=vol.ALLOW_EXTRA, @@ -132,49 +134,11 @@ async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaAsyncZero return aio_zc -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 +def _async_use_default_interface(adapters: list[Adapter]) -> bool: + for adapter in adapters: + if adapter["enabled"] and not adapter["default"]: + return False + return True async def async_setup(hass: HomeAssistant, config: dict) -> bool: @@ -182,10 +146,18 @@ async def async_setup(hass: HomeAssistant, config: dict) -> bool: zc_config = config.get(DOMAIN, {}) zc_args: dict = {} - if CONF_DEFAULT_INTERFACE not in zc_config: - zc_args["interfaces"] = await async_detect_interfaces_setting(hass) - elif zc_config[CONF_DEFAULT_INTERFACE]: + adapters = await network.async_get_adapters(hass) + if _async_use_default_interface(adapters): zc_args["interfaces"] = InterfaceChoice.Default + else: + interfaces = zc_args["interfaces"] = [] + for adapter in adapters: + if not adapter["enabled"]: + continue + if ipv4s := adapter["ipv4"]: + interfaces.append(ipv4s[0]["address"]) + elif ipv6s := adapter["ipv6"]: + interfaces.append(ipv6s[0]["scope_id"]) 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 3abd8824eba..030a970d77d 100644 --- a/homeassistant/components/zeroconf/manifest.json +++ b/homeassistant/components/zeroconf/manifest.json @@ -2,8 +2,8 @@ "domain": "zeroconf", "name": "Zero-configuration networking (zeroconf)", "documentation": "https://www.home-assistant.io/integrations/zeroconf", - "requirements": ["zeroconf==0.31.0","pyroute2==0.5.18"], - "dependencies": ["api"], + "requirements": ["zeroconf==0.31.0"], + "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", "iot_class": "local_push" diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e3533632af8..66e1ab9315c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,12 +19,12 @@ emoji==1.2.0 hass-nabucasa==0.43.0 home-assistant-frontend==20210518.0 httpx==0.18.0 +ifaddr==0.1.7 jinja2>=3.0.1 netdisco==2.8.3 paho-mqtt==1.5.1 pillow==8.1.2 pip>=8.0.3,<20.3 -pyroute2==0.5.18 python-slugify==4.0.1 pyyaml==5.4.1 requests==2.25.1 diff --git a/mypy.ini b/mypy.ini index 39b32b29994..c65f28336ff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -506,6 +506,17 @@ no_implicit_optional = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.network.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +no_implicit_optional = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.notify.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 6ecfed46076..72fe862ee52 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -818,6 +818,9 @@ ibmiotf==0.3.4 # homeassistant.components.ping icmplib==2.1.1 +# homeassistant.components.network +ifaddr==0.1.7 + # homeassistant.components.iglo iglo==1.2.7 @@ -1690,9 +1693,6 @@ pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.3 -# 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 8ebcfc3588b..6758315a32a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -462,6 +462,9 @@ iaqualink==0.3.4 # homeassistant.components.ping icmplib==2.1.1 +# homeassistant.components.network +ifaddr==0.1.7 + # homeassistant.components.influxdb influxdb-client==1.14.0 @@ -947,9 +950,6 @@ pyrisco==0.3.1 # homeassistant.components.rituals_perfume_genie pyrituals==0.0.3 -# homeassistant.components.zeroconf -pyroute2==0.5.18 - # homeassistant.components.ruckus_unleashed pyruckus==0.12 diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index cb7458af154..1df09d6f0d5 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -136,6 +136,8 @@ IGNORE_VIOLATIONS = { ("demo", "openalpr_local"), # Migration wizard from zwave to ozw. "ozw", + # Migration of settings from zeroconf to network + ("network", "zeroconf"), # This should become a helper method that integrations can submit data to ("websocket_api", "lovelace"), ("websocket_api", "shopping_list"), diff --git a/tests/components/network/__init__.py b/tests/components/network/__init__.py new file mode 100644 index 00000000000..f3ccacbd064 --- /dev/null +++ b/tests/components/network/__init__.py @@ -0,0 +1 @@ +"""Tests for the Network Configuration integration.""" diff --git a/tests/components/network/test_init.py b/tests/components/network/test_init.py new file mode 100644 index 00000000000..41d87d5a805 --- /dev/null +++ b/tests/components/network/test_init.py @@ -0,0 +1,446 @@ +"""Test the Network Configuration.""" +from unittest.mock import Mock, patch + +import ifaddr + +from homeassistant.components import network +from homeassistant.components.network.const import ( + ATTR_ADAPTERS, + ATTR_CONFIGURED_ADAPTERS, + STORAGE_KEY, + STORAGE_VERSION, +) +from homeassistant.setup import async_setup_component + +_NO_LOOPBACK_IPADDR = "192.168.1.5" +_LOOPBACK_IPADDR = "127.0.0.1" + + +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_eth0 = Mock(spec=ifaddr.Adapter) + mock_eth0.nice_name = "eth0" + mock_eth0.ips = [ifaddr.IP(("2001:db8::", 1, 1), 8, "eth0")] + mock_eth1 = Mock(spec=ifaddr.Adapter) + mock_eth1.nice_name = "eth1" + mock_eth1.ips = [ifaddr.IP("192.168.1.5", 23, "eth1")] + mock_vtun0 = Mock(spec=ifaddr.Adapter) + mock_vtun0.nice_name = "vtun0" + mock_vtun0.ips = [ifaddr.IP("169.254.3.2", 16, "vtun0")] + return [mock_eth0, mock_lo0, mock_eth1, mock_vtun0] + + +async def test_async_detect_interfaces_setting_non_loopback_route(hass, hass_storage): + """Test without default interface config and the route returns a non-loopback address.""" + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[_NO_LOOPBACK_IPADDR], + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == [] + + assert network_obj.adapters == [ + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + +async def test_async_detect_interfaces_setting_loopback_route(hass, hass_storage): + """Test without default interface config and the route returns a loopback address.""" + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[_LOOPBACK_IPADDR], + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == [] + assert network_obj.adapters == [ + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": True, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + +async def test_async_detect_interfaces_setting_empty_route(hass, hass_storage): + """Test without default interface config and the route returns nothing.""" + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[], + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == [] + assert network_obj.adapters == [ + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + +async def test_async_detect_interfaces_setting_exception(hass, hass_storage): + """Test without default interface config and the route throws an exception.""" + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + side_effect=AttributeError, + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == [] + assert network_obj.adapters == [ + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + +async def test_interfaces_configured_from_storage(hass, hass_storage): + """Test settings from storage are preferred over auto configure.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, + } + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[_NO_LOOPBACK_IPADDR], + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] + + assert network_obj.adapters == [ + { + "auto": False, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": True, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + +async def test_interfaces_configured_from_storage_websocket_update( + hass, hass_ws_client, hass_storage +): + """Test settings from storage can be updated via websocket api.""" + hass_storage[STORAGE_KEY] = { + "version": STORAGE_VERSION, + "key": STORAGE_KEY, + "data": {ATTR_CONFIGURED_ADAPTERS: ["eth0", "eth1", "vtun0"]}, + } + with patch( + "homeassistant.components.network.util.socket.socket.getsockname", + return_value=[_NO_LOOPBACK_IPADDR], + ), patch( + "homeassistant.components.network.util.ifaddr.get_adapters", + return_value=_generate_mock_adapters(), + ): + assert await async_setup_component(hass, network.DOMAIN, {network.DOMAIN: {}}) + await hass.async_block_till_done() + + network_obj = hass.data[network.DOMAIN] + assert network_obj.configured_adapters == ["eth0", "eth1", "vtun0"] + ws_client = await hass_ws_client(hass) + await ws_client.send_json({"id": 1, "type": "network"}) + + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"][ATTR_CONFIGURED_ADAPTERS] == ["eth0", "eth1", "vtun0"] + assert response["result"][ATTR_ADAPTERS] == [ + { + "auto": False, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": True, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] + + await ws_client.send_json( + {"id": 2, "type": "network/configure", "config": {ATTR_CONFIGURED_ADAPTERS: []}} + ) + response = await ws_client.receive_json() + assert response["result"][ATTR_CONFIGURED_ADAPTERS] == [] + + await ws_client.send_json({"id": 3, "type": "network"}) + response = await ws_client.receive_json() + assert response["result"][ATTR_CONFIGURED_ADAPTERS] == [] + assert response["result"][ATTR_ADAPTERS] == [ + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "127.0.0.1", "network_prefix": 8}], + "ipv6": [], + "name": "lo0", + }, + { + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, + ] diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 2f66404f27b..ef0ab1fda60 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,5 +1,5 @@ """Test Zeroconf component setup process.""" -from unittest.mock import patch +from unittest.mock import call, patch from zeroconf import InterfaceChoice, IPVersion, ServiceInfo, ServiceStateChange @@ -697,13 +697,27 @@ async def test_removed_ignored(hass, mock_zeroconf): assert mock_service_info.mock_calls[1][1][0] == "_service.updated.local." -async def test_async_detect_interfaces_setting_non_loopback_route(hass, mock_zeroconf): +_ADAPTER_WITH_DEFAULT_ENABLED = [ + { + "auto": True, + "default": True, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + } +] + + +async def test_async_detect_interfaces_setting_non_loopback_route(hass): """Test without default interface config and the route returns a non-loopback address.""" - with patch.object(hass.config_entries.flow, "async_init"), patch.object( + with patch( + "homeassistant.components.zeroconf.models.HaZeroconf" + ) as mock_zc, 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, + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTER_WITH_DEFAULT_ENABLED, ), patch( "homeassistant.components.zeroconf.ServiceInfo", side_effect=get_service_info_mock, @@ -712,47 +726,53 @@ async def test_async_detect_interfaces_setting_non_loopback_route(hass, mock_zer hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.Default) + assert mock_zc.mock_calls[0] == call(interfaces=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 - ), patch( - "homeassistant.components.zeroconf.ServiceInfo", - 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) +_ADAPTERS_WITH_MANUAL_CONFIG = [ + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [], + "ipv6": [ + { + "address": "2001:db8::", + "network_prefix": 8, + "flowinfo": 1, + "scope_id": 1, + } + ], + "name": "eth0", + }, + { + "auto": True, + "default": False, + "enabled": True, + "ipv4": [{"address": "192.168.1.5", "network_prefix": 23}], + "ipv6": [], + "name": "eth1", + }, + { + "auto": False, + "default": False, + "enabled": False, + "ipv4": [{"address": "169.254.3.2", "network_prefix": 16}], + "ipv6": [], + "name": "vtun0", + }, +] -async def test_async_detect_interfaces_setting_empty_route(hass, mock_zeroconf): +async def test_async_detect_interfaces_setting_empty_route(hass): """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=[]), patch( - "homeassistant.components.zeroconf.ServiceInfo", - 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( + with patch( + "homeassistant.components.zeroconf.models.HaZeroconf" + ) as mock_zc, 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 + "homeassistant.components.zeroconf.network.async_get_adapters", + return_value=_ADAPTERS_WITH_MANUAL_CONFIG, ), patch( "homeassistant.components.zeroconf.ServiceInfo", side_effect=get_service_info_mock, @@ -761,4 +781,4 @@ async def test_async_detect_interfaces_setting_exception(hass, mock_zeroconf): hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - assert mock_zeroconf.called_with(interface_choice=InterfaceChoice.All) + assert mock_zc.mock_calls[0] == call(interfaces=[1, "192.168.1.5"]) diff --git a/tests/test_requirements.py b/tests/test_requirements.py index ff3f5bcab87..f68601e889e 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -361,7 +361,7 @@ async def test_discovery_requirements_ssdp(hass): ) as mock_process: await async_get_integration_with_requirements(hass, "ssdp_comp") - assert len(mock_process.mock_calls) == 3 + assert len(mock_process.mock_calls) == 4 assert mock_process.mock_calls[0][1][2] == ssdp.requirements # Ensure zeroconf is a dep for ssdp assert mock_process.mock_calls[1][1][1] == "zeroconf" @@ -386,7 +386,7 @@ async def test_discovery_requirements_zeroconf(hass, partial_manifest): ) as mock_process: await async_get_integration_with_requirements(hass, "comp") - assert len(mock_process.mock_calls) == 2 # zeroconf also depends on http + assert len(mock_process.mock_calls) == 3 # zeroconf also depends on http assert mock_process.mock_calls[0][1][2] == zeroconf.requirements