1
0
mirror of https://github.com/esphome/esphome.git synced 2025-10-25 21:23:53 +01:00

Fix DNS resolution inconsistency between logs and OTA operations (#10595)

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
J. Nick Koston
2025-09-07 17:25:22 -05:00
committed by GitHub
parent b25506b045
commit 148fa698cc
6 changed files with 640 additions and 100 deletions

View File

@@ -1,3 +1,5 @@
from __future__ import annotations
import codecs
from contextlib import suppress
import ipaddress
@@ -11,6 +13,18 @@ from urllib.parse import urlparse
from esphome.const import __version__ as ESPHOME_VERSION
# Type aliases for socket address information
AddrInfo = tuple[
int, # family (AF_INET, AF_INET6, etc.)
int, # type (SOCK_STREAM, SOCK_DGRAM, etc.)
int, # proto (IPPROTO_TCP, etc.)
str, # canonname
tuple[str, int] | tuple[str, int, int, int], # sockaddr (IPv4 or IPv6)
]
IPv4SockAddr = tuple[str, int] # (host, port)
IPv6SockAddr = tuple[str, int, int, int] # (host, port, flowinfo, scope_id)
SockAddr = IPv4SockAddr | IPv6SockAddr
_LOGGER = logging.getLogger(__name__)
IS_MACOS = platform.system() == "Darwin"
@@ -147,32 +161,7 @@ def is_ip_address(host):
return False
def _resolve_with_zeroconf(host):
from esphome.core import EsphomeError
from esphome.zeroconf import EsphomeZeroconf
try:
zc = EsphomeZeroconf()
except Exception as err:
raise EsphomeError(
"Cannot start mDNS sockets, is this a docker container without "
"host network mode?"
) from err
try:
info = zc.resolve_host(f"{host}.")
except Exception as err:
raise EsphomeError(f"Error resolving mDNS hostname: {err}") from err
finally:
zc.close()
if info is None:
raise EsphomeError(
"Error resolving address with mDNS: Did not respond. "
"Maybe the device is offline."
)
return info
def addr_preference_(res):
def addr_preference_(res: AddrInfo) -> int:
# Trivial alternative to RFC6724 sorting. Put sane IPv6 first, then
# Legacy IP, then IPv6 link-local addresses without an actual link.
sa = res[4]
@@ -184,66 +173,70 @@ def addr_preference_(res):
return 1
def resolve_ip_address(host, port):
def resolve_ip_address(host: str | list[str], port: int) -> list[AddrInfo]:
import socket
from esphome.core import EsphomeError
# There are five cases here. The host argument could be one of:
# • a *list* of IP addresses discovered by MQTT,
# • a single IP address specified by the user,
# • a .local hostname to be resolved by mDNS,
# • a normal hostname to be resolved in DNS, or
# • A URL from which we should extract the hostname.
#
# In each of the first three cases, we end up with IP addresses in
# string form which need to be converted to a 5-tuple to be used
# for the socket connection attempt. The easiest way to construct
# those is to pass the IP address string to getaddrinfo(). Which,
# coincidentally, is how we do hostname lookups in the other cases
# too. So first build a list which contains either IP addresses or
# a single hostname, then call getaddrinfo() on each element of
# that list.
errs = []
hosts: list[str]
if isinstance(host, list):
addr_list = host
elif is_ip_address(host):
addr_list = [host]
hosts = host
else:
url = urlparse(host)
if url.scheme != "":
host = url.hostname
if not is_ip_address(host):
url = urlparse(host)
if url.scheme != "":
host = url.hostname
hosts = [host]
addr_list = []
if host.endswith(".local"):
res: list[AddrInfo] = []
if all(is_ip_address(h) for h in hosts):
# Fast path: all are IP addresses, use socket.getaddrinfo with AI_NUMERICHOST
for addr in hosts:
try:
_LOGGER.info("Resolving IP address of %s in mDNS", host)
addr_list = _resolve_with_zeroconf(host)
except EsphomeError as err:
errs.append(str(err))
res += socket.getaddrinfo(
addr, port, proto=socket.IPPROTO_TCP, flags=socket.AI_NUMERICHOST
)
except OSError:
_LOGGER.debug("Failed to parse IP address '%s'", addr)
# Sort by preference
res.sort(key=addr_preference_)
return res
# If not mDNS, or if mDNS failed, use normal DNS
if not addr_list:
addr_list = [host]
from esphome.resolver import AsyncResolver
# Now we have a list containing either IP addresses or a hostname
res = []
for addr in addr_list:
if not is_ip_address(addr):
_LOGGER.info("Resolving IP address of %s", host)
try:
r = socket.getaddrinfo(addr, port, proto=socket.IPPROTO_TCP)
except OSError as err:
errs.append(str(err))
raise EsphomeError(
f"Error resolving IP address: {', '.join(errs)}"
) from err
resolver = AsyncResolver(hosts, port)
addr_infos = resolver.resolve()
# Convert aioesphomeapi AddrInfo to our format
for addr_info in addr_infos:
sockaddr = addr_info.sockaddr
if addr_info.family == socket.AF_INET6:
# IPv6
sockaddr_tuple = (
sockaddr.address,
sockaddr.port,
sockaddr.flowinfo,
sockaddr.scope_id,
)
else:
# IPv4
sockaddr_tuple = (sockaddr.address, sockaddr.port)
res = res + r
res.append(
(
addr_info.family,
addr_info.type,
addr_info.proto,
"", # canonname
sockaddr_tuple,
)
)
# Zeroconf tends to give us link-local IPv6 addresses without specifying
# the link. Put those last in the list to be attempted.
# Sort by preference
res.sort(key=addr_preference_)
return res
@@ -262,15 +255,7 @@ def sort_ip_addresses(address_list: list[str]) -> list[str]:
# First "resolve" all the IP addresses to getaddrinfo() tuples of the form
# (family, type, proto, canonname, sockaddr)
res: list[
tuple[
int,
int,
int,
str | None,
tuple[str, int] | tuple[str, int, int, int],
]
] = []
res: list[AddrInfo] = []
for addr in address_list:
# This should always work as these are supposed to be IP addresses
try: