diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index 9419f00e41e..a32043b4412 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -247,7 +247,9 @@ class Analytics: ) 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_CUSTOM_INTEGRATIONS] = custom_integrations if supervisor_info is not None: diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 2d306ba5ee5..72f4400f11c 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -2,6 +2,9 @@ from __future__ import annotations import asyncio +from collections.abc import Callable +import contextlib +from dataclasses import dataclass import datetime from ipaddress import IPv4Network, IPv6Network, ip_network import logging @@ -87,18 +90,90 @@ STORAGE_KEY: Final = DOMAIN STORAGE_VERSION: Final = 1 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( 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.Optional(CONF_SERVER_HOST): vol.All( - cv.ensure_list, vol.Length(min=1), [cv.string] - ), - vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, + **SERVER_SCHEMA_WITHOUT_PORT, + **EXCLUSIVE_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.Exclusive( + CONF_SERVERS, SERVERS_GROUP, msg=SERVERS_EXCLUSIVE_MESSAGE + ): 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( cv.ensure_list, [cv.string] ), @@ -110,13 +185,11 @@ HTTP_SCHEMA: Final = vol.All( 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] - ), } ), ) + CONFIG_SCHEMA: Final = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA) @@ -161,6 +234,31 @@ class ApiConfig: 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: """Set up the HTTP API and debug interface.""" conf: ConfData | None = config.get(DOMAIN) @@ -168,27 +266,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if conf is None: conf = cast(ConfData, HTTP_SCHEMA({})) - 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) + # configuration options that affect all TCPSites cors_origins = conf[CONF_CORS_ORIGINS] use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) trusted_proxies = conf.get(CONF_TRUSTED_PROXIES) or [] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] 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( hass, - server_host=server_host, - server_port=server_port, - ssl_certificate=ssl_certificate, - ssl_peer_certificate=ssl_peer_certificate, - ssl_key=ssl_key, + site_configs=site_configs, trusted_proxies=trusted_proxies, - ssl_profile=ssl_profile, ) await server.async_initialize( 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) hass.http = server - + primary_server_conf = site_configs[0] local_ip = await async_get_source_ip(hass) - host = local_ip - if server_host is not None: - # Assume the first server host name provided as API host - host = server_host[0] + if primary_server_host := primary_server_conf.server_host: + primary_host = primary_server_host[0] + else: + primary_host = local_ip 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 @@ -286,13 +385,8 @@ class HomeAssistantHTTP: 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, + site_configs: list[SiteServerConfig], trusted_proxies: list[IPv4Network | IPv6Network], - ssl_profile: str, ) -> None: """Initialize the HTTP Home Assistant server.""" self.app = HomeAssistantApplication( @@ -304,16 +398,12 @@ class HomeAssistantHTTP: }, ) 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.site_configs = site_configs self.trusted_proxies = trusted_proxies - self.ssl_profile = ssl_profile self.runner: web.AppRunner | None = None - self.site: HomeAssistantTCPSite | None = None - self.context: ssl.SSLContext | None = None + self.sites: list[HomeAssistantTCPSite] = [] + # For backwards compat + self.server_port: int = site_configs[0].server_port async def async_initialize( self, @@ -341,10 +431,8 @@ class HomeAssistantHTTP: setup_cors(self.app, cors_origins) - if self.ssl_certificate: - self.context = await self.hass.async_add_executor_job( - self._create_ssl_context - ) + if any(site.ssl_certificate for site in self.site_configs): + await self.hass.async_add_executor_job(self._create_ssl_contexts) def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None: """Register a view with the WSGI server. @@ -417,53 +505,56 @@ class HomeAssistantHTTP: self.app.router.add_route("GET", url_path, serve_file) ) - 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.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, - ) + def _create_ssl_contexts(self) -> None: + for site in self.site_configs: + context: ssl.SSLContext | None = None + if site.ssl_certificate is None: + continue try: - context = self._create_emergency_ssl_context() - except OSError as error2: + if site.ssl_profile == SSL_INTERMEDIATE: + 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( - "Could not create an emergency self signed ssl certificate: %s", - error2, + "Could not read SSL certificate from %s: %s", + site.ssl_certificate, + error, ) - context = None - 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" - ) - return context + try: + context = self._create_emergency_ssl_context() + except OSError as os_error: + _LOGGER.error( + "Could not create an emergency self signed ssl certificate: %s", + os_error, + ) + 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 context is None: - raise HomeAssistantError( - "Failed to create ssl context, no fallback available because a peer" - " certificate is required." - ) + if site.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 - context.load_verify_locations(self.ssl_peer_certificate) + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(site.ssl_peer_certificate) - return context + site.ssl_context = context def _create_emergency_ssl_context(self) -> ssl.SSLContext: """Create an emergency ssl certificate so we can still startup.""" @@ -527,22 +618,43 @@ class HomeAssistantHTTP: ) await self.runner.setup() - self.site = HomeAssistantTCPSite( - self.runner, self.server_host, self.server_port, ssl_context=self.context - ) - try: - await self.site.start() - except OSError as error: - _LOGGER.error( - "Failed to create HTTP server at port %d: %s", self.server_port, error + sites = [ + HomeAssistantTCPSite( + self.runner, + site_config.server_host, + site_config.server_port, + ssl_context=site_config.ssl_context, ) + 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: """Stop the aiohttp server.""" - if self.site is not None: - await self.site.stop() + if self.sites: + await asyncio.gather(*(site.stop() for site in self.sites)) if self.runner is not None: await self.runner.cleanup() diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 412553e81cc..36ca469be96 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -199,7 +199,7 @@ async def test_send_usage( """Test send usage preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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}) assert analytics.preferences[ATTR_BASE] @@ -222,7 +222,7 @@ async def test_send_usage_with_supervisor( """Test send usage with supervisor preferences are defined.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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}) assert analytics.preferences[ATTR_BASE] assert analytics.preferences[ATTR_USAGE] @@ -417,7 +417,7 @@ async def test_custom_integrations( """Test sending custom integrations.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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": {}}) 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.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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}) @@ -565,7 +565,8 @@ async def test_send_usage_with_certificate( """Test send usage preferences with certificate.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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}) assert analytics.preferences[ATTR_BASE] @@ -586,7 +587,7 @@ async def test_send_with_recorder( """Test recorder information.""" aioclient_mock.post(ANALYTICS_ENDPOINT_URL, status=200) 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}) diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index 8f9fff79580..5f9f3a047df 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -288,25 +288,25 @@ async def test_emergency_ssl_certificate_when_invalid( ) hass.config.safe_mode = True - assert ( - await async_setup_component( - hass, - "http", - { - "http": {"ssl_certificate": cert_path, "ssl_key": key_path}, - }, + with patch.object(hass.loop, "create_server"): + assert ( + await async_setup_component( + hass, + "http", + { + "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() - 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 + assert len(hass.http.sites) == 1 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( "homeassistant.components.http.get_url", side_effect=NoURLAvailableError - ) as mock_get_url: + ) as mock_get_url, patch.object(hass.loop, "create_server"): assert ( await async_setup_component( hass, @@ -360,7 +360,7 @@ async def test_emergency_ssl_certificate_when_invalid_get_url_fails( 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( @@ -375,7 +375,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert( with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError - ) as mock_builder: + ) as mock_builder, patch.object(hass.loop, "create_server"): assert ( await async_setup_component( 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 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( @@ -412,7 +412,7 @@ async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert( with patch( "homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError - ) as mock_builder: + ) as mock_builder, patch.object(hass.loop, "create_server"): assert ( await async_setup_component( 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_block_till_done() 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 @@ -518,3 +519,100 @@ async def test_hass_access_logger_at_info_level( test_logger.setLevel(logging.WARNING) logger.log(mock_request, response, time.time()) 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