mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-17 06:06:29 +00:00
Change access to API (#686)
* Update API.md * Update API.md * Update API.md * Update addons.py * Update addons.py * Update addons.py * Update addons.py * Update __init__.py * Update security.py * Update security.py * Update const.py * Update validate.py * Update __init__.py * Update validate.py * Update homeassistant.py * Update homeassistant.py * Update homeassistant.py * Update addon.py * Update addon.py * Update homeassistant.py * Fix lint * Fix lint * Backward combatibility * Make token more robust * Fix bug * Logic error * Fix access * fix valid
This commit is contained in:
parent
ff7f6a0b4c
commit
cecefd6972
18
API.md
18
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`
|
||||
|
@ -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()
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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),
|
||||
])
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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()
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
@ -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):
|
||||
|
@ -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()
|
||||
|
@ -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):
|
||||
|
@ -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),
|
||||
|
Loading…
x
Reference in New Issue
Block a user