Compare commits

...
Sign in to create a new pull request.

27 commits

Author SHA1 Message Date
J. Nick Koston
74c518ce79
lint 2023-05-20 14:16:55 -05:00
J. Nick Koston
91b5b4de64
Merge branch 'dev' into multi_site 2023-05-20 10:22:38 -05:00
J. Nick Koston
d5c88dd4f5
Update homeassistant/components/http/__init__.py
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2023-05-20 10:22:27 -05:00
J. Nick Koston
bf18897b0f
Update homeassistant/components/http/__init__.py
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2023-05-20 10:22:22 -05:00
J. Nick Koston
a3cf66c98c
Update homeassistant/components/http/__init__.py
Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
2023-05-20 10:22:16 -05:00
J. Nick Koston
84aeaf7960
lint 2023-05-19 17:08:25 -05:00
J. Nick Koston
2a8a1bdc62
nicer message 2023-05-19 16:53:36 -05:00
J. Nick Koston
240f0cc490
moved 2023-05-19 16:43:36 -05:00
J. Nick Koston
82a4036965
moved 2023-05-19 16:40:18 -05:00
J. Nick Koston
b5220c4149
dep 2023-05-19 16:33:43 -05:00
J. Nick Koston
65ddb464c3
back compat 2023-05-19 15:47:00 -05:00
J. Nick Koston
775dcebeb9
Revert "back compat"
This reverts commit 10bf6db3a2.
2023-05-19 15:41:45 -05:00
J. Nick Koston
10bf6db3a2
back compat 2023-05-19 15:29:29 -05:00
J. Nick Koston
484c9d4d1f
fix 2023-05-19 15:26:59 -05:00
J. Nick Koston
e7c95ee088
fixes 2023-05-19 15:24:55 -05:00
J. Nick Koston
18ef390dca
make sure we actually get multiple servers 2023-05-19 14:14:03 -05:00
J. Nick Koston
c34aa0e23b
fix test 2023-05-19 14:12:42 -05:00
J. Nick Koston
2820a63f9f
debug 2023-05-19 13:59:05 -05:00
J. Nick Koston
11a2e30d22
debug 2023-05-19 13:58:40 -05:00
J. Nick Koston
85dd053be8
back compat 2023-05-19 13:44:04 -05:00
J. Nick Koston
64424f672c
back compat 2023-05-19 13:43:41 -05:00
J. Nick Koston
0652dda106
coverage 2023-05-19 13:34:04 -05:00
J. Nick Koston
5c3a76053d
tweak 2023-05-19 13:28:34 -05:00
J. Nick Koston
00e784d0c4
adjust 2023-05-19 13:24:21 -05:00
J. Nick Koston
5c0614eb17
fix tests 2023-05-19 13:10:37 -05:00
J. Nick Koston
cb06b5af78
Support multiple sites on the webserver
aiohttp already supports multiple TCPSites for a single server

This allows Home Assistant to listen on multiple ports with
different ssl configuration per port so that devices that
need a less secure configuration can be isolated to a single
port
2023-05-19 13:06:10 -05:00
J. Nick Koston
d8fa06de67
Support multiple sites on the webserver
aiohttp already supports multiple TCPSites for a single server

This allows Home Assistant to listen on multiple ports with
different ssl configuration per port so that devices that
need a less secure configuration can be isolated to a single
port.

The motivation for this is that I did not want to downgrade
my entire ssl configuration for a single device that needs
a less secure configuration, or to support an ONVIF camera
that does not do SSL
2023-05-19 13:01:53 -05:00
4 changed files with 341 additions and 128 deletions

View file

@ -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:

View file

@ -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()

View file

@ -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})

View file

@ -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