Autodetect zeroconf interface selection when not set (#49529)

This commit is contained in:
J. Nick Koston 2021-04-21 19:10:34 -10:00 committed by GitHub
parent 9003dbfdf3
commit cb4558c088
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 144 additions and 6 deletions

View File

@ -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

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)