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 import fnmatch
from functools import partial from functools import partial
import ipaddress import ipaddress
from ipaddress import ip_address
import logging import logging
import socket import socket
from typing import Any, TypedDict from typing import Any, Iterable, TypedDict, cast
from pyroute2 import IPRoute
import voluptuous as vol import voluptuous as vol
from zeroconf import ( from zeroconf import (
Error as ZeroconfError, 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.network import NoURLAvailableError, get_url
from homeassistant.helpers.singleton import singleton from homeassistant.helpers.singleton import singleton
from homeassistant.loader import async_get_homekit, async_get_zeroconf from homeassistant.loader import async_get_homekit, async_get_zeroconf
from homeassistant.util.network import is_loopback
from .models import HaServiceBrowser, HaZeroconf from .models import HaServiceBrowser, HaZeroconf
from .usage import install_multiple_zeroconf_catcher from .usage import install_multiple_zeroconf_catcher
@ -55,6 +58,8 @@ DEFAULT_IPV6 = True
HOMEKIT_PAIRED_STATUS_FLAG = "sf" HOMEKIT_PAIRED_STATUS_FLAG = "sf"
HOMEKIT_MODEL = "md" HOMEKIT_MODEL = "md"
MDNS_TARGET_IP = "224.0.0.251"
# Property key=value has a max length of 255 # Property key=value has a max length of 255
# so we use 230 to leave space for key= # so we use 230 to leave space for key=
MAX_PROPERTY_VALUE_LEN = 230 MAX_PROPERTY_VALUE_LEN = 230
@ -66,9 +71,7 @@ CONFIG_SCHEMA = vol.Schema(
{ {
DOMAIN: vol.Schema( DOMAIN: vol.Schema(
{ {
vol.Optional( vol.Optional(CONF_DEFAULT_INTERFACE): cv.boolean,
CONF_DEFAULT_INTERFACE, default=DEFAULT_DEFAULT_INTERFACE
): cv.boolean,
vol.Optional(CONF_IPV6, default=DEFAULT_IPV6): 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 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: async def async_setup(hass: HomeAssistant, config: dict) -> bool:
"""Set up Zeroconf and make Home Assistant discoverable.""" """Set up Zeroconf and make Home Assistant discoverable."""
zc_config = config.get(DOMAIN, {}) zc_config = config.get(DOMAIN, {})
zc_args: dict = {} 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 zc_args["interfaces"] = InterfaceChoice.Default
if not zc_config.get(CONF_IPV6, DEFAULT_IPV6): if not zc_config.get(CONF_IPV6, DEFAULT_IPV6):
zc_args["ip_version"] = IPVersion.V4Only zc_args["ip_version"] = IPVersion.V4Only

View File

@ -2,7 +2,7 @@
"domain": "zeroconf", "domain": "zeroconf",
"name": "Zero-configuration networking (zeroconf)", "name": "Zero-configuration networking (zeroconf)",
"documentation": "https://www.home-assistant.io/integrations/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"], "dependencies": ["api"],
"codeowners": ["@bdraco"], "codeowners": ["@bdraco"],
"quality_scale": "internal", "quality_scale": "internal",

View File

@ -23,6 +23,7 @@ netdisco==2.8.2
paho-mqtt==1.5.1 paho-mqtt==1.5.1
pillow==8.1.2 pillow==8.1.2
pip>=8.0.3,<20.3 pip>=8.0.3,<20.3
pyroute2==0.5.18
python-slugify==4.0.1 python-slugify==4.0.1
pytz>=2021.1 pytz>=2021.1
pyyaml==5.4.1 pyyaml==5.4.1

View File

@ -1672,6 +1672,9 @@ pyrisco==0.3.1
# homeassistant.components.rituals_perfume_genie # homeassistant.components.rituals_perfume_genie
pyrituals==0.0.2 pyrituals==0.0.2
# homeassistant.components.zeroconf
pyroute2==0.5.18
# homeassistant.components.ruckus_unleashed # homeassistant.components.ruckus_unleashed
pyruckus==0.12 pyruckus==0.12

View File

@ -920,6 +920,9 @@ pyrisco==0.3.1
# homeassistant.components.rituals_perfume_genie # homeassistant.components.rituals_perfume_genie
pyrituals==0.0.2 pyrituals==0.0.2
# homeassistant.components.zeroconf
pyroute2==0.5.18
# homeassistant.components.ruckus_unleashed # homeassistant.components.ruckus_unleashed
pyruckus==0.12 pyruckus==0.12

View File

@ -31,6 +31,27 @@ PROPERTIES = {
HOMEKIT_STATUS_UNPAIRED = b"1" HOMEKIT_STATUS_UNPAIRED = b"1"
HOMEKIT_STATUS_PAIRED = b"0" 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): def service_update_mock(zeroconf, services, handlers, *, limit_service=None):
"""Call service update handler.""" """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 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[0][1][0] == "_service.added"
assert mock_zeroconf.get_service_info.mock_calls[1][1][0] == "_service.updated" 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)