1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-24 21:06:19 +00:00

Add instance ID (mDNS) conflict detection and repair flow for zeroconf integration (#151487)

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Jan-Philipp Benecke
2025-10-19 21:37:10 +02:00
committed by GitHub
parent e79c76cd35
commit 2f5fbc1f0e
7 changed files with 506 additions and 41 deletions

View File

@@ -190,12 +190,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
homekit_model_lookup, homekit_model_matchers = build_homekit_model_lookups(
homekit_models
)
local_service_info = await _async_get_local_service_info(hass)
discovery = ZeroconfDiscovery(
hass,
zeroconf,
zeroconf_types,
homekit_model_lookup,
homekit_model_matchers,
local_service_info,
)
await discovery.async_setup()
hass.data[DATA_DISCOVERY] = discovery
@@ -206,8 +208,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
Wait till started or otherwise HTTP is not up and running.
"""
uuid = await instance_id.async_get(hass)
await _async_register_hass_zc_service(hass, aio_zc, uuid)
await _async_register_hass_zc_service(aio_zc, local_service_info)
async def _async_zeroconf_hass_stop(_event: Event) -> None:
await discovery.async_stop()
@@ -227,48 +228,12 @@ def _filter_disallowed_characters(name: str) -> str:
async def _async_register_hass_zc_service(
hass: HomeAssistant, aio_zc: HaAsyncZeroconf, uuid: str
aio_zc: HaAsyncZeroconf, local_service_info: AsyncServiceInfo
) -> None:
# Get instance UUID
valid_location_name = _truncate_location_name_to_valid(
_filter_disallowed_characters(hass.config.location_name or "Home")
)
params = {
"location_name": valid_location_name,
"uuid": uuid,
"version": __version__,
"external_url": "",
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
with suppress(NoURLAvailableError):
params["external_url"] = get_url(hass, allow_internal=False)
with suppress(NoURLAvailableError):
params["internal_url"] = get_url(hass, allow_external=False)
# Set old base URL based on external or internal
params["base_url"] = params["external_url"] or params["internal_url"]
_suppress_invalid_properties(params)
info = AsyncServiceInfo(
ZEROCONF_TYPE,
name=f"{valid_location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
parsed_addresses=await network.async_get_announce_addresses(hass),
port=hass.http.server_port,
properties=params,
)
"""Register the zeroconf service for the local Home Assistant instance."""
_LOGGER.info("Starting Zeroconf broadcast")
await aio_zc.async_register_service(info, allow_name_change=True)
await aio_zc.async_register_service(local_service_info, allow_name_change=True)
def _suppress_invalid_properties(properties: dict) -> None:
@@ -307,6 +272,47 @@ def _truncate_location_name_to_valid(location_name: str) -> str:
return location_name.encode("utf-8")[:MAX_NAME_LEN].decode("utf-8", "ignore")
async def _async_get_local_service_info(hass: HomeAssistant) -> AsyncServiceInfo:
"""Return the zeroconf service info for the local Home Assistant instance."""
valid_location_name = _truncate_location_name_to_valid(
_filter_disallowed_characters(hass.config.location_name or "Home")
)
uuid = await instance_id.async_get(hass)
params = {
"location_name": valid_location_name,
"uuid": uuid,
"version": __version__,
"external_url": "",
"internal_url": "",
# Old base URL, for backward compatibility
"base_url": "",
# Always needs authentication
"requires_api_password": True,
}
# Get instance URL's
with suppress(NoURLAvailableError):
params["external_url"] = get_url(hass, allow_internal=False)
with suppress(NoURLAvailableError):
params["internal_url"] = get_url(hass, allow_external=False)
# Set old base URL based on external or internal
params["base_url"] = params["external_url"] or params["internal_url"]
_suppress_invalid_properties(params)
return AsyncServiceInfo(
ZEROCONF_TYPE,
name=f"{valid_location_name}.{ZEROCONF_TYPE}",
server=f"{uuid}.local.",
parsed_addresses=await network.async_get_announce_addresses(hass),
port=hass.http.server_port,
properties=params,
)
# These can be removed if no deprecated constant are in this module anymore
__getattr__ = partial(check_if_deprecated_constant, module_globals=globals())
__dir__ = partial(

View File

@@ -19,6 +19,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.discovery_flow import DiscoveryKey
from homeassistant.helpers.dispatcher import async_dispatcher_connect
import homeassistant.helpers.issue_registry as ir
from homeassistant.helpers.service_info.zeroconf import (
ZeroconfServiceInfo as _ZeroconfServiceInfo,
)
@@ -49,6 +50,8 @@ ATTR_DOMAIN: Final = "domain"
ATTR_NAME: Final = "name"
ATTR_PROPERTIES: Final = "properties"
DUPLICATE_INSTANCE_ID_ISSUE_ID = "duplicate_instance_id"
DATA_DISCOVERY: HassKey[ZeroconfDiscovery] = HassKey("zeroconf_discovery")
@@ -183,6 +186,7 @@ class ZeroconfDiscovery:
zeroconf_types: dict[str, list[ZeroconfMatcher]],
homekit_model_lookups: dict[str, HomeKitDiscoveredIntegration],
homekit_model_matchers: dict[re.Pattern, HomeKitDiscoveredIntegration],
local_service_info: AsyncServiceInfo,
) -> None:
"""Init discovery."""
self.hass = hass
@@ -193,6 +197,11 @@ class ZeroconfDiscovery:
self.async_service_browser: AsyncServiceBrowser | None = None
self._service_update_listeners: set[Callable[[AsyncServiceInfo], None]] = set()
self._service_removed_listeners: set[Callable[[str], None]] = set()
self._conflicting_instances: set[str] = set()
self._local_service_info = info_from_service(local_service_info)
self._local_ips: set[IPv4Address | IPv6Address] = set()
if self._local_service_info:
self._local_ips = set(self._local_service_info.ip_addresses)
@callback
def async_register_service_update_listener(
@@ -278,6 +287,16 @@ class ZeroconfDiscovery:
)
if state_change is ServiceStateChange.Removed:
# Check if other Home Assistant instances has been removed.
# Then we can remove the duplicate instance ID issue
# as probably the conflicting instance has been shut down
if service_type == ZEROCONF_TYPE and name in self._conflicting_instances:
self._conflicting_instances.remove(name)
if len(self._conflicting_instances) == 0:
ir.async_delete_issue(
self.hass, DOMAIN, DUPLICATE_INSTANCE_ID_ISSUE_ID
)
self._async_dismiss_discoveries(name)
for listener in self._service_removed_listeners:
listener(name)
@@ -336,6 +355,13 @@ class ZeroconfDiscovery:
return
_LOGGER.debug("Discovered new device %s %s", name, info)
props: dict[str, str | None] = info.properties
# Instance ID conflict detection for Home Assistant core
if service_type == ZEROCONF_TYPE and (
discovered_instance_id := props.get("uuid")
):
self._async_check_instance_id_conflict(discovered_instance_id, info)
discovery_key = DiscoveryKey(
domain=DOMAIN,
key=(info.type, info.name),
@@ -408,3 +434,59 @@ class ZeroconfDiscovery:
info,
discovery_key=discovery_key,
)
@callback
def _async_check_instance_id_conflict(
self, discovered_instance_id: str, info: _ZeroconfServiceInfo
) -> None:
"""Check for instance ID conflicts and create repair issues if needed."""
if not self._local_service_info:
_LOGGER.debug(
"No local service info, cannot check for instance ID conflicts"
)
return
discovered_ips = set(info.ip_addresses)
is_disjoint = self._local_ips.isdisjoint(discovered_ips)
local_instance_id = self._local_service_info.properties.get("uuid")
if not is_disjoint:
# No conflict, IP addresses of service contain a local IP
# Ignore it as it's probably a mDNS reflection
return
if discovered_instance_id != local_instance_id:
# Conflict resolved, different instance IDs
# No conflict, different instance IDs
# If there was a conflict issue before, we remove it
# since the other instance may have changed its ID
if info.name in self._conflicting_instances:
self._conflicting_instances.remove(info.name)
if len(self._conflicting_instances) == 0:
ir.async_delete_issue(self.hass, DOMAIN, DUPLICATE_INSTANCE_ID_ISSUE_ID)
return
# Conflict detected, create repair issue
_joined_ips = ", ".join(str(ip_address) for ip_address in discovered_ips)
_LOGGER.warning(
"Discovered another Home Assistant instance with the same instance ID (%s) at %s",
discovered_instance_id,
_joined_ips,
)
self._conflicting_instances.add(info.name)
ir.async_create_issue(
self.hass,
DOMAIN,
DUPLICATE_INSTANCE_ID_ISSUE_ID,
is_fixable=True,
is_persistent=False,
severity=ir.IssueSeverity.ERROR,
translation_key=DUPLICATE_INSTANCE_ID_ISSUE_ID,
translation_placeholders={
"instance_id": local_instance_id,
"other_ip": _joined_ips,
"other_host_url": info.hostname.rstrip("."),
},
)

View File

@@ -0,0 +1,60 @@
"""Repairs for the zeroconf integration."""
from __future__ import annotations
from homeassistant import data_entry_flow
from homeassistant.components.homeassistant import (
DOMAIN as DOMAIN_HOMEASSISTANT,
SERVICE_HOMEASSISTANT_RESTART,
)
from homeassistant.components.repairs import RepairsFlow
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import instance_id, issue_registry as ir
class DuplicateInstanceIDRepairFlow(RepairsFlow):
"""Handler for duplicate instance ID repair."""
@callback
def _async_get_placeholders(self) -> dict[str, str]:
issue_registry = ir.async_get(self.hass)
issue = issue_registry.async_get_issue(self.handler, self.issue_id)
assert issue is not None
return issue.translation_placeholders or {}
async def async_step_init(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the initial step."""
return await self.async_step_confirm_recreate()
async def async_step_confirm_recreate(
self, user_input: dict[str, str] | None = None
) -> data_entry_flow.FlowResult:
"""Handle the confirm step."""
if user_input is not None:
await instance_id.async_recreate(self.hass)
await self.hass.services.async_call(
DOMAIN_HOMEASSISTANT, SERVICE_HOMEASSISTANT_RESTART
)
return self.async_create_entry(title="", data={})
return self.async_show_form(
step_id="confirm_recreate",
description_placeholders=self._async_get_placeholders(),
)
async def async_create_fix_flow(
hass: HomeAssistant,
issue_id: str,
data: dict[str, str | int | float | None] | None,
) -> RepairsFlow:
"""Create flow."""
if issue_id == "duplicate_instance_id":
return DuplicateInstanceIDRepairFlow()
# If Zeroconf adds confirm-only repairs in the future, this should be changed
# to return a ConfirmRepairFlow instead of raising a ValueError
raise ValueError(f"unknown repair {issue_id}")

View File

@@ -0,0 +1,14 @@
{
"issues": {
"duplicate_instance_id": {
"title": "Duplicate Home Assistant instance detected on your network",
"fix_flow": {
"step": {
"confirm_recreate": {
"description": "Another device ({other_ip}) on your network is advertising the same Home Assistant instance ID ({instance_id} reachable via {other_host_url}) as this instance. This can cause network instability and excessive traffic.\n\nTo fix this issue:\n1. Change the instance ID on **only one** of the Home Assistant instances.\n2. Once the conflict is resolved, the repair issue on the other instance will disappear automatically.\n\nAfter confirming, a new instance ID will be generated for this Home Assistant instance and the instance will restart. This will not affect your configuration or data, but it may take a few minutes for other devices on your network to recognize the change.\n\nTo proceed, click 'Submit' below."
}
}
}
}
}
}

View File

@@ -47,3 +47,14 @@ async def async_get(hass: HomeAssistant) -> str:
await store.async_save(data)
return data["uuid"]
async def async_recreate(hass: HomeAssistant) -> str:
"""Recreate a new unique ID for the hass instance."""
store = storage.Store[dict[str, str]](hass, DATA_VERSION, DATA_KEY, True)
data = {"uuid": uuid.uuid4().hex}
await store.async_save(data)
return data["uuid"]

View File

@@ -0,0 +1,280 @@
"""Tests for zeroconf repair issues."""
from unittest.mock import patch
import pytest
from zeroconf import ServiceStateChange
from zeroconf.asyncio import AsyncServiceInfo
from homeassistant.components.homeassistant import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.components.repairs import DOMAIN as REPAIRS_DOMAIN
from homeassistant.components.zeroconf import DOMAIN, discovery, repairs
from homeassistant.components.zeroconf.discovery import ZEROCONF_TYPE
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import instance_id, issue_registry as ir
from homeassistant.setup import async_setup_component
from .test_init import service_update_mock
from tests.components.repairs import (
async_process_repairs_platforms,
process_repair_fix_flow,
start_repair_fix_flow,
)
from tests.typing import ClientSessionGenerator
def service_state_change_mock(
zeroconf,
services,
handlers,
*,
state_change: ServiceStateChange = ServiceStateChange.Removed,
) -> None:
"""Call service update handler."""
for service in services:
handlers[0](zeroconf, service, f"_name.{service}", state_change)
def _get_hass_service_info_mock(
service_type: str,
name: str,
*,
instance_id="abc123",
) -> AsyncServiceInfo:
"""Return service info for Home Assistant instance."""
return AsyncServiceInfo(
ZEROCONF_TYPE,
name,
addresses=[b"\n\x00\x00\x01"],
port=8123,
weight=0,
priority=0,
server="other-host.local.",
properties={
"base_url": "http://10.0.0.1:8123",
"external_url": None,
"internal_url": "http://10.0.0.1:8123",
"location_name": "Home",
"requires_api_password": "True",
"uuid": instance_id,
"version": "2025.9.0.dev0",
},
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_instance_id_conflict_creates_repair_issue_remove(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test that a repair issue is created on instance ID conflict and gets removed when instance disappears."""
with (
patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"),
patch.object(
discovery, "AsyncServiceBrowser", side_effect=service_update_mock
) as mock_browser,
patch.object(hass.config_entries.flow, "async_init"),
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=_get_hass_service_info_mock,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
assert issue
assert issue.severity == ir.IssueSeverity.ERROR
assert issue.translation_key == "duplicate_instance_id"
assert issue.translation_placeholders == {
"other_host_url": "other-host.local",
"other_ip": "10.0.0.1",
"instance_id": "abc123",
}
# Now test that the issue is removed when the service goes away
service_state_change_mock(
mock_browser.call_args[0][0],
[ZEROCONF_TYPE],
mock_browser.call_args[1]["handlers"],
)
assert (
issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
is None
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_instance_id_conflict_creates_repair_issue_changing_id(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test that a repair issue is created on instance ID conflict and gets removed when instance ID changes."""
with (
patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"),
patch.object(
discovery, "AsyncServiceBrowser", side_effect=service_update_mock
) as mock_browser,
patch.object(hass.config_entries.flow, "async_init"),
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=_get_hass_service_info_mock,
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
assert issue
assert issue.severity == ir.IssueSeverity.ERROR
assert issue.translation_key == "duplicate_instance_id"
assert issue.translation_placeholders == {
"other_host_url": "other-host.local",
"other_ip": "10.0.0.1",
"instance_id": "abc123",
}
with (
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=lambda service_type, name: _get_hass_service_info_mock(
service_type, name, instance_id="different-id"
),
),
):
# Now test that the issue is removed when the service goes away
service_state_change_mock(
mock_browser.call_args[0][0],
[ZEROCONF_TYPE],
mock_browser.call_args[1]["handlers"],
state_change=ServiceStateChange.Updated,
)
assert (
issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
is None
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_instance_id_no_repair_issue_own_ip(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test that no repair issue is created when the other instance ID matches our IP."""
with (
patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"),
patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock),
patch.object(hass.config_entries.flow, "async_init"),
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=_get_hass_service_info_mock,
),
patch(
"homeassistant.components.network.async_get_announce_addresses",
return_value=["10.0.0.1", "10.0.0.2"],
),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert (
issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
is None
)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_instance_id_no_conflict_no_repair_issue(
hass: HomeAssistant, issue_registry: ir.IssueRegistry
) -> None:
"""Test that a repair issue is not created when no instance ID conflict exists."""
with (
patch("homeassistant.helpers.instance_id.async_get", return_value="xyz123"),
patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock),
patch.object(hass.config_entries.flow, "async_init"),
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=_get_hass_service_info_mock,
),
patch("homeassistant.helpers.issue_registry.async_create_issue"),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
assert (
issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
is None
)
async def test_create_fix_flow_raises_on_unknown_issue_id(hass: HomeAssistant) -> None:
"""Test create_fix_flow raises on unknown issue_id."""
with pytest.raises(ValueError):
await repairs.async_create_fix_flow(hass, "no_such_issue", None)
@pytest.mark.usefixtures("mock_async_zeroconf")
async def test_duplicate_repair_issue_repair_flow(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
issue_registry: ir.IssueRegistry,
) -> None:
"""Test desired flow of the fix flow for duplicate instance ID."""
assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}})
assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {})
await async_process_repairs_platforms(hass)
with (
patch("homeassistant.helpers.instance_id.async_get", return_value="abc123"),
patch.object(discovery, "AsyncServiceBrowser", side_effect=service_update_mock),
patch.object(hass.config_entries.flow, "async_init"),
patch(
"homeassistant.components.zeroconf.discovery.AsyncServiceInfo",
side_effect=_get_hass_service_info_mock,
),
patch.object(
instance_id, "async_recreate", return_value="new-uuid"
) as mock_recreate,
patch("homeassistant.config.async_check_ha_config_file", return_value=None),
patch("homeassistant.core.HomeAssistant.async_stop", return_value=None),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await hass.async_block_till_done()
issue = issue_registry.async_get_issue(
domain="zeroconf", issue_id="duplicate_instance_id"
)
assert issue is not None
client = await hass_client()
result = await start_repair_fix_flow(client, DOMAIN, issue.issue_id)
flow_id = result["flow_id"]
assert result["type"] == FlowResultType.FORM
assert result["step_id"] == "confirm_recreate"
result = await process_repair_fix_flow(client, flow_id, json={})
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert mock_recreate.called

View File

@@ -83,3 +83,15 @@ async def test_get_id_migrate_fail(
"Could not read hass instance ID from 'core.uuid' or '.uuid', a "
"new instance ID will be generated" in caplog.text
)
async def test_async_recreate(
hass: HomeAssistant, hass_storage: dict[str, Any]
) -> None:
"""Test recreating instance ID."""
uuid1 = await instance_id.async_get(hass)
uuid2 = await instance_id.async_recreate(hass)
assert uuid1 != uuid2
# Assert it's stored
assert hass_storage["core.uuid"]["data"]["uuid"] == uuid2