Leverage access and refresh tokens if available (#575)

* Leverage access and refresh tokens if available

* Update homeassistant.py

* Update homeassistant.py

* Update proxy.py

* Migrate HomeAssistant to new exception layout

* Fix build for 3.7

* Cleanups

* Fix style

* fix log strings

* Fix new style

* Fix travis build

* python 3.7

* next try

* fix

* fix lint

* Fix lint p2

* Add logging

* Fix logging

* fix access

* Fix spell

* fix return

* Fix runtime

* Add to hass config
This commit is contained in:
Paulus Schoutsen 2018-07-20 16:55:48 +02:00 committed by Pascal Vizeli
parent 1b481e0b37
commit 4df42e054d
10 changed files with 275 additions and 157 deletions

View File

@ -1,12 +1,6 @@
sudo: false
matrix:
fast_finish: true
include:
- python: "3.6"
cache:
directories:
- $HOME/.cache/pip
sudo: true
dist: xenial
install: pip install -U tox
language: python
python: 3.7
script: tox

View File

@ -10,7 +10,7 @@ from ..const import (
ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, ATTR_CPU_PERCENT,
ATTR_MEMORY_USAGE, ATTR_MEMORY_LIMIT, ATTR_NETWORK_RX, ATTR_NETWORK_TX,
ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_WAIT_BOOT, ATTR_MACHINE,
CONTENT_TYPE_BINARY)
ATTR_REFRESH_TOKEN, CONTENT_TYPE_BINARY)
from ..coresys import CoreSysAttributes
from ..validate import NETWORK_PORT, DOCKER_IMAGE
@ -30,6 +30,8 @@ SCHEMA_OPTIONS = vol.Schema({
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT):
vol.All(vol.Coerce(int), vol.Range(min=60)),
# Required once we enforce user system
vol.Optional(ATTR_REFRESH_TOKEN): str,
})
SCHEMA_VERSION = vol.Schema({
@ -83,8 +85,10 @@ class APIHomeAssistant(CoreSysAttributes):
if ATTR_WAIT_BOOT in body:
self.sys_homeassistant.wait_boot = body[ATTR_WAIT_BOOT]
if ATTR_REFRESH_TOKEN in body:
self.sys_homeassistant.refresh_token = body[ATTR_REFRESH_TOKEN]
self.sys_homeassistant.save_data()
return True
@api_process
async def stats(self, request):
@ -109,11 +113,7 @@ class APIHomeAssistant(CoreSysAttributes):
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_homeassistant.last_version)
if version == self.sys_homeassistant.version:
raise RuntimeError("Version {} is already in use".format(version))
return await asyncio.shield(
self.sys_homeassistant.update(version))
await asyncio.shield(self.sys_homeassistant.update(version))
@api_process
def stop(self, request):
@ -123,7 +123,7 @@ class APIHomeAssistant(CoreSysAttributes):
@api_process
def start(self, request):
"""Start homeassistant."""
return asyncio.shield(self.sys_homeassistant.start())
asyncio.shield(self.sys_homeassistant.start())
@api_process
def restart(self, request):

View File

@ -1,15 +1,18 @@
"""Utils for HomeAssistant Proxy."""
import asyncio
from contextlib import asynccontextmanager
import logging
import aiohttp
from aiohttp import web
from aiohttp.web_exceptions import HTTPBadGateway, HTTPInternalServerError
from aiohttp.web_exceptions import (
HTTPBadGateway, HTTPInternalServerError, HTTPUnauthorized)
from aiohttp.hdrs import CONTENT_TYPE
import async_timeout
from ..const import HEADER_HA_ACCESS
from ..coresys import CoreSysAttributes
from ..exceptions import HomeAssistantAuthError, HomeAssistantAPIError
_LOGGER = logging.getLogger(__name__)
@ -23,49 +26,43 @@ class APIProxy(CoreSysAttributes):
addon = self.sys_addons.from_uuid(hassio_token)
if not addon:
_LOGGER.warning("Unknown Home-Assistant API access!")
_LOGGER.warning("Unknown HomeAssistant API access!")
elif not addon.access_homeassistant_api:
_LOGGER.warning("Not permitted API access: %s", addon.slug)
else:
_LOGGER.info("%s access from %s", request.path, addon.slug)
return
raise HTTPUnauthorized()
@asynccontextmanager
async def _api_client(self, request, path, timeout=300):
"""Return a client request with proxy origin for Home-Assistant."""
url = f"{self.sys_homeassistant.api_url}/api/{path}"
try:
data = None
headers = {}
method = getattr(self.sys_websession_ssl, request.method.lower())
params = request.query or None
# read data
with async_timeout.timeout(30):
data = await request.read()
if data:
headers.update({CONTENT_TYPE: request.content_type})
content_type = request.content_type
else:
content_type = None
# need api password?
if self.sys_homeassistant.api_password:
headers = {
HEADER_HA_ACCESS: self.sys_homeassistant.api_password,
}
# reset headers
if not headers:
headers = None
client = await method(
url, data=data, headers=headers, timeout=timeout,
params=params
)
return client
async with self.sys_homeassistant.make_request(
request.method.lower(), f'api/{path}',
content_type=content_type,
data=data,
timeout=timeout,
) as resp:
yield resp
return
except HomeAssistantAuthError:
_LOGGER.error("Authenticate error on API for request %s", path)
except aiohttp.ClientError as err:
_LOGGER.error("Client error on API %s request %s.", path, err)
_LOGGER.error("Client error on API %s request %s", path, err)
except asyncio.TimeoutError:
_LOGGER.error("Client timeout error on API request %s.", path)
_LOGGER.error("Client timeout error on API request %s", path)
raise HTTPBadGateway()
@ -74,8 +71,7 @@ class APIProxy(CoreSysAttributes):
self._check_access(request)
_LOGGER.info("Home-Assistant EventStream start")
client = await self._api_client(request, 'stream', timeout=None)
async with self._api_client(request, 'stream', timeout=None) as client:
response = web.StreamResponse()
response.content_type = request.headers.get(CONTENT_TYPE)
try:
@ -105,8 +101,7 @@ class APIProxy(CoreSysAttributes):
# Normal request
path = request.match_info.get('path', '')
client = await self._api_client(request, path)
async with self._api_client(request, path) as client:
data = await client.read()
return web.Response(
body=data,
@ -123,19 +118,44 @@ class APIProxy(CoreSysAttributes):
url, heartbeat=60, verify_ssl=False)
# handle authentication
for _ in range(2):
data = await client.receive_json()
if data.get('type') == 'auth_ok':
return client
elif data.get('type') == 'auth_required':
if data.get('type') != 'auth_required':
# Invalid protocol
_LOGGER.error(
'Got unexpected response from HA websocket: %s', data)
raise HTTPBadGateway()
if self.sys_homeassistant.refresh_token:
await self.sys_homeassistant.ensure_access_token()
await client.send_json({
'type': 'auth',
'access_token': self.sys_homeassistant.access_token,
})
else:
await client.send_json({
'type': 'auth',
'api_password': self.sys_homeassistant.api_password,
})
_LOGGER.error("Authentication to Home-Assistant websocket")
data = await client.receive_json()
except (aiohttp.ClientError, RuntimeError) as err:
if data.get('type') == 'auth_ok':
return client
# Renew the Token is invalid
if (data.get('type') == 'invalid_auth' and
self.sys_homeassistant.refresh_token):
self.sys_homeassistant.access_token = None
return await self._websocket_client()
_LOGGER.error(
"Failed authentication to Home-Assistant websocket: %s", data)
except (RuntimeError, HomeAssistantAPIError) as err:
_LOGGER.error("Client error on websocket API %s.", err)
raise HTTPBadGateway()
@ -157,12 +177,18 @@ class APIProxy(CoreSysAttributes):
# Check API access
response = await server.receive_json()
hassio_token = response.get('api_password')
hassio_token = (response.get('api_password') or
response.get('access_token'))
addon = self.sys_addons.from_uuid(hassio_token)
if not addon:
if not addon or not addon.access_homeassistant_api:
_LOGGER.warning("Unauthorized websocket access!")
else:
await server.send_json({
'type': 'auth_invalid',
'message': 'Invalid access',
})
return server
_LOGGER.info("Websocket access from %s", addon.slug)
await server.send_json({

View File

@ -50,7 +50,7 @@ CONTENT_TYPE_JSON = 'application/json'
CONTENT_TYPE_TEXT = 'text/plain'
CONTENT_TYPE_TAR = 'application/tar'
HEADER_HA_ACCESS = 'x-ha-access'
HEADER_TOKEN = 'X-HASSIO-KEY'
HEADER_TOKEN = 'x-hassio-key'
ENV_TOKEN = 'HASSIO_TOKEN'
ENV_TIME = 'TZ'
@ -174,6 +174,7 @@ ATTR_DEVICETREE = 'devicetree'
ATTR_CPE = 'cpe'
ATTR_BOARD = 'board'
ATTR_HASSOS = 'hassos'
ATTR_REFRESH_TOKEN = 'refresh_token'
SERVICE_MQTT = 'mqtt'

View File

@ -1,4 +1,7 @@
"""Core Exceptions."""
import asyncio
import aiohttp
class HassioError(Exception):
@ -11,6 +14,29 @@ class HassioNotSupportedError(HassioError):
pass
# HomeAssistant
class HomeAssistantError(HassioError):
"""Home Assistant exception."""
pass
class HomeAssistantUpdateError(HomeAssistantError):
"""Error on update of a Home Assistant."""
pass
class HomeAssistantAuthError(HomeAssistantError):
"""Home Assistant Auth API exception."""
pass
class HomeAssistantAPIError(
HomeAssistantAuthError, asyncio.TimeoutError, aiohttp.ClientError):
"""Home Assistant API exception."""
pass
# HassOS
class HassOSError(HassioError):

View File

@ -1,5 +1,6 @@
"""HomeAssistant control object."""
import asyncio
from contextlib import asynccontextmanager, suppress
import logging
import os
import re
@ -7,15 +8,19 @@ import socket
import time
import aiohttp
from aiohttp.hdrs import CONTENT_TYPE
from aiohttp import hdrs
import attr
from .const import (
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, HEADER_HA_ACCESS, CONTENT_TYPE_JSON)
ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN,
HEADER_HA_ACCESS)
from .coresys import CoreSysAttributes
from .docker.homeassistant import DockerHomeAssistant
from .exceptions import (
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
HomeAssistantAuthError)
from .utils import convert_to_ascii, process_lock
from .utils.json import JsonConfig
from .validate import SCHEMA_HASS_CONFIG
@ -25,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
RE_YAML_ERROR = re.compile(r"homeassistant\.util\.yaml")
# pylint: disable=invalid-name
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'])
ConfigResult = attr.make_class('ConfigResult', ['valid', 'log'], frozen=True)
class HomeAssistant(JsonConfig, CoreSysAttributes):
@ -38,6 +43,8 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
self.instance = DockerHomeAssistant(coresys)
self.lock = asyncio.Lock(loop=coresys.loop)
self._error_state = False
# We don't persist access tokens. Instead we fetch new ones when needed
self.access_token = None
async def load(self):
"""Prepare HomeAssistant object."""
@ -175,6 +182,16 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
"""Return a UUID of this HomeAssistant."""
return self._data[ATTR_UUID]
@property
def refresh_token(self):
"""Return the refresh token to authenticate with HomeAssistant."""
return self._data.get(ATTR_REFRESH_TOKEN)
@refresh_token.setter
def refresh_token(self, value):
"""Set Home Assistant refresh_token."""
self._data[ATTR_REFRESH_TOKEN] = value
@process_lock
async def install_landingpage(self):
"""Install a landingpage."""
@ -186,7 +203,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
await asyncio.sleep(60)
# Run landingpage after installation
_LOGGER.info("Start landingpage")
try:
await self._start()
except HomeAssistantError:
_LOGGER.warning("Can't start landingpage")
@process_lock
async def install(self):
@ -205,8 +226,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
# finishing
_LOGGER.info("HomeAssistant docker now installed")
if self.boot:
try:
if not self.boot:
return
_LOGGER.info("Start HomeAssistant")
await self._start()
except HomeAssistantError:
_LOGGER.error("Can't start HomeAssistant!")
finally:
await self.instance.cleanup()
@process_lock
@ -219,32 +246,37 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
if exists and version == self.instance.version:
_LOGGER.warning("Version %s is already installed", version)
return False
return HomeAssistantUpdateError()
# process a update
async def _update(to_version):
"""Run Home Assistant update."""
try:
return await self.instance.update(to_version)
_LOGGER.info("Update HomeAssistant to version %s", to_version)
if not await self.instance.update(to_version):
raise HomeAssistantUpdateError()
finally:
if running:
await self._start()
_LOGGER.info("Successfull run HomeAssistant %s", to_version)
# Update Home Assistant
ret = await _update(version)
with suppress(HomeAssistantError):
await _update(version)
return
# Update going wrong, revert it
if self.error_state and rollback:
_LOGGER.fatal("Home Assistant update fails -> rollback!")
ret = await _update(rollback)
return ret
_LOGGER.fatal("HomeAssistant update fails -> rollback!")
await _update(rollback)
else:
raise HomeAssistantUpdateError()
async def _start(self):
"""Start HomeAssistant docker & wait."""
if not await self.instance.run():
return False
return await self._block_till_run()
raise HomeAssistantError()
await self._block_till_run()
@process_lock
def start(self):
@ -266,7 +298,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
async def restart(self):
"""Restart HomeAssistant docker."""
await self.instance.stop()
return await self._start()
await self._start()
def logs(self):
"""Get HomeAssistant docker logs.
@ -309,7 +341,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
# if not valid
if result.exit_code is None:
return ConfigResult(False, "")
raise HomeAssistantError()
# parse output
log = convert_to_ascii(result.output)
@ -317,51 +349,84 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
return ConfigResult(False, log)
return ConfigResult(True, log)
async def check_api_state(self):
"""Check if Home-Assistant up and running."""
url = f"{self.api_url}/api/"
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
async def ensure_access_token(self):
"""Ensures there is an access token."""
if self.access_token is not None:
return
if self.api_password:
header.update({HEADER_HA_ACCESS: self.api_password})
try:
# pylint: disable=bad-continuation
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
async with self.sys_websession_ssl.get(
url, headers=header, timeout=30) as request:
status = request.status
f"{self.api_url}/auth/token",
timeout=30,
data={
"grant_type": "refresh_token",
"refresh_token": self.refresh_token
}
) as resp:
if resp.status != 200:
_LOGGER.error("Authenticate problem with HomeAssistant!")
raise HomeAssistantAuthError()
tokens = await resp.json()
self.access_token = tokens['access_token']
return
except (asyncio.TimeoutError, aiohttp.ClientError):
return False
_LOGGER.error("Can't update HomeAssistant access token!")
raise HomeAssistantAPIError()
if status not in (200, 201):
_LOGGER.warning("Home-Assistant API config missmatch")
@asynccontextmanager
async def make_request(self, method, path, json=None, content_type=None,
data=None, timeout=30):
"""Async context manager to make a request with right auth."""
url = f"{self.api_url}/{path}"
headers = {}
if content_type is not None:
headers[hdrs.CONTENT_TYPE] = content_type
elif self.api_password:
headers[HEADER_HA_ACCESS] = self.api_password
for _ in (1, 2):
# Prepare Access token
if self.refresh_token:
await self.ensure_access_token()
headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}'
async with getattr(self.sys_websession_ssl, method)(
url, timeout=timeout, json=json
) as resp:
# Access token expired
if resp.status == 401 and self.refresh_token:
self.access_token = None
continue
yield resp
return
raise HomeAssistantAPIError()
async def check_api_state(self):
"""Return True if Home-Assistant up and running."""
with suppress(HomeAssistantAPIError):
async with self.make_request('get', 'api/') as resp:
if resp.status in (200, 201):
return True
err = resp.status
_LOGGER.warning("Home-Assistant API config missmatch: %d", err)
return False
async def send_event(self, event_type, event_data=None):
"""Send event to Home-Assistant."""
url = f"{self.api_url}/api/events/{event_type}"
header = {CONTENT_TYPE: CONTENT_TYPE_JSON}
with suppress(HomeAssistantAPIError):
async with self.make_request(
'get', f'api/events/{event_type}'
) as resp:
if resp.status in (200, 201):
return
err = resp.status
if self.api_password:
header.update({HEADER_HA_ACCESS: self.api_password})
try:
# pylint: disable=bad-continuation
async with self.sys_websession_ssl.post(
url, headers=header, timeout=30,
json=event_data) as request:
status = request.status
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOGGER.warning(
"Home-Assistant event %s fails: %s", event_type, err)
return False
if status not in (200, 201):
_LOGGER.warning("Home-Assistant event %s fails", event_type)
return False
return True
_LOGGER.warning("HomeAssistant event %s fails: %s", event_type, err)
return HomeAssistantError()
async def _block_till_run(self):
"""Block until Home-Assistant is booting up or startup timeout."""
@ -374,27 +439,28 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
result = sock.connect_ex((str(self.api_ip), self.api_port))
sock.close()
# Check if the port is available
if result == 0:
return True
return False
except OSError:
pass
return False
while time.monotonic() - start_time < self.wait_boot:
# Check if API response
if await self.sys_run_in_executor(check_port):
_LOGGER.info("Detect a running Home-Assistant instance")
_LOGGER.info("Detect a running HomeAssistant instance")
self._error_state = False
return True
return
# wait and don't hit the system
await asyncio.sleep(10)
# Check if Container is is_running
if not await self.instance.is_running():
_LOGGER.error("Home Assistant is crashed!")
break
# wait and don't hit the system
await asyncio.sleep(10)
_LOGGER.warning("Don't wait anymore of Home-Assistant startup!")
_LOGGER.warning("Don't wait anymore of HomeAssistant startup!")
self._error_state = True
return False
raise HomeAssistantError()

View File

@ -131,5 +131,7 @@ class Tasks(CoreSysAttributes):
return
_LOGGER.error("Watchdog found a problem with Home-Assistant API!")
try:
await self.sys_homeassistant.restart()
finally:
self._cache[HASS_WATCHDOG_API] = 0

View File

@ -9,7 +9,8 @@ from .const import (
ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_CHANNEL, ATTR_TIMEZONE, ATTR_HASSOS,
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
ATTR_WAIT_BOOT, ATTR_UUID, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN,
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
RE_REPOSITORY = re.compile(r"^(?P<url>[^#]+)(?:#(?P<branch>[\w\-]+))?$")
@ -88,6 +89,7 @@ SCHEMA_HASS_CONFIG = vol.Schema({
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
vol.Optional(ATTR_WAIT_BOOT, default=600):

View File

@ -33,7 +33,8 @@ disable=
too-few-public-methods,
abstract-method,
no-else-return,
useless-return
useless-return,
not-async-context-manager
[EXCEPTIONS]
overgeneral-exceptions=Exception,HomeAssistantError

View File

@ -43,7 +43,7 @@ setup(
'attr==0.3.1',
'async_timeout==3.0.0',
'aiohttp==3.3.2',
'docker==3.3.0',
'docker==3.4.0',
'colorlog==3.1.2',
'voluptuous==0.11.1',
'gitpython==2.1.10',