mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-10-13 13:49:34 +00:00
Compare commits
56 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
ac824d3af6 | ||
![]() |
dd25c29544 | ||
![]() |
5cbdbffbb2 | ||
![]() |
bb81f14c2c | ||
![]() |
cecefd6972 | ||
![]() |
ff7f6a0b4c | ||
![]() |
1dc9f35e12 | ||
![]() |
051b63c7cc | ||
![]() |
aac4b9b24a | ||
![]() |
1a208a20b6 | ||
![]() |
b1e8722ead | ||
![]() |
a66af6e903 | ||
![]() |
0c345fc615 | ||
![]() |
087b082a6b | ||
![]() |
0b85209eae | ||
![]() |
d81bc7de46 | ||
![]() |
e3a99b9f89 | ||
![]() |
5d319b37ea | ||
![]() |
9f25606986 | ||
![]() |
ecd12732ee | ||
![]() |
85fbde8e36 | ||
![]() |
6e6c2c3efb | ||
![]() |
0d4a808449 | ||
![]() |
087f746647 | ||
![]() |
640d66ad1a | ||
![]() |
f5f5ed83af | ||
![]() |
95f01a1161 | ||
![]() |
b84e7e7d94 | ||
![]() |
5d7018f3f0 | ||
![]() |
d87a85ceb5 | ||
![]() |
9ab6e80b6f | ||
![]() |
78e91e859e | ||
![]() |
9eee8eade6 | ||
![]() |
124ce0b8b7 | ||
![]() |
00e7d96472 | ||
![]() |
398815efd8 | ||
![]() |
bdc2bdcf56 | ||
![]() |
68eafb0a7d | ||
![]() |
7ca2fd7193 | ||
![]() |
ec823edd8f | ||
![]() |
858c7a1fa7 | ||
![]() |
6ac45a24fc | ||
![]() |
9430b39042 | ||
![]() |
ae7466ccfe | ||
![]() |
2c17fe5da8 | ||
![]() |
a0fb91af29 | ||
![]() |
f626e31fd3 | ||
![]() |
0151a149fd | ||
![]() |
9dea93142b | ||
![]() |
7f878bfac0 | ||
![]() |
ebe9ae2341 | ||
![]() |
e777bbd024 | ||
![]() |
2116d56124 | ||
![]() |
0b6a82b018 | ||
![]() |
b4ea28af4e | ||
![]() |
22f59712df |
21
API.md
21
API.md
@@ -1,4 +1,4 @@
|
|||||||
# Hass.io Server
|
# Hass.io
|
||||||
|
|
||||||
## Hass.io RESTful API
|
## 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
|
### Hass.io
|
||||||
|
|
||||||
- GET `/supervisor/ping`
|
- GET `/supervisor/ping`
|
||||||
|
|
||||||
|
This API call don't need a token.
|
||||||
|
|
||||||
- GET `/supervisor/info`
|
- GET `/supervisor/info`
|
||||||
|
|
||||||
The addons from `addons` are only installed one.
|
The addons from `addons` are only installed one.
|
||||||
@@ -412,6 +415,8 @@ Proxy to real websocket instance.
|
|||||||
|
|
||||||
### RESTful for API addons
|
### RESTful for API addons
|
||||||
|
|
||||||
|
If a add-on will call itself, you can use `/addons/self/...`.
|
||||||
|
|
||||||
- GET `/addons`
|
- GET `/addons`
|
||||||
|
|
||||||
Get all available addons.
|
Get all available addons.
|
||||||
@@ -478,10 +483,14 @@ Get all available addons.
|
|||||||
"changelog": "bool",
|
"changelog": "bool",
|
||||||
"hassio_api": "bool",
|
"hassio_api": "bool",
|
||||||
"homeassistant_api": "bool",
|
"homeassistant_api": "bool",
|
||||||
|
"full_access": "bool",
|
||||||
|
"protected": "bool",
|
||||||
|
"rating": "1-6",
|
||||||
"stdin": "bool",
|
"stdin": "bool",
|
||||||
"webui": "null|http(s)://[HOST]:port/xy/zx",
|
"webui": "null|http(s)://[HOST]:port/xy/zx",
|
||||||
"gpio": "bool",
|
"gpio": "bool",
|
||||||
"devicetree": "bool",
|
"devicetree": "bool",
|
||||||
|
"docker_api": "bool",
|
||||||
"audio": "bool",
|
"audio": "bool",
|
||||||
"audio_input": "null|0,0",
|
"audio_input": "null|0,0",
|
||||||
"audio_output": "null|0,0",
|
"audio_output": "null|0,0",
|
||||||
@@ -513,6 +522,16 @@ Get all available addons.
|
|||||||
|
|
||||||
Reset custom network/audio/options, set it `null`.
|
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}/start`
|
||||||
|
|
||||||
- POST `/addons/{addon}/stop`
|
- POST `/addons/{addon}/stop`
|
||||||
|
28
Dockerfile
28
Dockerfile
@@ -1,24 +1,22 @@
|
|||||||
ARG BUILD_FROM
|
ARG BUILD_FROM
|
||||||
FROM $BUILD_FROM
|
FROM $BUILD_FROM
|
||||||
|
|
||||||
# Add env
|
# Install base
|
||||||
ENV LANG C.UTF-8
|
|
||||||
|
|
||||||
# Setup base
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
git \
|
git \
|
||||||
socat \
|
socat \
|
||||||
glib \
|
glib \
|
||||||
libstdc++ \
|
libstdc++ \
|
||||||
eudev-libs \
|
eudev-libs
|
||||||
&& apk add --no-cache --virtual .build-dependencies \
|
|
||||||
|
# Install requirements
|
||||||
|
COPY requirements.txt /usr/src/
|
||||||
|
RUN apk add --no-cache --virtual .build-dependencies \
|
||||||
make \
|
make \
|
||||||
g++ \
|
g++ \
|
||||||
&& pip3 install --no-cache-dir \
|
&& pip3 install --no-cache-dir -r /usr/src/requirements.txt \
|
||||||
uvloop==0.10.2 \
|
&& apk del .build-dependencies \
|
||||||
cchardet==2.1.1 \
|
&& rm -f /usr/src/requirements.txt
|
||||||
pycryptodome==3.6.4 \
|
|
||||||
&& apk del .build-dependencies
|
|
||||||
|
|
||||||
# Install HassIO
|
# Install HassIO
|
||||||
COPY . /usr/src/hassio
|
COPY . /usr/src/hassio
|
||||||
|
@@ -50,6 +50,13 @@ class AddonManager(CoreSysAttributes):
|
|||||||
return addon
|
return addon
|
||||||
return None
|
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):
|
async def load(self):
|
||||||
"""Startup addon management."""
|
"""Startup addon management."""
|
||||||
self.data.reload()
|
self.data.reload()
|
||||||
|
@@ -25,10 +25,12 @@ from ..const import (
|
|||||||
ATTR_HASSIO_API, ATTR_AUDIO, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
|
ATTR_HASSIO_API, ATTR_AUDIO, ATTR_AUDIO_OUTPUT, ATTR_AUDIO_INPUT,
|
||||||
ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC,
|
ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY, ATTR_HOST_IPC,
|
||||||
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES,
|
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_DISCOVERY, ATTR_SERVICES,
|
||||||
ATTR_APPARMOR, ATTR_DEVICETREE, SECURITY_PROFILE, SECURITY_DISABLE,
|
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_FULL_ACCESS,
|
||||||
SECURITY_DEFAULT)
|
ATTR_PROTECTED, ATTR_ACCESS_TOKEN,
|
||||||
|
SECURITY_PROFILE, SECURITY_DISABLE, SECURITY_DEFAULT)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..docker.addon import DockerAddon
|
from ..docker.addon import DockerAddon
|
||||||
|
from ..utils import create_token
|
||||||
from ..utils.json import write_json_file, read_json_file
|
from ..utils.json import write_json_file, read_json_file
|
||||||
from ..utils.apparmor import adjust_profile
|
from ..utils.apparmor import adjust_profile
|
||||||
from ..exceptions import HostAppArmorError
|
from ..exceptions import HostAppArmorError
|
||||||
@@ -171,6 +173,13 @@ class Addon(CoreSysAttributes):
|
|||||||
return self._data.user[self._id][ATTR_UUID]
|
return self._data.user[self._id][ATTR_UUID]
|
||||||
return None
|
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
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
"""Return description of addon."""
|
"""Return description of addon."""
|
||||||
@@ -201,6 +210,18 @@ class Addon(CoreSysAttributes):
|
|||||||
return self._data.cache[self._id][ATTR_VERSION]
|
return self._data.cache[self._id][ATTR_VERSION]
|
||||||
return self.version_installed
|
return self.version_installed
|
||||||
|
|
||||||
|
@property
|
||||||
|
def protected(self):
|
||||||
|
"""Return if addon is in protected mode."""
|
||||||
|
if self.is_installed:
|
||||||
|
return self._data.user[self._id][ATTR_PROTECTED]
|
||||||
|
return True
|
||||||
|
|
||||||
|
@protected.setter
|
||||||
|
def protected(self, value):
|
||||||
|
"""Set addon in protected mode."""
|
||||||
|
self._data.user[self._id][ATTR_PROTECTED] = value
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def startup(self):
|
def startup(self):
|
||||||
"""Return startup type of addon."""
|
"""Return startup type of addon."""
|
||||||
@@ -335,6 +356,11 @@ class Addon(CoreSysAttributes):
|
|||||||
"""Return if the add-on don't support hass labels."""
|
"""Return if the add-on don't support hass labels."""
|
||||||
return self._mesh.get(ATTR_LEGACY)
|
return self._mesh.get(ATTR_LEGACY)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def access_docker_api(self):
|
||||||
|
"""Return if the add-on need read-only docker API access."""
|
||||||
|
return self._mesh.get(ATTR_DOCKER_API)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def access_hassio_api(self):
|
def access_hassio_api(self):
|
||||||
"""Return True if the add-on access to hassio api."""
|
"""Return True if the add-on access to hassio api."""
|
||||||
@@ -355,6 +381,11 @@ class Addon(CoreSysAttributes):
|
|||||||
"""Return True if the add-on access to gpio interface."""
|
"""Return True if the add-on access to gpio interface."""
|
||||||
return self._mesh[ATTR_GPIO]
|
return self._mesh[ATTR_GPIO]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def with_full_access(self):
|
||||||
|
"""Return True if the add-on want full access to hardware."""
|
||||||
|
return self._mesh[ATTR_FULL_ACCESS]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def with_devicetree(self):
|
def with_devicetree(self):
|
||||||
"""Return True if the add-on read access to devicetree."""
|
"""Return True if the add-on read access to devicetree."""
|
||||||
@@ -663,6 +694,14 @@ class Addon(CoreSysAttributes):
|
|||||||
@check_installed
|
@check_installed
|
||||||
async def start(self):
|
async def start(self):
|
||||||
"""Set options and start addon."""
|
"""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
|
# Options
|
||||||
if not self.write_options():
|
if not self.write_options():
|
||||||
return False
|
return False
|
||||||
|
@@ -80,7 +80,7 @@ class AddonsData(JsonConfig, CoreSysAttributes):
|
|||||||
read_json_file(repository_file)
|
read_json_file(repository_file)
|
||||||
)
|
)
|
||||||
|
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
_LOGGER.warning("Can't read repository information from %s",
|
_LOGGER.warning("Can't read repository information from %s",
|
||||||
repository_file)
|
repository_file)
|
||||||
return
|
return
|
||||||
|
@@ -4,11 +4,53 @@ import hashlib
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from ..const import (
|
||||||
|
SECURITY_DISABLE, SECURITY_PROFILE, PRIVILEGED_NET_ADMIN,
|
||||||
|
PRIVILEGED_SYS_ADMIN, PRIVILEGED_SYS_RAWIO)
|
||||||
|
|
||||||
RE_SHA1 = re.compile(r"[a-f0-9]{8}")
|
RE_SHA1 = re.compile(r"[a-f0-9]{8}")
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def rating_security(addon):
|
||||||
|
"""Return 1-5 for security rating.
|
||||||
|
|
||||||
|
1 = not secure
|
||||||
|
5 = high secure
|
||||||
|
"""
|
||||||
|
rating = 5
|
||||||
|
|
||||||
|
# AppArmor
|
||||||
|
if addon.apparmor == SECURITY_DISABLE:
|
||||||
|
rating += -1
|
||||||
|
elif addon.apparmor == SECURITY_PROFILE:
|
||||||
|
rating += 1
|
||||||
|
|
||||||
|
# API Access
|
||||||
|
if addon.access_hassio_api or addon.access_homeassistant_api:
|
||||||
|
rating += -1
|
||||||
|
|
||||||
|
# Privileged options
|
||||||
|
if addon.privileged in (PRIVILEGED_NET_ADMIN, PRIVILEGED_SYS_ADMIN,
|
||||||
|
PRIVILEGED_SYS_RAWIO):
|
||||||
|
rating += -1
|
||||||
|
|
||||||
|
# Not secure Networking
|
||||||
|
if addon.host_network:
|
||||||
|
rating += -1
|
||||||
|
|
||||||
|
# Full Access
|
||||||
|
if addon.with_full_access:
|
||||||
|
rating += -2
|
||||||
|
|
||||||
|
# Docker Access
|
||||||
|
if addon.access_docker_api:
|
||||||
|
rating = 1
|
||||||
|
|
||||||
|
return max(min(6, rating), 1)
|
||||||
|
|
||||||
|
|
||||||
def get_hash_from_repository(name):
|
def get_hash_from_repository(name):
|
||||||
"""Generate a hash from repository."""
|
"""Generate a hash from repository."""
|
||||||
key = name.lower().encode()
|
key = name.lower().encode()
|
||||||
|
@@ -18,7 +18,11 @@ from ..const import (
|
|||||||
ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH,
|
ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API, ATTR_BUILD_FROM, ATTR_SQUASH,
|
||||||
ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY,
|
ATTR_ARGS, ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, ATTR_LEGACY,
|
||||||
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY,
|
ATTR_HOST_DBUS, ATTR_AUTO_UART, ATTR_SERVICES, ATTR_DISCOVERY,
|
||||||
ATTR_APPARMOR, ATTR_DEVICETREE)
|
ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API, ATTR_PROTECTED,
|
||||||
|
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)
|
||||||
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE
|
from ..validate import NETWORK_PORT, DOCKER_PORTS, ALSA_DEVICE
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
@@ -58,12 +62,13 @@ STARTUP_ALL = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
PRIVILEGED_ALL = [
|
PRIVILEGED_ALL = [
|
||||||
"NET_ADMIN",
|
PRIVILEGED_NET_ADMIN,
|
||||||
"SYS_ADMIN",
|
PRIVILEGED_SYS_ADMIN,
|
||||||
"SYS_RAWIO",
|
PRIVILEGED_SYS_RAWIO,
|
||||||
"IPC_LOCK",
|
PRIVILEGED_IPC_LOCK,
|
||||||
"SYS_TIME",
|
PRIVILEGED_SYS_TIME,
|
||||||
"SYS_NICE"
|
PRIVILEGED_SYS_NICE,
|
||||||
|
PRIVILEGED_SYS_RESOURCE,
|
||||||
]
|
]
|
||||||
|
|
||||||
BASE_IMAGE = {
|
BASE_IMAGE = {
|
||||||
@@ -109,6 +114,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
|
|||||||
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
|
vol.Optional(ATTR_ENVIRONMENT): {vol.Match(r"\w*"): vol.Coerce(str)},
|
||||||
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
|
vol.Optional(ATTR_PRIVILEGED): [vol.In(PRIVILEGED_ALL)],
|
||||||
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
|
vol.Optional(ATTR_APPARMOR, default=True): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_FULL_ACCESS, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
vol.Optional(ATTR_AUDIO, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
|
vol.Optional(ATTR_GPIO, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
|
vol.Optional(ATTR_DEVICETREE, default=False): vol.Boolean(),
|
||||||
@@ -116,6 +122,7 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
|
|||||||
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
|
vol.Optional(ATTR_HOMEASSISTANT_API, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_STDIN, default=False): vol.Boolean(),
|
vol.Optional(ATTR_STDIN, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_LEGACY, default=False): vol.Boolean(),
|
vol.Optional(ATTR_LEGACY, default=False): vol.Boolean(),
|
||||||
|
vol.Optional(ATTR_DOCKER_API, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
|
vol.Optional(ATTR_SERVICES): [vol.Match(RE_SERVICE)],
|
||||||
vol.Optional(ATTR_DISCOVERY): [vol.Match(RE_DISCOVERY)],
|
vol.Optional(ATTR_DISCOVERY): [vol.Match(RE_DISCOVERY)],
|
||||||
vol.Required(ATTR_OPTIONS): dict,
|
vol.Required(ATTR_OPTIONS): dict,
|
||||||
@@ -129,7 +136,8 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
|
|||||||
vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])
|
vol.Coerce(str): vol.Any(SCHEMA_ELEMENT, [SCHEMA_ELEMENT])
|
||||||
}))
|
}))
|
||||||
}), False),
|
}), False),
|
||||||
vol.Optional(ATTR_IMAGE): vol.Match(r"^[\w{}]+/[\-\w{}]+$"),
|
vol.Optional(ATTR_IMAGE):
|
||||||
|
vol.Match(r"^([a-zA-Z.:\d{}]+/)*?([\w{}]+)/([\-\w{}]+)$"),
|
||||||
vol.Optional(ATTR_TIMEOUT, default=10):
|
vol.Optional(ATTR_TIMEOUT, default=10):
|
||||||
vol.All(vol.Coerce(int), vol.Range(min=10, max=120)),
|
vol.All(vol.Coerce(int), vol.Range(min=10, max=120)),
|
||||||
}, extra=vol.REMOVE_EXTRA)
|
}, extra=vol.REMOVE_EXTRA)
|
||||||
@@ -160,6 +168,7 @@ SCHEMA_ADDON_USER = vol.Schema({
|
|||||||
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
vol.Required(ATTR_VERSION): vol.Coerce(str),
|
||||||
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
|
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
|
||||||
vol.Match(r"^[0-9a-f]{32}$"),
|
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_OPTIONS, default=dict): dict,
|
||||||
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
|
vol.Optional(ATTR_AUTO_UPDATE, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_BOOT):
|
vol.Optional(ATTR_BOOT):
|
||||||
@@ -167,6 +176,7 @@ SCHEMA_ADDON_USER = vol.Schema({
|
|||||||
vol.Optional(ATTR_NETWORK): DOCKER_PORTS,
|
vol.Optional(ATTR_NETWORK): DOCKER_PORTS,
|
||||||
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
|
vol.Optional(ATTR_AUDIO_OUTPUT): ALSA_DEVICE,
|
||||||
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
|
vol.Optional(ATTR_AUDIO_INPUT): ALSA_DEVICE,
|
||||||
|
vol.Optional(ATTR_PROTECTED, default=True): vol.Boolean(),
|
||||||
}, extra=vol.REMOVE_EXTRA)
|
}, extra=vol.REMOVE_EXTRA)
|
||||||
|
|
||||||
|
|
||||||
|
@@ -158,6 +158,7 @@ class RestAPI(CoreSysAttributes):
|
|||||||
web.get('/addons/{addon}/logo', api_addons.logo),
|
web.get('/addons/{addon}/logo', api_addons.logo),
|
||||||
web.get('/addons/{addon}/changelog', api_addons.changelog),
|
web.get('/addons/{addon}/changelog', api_addons.changelog),
|
||||||
web.post('/addons/{addon}/stdin', api_addons.stdin),
|
web.post('/addons/{addon}/stdin', api_addons.stdin),
|
||||||
|
web.post('/addons/{addon}/security', api_addons.security),
|
||||||
web.get('/addons/{addon}/stats', api_addons.stats),
|
web.get('/addons/{addon}/stats', api_addons.stats),
|
||||||
])
|
])
|
||||||
|
|
||||||
@@ -235,13 +236,16 @@ class RestAPI(CoreSysAttributes):
|
|||||||
async def start(self):
|
async def start(self):
|
||||||
"""Run rest api webserver."""
|
"""Run rest api webserver."""
|
||||||
await self._runner.setup()
|
await self._runner.setup()
|
||||||
self._site = web.TCPSite(self._runner, "0.0.0.0", 80)
|
self._site = web.TCPSite(
|
||||||
|
self._runner, host="0.0.0.0", port=80, shutdown_timeout=5)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await self._site.start()
|
await self._site.start()
|
||||||
except OSError as err:
|
except OSError as err:
|
||||||
_LOGGER.fatal(
|
_LOGGER.fatal(
|
||||||
"Failed to create HTTP server at 0.0.0.0:80 -> %s", err)
|
"Failed to create HTTP server at 0.0.0.0:80 -> %s", err)
|
||||||
|
else:
|
||||||
|
_LOGGER.info("Start API on %s", self.sys_docker.network.supervisor)
|
||||||
|
|
||||||
async def stop(self):
|
async def stop(self):
|
||||||
"""Stop rest api webserver."""
|
"""Stop rest api webserver."""
|
||||||
@@ -251,3 +255,5 @@ class RestAPI(CoreSysAttributes):
|
|||||||
# Shutdown running API
|
# Shutdown running API
|
||||||
await self._site.stop()
|
await self._site.stop()
|
||||||
await self._runner.cleanup()
|
await self._runner.cleanup()
|
||||||
|
|
||||||
|
_LOGGER.info("Stop API on %s", self.sys_docker.network.supervisor)
|
||||||
|
@@ -6,6 +6,7 @@ import voluptuous as vol
|
|||||||
from voluptuous.humanize import humanize_error
|
from voluptuous.humanize import humanize_error
|
||||||
|
|
||||||
from .utils import api_process, api_process_raw, api_validate
|
from .utils import api_process, api_process_raw, api_validate
|
||||||
|
from ..addons.utils import rating_security
|
||||||
from ..const import (
|
from ..const import (
|
||||||
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS,
|
ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS,
|
||||||
ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY,
|
ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY,
|
||||||
@@ -17,10 +18,13 @@ from ..const import (
|
|||||||
ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION,
|
ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION,
|
||||||
ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX,
|
ATTR_CPU_PERCENT, ATTR_MEMORY_LIMIT, ATTR_MEMORY_USAGE, ATTR_NETWORK_TX,
|
||||||
ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES,
|
ATTR_NETWORK_RX, ATTR_BLK_READ, ATTR_BLK_WRITE, ATTR_ICON, ATTR_SERVICES,
|
||||||
ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE,
|
ATTR_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API,
|
||||||
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT)
|
ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING,
|
||||||
|
CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT,
|
||||||
|
REQUEST_FROM)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..validate import DOCKER_PORTS, ALSA_DEVICE
|
from ..validate import DOCKER_PORTS, ALSA_DEVICE
|
||||||
|
from ..exceptions import APINotSupportedError
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -37,13 +41,24 @@ SCHEMA_OPTIONS = vol.Schema({
|
|||||||
vol.Optional(ATTR_AUDIO_INPUT): 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(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
class APIAddons(CoreSysAttributes):
|
class APIAddons(CoreSysAttributes):
|
||||||
"""Handle rest api for addons functions."""
|
"""Handle rest api for addons functions."""
|
||||||
|
|
||||||
def _extract_addon(self, request, check_installed=True):
|
def _extract_addon(self, request, check_installed=True):
|
||||||
"""Return addon, throw an exception it it doesn't exist."""
|
"""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:
|
if not addon:
|
||||||
raise RuntimeError("Addon does not exist")
|
raise RuntimeError("Addon does not exist")
|
||||||
|
|
||||||
@@ -116,6 +131,8 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_REPOSITORY: addon.repository,
|
ATTR_REPOSITORY: addon.repository,
|
||||||
ATTR_LAST_VERSION: addon.last_version,
|
ATTR_LAST_VERSION: addon.last_version,
|
||||||
ATTR_STATE: await addon.state(),
|
ATTR_STATE: await addon.state(),
|
||||||
|
ATTR_PROTECTED: addon.protected,
|
||||||
|
ATTR_RATING: rating_security(addon),
|
||||||
ATTR_BOOT: addon.boot,
|
ATTR_BOOT: addon.boot,
|
||||||
ATTR_OPTIONS: addon.options,
|
ATTR_OPTIONS: addon.options,
|
||||||
ATTR_URL: addon.url,
|
ATTR_URL: addon.url,
|
||||||
@@ -126,6 +143,7 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_HOST_IPC: addon.host_ipc,
|
ATTR_HOST_IPC: addon.host_ipc,
|
||||||
ATTR_HOST_DBUS: addon.host_dbus,
|
ATTR_HOST_DBUS: addon.host_dbus,
|
||||||
ATTR_PRIVILEGED: addon.privileged,
|
ATTR_PRIVILEGED: addon.privileged,
|
||||||
|
ATTR_FULL_ACCESS: addon.with_full_access,
|
||||||
ATTR_APPARMOR: addon.apparmor,
|
ATTR_APPARMOR: addon.apparmor,
|
||||||
ATTR_DEVICES: self._pretty_devices(addon),
|
ATTR_DEVICES: self._pretty_devices(addon),
|
||||||
ATTR_ICON: addon.with_icon,
|
ATTR_ICON: addon.with_icon,
|
||||||
@@ -137,6 +155,7 @@ class APIAddons(CoreSysAttributes):
|
|||||||
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
|
ATTR_HOMEASSISTANT_API: addon.access_homeassistant_api,
|
||||||
ATTR_GPIO: addon.with_gpio,
|
ATTR_GPIO: addon.with_gpio,
|
||||||
ATTR_DEVICETREE: addon.with_devicetree,
|
ATTR_DEVICETREE: addon.with_devicetree,
|
||||||
|
ATTR_DOCKER_API: addon.access_docker_api,
|
||||||
ATTR_AUDIO: addon.with_audio,
|
ATTR_AUDIO: addon.with_audio,
|
||||||
ATTR_AUDIO_INPUT: addon.audio_input,
|
ATTR_AUDIO_INPUT: addon.audio_input,
|
||||||
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
ATTR_AUDIO_OUTPUT: addon.audio_output,
|
||||||
@@ -171,6 +190,25 @@ class APIAddons(CoreSysAttributes):
|
|||||||
addon.save_data()
|
addon.save_data()
|
||||||
return True
|
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]
|
||||||
|
|
||||||
|
addon.save_data()
|
||||||
|
return True
|
||||||
|
|
||||||
@api_process
|
@api_process
|
||||||
async def stats(self, request):
|
async def stats(self, request):
|
||||||
"""Return resource information."""
|
"""Return resource information."""
|
||||||
|
@@ -21,17 +21,16 @@ _LOGGER = logging.getLogger(__name__)
|
|||||||
SCHEMA_OPTIONS = vol.Schema({
|
SCHEMA_OPTIONS = vol.Schema({
|
||||||
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
vol.Optional(ATTR_BOOT): vol.Boolean(),
|
||||||
vol.Inclusive(ATTR_IMAGE, 'custom_hass'):
|
vol.Inclusive(ATTR_IMAGE, 'custom_hass'):
|
||||||
vol.Any(None, vol.Coerce(str)),
|
vol.Maybe(vol.Coerce(str)),
|
||||||
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
|
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'):
|
||||||
vol.Any(None, DOCKER_IMAGE),
|
vol.Any(None, DOCKER_IMAGE),
|
||||||
vol.Optional(ATTR_PORT): NETWORK_PORT,
|
vol.Optional(ATTR_PORT): NETWORK_PORT,
|
||||||
vol.Optional(ATTR_PASSWORD): vol.Any(None, vol.Coerce(str)),
|
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
|
||||||
vol.Optional(ATTR_SSL): vol.Boolean(),
|
vol.Optional(ATTR_SSL): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WAIT_BOOT):
|
vol.Optional(ATTR_WAIT_BOOT):
|
||||||
vol.All(vol.Coerce(int), vol.Range(min=60)),
|
vol.All(vol.Coerce(int), vol.Range(min=60)),
|
||||||
# Required once we enforce user system
|
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
|
||||||
vol.Optional(ATTR_REFRESH_TOKEN): str,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
SCHEMA_VERSION = vol.Schema({
|
SCHEMA_VERSION = vol.Schema({
|
||||||
|
@@ -23,7 +23,11 @@ class APIProxy(CoreSysAttributes):
|
|||||||
def _check_access(self, request):
|
def _check_access(self, request):
|
||||||
"""Check the Hass.io token."""
|
"""Check the Hass.io token."""
|
||||||
hassio_token = request.headers.get(HEADER_HA_ACCESS)
|
hassio_token = request.headers.get(HEADER_HA_ACCESS)
|
||||||
addon = self.sys_addons.from_uuid(hassio_token)
|
addon = self.sys_addons.from_token(hassio_token)
|
||||||
|
|
||||||
|
# Need removed with 131
|
||||||
|
if not addon:
|
||||||
|
addon = self.sys_addons.from_uuid(hassio_token)
|
||||||
|
|
||||||
if not addon:
|
if not addon:
|
||||||
_LOGGER.warning("Unknown HomeAssistant API access!")
|
_LOGGER.warning("Unknown HomeAssistant API access!")
|
||||||
@@ -59,6 +63,8 @@ class APIProxy(CoreSysAttributes):
|
|||||||
|
|
||||||
except HomeAssistantAuthError:
|
except HomeAssistantAuthError:
|
||||||
_LOGGER.error("Authenticate error on API for request %s", path)
|
_LOGGER.error("Authenticate error on API for request %s", path)
|
||||||
|
except HomeAssistantAPIError:
|
||||||
|
_LOGGER.error("Error on API for request %s", path)
|
||||||
except aiohttp.ClientError as err:
|
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:
|
except asyncio.TimeoutError:
|
||||||
@@ -148,11 +154,12 @@ class APIProxy(CoreSysAttributes):
|
|||||||
self.sys_homeassistant.access_token = None
|
self.sys_homeassistant.access_token = None
|
||||||
return await self._websocket_client()
|
return await self._websocket_client()
|
||||||
|
|
||||||
_LOGGER.error(
|
raise HomeAssistantAuthError()
|
||||||
"Failed authentication to Home-Assistant websocket: %s", data)
|
|
||||||
|
|
||||||
except (RuntimeError, HomeAssistantAPIError) as err:
|
except (RuntimeError, ValueError) as err:
|
||||||
_LOGGER.error("Client error on websocket API %s.", err)
|
_LOGGER.error("Client error on websocket API %s.", err)
|
||||||
|
except HomeAssistantAuthError as err:
|
||||||
|
_LOGGER.error("Failed authentication to HomeAssistant websocket")
|
||||||
|
|
||||||
raise HTTPBadGateway()
|
raise HTTPBadGateway()
|
||||||
|
|
||||||
@@ -175,7 +182,11 @@ class APIProxy(CoreSysAttributes):
|
|||||||
response = await server.receive_json()
|
response = await server.receive_json()
|
||||||
hassio_token = (response.get('api_password') or
|
hassio_token = (response.get('api_password') or
|
||||||
response.get('access_token'))
|
response.get('access_token'))
|
||||||
addon = self.sys_addons.from_uuid(hassio_token)
|
addon = self.sys_addons.from_token(hassio_token)
|
||||||
|
|
||||||
|
# Need removed with 131
|
||||||
|
if not addon:
|
||||||
|
addon = self.sys_addons.from_uuid(hassio_token)
|
||||||
|
|
||||||
if not addon or not addon.access_homeassistant_api:
|
if not addon or not addon.access_homeassistant_api:
|
||||||
_LOGGER.warning("Unauthorized websocket access!")
|
_LOGGER.warning("Unauthorized websocket access!")
|
||||||
|
@@ -3,18 +3,28 @@ import logging
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from aiohttp.web import middleware
|
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 ..const import HEADER_TOKEN, REQUEST_FROM
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
NO_SECURITY_CHECK = set((
|
NO_SECURITY_CHECK = re.compile(
|
||||||
re.compile(r"^/homeassistant/api/.*$"),
|
r"^(?:"
|
||||||
re.compile(r"^/homeassistant/websocket$"),
|
r"|/homeassistant/api/.*$"
|
||||||
re.compile(r"^/supervisor/ping$"),
|
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):
|
class SecurityMiddleware(CoreSysAttributes):
|
||||||
@@ -27,33 +37,50 @@ class SecurityMiddleware(CoreSysAttributes):
|
|||||||
@middleware
|
@middleware
|
||||||
async def token_validation(self, request, handler):
|
async def token_validation(self, request, handler):
|
||||||
"""Check security access of this layer."""
|
"""Check security access of this layer."""
|
||||||
|
request_from = None
|
||||||
hassio_token = request.headers.get(HEADER_TOKEN)
|
hassio_token = request.headers.get(HEADER_TOKEN)
|
||||||
|
|
||||||
# Ignore security check
|
# Ignore security check
|
||||||
for rule in NO_SECURITY_CHECK:
|
if NO_SECURITY_CHECK.match(request.path):
|
||||||
if rule.match(request.path):
|
_LOGGER.debug("Passthrough %s", request.path)
|
||||||
_LOGGER.debug("Passthrough %s", request.path)
|
return await handler(request)
|
||||||
return await handler(request)
|
|
||||||
|
# Not token
|
||||||
|
if not hassio_token:
|
||||||
|
_LOGGER.warning("No API token provided for %s", request.path)
|
||||||
|
raise HTTPUnauthorized()
|
||||||
|
|
||||||
# Home-Assistant
|
# Home-Assistant
|
||||||
if hassio_token == self.sys_homeassistant.uuid:
|
# UUID check need removed with 131
|
||||||
|
if hassio_token in (self.sys_homeassistant.uuid,
|
||||||
|
self.sys_homeassistant.hassio_token):
|
||||||
_LOGGER.debug("%s access from Home-Assistant", request.path)
|
_LOGGER.debug("%s access from Home-Assistant", request.path)
|
||||||
request[REQUEST_FROM] = 'homeassistant'
|
request_from = 'homeassistant'
|
||||||
|
|
||||||
# Host
|
# Host
|
||||||
if hassio_token == self.sys_machine_id:
|
if hassio_token == self.sys_machine_id:
|
||||||
_LOGGER.debug("%s access from Host", request.path)
|
_LOGGER.debug("%s access from Host", request.path)
|
||||||
request[REQUEST_FROM] = 'host'
|
request_from = 'host'
|
||||||
|
|
||||||
# Add-on
|
# Add-on
|
||||||
addon = self.sys_addons.from_uuid(hassio_token) \
|
addon = None
|
||||||
if hassio_token else None
|
if hassio_token and not request_from:
|
||||||
if addon:
|
addon = self.sys_addons.from_token(hassio_token)
|
||||||
_LOGGER.info("%s access from %s", request.path, addon.slug)
|
# Need removed with 131
|
||||||
request[REQUEST_FROM] = addon.slug
|
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)
|
return await handler(request)
|
||||||
|
|
||||||
_LOGGER.warning("Invalid token for access %s", request.path)
|
_LOGGER.warning("Invalid token for access %s", request.path)
|
||||||
raise HTTPUnauthorized()
|
raise HTTPForbidden()
|
||||||
|
@@ -30,10 +30,10 @@ def api_process(method):
|
|||||||
"""Return api information."""
|
"""Return api information."""
|
||||||
try:
|
try:
|
||||||
answer = await method(api, *args, **kwargs)
|
answer = await method(api, *args, **kwargs)
|
||||||
except RuntimeError as err:
|
|
||||||
return api_return_error(message=str(err))
|
|
||||||
except HassioError:
|
except HassioError:
|
||||||
return api_return_error()
|
return api_return_error()
|
||||||
|
except RuntimeError as err:
|
||||||
|
return api_return_error(message=str(err))
|
||||||
|
|
||||||
if isinstance(answer, dict):
|
if isinstance(answer, dict):
|
||||||
return api_return_ok(data=answer)
|
return api_return_ok(data=answer)
|
||||||
|
@@ -66,10 +66,11 @@ def initialize_system_data(coresys):
|
|||||||
config = coresys.config
|
config = coresys.config
|
||||||
|
|
||||||
# homeassistant config folder
|
# homeassistant config folder
|
||||||
if not config.path_config.is_dir():
|
if not config.path_homeassistant.is_dir():
|
||||||
_LOGGER.info(
|
_LOGGER.info(
|
||||||
"Create Home-Assistant config folder %s", config.path_config)
|
"Create Home-Assistant config folder %s",
|
||||||
config.path_config.mkdir()
|
config.path_homeassistant)
|
||||||
|
config.path_homeassistant.mkdir()
|
||||||
|
|
||||||
# hassio ssl folder
|
# hassio ssl folder
|
||||||
if not config.path_ssl.is_dir():
|
if not config.path_ssl.is_dir():
|
||||||
|
@@ -2,8 +2,11 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from pathlib import Path, PurePath
|
from pathlib import Path, PurePath
|
||||||
|
|
||||||
|
import pytz
|
||||||
|
|
||||||
from .const import (
|
from .const import (
|
||||||
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
|
FILE_HASSIO_CONFIG, HASSIO_DATA, ATTR_TIMEZONE, ATTR_ADDONS_CUSTOM_LIST,
|
||||||
ATTR_LAST_BOOT, ATTR_WAIT_BOOT)
|
ATTR_LAST_BOOT, ATTR_WAIT_BOOT)
|
||||||
@@ -29,6 +32,8 @@ APPARMOR_DATA = PurePath("apparmor")
|
|||||||
|
|
||||||
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
|
DEFAULT_BOOT_TIME = datetime.utcfromtimestamp(0).isoformat()
|
||||||
|
|
||||||
|
RE_TIMEZONE = re.compile(r"time_zone: (?P<timezone>[\w/\-+]+)")
|
||||||
|
|
||||||
|
|
||||||
class CoreConfig(JsonConfig):
|
class CoreConfig(JsonConfig):
|
||||||
"""Hold all core config data."""
|
"""Hold all core config data."""
|
||||||
@@ -40,7 +45,21 @@ class CoreConfig(JsonConfig):
|
|||||||
@property
|
@property
|
||||||
def timezone(self):
|
def timezone(self):
|
||||||
"""Return system timezone."""
|
"""Return system timezone."""
|
||||||
return self._data[ATTR_TIMEZONE]
|
config_file = Path(self.path_homeassistant, 'configuration.yaml')
|
||||||
|
try:
|
||||||
|
assert config_file.exists()
|
||||||
|
configuration = config_file.read_text()
|
||||||
|
|
||||||
|
data = RE_TIMEZONE.search(configuration)
|
||||||
|
assert data
|
||||||
|
|
||||||
|
timezone = data.group('timezone')
|
||||||
|
pytz.timezone(timezone)
|
||||||
|
except (pytz.exceptions.UnknownTimeZoneError, OSError, AssertionError):
|
||||||
|
_LOGGER.debug("Can't parse HomeAssistant timezone")
|
||||||
|
return self._data[ATTR_TIMEZONE]
|
||||||
|
|
||||||
|
return timezone
|
||||||
|
|
||||||
@timezone.setter
|
@timezone.setter
|
||||||
def timezone(self, value):
|
def timezone(self, value):
|
||||||
@@ -83,12 +102,12 @@ class CoreConfig(JsonConfig):
|
|||||||
return PurePath(os.environ['SUPERVISOR_SHARE'])
|
return PurePath(os.environ['SUPERVISOR_SHARE'])
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_extern_config(self):
|
def path_extern_homeassistant(self):
|
||||||
"""Return config path extern for docker."""
|
"""Return config path extern for docker."""
|
||||||
return str(PurePath(self.path_extern_hassio, HOMEASSISTANT_CONFIG))
|
return str(PurePath(self.path_extern_hassio, HOMEASSISTANT_CONFIG))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def path_config(self):
|
def path_homeassistant(self):
|
||||||
"""Return config path inside supervisor."""
|
"""Return config path inside supervisor."""
|
||||||
return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG)
|
return Path(HASSIO_DATA, HOMEASSISTANT_CONFIG)
|
||||||
|
|
||||||
|
@@ -2,7 +2,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from ipaddress import ip_network
|
from ipaddress import ip_network
|
||||||
|
|
||||||
HASSIO_VERSION = '118'
|
HASSIO_VERSION = '130'
|
||||||
|
|
||||||
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
|
URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons"
|
||||||
URL_HASSIO_VERSION = \
|
URL_HASSIO_VERSION = \
|
||||||
@@ -178,6 +178,11 @@ ATTR_HASSOS_CLI = 'hassos_cli'
|
|||||||
ATTR_VERSION_CLI = 'version_cli'
|
ATTR_VERSION_CLI = 'version_cli'
|
||||||
ATTR_VERSION_CLI_LATEST = 'version_cli_latest'
|
ATTR_VERSION_CLI_LATEST = 'version_cli_latest'
|
||||||
ATTR_REFRESH_TOKEN = 'refresh_token'
|
ATTR_REFRESH_TOKEN = 'refresh_token'
|
||||||
|
ATTR_ACCESS_TOKEN = 'access_token'
|
||||||
|
ATTR_DOCKER_API = 'docker_api'
|
||||||
|
ATTR_FULL_ACCESS = 'full_access'
|
||||||
|
ATTR_PROTECTED = 'protected'
|
||||||
|
ATTR_RATING = 'rating'
|
||||||
|
|
||||||
SERVICE_MQTT = 'mqtt'
|
SERVICE_MQTT = 'mqtt'
|
||||||
|
|
||||||
@@ -226,6 +231,14 @@ SECURITY_PROFILE = 'profile'
|
|||||||
SECURITY_DEFAULT = 'default'
|
SECURITY_DEFAULT = 'default'
|
||||||
SECURITY_DISABLE = 'disable'
|
SECURITY_DISABLE = 'disable'
|
||||||
|
|
||||||
|
PRIVILEGED_NET_ADMIN = 'NET_ADMIN'
|
||||||
|
PRIVILEGED_SYS_ADMIN = 'SYS_ADMIN'
|
||||||
|
PRIVILEGED_SYS_RAWIO = 'SYS_RAWIO'
|
||||||
|
PRIVILEGED_IPC_LOCK = 'IPC_LOCK'
|
||||||
|
PRIVILEGED_SYS_TIME = 'SYS_TIME'
|
||||||
|
PRIVILEGED_SYS_NICE = 'SYS_NICE'
|
||||||
|
PRIVILEGED_SYS_RESOURCE = 'SYS_RESOURCE'
|
||||||
|
|
||||||
FEATURES_SHUTDOWN = 'shutdown'
|
FEATURES_SHUTDOWN = 'shutdown'
|
||||||
FEATURES_REBOOT = 'reboot'
|
FEATURES_REBOOT = 'reboot'
|
||||||
FEATURES_HASSOS = 'hassos'
|
FEATURES_HASSOS = 'hassos'
|
||||||
|
@@ -3,6 +3,8 @@ from contextlib import suppress
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
from .coresys import CoreSysAttributes
|
from .coresys import CoreSysAttributes
|
||||||
from .const import (
|
from .const import (
|
||||||
STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE)
|
STARTUP_SYSTEM, STARTUP_SERVICES, STARTUP_APPLICATION, STARTUP_INITIALIZE)
|
||||||
@@ -65,7 +67,6 @@ class HassIO(CoreSysAttributes):
|
|||||||
|
|
||||||
# start api
|
# start api
|
||||||
await self.sys_api.start()
|
await self.sys_api.start()
|
||||||
_LOGGER.info("Start API on %s", self.sys_docker.network.supervisor)
|
|
||||||
|
|
||||||
# start addon mark as initialize
|
# start addon mark as initialize
|
||||||
await self.sys_addons.boot(STARTUP_INITIALIZE)
|
await self.sys_addons.boot(STARTUP_INITIALIZE)
|
||||||
@@ -113,12 +114,18 @@ class HassIO(CoreSysAttributes):
|
|||||||
self.sys_scheduler.suspend = True
|
self.sys_scheduler.suspend = True
|
||||||
|
|
||||||
# process async stop tasks
|
# process async stop tasks
|
||||||
await asyncio.wait([
|
try:
|
||||||
self.sys_api.stop(),
|
with async_timeout.timeout(10):
|
||||||
self.sys_dns.stop(),
|
await asyncio.wait([
|
||||||
self.sys_websession.close(),
|
self.sys_api.stop(),
|
||||||
self.sys_websession_ssl.close()
|
self.sys_dns.stop(),
|
||||||
])
|
self.sys_websession.close(),
|
||||||
|
self.sys_websession_ssl.close()
|
||||||
|
])
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.warning("Force Shutdown!")
|
||||||
|
|
||||||
|
_LOGGER.info("Hass.io is down")
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
"""Shutdown all running containers in correct order."""
|
"""Shutdown all running containers in correct order."""
|
||||||
|
@@ -66,6 +66,11 @@ class CoreSys:
|
|||||||
"""Return True if we run dev modus."""
|
"""Return True if we run dev modus."""
|
||||||
return self._updater.channel == CHANNEL_DEV
|
return self._updater.channel == CHANNEL_DEV
|
||||||
|
|
||||||
|
@property
|
||||||
|
def timezone(self):
|
||||||
|
"""Return timezone."""
|
||||||
|
return self._config.timezone
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def loop(self):
|
def loop(self):
|
||||||
"""Return loop object."""
|
"""Return loop object."""
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
"""Init file for HassIO addon docker object."""
|
"""Init file for HassIO addon docker object."""
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import docker
|
import docker
|
||||||
import requests
|
import requests
|
||||||
@@ -66,6 +67,11 @@ class DockerAddon(DockerInterface):
|
|||||||
return 'host'
|
return 'host'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def full_access(self):
|
||||||
|
"""Return True if full access is enabled."""
|
||||||
|
return not self.addon.protected and self.addon.with_full_access
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hostname(self):
|
def hostname(self):
|
||||||
"""Return slug/id of addon."""
|
"""Return slug/id of addon."""
|
||||||
@@ -85,8 +91,8 @@ class DockerAddon(DockerInterface):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
**addon_env,
|
**addon_env,
|
||||||
ENV_TIME: self.sys_config.timezone,
|
ENV_TIME: self.sys_timezone,
|
||||||
ENV_TOKEN: self.addon.uuid,
|
ENV_TOKEN: self.addon.hassio_token,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -172,7 +178,7 @@ class DockerAddon(DockerInterface):
|
|||||||
# setup config mappings
|
# setup config mappings
|
||||||
if MAP_CONFIG in addon_mapping:
|
if MAP_CONFIG in addon_mapping:
|
||||||
volumes.update({
|
volumes.update({
|
||||||
str(self.sys_config.path_extern_config): {
|
str(self.sys_config.path_extern_homeassistant): {
|
||||||
'bind': "/config", 'mode': addon_mapping[MAP_CONFIG]
|
'bind': "/config", 'mode': addon_mapping[MAP_CONFIG]
|
||||||
}})
|
}})
|
||||||
|
|
||||||
@@ -204,14 +210,14 @@ class DockerAddon(DockerInterface):
|
|||||||
|
|
||||||
# GPIO support
|
# GPIO support
|
||||||
if self.addon.with_gpio:
|
if self.addon.with_gpio:
|
||||||
volumes.update({
|
for gpio_path in ("/sys/class/gpio", "/sys/devices/platform/soc"):
|
||||||
"/sys/class/gpio": {
|
if not Path(gpio_path).exists():
|
||||||
'bind': "/sys/class/gpio", 'mode': 'rw'
|
continue
|
||||||
},
|
volumes.update({
|
||||||
"/sys/devices/platform/soc": {
|
gpio_path: {
|
||||||
'bind': "/sys/devices/platform/soc", 'mode': 'rw'
|
'bind': gpio_path, 'mode': 'rw'
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
# DeviceTree support
|
# DeviceTree support
|
||||||
if self.addon.with_devicetree:
|
if self.addon.with_devicetree:
|
||||||
@@ -221,6 +227,14 @@ class DockerAddon(DockerInterface):
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
# Docker API support
|
||||||
|
if not self.addon.protected and self.addon.access_docker_api:
|
||||||
|
volumes.update({
|
||||||
|
"/var/run/docker.sock": {
|
||||||
|
'bind': "/var/run/docker.sock", 'mode': 'ro'
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
# Host dbus system
|
# Host dbus system
|
||||||
if self.addon.host_dbus:
|
if self.addon.host_dbus:
|
||||||
volumes.update({
|
volumes.update({
|
||||||
@@ -245,6 +259,11 @@ class DockerAddon(DockerInterface):
|
|||||||
if self._is_running():
|
if self._is_running():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Security check
|
||||||
|
if not self.addon.protected:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"%s run with disabled proteced mode!", self.addon.name)
|
||||||
|
|
||||||
# cleanup
|
# cleanup
|
||||||
self._stop()
|
self._stop()
|
||||||
|
|
||||||
@@ -254,6 +273,7 @@ class DockerAddon(DockerInterface):
|
|||||||
hostname=self.hostname,
|
hostname=self.hostname,
|
||||||
detach=True,
|
detach=True,
|
||||||
init=True,
|
init=True,
|
||||||
|
privileged=self.full_access,
|
||||||
ipc_mode=self.ipc,
|
ipc_mode=self.ipc,
|
||||||
stdin_open=self.addon.with_stdin,
|
stdin_open=self.addon.with_stdin,
|
||||||
network_mode=self.network_mode,
|
network_mode=self.network_mode,
|
||||||
|
@@ -61,11 +61,11 @@ class DockerHomeAssistant(DockerInterface):
|
|||||||
network_mode='host',
|
network_mode='host',
|
||||||
environment={
|
environment={
|
||||||
'HASSIO': self.sys_docker.network.supervisor,
|
'HASSIO': self.sys_docker.network.supervisor,
|
||||||
ENV_TIME: self.sys_config.timezone,
|
ENV_TIME: self.sys_timezone,
|
||||||
ENV_TOKEN: self.sys_homeassistant.uuid,
|
ENV_TOKEN: self.sys_homeassistant.hassio_token,
|
||||||
},
|
},
|
||||||
volumes={
|
volumes={
|
||||||
str(self.sys_config.path_extern_config):
|
str(self.sys_config.path_extern_homeassistant):
|
||||||
{'bind': '/config', 'mode': 'rw'},
|
{'bind': '/config', 'mode': 'rw'},
|
||||||
str(self.sys_config.path_extern_ssl):
|
str(self.sys_config.path_extern_ssl):
|
||||||
{'bind': '/ssl', 'mode': 'ro'},
|
{'bind': '/ssl', 'mode': 'ro'},
|
||||||
@@ -95,13 +95,15 @@ class DockerHomeAssistant(DockerInterface):
|
|||||||
stdout=True,
|
stdout=True,
|
||||||
stderr=True,
|
stderr=True,
|
||||||
environment={
|
environment={
|
||||||
ENV_TIME: self.sys_config.timezone,
|
ENV_TIME: self.sys_timezone,
|
||||||
},
|
},
|
||||||
volumes={
|
volumes={
|
||||||
str(self.sys_config.path_extern_config):
|
str(self.sys_config.path_extern_homeassistant):
|
||||||
{'bind': '/config', 'mode': 'rw'},
|
{'bind': '/config', 'mode': 'rw'},
|
||||||
str(self.sys_config.path_extern_ssl):
|
str(self.sys_config.path_extern_ssl):
|
||||||
{'bind': '/ssl', 'mode': 'ro'},
|
{'bind': '/ssl', 'mode': 'ro'},
|
||||||
|
str(self.sys_config.path_extern_share):
|
||||||
|
{'bind': '/share', 'mode': 'ro'},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@@ -1,7 +1,4 @@
|
|||||||
"""Core Exceptions."""
|
"""Core Exceptions."""
|
||||||
import asyncio
|
|
||||||
|
|
||||||
import aiohttp
|
|
||||||
|
|
||||||
|
|
||||||
class HassioError(Exception):
|
class HassioError(Exception):
|
||||||
@@ -26,14 +23,13 @@ class HomeAssistantUpdateError(HomeAssistantError):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantAuthError(HomeAssistantError):
|
class HomeAssistantAPIError(HomeAssistantError):
|
||||||
"""Home Assistant Auth API exception."""
|
"""Home Assistant API exception."""
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class HomeAssistantAPIError(
|
class HomeAssistantAuthError(HomeAssistantAPIError):
|
||||||
HomeAssistantAuthError, asyncio.TimeoutError, aiohttp.ClientError):
|
"""Home Assistant Auth API exception."""
|
||||||
"""Home Assistant API exception."""
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +76,19 @@ class HostServiceError(HostError):
|
|||||||
|
|
||||||
class HostAppArmorError(HostError):
|
class HostAppArmorError(HostError):
|
||||||
"""Host apparmor functions fails."""
|
"""Host apparmor functions fails."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# API
|
||||||
|
|
||||||
|
class APIError(HassioError):
|
||||||
|
"""API errors."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class APINotSupportedError(HassioNotSupportedError):
|
||||||
|
"""API not supported error."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# utils/gdbus
|
# utils/gdbus
|
||||||
|
@@ -1,9 +1,11 @@
|
|||||||
"""HomeAssistant control object."""
|
"""HomeAssistant control object."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from contextlib import asynccontextmanager, suppress
|
from contextlib import asynccontextmanager, suppress
|
||||||
|
from datetime import datetime, timedelta
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
import socket
|
import socket
|
||||||
import time
|
import time
|
||||||
|
|
||||||
@@ -14,14 +16,14 @@ import attr
|
|||||||
from .const import (
|
from .const import (
|
||||||
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
|
FILE_HASSIO_HOMEASSISTANT, ATTR_IMAGE, ATTR_LAST_VERSION, ATTR_UUID,
|
||||||
ATTR_BOOT, ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG,
|
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)
|
HEADER_HA_ACCESS)
|
||||||
from .coresys import CoreSysAttributes
|
from .coresys import CoreSysAttributes
|
||||||
from .docker.homeassistant import DockerHomeAssistant
|
from .docker.homeassistant import DockerHomeAssistant
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
|
HomeAssistantUpdateError, HomeAssistantError, HomeAssistantAPIError,
|
||||||
HomeAssistantAuthError)
|
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 .utils.json import JsonConfig
|
||||||
from .validate import SCHEMA_HASS_CONFIG
|
from .validate import SCHEMA_HASS_CONFIG
|
||||||
|
|
||||||
@@ -45,6 +47,7 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
self._error_state = False
|
self._error_state = False
|
||||||
# We don't persist access tokens. Instead we fetch new ones when needed
|
# We don't persist access tokens. Instead we fetch new ones when needed
|
||||||
self.access_token = None
|
self.access_token = None
|
||||||
|
self._access_token_expires = None
|
||||||
|
|
||||||
async def load(self):
|
async def load(self):
|
||||||
"""Prepare HomeAssistant object."""
|
"""Prepare HomeAssistant object."""
|
||||||
@@ -182,6 +185,11 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
"""Return a UUID of this HomeAssistant."""
|
"""Return a UUID of this HomeAssistant."""
|
||||||
return self._data[ATTR_UUID]
|
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
|
@property
|
||||||
def refresh_token(self):
|
def refresh_token(self):
|
||||||
"""Return the refresh token to authenticate with HomeAssistant."""
|
"""Return the refresh token to authenticate with HomeAssistant."""
|
||||||
@@ -274,6 +282,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
|
|
||||||
async def _start(self):
|
async def _start(self):
|
||||||
"""Start HomeAssistant docker & wait."""
|
"""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():
|
if not await self.instance.run():
|
||||||
raise HomeAssistantError()
|
raise HomeAssistantError()
|
||||||
await self._block_till_run()
|
await self._block_till_run()
|
||||||
@@ -351,11 +367,12 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
|
|
||||||
async def ensure_access_token(self):
|
async def ensure_access_token(self):
|
||||||
"""Ensures there is an access token."""
|
"""Ensures there is an access token."""
|
||||||
if self.access_token is not None:
|
if (self.access_token is not None and
|
||||||
|
self._access_token_expires > datetime.utcnow()):
|
||||||
return
|
return
|
||||||
|
|
||||||
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
|
with suppress(asyncio.TimeoutError, aiohttp.ClientError):
|
||||||
async with self.sys_websession_ssl.get(
|
async with self.sys_websession_ssl.post(
|
||||||
f"{self.api_url}/auth/token",
|
f"{self.api_url}/auth/token",
|
||||||
timeout=30,
|
timeout=30,
|
||||||
data={
|
data={
|
||||||
@@ -364,14 +381,14 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
}
|
}
|
||||||
) as resp:
|
) as resp:
|
||||||
if resp.status != 200:
|
if resp.status != 200:
|
||||||
_LOGGER.error("Authenticate problem with HomeAssistant!")
|
_LOGGER.error("Can't update HomeAssistant access token!")
|
||||||
raise HomeAssistantAuthError()
|
raise HomeAssistantAuthError()
|
||||||
|
|
||||||
|
_LOGGER.info("Updated HomeAssistant API token")
|
||||||
tokens = await resp.json()
|
tokens = await resp.json()
|
||||||
self.access_token = tokens['access_token']
|
self.access_token = tokens['access_token']
|
||||||
return
|
self._access_token_expires = \
|
||||||
|
datetime.utcnow() + timedelta(seconds=tokens['expires_in'])
|
||||||
_LOGGER.error("Can't update HomeAssistant access token!")
|
|
||||||
raise HomeAssistantAPIError()
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def make_request(self, method, path, json=None, content_type=None,
|
async def make_request(self, method, path, json=None, content_type=None,
|
||||||
@@ -380,10 +397,12 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
url = f"{self.api_url}/{path}"
|
url = f"{self.api_url}/{path}"
|
||||||
headers = {}
|
headers = {}
|
||||||
|
|
||||||
|
# Passthrough content type
|
||||||
if content_type is not None:
|
if content_type is not None:
|
||||||
headers[hdrs.CONTENT_TYPE] = content_type
|
headers[hdrs.CONTENT_TYPE] = content_type
|
||||||
|
|
||||||
elif self.api_password:
|
# Set old API Password
|
||||||
|
if self.api_password:
|
||||||
headers[HEADER_HA_ACCESS] = self.api_password
|
headers[HEADER_HA_ACCESS] = self.api_password
|
||||||
|
|
||||||
for _ in (1, 2):
|
for _ in (1, 2):
|
||||||
@@ -392,15 +411,20 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
await self.ensure_access_token()
|
await self.ensure_access_token()
|
||||||
headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}'
|
headers[hdrs.AUTHORIZATION] = f'Bearer {self.access_token}'
|
||||||
|
|
||||||
async with getattr(self.sys_websession_ssl, method)(
|
try:
|
||||||
url, data=data, timeout=timeout, json=json, headers=headers
|
async with getattr(self.sys_websession_ssl, method)(
|
||||||
) as resp:
|
url, data=data, timeout=timeout, json=json,
|
||||||
# Access token expired
|
headers=headers
|
||||||
if resp.status == 401 and self.refresh_token:
|
) as resp:
|
||||||
self.access_token = None
|
# Access token expired
|
||||||
continue
|
if resp.status == 401 and self.refresh_token:
|
||||||
yield resp
|
self.access_token = None
|
||||||
return
|
continue
|
||||||
|
yield resp
|
||||||
|
return
|
||||||
|
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
|
||||||
|
_LOGGER.error("Error on call %s: %s", url, err)
|
||||||
|
break
|
||||||
|
|
||||||
raise HomeAssistantAPIError()
|
raise HomeAssistantAPIError()
|
||||||
|
|
||||||
@@ -431,6 +455,9 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
async def _block_till_run(self):
|
async def _block_till_run(self):
|
||||||
"""Block until Home-Assistant is booting up or startup timeout."""
|
"""Block until Home-Assistant is booting up or startup timeout."""
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
migration_progress = False
|
||||||
|
migration_file = Path(
|
||||||
|
self.sys_config.path_homeassistant, '.migration_progress')
|
||||||
|
|
||||||
def check_port():
|
def check_port():
|
||||||
"""Check if port is mapped."""
|
"""Check if port is mapped."""
|
||||||
@@ -446,21 +473,39 @@ class HomeAssistant(JsonConfig, CoreSysAttributes):
|
|||||||
pass
|
pass
|
||||||
return False
|
return False
|
||||||
|
|
||||||
while time.monotonic() - start_time < self.wait_boot:
|
while True:
|
||||||
|
await asyncio.sleep(10)
|
||||||
|
|
||||||
|
# 1
|
||||||
|
# Check if Container is is_running
|
||||||
|
if not await self.instance.is_running():
|
||||||
|
_LOGGER.error("HomeAssistant is crashed!")
|
||||||
|
break
|
||||||
|
|
||||||
|
# 2
|
||||||
# Check if API response
|
# Check if API response
|
||||||
if await self.sys_run_in_executor(check_port):
|
if await self.sys_run_in_executor(check_port):
|
||||||
_LOGGER.info("Detect a running HomeAssistant instance")
|
_LOGGER.info("Detect a running HomeAssistant instance")
|
||||||
self._error_state = False
|
self._error_state = False
|
||||||
return
|
return
|
||||||
|
|
||||||
# wait and don't hit the system
|
# 3
|
||||||
await asyncio.sleep(10)
|
# Running DB Migration
|
||||||
|
if migration_file.exists():
|
||||||
|
if not migration_progress:
|
||||||
|
migration_progress = True
|
||||||
|
_LOGGER.info("HomeAssistant record migration in progress")
|
||||||
|
continue
|
||||||
|
elif migration_progress:
|
||||||
|
migration_progress = False # Reset start time
|
||||||
|
start_time = time.monotonic()
|
||||||
|
_LOGGER.info("HomeAssistant record migration done")
|
||||||
|
|
||||||
# Check if Container is is_running
|
# 4
|
||||||
if not await self.instance.is_running():
|
# Timeout
|
||||||
_LOGGER.error("Home Assistant is crashed!")
|
if time.monotonic() - start_time > self.wait_boot:
|
||||||
|
_LOGGER.warning("Don't wait anymore of HomeAssistant startup!")
|
||||||
break
|
break
|
||||||
|
|
||||||
_LOGGER.warning("Don't wait anymore of HomeAssistant startup!")
|
|
||||||
self._error_state = True
|
self._error_state = True
|
||||||
raise HomeAssistantError()
|
raise HomeAssistantError()
|
||||||
|
@@ -3,6 +3,8 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
|
|
||||||
|
import async_timeout
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
COMMAND = "socat UDP-RECVFROM:53,fork UDP-SENDTO:127.0.0.11:53"
|
COMMAND = "socat UDP-RECVFROM:53,fork UDP-SENDTO:127.0.0.11:53"
|
||||||
@@ -38,5 +40,10 @@ class DNSForward:
|
|||||||
return
|
return
|
||||||
|
|
||||||
self.proc.kill()
|
self.proc.kill()
|
||||||
await self.proc.wait()
|
try:
|
||||||
|
with async_timeout.timeout(5):
|
||||||
|
await self.proc.wait()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
_LOGGER.warning("Stop waiting for DNS shutdown")
|
||||||
|
|
||||||
_LOGGER.info("Stop DNS forwarding")
|
_LOGGER.info("Stop DNS forwarding")
|
||||||
|
@@ -293,6 +293,7 @@ class SnapshotManager(CoreSysAttributes):
|
|||||||
# Stop Home-Assistant if they will be restored later
|
# Stop Home-Assistant if they will be restored later
|
||||||
if homeassistant and FOLDER_HOMEASSISTANT in folders:
|
if homeassistant and FOLDER_HOMEASSISTANT in folders:
|
||||||
await self.sys_homeassistant.stop()
|
await self.sys_homeassistant.stop()
|
||||||
|
snapshot.restore_homeassistant()
|
||||||
|
|
||||||
# Process folders
|
# Process folders
|
||||||
if folders:
|
if folders:
|
||||||
@@ -304,7 +305,6 @@ class SnapshotManager(CoreSysAttributes):
|
|||||||
if homeassistant:
|
if homeassistant:
|
||||||
_LOGGER.info("Restore %s run Home-Assistant",
|
_LOGGER.info("Restore %s run Home-Assistant",
|
||||||
snapshot.slug)
|
snapshot.slug)
|
||||||
snapshot.restore_homeassistant()
|
|
||||||
task_hass = self.sys_create_task(
|
task_hass = self.sys_create_task(
|
||||||
self.sys_homeassistant.update(
|
self.sys_homeassistant.update(
|
||||||
snapshot.homeassistant_version))
|
snapshot.homeassistant_version))
|
||||||
@@ -322,12 +322,20 @@ class SnapshotManager(CoreSysAttributes):
|
|||||||
_LOGGER.info("Restore %s old add-ons", snapshot.slug)
|
_LOGGER.info("Restore %s old add-ons", snapshot.slug)
|
||||||
await snapshot.restore_addons(addon_list)
|
await snapshot.restore_addons(addon_list)
|
||||||
|
|
||||||
# make sure homeassistant run agen
|
# Make sure homeassistant run agen
|
||||||
if task_hass:
|
if task_hass:
|
||||||
_LOGGER.info("Restore %s wait for Home-Assistant",
|
_LOGGER.info("Restore %s wait for Home-Assistant",
|
||||||
snapshot.slug)
|
snapshot.slug)
|
||||||
await task_hass
|
await task_hass
|
||||||
await self.sys_homeassistant.start()
|
|
||||||
|
# Do we need start HomeAssistant?
|
||||||
|
if not await self.sys_homeassistant.is_running():
|
||||||
|
await self.sys_homeassistant.start()
|
||||||
|
|
||||||
|
# Check If we can access to API / otherwise restart
|
||||||
|
if not await self.sys_homeassistant.check_api_state():
|
||||||
|
_LOGGER.warning("Need restart HomeAssistant for API")
|
||||||
|
await self.sys_homeassistant.restart()
|
||||||
|
|
||||||
except Exception: # pylint: disable=broad-except
|
except Exception: # pylint: disable=broad-except
|
||||||
_LOGGER.exception("Restore %s error", snapshot.slug)
|
_LOGGER.exception("Restore %s error", snapshot.slug)
|
||||||
|
@@ -20,7 +20,7 @@ from ..const import (
|
|||||||
ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE,
|
ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_VERSION, ATTR_TYPE, ATTR_IMAGE,
|
||||||
ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_CRYPTO,
|
ATTR_PORT, ATTR_SSL, ATTR_PASSWORD, ATTR_WATCHDOG, ATTR_BOOT, ATTR_CRYPTO,
|
||||||
ATTR_LAST_VERSION, ATTR_PROTECTED, ATTR_WAIT_BOOT, ATTR_SIZE,
|
ATTR_LAST_VERSION, ATTR_PROTECTED, ATTR_WAIT_BOOT, ATTR_SIZE,
|
||||||
CRYPTO_AES128)
|
ATTR_REFRESH_TOKEN, CRYPTO_AES128)
|
||||||
from ..coresys import CoreSysAttributes
|
from ..coresys import CoreSysAttributes
|
||||||
from ..utils.json import write_json_file
|
from ..utils.json import write_json_file
|
||||||
from ..utils.tar import SecureTarFile
|
from ..utils.tar import SecureTarFile
|
||||||
@@ -387,6 +387,8 @@ class Snapshot(CoreSysAttributes):
|
|||||||
# API/Proxy
|
# API/Proxy
|
||||||
self.homeassistant[ATTR_PORT] = self.sys_homeassistant.api_port
|
self.homeassistant[ATTR_PORT] = self.sys_homeassistant.api_port
|
||||||
self.homeassistant[ATTR_SSL] = self.sys_homeassistant.api_ssl
|
self.homeassistant[ATTR_SSL] = self.sys_homeassistant.api_ssl
|
||||||
|
self.homeassistant[ATTR_REFRESH_TOKEN] = \
|
||||||
|
self._encrypt_data(self.sys_homeassistant.refresh_token)
|
||||||
self.homeassistant[ATTR_PASSWORD] = \
|
self.homeassistant[ATTR_PASSWORD] = \
|
||||||
self._encrypt_data(self.sys_homeassistant.api_password)
|
self._encrypt_data(self.sys_homeassistant.api_password)
|
||||||
|
|
||||||
@@ -405,6 +407,8 @@ class Snapshot(CoreSysAttributes):
|
|||||||
# API/Proxy
|
# API/Proxy
|
||||||
self.sys_homeassistant.api_port = self.homeassistant[ATTR_PORT]
|
self.sys_homeassistant.api_port = self.homeassistant[ATTR_PORT]
|
||||||
self.sys_homeassistant.api_ssl = self.homeassistant[ATTR_SSL]
|
self.sys_homeassistant.api_ssl = self.homeassistant[ATTR_SSL]
|
||||||
|
self.sys_homeassistant.refresh_token = \
|
||||||
|
self._decrypt_data(self.homeassistant[ATTR_REFRESH_TOKEN])
|
||||||
self.sys_homeassistant.api_password = \
|
self.sys_homeassistant.api_password = \
|
||||||
self._decrypt_data(self.homeassistant[ATTR_PASSWORD])
|
self._decrypt_data(self.homeassistant[ATTR_PASSWORD])
|
||||||
|
|
||||||
|
@@ -7,6 +7,7 @@ from ..const import (
|
|||||||
ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE,
|
ATTR_VERSION, ATTR_HOMEASSISTANT, ATTR_FOLDERS, ATTR_TYPE, ATTR_IMAGE,
|
||||||
ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_SIZE,
|
ATTR_PASSWORD, ATTR_PORT, ATTR_SSL, ATTR_WATCHDOG, ATTR_BOOT, ATTR_SIZE,
|
||||||
ATTR_LAST_VERSION, ATTR_WAIT_BOOT, ATTR_PROTECTED, ATTR_CRYPTO,
|
ATTR_LAST_VERSION, ATTR_WAIT_BOOT, ATTR_PROTECTED, ATTR_CRYPTO,
|
||||||
|
ATTR_REFRESH_TOKEN,
|
||||||
FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL,
|
FOLDER_SHARE, FOLDER_HOMEASSISTANT, FOLDER_ADDONS, FOLDER_SSL,
|
||||||
SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CRYPTO_AES128)
|
SNAPSHOT_FULL, SNAPSHOT_PARTIAL, CRYPTO_AES128)
|
||||||
from ..validate import NETWORK_PORT, REPOSITORIES, DOCKER_IMAGE
|
from ..validate import NETWORK_PORT, REPOSITORIES, DOCKER_IMAGE
|
||||||
@@ -40,6 +41,7 @@ SCHEMA_SNAPSHOT = vol.Schema({
|
|||||||
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
|
vol.Optional(ATTR_SSL, default=False): vol.Boolean(),
|
||||||
vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
|
vol.Optional(ATTR_PORT, default=8123): NETWORK_PORT,
|
||||||
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
|
vol.Optional(ATTR_PASSWORD): vol.Maybe(vol.Coerce(str)),
|
||||||
|
vol.Optional(ATTR_REFRESH_TOKEN): vol.Maybe(vol.Coerce(str)),
|
||||||
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
|
vol.Optional(ATTR_WATCHDOG, default=True): vol.Boolean(),
|
||||||
vol.Optional(ATTR_WAIT_BOOT, default=600):
|
vol.Optional(ATTR_WAIT_BOOT, default=600):
|
||||||
vol.All(vol.Coerce(int), vol.Range(min=60)),
|
vol.All(vol.Coerce(int), vol.Range(min=60)),
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
"""Tools file for HassIO."""
|
"""Tools file for HassIO."""
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
|
RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))")
|
||||||
@@ -12,6 +14,11 @@ def convert_to_ascii(raw):
|
|||||||
return RE_STRING.sub("", raw.decode())
|
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):
|
def process_lock(method):
|
||||||
"""Wrap function with only run once."""
|
"""Wrap function with only run once."""
|
||||||
async def wrap_api(api, *args, **kwargs):
|
async def wrap_api(api, *args, **kwargs):
|
||||||
|
@@ -9,7 +9,6 @@ UTC = pytz.utc
|
|||||||
|
|
||||||
_LOGGER = logging.getLogger(__name__)
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
FREEGEOIP_URL = "https://freegeoip.net/json/"
|
|
||||||
|
|
||||||
# Copyright (c) Django Software Foundation and individual contributors.
|
# Copyright (c) Django Software Foundation and individual contributors.
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
@@ -45,7 +45,7 @@ class JsonConfig:
|
|||||||
if self._file.is_file():
|
if self._file.is_file():
|
||||||
try:
|
try:
|
||||||
self._data = read_json_file(self._file)
|
self._data = read_json_file(self._file)
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
|
||||||
_LOGGER.warning("Can't read %s", self._file)
|
_LOGGER.warning("Can't read %s", self._file)
|
||||||
self._data = {}
|
self._data = {}
|
||||||
|
|
||||||
|
@@ -10,6 +10,7 @@ from .const import (
|
|||||||
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
|
ATTR_ADDONS_CUSTOM_LIST, ATTR_PASSWORD, ATTR_HOMEASSISTANT, ATTR_HASSIO,
|
||||||
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
|
ATTR_BOOT, ATTR_LAST_BOOT, ATTR_SSL, ATTR_PORT, ATTR_WATCHDOG,
|
||||||
ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI,
|
ATTR_WAIT_BOOT, ATTR_UUID, ATTR_REFRESH_TOKEN, ATTR_HASSOS_CLI,
|
||||||
|
ATTR_ACCESS_TOKEN,
|
||||||
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
|
CHANNEL_STABLE, CHANNEL_BETA, CHANNEL_DEV)
|
||||||
|
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ DOCKER_PORTS = vol.Schema({
|
|||||||
SCHEMA_HASS_CONFIG = vol.Schema({
|
SCHEMA_HASS_CONFIG = vol.Schema({
|
||||||
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
|
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex):
|
||||||
vol.Match(r"^[0-9a-f]{32}$"),
|
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.Optional(ATTR_BOOT, default=True): vol.Boolean(),
|
||||||
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE,
|
vol.Inclusive(ATTR_IMAGE, 'custom_hass'): DOCKER_IMAGE,
|
||||||
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
|
vol.Inclusive(ATTR_LAST_VERSION, 'custom_hass'): vol.Coerce(str),
|
||||||
|
13
requirements.txt
Normal file
13
requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
attr==0.3.1
|
||||||
|
async_timeout==3.0.0
|
||||||
|
aiohttp==3.4.0
|
||||||
|
docker==3.5.0
|
||||||
|
colorlog==3.1.2
|
||||||
|
voluptuous==0.11.5
|
||||||
|
gitpython==2.1.10
|
||||||
|
pytz==2018.4
|
||||||
|
pyudev==0.21.0
|
||||||
|
pycryptodome==3.6.6
|
||||||
|
cpe==1.2.1
|
||||||
|
uvloop==0.11.2
|
||||||
|
cchardet==2.1.1
|
15
setup.py
15
setup.py
@@ -38,18 +38,5 @@ setup(
|
|||||||
'hassio.utils',
|
'hassio.utils',
|
||||||
'hassio.snapshots'
|
'hassio.snapshots'
|
||||||
],
|
],
|
||||||
include_package_data=True,
|
include_package_data=True
|
||||||
install_requires=[
|
|
||||||
'attr==0.3.1',
|
|
||||||
'async_timeout==3.0.0',
|
|
||||||
'aiohttp==3.3.2',
|
|
||||||
'docker==3.4.0',
|
|
||||||
'colorlog==3.1.2',
|
|
||||||
'voluptuous==0.11.1',
|
|
||||||
'gitpython==2.1.10',
|
|
||||||
'pytz==2018.4',
|
|
||||||
'pyudev==0.21.0',
|
|
||||||
'pycryptodome==3.6.4',
|
|
||||||
"cpe==1.2.1"
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
Reference in New Issue
Block a user