mirror of
https://github.com/home-assistant/core.git
synced 2025-07-21 12:17:07 +00:00
Startup with an emergency self signed cert if the ssl certificate cannot be loaded (#66707)
This commit is contained in:
parent
0269ad4738
commit
3bf2be1765
@ -158,8 +158,11 @@ async def async_setup_hass(
|
||||
|
||||
safe_mode = True
|
||||
old_config = hass.config
|
||||
old_logging = hass.data.get(DATA_LOGGING)
|
||||
|
||||
hass = core.HomeAssistant()
|
||||
if old_logging:
|
||||
hass.data[DATA_LOGGING] = old_logging
|
||||
hass.config.skip_pip = old_config.skip_pip
|
||||
hass.config.internal_url = old_config.internal_url
|
||||
hass.config.external_url = old_config.external_url
|
||||
|
@ -1,22 +1,31 @@
|
||||
"""Support to serve the Home Assistant API as WSGI application."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from ipaddress import IPv4Network, IPv6Network, ip_network
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
from tempfile import NamedTemporaryFile
|
||||
from typing import Any, Final, Optional, TypedDict, Union, cast
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.typedefs import StrOrURL
|
||||
from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection
|
||||
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 EVENT_HOMEASSISTANT_STOP, SERVER_PORT
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import storage
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import bind_hass
|
||||
from homeassistant.setup import async_start_setup, async_when_setup_or_start
|
||||
@ -231,6 +240,7 @@ class HomeAssistantHTTP:
|
||||
self.ssl_profile = ssl_profile
|
||||
self.runner: web.AppRunner | None = None
|
||||
self.site: HomeAssistantTCPSite | None = None
|
||||
self.context: ssl.SSLContext | None = None
|
||||
|
||||
async def async_initialize(
|
||||
self,
|
||||
@ -258,6 +268,11 @@ 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
|
||||
)
|
||||
|
||||
def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None:
|
||||
"""Register a view with the WSGI server.
|
||||
|
||||
@ -329,35 +344,100 @@ class HomeAssistantHTTP:
|
||||
self.app.router.add_route("GET", url_path, serve_file)
|
||||
)
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the aiohttp server."""
|
||||
context: ssl.SSLContext | None
|
||||
if self.ssl_certificate:
|
||||
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}: {error}"
|
||||
) from error
|
||||
_LOGGER.error(
|
||||
"Could not read SSL certificate from %s: %s",
|
||||
self.ssl_certificate,
|
||||
error,
|
||||
)
|
||||
try:
|
||||
if self.ssl_profile == SSL_INTERMEDIATE:
|
||||
context = ssl_util.server_context_intermediate()
|
||||
else:
|
||||
context = ssl_util.server_context_modern()
|
||||
await self.hass.async_add_executor_job(
|
||||
context.load_cert_chain, self.ssl_certificate, self.ssl_key
|
||||
)
|
||||
context = self._create_emergency_ssl_context()
|
||||
except OSError as error:
|
||||
_LOGGER.error(
|
||||
"Could not read SSL certificate from %s: %s",
|
||||
self.ssl_certificate,
|
||||
"Could not create an emergency self signed ssl certificate: %s",
|
||||
error,
|
||||
)
|
||||
return
|
||||
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
|
||||
|
||||
if self.ssl_peer_certificate:
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
await self.hass.async_add_executor_job(
|
||||
context.load_verify_locations, self.ssl_peer_certificate
|
||||
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."
|
||||
)
|
||||
|
||||
else:
|
||||
context = None
|
||||
context.verify_mode = ssl.CERT_REQUIRED
|
||||
context.load_verify_locations(self.ssl_peer_certificate)
|
||||
|
||||
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),
|
||||
]
|
||||
)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(subject)
|
||||
.issuer_name(issuer)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(datetime.datetime.utcnow())
|
||||
.not_valid_after(datetime.datetime.utcnow() + 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 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.
|
||||
@ -369,7 +449,7 @@ class HomeAssistantHTTP:
|
||||
await self.runner.setup()
|
||||
|
||||
self.site = HomeAssistantTCPSite(
|
||||
self.runner, self.server_host, self.server_port, ssl_context=context
|
||||
self.runner, self.server_host, self.server_port, ssl_context=self.context
|
||||
)
|
||||
try:
|
||||
await self.site.start()
|
||||
|
@ -3,11 +3,13 @@ from datetime import timedelta
|
||||
from http import HTTPStatus
|
||||
from ipaddress import ip_network
|
||||
import logging
|
||||
import pathlib
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import homeassistant.components.http as http
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.ssl import server_context_intermediate, server_context_modern
|
||||
@ -15,6 +17,26 @@ from homeassistant.util.ssl import server_context_intermediate, server_context_m
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
def _setup_broken_ssl_pem_files(tmpdir):
|
||||
test_dir = tmpdir.mkdir("test_broken_ssl")
|
||||
cert_path = pathlib.Path(test_dir) / "cert.pem"
|
||||
cert_path.write_text("garbage")
|
||||
key_path = pathlib.Path(test_dir) / "key.pem"
|
||||
key_path.write_text("garbage")
|
||||
return cert_path, key_path
|
||||
|
||||
|
||||
def _setup_empty_ssl_pem_files(tmpdir):
|
||||
test_dir = tmpdir.mkdir("test_empty_ssl")
|
||||
cert_path = pathlib.Path(test_dir) / "cert.pem"
|
||||
cert_path.write_text("-")
|
||||
peer_cert_path = pathlib.Path(test_dir) / "peer_cert.pem"
|
||||
peer_cert_path.write_text("-")
|
||||
key_path = pathlib.Path(test_dir) / "key.pem"
|
||||
key_path.write_text("-")
|
||||
return cert_path, key_path, peer_cert_path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_stack():
|
||||
"""Mock extract stack."""
|
||||
@ -118,62 +140,278 @@ async def test_proxy_config_only_trust_proxies(hass):
|
||||
)
|
||||
|
||||
|
||||
async def test_ssl_profile_defaults_modern(hass):
|
||||
async def test_ssl_profile_defaults_modern(hass, tmpdir):
|
||||
"""Test default ssl profile."""
|
||||
assert await async_setup_component(hass, "http", {}) is True
|
||||
|
||||
hass.http.ssl_certificate = "bla"
|
||||
cert_path, key_path, _ = await hass.async_add_executor_job(
|
||||
_setup_empty_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||
"homeassistant.util.ssl.server_context_modern",
|
||||
side_effect=server_context_modern,
|
||||
) as mock_context:
|
||||
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 len(mock_context.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_ssl_profile_change_intermediate(hass):
|
||||
async def test_ssl_profile_change_intermediate(hass, tmpdir):
|
||||
"""Test setting ssl profile to intermediate."""
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass, "http", {"http": {"ssl_profile": "intermediate"}}
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
hass.http.ssl_certificate = "bla"
|
||||
cert_path, key_path, _ = await hass.async_add_executor_job(
|
||||
_setup_empty_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||
"homeassistant.util.ssl.server_context_intermediate",
|
||||
side_effect=server_context_intermediate,
|
||||
) as mock_context:
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"http",
|
||||
{
|
||||
"http": {
|
||||
"ssl_profile": "intermediate",
|
||||
"ssl_certificate": cert_path,
|
||||
"ssl_key": key_path,
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_context.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_ssl_profile_change_modern(hass):
|
||||
async def test_ssl_profile_change_modern(hass, tmpdir):
|
||||
"""Test setting ssl profile to modern."""
|
||||
assert (
|
||||
await async_setup_component(hass, "http", {"http": {"ssl_profile": "modern"}})
|
||||
is True
|
||||
)
|
||||
|
||||
hass.http.ssl_certificate = "bla"
|
||||
cert_path, key_path, _ = await hass.async_add_executor_job(
|
||||
_setup_empty_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||
"homeassistant.util.ssl.server_context_modern",
|
||||
side_effect=server_context_modern,
|
||||
) as mock_context:
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"http",
|
||||
{
|
||||
"http": {
|
||||
"ssl_profile": "modern",
|
||||
"ssl_certificate": cert_path,
|
||||
"ssl_key": key_path,
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_context.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_peer_cert(hass, tmpdir):
|
||||
"""Test required peer cert."""
|
||||
cert_path, key_path, peer_cert_path = await hass.async_add_executor_job(
|
||||
_setup_empty_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
with patch("ssl.SSLContext.load_cert_chain"), patch(
|
||||
"ssl.SSLContext.load_verify_locations"
|
||||
) as mock_load_verify_locations, patch(
|
||||
"homeassistant.util.ssl.server_context_modern",
|
||||
side_effect=server_context_modern,
|
||||
) as mock_context:
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"http",
|
||||
{
|
||||
"http": {
|
||||
"ssl_peer_certificate": peer_cert_path,
|
||||
"ssl_profile": "modern",
|
||||
"ssl_certificate": cert_path,
|
||||
"ssl_key": key_path,
|
||||
}
|
||||
},
|
||||
)
|
||||
is True
|
||||
)
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert len(mock_context.mock_calls) == 1
|
||||
assert len(mock_load_verify_locations.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_emergency_ssl_certificate_when_invalid(hass, tmpdir, caplog):
|
||||
"""Test http can startup with an emergency self signed cert when the current one is broken."""
|
||||
|
||||
cert_path, key_path = await hass.async_add_executor_job(
|
||||
_setup_broken_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
hass.config.safe_mode = True
|
||||
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
|
||||
)
|
||||
|
||||
assert hass.http.site is not None
|
||||
|
||||
|
||||
async def test_emergency_ssl_certificate_not_used_when_not_safe_mode(
|
||||
hass, tmpdir, caplog
|
||||
):
|
||||
"""Test an emergency cert is only used in safe mode."""
|
||||
|
||||
cert_path, key_path = await hass.async_add_executor_job(
|
||||
_setup_broken_ssl_pem_files, tmpdir
|
||||
)
|
||||
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass, "http", {"http": {"ssl_certificate": cert_path, "ssl_key": key_path}}
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
|
||||
async def test_emergency_ssl_certificate_when_invalid_get_url_fails(
|
||||
hass, tmpdir, caplog
|
||||
):
|
||||
"""Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.
|
||||
|
||||
Ensure we can still start of we cannot determine the external url as well.
|
||||
"""
|
||||
cert_path, key_path = await hass.async_add_executor_job(
|
||||
_setup_broken_ssl_pem_files, tmpdir
|
||||
)
|
||||
hass.config.safe_mode = True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.get_url", side_effect=NoURLAvailableError
|
||||
) as mock_get_url:
|
||||
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 len(mock_get_url.mock_calls) == 1
|
||||
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_invalid_ssl_and_cannot_create_emergency_cert(hass, tmpdir, caplog):
|
||||
"""Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken."""
|
||||
|
||||
cert_path, key_path = await hass.async_add_executor_job(
|
||||
_setup_broken_ssl_pem_files, tmpdir
|
||||
)
|
||||
hass.config.safe_mode = True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
||||
) as mock_builder:
|
||||
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 "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
|
||||
|
||||
|
||||
async def test_invalid_ssl_and_cannot_create_emergency_cert_with_ssl_peer_cert(
|
||||
hass, tmpdir, caplog
|
||||
):
|
||||
"""Test http falls back to no ssl when an emergency cert cannot be created when the configured one is broken.
|
||||
|
||||
When there is a peer cert verification and we cannot create
|
||||
an emergency cert (probably will never happen since this means
|
||||
the system is very broken), we do not want to startup http
|
||||
as it would allow connections that are not verified by the cert.
|
||||
"""
|
||||
|
||||
cert_path, key_path = await hass.async_add_executor_job(
|
||||
_setup_broken_ssl_pem_files, tmpdir
|
||||
)
|
||||
hass.config.safe_mode = True
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.http.x509.CertificateBuilder", side_effect=OSError
|
||||
) as mock_builder:
|
||||
assert (
|
||||
await async_setup_component(
|
||||
hass,
|
||||
"http",
|
||||
{
|
||||
"http": {
|
||||
"ssl_certificate": cert_path,
|
||||
"ssl_key": key_path,
|
||||
"ssl_peer_certificate": cert_path,
|
||||
},
|
||||
},
|
||||
)
|
||||
is False
|
||||
)
|
||||
await hass.async_start()
|
||||
await hass.async_block_till_done()
|
||||
assert "Could not create an emergency self signed ssl certificate" in caplog.text
|
||||
assert len(mock_builder.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_cors_defaults(hass):
|
||||
"""Test the CORS default settings."""
|
||||
with patch("homeassistant.components.http.setup_cors") as mock_setup:
|
||||
|
Loading…
x
Reference in New Issue
Block a user