diff --git a/API.md b/API.md index 8b1a1ade8..e2c915414 100644 --- a/API.md +++ b/API.md @@ -284,7 +284,10 @@ Optional: "devices": [""], "image": "str", "custom": "bool -> if custom image", - "boot": "bool" + "boot": "bool", + "port": 8123, + "ssl": "bool", + "watchdog": "bool" } ``` @@ -303,21 +306,30 @@ Optional: Output is the raw Docker log. - POST `/homeassistant/restart` -- POST `/homeassistant/options` - POST `/homeassistant/check` - POST `/homeassistant/start` - POST `/homeassistant/stop` +- POST `/homeassistant/options` + ```json { "devices": [], "image": "Optional|null", - "last_version": "Optional for custom image|null" + "last_version": "Optional for custom image|null", + "port": "port for access hass", + "ssl": "bool", + "password": "", + "watchdog": "bool" } ``` Image with `null` and last_version with `null` reset this options. +- POST/GET `/homeassistant/api` + +Proxy to real home-assistant instance. + ### RESTful for API addons - GET `/addons` diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index 2ac759e84..02d8741cd 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -106,7 +106,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({ vol.Optional(ATTR_IMAGE): vol.Match(r"^[\-\w{}]+/[\-\w{}]+$"), vol.Optional(ATTR_TIMEOUT, default=10): vol.All(vol.Coerce(int), vol.Range(min=10, max=120)) -}) +}, extra=vol.REMOVE_EXTRA) # pylint: disable=no-value-for-parameter @@ -114,7 +114,7 @@ SCHEMA_REPOSITORY_CONFIG = vol.Schema({ vol.Required(ATTR_NAME): vol.Coerce(str), vol.Optional(ATTR_URL): vol.Url(), vol.Optional(ATTR_MAINTAINER): vol.Coerce(str), -}) +}, extra=vol.REMOVE_EXTRA) # pylint: disable=no-value-for-parameter diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index f012c83af..627ca7eac 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -75,6 +75,10 @@ class RestAPI(object): self.webapp.router.add_post('/homeassistant/stop', api_hass.stop) self.webapp.router.add_post('/homeassistant/start', api_hass.start) self.webapp.router.add_post('/homeassistant/check', api_hass.check) + self.webapp.router.add_post( + '/homeassistant/api/{path:.+}', api_hass.api) + self.webapp.router.add_get( + '/homeassistant/api/{path:.+}', api_hass.api) def register_addons(self, addons): """Register homeassistant function.""" diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py index 99a1af060..a455956c2 100644 --- a/hassio/api/homeassistant.py +++ b/hassio/api/homeassistant.py @@ -2,13 +2,19 @@ import asyncio import logging +import aiohttp +from aiohttp import web +from aiohttp.web_exceptions import HTTPBadGateway +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout import voluptuous as vol from .util import api_process, api_process_raw, api_validate from ..const import ( ATTR_VERSION, ATTR_LAST_VERSION, ATTR_DEVICES, ATTR_IMAGE, ATTR_CUSTOM, - ATTR_BOOT, CONTENT_TYPE_BINARY) -from ..validate import HASS_DEVICES + ATTR_BOOT, ATTR_PORT, ATTR_PASSWORD, ATTR_SSL, ATTR_WATCHDOG, + CONTENT_TYPE_BINARY, HEADER_HA_ACCESS) +from ..validate import HASS_DEVICES, NETWORK_PORT _LOGGER = logging.getLogger(__name__) @@ -20,6 +26,10 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Any(None, vol.Coerce(str)), vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Any(None, vol.Coerce(str)), + vol.Optional(ATTR_PORT): NETWORK_PORT, + vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), + vol.Optional(ATTR_SSL): vol.Boolean(), + vol.Optional(ATTR_WATCHDOG): vol.Boolean(), }) SCHEMA_VERSION = vol.Schema({ @@ -36,6 +46,45 @@ class APIHomeAssistant(object): self.loop = loop self.homeassistant = homeassistant + async def homeassistant_proxy(self, path, request): + """Return a client request with proxy origin for Home-Assistant.""" + url = "{}/api/{}".format(self.homeassistant.api_url, path) + + try: + data = None + headers = {} + method = getattr( + self.homeassistant.websession, request.method.lower()) + + # read data + with async_timeout.timeout(10, loop=self.loop): + data = await request.read() + + if data: + headers.update({CONTENT_TYPE: request.content_type}) + + # need api password? + if self.homeassistant.api_password: + headers = {HEADER_HA_ACCESS: self.homeassistant.api_password} + + # reset headers + if not headers: + headers = None + + client = await method( + url, data=data, headers=headers, timeout=300 + ) + + return client + + except aiohttp.ClientError as 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) + + raise HTTPBadGateway() + @api_process async def info(self, request): """Return host information.""" @@ -46,6 +95,9 @@ class APIHomeAssistant(object): ATTR_DEVICES: self.homeassistant.devices, ATTR_CUSTOM: self.homeassistant.is_custom_image, ATTR_BOOT: self.homeassistant.boot, + ATTR_PORT: self.homeassistant.api_port, + ATTR_SSL: self.homeassistant.api_ssl, + ATTR_WATCHDOG: self.homeassistant.watchdog, } @api_process @@ -63,6 +115,18 @@ class APIHomeAssistant(object): if ATTR_BOOT in body: self.homeassistant.boot = body[ATTR_BOOT] + if ATTR_PORT in body: + self.homeassistant.api_port = body[ATTR_PORT] + + if ATTR_PASSWORD in body: + self.homeassistant.api_password = body[ATTR_PASSWORD] + + if ATTR_SSL in body: + self.homeassistant.api_ssl = body[ATTR_SSL] + + if ATTR_WATCHDOG in body: + self.homeassistant.watchdog = body[ATTR_WATCHDOG] + return True @api_process @@ -105,3 +169,14 @@ class APIHomeAssistant(object): raise RuntimeError(message) return True + + async def api(self, request): + """Proxy API request to Home-Assistant.""" + path = request.match_info.get('path') + + client = await self.homeassistant_proxy(path, request) + return web.Response( + body=await client.read(), + status=client.status, + content_type=client.content_type + ) diff --git a/hassio/const.py b/hassio/const.py index 43e5bfa0f..807eb1dbb 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -16,7 +16,8 @@ RUN_UPDATE_SUPERVISOR_TASKS = 29100 RUN_UPDATE_ADDONS_TASKS = 57600 RUN_RELOAD_ADDONS_TASKS = 28800 RUN_RELOAD_SNAPSHOTS_TASKS = 72000 -RUN_WATCHDOG_HOMEASSISTANT = 15 +RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15 +RUN_WATCHDOG_HOMEASSISTANT_API = 300 RUN_CLEANUP_API_SESSIONS = 900 RESTART_EXIT_CODE = 100 @@ -50,7 +51,10 @@ RESULT_OK = 'ok' CONTENT_TYPE_BINARY = 'application/octet-stream' CONTENT_TYPE_PNG = 'image/png' +CONTENT_TYPE_JSON = 'application/json' +HEADER_HA_ACCESS = 'x-ha-access' +ATTR_WATCHDOG = 'watchdog' ATTR_DATE = 'date' ATTR_ARCH = 'arch' ATTR_HOSTNAME = 'hostname' @@ -71,6 +75,8 @@ ATTR_DESCRIPTON = 'description' ATTR_STARTUP = 'startup' ATTR_BOOT = 'boot' ATTR_PORTS = 'ports' +ATTR_PORT = 'port' +ATTR_SSL = 'ssl' ATTR_MAP = 'map' ATTR_WEBUI = 'webui' ATTR_OPTIONS = 'options' diff --git a/hassio/core.py b/hassio/core.py index 6feece8bf..be7d3cdcb 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -9,7 +9,7 @@ from .api import RestAPI from .host_control import HostControl from .const import ( RUN_UPDATE_INFO_TASKS, RUN_RELOAD_ADDONS_TASKS, - RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT, + RUN_UPDATE_SUPERVISOR_TASKS, RUN_WATCHDOG_HOMEASSISTANT_DOCKER, RUN_CLEANUP_API_SESSIONS, STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE, RUN_RELOAD_SNAPSHOTS_TASKS, RUN_UPDATE_ADDONS_TASKS) @@ -22,7 +22,8 @@ from .dns import DNSForward from .snapshots import SnapshotsManager from .updater import Updater from .tasks import ( - hassio_update, homeassistant_watchdog, api_sessions_cleanup, addons_update) + hassio_update, homeassistant_watchdog_docker, api_sessions_cleanup, + addons_update) from .tools import fetch_timezone _LOGGER = logging.getLogger(__name__) @@ -165,8 +166,12 @@ class HassIO(object): finally: # schedule homeassistant watchdog self.scheduler.register_task( - homeassistant_watchdog(self.loop, self.homeassistant), - RUN_WATCHDOG_HOMEASSISTANT) + homeassistant_watchdog_docker(self.loop, self.homeassistant), + RUN_WATCHDOG_HOMEASSISTANT_DOCKER) + + # self.scheduler.register_task( + # homeassistant_watchdog_api(self.loop, self.homeassistant), + # RUN_WATCHDOG_HOMEASSISTANT_API) # If landingpage / run upgrade in background if self.homeassistant.version == 'landingpage': @@ -179,6 +184,7 @@ class HassIO(object): # process stop tasks self.websession.close() + self.homeassistant.websession.close() await asyncio.wait([self.api.stop(), self.dns.stop()], loop=self.loop) self.exit_code = exit_code diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index a94868224..90ab43d6c 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -4,9 +4,14 @@ import logging import os import re +import aiohttp +from aiohttp.hdrs import CONTENT_TYPE +import async_timeout + from .const import ( FILE_HASSIO_HOMEASSISTANT, ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, - ATTR_VERSION, ATTR_BOOT) + ATTR_VERSION, ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, + HEADER_HA_ACCESS, CONTENT_TYPE_JSON) from .dock.homeassistant import DockerHomeAssistant from .tools import JsonConfig, convert_to_ascii from .validate import SCHEMA_HASS_CONFIG @@ -26,6 +31,9 @@ class HomeAssistant(JsonConfig): self.loop = loop self.updater = updater self.docker = DockerHomeAssistant(config, loop, docker, self) + self.api_ip = docker.network.gateway + self.websession = aiohttp.ClientSession( + connector=aiohttp.TCPConnector(verify_ssl=False), loop=loop) async def prepare(self): """Prepare HomeAssistant object.""" @@ -38,6 +46,57 @@ class HomeAssistant(JsonConfig): else: await self.docker.attach() + @property + def api_port(self): + """Return network port to home-assistant instance.""" + return self._data[ATTR_PORT] + + @api_port.setter + def api_port(self, value): + """Set network port for home-assistant instance.""" + self._data[ATTR_PORT] = value + self.save() + + @property + def api_password(self): + """Return password for home-assistant instance.""" + return self._data.get(ATTR_PASSWORD) + + @api_password.setter + def api_password(self, value): + """Set password for home-assistant instance.""" + self._data[ATTR_PASSWORD] = value + self.save() + + @property + def api_ssl(self): + """Return if we need ssl to home-assistant instance.""" + return self._data[ATTR_SSL] + + @api_ssl.setter + def api_ssl(self, value): + """Set SSL for home-assistant instance.""" + self._data[ATTR_SSL] = value + self.save() + + @property + def api_url(self): + """Return API url to Home-Assistant.""" + return "{}://{}:{}".format( + 'https' if self.api_ssl else 'http', self.api_ip, self.api_port + ) + + @property + def watchdog(self): + """Return True if the watchdog should protect Home-Assistant.""" + return self._data[ATTR_WATCHDOG] + + @watchdog.setter + def watchdog(self, value): + """Return True if the watchdog should protect Home-Assistant.""" + self._data[ATTR_WATCHDOG] = value + self._data.save() + @property def version(self): """Return version of running homeassistant.""" @@ -209,3 +268,23 @@ class HomeAssistant(JsonConfig): if exit_code != 0 or RE_YAML_ERROR.search(log): return (False, log) return (True, log) + + async def check_api_state(self): + """Check if Home-Assistant up and running.""" + url = "{}/api/".format(self.api_url) + header = {CONTENT_TYPE: CONTENT_TYPE_JSON} + + if self.api_password: + header.update({HEADER_HA_ACCESS: self.api_password}) + + try: + async with async_timeout.timeout(30, loop=self.loop): + async with self.websession.get(url, headers=header) as request: + status = request.status + + except (asyncio.TimeoutError, aiohttp.ClientError): + return False + + if status not in (200, 201): + _LOGGER.warning("Home-Assistant API config missmatch") + return True diff --git a/hassio/tasks.py b/hassio/tasks.py index a62a9c821..88768256c 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -62,18 +62,54 @@ def hassio_update(supervisor, updater): return _hassio_update -def homeassistant_watchdog(loop, homeassistant): - """Create scheduler task for montoring running state.""" - async def _homeassistant_watchdog(): - """Check running state and start if they is close.""" +def homeassistant_watchdog_docker(loop, homeassistant): + """Create scheduler task for montoring running state of docker.""" + async def _homeassistant_watchdog_docker(): + """Check running state of docker and start if they is close.""" # if Home-Assistant is active - if not await homeassistant.is_initialize(): + if not await homeassistant.is_initialize() or \ + not homeassistant.watchdog: return - # If Home-Assistant is running + # if Home-Assistant is running if homeassistant.in_progress or await homeassistant.is_running(): return loop.create_task(homeassistant.run()) + _LOGGER.error("Watchdog found a problem with Home-Assistant docker!") - return _homeassistant_watchdog + return _homeassistant_watchdog_docker + + +def homeassistant_watchdog_api(loop, homeassistant): + """Create scheduler task for montoring running state of API. + + Try 2 times to call API before we restart Home-Assistant. Maybe we had a + delay in our system. + """ + retry_scan = 0 + + async def _homeassistant_watchdog_api(): + """Check running state of API and start if they is close.""" + nonlocal retry_scan + + # if Home-Assistant is active + if not await homeassistant.is_initialize() or \ + not homeassistant.watchdog: + return + + # if Home-Assistant API is up + if homeassistant.in_progress or await homeassistant.check_api_state(): + return + retry_scan += 1 + + # Retry active + if retry_scan == 1: + _LOGGER.warning("Watchdog miss API response from Home-Assistant") + return + + loop.create_task(homeassistant.restart()) + _LOGGER.error("Watchdog found a problem with Home-Assistant API!") + retry_scan = 0 + + return _homeassistant_watchdog_api diff --git a/hassio/validate.py b/hassio/validate.py index 6004a989e..f0507c531 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -7,7 +7,8 @@ from .const import ( ATTR_DEVICES, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_SESSIONS, ATTR_PASSWORD, ATTR_TOTP, ATTR_SECURITY, ATTR_BETA_CHANNEL, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT, - ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT) + ATTR_HOMEASSISTANT, ATTR_HASSIO, ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, + ATTR_PORT, ATTR_WATCHDOG) NETWORK_PORT = vol.All(vol.Coerce(int), vol.Range(min=1, max=65535)) @@ -61,7 +62,11 @@ SCHEMA_HASS_CONFIG = vol.Schema({ vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): vol.Coerce(str), vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str), -}) + vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT, + vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)), + vol.Optional(ATTR_SSL, default=False): vol.Boolean(), + vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(), +}, extra=vol.REMOVE_EXTRA) # pylint: disable=no-value-for-parameter @@ -69,7 +74,7 @@ SCHEMA_UPDATER_CONFIG = vol.Schema({ vol.Optional(ATTR_BETA_CHANNEL, default=False): vol.Boolean(), vol.Optional(ATTR_HOMEASSISTANT): vol.Coerce(str), vol.Optional(ATTR_HASSIO): vol.Coerce(str), -}) +}, extra=vol.REMOVE_EXTRA) # pylint: disable=no-value-for-parameter