Startup with an emergency self signed cert if the ssl certificate cannot be loaded (#66707)

This commit is contained in:
J. Nick Koston 2022-02-18 18:08:26 -06:00 committed by GitHub
parent 0269ad4738
commit 3bf2be1765
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 359 additions and 38 deletions

View File

@ -158,8 +158,11 @@ async def async_setup_hass(
safe_mode = True safe_mode = True
old_config = hass.config old_config = hass.config
old_logging = hass.data.get(DATA_LOGGING)
hass = core.HomeAssistant() hass = core.HomeAssistant()
if old_logging:
hass.data[DATA_LOGGING] = old_logging
hass.config.skip_pip = old_config.skip_pip hass.config.skip_pip = old_config.skip_pip
hass.config.internal_url = old_config.internal_url hass.config.internal_url = old_config.internal_url
hass.config.external_url = old_config.external_url hass.config.external_url = old_config.external_url

View File

@ -1,22 +1,31 @@
"""Support to serve the Home Assistant API as WSGI application.""" """Support to serve the Home Assistant API as WSGI application."""
from __future__ import annotations from __future__ import annotations
import datetime
from ipaddress import IPv4Network, IPv6Network, ip_network from ipaddress import IPv4Network, IPv6Network, ip_network
import logging import logging
import os import os
import ssl import ssl
from tempfile import NamedTemporaryFile
from typing import Any, Final, Optional, TypedDict, Union, cast from typing import Any, Final, Optional, TypedDict, Union, cast
from aiohttp import web from aiohttp import web
from aiohttp.typedefs import StrOrURL from aiohttp.typedefs import StrOrURL
from aiohttp.web_exceptions import HTTPMovedPermanently, HTTPRedirection 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 import voluptuous as vol
from yarl import URL
from homeassistant.components.network import async_get_source_ip from homeassistant.components.network import async_get_source_ip
from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT from homeassistant.const import EVENT_HOMEASSISTANT_STOP, SERVER_PORT
from homeassistant.core import Event, HomeAssistant from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import storage from homeassistant.helpers import storage
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import bind_hass from homeassistant.loader import bind_hass
from homeassistant.setup import async_start_setup, async_when_setup_or_start from homeassistant.setup import async_start_setup, async_when_setup_or_start
@ -231,6 +240,7 @@ class HomeAssistantHTTP:
self.ssl_profile = ssl_profile self.ssl_profile = ssl_profile
self.runner: web.AppRunner | None = None self.runner: web.AppRunner | None = None
self.site: HomeAssistantTCPSite | None = None self.site: HomeAssistantTCPSite | None = None
self.context: ssl.SSLContext | None = None
async def async_initialize( async def async_initialize(
self, self,
@ -258,6 +268,11 @@ class HomeAssistantHTTP:
setup_cors(self.app, cors_origins) 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: def register_view(self, view: HomeAssistantView | type[HomeAssistantView]) -> None:
"""Register a view with the WSGI server. """Register a view with the WSGI server.
@ -329,35 +344,100 @@ class HomeAssistantHTTP:
self.app.router.add_route("GET", url_path, serve_file) self.app.router.add_route("GET", url_path, serve_file)
) )
async def start(self) -> None: def _create_ssl_context(self) -> ssl.SSLContext | None:
"""Start the aiohttp server.""" context: ssl.SSLContext | None = None
context: ssl.SSLContext | None assert self.ssl_certificate is not None
if self.ssl_certificate:
try: try:
if self.ssl_profile == SSL_INTERMEDIATE: if self.ssl_profile == SSL_INTERMEDIATE:
context = ssl_util.server_context_intermediate() context = ssl_util.server_context_intermediate()
else: else:
context = ssl_util.server_context_modern() context = ssl_util.server_context_modern()
await self.hass.async_add_executor_job( context.load_cert_chain(self.ssl_certificate, self.ssl_key)
context.load_cert_chain, self.ssl_certificate, self.ssl_key
)
except OSError as error: 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( _LOGGER.error(
"Could not read SSL certificate from %s: %s", "Could not read SSL certificate from %s: %s",
self.ssl_certificate, self.ssl_certificate,
error, error,
) )
return try:
context = self._create_emergency_ssl_context()
except OSError as error:
_LOGGER.error(
"Could not create an emergency self signed ssl certificate: %s",
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
if self.ssl_peer_certificate: if self.ssl_peer_certificate:
context.verify_mode = ssl.CERT_REQUIRED if context is None:
await self.hass.async_add_executor_job( raise HomeAssistantError(
context.load_verify_locations, self.ssl_peer_certificate "Failed to create ssl context, no fallback available because a peer certificate is required."
) )
else: context.verify_mode = ssl.CERT_REQUIRED
context = None 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. # Aiohttp freezes apps after start so that no changes can be made.
# However in Home Assistant components can be discovered after boot. # However in Home Assistant components can be discovered after boot.
# This will now raise a RunTimeError. # This will now raise a RunTimeError.
@ -369,7 +449,7 @@ class HomeAssistantHTTP:
await self.runner.setup() await self.runner.setup()
self.site = HomeAssistantTCPSite( 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: try:
await self.site.start() await self.site.start()

View File

@ -3,11 +3,13 @@ from datetime import timedelta
from http import HTTPStatus from http import HTTPStatus
from ipaddress import ip_network from ipaddress import ip_network
import logging import logging
import pathlib
from unittest.mock import Mock, patch from unittest.mock import Mock, patch
import pytest import pytest
import homeassistant.components.http as http import homeassistant.components.http as http
from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from homeassistant.util.ssl import server_context_intermediate, server_context_modern 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 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 @pytest.fixture
def mock_stack(): def mock_stack():
"""Mock extract 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.""" """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( with patch("ssl.SSLContext.load_cert_chain"), patch(
"homeassistant.util.ssl.server_context_modern", "homeassistant.util.ssl.server_context_modern",
side_effect=server_context_modern, side_effect=server_context_modern,
) as mock_context: ) 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_start()
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_context.mock_calls) == 1 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.""" """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( with patch("ssl.SSLContext.load_cert_chain"), patch(
"homeassistant.util.ssl.server_context_intermediate", "homeassistant.util.ssl.server_context_intermediate",
side_effect=server_context_intermediate, side_effect=server_context_intermediate,
) as mock_context: ) 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_start()
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_context.mock_calls) == 1 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.""" """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( with patch("ssl.SSLContext.load_cert_chain"), patch(
"homeassistant.util.ssl.server_context_modern", "homeassistant.util.ssl.server_context_modern",
side_effect=server_context_modern, side_effect=server_context_modern,
) as mock_context: ) 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_start()
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(mock_context.mock_calls) == 1 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): async def test_cors_defaults(hass):
"""Test the CORS default settings.""" """Test the CORS default settings."""
with patch("homeassistant.components.http.setup_cors") as mock_setup: with patch("homeassistant.components.http.setup_cors") as mock_setup: