mirror of
https://github.com/home-assistant/core.git
synced 2026-08-06 13:26:29 +01:00
266 lines
9.2 KiB
Python
266 lines
9.2 KiB
Python
"""Support to serve the Home Assistant API as WSGI application."""
|
|
|
|
from ipaddress import ip_network
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, Final
|
|
|
|
import voluptuous as vol
|
|
|
|
from homeassistant.components.network import async_get_source_ip
|
|
from homeassistant.const import (
|
|
EVENT_HOMEASSISTANT_START,
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
HASSIO_USER_NAME,
|
|
)
|
|
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.http import ( # noqa: F401
|
|
KEY_ALLOW_CONFIGURED_CORS,
|
|
KEY_AUTHENTICATED,
|
|
KEY_HASS,
|
|
HomeAssistantView,
|
|
current_request,
|
|
)
|
|
from homeassistant.helpers.importlib import async_import_module
|
|
from homeassistant.helpers.typing import ConfigType
|
|
from homeassistant.setup import (
|
|
SetupPhases,
|
|
async_start_setup,
|
|
async_when_setup_or_start,
|
|
)
|
|
from homeassistant.util.async_ import create_eager_task
|
|
|
|
from .config import async_get_and_load_store, async_load_config
|
|
from .const import ( # noqa: F401
|
|
CONF_BASE_URL,
|
|
CONF_CORS_ORIGINS,
|
|
CONF_IP_BAN_ENABLED,
|
|
CONF_LOGIN_ATTEMPTS_THRESHOLD,
|
|
CONF_SERVER_HOST,
|
|
CONF_SERVER_PORT,
|
|
CONF_SSL_CERTIFICATE,
|
|
CONF_SSL_KEY,
|
|
CONF_SSL_PEER_CERTIFICATE,
|
|
CONF_SSL_PROFILE,
|
|
CONF_TRUSTED_PROXIES,
|
|
CONF_USE_X_FORWARDED_FOR,
|
|
CONF_USE_X_FRAME_OPTIONS,
|
|
DEFAULT_CORS,
|
|
DOMAIN,
|
|
KEY_HASS_REFRESH_TOKEN_ID,
|
|
KEY_HASS_USER,
|
|
NO_LOGIN_ATTEMPT_THRESHOLD,
|
|
SSL_INTERMEDIATE,
|
|
SSL_MODERN,
|
|
)
|
|
from .decorators import require_admin # noqa: F401
|
|
from .server import (
|
|
DEFAULT_BIND,
|
|
HassioHTTPConfigView,
|
|
HomeAssistantHTTP, # noqa: F401
|
|
HomeAssistantRequest, # noqa: F401
|
|
StaticPathConfig, # noqa: F401
|
|
make_server,
|
|
)
|
|
|
|
_LOGGER: Final = logging.getLogger(__name__)
|
|
|
|
DEFAULT_DEVELOPMENT: Final = "0"
|
|
|
|
HTTP_SCHEMA: Final = vol.All(
|
|
cv.deprecated(CONF_BASE_URL),
|
|
vol.Schema(
|
|
{
|
|
vol.Optional(CONF_SERVER_HOST): vol.All(
|
|
cv.ensure_list, vol.Length(min=1), [cv.string]
|
|
),
|
|
# No default: the YAML migration needs to tell an explicitly
|
|
# configured port apart from an omitted one, which it keeps on the
|
|
# previous default port instead of the Supervisor default.
|
|
vol.Optional(CONF_SERVER_PORT): cv.port,
|
|
vol.Optional(CONF_BASE_URL): cv.string,
|
|
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
|
|
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
|
|
vol.Optional(CONF_SSL_KEY): cv.isfile,
|
|
vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
|
|
cv.ensure_list, [cv.string]
|
|
),
|
|
vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean,
|
|
vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All(
|
|
cv.ensure_list, [ip_network]
|
|
),
|
|
vol.Optional(
|
|
CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
|
|
): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD),
|
|
vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean,
|
|
vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
|
|
[SSL_INTERMEDIATE, SSL_MODERN]
|
|
),
|
|
vol.Optional(CONF_USE_X_FRAME_OPTIONS, default=True): cv.boolean,
|
|
}
|
|
),
|
|
)
|
|
|
|
CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
|
|
|
|
|
class ApiConfig:
|
|
"""Configuration settings for API server."""
|
|
|
|
def __init__(
|
|
self,
|
|
local_ip: str,
|
|
host: str,
|
|
port: int,
|
|
use_ssl: bool,
|
|
) -> None:
|
|
"""Initialize a new API config object."""
|
|
self.local_ip = local_ip
|
|
self.host = host
|
|
self.port = port
|
|
self.use_ssl = use_ssl
|
|
|
|
|
|
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
|
|
# we import aiohttp_fast_zlib
|
|
(await async_import_module(hass, "aiohttp_fast_zlib")).enable()
|
|
|
|
# Deferred import: websocket_api declares http as its manifest
|
|
# dependency and imports back into this package at module load
|
|
# (websocket_api/http.py -> homeassistant.components.http). A top-level
|
|
# import of .websocket_api here would re-enter the still-loading
|
|
# websocket_api package and fail when applying its decorators
|
|
# (e.g. @websocket_api.require_admin).
|
|
websocket_api_module = await async_import_module(
|
|
hass, "homeassistant.components.http.websocket_api"
|
|
)
|
|
|
|
conf = await async_load_config(hass, config)
|
|
|
|
websocket_api_module.async_register_websocket_commands(hass)
|
|
|
|
supervisor_unix_socket_path: Path | None = None
|
|
if socket_env := os.environ.get("SUPERVISOR_CORE_API_SOCKET"):
|
|
socket_path = Path(socket_env)
|
|
if socket_path.is_absolute():
|
|
supervisor_unix_socket_path = socket_path
|
|
else:
|
|
_LOGGER.error(
|
|
"Invalid Supervisor Unix socket path %s: path must be absolute",
|
|
socket_env,
|
|
)
|
|
|
|
server = make_server(hass, conf, supervisor_unix_socket_path)
|
|
trial_reverted = False
|
|
while True:
|
|
try:
|
|
await server.async_bind()
|
|
except (HomeAssistantError, OSError) as err:
|
|
store = await async_get_and_load_store(hass)
|
|
trial_reverted = store.revert_deadline is not None
|
|
conf = await store.async_get_fallback_config(err)
|
|
server = make_server(hass, conf, supervisor_unix_socket_path)
|
|
continue
|
|
if trial_reverted:
|
|
_LOGGER.warning(
|
|
"The previous HTTP configuration has been restored (server port %d)",
|
|
conf[CONF_SERVER_PORT],
|
|
)
|
|
break
|
|
|
|
# Created only after the fallback chain succeeded: if setup fails above,
|
|
# an already running task would be left behind unawaited.
|
|
source_ip_task = create_eager_task(async_get_source_ip(hass))
|
|
|
|
async def stop_server(event: Event) -> None:
|
|
"""Stop the server."""
|
|
await server.stop()
|
|
|
|
# Register the stop listener right away, not only once serving starts:
|
|
# sockets are already bound, and if the remainder of startup fails the
|
|
# recovery-mode teardown (which fires the stop event) must release them,
|
|
# or the recovery boot cannot bind the same address again.
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
|
|
|
server_host = conf.get(CONF_SERVER_HOST, DEFAULT_BIND)
|
|
server_port = conf[CONF_SERVER_PORT]
|
|
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
|
|
|
await server.async_initialize(
|
|
cors_origins=conf[CONF_CORS_ORIGINS],
|
|
use_x_forwarded_for=conf.get(CONF_USE_X_FORWARDED_FOR, False),
|
|
login_threshold=conf[CONF_LOGIN_ATTEMPTS_THRESHOLD],
|
|
is_ban_enabled=conf[CONF_IP_BAN_ENABLED],
|
|
use_x_frame_options=conf[CONF_USE_X_FRAME_OPTIONS],
|
|
)
|
|
|
|
async def start_server(*_: Any) -> None:
|
|
"""Start the server."""
|
|
with async_start_setup(hass, integration="http", phase=SetupPhases.SETUP):
|
|
await server.start()
|
|
|
|
async_when_setup_or_start(hass, "frontend", start_server)
|
|
|
|
if server.supervisor_unix_socket_path is not None:
|
|
# Let Supervisor pull its connection parameters over the socket instead
|
|
# of relying on the hassio integration having pushed them first.
|
|
server.register_view(HassioHTTPConfigView)
|
|
|
|
async def start_supervisor_unix_socket(*_: Any) -> None:
|
|
"""Start the Unix socket after the Supervisor user is available."""
|
|
if any(
|
|
user
|
|
for user in await hass.auth.async_get_users()
|
|
if user.system_generated and user.name == HASSIO_USER_NAME
|
|
):
|
|
await server.async_start_supervisor_unix_socket()
|
|
else:
|
|
_LOGGER.error("Supervisor user not found; not starting Unix socket")
|
|
|
|
async_when_setup_or_start(hass, "hassio", start_supervisor_unix_socket)
|
|
|
|
hass.http = server
|
|
|
|
local_ip = await source_ip_task
|
|
|
|
host = local_ip
|
|
if server_host is not None:
|
|
# Assume the first server host name provided as API host
|
|
host = server_host[0]
|
|
|
|
hass.config.api = ApiConfig(
|
|
local_ip, host, server_port, ssl_certificate is not None
|
|
)
|
|
|
|
@callback
|
|
def _async_check_ssl_issue(_: Event) -> None:
|
|
if (
|
|
ssl_certificate is not None
|
|
and (hass.config.external_url or hass.config.internal_url) is None
|
|
):
|
|
from homeassistant.components.cloud import ( # noqa: PLC0415
|
|
CloudNotAvailable,
|
|
async_remote_ui_url,
|
|
)
|
|
|
|
try:
|
|
async_remote_ui_url(hass)
|
|
except CloudNotAvailable:
|
|
ir.async_create_issue(
|
|
hass,
|
|
DOMAIN,
|
|
"ssl_configured_without_configured_urls",
|
|
is_fixable=False,
|
|
severity=ir.IssueSeverity.ERROR,
|
|
translation_key="ssl_configured_without_configured_urls",
|
|
)
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _async_check_ssl_issue)
|
|
|
|
return True
|