From 5958e6a3f972aa0b01cdcce54ca1033a5e80beee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 23 Oct 2021 08:50:19 -1000 Subject: [PATCH] Ensure zeroconf uses the newest non-link local address in discovery (#58257) --- homeassistant/components/zeroconf/__init__.py | 19 ++++++-- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zeroconf/test_init.py | 45 ++++++++++++++++++- 6 files changed, 62 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 1d72c7d20e9..8b845f303cd 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -469,13 +469,15 @@ def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: if isinstance(value, bytes): properties[key] = value.decode("utf-8") - if not service.addresses: + addresses = service.addresses + + if not addresses: + return None + if (host := _first_non_link_local_or_v6_address(addresses)) is None: return None - address = service.addresses[0] - return { - "host": str(ip_address(address)), + "host": str(host), "port": service.port, "hostname": service.server, "type": service.type, @@ -484,6 +486,15 @@ def info_from_service(service: AsyncServiceInfo) -> HaServiceInfo | None: } +def _first_non_link_local_or_v6_address(addresses: list[bytes]) -> str | None: + """Return the first ipv6 or non-link local ipv4 address.""" + for address in addresses: + ip_addr = ip_address(address) + if not ip_addr.is_link_local or ip_addr.version == 6: + return str(ip_addr) + return None + + def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index e89697f2131..9870258027b 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.36.8"], + "requirements": ["zeroconf==0.36.9"], "dependencies": ["network", "api"], "codeowners": ["@bdraco"], "quality_scale": "internal", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 63472852399..b4fd1d08e40 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -32,7 +32,7 @@ sqlalchemy==1.4.23 voluptuous-serialize==2.4.0 voluptuous==0.12.2 yarl==1.6.3 -zeroconf==0.36.8 +zeroconf==0.36.9 pycryptodome>=3.6.6 diff --git a/requirements_all.txt b/requirements_all.txt index 74435306616..b2a614b682b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2465,7 +2465,7 @@ youtube_dl==2021.06.06 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.36.8 +zeroconf==0.36.9 # homeassistant.components.zha zha-quirks==0.0.62 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bc6da885d6..ef9d5eb9d6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1427,7 +1427,7 @@ yeelight==0.7.8 youless-api==0.14 # homeassistant.components.zeroconf -zeroconf==0.36.8 +zeroconf==0.36.9 # homeassistant.components.zha zha-quirks==0.0.62 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index 0d3c5fc7792..edf29a32f69 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -1,5 +1,6 @@ """Test Zeroconf component setup process.""" from ipaddress import ip_address +from typing import Any from unittest.mock import call, patch from zeroconf import InterfaceChoice, IPVersion, ServiceStateChange @@ -39,7 +40,9 @@ def service_update_mock(ipv6, zeroconf, services, handlers, *, limit_service=Non handlers[0](zeroconf, service, f"_name.{service}", ServiceStateChange.Added) -def get_service_info_mock(service_type, name, *args, **kwargs): +def get_service_info_mock( + service_type: str, name: str, *args: Any, **kwargs: Any +) -> AsyncServiceInfo: """Return service info for get_service_info.""" return AsyncServiceInfo( service_type, @@ -53,7 +56,9 @@ def get_service_info_mock(service_type, name, *args, **kwargs): ) -def get_service_info_mock_without_an_address(service_type, name): +def get_service_info_mock_without_an_address( + service_type: str, name: str +) -> AsyncServiceInfo: """Return service info for get_service_info without any addresses.""" return AsyncServiceInfo( service_type, @@ -633,6 +638,42 @@ async def test_info_from_service_with_addresses(hass): assert info is None +async def test_info_from_service_with_link_local_address_first(hass): + """Test that the link local address is ignored.""" + service_type = "_test._tcp.local." + service_info = get_service_info_mock(service_type, f"test.{service_type}") + service_info.addresses = ["169.254.12.3", "192.168.66.12"] + info = zeroconf.info_from_service(service_info) + assert info["host"] == "192.168.66.12" + + +async def test_info_from_service_with_link_local_address_second(hass): + """Test that the link local address is ignored.""" + service_type = "_test._tcp.local." + service_info = get_service_info_mock(service_type, f"test.{service_type}") + service_info.addresses = ["192.168.66.12", "169.254.12.3"] + info = zeroconf.info_from_service(service_info) + assert info["host"] == "192.168.66.12" + + +async def test_info_from_service_with_link_local_address_only(hass): + """Test that the link local address is ignored.""" + service_type = "_test._tcp.local." + service_info = get_service_info_mock(service_type, f"test.{service_type}") + service_info.addresses = ["169.254.12.3"] + info = zeroconf.info_from_service(service_info) + assert info is None + + +async def test_info_from_service_prefers_ipv4(hass): + """Test that ipv4 addresses are preferred.""" + service_type = "_test._tcp.local." + service_info = get_service_info_mock(service_type, f"test.{service_type}") + service_info.addresses = ["2001:db8:3333:4444:5555:6666:7777:8888", "192.168.66.12"] + info = zeroconf.info_from_service(service_info) + assert info["host"] == "192.168.66.12" + + async def test_get_instance(hass, mock_async_zeroconf): """Test we get an instance.""" assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}})