From fe092acee498ae443bf61e86efe6231e376f940c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 21 Jul 2026 17:23:25 +0200 Subject: [PATCH] Move http server code into it's own module (#176968) --- homeassistant/components/http/__init__.py | 543 +---------------- homeassistant/components/http/server.py | 553 ++++++++++++++++++ .../components/http/websocket_api.py | 2 +- tests/components/http/test_auth.py | 2 +- tests/components/http/test_ban.py | 4 +- tests/components/http/test_cors.py | 4 +- tests/components/http/test_init.py | 10 +- 7 files changed, 579 insertions(+), 539 deletions(-) create mode 100644 homeassistant/components/http/server.py diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 1c6c0c857ddc..215976b5fbd2 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -1,32 +1,12 @@ """Support to serve the Home Assistant API as WSGI application.""" -import asyncio -from collections.abc import Collection -from dataclasses import dataclass -import datetime -from functools import partial -from ipaddress import IPv4Network, IPv6Network, ip_network +from ipaddress import ip_network import logging import os from pathlib import Path -import socket -import ssl -from tempfile import NamedTemporaryFile -from typing import Any, Final, cast, override +from typing import Any, Final -from aiohttp import web -from aiohttp.abc import AbstractStreamWriter -from aiohttp.http_parser import RawRequestMessage -from aiohttp.streams import StreamReader -from aiohttp.typedefs import JSONDecoder, StrOrURL -from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection -from aiohttp.web_protocol import RequestHandler -from cryptography import x509 -from cryptography.hazmat.primitives import hashes, serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.x509.oid import NameOID import voluptuous as vol -from yarl import URL from homeassistant.components.network import async_get_source_ip from homeassistant.const import ( @@ -38,27 +18,22 @@ from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.hassio import is_hassio -from homeassistant.helpers.http import ( +from homeassistant.helpers.http import ( # noqa: F401 KEY_ALLOW_CONFIGURED_CORS, - KEY_AUTHENTICATED, # noqa: F401 + KEY_AUTHENTICATED, KEY_HASS, HomeAssistantView, current_request, ) from homeassistant.helpers.importlib import async_import_module -from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.typing import ConfigType from homeassistant.setup import ( SetupPhases, async_start_setup, async_when_setup_or_start, ) -from homeassistant.util import dt as dt_util, ssl as ssl_util from homeassistant.util.async_ import create_eager_task -from homeassistant.util.json import json_loads -from .auth import async_setup_auth -from .ban import setup_bans from .config import ( _DEFAULT_CONFIG, ConfData, @@ -89,25 +64,19 @@ from .const import ( # noqa: F401 SSL_INTERMEDIATE, SSL_MODERN, ) -from .cors import setup_cors from .decorators import require_admin # noqa: F401 -from .forwarded import async_setup_forwarded -from .headers import setup_headers -from .request_context import setup_request_context -from .security_filter import setup_security_filter -from .static import CACHE_HEADERS, CachingStaticResource -from .web_runner import HomeAssistantUnixSite +from .server import ( + DEFAULT_BIND, + HomeAssistantHTTP, # noqa: F401 + HomeAssistantRequest, # noqa: F401 + StaticPathConfig, # noqa: F401 + make_server, +) _LOGGER: Final = logging.getLogger(__name__) DEFAULT_DEVELOPMENT: Final = "0" -MAX_CLIENT_SIZE: Final = 1024**2 * 16 -MAX_LINE_SIZE: Final = 24570 - -_HAS_IPV6 = hasattr(socket, "AF_INET6") -_DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] - HTTP_SCHEMA: Final = vol.All( cv.deprecated(CONF_BASE_URL), vol.Schema( @@ -142,21 +111,6 @@ HTTP_SCHEMA: Final = vol.All( CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) -@dataclass(slots=True) -class StaticPathConfig: - """Configuration for a static path.""" - - url_path: str - path: str - cache_headers: bool = True - - -_STATIC_CLASSES = { - True: CachingStaticResource, - False: web.StaticResource, -} - - class ApiConfig: """Configuration settings for API server.""" @@ -231,52 +185,6 @@ async def _async_fallback_config( return _DEFAULT_CONFIG -def _make_server( - hass: HomeAssistant, - conf: ConfData, - supervisor_unix_socket_path: Path | None = None, -) -> HomeAssistantHTTP: - """Create a server instance for the given config.""" - return HomeAssistantHTTP( - hass, - server_host=conf.get(CONF_SERVER_HOST, _DEFAULT_BIND), - server_port=conf[CONF_SERVER_PORT], - ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), - ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), - ssl_key=conf.get(CONF_SSL_KEY), - # The loaded config stores trusted proxies as strings - # (JSON-serializable); the forwarded middleware needs - # IPv4Network/IPv6Network objects. - trusted_proxies=[ - ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] - ], - ssl_profile=conf[CONF_SSL_PROFILE], - supervisor_unix_socket_path=supervisor_unix_socket_path, - ) - - -async def async_verify_can_bind(hass: HomeAssistant, conf: ConfData) -> None: - """Verify a server for ``conf`` can be created and its address bound. - - Used to validate a new user-supplied config before it is stored and - applied via a restart; the sockets are released right away. Best effort: - the address can still be taken by another process before the restart, so - the setup fallback chain remains the safety net. - - Raises ``HomeAssistantError`` if the SSL configuration is unusable or the - configured address cannot be bound. - """ - server = _make_server(hass, conf) - try: - await server.async_bind() - except OSError as err: - raise HomeAssistantError( - f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" - ) from err - finally: - await server.stop() - - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the HTTP API and debug interface.""" # Late import to ensure isal is updated before @@ -308,7 +216,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: socket_env, ) - server = _make_server(hass, conf, supervisor_unix_socket_path) + server = make_server(hass, conf, supervisor_unix_socket_path) trial_reverted = False while True: try: @@ -317,7 +225,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: store = await async_get_and_load_store(hass) trial_reverted = store.revert_deadline is not None conf = await _async_fallback_config(hass, store, conf, err) - server = _make_server(hass, conf, supervisor_unix_socket_path) + server = make_server(hass, conf, supervisor_unix_socket_path) continue if trial_reverted: _LOGGER.warning( @@ -352,7 +260,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: translation_key=issue_id, ) - server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND) + server_host = conf.get(CONF_SERVER_HOST, DEFAULT_BIND) server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) @@ -425,426 +333,3 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_check_ssl_issue) return True - - -class HomeAssistantRequest(web.Request): - """Home Assistant request object.""" - - @override - async def json(self, *, loads: JSONDecoder = json_loads) -> Any: - """Return body as JSON.""" - # json_loads is a wrapper around orjson.loads that handles - # bytes and str. We can pass the bytes directly to json_loads. - return json_loads(await self.read()) - - -class HomeAssistantApplication(web.Application): - """Home Assistant application.""" - - @override - def _make_request( - self, - message: RawRequestMessage, - payload: StreamReader, - protocol: RequestHandler, - writer: AbstractStreamWriter, - task: asyncio.Task[None], - _cls: type[web.Request] = HomeAssistantRequest, - ) -> web.Request: - """Create request instance.""" - return _cls( - message, - payload, - protocol, - writer, - task, - # loop will never be None when called from aiohttp - loop=self._loop, # type: ignore[arg-type] - client_max_size=self._client_max_size, - ) - - -async def _serve_file_with_cache_headers( - path: str, request: web.Request -) -> web.FileResponse: - return web.FileResponse(path, headers=CACHE_HEADERS) - - -async def _serve_file(path: str, request: web.Request) -> web.FileResponse: - return web.FileResponse(path) - - -class HomeAssistantHTTP: - """HTTP server for Home Assistant.""" - - def __init__( - self, - hass: HomeAssistant, - ssl_certificate: str | None, - ssl_peer_certificate: str | None, - ssl_key: str | None, - server_host: list[str] | None, - server_port: int, - trusted_proxies: list[IPv4Network | IPv6Network], - ssl_profile: str, - supervisor_unix_socket_path: Path | None = None, - ) -> None: - """Initialize the HTTP Home Assistant server.""" - self.app = HomeAssistantApplication( - middlewares=[], - client_max_size=MAX_CLIENT_SIZE, - handler_args={ - "max_line_size": MAX_LINE_SIZE, - "max_field_size": MAX_LINE_SIZE, - }, - ) - self.hass = hass - self.ssl_certificate = ssl_certificate - self.ssl_peer_certificate = ssl_peer_certificate - self.ssl_key = ssl_key - self.server_host = server_host - self.server_port = server_port - self.trusted_proxies = trusted_proxies - self.ssl_profile = ssl_profile - self.supervisor_unix_socket_path = supervisor_unix_socket_path - self.runner: web.AppRunner | None = None - self.supervisor_site: HomeAssistantUnixSite | None = None - self.context: ssl.SSLContext | None = None - self._server: asyncio.Server | None = None - - async def async_bind(self) -> None: - """Create the SSL context and the server, binding its sockets. - - Called during setup so that an unusable configuration surfaces before - it is applied; serving starts later in ``start()``. Raises - ``HomeAssistantError`` if the SSL configuration is unusable and - ``OSError`` if the configured address cannot be bound. - """ - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) - self._server = await self._async_create_server() - - async def _async_create_server(self) -> asyncio.Server: - """Create the (not yet serving) HTTP server, binding its sockets.""" - try: - return await self.hass.loop.create_server( - self._make_protocol, - self.server_host if self.server_host is not None else _DEFAULT_BIND, - self.server_port, - ssl=self.context, - backlog=128, - start_serving=False, - ) - except UnicodeError as err: - # create_server() raises UnicodeError for hosts the IDNA codec - # cannot encode (e.g. a label longer than 63 characters); - # normalize to OSError so callers only need to handle one error - # type. - raise OSError(f"error while resolving host: {err}") from err - - def _make_protocol(self) -> RequestHandler: - """Create a protocol instance for an accepted connection. - - Connections are only accepted once ``start()`` has run, so the - runner is set up by the time this is called. - """ - runner = self.runner - assert runner is not None and runner.server is not None - return runner.server() - - async def async_initialize( - self, - *, - cors_origins: list[str], - use_x_forwarded_for: bool, - login_threshold: int, - is_ban_enabled: bool, - use_x_frame_options: bool, - ) -> None: - """Initialize the server.""" - self.app[KEY_HASS] = self.hass - self.app["hass"] = self.hass # For backwards compatibility - - # Order matters, security filters middleware needs to go first, - # forwarded middleware needs to go second. - setup_security_filter(self.app) - - async_setup_forwarded(self.app, use_x_forwarded_for, self.trusted_proxies) - - setup_request_context(self.app, current_request) - - if is_ban_enabled: - setup_bans(self.hass, self.app, login_threshold) - - await async_setup_auth(self.hass, self.app) - - setup_headers(self.app, use_x_frame_options) - setup_cors(self.app, cors_origins) - - def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: - """Register a view with the WSGI server. - - The view argument must be a class that inherits from HomeAssistantView. - It is optional to instantiate it before registering; this method will - handle it either way. - """ - if isinstance(view, type): - # Instantiate the view, if needed - view = view() - - if not hasattr(view, "url"): - class_name = view.__class__.__name__ - raise AttributeError(f'{class_name} missing required attribute "url"') - - if not hasattr(view, "name"): - class_name = view.__class__.__name__ - raise AttributeError(f'{class_name} missing required attribute "name"') - - view.register(self.hass, self.app, self.app.router) - - def register_redirect( - self, - url: str, - redirect_to: StrOrURL, - *, - redirect_exc: type[HTTPRedirection] = HTTPMovedPermanently, - ) -> None: - """Register a redirect with the server. - - If given this must be either a string or callable. In case of a - callable it's called with the url adapter that triggered the match and - the values of the URL as keyword arguments and has to return the target - for the redirect, otherwise it has to be a string with placeholders in - rule syntax. - """ - - async def redirect(request: web.Request) -> web.StreamResponse: - """Redirect to location.""" - # Should be instance of aiohttp.web_exceptions._HTTPMove. - raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] - - self.app[KEY_ALLOW_CONFIGURED_CORS]( - self.app.router.add_route("GET", url, redirect) - ) - - def _make_static_resources( - self, configs: Collection[StaticPathConfig] - ) -> dict[str, CachingStaticResource | web.StaticResource | None]: - """Create a list of static resources.""" - return { - config.url_path: _STATIC_CLASSES[config.cache_headers]( - config.url_path, config.path - ) - if os.path.isdir(config.path) - else None - for config in configs - } - - async def async_register_static_paths( - self, configs: Collection[StaticPathConfig] - ) -> None: - """Register a folder or file to serve as a static path.""" - resources = await self.hass.async_add_executor_job( - self._make_static_resources, configs - ) - self._async_register_static_paths(configs, resources) - - @callback - def _async_register_static_paths( - self, - configs: Collection[StaticPathConfig], - resources: dict[str, CachingStaticResource | web.StaticResource | None], - ) -> None: - """Register a folders or files to serve as a static path.""" - app = self.app - allow_cors = app[KEY_ALLOW_CONFIGURED_CORS] - for config in configs: - if resource := resources[config.url_path]: - app.router.register_resource(resource) - allow_cors(resource) - - target = ( - _serve_file_with_cache_headers if config.cache_headers else _serve_file - ) - allow_cors( - self.app.router.add_route( - "GET", config.url_path, partial(target, config.path) - ) - ) - - def _create_ssl_context(self) -> ssl.SSLContext | None: - context: ssl.SSLContext | None = None - assert self.ssl_certificate is not None - try: - if self.ssl_profile == SSL_INTERMEDIATE: - context = ssl_util.server_context_intermediate() - else: - context = ssl_util.server_context_modern() - context.load_cert_chain(self.ssl_certificate, self.ssl_key) - except OSError as error: - if not self.hass.config.recovery_mode: - raise HomeAssistantError( - f"Could not use SSL certificate from {self.ssl_certificate}:" - f" {error}" - ) from error - _LOGGER.error( - "Could not read SSL certificate from %s: %s", - self.ssl_certificate, - error, - ) - try: - context = self._create_emergency_ssl_context() - except OSError as error2: - _LOGGER.error( - "Could not create an emergency self signed ssl certificate: %s", - error2, - ) - context = None - else: - # Fall through: a configured peer certificate must still be - # enforced on the emergency context. - _LOGGER.critical( - "Home Assistant is running in recovery mode with an emergency self" - " signed ssl certificate because the configured SSL certificate was" - " not usable" - ) - - if self.ssl_peer_certificate: - if context is None: - raise HomeAssistantError( - "Failed to create ssl context, no fallback available because a peer" - " certificate is required." - ) - - context.verify_mode = ssl.CERT_REQUIRED - try: - context.load_verify_locations(self.ssl_peer_certificate) - except OSError as error: - # Raise HomeAssistantError so the caller can tell an unusable - # SSL configuration apart from a socket bind failure (OSError). - raise HomeAssistantError( - f"Could not use SSL peer certificate from" - f" {self.ssl_peer_certificate}: {error}" - ) from error - - return context - - def _create_emergency_ssl_context(self) -> ssl.SSLContext: - """Create an emergency ssl certificate so we can still startup.""" - context = ssl_util.server_context_modern() - host: str - try: - host = cast(str, URL(get_url(self.hass, prefer_external=True)).host) - except NoURLAvailableError: - host = "homeassistant.local" - key = rsa.generate_private_key( - public_exponent=65537, - key_size=2048, - ) - subject = issuer = x509.Name( - [ - x509.NameAttribute( - NameOID.ORGANIZATION_NAME, "Home Assistant Emergency Certificate" - ), - x509.NameAttribute(NameOID.COMMON_NAME, host), - ] - ) - now = dt_util.utcnow() - cert = ( - x509.CertificateBuilder() - .subject_name(subject) - .issuer_name(issuer) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now) - .not_valid_after(now + datetime.timedelta(days=30)) - .add_extension( - x509.SubjectAlternativeName([x509.DNSName(host)]), - critical=False, - ) - .sign(key, hashes.SHA256()) - ) - with NamedTemporaryFile() as cert_pem, NamedTemporaryFile() as key_pem: - cert_pem.write(cert.public_bytes(serialization.Encoding.PEM)) - key_pem.write( - key.private_bytes( - serialization.Encoding.PEM, - format=serialization.PrivateFormat.TraditionalOpenSSL, - encryption_algorithm=serialization.NoEncryption(), - ) - ) - cert_pem.flush() - key_pem.flush() - context.load_cert_chain(cert_pem.name, key_pem.name) - return context - - async def async_start_supervisor_unix_socket(self) -> None: - """Start listening on the Unix socket. - - This is called separately from start() to delay serving the Unix - socket until the Supervisor user exists (created by the hassio - integration). Without this delay, Supervisor could connect before - its user is available and receive 401 responses it won't retry. - """ - if self.supervisor_unix_socket_path is None or self.runner is None: - return - self.supervisor_site = HomeAssistantUnixSite( - self.runner, self.supervisor_unix_socket_path - ) - try: - await self.supervisor_site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server on unix socket %s: %s", - self.supervisor_unix_socket_path, - error, - ) - self.supervisor_site = None - else: - _LOGGER.info( - "Now listening on unix socket %s", self.supervisor_unix_socket_path - ) - - async def start(self) -> None: - """Start the aiohttp server.""" - # Aiohttp freezes apps after start so that no changes can be made. - # However in Home Assistant components can be discovered after boot. - # This will now raise a RunTimeError. - # To work around this we now prevent the router from getting frozen - self.app._router.freeze = lambda: None # type: ignore[method-assign] # noqa: SLF001 - - self.runner = web.AppRunner( - self.app, handler_cancellation=True, shutdown_timeout=10 - ) - await self.runner.setup() - - # Setup either binds the server or fails, so it is always available - # here. - assert self._server is not None - await self._server.start_serving() - - _LOGGER.info("Now listening on port %d", self.server_port) - - async def stop(self) -> None: - """Stop the aiohttp server.""" - if self.supervisor_site is not None: - await self.supervisor_site.stop() - if self.supervisor_unix_socket_path is not None: - try: - await self.hass.async_add_executor_job( - self.supervisor_unix_socket_path.unlink, True - ) - except OSError as err: - _LOGGER.warning( - "Could not remove Supervisor unix socket %s: %s", - self.supervisor_unix_socket_path, - err, - ) - if self._server is not None: - # Only close (stop listening); do not await wait_closed() here. - # Let runner.cleanup() terminate active connections. - self._server.close() - if self.runner is not None: - await self.runner.cleanup() diff --git a/homeassistant/components/http/server.py b/homeassistant/components/http/server.py new file mode 100644 index 000000000000..3017e818fff8 --- /dev/null +++ b/homeassistant/components/http/server.py @@ -0,0 +1,553 @@ +"""HTTP server implementation for the Home Assistant HTTP integration.""" + +import asyncio +from collections.abc import Collection +from dataclasses import dataclass +import datetime +from functools import partial +from ipaddress import IPv4Network, IPv6Network, ip_network +import logging +import os +from pathlib import Path +import socket +import ssl +from tempfile import NamedTemporaryFile +from typing import Any, Final, cast, override + +from aiohttp import web +from aiohttp.abc import AbstractStreamWriter +from aiohttp.http_parser import RawRequestMessage +from aiohttp.streams import StreamReader +from aiohttp.typedefs import JSONDecoder, StrOrURL +from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection +from aiohttp.web_protocol import RequestHandler +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.x509.oid import NameOID +from yarl import URL + +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.http import ( + KEY_ALLOW_CONFIGURED_CORS, + KEY_HASS, + HomeAssistantView, + current_request, +) +from homeassistant.helpers.network import NoURLAvailableError, get_url +from homeassistant.util import dt as dt_util, ssl as ssl_util +from homeassistant.util.json import json_loads + +from .auth import async_setup_auth +from .ban import setup_bans +from .config import ConfData +from .const import ( + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, + CONF_SSL_KEY, + CONF_SSL_PEER_CERTIFICATE, + CONF_SSL_PROFILE, + CONF_TRUSTED_PROXIES, + SSL_INTERMEDIATE, +) +from .cors import setup_cors +from .forwarded import async_setup_forwarded +from .headers import setup_headers +from .request_context import setup_request_context +from .security_filter import setup_security_filter +from .static import CACHE_HEADERS, CachingStaticResource +from .web_runner import HomeAssistantUnixSite + +_LOGGER: Final = logging.getLogger(__name__) + +MAX_CLIENT_SIZE: Final = 1024**2 * 16 +MAX_LINE_SIZE: Final = 24570 + +_HAS_IPV6 = hasattr(socket, "AF_INET6") +DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"] + + +@dataclass(slots=True) +class StaticPathConfig: + """Configuration for a static path.""" + + url_path: str + path: str + cache_headers: bool = True + + +_STATIC_CLASSES = { + True: CachingStaticResource, + False: web.StaticResource, +} + + +def make_server( + hass: HomeAssistant, + conf: ConfData, + supervisor_unix_socket_path: Path | None = None, +) -> HomeAssistantHTTP: + """Create a server instance for the given config.""" + return HomeAssistantHTTP( + hass, + server_host=conf.get(CONF_SERVER_HOST, DEFAULT_BIND), + server_port=conf[CONF_SERVER_PORT], + ssl_certificate=conf.get(CONF_SSL_CERTIFICATE), + ssl_peer_certificate=conf.get(CONF_SSL_PEER_CERTIFICATE), + ssl_key=conf.get(CONF_SSL_KEY), + # The loaded config stores trusted proxies as strings + # (JSON-serializable); the forwarded middleware needs + # IPv4Network/IPv6Network objects. + trusted_proxies=[ + ip_network(proxy) for proxy in conf.get(CONF_TRUSTED_PROXIES) or [] + ], + ssl_profile=conf[CONF_SSL_PROFILE], + supervisor_unix_socket_path=supervisor_unix_socket_path, + ) + + +async def async_verify_can_bind(hass: HomeAssistant, conf: ConfData) -> None: + """Verify a server for ``conf`` can be created and its address bound. + + Used to validate a new user-supplied config before it is stored and + applied via a restart; the sockets are released right away. Best effort: + the address can still be taken by another process before the restart, so + the setup fallback chain remains the safety net. + + Raises ``HomeAssistantError`` if the SSL configuration is unusable or the + configured address cannot be bound. + """ + server = make_server(hass, conf) + try: + await server.async_bind() + except OSError as err: + raise HomeAssistantError( + f"Failed to create HTTP server at port {conf[CONF_SERVER_PORT]}: {err}" + ) from err + finally: + await server.stop() + + +class HomeAssistantRequest(web.Request): + """Home Assistant request object.""" + + @override + async def json(self, *, loads: JSONDecoder = json_loads) -> Any: + """Return body as JSON.""" + # json_loads is a wrapper around orjson.loads that handles + # bytes and str. We can pass the bytes directly to json_loads. + return json_loads(await self.read()) + + +class HomeAssistantApplication(web.Application): + """Home Assistant application.""" + + @override + def _make_request( + self, + message: RawRequestMessage, + payload: StreamReader, + protocol: RequestHandler, + writer: AbstractStreamWriter, + task: asyncio.Task[None], + _cls: type[web.Request] = HomeAssistantRequest, + ) -> web.Request: + """Create request instance.""" + return _cls( + message, + payload, + protocol, + writer, + task, + # loop will never be None when called from aiohttp + loop=self._loop, # type: ignore[arg-type] + client_max_size=self._client_max_size, + ) + + +async def _serve_file_with_cache_headers( + path: str, request: web.Request +) -> web.FileResponse: + return web.FileResponse(path, headers=CACHE_HEADERS) + + +async def _serve_file(path: str, request: web.Request) -> web.FileResponse: + return web.FileResponse(path) + + +class HomeAssistantHTTP: + """HTTP server for Home Assistant.""" + + def __init__( + self, + hass: HomeAssistant, + ssl_certificate: str | None, + ssl_peer_certificate: str | None, + ssl_key: str | None, + server_host: list[str] | None, + server_port: int, + trusted_proxies: list[IPv4Network | IPv6Network], + ssl_profile: str, + supervisor_unix_socket_path: Path | None = None, + ) -> None: + """Initialize the HTTP Home Assistant server.""" + self.app = HomeAssistantApplication( + middlewares=[], + client_max_size=MAX_CLIENT_SIZE, + handler_args={ + "max_line_size": MAX_LINE_SIZE, + "max_field_size": MAX_LINE_SIZE, + }, + ) + self.hass = hass + self.ssl_certificate = ssl_certificate + self.ssl_peer_certificate = ssl_peer_certificate + self.ssl_key = ssl_key + self.server_host = server_host + self.server_port = server_port + self.trusted_proxies = trusted_proxies + self.ssl_profile = ssl_profile + self.supervisor_unix_socket_path = supervisor_unix_socket_path + self.runner: web.AppRunner | None = None + self.supervisor_site: HomeAssistantUnixSite | None = None + self.context: ssl.SSLContext | None = None + self._server: asyncio.Server | None = None + + async def async_bind(self) -> None: + """Create the SSL context and the server, binding its sockets. + + Called during setup so that an unusable configuration surfaces before + it is applied; serving starts later in ``start()``. Raises + ``HomeAssistantError`` if the SSL configuration is unusable and + ``OSError`` if the configured address cannot be bound. + """ + if self.ssl_certificate: + self.context = await self.hass.async_add_executor_job( + self._create_ssl_context + ) + self._server = await self._async_create_server() + + async def _async_create_server(self) -> asyncio.Server: + """Create the (not yet serving) HTTP server, binding its sockets.""" + try: + return await self.hass.loop.create_server( + self._make_protocol, + self.server_host if self.server_host is not None else DEFAULT_BIND, + self.server_port, + ssl=self.context, + backlog=128, + start_serving=False, + ) + except UnicodeError as err: + # create_server() raises UnicodeError for hosts the IDNA codec + # cannot encode (e.g. a label longer than 63 characters); + # normalize to OSError so callers only need to handle one error + # type. + raise OSError(f"error while resolving host: {err}") from err + + def _make_protocol(self) -> RequestHandler: + """Create a protocol instance for an accepted connection. + + Connections are only accepted once ``start()`` has run, so the + runner is set up by the time this is called. + """ + runner = self.runner + assert runner is not None and runner.server is not None + return runner.server() + + async def async_initialize( + self, + *, + cors_origins: list[str], + use_x_forwarded_for: bool, + login_threshold: int, + is_ban_enabled: bool, + use_x_frame_options: bool, + ) -> None: + """Initialize the server.""" + self.app[KEY_HASS] = self.hass + self.app["hass"] = self.hass # For backwards compatibility + + # Order matters, security filters middleware needs to go first, + # forwarded middleware needs to go second. + setup_security_filter(self.app) + + async_setup_forwarded(self.app, use_x_forwarded_for, self.trusted_proxies) + + setup_request_context(self.app, current_request) + + if is_ban_enabled: + setup_bans(self.hass, self.app, login_threshold) + + await async_setup_auth(self.hass, self.app) + + setup_headers(self.app, use_x_frame_options) + setup_cors(self.app, cors_origins) + + def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: + """Register a view with the WSGI server. + + The view argument must be a class that inherits from HomeAssistantView. + It is optional to instantiate it before registering; this method will + handle it either way. + """ + if isinstance(view, type): + # Instantiate the view, if needed + view = view() + + if not hasattr(view, "url"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "url"') + + if not hasattr(view, "name"): + class_name = view.__class__.__name__ + raise AttributeError(f'{class_name} missing required attribute "name"') + + view.register(self.hass, self.app, self.app.router) + + def register_redirect( + self, + url: str, + redirect_to: StrOrURL, + *, + redirect_exc: type[HTTPRedirection] = HTTPMovedPermanently, + ) -> None: + """Register a redirect with the server. + + If given this must be either a string or callable. In case of a + callable it's called with the url adapter that triggered the match and + the values of the URL as keyword arguments and has to return the target + for the redirect, otherwise it has to be a string with placeholders in + rule syntax. + """ + + async def redirect(request: web.Request) -> web.StreamResponse: + """Redirect to location.""" + # Should be instance of aiohttp.web_exceptions._HTTPMove. + raise redirect_exc(redirect_to) # type: ignore[arg-type,call-arg] + + self.app[KEY_ALLOW_CONFIGURED_CORS]( + self.app.router.add_route("GET", url, redirect) + ) + + def _make_static_resources( + self, configs: Collection[StaticPathConfig] + ) -> dict[str, CachingStaticResource | web.StaticResource | None]: + """Create a list of static resources.""" + return { + config.url_path: _STATIC_CLASSES[config.cache_headers]( + config.url_path, config.path + ) + if os.path.isdir(config.path) + else None + for config in configs + } + + async def async_register_static_paths( + self, configs: Collection[StaticPathConfig] + ) -> None: + """Register a folder or file to serve as a static path.""" + resources = await self.hass.async_add_executor_job( + self._make_static_resources, configs + ) + self._async_register_static_paths(configs, resources) + + @callback + def _async_register_static_paths( + self, + configs: Collection[StaticPathConfig], + resources: dict[str, CachingStaticResource | web.StaticResource | None], + ) -> None: + """Register a folders or files to serve as a static path.""" + app = self.app + allow_cors = app[KEY_ALLOW_CONFIGURED_CORS] + for config in configs: + if resource := resources[config.url_path]: + app.router.register_resource(resource) + allow_cors(resource) + + target = ( + _serve_file_with_cache_headers if config.cache_headers else _serve_file + ) + allow_cors( + self.app.router.add_route( + "GET", config.url_path, partial(target, config.path) + ) + ) + + def _create_ssl_context(self) -> ssl.SSLContext | None: + context: ssl.SSLContext | None = None + assert self.ssl_certificate is not None + try: + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() + context.load_cert_chain(self.ssl_certificate, self.ssl_key) + except OSError as error: + if not self.hass.config.recovery_mode: + raise HomeAssistantError( + f"Could not use SSL certificate from {self.ssl_certificate}:" + f" {error}" + ) from error + _LOGGER.error( + "Could not read SSL certificate from %s: %s", + self.ssl_certificate, + error, + ) + try: + context = self._create_emergency_ssl_context() + except OSError as error2: + _LOGGER.error( + "Could not create an emergency self signed ssl certificate: %s", + error2, + ) + context = None + else: + # Fall through: a configured peer certificate must still be + # enforced on the emergency context. + _LOGGER.critical( + "Home Assistant is running in recovery mode with an emergency self" + " signed ssl certificate because the configured SSL certificate was" + " not usable" + ) + + if self.ssl_peer_certificate: + if context is None: + raise HomeAssistantError( + "Failed to create ssl context, no fallback available because a peer" + " certificate is required." + ) + + context.verify_mode = ssl.CERT_REQUIRED + try: + context.load_verify_locations(self.ssl_peer_certificate) + except OSError as error: + # Raise HomeAssistantError so the caller can tell an unusable + # SSL configuration apart from a socket bind failure (OSError). + raise HomeAssistantError( + f"Could not use SSL peer certificate from" + f" {self.ssl_peer_certificate}: {error}" + ) from error + + return context + + def _create_emergency_ssl_context(self) -> ssl.SSLContext: + """Create an emergency ssl certificate so we can still startup.""" + context = ssl_util.server_context_modern() + host: str + try: + host = cast(str, URL(get_url(self.hass, prefer_external=True)).host) + except NoURLAvailableError: + host = "homeassistant.local" + key = rsa.generate_private_key( + public_exponent=65537, + key_size=2048, + ) + subject = issuer = x509.Name( + [ + x509.NameAttribute( + NameOID.ORGANIZATION_NAME, "Home Assistant Emergency Certificate" + ), + x509.NameAttribute(NameOID.COMMON_NAME, host), + ] + ) + now = dt_util.utcnow() + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now) + .not_valid_after(now + datetime.timedelta(days=30)) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName(host)]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + with NamedTemporaryFile() as cert_pem, NamedTemporaryFile() as key_pem: + cert_pem.write(cert.public_bytes(serialization.Encoding.PEM)) + key_pem.write( + key.private_bytes( + serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + cert_pem.flush() + key_pem.flush() + context.load_cert_chain(cert_pem.name, key_pem.name) + return context + + async def async_start_supervisor_unix_socket(self) -> None: + """Start listening on the Unix socket. + + This is called separately from start() to delay serving the Unix + socket until the Supervisor user exists (created by the hassio + integration). Without this delay, Supervisor could connect before + its user is available and receive 401 responses it won't retry. + """ + if self.supervisor_unix_socket_path is None or self.runner is None: + return + self.supervisor_site = HomeAssistantUnixSite( + self.runner, self.supervisor_unix_socket_path + ) + try: + await self.supervisor_site.start() + except OSError as error: + _LOGGER.error( + "Failed to create HTTP server on unix socket %s: %s", + self.supervisor_unix_socket_path, + error, + ) + self.supervisor_site = None + else: + _LOGGER.info( + "Now listening on unix socket %s", self.supervisor_unix_socket_path + ) + + async def start(self) -> None: + """Start the aiohttp server.""" + # Aiohttp freezes apps after start so that no changes can be made. + # However in Home Assistant components can be discovered after boot. + # This will now raise a RunTimeError. + # To work around this we now prevent the router from getting frozen + self.app._router.freeze = lambda: None # type: ignore[method-assign] # noqa: SLF001 + + self.runner = web.AppRunner( + self.app, handler_cancellation=True, shutdown_timeout=10 + ) + await self.runner.setup() + + # Setup either binds the server or fails, so it is always available + # here. + assert self._server is not None + await self._server.start_serving() + + _LOGGER.info("Now listening on port %d", self.server_port) + + async def stop(self) -> None: + """Stop the aiohttp server.""" + if self.supervisor_site is not None: + await self.supervisor_site.stop() + if self.supervisor_unix_socket_path is not None: + try: + await self.hass.async_add_executor_job( + self.supervisor_unix_socket_path.unlink, True + ) + except OSError as err: + _LOGGER.warning( + "Could not remove Supervisor unix socket %s: %s", + self.supervisor_unix_socket_path, + err, + ) + if self._server is not None: + # Only close (stop listening); do not await wait_closed() here. + # Let runner.cleanup() terminate active connections. + self._server.close() + if self.runner is not None: + await self.runner.cleanup() diff --git a/homeassistant/components/http/websocket_api.py b/homeassistant/components/http/websocket_api.py index 343e1850d96d..9df63bbaf2ff 100644 --- a/homeassistant/components/http/websocket_api.py +++ b/homeassistant/components/http/websocket_api.py @@ -12,9 +12,9 @@ from homeassistant.components.homeassistant import ( from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from . import async_verify_can_bind from .config import HTTP_STORAGE_SCHEMA, ConfData, async_get_and_load_store from .const import ATTR_CONFIG, CONF_SERVER_PORT +from .server import async_verify_can_bind ERR_BIND_FAILED: Final = "bind_failed" diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index a2f5e281e66f..a5b7469128ce 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -111,7 +111,7 @@ def trusted_networks_auth( async def test_auth_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.async_setup_auth") as mock_setup: + with patch("homeassistant.components.http.server.async_setup_auth") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index 086adb99dca9..5f5fd1660f4f 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -310,7 +310,7 @@ async def test_access_from_supervisor_ip( async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_bans") as mock_setup: + with patch("homeassistant.components.http.server.setup_bans") as mock_setup: await async_setup_component( hass, DOMAIN, {"http": {http.CONF_IP_BAN_ENABLED: False}} ) @@ -320,7 +320,7 @@ async def test_ban_middleware_not_loaded_by_config(hass: HomeAssistant) -> None: async def test_ban_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_bans") as mock_setup: + with patch("homeassistant.components.http.server.setup_bans") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 3d3d6ba89e1f..61b981528a47 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -31,7 +31,7 @@ TRUSTED_ORIGIN = "https://home-assistant.io" async def test_cors_middleware_loaded_by_default(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: await async_setup_component(hass, DOMAIN, {"http": {}}) assert len(mock_setup.mock_calls) == 1 @@ -39,7 +39,7 @@ async def test_cors_middleware_loaded_by_default(hass: HomeAssistant) -> None: async def test_cors_middleware_loaded_from_config(hass: HomeAssistant) -> None: """Test accessing to server from banned IP when feature is off.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: await async_setup_component( hass, DOMAIN, diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index b28e57d015ae..2dc1d7d4de33 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -492,7 +492,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.get_url", side_effect=NoURLAvailableError + "homeassistant.components.http.server.get_url", side_effect=NoURLAvailableError ) as mock_get_url: assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() @@ -525,7 +525,8 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + "homeassistant.components.http.server.x509.CertificateBuilder", + side_effect=OSError, ) as mock_builder: assert await async_setup_component(hass, DOMAIN, {}) is True await hass.async_start() @@ -566,7 +567,8 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( hass.config.recovery_mode = True with patch( - "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError + "homeassistant.components.http.server.x509.CertificateBuilder", + side_effect=OSError, ) as mock_builder: assert await async_setup_component(hass, DOMAIN, {}) is False await hass.async_start() @@ -639,7 +641,7 @@ async def test_create_server_passes_configuration(hass: HomeAssistant) -> None: async def test_cors_defaults(hass: HomeAssistant) -> None: """Test the CORS default settings.""" - with patch("homeassistant.components.http.setup_cors") as mock_setup: + with patch("homeassistant.components.http.server.setup_cors") as mock_setup: assert await async_setup_component(hass, DOMAIN, {}) assert len(mock_setup.mock_calls) == 1