Compare commits
27 commits
dev
...
multi_site
Author | SHA1 | Date | |
---|---|---|---|
|
74c518ce79 | ||
|
91b5b4de64 | ||
|
d5c88dd4f5 | ||
|
bf18897b0f | ||
|
a3cf66c98c | ||
|
84aeaf7960 | ||
|
2a8a1bdc62 | ||
|
240f0cc490 | ||
|
82a4036965 | ||
|
b5220c4149 | ||
|
65ddb464c3 | ||
|
775dcebeb9 | ||
|
10bf6db3a2 | ||
|
484c9d4d1f | ||
|
e7c95ee088 | ||
|
18ef390dca | ||
|
c34aa0e23b | ||
|
2820a63f9f | ||
|
11a2e30d22 | ||
|
85dd053be8 | ||
|
64424f672c | ||
|
0652dda106 | ||
|
5c3a76053d | ||
|
00e784d0c4 | ||
|
5c0614eb17 | ||
|
cb06b5af78 | ||
|
d8fa06de67 |
4 changed files with 341 additions and 128 deletions
|
@ -247,7 +247,9 @@ class Analytics:
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.preferences.get(ATTR_USAGE, False):
|
if self.preferences.get(ATTR_USAGE, False):
|
||||||
payload[ATTR_CERTIFICATE] = self.hass.http.ssl_certificate is not None
|
payload[ATTR_CERTIFICATE] = (
|
||||||
|
self.hass.config.api and self.hass.config.api.use_ssl
|
||||||
|
)
|
||||||
payload[ATTR_INTEGRATIONS] = integrations
|
payload[ATTR_INTEGRATIONS] = integrations
|
||||||
payload[ATTR_CUSTOM_INTEGRATIONS] = custom_integrations
|
payload[ATTR_CUSTOM_INTEGRATIONS] = custom_integrations
|
||||||
if supervisor_info is not None:
|
if supervisor_info is not None:
|
||||||
|
|
|
@ -2,6 +2,9 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from collections.abc import Callable
|
||||||
|
import contextlib
|
||||||
|
from dataclasses import dataclass
|
||||||
import datetime
|
import datetime
|
||||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||||
import logging
|
import logging
|
||||||
|
@ -87,18 +90,90 @@ STORAGE_KEY: Final = DOMAIN
|
||||||
STORAGE_VERSION: Final = 1
|
STORAGE_VERSION: Final = 1
|
||||||
SAVE_DELAY: Final = 180
|
SAVE_DELAY: Final = 180
|
||||||
|
|
||||||
|
CONF_SERVERS = "servers"
|
||||||
|
SERVERS_GROUP = "servers"
|
||||||
|
|
||||||
|
SERVER_SCHEMA_WITHOUT_PORT = {
|
||||||
|
vol.Optional(CONF_SERVER_HOST): vol.All(
|
||||||
|
cv.ensure_list, vol.Length(min=1), [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_SSL_PROFILE, default=SSL_MODERN): vol.In(
|
||||||
|
[SSL_INTERMEDIATE, SSL_MODERN]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
OPTIONAL_PORT = {vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port}
|
||||||
|
|
||||||
|
_EXCLUSIVE_PORT_KEY = vol.Exclusive(CONF_SERVER_PORT, SERVERS_GROUP)
|
||||||
|
_EXCLUSIVE_PORT_KEY.default = vol.default_factory(SERVER_PORT)
|
||||||
|
|
||||||
|
EXCLUSIVE_PORT = {_EXCLUSIVE_PORT_KEY: cv.port}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_all_unique_ports(servers: list[ConfigType]) -> list[ConfigType]:
|
||||||
|
"""Validate that each http service has a unique port."""
|
||||||
|
ports = [list[CONF_SERVER_PORT] for list in servers]
|
||||||
|
vol.Schema(vol.Unique())(ports)
|
||||||
|
return servers
|
||||||
|
|
||||||
|
|
||||||
|
SERVERS_EXCLUSIVE_MESSAGE = (
|
||||||
|
'Configure one server at top level or configure multiple servers under "servers"'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _relocated_with_message(
|
||||||
|
key: str, new_location: str
|
||||||
|
) -> Callable[[ConfigType], ConfigType]:
|
||||||
|
"""Log key as relocated with a message."""
|
||||||
|
|
||||||
|
def validator(config: ConfigType) -> ConfigType:
|
||||||
|
"""Check if key is in config and log the new location."""
|
||||||
|
near = ""
|
||||||
|
with contextlib.suppress(AttributeError):
|
||||||
|
near = (
|
||||||
|
f"near {config.__config_file__}" # type: ignore[attr-defined]
|
||||||
|
f":{config.__line__}"
|
||||||
|
)
|
||||||
|
if key in config:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"The '%s' option %s has moved to '%s', please update your configuration",
|
||||||
|
key,
|
||||||
|
near,
|
||||||
|
new_location,
|
||||||
|
)
|
||||||
|
return config
|
||||||
|
|
||||||
|
return validator
|
||||||
|
|
||||||
|
|
||||||
HTTP_SCHEMA: Final = vol.All(
|
HTTP_SCHEMA: Final = vol.All(
|
||||||
cv.deprecated(CONF_BASE_URL),
|
cv.deprecated(CONF_BASE_URL),
|
||||||
|
_relocated_with_message(CONF_SERVER_HOST, new_location="servers[0].server_host"),
|
||||||
|
_relocated_with_message(CONF_SERVER_PORT, new_location="servers[0].server_port"),
|
||||||
|
_relocated_with_message(
|
||||||
|
CONF_SSL_CERTIFICATE, new_location="servers[0].ssl_certificate"
|
||||||
|
),
|
||||||
|
_relocated_with_message(
|
||||||
|
CONF_SSL_PEER_CERTIFICATE, new_location="servers[0].ssl_peer_certificate"
|
||||||
|
),
|
||||||
|
_relocated_with_message(CONF_SSL_KEY, new_location="servers[0].ssl_key"),
|
||||||
|
_relocated_with_message(CONF_SSL_PROFILE, new_location="servers[0].ssl_profile"),
|
||||||
vol.Schema(
|
vol.Schema(
|
||||||
{
|
{
|
||||||
vol.Optional(CONF_SERVER_HOST): vol.All(
|
**SERVER_SCHEMA_WITHOUT_PORT,
|
||||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
**EXCLUSIVE_PORT,
|
||||||
),
|
|
||||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
|
|
||||||
vol.Optional(CONF_BASE_URL): cv.string,
|
vol.Optional(CONF_BASE_URL): cv.string,
|
||||||
vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile,
|
vol.Exclusive(
|
||||||
vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile,
|
CONF_SERVERS, SERVERS_GROUP, msg=SERVERS_EXCLUSIVE_MESSAGE
|
||||||
vol.Optional(CONF_SSL_KEY): cv.isfile,
|
): vol.All(
|
||||||
|
cv.ensure_list,
|
||||||
|
[vol.Schema({**SERVER_SCHEMA_WITHOUT_PORT, **OPTIONAL_PORT})],
|
||||||
|
_has_all_unique_ports,
|
||||||
|
),
|
||||||
vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
|
vol.Optional(CONF_CORS_ORIGINS, default=DEFAULT_CORS): vol.All(
|
||||||
cv.ensure_list, [cv.string]
|
cv.ensure_list, [cv.string]
|
||||||
),
|
),
|
||||||
|
@ -110,13 +185,11 @@ HTTP_SCHEMA: Final = vol.All(
|
||||||
CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
|
CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD
|
||||||
): vol.Any(cv.positive_int, 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_IP_BAN_ENABLED, default=True): cv.boolean,
|
||||||
vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): vol.In(
|
|
||||||
[SSL_INTERMEDIATE, SSL_MODERN]
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
|
@ -161,6 +234,31 @@ class ApiConfig:
|
||||||
self.use_ssl = use_ssl
|
self.use_ssl = use_ssl
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SiteServerConfig:
|
||||||
|
"""Configuration for a single TCPSite."""
|
||||||
|
|
||||||
|
server_host: list[str] | None
|
||||||
|
server_port: int
|
||||||
|
ssl_certificate: str | None
|
||||||
|
ssl_peer_certificate: str | None
|
||||||
|
ssl_key: str | None
|
||||||
|
ssl_profile: str
|
||||||
|
ssl_context: ssl.SSLContext | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_site_server_config_from_dict(conf: ConfData) -> SiteServerConfig:
|
||||||
|
"""Create a SiteServerConfig from a dict."""
|
||||||
|
return SiteServerConfig(
|
||||||
|
server_host=conf.get(CONF_SERVER_HOST),
|
||||||
|
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),
|
||||||
|
ssl_profile=conf[CONF_SSL_PROFILE],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the HTTP API and debug interface."""
|
"""Set up the HTTP API and debug interface."""
|
||||||
conf: ConfData | None = config.get(DOMAIN)
|
conf: ConfData | None = config.get(DOMAIN)
|
||||||
|
@ -168,27 +266,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
if conf is None:
|
if conf is None:
|
||||||
conf = cast(ConfData, HTTP_SCHEMA({}))
|
conf = cast(ConfData, HTTP_SCHEMA({}))
|
||||||
|
|
||||||
server_host = conf.get(CONF_SERVER_HOST)
|
# configuration options that affect all TCPSites
|
||||||
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)
|
|
||||||
cors_origins = conf[CONF_CORS_ORIGINS]
|
cors_origins = conf[CONF_CORS_ORIGINS]
|
||||||
use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
|
use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
|
||||||
trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or []
|
trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or []
|
||||||
is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
|
is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
|
||||||
login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
|
login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
|
||||||
ssl_profile = conf[CONF_SSL_PROFILE]
|
|
||||||
|
site_configs: list[SiteServerConfig] = []
|
||||||
|
|
||||||
|
if server_cfg := conf.get(CONF_SERVERS):
|
||||||
|
servers = cast(list[ConfData], server_cfg)
|
||||||
|
site_configs = [_create_site_server_config_from_dict(cfg) for cfg in servers]
|
||||||
|
else:
|
||||||
|
site_configs = [_create_site_server_config_from_dict(conf)]
|
||||||
|
|
||||||
server = HomeAssistantHTTP(
|
server = HomeAssistantHTTP(
|
||||||
hass,
|
hass,
|
||||||
server_host=server_host,
|
site_configs=site_configs,
|
||||||
server_port=server_port,
|
|
||||||
ssl_certificate=ssl_certificate,
|
|
||||||
ssl_peer_certificate=ssl_peer_certificate,
|
|
||||||
ssl_key=ssl_key,
|
|
||||||
trusted_proxies=trusted_proxies,
|
trusted_proxies=trusted_proxies,
|
||||||
ssl_profile=ssl_profile,
|
|
||||||
)
|
)
|
||||||
await server.async_initialize(
|
await server.async_initialize(
|
||||||
cors_origins=cors_origins,
|
cors_origins=cors_origins,
|
||||||
|
@ -212,16 +308,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
async_when_setup_or_start(hass, "frontend", start_server)
|
async_when_setup_or_start(hass, "frontend", start_server)
|
||||||
|
|
||||||
hass.http = server
|
hass.http = server
|
||||||
|
primary_server_conf = site_configs[0]
|
||||||
local_ip = await async_get_source_ip(hass)
|
local_ip = await async_get_source_ip(hass)
|
||||||
|
|
||||||
host = local_ip
|
if primary_server_host := primary_server_conf.server_host:
|
||||||
if server_host is not None:
|
primary_host = primary_server_host[0]
|
||||||
# Assume the first server host name provided as API host
|
else:
|
||||||
host = server_host[0]
|
primary_host = local_ip
|
||||||
|
|
||||||
hass.config.api = ApiConfig(
|
hass.config.api = ApiConfig(
|
||||||
local_ip, host, server_port, ssl_certificate is not None
|
local_ip,
|
||||||
|
primary_host,
|
||||||
|
primary_server_conf.server_port,
|
||||||
|
all(site_config.ssl_certificate is not None for site_config in site_configs),
|
||||||
)
|
)
|
||||||
|
|
||||||
return True
|
return True
|
||||||
|
@ -286,13 +385,8 @@ class HomeAssistantHTTP:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
ssl_certificate: str | None,
|
site_configs: list[SiteServerConfig],
|
||||||
ssl_peer_certificate: str | None,
|
|
||||||
ssl_key: str | None,
|
|
||||||
server_host: list[str] | None,
|
|
||||||
server_port: int,
|
|
||||||
trusted_proxies: list[IPv4Network | IPv6Network],
|
trusted_proxies: list[IPv4Network | IPv6Network],
|
||||||
ssl_profile: str,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the HTTP Home Assistant server."""
|
"""Initialize the HTTP Home Assistant server."""
|
||||||
self.app = HomeAssistantApplication(
|
self.app = HomeAssistantApplication(
|
||||||
|
@ -304,16 +398,12 @@ class HomeAssistantHTTP:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.ssl_certificate = ssl_certificate
|
self.site_configs = site_configs
|
||||||
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.trusted_proxies = trusted_proxies
|
||||||
self.ssl_profile = ssl_profile
|
|
||||||
self.runner: web.AppRunner | None = None
|
self.runner: web.AppRunner | None = None
|
||||||
self.site: HomeAssistantTCPSite | None = None
|
self.sites: list[HomeAssistantTCPSite] = []
|
||||||
self.context: ssl.SSLContext | None = None
|
# For backwards compat
|
||||||
|
self.server_port: int = site_configs[0].server_port
|
||||||
|
|
||||||
async def async_initialize(
|
async def async_initialize(
|
||||||
self,
|
self,
|
||||||
|
@ -341,10 +431,8 @@ class HomeAssistantHTTP:
|
||||||
|
|
||||||
setup_cors(self.app, cors_origins)
|
setup_cors(self.app, cors_origins)
|
||||||
|
|
||||||
if self.ssl_certificate:
|
if any(site.ssl_certificate for site in self.site_configs):
|
||||||
self.context = await self.hass.async_add_executor_job(
|
await self.hass.async_add_executor_job(self._create_ssl_contexts)
|
||||||
self._create_ssl_context
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None:
|
def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None:
|
||||||
"""Register a view with the WSGI server.
|
"""Register a view with the WSGI server.
|
||||||
|
@ -417,53 +505,56 @@ class HomeAssistantHTTP:
|
||||||
self.app.router.add_route("GET", url_path, serve_file)
|
self.app.router.add_route("GET", url_path, serve_file)
|
||||||
)
|
)
|
||||||
|
|
||||||
def _create_ssl_context(self) -> ssl.SSLContext | None:
|
def _create_ssl_contexts(self) -> None:
|
||||||
context: ssl.SSLContext | None = None
|
for site in self.site_configs:
|
||||||
assert self.ssl_certificate is not None
|
context: ssl.SSLContext | None = None
|
||||||
try:
|
if site.ssl_certificate is None:
|
||||||
if self.ssl_profile == SSL_INTERMEDIATE:
|
continue
|
||||||
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.safe_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:
|
try:
|
||||||
context = self._create_emergency_ssl_context()
|
if site.ssl_profile == SSL_INTERMEDIATE:
|
||||||
except OSError as error2:
|
context = ssl_util.server_context_intermediate()
|
||||||
|
else:
|
||||||
|
context = ssl_util.server_context_modern()
|
||||||
|
context.load_cert_chain(site.ssl_certificate, site.ssl_key)
|
||||||
|
except OSError as error:
|
||||||
|
if not self.hass.config.safe_mode:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
f"Could not use SSL certificate from {site.ssl_certificate}:"
|
||||||
|
f" {error}"
|
||||||
|
) from error
|
||||||
_LOGGER.error(
|
_LOGGER.error(
|
||||||
"Could not create an emergency self signed ssl certificate: %s",
|
"Could not read SSL certificate from %s: %s",
|
||||||
error2,
|
site.ssl_certificate,
|
||||||
|
error,
|
||||||
)
|
)
|
||||||
context = None
|
try:
|
||||||
else:
|
context = self._create_emergency_ssl_context()
|
||||||
_LOGGER.critical(
|
except OSError as os_error:
|
||||||
"Home Assistant is running in safe mode with an emergency self"
|
_LOGGER.error(
|
||||||
" signed ssl certificate because the configured SSL certificate was"
|
"Could not create an emergency self signed ssl certificate: %s",
|
||||||
" not usable"
|
os_error,
|
||||||
)
|
)
|
||||||
return context
|
continue
|
||||||
|
else:
|
||||||
|
_LOGGER.critical(
|
||||||
|
"Home Assistant is running in safe mode with an emergency self"
|
||||||
|
" signed ssl certificate because the configured SSL certificate was"
|
||||||
|
" not usable"
|
||||||
|
)
|
||||||
|
site.ssl_context = context
|
||||||
|
continue
|
||||||
|
|
||||||
if self.ssl_peer_certificate:
|
if site.ssl_peer_certificate:
|
||||||
if context is None:
|
if context is None:
|
||||||
raise HomeAssistantError(
|
raise HomeAssistantError(
|
||||||
"Failed to create ssl context, no fallback available because a peer"
|
"Failed to create ssl context, no fallback available because a peer"
|
||||||
" certificate is required."
|
" certificate is required."
|
||||||
)
|
)
|
||||||
|
|
||||||
context.verify_mode = ssl.CERT_REQUIRED
|
context.verify_mode = ssl.CERT_REQUIRED
|
||||||
context.load_verify_locations(self.ssl_peer_certificate)
|
context.load_verify_locations(site.ssl_peer_certificate)
|
||||||
|
|
||||||
return context
|
site.ssl_context = context
|
||||||
|
|
||||||
def _create_emergency_ssl_context(self) -> ssl.SSLContext:
|
def _create_emergency_ssl_context(self) -> ssl.SSLContext:
|
||||||
"""Create an emergency ssl certificate so we can still startup."""
|
"""Create an emergency ssl certificate so we can still startup."""
|
||||||
|
@ -527,22 +618,43 @@ class HomeAssistantHTTP:
|
||||||
)
|
)
|
||||||
await self.runner.setup()
|
await self.runner.setup()
|
||||||
|
|
||||||
self.site = HomeAssistantTCPSite(
|
sites = [
|
||||||
self.runner, self.server_host, self.server_port, ssl_context=self.context
|
HomeAssistantTCPSite(
|
||||||
)
|
self.runner,
|
||||||
try:
|
site_config.server_host,
|
||||||
await self.site.start()
|
site_config.server_port,
|
||||||
except OSError as error:
|
ssl_context=site_config.ssl_context,
|
||||||
_LOGGER.error(
|
|
||||||
"Failed to create HTTP server at port %d: %s", self.server_port, error
|
|
||||||
)
|
)
|
||||||
|
for site_config in self.site_configs
|
||||||
|
]
|
||||||
|
|
||||||
_LOGGER.info("Now listening on port %d", self.server_port)
|
results = await asyncio.gather(
|
||||||
|
*(site.start() for site in sites), return_exceptions=True
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, result in enumerate(results):
|
||||||
|
site_config = self.site_configs[idx]
|
||||||
|
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
_LOGGER.error(
|
||||||
|
"Failed to create HTTP server at port %s:%d: %s",
|
||||||
|
site_config.server_host,
|
||||||
|
site_config.server_port,
|
||||||
|
result,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.sites.append(sites[idx])
|
||||||
|
_LOGGER.info(
|
||||||
|
"Now listening on %s:%d",
|
||||||
|
site_config.server_host,
|
||||||
|
site_config.server_port,
|
||||||
|
)
|
||||||
|
|
||||||
async def stop(self) -> None:
|
async def stop(self) -> None:
|
||||||
"""Stop the aiohttp server."""
|
"""Stop the aiohttp server."""
|
||||||
if self.site is not None:
|
if self.sites:
|
||||||
await self.site.stop()
|
await asyncio.gather(*(site.stop() for site in self.sites))
|
||||||
if self.runner is not None:
|
if self.runner is not None:
|
||||||
await self.runner.cleanup()
|
await self.runner.cleanup()
|
||||||
|
|
||||||
|
|
|
@ -199,7 +199,7 @@ async def test_send_usage(
|
||||||
"""Test send usage preferences are defined."""
|
"""Test send usage preferences are defined."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate=None)
|
hass.config.api = Mock(use_ssl=False)
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
|
|
||||||
assert analytics.preferences[ATTR_BASE]
|
assert analytics.preferences[ATTR_BASE]
|
||||||
|
@ -222,7 +222,7 @@ async def test_send_usage_with_supervisor(
|
||||||
"""Test send usage with supervisor preferences are defined."""
|
"""Test send usage with supervisor preferences are defined."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate=None)
|
hass.config.api = Mock(use_ssl=False)
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
assert analytics.preferences[ATTR_BASE]
|
assert analytics.preferences[ATTR_BASE]
|
||||||
assert analytics.preferences[ATTR_USAGE]
|
assert analytics.preferences[ATTR_USAGE]
|
||||||
|
@ -417,7 +417,7 @@ async def test_custom_integrations(
|
||||||
"""Test sending custom integrations."""
|
"""Test sending custom integrations."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate=None)
|
hass.config.api = Mock(use_ssl=False)
|
||||||
assert await async_setup_component(hass, "test_package", {"test_package": {}})
|
assert await async_setup_component(hass, "test_package", {"test_package": {}})
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
|
|
||||||
|
@ -491,7 +491,7 @@ async def test_send_with_no_energy(
|
||||||
"""Test send base preferences are defined."""
|
"""Test send base preferences are defined."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate=None)
|
hass.config.api = Mock(use_ssl=False)
|
||||||
|
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
|
|
||||||
|
@ -565,7 +565,8 @@ async def test_send_usage_with_certificate(
|
||||||
"""Test send usage preferences with certificate."""
|
"""Test send usage preferences with certificate."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate="/some/path/to/cert.pem")
|
|
||||||
|
hass.config.api = Mock(use_ssl=True)
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
|
|
||||||
assert analytics.preferences[ATTR_BASE]
|
assert analytics.preferences[ATTR_BASE]
|
||||||
|
@ -586,7 +587,7 @@ async def test_send_with_recorder(
|
||||||
"""Test recorder information."""
|
"""Test recorder information."""
|
||||||
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200)
|
||||||
analytics = Analytics(hass)
|
analytics = Analytics(hass)
|
||||||
hass.http = Mock(ssl_certificate="/some/path/to/cert.pem")
|
hass.config.api = Mock(use_ssl=True)
|
||||||
|
|
||||||
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
await analytics.save_preferences({ATTR_BASE: True, ATTR_USAGE: True})
|
||||||
|
|
||||||
|
|
|
@ -288,25 +288,25 @@ async def test_emergency_ssl_certificate_when_invalid(
|
||||||
)
|
)
|
||||||
|
|
||||||
hass.config.safe_mode = True
|
hass.config.safe_mode = True
|
||||||
assert (
|
with patch.object(hass.loop, "create_server"):
|
||||||
await async_setup_component(
|
assert (
|
||||||
hass,
|
await async_setup_component(
|
||||||
"http",
|
hass,
|
||||||
{
|
"http",
|
||||||
"http": {"ssl_certificate": cert_path, "ssl_key": key_path},
|
{
|
||||||
},
|
"http": {"ssl_certificate": cert_path, "ssl_key": key_path},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
await hass.async_start()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
assert (
|
||||||
|
"Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable"
|
||||||
|
in caplog.text
|
||||||
)
|
)
|
||||||
is True
|
|
||||||
)
|
|
||||||
|
|
||||||
await hass.async_start()
|
assert len(hass.http.sites) == 1
|
||||||
await hass.async_block_till_done()
|
|
||||||
assert (
|
|
||||||
"Home Assistant is running in safe mode with an emergency self signed ssl certificate because the configured SSL certificate was not usable"
|
|
||||||
in caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
assert hass.http.site is not None
|
|
||||||
|
|
||||||
|
|
||||||
async def test_emergency_ssl_certificate_not_used_when_not_safe_mode(
|
async def test_emergency_ssl_certificate_not_used_when_not_safe_mode(
|
||||||
|
@ -340,7 +340,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails(
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.http.get_url", side_effect=NoURLAvailableError
|
"homeassistant.components.http.get_url", side_effect=NoURLAvailableError
|
||||||
) as mock_get_url:
|
) as mock_get_url, patch.object(hass.loop, "create_server"):
|
||||||
assert (
|
assert (
|
||||||
await async_setup_component(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
|
@ -360,7 +360,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails(
|
||||||
in caplog.text
|
in caplog.text
|
||||||
)
|
)
|
||||||
|
|
||||||
assert hass.http.site is not None
|
assert len(hass.http.sites) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_invalid_ssl_and_cannot_create_emergency_cert(
|
async def test_invalid_ssl_and_cannot_create_emergency_cert(
|
||||||
|
@ -375,7 +375,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert(
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
||||||
) as mock_builder:
|
) as mock_builder, patch.object(hass.loop, "create_server"):
|
||||||
assert (
|
assert (
|
||||||
await async_setup_component(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
|
@ -391,7 +391,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert(
|
||||||
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
||||||
assert len(mock_builder.mock_calls) == 1
|
assert len(mock_builder.mock_calls) == 1
|
||||||
|
|
||||||
assert hass.http.site is not None
|
assert len(hass.http.sites) == 1
|
||||||
|
|
||||||
|
|
||||||
async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
||||||
|
@ -412,7 +412,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
||||||
|
|
||||||
with patch(
|
with patch(
|
||||||
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
||||||
) as mock_builder:
|
) as mock_builder, patch.object(hass.loop, "create_server"):
|
||||||
assert (
|
assert (
|
||||||
await async_setup_component(
|
await async_setup_component(
|
||||||
hass,
|
hass,
|
||||||
|
@ -425,11 +425,12 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
is False
|
is True
|
||||||
)
|
)
|
||||||
await hass.async_start()
|
await hass.async_start()
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
||||||
|
# We should fallback to non-ssl so they can still access the system
|
||||||
assert len(mock_builder.mock_calls) == 1
|
assert len(mock_builder.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@ -518,3 +519,100 @@ async def test_hass_access_logger_at_info_level(
|
||||||
test_logger.setLevel(logging.WARNING)
|
test_logger.setLevel(logging.WARNING)
|
||||||
logger.log(mock_request, response, time.time())
|
logger.log(mock_request, response, time.time())
|
||||||
assert "42" not in caplog.text
|
assert "42" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
async def test_multiple_ssl_profiles(hass: HomeAssistant, tmp_path: Path) -> None:
|
||||||
|
"""Test different ssl profiles on different ports."""
|
||||||
|
|
||||||
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
||||||
|
_setup_empty_ssl_pem_files, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||||
|
"homeassistant.util.ssl.server_context_modern",
|
||||||
|
side_effect=server_context_modern,
|
||||||
|
) as mock_server_context_modern, patch(
|
||||||
|
"homeassistant.util.ssl.server_context_intermediate",
|
||||||
|
side_effect=server_context_intermediate,
|
||||||
|
) as mock_server_context_intermediate, patch.object(
|
||||||
|
hass.loop, "create_server"
|
||||||
|
) as mock_create_server:
|
||||||
|
assert (
|
||||||
|
await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"http",
|
||||||
|
{
|
||||||
|
"http": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"ssl_certificate": cert_path,
|
||||||
|
"ssl_key": key_path,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ssl_profile": "intermediate",
|
||||||
|
"ssl_certificate": cert_path,
|
||||||
|
"ssl_key": key_path,
|
||||||
|
"server_port": "8124",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
await hass.async_start()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_server_context_modern.mock_calls) == 1
|
||||||
|
assert len(mock_server_context_intermediate.mock_calls) == 1
|
||||||
|
assert len(hass.http.sites) == 2
|
||||||
|
assert len(mock_create_server.mock_calls) == 2
|
||||||
|
# If all sites are using ssl use_ssl should be True
|
||||||
|
# for backwards compatibility
|
||||||
|
assert hass.config.api.use_ssl is True
|
||||||
|
|
||||||
|
|
||||||
|
async def test_ssl_for_external_no_ssl_internal(
|
||||||
|
hass: HomeAssistant, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Test ssl for external and no ssl for internal."""
|
||||||
|
|
||||||
|
cert_path, key_path, _ = await hass.async_add_executor_job(
|
||||||
|
_setup_empty_ssl_pem_files, tmp_path
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||||
|
"homeassistant.util.ssl.server_context_modern",
|
||||||
|
side_effect=server_context_modern,
|
||||||
|
) as mock_server_context_modern, patch.object(
|
||||||
|
hass.loop, "create_server"
|
||||||
|
) as mock_create_server:
|
||||||
|
assert (
|
||||||
|
await async_setup_component(
|
||||||
|
hass,
|
||||||
|
"http",
|
||||||
|
{
|
||||||
|
"http": {
|
||||||
|
"servers": [
|
||||||
|
{
|
||||||
|
"ssl_certificate": cert_path,
|
||||||
|
"ssl_key": key_path,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"server_port": "8124",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
await hass.async_start()
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert len(mock_server_context_modern.mock_calls) == 1
|
||||||
|
assert len(mock_create_server.mock_calls) == 2
|
||||||
|
assert len(hass.http.sites) == 2
|
||||||
|
# If there is at least one site not using ssl, use_ssl
|
||||||
|
# should be False for backwards compatibility
|
||||||
|
assert hass.config.api.use_ssl is False
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue