diff --git a/API.md b/API.md index 32f96620f..1b69a9c8f 100644 --- a/API.md +++ b/API.md @@ -1,4 +1,4 @@ -# Hass.io Server +# Hass.io ## Hass.io RESTful API @@ -27,6 +27,9 @@ For access to API you need set the `X-HASSIO-KEY` they will be available for Add ### Hass.io - GET `/supervisor/ping` + +This API call don't need a token. + - GET `/supervisor/info` The addons from `addons` are only installed one. @@ -412,6 +415,8 @@ Proxy to real websocket instance. ### RESTful for API addons +If a add-on will call itself, you can use `/addons/self/...`. + - GET `/addons` Get all available addons. @@ -510,7 +515,6 @@ Get all available addons. "CONTAINER": "port|[ip, port]" }, "options": {}, - "protected": "bool", "audio_output": "null|0,0", "audio_input": "null|0,0" } @@ -518,6 +522,16 @@ Get all available addons. Reset custom network/audio/options, set it `null`. +- POST `/addons/{addon}/security` + +This function is not callable by itself. + +```json +{ + "protected": "bool", +} +``` + - POST `/addons/{addon}/start` - POST `/addons/{addon}/stop` diff --git a/hassio/addons/__init__.py b/hassio/addons/__init__.py index a9d9817b7..d22884a2a 100644 --- a/hassio/addons/__init__.py +++ b/hassio/addons/__init__.py @@ -50,6 +50,13 @@ class AddonManager(CoreSysAttributes): return addon return None + def from_token(self, token): + """Return an add-on from hassio token.""" + for addon in self.list_addons: + if addon.is_installed and token == addon.hassio_token: + return addon + return None + async def load(self): """Startup addon management.""" self.data.reload() diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index 103d1bed4..7e207ff94 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -26,10 +26,11 @@ from ..const import ( ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS, - ATTR_PROTECTED, + ATTR_PROTECTED, ATTR_ACCESS_TOKEN, SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT) from ..coresys import CoreSysAttributes from ..docker.addon import DockerAddon +from ..utils import create_token from ..utils.json import write_json_file, read_json_file from ..utils.apparmor import adjust_profile from ..exceptions import HostAppArmorError @@ -172,6 +173,13 @@ class Addon(CoreSysAttributes): return self._data.user[self._id][ATTR_UUID] return None + @property + def hassio_token(self): + """Return access token for hass.io API.""" + if self.is_installed: + return self._data.user[self._id].get(ATTR_ACCESS_TOKEN) + return None + @property def description(self): """Return description of addon.""" @@ -686,6 +694,14 @@ class Addon(CoreSysAttributes): @check_installed async def start(self): """Set options and start addon.""" + if await self.instance.is_running(): + _LOGGER.warning("%s allready running!", self.slug) + return + + # Access Token + self._data.user[self._id][ATTR_ACCESS_TOKEN] = create_token() + self._data.save_data() + # Options if not self.write_options(): return False diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index d5881bead..275465a60 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -19,7 +19,7 @@ from ..const import ( ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED, - ATTR_FULL_ACCESS, + ATTR_FULL_ACCESS, ATTR_ACCESS_TOKEN, PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO, PRIVILEGED_IPC_LOCK, PRIVILEGED_SYS_TIME, PRIVILEGED_SYS_NICE, PRIVILEGED_SYS_RESOURCE) @@ -168,6 +168,7 @@ SCHEMA_ADDON_USER = vol.Schema({ vol.Required(ATTR_VERSION): vol.Coerce(str), vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): vol.Match(r"^[0-9a-f]{32}$"), + vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"), vol.Optional(ATTR_OPTIONS, default=dict): dict, vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(), vol.Optional(ATTR_BOOT): diff --git a/hassio/api/__init__.py b/hassio/api/__init__.py index b07aa00ea..94d04833b 100644 --- a/hassio/api/__init__.py +++ b/hassio/api/__init__.py @@ -158,6 +158,7 @@ class RestAPI(CoreSysAttributes): web.get('/addons/{addon}/logo', api_addons.logo), web.get('/addons/{addon}/changelog', api_addons.changelog), web.post('/addons/{addon}/stdin', api_addons.stdin), + web.post('/addons/{addon}/security', api_addons.security), web.get('/addons/{addon}/stats', api_addons.stats), ]) diff --git a/hassio/api/addons.py b/hassio/api/addons.py index 927271ddb..0e630c0bb 100644 --- a/hassio/api/addons.py +++ b/hassio/api/addons.py @@ -20,7 +20,8 @@ from ..const import ( ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES, ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING, - CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM) + CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, + REQUEST_FROM) from ..coresys import CoreSysAttributes from ..validate import DOCKER_PORTS, ALSA_DEVICE from ..exceptions import APINotSupportedError @@ -38,6 +39,10 @@ SCHEMA_OPTIONS = vol.Schema({ vol.Optional(ATTR_AUTO_UPDATE): vol.Boolean(), vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE, vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE, +}) + +# pylint: disable=no-value-for-parameter +SCHEMA_SECURITY = vol.Schema({ vol.Optional(ATTR_PROTECTED): vol.Boolean(), }) @@ -47,7 +52,13 @@ class APIAddons(CoreSysAttributes): def _extract_addon(self, request, check_installed=True): """Return addon, throw an exception it it doesn't exist.""" - addon = self.sys_addons.get(request.match_info.get('addon')) + addon_slug = request.match_info.get('addon') + + # Lookup itself + if addon_slug == 'self': + addon_slug = request.get(REQUEST_FROM) + + addon = self.sys_addons.get(addon_slug) if not addon: raise RuntimeError("Addon does not exist") @@ -157,11 +168,6 @@ class APIAddons(CoreSysAttributes): """Store user options for addon.""" addon = self._extract_addon(request) - # Have Access - if addon.slug == request[REQUEST_FROM]: - _LOGGER.error("Add-on can't self modify his options!") - raise APINotSupportedError() - addon_schema = SCHEMA_OPTIONS.extend({ vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema), }) @@ -180,6 +186,22 @@ class APIAddons(CoreSysAttributes): addon.audio_input = body[ATTR_AUDIO_INPUT] if ATTR_AUDIO_OUTPUT in body: addon.audio_output = body[ATTR_AUDIO_OUTPUT] + + addon.save_data() + return True + + @api_process + async def security(self, request): + """Store security options for addon.""" + addon = self._extract_addon(request) + + # Have Access + if addon.slug == request[REQUEST_FROM]: + _LOGGER.error("Can't self modify his security!") + raise APINotSupportedError() + + body = await api_validate(SCHEMA_SECURITY, request) + if ATTR_PROTECTED in body: _LOGGER.warning("Protected flag changing for %s!", addon.slug) addon.protected = body[ATTR_PROTECTED] diff --git a/hassio/api/security.py b/hassio/api/security.py index 2ecb67daa..c1f9d9b0e 100644 --- a/hassio/api/security.py +++ b/hassio/api/security.py @@ -3,18 +3,28 @@ import logging import re from aiohttp.web import middleware -from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp.web_exceptions import HTTPUnauthorized, HTTPForbidden from ..const import HEADER_TOKEN, REQUEST_FROM from ..coresys import CoreSysAttributes _LOGGER = logging.getLogger(__name__) -NO_SECURITY_CHECK = set(( - re.compile(r"^/homeassistant/api/.*$"), - re.compile(r"^/homeassistant/websocket$"), - re.compile(r"^/supervisor/ping$"), -)) +NO_SECURITY_CHECK = re.compile( + r"^(?:" + r"|/homeassistant/api/.*$" + r"|/homeassistant/websocket$" + r"|/supervisor/ping$" + r")$" +) + +ADDONS_API_BYPASS = re.compile( + r"^(?:" + r"|/homeassistant/info$" + r"|/supervisor/info$" + r"|/addons(?:/self/[^/]+)?$" + r")$" +) class SecurityMiddleware(CoreSysAttributes): @@ -27,33 +37,50 @@ class SecurityMiddleware(CoreSysAttributes): @middleware async def token_validation(self, request, handler): """Check security access of this layer.""" + request_from = None hassio_token = request.headers.get(HEADER_TOKEN) # Ignore security check - for rule in NO_SECURITY_CHECK: - if rule.match(request.path): - _LOGGER.debug("Passthrough %s", request.path) - return await handler(request) + if NO_SECURITY_CHECK.match(request.path): + _LOGGER.debug("Passthrough %s", request.path) + return await handler(request) + + # Not token + if not hassio_token: + _LOGGER.warning("No API token provided for %s", request.path) + raise HTTPUnauthorized() # Home-Assistant - if hassio_token == self.sys_homeassistant.uuid: + # UUID check need removed with 130 + if hassio_token in (self.sys_homeassistant.uuid, + self.sys_homeassistant.hassio_token): _LOGGER.debug("%s access from Home-Assistant", request.path) - request[REQUEST_FROM] = 'homeassistant' + request_from = 'homeassistant' # Host if hassio_token == self.sys_machine_id: _LOGGER.debug("%s access from Host", request.path) - request[REQUEST_FROM] = 'host' + request_from = 'host' # Add-on - addon = self.sys_addons.from_uuid(hassio_token) \ - if hassio_token else None - if addon: - _LOGGER.info("%s access from %s", request.path, addon.slug) - request[REQUEST_FROM] = addon.slug + addon = None + if hassio_token and not request_from: + addon = self.sys_addons.from_token(hassio_token) + # Need removed with 130 + if not addon: + addon = self.sys_addons.from_uuid(hassio_token) - if request.get(REQUEST_FROM): + # Check Add-on API access + if addon and addon.access_hassio_api: + _LOGGER.info("%s access from %s", request.path, addon.slug) + request_from = addon.slug + elif addon and ADDONS_API_BYPASS.match(request.path): + _LOGGER.debug("Passthrough %s from %s", request.path, addon.slug) + request_from = addon.slug + + if request_from: + request[REQUEST_FROM] = request_from return await handler(request) _LOGGER.warning("Invalid token for access %s", request.path) - raise HTTPUnauthorized() + raise HTTPForbidden() diff --git a/hassio/const.py b/hassio/const.py index 1cf43c08e..baf7ad2ba 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -178,6 +178,7 @@ ATTR_HASSOS_CLI = 'hassos_cli' ATTR_VERSION_CLI = 'version_cli' ATTR_VERSION_CLI_LATEST = 'version_cli_latest' ATTR_REFRESH_TOKEN = 'refresh_token' +ATTR_ACCESS_TOKEN = 'access_token' ATTR_DOCKER_API = 'docker_api' ATTR_FULL_ACCESS = 'full_access' ATTR_PROTECTED = 'protected' diff --git a/hassio/docker/addon.py b/hassio/docker/addon.py index 444c4d24a..232721b72 100644 --- a/hassio/docker/addon.py +++ b/hassio/docker/addon.py @@ -92,7 +92,7 @@ class DockerAddon(DockerInterface): return { **addon_env, ENV_TIME: self.sys_timezone, - ENV_TOKEN: self.addon.uuid, + ENV_TOKEN: self.addon.hassio_token, } @property diff --git a/hassio/docker/homeassistant.py b/hassio/docker/homeassistant.py index 556d355ea..e912a674f 100644 --- a/hassio/docker/homeassistant.py +++ b/hassio/docker/homeassistant.py @@ -62,7 +62,7 @@ class DockerHomeAssistant(DockerInterface): environment={ 'HASSIO': self.sys_docker.network.supervisor, ENV_TIME: self.sys_timezone, - ENV_TOKEN: self.sys_homeassistant.uuid, + ENV_TOKEN: self.sys_homeassistant.hassio_token, }, volumes={ str(self.sys_config.path_extern_homeassistant): diff --git a/hassio/homeassistant.py b/hassio/homeassistant.py index bc94be29d..819043475 100644 --- a/hassio/homeassistant.py +++ b/hassio/homeassistant.py @@ -16,14 +16,14 @@ 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, ATTR_REFRESH_TOKEN, + ATTR_WAIT_BOOT, ATTR_REFRESH_TOKEN, ATTR_ACCESS_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 import convert_to_ascii, process_lock, create_token from .utils.json import JsonConfig from .validate import SCHEMA_HASS_CONFIG @@ -185,6 +185,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): """Return a UUID of this HomeAssistant.""" return self._data[ATTR_UUID] + @property + def hassio_token(self): + """Return a access token for Hass.io API.""" + return self._data.get(ATTR_ACCESS_TOKEN) + @property def refresh_token(self): """Return the refresh token to authenticate with HomeAssistant.""" @@ -277,6 +282,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes): async def _start(self): """Start HomeAssistant docker & wait.""" + if await self.instance.is_running(): + _LOGGER.warning("HomeAssistant allready running!") + return + + # Create new API token + self._data[ATTR_ACCESS_TOKEN] = create_token() + self.save_data() + if not await self.instance.run(): raise HomeAssistantError() await self._block_till_run() diff --git a/hassio/utils/__init__.py b/hassio/utils/__init__.py index 57220352d..b192b7098 100644 --- a/hassio/utils/__init__.py +++ b/hassio/utils/__init__.py @@ -1,7 +1,9 @@ """Tools file for HassIO.""" from datetime import datetime +import hashlib import logging import re +import uuid _LOGGER = logging.getLogger(__name__) RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") @@ -12,6 +14,11 @@ def convert_to_ascii(raw): return RE_STRING.sub("", raw.decode()) +def create_token(): + """Create token for API access.""" + return hashlib.sha256(uuid.uuid4().bytes).hexdigest() + + def process_lock(method): """Wrap function with only run once.""" async def wrap_api(api, *args, **kwargs): diff --git a/hassio/validate.py b/hassio/validate.py index 86a8b1e85..b1cf8df84 100644 --- a/hassio/validate.py +++ b/hassio/validate.py @@ -10,6 +10,7 @@ from .const import ( 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, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI, + ATTR_ACCESS_TOKEN, CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV) @@ -84,6 +85,7 @@ DOCKER_PORTS = vol.Schema({ SCHEMA_HASS_CONFIG = vol.Schema({ vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): vol.Match(r"^[0-9a-f]{32}$"), + vol.Optional(ATTR_ACCESS_TOKEN): vol.Match(r"^[0-9a-f]{64}$"), vol.Optional(ATTR_BOOT, default=True): vol.Boolean(), vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE, vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),