# -*- coding: utf-8 -*- """async_upnp_client.aiohttp module.""" import asyncio import logging from asyncio.events import AbstractEventLoop, AbstractServer from ipaddress import ip_address from typing import Mapping from urllib.parse import urlparse import aiohttp.web from aiohttp import ( ClientConnectionError, ClientError, ClientResponseError, ClientSession, ClientTimeout, ) from async_upnp_client.client import UpnpRequester from async_upnp_client.const import ( AddressTupleVXType, HttpRequest, HttpResponse, IPvXAddress, ) from async_upnp_client.event_handler import UpnpEventHandler, UpnpNotifyServer from async_upnp_client.exceptions import ( UpnpClientResponseError, UpnpCommunicationError, UpnpConnectionError, UpnpConnectionTimeoutError, UpnpServerOSError, ) _LOGGER = logging.getLogger(__name__) _LOGGER_TRAFFIC_UPNP = logging.getLogger("async_upnp_client.traffic.upnp") def _fixed_host_header(url: str) -> dict[str, str]: """Strip scope_id from IPv6 host, if needed.""" if "%" not in url: return {} url_parts = urlparse(url) if url_parts.hostname and "%" in url_parts.hostname: idx = url_parts.hostname.rindex("%") fixed_hostname = url_parts.hostname[:idx] if ":" in fixed_hostname: fixed_hostname = f"[{fixed_hostname}]" host = ( f"{fixed_hostname}:{url_parts.port}" if url_parts.port else fixed_hostname ) return {"Host": host} return {} class AiohttpRequester(UpnpRequester): """Standard AioHttpUpnpRequester, to be used with UpnpFactory.""" # pylint: disable=too-few-public-methods def __init__( self, timeout: int = 5, http_headers: Mapping[str, str] | None = None ) -> None: """Initialize.""" self._timeout = ClientTimeout(total=float(timeout)) self._http_headers = http_headers or {} async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" req_headers = { **_fixed_host_header(http_request.url), **self._http_headers, **(http_request.headers or {}), } log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Sending request:\n%s %s\n%s\n%s\n", http_request.method, http_request.url, "\n".join( [key + ": " + value for key, value in (req_headers or {}).items()] ), http_request.body or "", ) try: async with ClientSession() as session: async with session.request( http_request.method, http_request.url, headers=req_headers, data=http_request.body, timeout=self._timeout, ) as response: status = response.status resp_headers: Mapping = response.headers or {} resp_body = await response.read() if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Got response from %s %s:\n%s\n%s\n\n%s", http_request.method, http_request.url, status, "\n".join( [ key + ": " + value for key, value in resp_headers.items() ] ), resp_body, ) resp_body_text = await response.text() except asyncio.TimeoutError as err: raise UpnpConnectionTimeoutError(repr(err)) from err except ClientConnectionError as err: raise UpnpConnectionError(repr(err)) from err except ClientResponseError as err: raise UpnpClientResponseError( request_info=err.request_info, history=err.history, status=err.status, message=err.message, headers=err.headers, ) from err except ClientError as err: raise UpnpCommunicationError(repr(err)) from err except UnicodeDecodeError as err: raise UpnpCommunicationError(repr(err)) from err return HttpResponse(status, resp_headers, resp_body_text) class AiohttpSessionRequester(UpnpRequester): """ Standard AiohttpSessionRequester, to be used with UpnpFactory. With pluggable session. """ # pylint: disable=too-few-public-methods def __init__( self, session: ClientSession, with_sleep: bool = False, timeout: int = 5, http_headers: Mapping[str, str] | None = None, ) -> None: """Initialize.""" self._session = session self._with_sleep = with_sleep self._timeout = ClientTimeout(total=float(timeout)) self._http_headers = http_headers or {} async def async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request with a retry on ServerDisconnectedError. The HTTP/1.1 spec allows the server to disconnect at any time. We want to retry the request in this event. """ for _ in range(2): try: return await self._async_http_request(http_request) except ClientConnectionError as err: _LOGGER.debug( "%r during request %s %s; retrying", err, http_request.method, http_request.url, ) try: return await self._async_http_request(http_request) except ClientConnectionError as err: raise UpnpConnectionError(repr(err)) from err async def _async_http_request( self, http_request: HttpRequest, ) -> HttpResponse: """Do a HTTP request.""" # pylint: disable=too-many-arguments req_headers = { **_fixed_host_header(http_request.url), **self._http_headers, **(http_request.headers or {}), } log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Sending request:\n%s %s\n%s\n%s\n", http_request.method, http_request.url, "\n".join( [key + ": " + value for key, value in (req_headers or {}).items()] ), http_request.body or "", ) if self._with_sleep: await asyncio.sleep(0) try: async with self._session.request( http_request.method, http_request.url, headers=req_headers, data=http_request.body, timeout=self._timeout, ) as response: status = response.status resp_headers: Mapping = response.headers or {} resp_body = await response.read() if log_traffic: # pragma: no branch _LOGGER_TRAFFIC_UPNP.debug( "Got response from %s %s:\n%s\n%s\n\n%s", http_request.method, http_request.url, status, "\n".join( [key + ": " + value for key, value in resp_headers.items()] ), resp_body, ) resp_body_text = await response.text() except asyncio.TimeoutError as err: raise UpnpConnectionTimeoutError(repr(err)) from err except ClientConnectionError: raise except ClientResponseError as err: raise UpnpClientResponseError( request_info=err.request_info, history=err.history, status=err.status, message=err.message, headers=err.headers, ) from err except ClientError as err: raise UpnpCommunicationError(repr(err)) from err except UnicodeDecodeError as err: raise UpnpCommunicationError(repr(err)) from err return HttpResponse(status, resp_headers, resp_body_text) class AiohttpNotifyServer(UpnpNotifyServer): """ Aio HTTP Server to handle incoming events. It is advisable to use one AiohttpNotifyServer per listening IP, UpnpDevices can share a AiohttpNotifyServer/UpnpEventHandler. """ def __init__( self, requester: UpnpRequester, source: AddressTupleVXType, callback_url: str | None = None, loop: AbstractEventLoop | None = None, ) -> None: """Initialize.""" self._source = source self._callback_url = callback_url self._loop = loop or asyncio.get_event_loop() self._aiohttp_server: aiohttp.web.Server | None = None self._server: AbstractServer | None = None self.event_handler = UpnpEventHandler(self, requester) async def async_start_server(self) -> None: """Start the HTTP server.""" self._aiohttp_server = aiohttp.web.Server(self._handle_request) try: self._server = await self._loop.create_server( self._aiohttp_server, self._source[0], self._source[1] ) except OSError as err: _LOGGER.error( "Failed to create HTTP server at %s:%d: %s", self._source[0], self._source[1], err, ) raise UpnpServerOSError( errno=err.errno, strerror=err.strerror, ) from err # Get listening port. socks = self._server.sockets assert socks and len(socks) == 1 sock = socks[0] self._source = sock.getsockname() _LOGGER.debug("New source for UpnpNotifyServer: %s", self._source) async def async_stop_server(self) -> None: """Stop the HTTP server.""" await self.event_handler.async_unsubscribe_all() if self._aiohttp_server: await self._aiohttp_server.shutdown(10) self._aiohttp_server = None if self._server: self._server.close() self._server = None async def _handle_request( self, request: aiohttp.web.BaseRequest ) -> aiohttp.web.Response: """Handle incoming requests.""" _LOGGER.debug("Received request: %s", request) log_traffic = _LOGGER_TRAFFIC_UPNP.isEnabledFor(logging.DEBUG) headers = request.headers body = await request.text() if log_traffic: _LOGGER_TRAFFIC_UPNP.debug( "Incoming request:\nNOTIFY\n%s\n\n%s", "\n".join([key + ": " + value for key, value in headers.items()]), body, ) if request.method != "NOTIFY": _LOGGER.debug("Not notify") return aiohttp.web.Response(status=405) http_request = HttpRequest( request.method, self.callback_url, request.headers, body ) status = await self.event_handler.handle_notify(http_request) _LOGGER.debug("NOTIFY response status: %s", status) if log_traffic: _LOGGER_TRAFFIC_UPNP.debug("Sending response: %s", status) return aiohttp.web.Response(status=status) @property def listen_ip(self) -> IPvXAddress: """Get listening IP Address.""" return ip_address(self._source[0]) @property def listen_host(self) -> str: """Get listening host.""" return str(self.listen_ip) @property def listen_port(self) -> int: """Get the listening port.""" return self._source[1] @property def callback_url(self) -> str: """Return callback URL on which we are callable.""" listen_ip = self.listen_ip return self._callback_url or ( self._callback_url or f"http://{self.listen_host}:{self.listen_port}/notify" if listen_ip.version == 4 else f"http://[{self.listen_host}]:{self.listen_port}/notify" )