core/homeassistant/components/http.py

353 lines
12 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
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
2014-10-27 01:10:01 +00:00
DOMAIN = "http"
2016-05-14 07:58:36 +00:00
REQUIREMENTS = ("eventlet==0.18.4", "static3==0.6.1", "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
def setup(hass, config):
2016-03-08 16:55:57 +00:00
"""Set up the HTTP API and debug interface."""
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-14 07:58:36 +00:00
# class StaticFileServer(object):
# """Static file serving middleware."""
# def __call__(self, environ, start_response):
# from werkzeug.wsgi import DispatcherMiddleware
# app = DispatcherMiddleware(self.base_app, self.extra_apps)
# # Strip out any cachebusting MD% fingerprints
# fingerprinted = _FINGERPRINT.match(environ['PATH_INFO'])
# if fingerprinted:
# environ['PATH_INFO'] = "{}.{}".format(*fingerprinted.groups())
# return app(environ, start_response)
2016-03-08 16:55:57 +00:00
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.exceptions import BadRequest
from werkzeug.wrappers import BaseRequest, AcceptMixin
from werkzeug.routing import Map
from werkzeug.utils import cached_property
from werkzeug.wrappers import Response
2016-05-14 22:07:20 +00:00
class Request(BaseRequest, AcceptMixin):
2016-05-14 07:58:36 +00:00
"""Base class for incoming requests."""
@cached_property
def json(self):
"""Get the result of json.loads if possible."""
if not self.data:
return None
2016-05-14 22:07:20 +00:00
# elif 'json' not in self.environ.get('CONTENT_TYPE', ''):
# raise BadRequest('Not a JSON request')
2016-05-14 07:58:36 +00:00
try:
return json.loads(self.data.decode(
self.charset, self.encoding_errors))
except (TypeError, ValueError):
raise BadRequest('Unable to read JSON request')
Response.mimetype = 'text/html'
# pylint: disable=invalid-name
self.Request = Request
self.url_map = Map()
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 its 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.
"""
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
2016-05-14 07:58:36 +00:00
if url_root in self.extra_apps:
_LOGGER.warning("Static path '%s' is being overwritten", path)
self.extra_apps[url_root] = Cling(path)
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:
eventlet.wrap_ssl(sock, certfile=self.ssl_certificate,
keyfile=self.ssl_key, server_side=True)
wsgi.server(sock, self)
def dispatch_request(self, request):
"""Handle incoming request."""
from werkzeug.exceptions import (
MethodNotAllowed, NotFound, BadRequest, Unauthorized,
)
from werkzeug.routing import RequestRedirect
2016-05-14 07:58:36 +00:00
adapter = self.url_map.bind_to_environ(request.environ)
2015-01-30 16:26:06 +00:00
try:
2016-05-14 07:58:36 +00:00
endpoint, values = adapter.match()
return self.views[endpoint].handle_request(request, **values)
except RequestRedirect as ex:
return ex
except BadRequest as ex:
return self._handle_error(request, str(ex), 400)
except NotFound as ex:
return self._handle_error(request, str(ex), 404)
except MethodNotAllowed as ex:
return self._handle_error(request, str(ex), 405)
except Unauthorized as ex:
return self._handle_error(request, str(ex), 401)
# TODO This long chain of except blocks is silly. _handle_error should
# just take the exception as an argument and parse the status code
# itself
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)
def _handle_error(self, request, message, status):
"""Handle a WSGI request error."""
from werkzeug.wrappers import Response
if request.accept_mimetypes.accept_json:
message = json.dumps({
"result": "error",
"message": message,
})
mimetype = "application/json"
else:
mimetype = "text/plain"
return Response(message, status=status, mimetype=mimetype)
2015-11-29 06:14:40 +00:00
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, BadRequest,
)
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
# TODO: session support + uncomment session test
2016-05-14 07:58:36 +00:00
# Auth code verbose on purpose
authenticated = False
2016-05-14 07:58:36 +00:00
if not self.requires_auth:
authenticated = True
2016-05-14 07:58:36 +00:00
elif self.hass.wsgi.api_password is None:
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
2016-05-14 07:58:36 +00:00
else:
# Do we still want to support passing it in as post data?
try:
json_data = request.json
if (json_data is not None and
hmac.compare_digest(
json_data.get(DATA_API_PASSWORD, ''),
self.hass.wsgi.api_password)):
authenticated = True
except BadRequest:
pass
if not authenticated:
raise Unauthorized()
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)
def file(self, request, fil, content_type=None):
"""Return a file."""
from werkzeug.wsgi import wrap_file
from werkzeug.exceptions import NotFound
if isinstance(fil, str):
try:
fil = open(fil)
except IOError:
raise NotFound()
2016-05-14 07:58:36 +00:00
# TODO mimetypes, etc
2016-05-14 07:58:36 +00:00
resp = self.Response(wrap_file(request.environ, fil))
if content_type is not None:
resp.mimetype = content_type
return resp