2019-02-14 15:01:46 +00:00
|
|
|
"""Support to serve the Home Assistant API as WSGI application."""
|
2017-11-04 19:04:05 +00:00
|
|
|
from ipaddress import ip_network
|
2016-11-25 21:04:06 +00:00
|
|
|
import logging
|
2017-11-04 19:04:05 +00:00
|
|
|
import os
|
2016-11-25 21:04:06 +00:00
|
|
|
import ssl
|
2018-08-13 07:26:20 +00:00
|
|
|
from typing import Optional
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
from aiohttp import web
|
2018-03-09 01:51:49 +00:00
|
|
|
from aiohttp.web_exceptions import HTTPMovedPermanently
|
2017-11-04 19:04:05 +00:00
|
|
|
import voluptuous as vol
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2017-11-04 19:04:05 +00:00
|
|
|
from homeassistant.const import (
|
2019-07-31 19:25:30 +00:00
|
|
|
EVENT_HOMEASSISTANT_START,
|
|
|
|
EVENT_HOMEASSISTANT_STOP,
|
|
|
|
SERVER_PORT,
|
|
|
|
)
|
2016-11-25 21:04:06 +00:00
|
|
|
import homeassistant.helpers.config_validation as cv
|
2016-12-18 20:56:07 +00:00
|
|
|
import homeassistant.util as hass_util
|
2018-07-16 08:32:07 +00:00
|
|
|
from homeassistant.util import ssl as ssl_util
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-02-15 21:06:14 +00:00
|
|
|
from .auth import setup_auth
|
|
|
|
from .ban import setup_bans
|
2019-11-16 09:22:07 +00:00
|
|
|
from .const import KEY_AUTHENTICATED, KEY_HASS, KEY_HASS_USER, KEY_REAL_IP # noqa: F401
|
2018-02-15 21:06:14 +00:00
|
|
|
from .cors import setup_cors
|
|
|
|
from .real_ip import setup_real_ip
|
2019-02-15 17:31:54 +00:00
|
|
|
from .static import CACHE_HEADERS, CachingStaticResource
|
2019-11-16 09:22:07 +00:00
|
|
|
from .view import HomeAssistantView # noqa: F401
|
2018-03-09 01:51:49 +00:00
|
|
|
|
2019-08-12 03:38:18 +00:00
|
|
|
# mypy: allow-untyped-defs, no-check-untyped-defs
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
DOMAIN = "http"
|
|
|
|
|
|
|
|
CONF_SERVER_HOST = "server_host"
|
|
|
|
CONF_SERVER_PORT = "server_port"
|
|
|
|
CONF_BASE_URL = "base_url"
|
|
|
|
CONF_SSL_CERTIFICATE = "ssl_certificate"
|
|
|
|
CONF_SSL_PEER_CERTIFICATE = "ssl_peer_certificate"
|
|
|
|
CONF_SSL_KEY = "ssl_key"
|
|
|
|
CONF_CORS_ORIGINS = "cors_allowed_origins"
|
|
|
|
CONF_USE_X_FORWARDED_FOR = "use_x_forwarded_for"
|
|
|
|
CONF_TRUSTED_PROXIES = "trusted_proxies"
|
|
|
|
CONF_LOGIN_ATTEMPTS_THRESHOLD = "login_attempts_threshold"
|
|
|
|
CONF_IP_BAN_ENABLED = "ip_ban_enabled"
|
|
|
|
CONF_SSL_PROFILE = "ssl_profile"
|
|
|
|
|
|
|
|
SSL_MODERN = "modern"
|
|
|
|
SSL_INTERMEDIATE = "intermediate"
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
_LOGGER = logging.getLogger(__name__)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
DEFAULT_SERVER_HOST = "0.0.0.0"
|
|
|
|
DEFAULT_DEVELOPMENT = "0"
|
2019-08-05 06:24:54 +00:00
|
|
|
# To be able to load custom cards.
|
|
|
|
DEFAULT_CORS = "https://cast.home-assistant.io"
|
2018-02-17 09:29:14 +00:00
|
|
|
NO_LOGIN_ATTEMPT_THRESHOLD = -1
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2020-01-09 10:09:34 +00:00
|
|
|
MAX_CLIENT_SIZE: int = 1024 ** 2 * 16
|
|
|
|
|
2019-02-26 22:42:48 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
HTTP_SCHEMA = vol.Schema(
|
|
|
|
{
|
|
|
|
vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string,
|
|
|
|
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.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,
|
2019-08-05 06:24:54 +00:00
|
|
|
vol.Optional(CONF_CORS_ORIGINS, default=[DEFAULT_CORS]): vol.All(
|
2019-07-31 19:25:30 +00:00
|
|
|
cv.ensure_list, [cv.string]
|
|
|
|
),
|
|
|
|
vol.Inclusive(CONF_USE_X_FORWARDED_FOR, "proxy"): cv.boolean,
|
|
|
|
vol.Inclusive(CONF_TRUSTED_PROXIES, "proxy"): vol.All(
|
|
|
|
cv.ensure_list, [ip_network]
|
|
|
|
),
|
|
|
|
vol.Optional(
|
|
|
|
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 = vol.Schema({DOMAIN: HTTP_SCHEMA}, extra=vol.ALLOW_EXTRA)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
|
2018-08-13 07:26:20 +00:00
|
|
|
class ApiConfig:
|
|
|
|
"""Configuration settings for API server."""
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
|
|
|
self, host: str, port: Optional[int] = SERVER_PORT, use_ssl: bool = False
|
|
|
|
) -> None:
|
2018-08-13 07:26:20 +00:00
|
|
|
"""Initialize a new API config object."""
|
|
|
|
self.host = host
|
|
|
|
self.port = port
|
2019-10-13 21:16:27 +00:00
|
|
|
self.use_ssl = use_ssl
|
2018-08-13 07:26:20 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
host = host.rstrip("/")
|
2018-08-13 07:26:20 +00:00
|
|
|
if host.startswith(("http://", "https://")):
|
|
|
|
self.base_url = host
|
|
|
|
elif use_ssl:
|
2019-08-23 16:53:33 +00:00
|
|
|
self.base_url = f"https://{host}"
|
2018-08-13 07:26:20 +00:00
|
|
|
else:
|
2019-08-23 16:53:33 +00:00
|
|
|
self.base_url = f"http://{host}"
|
2018-08-13 07:26:20 +00:00
|
|
|
|
|
|
|
if port is not None:
|
2019-08-23 16:53:33 +00:00
|
|
|
self.base_url += f":{port}"
|
2018-08-13 07:26:20 +00:00
|
|
|
|
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def async_setup(hass, config):
|
2016-11-25 21:04:06 +00:00
|
|
|
"""Set up the HTTP API and debug interface."""
|
|
|
|
conf = config.get(DOMAIN)
|
|
|
|
|
|
|
|
if conf is None:
|
|
|
|
conf = HTTP_SCHEMA({})
|
|
|
|
|
|
|
|
server_host = conf[CONF_SERVER_HOST]
|
|
|
|
server_port = conf[CONF_SERVER_PORT]
|
2018-02-17 09:29:14 +00:00
|
|
|
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
2018-06-26 15:44:08 +00:00
|
|
|
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
|
2018-02-17 09:29:14 +00:00
|
|
|
ssl_key = conf.get(CONF_SSL_KEY)
|
2016-11-25 21:04:06 +00:00
|
|
|
cors_origins = conf[CONF_CORS_ORIGINS]
|
2018-08-03 11:52:34 +00:00
|
|
|
use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False)
|
|
|
|
trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, [])
|
2016-11-25 21:04:06 +00:00
|
|
|
is_ban_enabled = conf[CONF_IP_BAN_ENABLED]
|
|
|
|
login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD]
|
2018-08-14 06:20:17 +00:00
|
|
|
ssl_profile = conf[CONF_SSL_PROFILE]
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-02-15 21:06:14 +00:00
|
|
|
server = HomeAssistantHTTP(
|
2016-11-25 21:04:06 +00:00
|
|
|
hass,
|
|
|
|
server_host=server_host,
|
|
|
|
server_port=server_port,
|
|
|
|
ssl_certificate=ssl_certificate,
|
2018-06-26 15:44:08 +00:00
|
|
|
ssl_peer_certificate=ssl_peer_certificate,
|
2016-11-25 21:04:06 +00:00
|
|
|
ssl_key=ssl_key,
|
|
|
|
cors_origins=cors_origins,
|
|
|
|
use_x_forwarded_for=use_x_forwarded_for,
|
2018-06-29 20:27:06 +00:00
|
|
|
trusted_proxies=trusted_proxies,
|
2016-11-25 21:04:06 +00:00
|
|
|
login_threshold=login_threshold,
|
2018-08-14 06:20:17 +00:00
|
|
|
is_ban_enabled=is_ban_enabled,
|
|
|
|
ssl_profile=ssl_profile,
|
2016-11-25 21:04:06 +00:00
|
|
|
)
|
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def stop_server(event):
|
2017-04-30 05:04:49 +00:00
|
|
|
"""Stop the server."""
|
2018-03-09 01:51:49 +00:00
|
|
|
await server.stop()
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def start_server(event):
|
2017-04-30 05:04:49 +00:00
|
|
|
"""Start the server."""
|
2016-11-25 21:04:06 +00:00
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
2018-03-09 01:51:49 +00:00
|
|
|
await server.start()
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server)
|
|
|
|
|
|
|
|
hass.http = server
|
2016-12-18 20:56:07 +00:00
|
|
|
|
|
|
|
host = conf.get(CONF_BASE_URL)
|
|
|
|
|
|
|
|
if host:
|
2016-12-18 22:59:45 +00:00
|
|
|
port = None
|
2016-12-18 20:56:07 +00:00
|
|
|
elif server_host != DEFAULT_SERVER_HOST:
|
|
|
|
host = server_host
|
2016-12-18 22:59:45 +00:00
|
|
|
port = server_port
|
2016-12-18 20:56:07 +00:00
|
|
|
else:
|
|
|
|
host = hass_util.get_local_ip()
|
2016-12-18 22:59:45 +00:00
|
|
|
port = server_port
|
2016-12-18 20:56:07 +00:00
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
hass.config.api = ApiConfig(host, port, ssl_certificate is not None)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
2018-07-20 08:45:20 +00:00
|
|
|
class HomeAssistantHTTP:
|
2018-02-15 21:06:14 +00:00
|
|
|
"""HTTP server for Home Assistant."""
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
hass,
|
|
|
|
ssl_certificate,
|
|
|
|
ssl_peer_certificate,
|
|
|
|
ssl_key,
|
|
|
|
server_host,
|
|
|
|
server_port,
|
|
|
|
cors_origins,
|
|
|
|
use_x_forwarded_for,
|
|
|
|
trusted_proxies,
|
|
|
|
login_threshold,
|
|
|
|
is_ban_enabled,
|
|
|
|
ssl_profile,
|
|
|
|
):
|
2018-02-15 21:06:14 +00:00
|
|
|
"""Initialize the HTTP Home Assistant server."""
|
2020-01-09 10:09:34 +00:00
|
|
|
app = self.app = web.Application(
|
|
|
|
middlewares=[], client_max_size=MAX_CLIENT_SIZE
|
|
|
|
)
|
2019-03-11 02:55:36 +00:00
|
|
|
app[KEY_HASS] = hass
|
2018-02-15 21:06:14 +00:00
|
|
|
|
|
|
|
# This order matters
|
2018-06-29 20:27:06 +00:00
|
|
|
setup_real_ip(app, use_x_forwarded_for, trusted_proxies)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
if is_ban_enabled:
|
2018-02-15 21:06:14 +00:00
|
|
|
setup_bans(hass, app, login_threshold)
|
|
|
|
|
2019-03-11 02:55:36 +00:00
|
|
|
setup_auth(hass, app)
|
2018-02-15 21:06:14 +00:00
|
|
|
|
2018-07-19 06:37:00 +00:00
|
|
|
setup_cors(app, cors_origins)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
self.hass = hass
|
|
|
|
self.ssl_certificate = ssl_certificate
|
2018-06-26 15:44:08 +00:00
|
|
|
self.ssl_peer_certificate = ssl_peer_certificate
|
2016-11-25 21:04:06 +00:00
|
|
|
self.ssl_key = ssl_key
|
|
|
|
self.server_host = server_host
|
|
|
|
self.server_port = server_port
|
2019-06-08 06:08:55 +00:00
|
|
|
self.trusted_proxies = trusted_proxies
|
2018-02-15 21:06:14 +00:00
|
|
|
self.is_ban_enabled = is_ban_enabled
|
2018-08-14 06:20:17 +00:00
|
|
|
self.ssl_profile = ssl_profile
|
2016-11-25 21:04:06 +00:00
|
|
|
self._handler = None
|
2018-08-20 12:03:35 +00:00
|
|
|
self.runner = None
|
|
|
|
self.site = None
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
def register_view(self, view):
|
|
|
|
"""Register a view with the WSGI server.
|
|
|
|
|
|
|
|
The view argument must be a class that inherits from HomeAssistantView.
|
|
|
|
It is optional to instantiate it before registering; this method will
|
|
|
|
handle it either way.
|
|
|
|
"""
|
|
|
|
if isinstance(view, type):
|
|
|
|
# Instantiate the view, if needed
|
|
|
|
view = view()
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if not hasattr(view, "url"):
|
2016-11-25 21:04:06 +00:00
|
|
|
class_name = view.__class__.__name__
|
2019-08-23 16:53:33 +00:00
|
|
|
raise AttributeError(f'{class_name} missing required attribute "url"')
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
if not hasattr(view, "name"):
|
2016-11-25 21:04:06 +00:00
|
|
|
class_name = view.__class__.__name__
|
2019-08-23 16:53:33 +00:00
|
|
|
raise AttributeError(f'{class_name} missing required attribute "name"')
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-07-19 06:37:00 +00:00
|
|
|
view.register(self.app, self.app.router)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
|
|
|
def register_redirect(self, url, redirect_to):
|
|
|
|
"""Register a redirect with the server.
|
|
|
|
|
|
|
|
If given this must be either a string or callable. In case of a
|
|
|
|
callable it's called with the url adapter that triggered the match and
|
|
|
|
the values of the URL as keyword arguments and has to return the target
|
|
|
|
for the redirect, otherwise it has to be a string with placeholders in
|
|
|
|
rule syntax.
|
|
|
|
"""
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2019-07-27 00:40:40 +00:00
|
|
|
async def redirect(request):
|
2016-11-25 21:04:06 +00:00
|
|
|
"""Redirect to location."""
|
|
|
|
raise HTTPMovedPermanently(redirect_to)
|
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.app.router.add_route("GET", url, redirect)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2017-03-30 07:50:53 +00:00
|
|
|
def register_static_path(self, url_path, path, cache_headers=True):
|
|
|
|
"""Register a folder or file to serve as a static path."""
|
2016-11-25 21:04:06 +00:00
|
|
|
if os.path.isdir(path):
|
2017-03-30 07:50:53 +00:00
|
|
|
if cache_headers:
|
|
|
|
resource = CachingStaticResource
|
|
|
|
else:
|
|
|
|
resource = web.StaticResource
|
|
|
|
self.app.router.register_resource(resource(url_path, path))
|
|
|
|
return
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2017-03-30 07:50:53 +00:00
|
|
|
if cache_headers:
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def serve_file(request):
|
2017-03-30 07:50:53 +00:00
|
|
|
"""Serve file from disk."""
|
2019-02-15 17:31:54 +00:00
|
|
|
return web.FileResponse(path, headers=CACHE_HEADERS)
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2017-03-30 07:50:53 +00:00
|
|
|
else:
|
2019-07-31 19:25:30 +00:00
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def serve_file(request):
|
2017-03-30 07:50:53 +00:00
|
|
|
"""Serve file from disk."""
|
|
|
|
return web.FileResponse(path)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2019-07-31 19:25:30 +00:00
|
|
|
self.app.router.add_route("GET", url_path, serve_file)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def start(self):
|
2018-08-20 12:03:35 +00:00
|
|
|
"""Start the aiohttp server."""
|
2016-11-25 21:04:06 +00:00
|
|
|
if self.ssl_certificate:
|
2016-12-13 06:02:24 +00:00
|
|
|
try:
|
2018-08-14 06:20:17 +00:00
|
|
|
if self.ssl_profile == SSL_INTERMEDIATE:
|
|
|
|
context = ssl_util.server_context_intermediate()
|
|
|
|
else:
|
|
|
|
context = ssl_util.server_context_modern()
|
2018-08-20 12:03:35 +00:00
|
|
|
await self.hass.async_add_executor_job(
|
2019-07-31 19:25:30 +00:00
|
|
|
context.load_cert_chain, self.ssl_certificate, self.ssl_key
|
|
|
|
)
|
2016-12-13 06:02:24 +00:00
|
|
|
except OSError as error:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Could not read SSL certificate from %s: %s",
|
|
|
|
self.ssl_certificate,
|
|
|
|
error,
|
|
|
|
)
|
2016-12-13 06:02:24 +00:00
|
|
|
return
|
2018-06-26 15:44:08 +00:00
|
|
|
|
|
|
|
if self.ssl_peer_certificate:
|
|
|
|
context.verify_mode = ssl.CERT_REQUIRED
|
2018-08-20 12:03:35 +00:00
|
|
|
await self.hass.async_add_executor_job(
|
2019-07-31 19:25:30 +00:00
|
|
|
context.load_verify_locations, self.ssl_peer_certificate
|
|
|
|
)
|
2018-06-26 15:44:08 +00:00
|
|
|
|
2016-11-25 21:04:06 +00:00
|
|
|
else:
|
|
|
|
context = None
|
|
|
|
|
2016-11-27 22:01:12 +00:00
|
|
|
# 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.
|
2018-03-05 21:28:41 +00:00
|
|
|
# To work around this we now prevent the router from getting frozen
|
2018-11-21 19:55:21 +00:00
|
|
|
# pylint: disable=protected-access
|
2018-03-05 21:28:41 +00:00
|
|
|
self.app._router.freeze = lambda: None
|
2016-11-27 22:01:12 +00:00
|
|
|
|
2018-08-20 12:03:35 +00:00
|
|
|
self.runner = web.AppRunner(self.app)
|
|
|
|
await self.runner.setup()
|
2019-07-31 19:25:30 +00:00
|
|
|
self.site = web.TCPSite(
|
|
|
|
self.runner, self.server_host, self.server_port, ssl_context=context
|
|
|
|
)
|
2016-12-13 06:02:24 +00:00
|
|
|
try:
|
2018-08-20 12:03:35 +00:00
|
|
|
await self.site.start()
|
2016-12-13 06:02:24 +00:00
|
|
|
except OSError as error:
|
2019-07-31 19:25:30 +00:00
|
|
|
_LOGGER.error(
|
|
|
|
"Failed to create HTTP server at port %d: %s", self.server_port, error
|
|
|
|
)
|
2016-11-25 21:04:06 +00:00
|
|
|
|
2018-03-09 01:51:49 +00:00
|
|
|
async def stop(self):
|
2018-08-20 12:03:35 +00:00
|
|
|
"""Stop the aiohttp server."""
|
|
|
|
await self.site.stop()
|
|
|
|
await self.runner.cleanup()
|