1
0
mirror of https://github.com/esphome/esphome.git synced 2026-02-08 00:31:58 +00:00

Fix bare hostname ping fallback in dashboard (#13760)

This commit is contained in:
J. Nick Koston
2026-02-05 06:18:03 +01:00
committed by GitHub
parent 67dfa5e2bc
commit 89fc5ebc97
2 changed files with 100 additions and 3 deletions

View File

@@ -3,11 +3,16 @@ from __future__ import annotations
import asyncio import asyncio
from contextlib import suppress from contextlib import suppress
from ipaddress import ip_address from ipaddress import ip_address
import logging
from icmplib import NameLookupError, async_resolve from icmplib import NameLookupError, async_resolve
RESOLVE_TIMEOUT = 3.0 RESOLVE_TIMEOUT = 3.0
_LOGGER = logging.getLogger(__name__)
_RESOLVE_EXCEPTIONS = (TimeoutError, NameLookupError, UnicodeError)
async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception: async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception:
"""Wrap the icmplib async_resolve function.""" """Wrap the icmplib async_resolve function."""
@@ -16,7 +21,21 @@ async def _async_resolve_wrapper(hostname: str) -> list[str] | Exception:
try: try:
async with asyncio.timeout(RESOLVE_TIMEOUT): async with asyncio.timeout(RESOLVE_TIMEOUT):
return await async_resolve(hostname) return await async_resolve(hostname)
except (TimeoutError, NameLookupError, UnicodeError) as ex: except _RESOLVE_EXCEPTIONS as ex:
# If the hostname ends with .local and resolution failed,
# try the bare hostname as a fallback since mDNS may not be
# working on the system but unicast DNS might resolve it
if hostname.endswith(".local"):
bare_hostname = hostname[:-6] # Remove ".local"
try:
async with asyncio.timeout(RESOLVE_TIMEOUT):
result = await async_resolve(bare_hostname)
_LOGGER.debug(
"Bare hostname %s resolved to %s", bare_hostname, result
)
return result
except _RESOLVE_EXCEPTIONS:
_LOGGER.debug("Bare hostname %s also failed to resolve", bare_hostname)
return ex return ex

View File

@@ -3,11 +3,12 @@
from __future__ import annotations from __future__ import annotations
import time import time
from unittest.mock import patch from unittest.mock import AsyncMock, patch
from icmplib import NameLookupError
import pytest import pytest
from esphome.dashboard.dns import DNSCache from esphome.dashboard.dns import DNSCache, _async_resolve_wrapper
@pytest.fixture @pytest.fixture
@@ -119,3 +120,80 @@ def test_async_resolve_not_called(dns_cache_fixture: DNSCache) -> None:
result = dns_cache_fixture.get_cached_addresses("valid.com", now) result = dns_cache_fixture.get_cached_addresses("valid.com", now)
assert result == ["192.168.1.10"] assert result == ["192.168.1.10"]
mock_resolve.assert_not_called() mock_resolve.assert_not_called()
@pytest.mark.asyncio
async def test_async_resolve_wrapper_ip_address() -> None:
"""Test _async_resolve_wrapper returns IP address directly."""
result = await _async_resolve_wrapper("192.168.1.10")
assert result == ["192.168.1.10"]
result = await _async_resolve_wrapper("2001:db8::1")
assert result == ["2001:db8::1"]
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_success() -> None:
"""Test _async_resolve_wrapper falls back to bare hostname for .local."""
mock_resolve = AsyncMock()
# First call (device.local) fails, second call (device) succeeds
mock_resolve.side_effect = [
NameLookupError("device.local"),
["192.168.1.50"],
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
assert mock_resolve.call_count == 2
mock_resolve.assert_any_call("device.local")
mock_resolve.assert_any_call("device")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_fallback_both_fail() -> None:
"""Test _async_resolve_wrapper returns exception when both fail."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.local")
mock_resolve.side_effect = [
original_exception,
NameLookupError("device"),
]
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
# Should return the original exception, not the fallback exception
assert result is original_exception
assert mock_resolve.call_count == 2
@pytest.mark.asyncio
async def test_async_resolve_wrapper_non_local_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback for non-.local hostnames."""
mock_resolve = AsyncMock()
original_exception = NameLookupError("device.example.com")
mock_resolve.side_effect = original_exception
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.example.com")
assert result is original_exception
# Should only try the original hostname, no fallback
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.example.com")
@pytest.mark.asyncio
async def test_async_resolve_wrapper_local_success_no_fallback() -> None:
"""Test _async_resolve_wrapper doesn't fallback when .local succeeds."""
mock_resolve = AsyncMock(return_value=["192.168.1.50"])
with patch("esphome.dashboard.dns.async_resolve", mock_resolve):
result = await _async_resolve_wrapper("device.local")
assert result == ["192.168.1.50"]
# Should only try once since it succeeded
assert mock_resolve.call_count == 1
mock_resolve.assert_called_once_with("device.local")