diff --git a/.coveragerc b/.coveragerc index ed658f3ca550f..68d7629b6c5a2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -7,6 +7,7 @@ source = homeassistant omit = homeassistant/__main__.py homeassistant/helpers/signal.py + homeassistant/helpers/backports/* homeassistant/scripts/__init__.py homeassistant/scripts/check_config.py homeassistant/scripts/ensure_config.py diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index 15437b001830a..6278586f469cc 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -22,6 +22,7 @@ from homeassistant.util import ssl as ssl_util from homeassistant.util.json import json_loads +from .backports.aiohttp_resolver import AsyncResolver from .frame import warn_use from .json import json_dumps @@ -310,6 +311,7 @@ def _async_get_connector( ssl=ssl_context, limit=MAXIMUM_CONNECTIONS, limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST, + resolver=AsyncResolver(), ) connectors[connector_key] = connector diff --git a/homeassistant/helpers/backports/__init__.py b/homeassistant/helpers/backports/__init__.py new file mode 100644 index 0000000000000..e672fe1d3d2e9 --- /dev/null +++ b/homeassistant/helpers/backports/__init__.py @@ -0,0 +1 @@ +"""Backports for helpers.""" diff --git a/homeassistant/helpers/backports/aiohttp_resolver.py b/homeassistant/helpers/backports/aiohttp_resolver.py new file mode 100644 index 0000000000000..efa4ba4bb8593 --- /dev/null +++ b/homeassistant/helpers/backports/aiohttp_resolver.py @@ -0,0 +1,116 @@ +"""Backport of aiohttp's AsyncResolver for Home Assistant. + +This is a backport of the AsyncResolver class from aiohttp 3.10. + +Before aiohttp 3.10, on system with IPv6 support, AsyncResolver would not fallback +to providing A records when AAAA records were not available. + +Additionally, unlike the ThreadedResolver, AsyncResolver +did not handle link-local addresses correctly. +""" + +from __future__ import annotations + +import asyncio +import socket +import sys +from typing import Any, TypedDict + +import aiodns +from aiohttp.abc import AbstractResolver + +# This is a backport of https://github.com/aio-libs/aiohttp/pull/8270 +# This can be removed once aiohttp 3.10 is the minimum supported version. + +_NUMERIC_SOCKET_FLAGS = socket.AI_NUMERICHOST | socket.AI_NUMERICSERV +_SUPPORTS_SCOPE_ID = sys.version_info >= (3, 9, 0) + + +class ResolveResult(TypedDict): + """Resolve result. + + This is the result returned from an AbstractResolver's + resolve method. + + :param hostname: The hostname that was provided. + :param host: The IP address that was resolved. + :param port: The port that was resolved. + :param family: The address family that was resolved. + :param proto: The protocol that was resolved. + :param flags: The flags that were resolved. + """ + + hostname: str + host: str + port: int + family: int + proto: int + flags: int + + +class AsyncResolver(AbstractResolver): + """Use the `aiodns` package to make asynchronous DNS lookups.""" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the resolver.""" + if aiodns is None: + raise RuntimeError("Resolver requires aiodns library") + + self._loop = asyncio.get_running_loop() + self._resolver = aiodns.DNSResolver(*args, loop=self._loop, **kwargs) # type: ignore[misc] + + async def resolve( # type: ignore[override] + self, host: str, port: int = 0, family: int = socket.AF_INET + ) -> list[ResolveResult]: + """Resolve a host name to an IP address.""" + try: + resp = await self._resolver.getaddrinfo( + host, + port=port, + type=socket.SOCK_STREAM, + family=family, # type: ignore[arg-type] + flags=socket.AI_ADDRCONFIG, + ) + except aiodns.error.DNSError as exc: + msg = exc.args[1] if len(exc.args) >= 1 else "DNS lookup failed" + raise OSError(msg) from exc + hosts: list[ResolveResult] = [] + for node in resp.nodes: + address: tuple[bytes, int] | tuple[bytes, int, int, int] = node.addr + family = node.family + if family == socket.AF_INET6: + if len(address) > 3 and address[3] and _SUPPORTS_SCOPE_ID: + # This is essential for link-local IPv6 addresses. + # LL IPv6 is a VERY rare case. Strictly speaking, we should use + # getnameinfo() unconditionally, but performance makes sense. + result = await self._resolver.getnameinfo( + (address[0].decode("ascii"), *address[1:]), + _NUMERIC_SOCKET_FLAGS, + ) + resolved_host = result.node + else: + resolved_host = address[0].decode("ascii") + port = address[1] + else: # IPv4 + assert family == socket.AF_INET + resolved_host = address[0].decode("ascii") + port = address[1] + hosts.append( + ResolveResult( + hostname=host, + host=resolved_host, + port=port, + family=family, + proto=0, + flags=_NUMERIC_SOCKET_FLAGS, + ) + ) + + if not hosts: + raise OSError("DNS lookup failed") + + return hosts + + async def close(self) -> None: + """Close the resolver.""" + self._resolver.cancel() diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 610e781faec26..560a1329a329b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -2,6 +2,7 @@ aiodhcpwatcher==1.0.0 aiodiscover==2.0.0 +aiodns==3.2.0 aiohttp-fast-url-dispatcher==0.3.0 aiohttp-zlib-ng==0.3.1 aiohttp==3.9.3 diff --git a/pyproject.toml b/pyproject.toml index 0bcb34617298b..e8558524b08d7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ classifiers = [ ] requires-python = ">=3.12.0" dependencies = [ + "aiodns==3.2.0", "aiohttp==3.9.3", "aiohttp_cors==0.7.0", "aiohttp-fast-url-dispatcher==0.3.0", diff --git a/requirements.txt b/requirements.txt index 22bc0743a27eb..7d550bc8c6a58 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ -c homeassistant/package_constraints.txt # Home Assistant Core +aiodns==3.2.0 aiohttp==3.9.3 aiohttp_cors==0.7.0 aiohttp-fast-url-dispatcher==0.3.0