diff --git a/.github/main.workflow b/.github/main.workflow
deleted file mode 100644
index 8375d5b87..000000000
--- a/.github/main.workflow
+++ /dev/null
@@ -1,16 +0,0 @@
-workflow "tox" {
- on = "push"
- resolves = [
- "Python 3.7",
- "Json Files",
- ]
-}
-
-action "Python 3.7" {
- uses = "home-assistant/actions/py37-tox@master"
-}
-
-action "Json Files" {
- uses = "home-assistant/actions/jq@master"
- args = "**/*.json"
-}
diff --git a/API.md b/API.md
index e54496400..f22407a32 100644
--- a/API.md
+++ b/API.md
@@ -41,6 +41,7 @@ The addons from `addons` are only installed one.
"arch": "armhf|aarch64|i386|amd64",
"channel": "stable|beta|dev",
"timezone": "TIMEZONE",
+ "ip_address": "ip address",
"wait_boot": "int",
"addons": [
{
@@ -348,6 +349,7 @@ Load host configs from a USB stick.
"last_version": "LAST_VERSION",
"arch": "arch",
"machine": "Image machine type",
+ "ip_address": "ip address",
"image": "str",
"custom": "bool -> if custom image",
"boot": "bool",
@@ -469,6 +471,7 @@ Get all available addons.
"available": "bool",
"arch": ["armhf", "aarch64", "i386", "amd64"],
"machine": "[raspberrypi2, tinker]",
+ "homeassistant": "null|min Home Assistant version",
"repository": "12345678|null",
"version": "null|VERSION_INSTALLED",
"last_version": "LAST_VERSION",
@@ -505,7 +508,11 @@ Get all available addons.
"audio_input": "null|0,0",
"audio_output": "null|0,0",
"services_role": "['service:access']",
- "discovery": "['service']"
+ "discovery": "['service']",
+ "ip_address": "ip address",
+ "ingress": "bool",
+ "ingress_entry": "null|/api/hassio_ingress/slug",
+ "ingress_url": "null|/api/hassio_ingress/slug/entry.html"
}
```
@@ -579,6 +586,23 @@ Write data to add-on stdin
}
```
+### ingress
+
+- POST `/ingress/session`
+
+Create a new Session for access to ingress service.
+
+```json
+{
+ "session": "token"
+}
+```
+
+- VIEW `/ingress/{token}`
+
+Ingress WebUI for this Add-on. The addon need support HASS Auth!
+Need ingress session as cookie.
+
### discovery
- GET `/discovery`
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
new file mode 100644
index 000000000..0c1a6f471
--- /dev/null
+++ b/azure-pipelines.yml
@@ -0,0 +1,45 @@
+# Python package
+# Create and test a Python package on multiple Python versions.
+# Add steps that analyze code, save the dist with the build record, publish to a PyPI-compatible index, and more:
+# https://docs.microsoft.com/azure/devops/pipelines/languages/python
+
+trigger:
+- master
+- dev
+
+pr:
+- dev
+
+jobs:
+
+- job: "Tox"
+
+ pool:
+ vmImage: 'ubuntu-16.04'
+
+ steps:
+ - task: UsePythonVersion@0
+ displayName: 'Use Python $(python.version)'
+ inputs:
+ versionSpec: '3.7'
+
+ - script: pip install tox
+ displayName: 'Install Tox'
+
+ - script: tox
+ displayName: 'Run Tox'
+
+
+- job: "JQ"
+
+ pool:
+ vmImage: 'ubuntu-16.04'
+
+ steps:
+ - script: sudo apt-get install -y jq
+ displayName: 'Install JQ'
+
+ - bash: |
+ shopt -s globstar
+ cat **/*.json | jq '.'
+ displayName: 'Run JQ'
diff --git a/hassio/__main__.py b/hassio/__main__.py
index 074849d91..31596d318 100644
--- a/hassio/__main__.py
+++ b/hassio/__main__.py
@@ -13,7 +13,8 @@ def initialize_event_loop():
"""Attempt to use uvloop."""
try:
import uvloop
- asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
+
+ uvloop.install()
except ImportError:
pass
diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py
index 8019e3cc7..63293eb87 100644
--- a/hassio/addons/addon.py
+++ b/hassio/addons/addon.py
@@ -1,41 +1,105 @@
"""Init file for Hass.io add-ons."""
from contextlib import suppress
from copy import deepcopy
+from distutils.version import StrictVersion
+from ipaddress import IPv4Address, ip_address
import logging
from pathlib import Path, PurePath
import re
+import secrets
import shutil
import tarfile
from tempfile import TemporaryDirectory
-from typing import Dict, Any
+from typing import Any, Awaitable, Dict, Optional
import voluptuous as vol
from voluptuous.humanize import humanize_error
from ..const import (
- ATTR_ACCESS_TOKEN, ATTR_APPARMOR, ATTR_ARCH, ATTR_AUDIO, ATTR_AUDIO_INPUT,
- ATTR_AUDIO_OUTPUT, ATTR_AUTH_API, ATTR_AUTO_UART, ATTR_AUTO_UPDATE,
- ATTR_BOOT, ATTR_DESCRIPTON, ATTR_DEVICES, ATTR_DEVICETREE, ATTR_DISCOVERY,
- ATTR_DOCKER_API, ATTR_ENVIRONMENT, ATTR_FULL_ACCESS, ATTR_GPIO,
- ATTR_HASSIO_API, ATTR_HASSIO_ROLE, ATTR_HOMEASSISTANT_API, ATTR_HOST_DBUS,
- ATTR_HOST_IPC, ATTR_HOST_NETWORK, ATTR_HOST_PID, ATTR_IMAGE,
- ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, ATTR_MACHINE, ATTR_MAP,
- ATTR_NAME, ATTR_NETWORK, ATTR_OPTIONS, ATTR_PORTS, ATTR_PRIVILEGED,
- ATTR_PROTECTED, ATTR_REPOSITORY, ATTR_SCHEMA, ATTR_SERVICES, ATTR_SLUG,
- ATTR_STARTUP, ATTR_STATE, ATTR_STDIN, ATTR_SYSTEM, ATTR_TIMEOUT,
- ATTR_TMPFS, ATTR_URL, ATTR_USER, ATTR_UUID, ATTR_VERSION, ATTR_WEBUI,
- SECURITY_DEFAULT, SECURITY_DISABLE, SECURITY_PROFILE, STATE_NONE,
- STATE_STARTED, STATE_STOPPED)
-from ..coresys import CoreSysAttributes
+ ATTR_ACCESS_TOKEN,
+ ATTR_APPARMOR,
+ ATTR_ARCH,
+ ATTR_AUDIO,
+ ATTR_AUDIO_INPUT,
+ ATTR_AUDIO_OUTPUT,
+ ATTR_AUTH_API,
+ ATTR_AUTO_UART,
+ ATTR_AUTO_UPDATE,
+ ATTR_BOOT,
+ ATTR_DESCRIPTON,
+ ATTR_DEVICES,
+ ATTR_DEVICETREE,
+ ATTR_DISCOVERY,
+ ATTR_DOCKER_API,
+ ATTR_ENVIRONMENT,
+ ATTR_FULL_ACCESS,
+ ATTR_GPIO,
+ ATTR_HASSIO_API,
+ ATTR_HASSIO_ROLE,
+ ATTR_HOMEASSISTANT,
+ ATTR_HOMEASSISTANT_API,
+ ATTR_HOST_DBUS,
+ ATTR_HOST_IPC,
+ ATTR_HOST_NETWORK,
+ ATTR_HOST_PID,
+ ATTR_IMAGE,
+ ATTR_INGRESS,
+ ATTR_INGRESS_ENTRY,
+ ATTR_INGRESS_PORT,
+ ATTR_INGRESS_TOKEN,
+ ATTR_KERNEL_MODULES,
+ ATTR_LEGACY,
+ ATTR_LOCATON,
+ ATTR_MACHINE,
+ ATTR_MAP,
+ ATTR_NAME,
+ ATTR_NETWORK,
+ ATTR_OPTIONS,
+ ATTR_PORTS,
+ ATTR_PRIVILEGED,
+ ATTR_PROTECTED,
+ ATTR_REPOSITORY,
+ ATTR_SCHEMA,
+ ATTR_SERVICES,
+ ATTR_SLUG,
+ ATTR_STARTUP,
+ ATTR_STATE,
+ ATTR_STDIN,
+ ATTR_SYSTEM,
+ ATTR_TIMEOUT,
+ ATTR_TMPFS,
+ ATTR_URL,
+ ATTR_USER,
+ ATTR_UUID,
+ ATTR_VERSION,
+ ATTR_WEBUI,
+ SECURITY_DEFAULT,
+ SECURITY_DISABLE,
+ SECURITY_PROFILE,
+ STATE_NONE,
+ STATE_STARTED,
+ STATE_STOPPED,
+)
+from ..coresys import CoreSys, CoreSysAttributes
from ..docker.addon import DockerAddon
-from ..exceptions import HostAppArmorError, JsonFileError
-from ..utils import create_token
+from ..docker.stats import DockerStats
+from ..exceptions import (
+ AddonsError,
+ AddonsNotSupportedError,
+ DockerAPIError,
+ HostAppArmorError,
+ JsonFileError,
+)
from ..utils.apparmor import adjust_profile
from ..utils.json import read_json_file, write_json_file
from .utils import check_installed, remove_data
from .validate import (
- MACHINE_ALL, RE_SERVICE, RE_VOLUME, SCHEMA_ADDON_SNAPSHOT,
- validate_options)
+ MACHINE_ALL,
+ RE_SERVICE,
+ RE_VOLUME,
+ SCHEMA_ADDON_SNAPSHOT,
+ validate_options,
+)
_LOGGER = logging.getLogger(__name__)
@@ -47,21 +111,28 @@ RE_WEBUI = re.compile(
class Addon(CoreSysAttributes):
"""Hold data for add-on inside Hass.io."""
- def __init__(self, coresys, slug):
+ def __init__(self, coresys: CoreSys, slug: str):
"""Initialize data holder."""
- self.coresys = coresys
- self.instance = DockerAddon(coresys, slug)
+ self.coresys: CoreSys = coresys
+ self.instance: DockerAddon = DockerAddon(coresys, slug)
+ self._id: str = slug
- self._id = slug
-
- async def load(self):
+ async def load(self) -> None:
"""Async initialize of object."""
if not self.is_installed:
return
- await self.instance.attach()
+ with suppress(DockerAPIError):
+ await self.instance.attach()
@property
- def slug(self):
+ def ip_address(self) -> IPv4Address:
+ """Return IP of Add-on instance."""
+ if not self.is_installed:
+ return ip_address("0.0.0.0")
+ return self.instance.ip_address
+
+ @property
+ def slug(self) -> str:
"""Return slug/id of add-on."""
return self._id
@@ -76,30 +147,41 @@ class Addon(CoreSysAttributes):
return self.sys_addons.data
@property
- def is_installed(self):
+ def is_installed(self) -> bool:
"""Return True if an add-on is installed."""
return self._id in self._data.system
@property
- def is_detached(self):
+ def is_detached(self) -> bool:
"""Return True if add-on is detached."""
return self._id not in self._data.cache
@property
- def available(self):
+ def available(self) -> bool:
"""Return True if this add-on is available on this platform."""
+ if self.is_detached:
+ addon_data = self._data.system.get(self._id)
+ else:
+ addon_data = self._data.cache.get(self._id)
+
# Architecture
- if not self.sys_arch.is_supported(self.supported_arch):
+ if not self.sys_arch.is_supported(addon_data[ATTR_ARCH]):
return False
# Machine / Hardware
- if self.sys_machine not in self.supported_machine:
+ machine = addon_data.get(ATTR_MACHINE) or MACHINE_ALL
+ if self.sys_machine not in machine:
+ return False
+
+ # Home Assistant
+ version = addon_data.get(ATTR_HOMEASSISTANT) or self.sys_homeassistant.version
+ if StrictVersion(self.sys_homeassistant.version) < StrictVersion(version):
return False
return True
@property
- def version_installed(self):
+ def version_installed(self) -> Optional[str]:
"""Return installed version."""
return self._data.user.get(self._id, {}).get(ATTR_VERSION)
@@ -202,6 +284,20 @@ class Addon(CoreSysAttributes):
return self._data.user[self._id].get(ATTR_ACCESS_TOKEN)
return None
+ @property
+ def ingress_token(self):
+ """Return access token for Hass.io API."""
+ if self.is_installed:
+ return self._data.user[self._id].get(ATTR_INGRESS_TOKEN)
+ return None
+
+ @property
+ def ingress_entry(self):
+ """Return ingress external URL."""
+ if self.is_installed and self.with_ingress:
+ return f"/api/hassio_ingress/{self.ingress_token}"
+ return None
+
@property
def description(self):
"""Return description of add-on."""
@@ -292,6 +388,17 @@ class Addon(CoreSysAttributes):
self._data.user[self._id][ATTR_NETWORK] = new_ports
+ @property
+ def ingress_url(self):
+ """Return URL to ingress url."""
+ if not self.is_installed or not self.with_ingress:
+ return None
+
+ webui = f"/api/hassio_ingress/{self.ingress_token}/"
+ if ATTR_INGRESS_ENTRY in self._mesh:
+ return f"{webui}{self._mesh[ATTR_INGRESS_ENTRY]}"
+ return webui
+
@property
def webui(self):
"""Return URL to webui or None."""
@@ -323,6 +430,11 @@ class Addon(CoreSysAttributes):
return f"{proto}://[HOST]:{port}{s_suffix}"
+ @property
+ def ingress_internal(self):
+ """Return Ingress host URL."""
+ return f"http://{self.ip_address}:{self._mesh[ATTR_INGRESS_PORT]}"
+
@property
def host_network(self):
"""Return True if add-on run on host network."""
@@ -407,6 +519,11 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access use stdin input."""
return self._mesh[ATTR_STDIN]
+ @property
+ def with_ingress(self):
+ """Return True if the add-on access support ingress."""
+ return self._mesh[ATTR_INGRESS]
+
@property
def with_gpio(self):
"""Return True if the add-on access to GPIO interface."""
@@ -437,6 +554,11 @@ class Addon(CoreSysAttributes):
"""Return True if the add-on access to audio."""
return self._mesh[ATTR_AUDIO]
+ @property
+ def homeassistant_version(self) -> Optional[str]:
+ """Return min Home Assistant version they needed by Add-on."""
+ return self._mesh.get(ATTR_HOMEASSISTANT)
+
@property
def audio_output(self):
"""Return ALSA config for output or None."""
@@ -642,7 +764,7 @@ class Addon(CoreSysAttributes):
return True
- async def _install_apparmor(self):
+ async def _install_apparmor(self) -> None:
"""Install or Update AppArmor profile for Add-on."""
exists_local = self.sys_host.apparmor.exists(self.slug)
exists_addon = self.path_apparmor.exists()
@@ -664,7 +786,7 @@ class Addon(CoreSysAttributes):
await self.sys_host.apparmor.load_profile(self.slug, profile_file)
@property
- def schema(self):
+ def schema(self) -> vol.Schema:
"""Create a schema for add-on options."""
raw_schema = self._mesh[ATTR_SCHEMA]
@@ -672,7 +794,7 @@ class Addon(CoreSysAttributes):
return vol.Schema(dict)
return vol.Schema(vol.All(dict, validate_options(raw_schema)))
- def test_update_schema(self):
+ def test_update_schema(self) -> bool:
"""Check if the existing configuration is valid after update."""
if not self.is_installed or self.is_detached:
return True
@@ -702,17 +824,17 @@ class Addon(CoreSysAttributes):
return False
return True
- async def install(self):
+ async def install(self) -> None:
"""Install an add-on."""
if not self.available:
_LOGGER.error(
"Add-on %s not supported on %s with %s architecture",
self._id, self.sys_machine, self.sys_arch.supported)
- return False
+ raise AddonsNotSupportedError()
if self.is_installed:
- _LOGGER.error("Add-on %s is already installed", self._id)
- return False
+ _LOGGER.warning("Add-on %s is already installed", self._id)
+ return
if not self.path_data.is_dir():
_LOGGER.info(
@@ -722,18 +844,20 @@ class Addon(CoreSysAttributes):
# Setup/Fix AppArmor profile
await self._install_apparmor()
- if not await self.instance.install(
- self.last_version, self.image_next):
- return False
-
- self._set_install(self.image_next, self.last_version)
- return True
+ try:
+ await self.instance.install(self.last_version, self.image_next)
+ except DockerAPIError:
+ raise AddonsError() from None
+ else:
+ self._set_install(self.image_next, self.last_version)
@check_installed
- async def uninstall(self):
+ async def uninstall(self) -> None:
"""Remove an add-on."""
- if not await self.instance.remove():
- return False
+ try:
+ await self.instance.remove()
+ except DockerAPIError:
+ raise AddonsError() from None
if self.path_data.is_dir():
_LOGGER.info(
@@ -750,13 +874,11 @@ class Addon(CoreSysAttributes):
with suppress(HostAppArmorError):
await self.sys_host.apparmor.remove_profile(self.slug)
- # Remove discovery messages
+ # Cleanup internal data
self.remove_discovery()
-
self._set_uninstall()
- return True
- async def state(self):
+ async def state(self) -> str:
"""Return running state of add-on."""
if not self.is_installed:
return STATE_NONE
@@ -766,46 +888,57 @@ class Addon(CoreSysAttributes):
return STATE_STOPPED
@check_installed
- async def start(self):
+ async def start(self) -> None:
"""Set options and start add-on."""
if await self.instance.is_running():
_LOGGER.warning("%s already running!", self.slug)
return
# Access Token
- self._data.user[self._id][ATTR_ACCESS_TOKEN] = create_token()
+ self._data.user[self._id][ATTR_ACCESS_TOKEN] = secrets.token_hex(56)
self.save_data()
# Options
if not self.write_options():
- return False
+ raise AddonsError()
# Sound
if self.with_audio and not self.write_asound():
- return False
+ raise AddonsError()
- return await self.instance.run()
+ try:
+ await self.instance.run()
+ except DockerAPIError:
+ raise AddonsError() from None
@check_installed
- def stop(self):
- """Stop add-on.
-
- Return a coroutine.
- """
- return self.instance.stop()
+ async def stop(self) -> None:
+ """Stop add-on."""
+ try:
+ return await self.instance.stop()
+ except DockerAPIError:
+ raise AddonsError() from None
@check_installed
- async def update(self):
+ async def update(self) -> None:
"""Update add-on."""
- last_state = await self.state()
-
if self.last_version == self.version_installed:
_LOGGER.warning("No update available for add-on %s", self._id)
- return False
+ return
- if not await self.instance.update(
- self.last_version, self.image_next):
- return False
+ # Check if available, Maybe something have changed
+ if not self.available:
+ _LOGGER.error(
+ "Add-on %s not supported on %s with %s architecture",
+ self._id, self.sys_machine, self.sys_arch.supported)
+ raise AddonsNotSupportedError()
+
+ # Update instance
+ last_state = await self.state()
+ try:
+ await self.instance.update(self.last_version, self.image_next)
+ except DockerAPIError:
+ raise AddonsError() from None
self._set_update(self.image_next, self.last_version)
# Setup/Fix AppArmor profile
@@ -814,16 +947,16 @@ class Addon(CoreSysAttributes):
# restore state
if last_state == STATE_STARTED:
await self.start()
- return True
@check_installed
- async def restart(self):
+ async def restart(self) -> None:
"""Restart add-on."""
- await self.stop()
- return await self.start()
+ with suppress(AddonsError):
+ await self.stop()
+ await self.start()
@check_installed
- def logs(self):
+ def logs(self) -> Awaitable[bytes]:
"""Return add-ons log output.
Return a coroutine.
@@ -831,33 +964,32 @@ class Addon(CoreSysAttributes):
return self.instance.logs()
@check_installed
- def stats(self):
- """Return stats of container.
-
- Return a coroutine.
- """
- return self.instance.stats()
+ async def stats(self) -> DockerStats:
+ """Return stats of container."""
+ try:
+ return await self.instance.stats()
+ except DockerAPIError:
+ raise AddonsError() from None
@check_installed
- async def rebuild(self):
+ async def rebuild(self) -> None:
"""Perform a rebuild of local build add-on."""
last_state = await self.state()
if not self.need_build:
_LOGGER.error("Can't rebuild a none local build add-on!")
- return False
+ raise AddonsNotSupportedError()
# remove docker container but not addon config
- if not await self.instance.remove():
- return False
-
- if not await self.instance.install(self.version_installed):
- return False
+ try:
+ await self.instance.remove()
+ await self.instance.install(self.version_installed)
+ except DockerAPIError:
+ raise AddonsError() from None
# restore state
if last_state == STATE_STARTED:
await self.start()
- return True
@check_installed
async def write_stdin(self, data):
@@ -867,18 +999,23 @@ class Addon(CoreSysAttributes):
"""
if not self.with_stdin:
_LOGGER.error("Add-on don't support write to stdin!")
- return False
+ raise AddonsNotSupportedError()
- return await self.instance.write_stdin(data)
+ try:
+ return await self.instance.write_stdin(data)
+ except DockerAPIError:
+ raise AddonsError() from None
@check_installed
- async def snapshot(self, tar_file):
+ async def snapshot(self, tar_file: tarfile.TarFile) -> None:
"""Snapshot state of an add-on."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp:
# store local image
- if self.need_build and not await \
- self.instance.export_image(Path(temp, 'image.tar')):
- return False
+ if self.need_build:
+ try:
+ await self.instance.export_image(Path(temp, 'image.tar'))
+ except DockerAPIError:
+ raise AddonsError() from None
data = {
ATTR_USER: self._data.user.get(self._id, {}),
@@ -892,7 +1029,7 @@ class Addon(CoreSysAttributes):
write_json_file(Path(temp, 'addon.json'), data)
except JsonFileError:
_LOGGER.error("Can't save meta for %s", self._id)
- return False
+ raise AddonsError() from None
# Store AppArmor Profile
if self.sys_host.apparmor.exists(self.slug):
@@ -901,7 +1038,7 @@ class Addon(CoreSysAttributes):
self.sys_host.apparmor.backup_profile(self.slug, profile)
except HostAppArmorError:
_LOGGER.error("Can't backup AppArmor profile")
- return False
+ raise AddonsError() from None
# write into tarfile
def _write_tarfile():
@@ -915,12 +1052,11 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_write_tarfile)
except (tarfile.TarError, OSError) as err:
_LOGGER.error("Can't write tarfile %s: %s", tar_file, err)
- return False
+ raise AddonsError() from None
_LOGGER.info("Finish snapshot for addon %s", self._id)
- return True
- async def restore(self, tar_file):
+ async def restore(self, tar_file: tarfile.TarFile) -> None:
"""Restore state of an add-on."""
with TemporaryDirectory(dir=str(self.sys_config.path_tmp)) as temp:
# extract snapshot
@@ -933,13 +1069,13 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_extract_tarfile)
except tarfile.TarError as err:
_LOGGER.error("Can't read tarfile %s: %s", tar_file, err)
- return False
+ raise AddonsError() from None
# Read snapshot data
try:
data = read_json_file(Path(temp, 'addon.json'))
except JsonFileError:
- return False
+ raise AddonsError() from None
# Validate
try:
@@ -947,7 +1083,7 @@ class Addon(CoreSysAttributes):
except vol.Invalid as err:
_LOGGER.error("Can't validate %s, snapshot data: %s",
self._id, humanize_error(data, err))
- return False
+ raise AddonsError() from None
# Restore local add-on informations
_LOGGER.info("Restore config for addon %s", self._id)
@@ -961,15 +1097,19 @@ class Addon(CoreSysAttributes):
image_file = Path(temp, 'image.tar')
if image_file.is_file():
- await self.instance.import_image(image_file, version)
+ with suppress(DockerAPIError):
+ await self.instance.import_image(image_file, version)
else:
- if await self.instance.install(version, restore_image):
+ with suppress(DockerAPIError):
+ await self.instance.install(version, restore_image)
await self.instance.cleanup()
elif self.instance.version != version or self.legacy:
_LOGGER.info("Restore/Update image for addon %s", self._id)
- await self.instance.update(version, restore_image)
+ with suppress(DockerAPIError):
+ await self.instance.update(version, restore_image)
else:
- await self.instance.stop()
+ with suppress(DockerAPIError):
+ await self.instance.stop()
# Restore data
def _restore_data():
@@ -983,7 +1123,7 @@ class Addon(CoreSysAttributes):
await self.sys_run_in_executor(_restore_data)
except shutil.Error as err:
_LOGGER.error("Can't restore origin data: %s", err)
- return False
+ raise AddonsError() from None
# Restore AppArmor
profile_file = Path(temp, 'apparmor.txt')
@@ -993,11 +1133,10 @@ class Addon(CoreSysAttributes):
self.slug, profile_file)
except HostAppArmorError:
_LOGGER.error("Can't restore AppArmor profile")
- return False
+ raise AddonsError() from None
# Run add-on
if data[ATTR_STATE] == STATE_STARTED:
return await self.start()
_LOGGER.info("Finish restore for add-on %s", self._id)
- return True
diff --git a/hassio/addons/utils.py b/hassio/addons/utils.py
index b2ae6f718..3d59e585a 100644
--- a/hassio/addons/utils.py
+++ b/hassio/addons/utils.py
@@ -20,6 +20,7 @@ from ..const import (
SECURITY_DISABLE,
SECURITY_PROFILE,
)
+from ..exceptions import AddonsNotSupportedError
if TYPE_CHECKING:
from .addon import Addon
@@ -107,7 +108,7 @@ def check_installed(method):
"""Return False if not installed or the function."""
if not addon.is_installed:
_LOGGER.error("Addon %s is not installed", addon.slug)
- return False
+ raise AddonsNotSupportedError()
return await method(addon, *args, **kwargs)
return wrap_check
diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py
index 62b2567b8..91477c9e6 100644
--- a/hassio/addons/validate.py
+++ b/hassio/addons/validate.py
@@ -1,29 +1,87 @@
"""Validate add-ons options schema."""
import logging
import re
+import secrets
import uuid
import voluptuous as vol
from ..const import (
- ARCH_ALL, ATTR_ACCESS_TOKEN, ATTR_APPARMOR, ATTR_ARCH, ATTR_ARGS,
- ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_AUTH_API,
- ATTR_AUTO_UART, ATTR_AUTO_UPDATE, ATTR_BOOT, ATTR_BUILD_FROM,
- ATTR_DESCRIPTON, ATTR_DEVICES, ATTR_DEVICETREE, ATTR_DISCOVERY,
- ATTR_DOCKER_API, ATTR_ENVIRONMENT, ATTR_FULL_ACCESS, ATTR_GPIO,
- ATTR_HASSIO_API, ATTR_HASSIO_ROLE, ATTR_HOMEASSISTANT_API, ATTR_HOST_DBUS,
- ATTR_HOST_IPC, ATTR_HOST_NETWORK, ATTR_HOST_PID, ATTR_IMAGE,
- ATTR_KERNEL_MODULES, ATTR_LEGACY, ATTR_LOCATON, ATTR_MACHINE,
- ATTR_MAINTAINER, ATTR_MAP, ATTR_NAME, ATTR_NETWORK, ATTR_OPTIONS,
- ATTR_PORTS, ATTR_PRIVILEGED, ATTR_PROTECTED, ATTR_REPOSITORY, ATTR_SCHEMA,
- ATTR_SERVICES, ATTR_SLUG, ATTR_SQUASH, ATTR_STARTUP, ATTR_STATE,
- ATTR_STDIN, ATTR_SYSTEM, ATTR_TIMEOUT, ATTR_TMPFS, ATTR_URL, ATTR_USER,
- ATTR_UUID, ATTR_VERSION, ATTR_WEBUI, BOOT_AUTO, BOOT_MANUAL,
- PRIVILEGED_ALL, ROLE_ALL, ROLE_DEFAULT, STARTUP_ALL, STARTUP_APPLICATION,
- STARTUP_SERVICES, STATE_STARTED, STATE_STOPPED)
+ ARCH_ALL,
+ ATTR_ACCESS_TOKEN,
+ ATTR_APPARMOR,
+ ATTR_ARCH,
+ ATTR_ARGS,
+ ATTR_AUDIO,
+ ATTR_AUDIO_INPUT,
+ ATTR_AUDIO_OUTPUT,
+ ATTR_AUTH_API,
+ ATTR_AUTO_UART,
+ ATTR_AUTO_UPDATE,
+ ATTR_BOOT,
+ ATTR_BUILD_FROM,
+ ATTR_DESCRIPTON,
+ ATTR_DEVICES,
+ ATTR_DEVICETREE,
+ ATTR_DISCOVERY,
+ ATTR_DOCKER_API,
+ ATTR_ENVIRONMENT,
+ ATTR_FULL_ACCESS,
+ ATTR_GPIO,
+ ATTR_HASSIO_API,
+ ATTR_HASSIO_ROLE,
+ ATTR_HOMEASSISTANT_API,
+ ATTR_HOMEASSISTANT,
+ ATTR_HOST_DBUS,
+ ATTR_HOST_IPC,
+ ATTR_HOST_NETWORK,
+ ATTR_HOST_PID,
+ ATTR_IMAGE,
+ ATTR_INGRESS,
+ ATTR_INGRESS_ENTRY,
+ ATTR_INGRESS_PORT,
+ ATTR_INGRESS_TOKEN,
+ ATTR_KERNEL_MODULES,
+ ATTR_LEGACY,
+ ATTR_LOCATON,
+ ATTR_MACHINE,
+ ATTR_MAINTAINER,
+ ATTR_MAP,
+ ATTR_NAME,
+ ATTR_NETWORK,
+ ATTR_OPTIONS,
+ ATTR_PORTS,
+ ATTR_PRIVILEGED,
+ ATTR_PROTECTED,
+ ATTR_REPOSITORY,
+ ATTR_SCHEMA,
+ ATTR_SERVICES,
+ ATTR_SLUG,
+ ATTR_SQUASH,
+ ATTR_STARTUP,
+ ATTR_STATE,
+ ATTR_STDIN,
+ ATTR_SYSTEM,
+ ATTR_TIMEOUT,
+ ATTR_TMPFS,
+ ATTR_URL,
+ ATTR_USER,
+ ATTR_UUID,
+ ATTR_VERSION,
+ ATTR_WEBUI,
+ BOOT_AUTO,
+ BOOT_MANUAL,
+ PRIVILEGED_ALL,
+ ROLE_ALL,
+ ROLE_DEFAULT,
+ STARTUP_ALL,
+ STARTUP_APPLICATION,
+ STARTUP_SERVICES,
+ STATE_STARTED,
+ STATE_STOPPED,
+)
from ..discovery.validate import valid_discovery_service
-from ..validate import (
- ALSA_DEVICE, DOCKER_PORTS, NETWORK_PORT, SHA256, UUID_MATCH)
+from ..validate import ALSA_DEVICE, DOCKER_PORTS, NETWORK_PORT, TOKEN, UUID_MATCH
_LOGGER = logging.getLogger(__name__)
@@ -89,6 +147,10 @@ SCHEMA_ADDON_CONFIG = vol.Schema({
vol.Optional(ATTR_PORTS): DOCKER_PORTS,
vol.Optional(ATTR_WEBUI):
vol.Match(r"^(?:https?|\[PROTO:\w+\]):\/\/\[HOST\]:\[PORT:\d+\].*$"),
+ vol.Optional(ATTR_INGRESS, default=False): vol.Boolean(),
+ vol.Optional(ATTR_INGRESS_PORT, default=8099): NETWORK_PORT,
+ vol.Optional(ATTR_INGRESS_ENTRY): vol.Coerce(str),
+ vol.Optional(ATTR_HOMEASSISTANT): vol.Maybe(vol.Coerce(str)),
vol.Optional(ATTR_HOST_NETWORK, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_PID, default=False): vol.Boolean(),
vol.Optional(ATTR_HOST_IPC, default=False): vol.Boolean(),
@@ -158,7 +220,8 @@ SCHEMA_ADDON_USER = vol.Schema({
vol.Required(ATTR_VERSION): vol.Coerce(str),
vol.Optional(ATTR_IMAGE): vol.Coerce(str),
vol.Optional(ATTR_UUID, default=lambda: uuid.uuid4().hex): UUID_MATCH,
- vol.Optional(ATTR_ACCESS_TOKEN): SHA256,
+ vol.Optional(ATTR_ACCESS_TOKEN): TOKEN,
+ vol.Optional(ATTR_INGRESS_TOKEN, default=secrets.token_urlsafe): vol.Coerce(str),
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 c105500e6..865a3cb54 100644
--- a/hassio/api/__init__.py
+++ b/hassio/api/__init__.py
@@ -14,6 +14,7 @@ from .hassos import APIHassOS
from .homeassistant import APIHomeAssistant
from .host import APIHost
from .info import APIInfo
+from .ingress import APIIngress
from .proxy import APIProxy
from .security import SecurityMiddleware
from .services import APIServices
@@ -47,6 +48,7 @@ class RestAPI(CoreSysAttributes):
self._register_proxy()
self._register_panel()
self._register_addons()
+ self._register_ingress()
self._register_snapshots()
self._register_discovery()
self._register_services()
@@ -186,6 +188,16 @@ class RestAPI(CoreSysAttributes):
web.get('/addons/{addon}/stats', api_addons.stats),
])
+ def _register_ingress(self) -> None:
+ """Register Ingress functions."""
+ api_ingress = APIIngress()
+ api_ingress.coresys = self.coresys
+
+ self.webapp.add_routes([
+ web.post('/ingress/session', api_ingress.create_session),
+ web.view('/ingress/{token}/{path:.*}', api_ingress.handler),
+ ])
+
def _register_snapshots(self) -> None:
"""Register snapshots functions."""
api_snapshots = APISnapshots()
diff --git a/hassio/api/addons.py b/hassio/api/addons.py
index 853c923cd..fefb9306d 100644
--- a/hassio/api/addons.py
+++ b/hassio/api/addons.py
@@ -1,31 +1,89 @@
"""Init file for Hass.io Home Assistant RESTful API."""
import asyncio
import logging
+from typing import Any, Awaitable, Dict, List
+from aiohttp import web
import voluptuous as vol
from voluptuous.humanize import humanize_error
-from .utils import api_process, api_process_raw, api_validate
+from ..addons.addon import Addon
from ..addons.utils import rating_security
from ..const import (
- ATTR_VERSION, ATTR_LAST_VERSION, ATTR_STATE, ATTR_BOOT, ATTR_OPTIONS,
- ATTR_URL, ATTR_DESCRIPTON, ATTR_DETACHED, ATTR_NAME, ATTR_REPOSITORY,
- ATTR_BUILD, ATTR_AUTO_UPDATE, ATTR_NETWORK, ATTR_HOST_NETWORK, ATTR_SLUG,
- ATTR_SOURCE, ATTR_REPOSITORIES, ATTR_ADDONS, ATTR_ARCH, ATTR_MAINTAINER,
- ATTR_INSTALLED, ATTR_LOGO, ATTR_WEBUI, ATTR_DEVICES, ATTR_PRIVILEGED,
- ATTR_AUDIO, ATTR_AUDIO_INPUT, ATTR_AUDIO_OUTPUT, ATTR_HASSIO_API,
- ATTR_GPIO, ATTR_HOMEASSISTANT_API, ATTR_STDIN, BOOT_AUTO, BOOT_MANUAL,
- ATTR_CHANGELOG, ATTR_HOST_IPC, ATTR_HOST_DBUS, ATTR_LONG_DESCRIPTION,
- 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_DISCOVERY, ATTR_APPARMOR, ATTR_DEVICETREE, ATTR_DOCKER_API,
- ATTR_FULL_ACCESS, ATTR_PROTECTED, ATTR_RATING, ATTR_HOST_PID,
- ATTR_HASSIO_ROLE, ATTR_MACHINE, ATTR_AVAILABLE, ATTR_AUTH_API,
+ ATTR_ADDONS,
+ ATTR_APPARMOR,
+ ATTR_ARCH,
+ ATTR_AUDIO,
+ ATTR_AUDIO_INPUT,
+ ATTR_AUDIO_OUTPUT,
+ ATTR_AUTH_API,
+ ATTR_AUTO_UPDATE,
+ ATTR_AVAILABLE,
+ ATTR_BLK_READ,
+ ATTR_BLK_WRITE,
+ ATTR_BOOT,
+ ATTR_BUILD,
+ ATTR_CHANGELOG,
+ ATTR_CPU_PERCENT,
+ ATTR_DESCRIPTON,
+ ATTR_DETACHED,
+ ATTR_DEVICES,
+ ATTR_DEVICETREE,
+ ATTR_DISCOVERY,
+ ATTR_DOCKER_API,
+ ATTR_FULL_ACCESS,
+ ATTR_GPIO,
+ ATTR_HASSIO_API,
+ ATTR_HASSIO_ROLE,
+ ATTR_HOMEASSISTANT,
+ ATTR_HOMEASSISTANT_API,
+ ATTR_HOST_DBUS,
+ ATTR_HOST_IPC,
+ ATTR_HOST_NETWORK,
+ ATTR_HOST_PID,
+ ATTR_ICON,
+ ATTR_INGRESS,
+ ATTR_INGRESS_ENTRY,
+ ATTR_INGRESS_URL,
+ ATTR_INSTALLED,
+ ATTR_IP_ADDRESS,
ATTR_KERNEL_MODULES,
- CONTENT_TYPE_PNG, CONTENT_TYPE_BINARY, CONTENT_TYPE_TEXT, REQUEST_FROM)
+ ATTR_LAST_VERSION,
+ ATTR_LOGO,
+ ATTR_LONG_DESCRIPTION,
+ ATTR_MACHINE,
+ ATTR_MAINTAINER,
+ ATTR_MEMORY_LIMIT,
+ ATTR_MEMORY_USAGE,
+ ATTR_NAME,
+ ATTR_NETWORK,
+ ATTR_NETWORK_RX,
+ ATTR_NETWORK_TX,
+ ATTR_OPTIONS,
+ ATTR_PRIVILEGED,
+ ATTR_PROTECTED,
+ ATTR_RATING,
+ ATTR_REPOSITORIES,
+ ATTR_REPOSITORY,
+ ATTR_SERVICES,
+ ATTR_SLUG,
+ ATTR_SOURCE,
+ ATTR_STATE,
+ ATTR_STDIN,
+ ATTR_URL,
+ ATTR_VERSION,
+ ATTR_WEBUI,
+ BOOT_AUTO,
+ BOOT_MANUAL,
+ CONTENT_TYPE_BINARY,
+ CONTENT_TYPE_PNG,
+ CONTENT_TYPE_TEXT,
+ REQUEST_FROM,
+)
from ..coresys import CoreSysAttributes
-from ..validate import DOCKER_PORTS, ALSA_DEVICE
from ..exceptions import APIError
+from ..validate import ALSA_DEVICE, DOCKER_PORTS
+from .utils import api_process, api_process_raw, api_validate
_LOGGER = logging.getLogger(__name__)
@@ -51,7 +109,7 @@ SCHEMA_SECURITY = vol.Schema({
class APIAddons(CoreSysAttributes):
"""Handle RESTful API for add-on functions."""
- def _extract_addon(self, request, check_installed=True):
+ def _extract_addon(self, request: web.Request, check_installed: bool = True) -> Addon:
"""Return addon, throw an exception it it doesn't exist."""
addon_slug = request.match_info.get('addon')
@@ -69,7 +127,7 @@ class APIAddons(CoreSysAttributes):
return addon
@api_process
- async def list(self, request):
+ async def list(self, request: web.Request) -> Dict[str, Any]:
"""Return all add-ons or repositories."""
data_addons = []
for addon in self.sys_addons.list_addons:
@@ -104,13 +162,12 @@ class APIAddons(CoreSysAttributes):
}
@api_process
- async def reload(self, request):
+ async def reload(self, request: web.Request) -> None:
"""Reload all add-on data."""
await asyncio.shield(self.sys_addons.reload())
- return True
@api_process
- async def info(self, request):
+ async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return add-on information."""
addon = self._extract_addon(request, check_installed=False)
@@ -130,6 +187,7 @@ class APIAddons(CoreSysAttributes):
ATTR_OPTIONS: addon.options,
ATTR_ARCH: addon.supported_arch,
ATTR_MACHINE: addon.supported_machine,
+ ATTR_HOMEASSISTANT: addon.homeassistant_version,
ATTR_URL: addon.url,
ATTR_DETACHED: addon.is_detached,
ATTR_AVAILABLE: addon.available,
@@ -161,17 +219,20 @@ class APIAddons(CoreSysAttributes):
ATTR_AUDIO_OUTPUT: addon.audio_output,
ATTR_SERVICES: _pretty_services(addon),
ATTR_DISCOVERY: addon.discovery,
+ ATTR_IP_ADDRESS: str(addon.ip_address),
+ ATTR_INGRESS: addon.with_ingress,
+ ATTR_INGRESS_ENTRY: addon.ingress_entry,
+ ATTR_INGRESS_URL: addon.ingress_url,
}
@api_process
- async def options(self, request):
+ async def options(self, request: web.Request) -> None:
"""Store user options for add-on."""
addon = self._extract_addon(request)
addon_schema = SCHEMA_OPTIONS.extend({
vol.Optional(ATTR_OPTIONS): vol.Any(None, addon.schema),
})
-
body = await api_validate(addon_schema, request)
if ATTR_OPTIONS in body:
@@ -188,10 +249,9 @@ class APIAddons(CoreSysAttributes):
addon.audio_output = body[ATTR_AUDIO_OUTPUT]
addon.save_data()
- return True
@api_process
- async def security(self, request):
+ async def security(self, request: web.Request) -> None:
"""Store security options for add-on."""
addon = self._extract_addon(request)
body = await api_validate(SCHEMA_SECURITY, request)
@@ -201,17 +261,13 @@ class APIAddons(CoreSysAttributes):
addon.protected = body[ATTR_PROTECTED]
addon.save_data()
- return True
@api_process
- async def stats(self, request):
+ async def stats(self, request: web.Request) -> Dict[str, Any]:
"""Return resource information."""
addon = self._extract_addon(request)
stats = await addon.stats()
- if not stats:
- raise APIError("No stats available")
-
return {
ATTR_CPU_PERCENT: stats.cpu_percent,
ATTR_MEMORY_USAGE: stats.memory_usage,
@@ -223,19 +279,19 @@ class APIAddons(CoreSysAttributes):
}
@api_process
- def install(self, request):
+ def install(self, request: web.Request) -> Awaitable[None]:
"""Install add-on."""
addon = self._extract_addon(request, check_installed=False)
return asyncio.shield(addon.install())
@api_process
- def uninstall(self, request):
+ def uninstall(self, request: web.Request) -> Awaitable[None]:
"""Uninstall add-on."""
addon = self._extract_addon(request)
return asyncio.shield(addon.uninstall())
@api_process
- def start(self, request):
+ def start(self, request: web.Request) -> Awaitable[None]:
"""Start add-on."""
addon = self._extract_addon(request)
@@ -249,13 +305,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.start())
@api_process
- def stop(self, request):
+ def stop(self, request: web.Request) -> Awaitable[None]:
"""Stop add-on."""
addon = self._extract_addon(request)
return asyncio.shield(addon.stop())
@api_process
- def update(self, request):
+ def update(self, request: web.Request) -> Awaitable[None]:
"""Update add-on."""
addon = self._extract_addon(request)
@@ -265,13 +321,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.update())
@api_process
- def restart(self, request):
+ def restart(self, request: web.Request) -> Awaitable[None]:
"""Restart add-on."""
addon = self._extract_addon(request)
return asyncio.shield(addon.restart())
@api_process
- def rebuild(self, request):
+ def rebuild(self, request: web.Request) -> Awaitable[None]:
"""Rebuild local build add-on."""
addon = self._extract_addon(request)
if not addon.need_build:
@@ -280,13 +336,13 @@ class APIAddons(CoreSysAttributes):
return asyncio.shield(addon.rebuild())
@api_process_raw(CONTENT_TYPE_BINARY)
- def logs(self, request):
+ def logs(self, request: web.Request) -> Awaitable[bytes]:
"""Return logs from add-on."""
addon = self._extract_addon(request)
return addon.logs()
@api_process_raw(CONTENT_TYPE_PNG)
- async def icon(self, request):
+ async def icon(self, request: web.Request) -> bytes:
"""Return icon from add-on."""
addon = self._extract_addon(request, check_installed=False)
if not addon.with_icon:
@@ -296,7 +352,7 @@ class APIAddons(CoreSysAttributes):
return png.read()
@api_process_raw(CONTENT_TYPE_PNG)
- async def logo(self, request):
+ async def logo(self, request: web.Request) -> bytes:
"""Return logo from add-on."""
addon = self._extract_addon(request, check_installed=False)
if not addon.with_logo:
@@ -306,7 +362,7 @@ class APIAddons(CoreSysAttributes):
return png.read()
@api_process_raw(CONTENT_TYPE_TEXT)
- async def changelog(self, request):
+ async def changelog(self, request: web.Request) -> str:
"""Return changelog from add-on."""
addon = self._extract_addon(request, check_installed=False)
if not addon.with_changelog:
@@ -316,17 +372,17 @@ class APIAddons(CoreSysAttributes):
return changelog.read()
@api_process
- async def stdin(self, request):
+ async def stdin(self, request: web.Request) -> None:
"""Write to stdin of add-on."""
addon = self._extract_addon(request)
if not addon.with_stdin:
raise APIError("STDIN not supported by add-on")
data = await request.read()
- return await asyncio.shield(addon.write_stdin(data))
+ await asyncio.shield(addon.write_stdin(data))
-def _pretty_devices(addon):
+def _pretty_devices(addon: Addon) -> List[str]:
"""Return a simplified device list."""
dev_list = addon.devices
if not dev_list:
@@ -334,7 +390,7 @@ def _pretty_devices(addon):
return [row.split(':')[0] for row in dev_list]
-def _pretty_services(addon):
+def _pretty_services(addon: Addon) -> List[str]:
"""Return a simplified services role list."""
services = []
for name, access in addon.services_role.items():
diff --git a/hassio/api/hassos.py b/hassio/api/hassos.py
index eb2d8ccdd..36a84c481 100644
--- a/hassio/api/hassos.py
+++ b/hassio/api/hassos.py
@@ -1,27 +1,31 @@
"""Init file for Hass.io HassOS RESTful API."""
import asyncio
import logging
+from typing import Any, Awaitable, Dict
import voluptuous as vol
+from aiohttp import web
-from .utils import api_process, api_validate
from ..const import (
- ATTR_VERSION, ATTR_BOARD, ATTR_VERSION_LATEST, ATTR_VERSION_CLI,
- ATTR_VERSION_CLI_LATEST)
+ ATTR_BOARD,
+ ATTR_VERSION,
+ ATTR_VERSION_CLI,
+ ATTR_VERSION_CLI_LATEST,
+ ATTR_VERSION_LATEST,
+)
from ..coresys import CoreSysAttributes
+from .utils import api_process, api_validate
_LOGGER = logging.getLogger(__name__)
-SCHEMA_VERSION = vol.Schema({
- vol.Optional(ATTR_VERSION): vol.Coerce(str),
-})
+SCHEMA_VERSION = vol.Schema({vol.Optional(ATTR_VERSION): vol.Coerce(str)})
class APIHassOS(CoreSysAttributes):
"""Handle RESTful API for HassOS functions."""
@api_process
- async def info(self, request):
+ async def info(self, request: web.Request) -> Dict[str, Any]:
"""Return HassOS information."""
return {
ATTR_VERSION: self.sys_hassos.version,
@@ -32,7 +36,7 @@ class APIHassOS(CoreSysAttributes):
}
@api_process
- async def update(self, request):
+ async def update(self, request: web.Request) -> None:
"""Update HassOS."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_latest)
@@ -40,7 +44,7 @@ class APIHassOS(CoreSysAttributes):
await asyncio.shield(self.sys_hassos.update(version))
@api_process
- async def update_cli(self, request):
+ async def update_cli(self, request: web.Request) -> None:
"""Update HassOS CLI."""
body = await api_validate(SCHEMA_VERSION, request)
version = body.get(ATTR_VERSION, self.sys_hassos.version_cli_latest)
@@ -48,6 +52,6 @@ class APIHassOS(CoreSysAttributes):
await asyncio.shield(self.sys_hassos.update_cli(version))
@api_process
- def config_sync(self, request):
+ def config_sync(self, request: web.Request) -> Awaitable[None]:
"""Trigger config reload on HassOS."""
return asyncio.shield(self.sys_hassos.config_sync())
diff --git a/hassio/api/homeassistant.py b/hassio/api/homeassistant.py
index 619858d06..d5b305788 100644
--- a/hassio/api/homeassistant.py
+++ b/hassio/api/homeassistant.py
@@ -27,6 +27,7 @@ from ..const import (
ATTR_VERSION,
ATTR_WAIT_BOOT,
ATTR_WATCHDOG,
+ ATTR_IP_ADDRESS,
CONTENT_TYPE_BINARY,
)
from ..coresys import CoreSysAttributes
@@ -64,6 +65,7 @@ class APIHomeAssistant(CoreSysAttributes):
ATTR_VERSION: self.sys_homeassistant.version,
ATTR_LAST_VERSION: self.sys_homeassistant.last_version,
ATTR_MACHINE: self.sys_homeassistant.machine,
+ ATTR_IP_ADDRESS: str(self.sys_homeassistant.ip_address),
ATTR_ARCH: self.sys_homeassistant.arch,
ATTR_IMAGE: self.sys_homeassistant.image,
ATTR_CUSTOM: self.sys_homeassistant.is_custom_image,
diff --git a/hassio/api/ingress.py b/hassio/api/ingress.py
new file mode 100644
index 000000000..c4fd6b087
--- /dev/null
+++ b/hassio/api/ingress.py
@@ -0,0 +1,217 @@
+"""Hass.io Add-on ingress service."""
+import asyncio
+from ipaddress import ip_address
+import logging
+from typing import Any, Dict, Union
+
+import aiohttp
+from aiohttp import hdrs, web
+from aiohttp.web_exceptions import (
+ HTTPBadGateway,
+ HTTPServiceUnavailable,
+ HTTPUnauthorized,
+)
+from multidict import CIMultiDict, istr
+
+from ..addons.addon import Addon
+from ..const import ATTR_SESSION, HEADER_TOKEN, REQUEST_FROM, COOKIE_INGRESS
+from ..coresys import CoreSysAttributes
+from .utils import api_process
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class APIIngress(CoreSysAttributes):
+ """Ingress view to handle add-on webui routing."""
+
+ def _extract_addon(self, request: web.Request) -> Addon:
+ """Return addon, throw an exception it it doesn't exist."""
+ token = request.match_info.get("token")
+
+ # Find correct add-on
+ addon = self.sys_ingress.get(token)
+ if not addon:
+ _LOGGER.warning("Ingress for %s not available", token)
+ raise HTTPServiceUnavailable()
+
+ return addon
+
+ def _check_ha_access(self, request: web.Request) -> None:
+ if request[REQUEST_FROM] != self.sys_homeassistant:
+ _LOGGER.warning("Ingress is only available behind Home Assistant")
+ raise HTTPUnauthorized()
+
+ def _create_url(self, addon: Addon, path: str) -> str:
+ """Create URL to container."""
+ return f"{addon.ingress_internal}/{path}"
+
+ @api_process
+ async def create_session(self, request: web.Request) -> Dict[str, Any]:
+ """Create a new session."""
+ self._check_ha_access(request)
+
+ session = self.sys_ingress.create_session()
+ return {ATTR_SESSION: session}
+
+ async def handler(
+ self, request: web.Request
+ ) -> Union[web.Response, web.StreamResponse, web.WebSocketResponse]:
+ """Route data to Hass.io ingress service."""
+ self._check_ha_access(request)
+
+ # Check Ingress Session
+ session = request.cookies.get(COOKIE_INGRESS)
+ if not self.sys_ingress.validate_session(session):
+ _LOGGER.warning("No valid ingress session %s", session)
+ raise HTTPUnauthorized()
+
+ # Process requests
+ addon = self._extract_addon(request)
+ path = request.match_info.get("path")
+ try:
+ # Websocket
+ if _is_websocket(request):
+ return await self._handle_websocket(request, addon, path)
+
+ # Request
+ return await self._handle_request(request, addon, path)
+
+ except aiohttp.ClientError as err:
+ _LOGGER.error("Ingress error: %s", err)
+
+ raise HTTPBadGateway() from None
+
+ async def _handle_websocket(
+ self, request: web.Request, addon: Addon, path: str
+ ) -> web.WebSocketResponse:
+ """Ingress route for websocket."""
+ ws_server = web.WebSocketResponse()
+ await ws_server.prepare(request)
+
+ # Preparing
+ url = self._create_url(addon, path)
+ source_header = _init_header(request, addon)
+
+ # Support GET query
+ if request.query_string:
+ url = "{}?{}".format(url, request.query_string)
+
+ # Start proxy
+ async with self.sys_websession.ws_connect(
+ url, headers=source_header
+ ) as ws_client:
+ # Proxy requests
+ await asyncio.wait(
+ [
+ _websocket_forward(ws_server, ws_client),
+ _websocket_forward(ws_client, ws_server),
+ ],
+ return_when=asyncio.FIRST_COMPLETED,
+ )
+
+ return ws_server
+
+ async def _handle_request(
+ self, request: web.Request, addon: Addon, path: str
+ ) -> Union[web.Response, web.StreamResponse]:
+ """Ingress route for request."""
+ url = self._create_url(addon, path)
+ data = await request.read()
+ source_header = _init_header(request, addon)
+
+ async with self.sys_websession.request(
+ request.method, url, headers=source_header, params=request.query, data=data
+ ) as result:
+ headers = _response_header(result)
+
+ # Simple request
+ if (
+ hdrs.CONTENT_LENGTH in result.headers
+ and int(result.headers.get(hdrs.CONTENT_LENGTH, 0)) < 4_194_000
+ ):
+ # Return Response
+ body = await result.read()
+ return web.Response(headers=headers, status=result.status, body=body)
+
+ # Stream response
+ response = web.StreamResponse(status=result.status, headers=headers)
+ response.content_type = result.content_type
+
+ try:
+ await response.prepare(request)
+ async for data in result.content.iter_chunked(4096):
+ await response.write(data)
+
+ except (aiohttp.ClientError, aiohttp.ClientPayloadError) as err:
+ _LOGGER.error("Stream error with %s: %s", url, err)
+
+ return response
+
+
+def _init_header(
+ request: web.Request, addon: str
+) -> Union[CIMultiDict, Dict[str, str]]:
+ """Create initial header."""
+ headers = {}
+
+ # filter flags
+ for name, value in request.headers.items():
+ if name in (
+ hdrs.CONTENT_LENGTH,
+ hdrs.CONTENT_TYPE,
+ hdrs.CONTENT_ENCODING,
+ istr(HEADER_TOKEN),
+ ):
+ continue
+ headers[name] = value
+
+ # Update X-Forwarded-For
+ forward_for = request.headers.get(hdrs.X_FORWARDED_FOR)
+ connected_ip = ip_address(request.transport.get_extra_info("peername")[0])
+ headers[hdrs.X_FORWARDED_FOR] = f"{forward_for}, {connected_ip!s}"
+
+ return headers
+
+
+def _response_header(response: aiohttp.ClientResponse) -> Dict[str, str]:
+ """Create response header."""
+ headers = {}
+
+ for name, value in response.headers.items():
+ if name in (
+ hdrs.TRANSFER_ENCODING,
+ hdrs.CONTENT_LENGTH,
+ hdrs.CONTENT_TYPE,
+ hdrs.CONTENT_ENCODING,
+ ):
+ continue
+ headers[name] = value
+
+ return headers
+
+
+def _is_websocket(request: web.Request) -> bool:
+ """Return True if request is a websocket."""
+ headers = request.headers
+
+ if (
+ headers.get(hdrs.CONNECTION) == "Upgrade"
+ and headers.get(hdrs.UPGRADE) == "websocket"
+ ):
+ return True
+ return False
+
+
+async def _websocket_forward(ws_from, ws_to):
+ """Handle websocket message directly."""
+ async for msg in ws_from:
+ if msg.type == aiohttp.WSMsgType.TEXT:
+ await ws_to.send_str(msg.data)
+ elif msg.type == aiohttp.WSMsgType.BINARY:
+ await ws_to.send_bytes(msg.data)
+ elif msg.type == aiohttp.WSMsgType.PING:
+ await ws_to.ping()
+ elif msg.type == aiohttp.WSMsgType.PONG:
+ await ws_to.pong()
+ elif ws_to.closed:
+ await ws_to.close(code=ws_to.close_code, message=msg.extra)
diff --git a/hassio/api/panel/chunk.05bbfb49a092df0b4304.js.gz b/hassio/api/panel/chunk.05bbfb49a092df0b4304.js.gz
deleted file mode 100644
index 3eec9c684..000000000
Binary files a/hassio/api/panel/chunk.05bbfb49a092df0b4304.js.gz and /dev/null differ
diff --git a/hassio/api/panel/chunk.088b1034e27d00ee9329.js b/hassio/api/panel/chunk.088b1034e27d00ee9329.js
deleted file mode 100644
index c3f9f2efe..000000000
--- a/hassio/api/panel/chunk.088b1034e27d00ee9329.js
+++ /dev/null
@@ -1 +0,0 @@
-(window.webpackJsonp=window.webpackJsonp||[]).push([[7],{101:function(e,t,n){(function(r){var i;function o(e){return(o="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}!function(r){"use strict";var s={newline:/^\n+/,code:/^( {4}[^\n]+\n*)+/,fences:y,hr:/^ {0,3}((?:- *){3,}|(?:_ *){3,}|(?:\* *){3,})(?:\n+|$)/,heading:/^ *(#{1,6}) *([^\n]+?) *(?:#+ *)?(?:\n+|$)/,nptable:y,blockquote:/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/,list:/^( {0,3})(bull) [\s\S]+?(?:hr|def|\n{2,}(?! )(?!\1bull )\n*|\s*$)/,html:"^ {0,3}(?:<(script|pre|style)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)|comment[^\\n]*(\\n+|$)|<\\?[\\s\\S]*?\\?>\\n*|\\n*|\\n*|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:\\n{2,}|$)|<(?!script|pre|style)([a-z][\\w-]*)(?:attribute)*? */?>(?=\\h*\\n)[\\s\\S]*?(?:\\n{2,}|$)|(?!script|pre|style)[a-z][\\w-]*\\s*>(?=\\h*\\n)[\\s\\S]*?(?:\\n{2,}|$))",def:/^ {0,3}\[(label)\]: *\n? *([^\s>]+)>?(?:(?: +\n? *| *\n *)(title))? *(?:\n+|$)/,table:y,lheading:/^([^\n]+)\n *(=|-){2,} *(?:\n+|$)/,paragraph:/^([^\n]+(?:\n(?!hr|heading|lheading| {0,3}>|<\/?(?:tag)(?: +|\n|\/?>)|<(?:script|pre|style|!--))[^\n]+)*)/,text:/^[^\n]+/};function a(e){this.tokens=[],this.tokens.links=Object.create(null),this.options=e||A.defaults,this.rules=s.normal,this.options.pedantic?this.rules=s.pedantic:this.options.gfm&&(this.options.tables?this.rules=s.tables:this.rules=s.gfm)}s._label=/(?!\s*\])(?:\\[\[\]]|[^\[\]])+/,s._title=/(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/,s.def=m(s.def).replace("label",s._label).replace("title",s._title).getRegex(),s.bullet=/(?:[*+-]|\d{1,9}\.)/,s.item=/^( *)(bull) ?[^\n]*(?:\n(?!\1bull ?)[^\n]*)*/,s.item=m(s.item,"gm").replace(/bull/g,s.bullet).getRegex(),s.list=m(s.list).replace(/bull/g,s.bullet).replace("hr","\\n+(?=\\1?(?:(?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$))").replace("def","\\n+(?="+s.def.source+")").getRegex(),s._tag="address|article|aside|base|basefont|blockquote|body|caption|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option|p|param|section|source|summary|table|tbody|td|tfoot|th|thead|title|tr|track|ul",s._comment=//,s.html=m(s.html,"i").replace("comment",s._comment).replace("tag",s._tag).replace("attribute",/ +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/).getRegex(),s.paragraph=m(s.paragraph).replace("hr",s.hr).replace("heading",s.heading).replace("lheading",s.lheading).replace("tag",s._tag).getRegex(),s.blockquote=m(s.blockquote).replace("paragraph",s.paragraph).getRegex(),s.normal=w({},s),s.gfm=w({},s.normal,{fences:/^ {0,3}(`{3,}|~{3,})([^`\n]*)\n(?:|([\s\S]*?)\n)(?: {0,3}\1[~`]* *(?:\n+|$)|$)/,paragraph:/^/,heading:/^ *(#{1,6}) +([^\n]+?) *#* *(?:\n+|$)/}),s.gfm.paragraph=m(s.paragraph).replace("(?!","(?!"+s.gfm.fences.source.replace("\\1","\\2")+"|"+s.list.source.replace("\\1","\\3")+"|").getRegex(),s.tables=w({},s.gfm,{nptable:/^ *([^|\n ].*\|.*)\n *([-:]+ *\|[-| :]*)(?:\n((?:.*[^>\n ].*(?:\n|$))*)\n*|$)/,table:/^ *\|(.+)\n *\|?( *[-:]+[-| :]*)(?:\n((?: *[^>\n ].*(?:\n|$))*)\n*|$)/}),s.pedantic=w({},s.normal,{html:m("^ *(?:comment *(?:\\n|\\s*$)|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)| "+e+"
\n":"'+(n?e:f(e,!0))+"
"},u.prototype.blockquote=function(e){return""+(n?e:f(e,!0))+"
\n"+e+"
\n"},u.prototype.html=function(e){return e},u.prototype.heading=function(e,t,n,r){return this.options.headerIds?"
\n":"
\n"},u.prototype.list=function(e,t,n){var r=t?"ol":"ul";return"<"+r+(t&&1!==n?' start="'+n+'"':"")+">\n"+e+""+r+">\n"},u.prototype.listitem=function(e){return"\n\n"+e+"\n"+t+"
\n"},u.prototype.tablerow=function(e){return"\n"+e+" \n"},u.prototype.tablecell=function(e,t){var n=t.header?"th":"td";return(t.align?"<"+n+' align="'+t.align+'">':"<"+n+">")+e+""+n+">\n"},u.prototype.strong=function(e){return""+e+""},u.prototype.em=function(e){return""+e+""},u.prototype.codespan=function(e){return""+e+"
"},u.prototype.br=function(){return this.options.xhtml?"
":"
"},u.prototype.del=function(e){return""+e+""},u.prototype.link=function(e,t,n){if(null===(e=b(this.options.sanitize,this.options.baseUrl,e)))return n;var r='"+n+""},u.prototype.image=function(e,t,n){if(null===(e=b(this.options.sanitize,this.options.baseUrl,e)))return n;var r='":">"},u.prototype.text=function(e){return e},p.prototype.strong=p.prototype.em=p.prototype.codespan=p.prototype.del=p.prototype.text=function(e){return e},p.prototype.link=p.prototype.image=function(e,t,n){return""+n},p.prototype.br=function(){return""},h.parse=function(e,t){return new h(t).parse(e)},h.prototype.parse=function(e){this.inline=new c(e.links,this.options),this.inlineText=new c(e.links,w({},this.options,{renderer:new p})),this.tokens=e.reverse();for(var t="";this.next();)t+=this.tok();return t},h.prototype.next=function(){return this.token=this.tokens.pop()},h.prototype.peek=function(){return this.tokens[this.tokens.length-1]||0},h.prototype.parseText=function(){for(var e=this.token.text;"text"===this.peek().type;)e+="\n"+this.next().text;return this.inline.output(e)},h.prototype.tok=function(){switch(this.token.type){case"space":return"";case"hr":return this.renderer.hr();case"heading":return this.renderer.heading(this.inline.output(this.token.text),this.token.depth,d(this.inlineText.output(this.token.text)),this.slugger);case"code":return this.renderer.code(this.token.text,this.token.lang,this.token.escaped);case"table":var e,t,n,r,i="",o="";for(n="",e=0;e
"+f(e.message+"",!0)+"";throw e}}y.exec=y,A.options=A.setOptions=function(e){return w(A.defaults,e),A},A.getDefaults=function(){return{baseUrl:null,breaks:!1,gfm:!0,headerIds:!0,headerPrefix:"",highlight:null,langPrefix:"language-",mangle:!0,pedantic:!1,renderer:new u,sanitize:!1,sanitizer:null,silent:!1,smartLists:!1,smartypants:!1,tables:!0,xhtml:!1}},A.defaults=A.getDefaults(),A.Parser=h,A.parser=h.parse,A.Renderer=u,A.TextRenderer=p,A.Lexer=a,A.lexer=a.lex,A.InlineLexer=c,A.inlineLexer=c.output,A.Slugger=g,A.parse=A,"object"===o(t)?e.exports=A:void 0===(i=function(){return A}.call(t,n,t,e))||(e.exports=i)}(this||"undefined"!=typeof window&&window)}).call(this,n(102))},102:function(e,t){function n(e){return(n="function"==typeof Symbol&&"symbol"==typeof Symbol.iterator?function(e){return typeof e}:function(e){return e&&"function"==typeof Symbol&&e.constructor===Symbol&&e!==Symbol.prototype?"symbol":typeof e})(e)}var r;r=function(){return this}();try{r=r||new Function("return this")()}catch(e){"object"===("undefined"==typeof window?"undefined":n(window))&&(r=window)}e.exports=r},103:function(e,t,n){var r=n(81),i=n(84),o=n(106);for(var s in(t=e.exports=function(e,t){return new o(t).process(e)}).FilterXSS=o,r)t[s]=r[s];for(var s in i)t[s]=i[s];"undefined"!=typeof window&&(window.filterXSS=e.exports),"undefined"!=typeof self&&"undefined"!=typeof DedicatedWorkerGlobalScope&&self instanceof DedicatedWorkerGlobalScope&&(self.filterXSS=e.exports)},104:function(e,t,n){var r=n(82),i=n(105);n(83);function o(e){return null==e}function s(e){(e=function(e){var t={};for(var n in e)t[n]=e[n];return t}(e||{})).whiteList=e.whiteList||r.whiteList,e.onAttr=e.onAttr||r.onAttr,e.onIgnoreAttr=e.onIgnoreAttr||r.onIgnoreAttr,e.safeAttrValue=e.safeAttrValue||r.safeAttrValue,this.options=e}s.prototype.process=function(e){if(!(e=(e=e||"").toString()))return"";var t=this.options,n=t.whiteList,r=t.onAttr,s=t.onIgnoreAttr,a=t.safeAttrValue;return i(e,function(e,t,i,l,c){var u=n[i],p=!1;if(!0===u?p=u:"function"==typeof u?p=u(l):u instanceof RegExp&&(p=u.test(l)),!0!==p&&(p=!1),l=a(i,l)){var h,g={position:t,sourcePosition:e,source:c,isWhite:p};return p?o(h=r(i,l,g))?i+":"+l:h:o(h=s(i,l,g))?void 0:h}})},e.exports=s},105:function(e,t,n){var r=n(83);e.exports=function(e,t){";"!==(e=r.trimRight(e))[e.length-1]&&(e+=";");var n=e.length,i=!1,o=0,s=0,a="";function l(){if(!i){var n=r.trim(e.slice(o,s)),l=n.indexOf(":");if(-1!==l){var c=r.trim(n.slice(0,l)),u=r.trim(n.slice(l+1));if(c){var p=t(o,a.length,c,u,n);p&&(a+=p+"; ")}}}o=s+1}for(;s
This addon is not available on your system.
\n \nContainer | \nHost | \n
---|---|
[[item.container]] | \n\n | \n
Error: [[error]]
\n \n \n[[error]]
\n \nHostname | \n[[data.hostname]] | \n
System | \n[[data.operating_system]] | \n
Deployment | \n[[data.deployment]] | \n
Version | \n[[data.version]] | \n
Latest version | \n[[data.last_version]] | \n
Channel | \n[[data.channel]] | \n
This addon is not available on your system.
\n \nContainer | \nHost | \n
---|---|
[[item.container]] | \n\n | \n