mirror of
https://github.com/home-assistant/core.git
synced 2026-08-20 05:10:44 +01:00
Move http server code into it's own module (#176968)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user