core/homeassistant/components/http.py

412 lines
13 KiB
Python
Raw Normal View History

2016-05-14 07:58:36 +00:00
"""This module provides WSGI application to serve the Home Assistant API."""
import hmac
2013-09-28 18:09:36 +00:00
import json
2013-09-22 00:59:31 +00:00
import logging
2016-05-15 03:35:58 +00:00
import mimetypes
2015-12-06 22:05:58 +00:00
import threading
2016-05-14 07:58:36 +00:00
import re
2015-08-17 03:44:46 +00:00
import homeassistant.core as ha
import homeassistant.remote as rem
2016-05-14 07:58:36 +00:00
from homeassistant import util
from homeassistant.const import (
SERVER_PORT, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CACHE_CONTROL)
from homeassistant.helpers.entity import split_entity_id
2016-05-15 04:18:46 +00:00
import homeassistant.util.dt as dt_util
2014-10-27 01:10:01 +00:00
DOMAIN = "http"
2016-05-23 03:14:46 +00:00
REQUIREMENTS = ("eventlet==0.19.0", "static3==0.7.0", "Werkzeug==0.11.5",)
2013-09-20 06:59:49 +00:00
CONF_API_PASSWORD = "api_password"
CONF_SERVER_HOST = "server_host"
CONF_SERVER_PORT = "server_port"
CONF_DEVELOPMENT = "development"
2015-12-06 22:19:25 +00:00
CONF_SSL_CERTIFICATE = 'ssl_certificate'
CONF_SSL_KEY = 'ssl_key'
DATA_API_PASSWORD = 'api_password'
2016-05-14 07:58:36 +00:00
_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
2014-11-08 21:57:08 +00:00
_LOGGER = logging.getLogger(__name__)
2014-11-23 20:57:29 +00:00
class HideSensitiveFilter(logging.Filter):
"""Filter API password calls."""
# pylint: disable=too-few-public-methods
def __init__(self, hass):
"""Initialize sensitive data filter."""
super().__init__()
self.hass = hass
def filter(self, record):
"""Hide sensitive data in messages."""
if self.hass.wsgi.api_password is None:
return True
record.msg = record.msg.replace(self.hass.wsgi.api_password, '*******')
return True
def setup(hass, config):
2016-03-08 16:55:57 +00:00
"""Set up the HTTP API and debug interface."""
_LOGGER.addFilter(HideSensitiveFilter(hass))
2015-12-05 21:44:50 +00:00
conf = config.get(DOMAIN, {})
api_password = util.convert(conf.get(CONF_API_PASSWORD), str)
server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0')
server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT)
development = str(conf.get(CONF_DEVELOPMENT, "")) == "1"
2015-12-06 22:19:25 +00:00
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
ssl_key = conf.get(CONF_SSL_KEY)
2016-05-14 07:58:36 +00:00
server = HomeAssistantWSGI(
hass,
development=development,
server_host=server_host,
server_port=server_port,
api_password=api_password,
ssl_certificate=ssl_certificate,
ssl_key=ssl_key,
)
2013-09-22 00:59:31 +00:00
2014-11-29 07:19:59 +00:00
hass.bus.listen_once(
ha.EVENT_HOMEASSISTANT_START,
lambda event:
2016-03-08 00:43:33 +00:00
threading.Thread(target=server.start, daemon=True,
2016-05-14 07:58:36 +00:00
name='WSGI-server').start())
2016-05-14 07:58:36 +00:00
hass.wsgi = server
hass.config.api = rem.API(server_host if server_host != '0.0.0.0'
else util.get_local_ip(),
api_password, server_port,
2015-12-06 22:19:25 +00:00
ssl_certificate is not None)
return True
2015-05-18 14:08:02 +00:00
2016-05-15 04:18:46 +00:00
def request_class():
"""Generate request class.
2016-05-16 07:25:47 +00:00
Done in method because of imports.
"""
2016-05-15 04:18:46 +00:00
from werkzeug.exceptions import BadRequest
from werkzeug.wrappers import BaseRequest, AcceptMixin
from werkzeug.utils import cached_property
class Request(BaseRequest, AcceptMixin):
"""Base class for incoming requests."""
@cached_property
def json(self):
"""Get the result of json.loads if possible."""
if not self.data:
return None
# elif 'json' not in self.environ.get('CONTENT_TYPE', ''):
# raise BadRequest('Not a JSON request')
try:
return json.loads(self.data.decode(
self.charset, self.encoding_errors))
except (TypeError, ValueError):
raise BadRequest('Unable to read JSON request')
return Request
def routing_map(hass):
"""Generate empty routing map with HA validators."""
from werkzeug.routing import Map, BaseConverter, ValidationError
class EntityValidator(BaseConverter):
"""Validate entity_id in urls."""
2016-05-16 07:25:47 +00:00
2016-05-15 04:18:46 +00:00
regex = r"(\w+)\.(\w+)"
def __init__(self, url_map, exist=True, domain=None):
"""Initilalize entity validator."""
super().__init__(url_map)
self._exist = exist
self._domain = domain
def to_python(self, value):
"""Validate entity id."""
if self._exist and hass.states.get(value) is None:
raise ValidationError()
if self._domain is not None and \
split_entity_id(value)[0] != self._domain:
raise ValidationError()
return value
def to_url(self, value):
"""Convert entity_id for a url."""
return value
class DateValidator(BaseConverter):
"""Validate dates in urls."""
regex = r'\d{4}-(0[1-9])|(1[012])-((0[1-9])|([12]\d)|(3[01]))'
def to_python(self, value):
"""Validate and convert date."""
parsed = dt_util.parse_date(value)
if value is None:
raise ValidationError()
return parsed
def to_url(self, value):
"""Convert date to url value."""
return value.isoformat()
return Map(converters={
'entity': EntityValidator,
'date': DateValidator,
})
2016-05-14 07:58:36 +00:00
class HomeAssistantWSGI(object):
"""WSGI server for Home Assistant."""
2014-11-23 17:51:16 +00:00
2016-05-14 07:58:36 +00:00
# pylint: disable=too-many-instance-attributes, too-many-locals
2014-10-25 06:44:00 +00:00
# pylint: disable=too-many-arguments
2013-09-22 00:59:31 +00:00
2016-05-14 07:58:36 +00:00
def __init__(self, hass, development, api_password, ssl_certificate,
ssl_key, server_host, server_port):
"""Initilalize the WSGI Home Assistant server."""
from werkzeug.wrappers import Response
Response.mimetype = 'text/html'
# pylint: disable=invalid-name
2016-05-15 04:18:46 +00:00
self.Request = request_class()
self.url_map = routing_map(hass)
2016-05-14 07:58:36 +00:00
self.views = {}
self.hass = hass
2016-05-14 07:58:36 +00:00
self.extra_apps = {}
self.development = development
2016-05-14 07:58:36 +00:00
self.api_password = api_password
self.ssl_certificate = ssl_certificate
self.ssl_key = ssl_key
self.server_host = server_host
self.server_port = server_port
self.event_forwarder = None
2013-09-22 00:59:31 +00:00
2016-05-14 07:58:36 +00:00
def register_view(self, view):
"""Register a view with the WSGI server.
2014-10-27 01:10:01 +00:00
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.
2016-05-14 07:58:36 +00:00
"""
from werkzeug.routing import Rule
2013-09-28 18:09:36 +00:00
2016-05-14 07:58:36 +00:00
if view.name in self.views:
_LOGGER.warning("View '%s' is being overwritten", view.name)
if isinstance(view, type):
# Instantiate the view, if needed
2016-05-14 07:58:36 +00:00
view = view(self.hass)
2014-10-17 07:17:02 +00:00
2016-05-14 07:58:36 +00:00
self.views[view.name] = view
2014-10-17 07:17:02 +00:00
2016-05-14 07:58:36 +00:00
rule = Rule(view.url, endpoint=view.name)
self.url_map.add(rule)
for url in view.extra_urls:
rule = Rule(url, endpoint=view.name)
self.url_map.add(rule)
2013-09-28 18:09:36 +00:00
2016-05-14 07:58:36 +00:00
def register_redirect(self, url, redirect_to):
"""Register a redirect with the server.
2013-11-01 18:34:43 +00:00
2016-05-14 07:58:36 +00:00
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
2016-05-14 07:58:36 +00:00
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.
"""
from werkzeug.routing import Rule
2013-11-01 18:34:43 +00:00
2016-05-14 07:58:36 +00:00
self.url_map.add(Rule(url, redirect_to=redirect_to))
2016-05-14 07:58:36 +00:00
def register_static_path(self, url_root, path):
"""Register a folder to serve as a static path."""
from static import Cling
2015-01-30 16:26:06 +00:00
headers = []
if not self.development:
# 1 year in seconds
cache_time = 365 * 86400
headers.append({
'prefix': '',
HTTP_HEADER_CACHE_CONTROL:
"public, max-age={}".format(cache_time)
})
2016-05-16 06:54:14 +00:00
self.register_wsgi_app(url_root, Cling(path, headers=headers))
def register_wsgi_app(self, url_root, app):
"""Register a path to serve a WSGI app."""
if url_root in self.extra_apps:
_LOGGER.warning("Url root '%s' is being overwritten", url_root)
self.extra_apps[url_root] = app
2016-05-14 07:58:36 +00:00
def start(self):
"""Start the wsgi server."""
from eventlet import wsgi
import eventlet
sock = eventlet.listen((self.server_host, self.server_port))
if self.ssl_certificate:
sock = eventlet.wrap_ssl(sock, certfile=self.ssl_certificate,
keyfile=self.ssl_key, server_side=True)
wsgi.server(sock, self, log=_LOGGER)
2016-05-14 07:58:36 +00:00
def dispatch_request(self, request):
"""Handle incoming request."""
from werkzeug.exceptions import (
MethodNotAllowed, NotFound, BadRequest, Unauthorized,
)
from werkzeug.routing import RequestRedirect
2016-05-16 06:54:14 +00:00
with request:
adapter = self.url_map.bind_to_environ(request.environ)
try:
endpoint, values = adapter.match()
2016-05-21 22:00:52 +00:00
return self.views[endpoint].handle_request(request, **values)
2016-05-16 06:54:14 +00:00
except RequestRedirect as ex:
return ex
2016-05-16 07:25:47 +00:00
except (BadRequest, NotFound, MethodNotAllowed,
Unauthorized) as ex:
resp = ex.get_response(request.environ)
if request.accept_mimetypes.accept_json:
resp.data = json.dumps({
"result": "error",
"message": str(ex),
})
resp.mimetype = "application/json"
return resp
2016-05-14 07:58:36 +00:00
def base_app(self, environ, start_response):
"""WSGI Handler of requests to base app."""
request = self.Request(environ)
response = self.dispatch_request(request)
return response(environ, start_response)
def __call__(self, environ, start_response):
"""Handle a request for base app + extra apps."""
from werkzeug.wsgi import DispatcherMiddleware
app = DispatcherMiddleware(self.base_app, self.extra_apps)
# Strip out any cachebusting MD5 fingerprints
fingerprinted = _FINGERPRINT.match(environ.get('PATH_INFO', ''))
if fingerprinted:
environ['PATH_INFO'] = "{}.{}".format(*fingerprinted.groups())
return app(environ, start_response)
2015-03-04 05:15:15 +00:00
2016-05-14 07:58:36 +00:00
class HomeAssistantView(object):
"""Base view for all views."""
2016-05-14 07:58:36 +00:00
extra_urls = []
requires_auth = True # Views inheriting from this class can override this
2016-05-14 07:58:36 +00:00
def __init__(self, hass):
"""Initilalize the base view."""
from werkzeug.wrappers import Response
if not hasattr(self, 'url'):
class_name = self.__class__.__name__
raise AttributeError(
'{0} missing required attribute "url"'.format(class_name)
)
if not hasattr(self, 'name'):
class_name = self.__class__.__name__
raise AttributeError(
'{0} missing required attribute "name"'.format(class_name)
)
2016-05-14 07:58:36 +00:00
self.hass = hass
# pylint: disable=invalid-name
self.Response = Response
2016-05-14 07:58:36 +00:00
def handle_request(self, request, **values):
"""Handle request to url."""
from werkzeug.exceptions import MethodNotAllowed, Unauthorized
try:
2016-05-14 07:58:36 +00:00
handler = getattr(self, request.method.lower())
except AttributeError:
raise MethodNotAllowed
2016-05-14 07:58:36 +00:00
# Auth code verbose on purpose
authenticated = False
if self.hass.wsgi.api_password is None:
2016-05-14 07:58:36 +00:00
authenticated = True
2016-03-08 16:55:57 +00:00
2016-05-14 07:58:36 +00:00
elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
self.hass.wsgi.api_password):
# A valid auth header has been set
authenticated = True
2016-05-14 07:58:36 +00:00
elif hmac.compare_digest(request.args.get(DATA_API_PASSWORD, ''),
self.hass.wsgi.api_password):
authenticated = True
if self.requires_auth and not authenticated:
2016-05-14 07:58:36 +00:00
raise Unauthorized()
request.authenticated = authenticated
2016-05-14 07:58:36 +00:00
result = handler(request, **values)
if isinstance(result, self.Response):
# The method handler returned a ready-made Response, how nice of it
return result
status_code = 200
if isinstance(result, tuple):
result, status_code = result
return self.Response(result, status=status_code)
def json(self, result, status_code=200):
"""Return a JSON response."""
msg = json.dumps(
result,
sort_keys=True,
cls=rem.JSONEncoder
).encode('UTF-8')
return self.Response(msg, mimetype="application/json",
status=status_code)
def json_message(self, error, status_code=200):
"""Return a JSON message response."""
return self.json({'message': error}, status_code)
2016-05-15 03:35:58 +00:00
def file(self, request, fil, mimetype=None):
2016-05-14 07:58:36 +00:00
"""Return a file."""
from werkzeug.wsgi import wrap_file
from werkzeug.exceptions import NotFound
if isinstance(fil, str):
2016-05-15 04:18:46 +00:00
if mimetype is None:
mimetype = mimetypes.guess_type(fil)[0]
2016-05-14 07:58:36 +00:00
try:
fil = open(fil)
except IOError:
raise NotFound()
2016-05-15 03:35:58 +00:00
return self.Response(wrap_file(request.environ, fil),
mimetype=mimetype, direct_passthrough=True)