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

View File

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

View File

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