diff --git a/API.md b/API.md index 00ab200de..17e41db45 100644 --- a/API.md +++ b/API.md @@ -25,7 +25,7 @@ On success / Code 200: To access the API you need use an authorization header with a `Bearer` token. -The token is available for add-ons and Home Assistant using the +The token is available for add-ons and Home Assistant using the `SUPERVISOR_TOKEN` environment variable. ### Supervisor @@ -242,13 +242,16 @@ return: ```json { - "hostname": "hostname|null", - "features": ["shutdown", "reboot", "hostname", "services", "hassos"], - "operating_system": "HassOS XY|Ubuntu 16.4|null", - "kernel": "4.15.7|null", "chassis": "specific|null", + "cpe": "xy|null", "deployment": "stable|beta|dev|null", - "cpe": "xy|null" + "disk_total": 32.0, + "disk_used": 30.0, + "disk_free": 2.0, + "features": ["shutdown", "reboot", "hostname", "services", "hassos"], + "hostname": "hostname|null", + "kernel": "4.15.7|null", + "operating_system": "HassOS XY|Ubuntu 16.4|null" } ``` diff --git a/requirements.txt b/requirements.txt index 6ca0bb8ce..82803a60a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,6 +14,6 @@ pulsectl==20.5.1 pytz==2020.1 pyudev==0.22.0 ruamel.yaml==0.15.100 -sentry-sdk==0.16.3 +sentry-sdk==0.16.4 uvloop==0.14.0 voluptuous==0.11.7 diff --git a/supervisor/addons/__init__.py b/supervisor/addons/__init__.py index 7c64dd48c..7518f4dcf 100644 --- a/supervisor/addons/__init__.py +++ b/supervisor/addons/__init__.py @@ -45,7 +45,7 @@ class AddonManager(CoreSysAttributes): """Return a list of all installed add-ons.""" return list(self.local.values()) - def get(self, addon_slug: str) -> Optional[AnyAddon]: + def get(self, addon_slug: str, local_only: bool = False) -> Optional[AnyAddon]: """Return an add-on from slug. Prio: @@ -54,7 +54,9 @@ class AddonManager(CoreSysAttributes): """ if addon_slug in self.local: return self.local[addon_slug] - return self.store.get(addon_slug) + if not local_only: + return self.store.get(addon_slug) + return None def from_token(self, token: str) -> Optional[Addon]: """Return an add-on from Supervisor token.""" @@ -156,7 +158,12 @@ class AddonManager(CoreSysAttributes): raise AddonsError() from None else: self.local[slug] = addon - _LOGGER.info("Add-on '%s' successfully installed", slug) + + # Reload ingress tokens + if addon.with_ingress: + await self.sys_ingress.reload() + + _LOGGER.info("Add-on '%s' successfully installed", slug) async def uninstall(self, slug: str) -> None: """Remove an add-on.""" @@ -188,7 +195,9 @@ class AddonManager(CoreSysAttributes): await self.sys_ingress.update_hass_panel(addon) # Cleanup Ingress dynamic port assignment - self.sys_ingress.del_dynamic_port(slug) + if addon.with_ingress: + self.sys_create_task(self.sys_ingress.reload()) + self.sys_ingress.del_dynamic_port(slug) # Cleanup discovery data for message in self.sys_discovery.list_messages: @@ -302,6 +311,7 @@ class AddonManager(CoreSysAttributes): # Update ingress if addon.with_ingress: + await self.sys_ingress.reload() with suppress(HomeAssistantAPIError): await self.sys_ingress.update_hass_panel(addon) diff --git a/supervisor/addons/addon.py b/supervisor/addons/addon.py index f92cfb7db..bf36430d1 100644 --- a/supervisor/addons/addon.py +++ b/supervisor/addons/addon.py @@ -43,6 +43,7 @@ from ..coresys import CoreSys from ..docker.addon import DockerAddon from ..docker.stats import DockerStats from ..exceptions import ( + AddonConfigurationError, AddonsError, AddonsNotSupportedError, DockerAPIError, @@ -375,7 +376,7 @@ class Addon(AddonModel): _LOGGER.debug("Add-on %s write options: %s", self.slug, options) return - raise AddonsError() + raise AddonConfigurationError() async def remove_data(self) -> None: """Remove add-on data.""" diff --git a/supervisor/api/host.py b/supervisor/api/host.py index 2e2361164..d463fcf83 100644 --- a/supervisor/api/host.py +++ b/supervisor/api/host.py @@ -11,6 +11,9 @@ from ..const import ( ATTR_CPE, ATTR_DEPLOYMENT, ATTR_DESCRIPTON, + ATTR_DISK_FREE, + ATTR_DISK_TOTAL, + ATTR_DISK_USED, ATTR_FEATURES, ATTR_HOSTNAME, ATTR_KERNEL, @@ -39,11 +42,14 @@ class APIHost(CoreSysAttributes): return { ATTR_CHASSIS: self.sys_host.info.chassis, ATTR_CPE: self.sys_host.info.cpe, - ATTR_FEATURES: self.sys_host.supperted_features, - ATTR_HOSTNAME: self.sys_host.info.hostname, - ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, ATTR_DEPLOYMENT: self.sys_host.info.deployment, + ATTR_DISK_FREE: self.sys_host.info.free_space, + ATTR_DISK_TOTAL: self.sys_host.info.total_space, + ATTR_DISK_USED: self.sys_host.info.used_space, + ATTR_FEATURES: self.sys_host.supported_features, + ATTR_HOSTNAME: self.sys_host.info.hostname, ATTR_KERNEL: self.sys_host.info.kernel, + ATTR_OPERATING_SYSTEM: self.sys_host.info.operating_system, } @api_process diff --git a/supervisor/api/info.py b/supervisor/api/info.py index 6b25cf800..d3cc898b8 100644 --- a/supervisor/api/info.py +++ b/supervisor/api/info.py @@ -39,7 +39,7 @@ class APIInfo(CoreSysAttributes): ATTR_MACHINE: self.sys_machine, ATTR_ARCH: self.sys_arch.default, ATTR_SUPPORTED_ARCH: self.sys_arch.supported, - ATTR_SUPPORTED: self.sys_supported, + ATTR_SUPPORTED: self.sys_core.supported, ATTR_CHANNEL: self.sys_updater.channel, ATTR_LOGGING: self.sys_config.logging, ATTR_TIMEZONE: self.sys_timezone, diff --git a/supervisor/api/supervisor.py b/supervisor/api/supervisor.py index 2b9a09728..067ef42de 100644 --- a/supervisor/api/supervisor.py +++ b/supervisor/api/supervisor.py @@ -100,7 +100,7 @@ class APISupervisor(CoreSysAttributes): ATTR_VERSION_LATEST: self.sys_updater.version_supervisor, ATTR_CHANNEL: self.sys_updater.channel, ATTR_ARCH: self.sys_supervisor.arch, - ATTR_SUPPORTED: self.sys_supported, + ATTR_SUPPORTED: self.sys_core.supported, ATTR_HEALTHY: self.sys_core.healthy, ATTR_IP_ADDRESS: str(self.sys_supervisor.ip_address), ATTR_WAIT_BOOT: self.sys_config.wait_boot, diff --git a/supervisor/bootstrap.py b/supervisor/bootstrap.py index 65dd15e8d..d3a5a3d9b 100644 --- a/supervisor/bootstrap.py +++ b/supervisor/bootstrap.py @@ -16,13 +16,13 @@ from .arch import CpuArch from .auth import Auth from .const import ( ENV_HOMEASSISTANT_REPOSITORY, + ENV_SUPERVISOR_DEV, ENV_SUPERVISOR_MACHINE, ENV_SUPERVISOR_NAME, ENV_SUPERVISOR_SHARE, MACHINE_ID, SOCKET_DOCKER, SUPERVISOR_VERSION, - CoreStates, LogLevel, UpdateChannels, ) @@ -34,6 +34,7 @@ from .hassos import HassOS from .homeassistant import HomeAssistant from .host import HostManager from .ingress import Ingress +from .misc.filter import filter_data from .misc.hwmon import HwMonitor from .misc.scheduler import Scheduler from .misc.secrets import SecretsManager @@ -169,7 +170,7 @@ def initialize_system_data(coresys: CoreSys) -> None: coresys.config.modify_log_level() # Check if ENV is in development mode - if bool(os.environ.get("SUPERVISOR_DEV", 0)): + if bool(os.environ.get(ENV_SUPERVISOR_DEV, 0)): _LOGGER.warning("SUPERVISOR_DEV is set") coresys.updater.channel = UpdateChannels.DEV coresys.config.logging = LogLevel.DEBUG @@ -280,54 +281,19 @@ def supervisor_debugger(coresys: CoreSys) -> None: def setup_diagnostics(coresys: CoreSys) -> None: """Sentry diagnostic backend.""" - _LOGGER.info("Initialize Supervisor Sentry") - def filter_data(event, hint): - # Ignore issue if system is not supported or diagnostics is disabled - if not coresys.config.diagnostics or not coresys.supported: - return None - - # Not full startup - missing information - if coresys.core.state in (CoreStates.INITIALIZE, CoreStates.SETUP): - return event - - # Update information - event.setdefault("extra", {}).update( - { - "supervisor": { - "machine": coresys.machine, - "arch": coresys.arch.default, - "docker": coresys.docker.info.version, - "channel": coresys.updater.channel, - "supervisor": coresys.supervisor.version, - "os": coresys.hassos.version, - "host": coresys.host.info.operating_system, - "kernel": coresys.host.info.kernel, - "core": coresys.homeassistant.version, - "audio": coresys.plugins.audio.version, - "dns": coresys.plugins.dns.version, - "multicast": coresys.plugins.multicast.version, - "cli": coresys.plugins.cli.version, - } - } - ) - event.setdefault("tags", {}).update( - { - "installation_type": "os" if coresys.hassos.available else "supervised", - "machine": coresys.machine, - } - ) - - return event + def filter_event(event, hint): + return filter_data(coresys, event, hint) # Set log level sentry_logging = LoggingIntegration( level=logging.WARNING, event_level=logging.CRITICAL ) + _LOGGER.info("Initialize Supervisor Sentry") sentry_sdk.init( dsn="https://9c6ea70f49234442b4746e447b24747e@o427061.ingest.sentry.io/5370612", - before_send=filter_data, + before_send=filter_event, max_breadcrumbs=30, integrations=[AioHttpIntegration(), sentry_logging], release=SUPERVISOR_VERSION, diff --git a/supervisor/const.py b/supervisor/const.py index d9316c9b3..67db991b0 100644 --- a/supervisor/const.py +++ b/supervisor/const.py @@ -3,7 +3,7 @@ from enum import Enum from ipaddress import ip_network from pathlib import Path -SUPERVISOR_VERSION = "232" +SUPERVISOR_VERSION = "233" URL_HASSIO_ADDONS = "https://github.com/home-assistant/hassio-addons" URL_HASSIO_VERSION = "https://version.home-assistant.io/{channel}.json" @@ -36,6 +36,11 @@ SOCKET_DBUS = Path("/run/dbus/system_bus_socket") DOCKER_NETWORK = "hassio" DOCKER_NETWORK_MASK = ip_network("172.30.32.0/23") DOCKER_NETWORK_RANGE = ip_network("172.30.33.0/24") +DOCKER_IMAGE_DENYLIST = [ + "containrrr/watchtower", + "pyouroboros/ouroboros", + "v2tec/watchtower", +] DNS_SUFFIX = "local.hass.io" @@ -74,6 +79,7 @@ ENV_HOMEASSISTANT_REPOSITORY = "HOMEASSISTANT_REPOSITORY" ENV_SUPERVISOR_SHARE = "SUPERVISOR_SHARE" ENV_SUPERVISOR_NAME = "SUPERVISOR_NAME" ENV_SUPERVISOR_MACHINE = "SUPERVISOR_MACHINE" +ENV_SUPERVISOR_DEV = "SUPERVISOR_DEV" REQUEST_FROM = "HASSIO_FROM" @@ -163,6 +169,9 @@ ATTR_AUDIO_OUTPUT = "audio_output" ATTR_INPUT = "input" ATTR_OUTPUT = "output" ATTR_DISK = "disk" +ATTR_DISK_FREE = "disk_free" +ATTR_DISK_TOTAL = "disk_total" +ATTR_DISK_USED = "disk_used" ATTR_SERIAL = "serial" ATTR_SECURITY = "security" ATTR_BUILD_FROM = "build_from" @@ -328,6 +337,8 @@ ROLE_ALL = [ROLE_DEFAULT, ROLE_HOMEASSISTANT, ROLE_BACKUP, ROLE_MANAGER, ROLE_AD CHAN_ID = "chan_id" CHAN_TYPE = "chan_type" +SUPERVISED_SUPPORTED_OS = ["Debian GNU/Linux 10 (buster)"] + class AddonStartup(str, Enum): """Startup types of Add-on.""" diff --git a/supervisor/core.py b/supervisor/core.py index 505804d05..ba9f1e219 100644 --- a/supervisor/core.py +++ b/supervisor/core.py @@ -5,7 +5,7 @@ import logging import async_timeout -from .const import SOCKET_DBUS, AddonStartup, CoreStates +from .const import SOCKET_DBUS, SUPERVISED_SUPPORTED_OS, AddonStartup, CoreStates from .coresys import CoreSys, CoreSysAttributes from .exceptions import HassioError, HomeAssistantError, SupervisorUpdateError @@ -19,12 +19,8 @@ class Core(CoreSysAttributes): """Initialize Supervisor object.""" self.coresys: CoreSys = coresys self.state: CoreStates = CoreStates.INITIALIZE - self._healthy: bool = True - - @property - def healthy(self) -> bool: - """Return True if system is healthy.""" - return self._healthy and self.sys_supported + self.healthy: bool = True + self.supported: bool = True async def connect(self): """Connect Supervisor container.""" @@ -32,21 +28,25 @@ class Core(CoreSysAttributes): # If host docker is supported? if not self.sys_docker.info.supported_version: - self.coresys.supported = False + self.supported = False + self.healthy = False _LOGGER.error( "Docker version %s is not supported by Supervisor!", self.sys_docker.info.version, ) elif self.sys_docker.info.inside_lxc: - self.coresys.supported = False + self.supported = False + self.healthy = False _LOGGER.error( "Detected Docker running inside LXC. Running Home Assistant with the Supervisor on LXC is not supported!" ) - self.sys_docker.info.check_requirements() + + if self.sys_docker.info.check_requirements(): + self.supported = False # Dbus available if not SOCKET_DBUS.exists(): - self.coresys.supported = False + self.supported = False _LOGGER.error( "DBus is required for Home Assistant. This system is not supported!" ) @@ -58,13 +58,13 @@ class Core(CoreSysAttributes): self.sys_config.version == "dev" or self.sys_supervisor.instance.version == "dev" ): - self.coresys.supported = False + self.supported = False _LOGGER.warning( "Found a development supervisor outside dev channel (%s)", self.sys_updater.channel, ) elif self.sys_config.version != self.sys_supervisor.version: - self._healthy = False + self.healthy = False _LOGGER.error( "Update %s of Supervisor %s fails!", self.sys_config.version, @@ -120,13 +120,42 @@ class Core(CoreSysAttributes): # Load secrets await self.sys_secrets.load() + # Check supported OS + if not self.sys_hassos.available: + if self.sys_host.info.operating_system not in SUPERVISED_SUPPORTED_OS: + self.supported = False + _LOGGER.error( + "Using '%s' as the OS is not supported", + self.sys_host.info.operating_system, + ) + else: + # Check rauc connectivity on our OS + if not self.sys_dbus.rauc.is_connected: + self.healthy = False + + # Check all DBUS connectivity + if not self.sys_dbus.hostname.is_connected: + self.supported = False + _LOGGER.error("Hostname DBUS is not connected") + if not self.sys_dbus.nmi_dns.is_connected: + self.supported = False + _LOGGER.error("NetworkManager DNS DBUS is not connected") + if not self.sys_dbus.systemd.is_connected: + self.supported = False + _LOGGER.error("Systemd DBUS is not connected") + + # Check if image names from denylist exist + if await self.sys_run_in_executor(self.sys_docker.check_denylist_images): + self.coresys.supported = False + self.healthy = False + async def start(self): """Start Supervisor orchestration.""" self.state = CoreStates.STARTUP await self.sys_api.start() # Check if system is healthy - if not self.sys_supported: + if not self.supported: _LOGGER.critical("System running in a unsupported environment!") elif not self.healthy: _LOGGER.critical( diff --git a/supervisor/coresys.py b/supervisor/coresys.py index ab10c2631..eb8b0bc6b 100644 --- a/supervisor/coresys.py +++ b/supervisor/coresys.py @@ -48,9 +48,6 @@ class CoreSys: self._machine_id: Optional[str] = None self._machine: Optional[str] = None - # Static attributes - self.supported: bool = True - # External objects self._loop: asyncio.BaseEventLoop = asyncio.get_running_loop() self._websession: aiohttp.ClientSession = aiohttp.ClientSession() @@ -462,11 +459,6 @@ class CoreSysAttributes: """Return True if we run dev mode.""" return self.coresys.dev - @property - def sys_supported(self) -> bool: - """Return True if the system is supported.""" - return self.coresys.supported - @property def sys_timezone(self) -> str: """Return timezone.""" diff --git a/supervisor/docker/__init__.py b/supervisor/docker/__init__.py index 985b73c56..dfc5b59ca 100644 --- a/supervisor/docker/__init__.py +++ b/supervisor/docker/__init__.py @@ -9,7 +9,7 @@ import attr import docker from packaging import version as pkg_version -from ..const import DNS_SUFFIX, SOCKET_DOCKER +from ..const import DNS_SUFFIX, DOCKER_IMAGE_DENYLIST, SOCKET_DOCKER from ..exceptions import DockerAPIError from .network import DockerNetwork @@ -60,6 +60,8 @@ class DockerInfo: if self.logging != "journald": _LOGGER.error("Docker logging driver %s is not supported!", self.logging) + return self.storage != "overlay2" or self.logging != "journald" + class DockerAPI: """Docker Supervisor wrapper. @@ -230,3 +232,24 @@ class DockerAPI: _LOGGER.debug("Networks prune: %s", output) except docker.errors.APIError as err: _LOGGER.warning("Error for networks prune: %s", err) + + def check_denylist_images(self) -> bool: + """Return a boolean if the host has images in the denylist.""" + denied_images = set() + for image in self.images.list(): + for tag in image.tags: + image_name = tag.split(":")[0] + if ( + image_name in DOCKER_IMAGE_DENYLIST + and image_name not in denied_images + ): + denied_images.add(image_name) + + if not denied_images: + return False + + _LOGGER.error( + "Found images: '%s' which are not supported, remove these from the host!", + ", ".join(denied_images), + ) + return True diff --git a/supervisor/docker/network.py b/supervisor/docker/network.py index d1eee8a85..fc4e98df9 100644 --- a/supervisor/docker/network.py +++ b/supervisor/docker/network.py @@ -31,7 +31,15 @@ class DockerNetwork: @property def containers(self) -> List[docker.models.containers.Container]: """Return of connected containers from network.""" - return self.network.containers + containers: List[docker.models.containers.Container] = [] + for cid, data in self.network.attrs.get("Containers", {}).items(): + try: + containers.append(self.docker.containers.get(cid)) + except docker.errors.APIError as err: + _LOGGER.warning("Docker network is corrupt! %s - run autofix", err) + self.stale_cleanup(data.get("Name", cid)) + + return containers @property def gateway(self) -> IPv4Address: diff --git a/supervisor/exceptions.py b/supervisor/exceptions.py index b6b89f004..2b7fe5871 100644 --- a/supervisor/exceptions.py +++ b/supervisor/exceptions.py @@ -105,6 +105,10 @@ class AddonsError(HassioError): """Addons exception.""" +class AddonConfigurationError(AddonsError): + """Error with add-on configuration.""" + + class AddonsNotSupportedError(HassioNotSupportedError): """Addons don't support a function.""" diff --git a/supervisor/host/__init__.py b/supervisor/host/__init__.py index c88ec8598..e4bb5b71e 100644 --- a/supervisor/host/__init__.py +++ b/supervisor/host/__init__.py @@ -66,7 +66,7 @@ class HostManager(CoreSysAttributes): return self._sound @property - def supperted_features(self): + def supported_features(self): """Return a list of supported host features.""" features = [] diff --git a/supervisor/host/info.py b/supervisor/host/info.py index 66bcb05f3..26569b213 100644 --- a/supervisor/host/info.py +++ b/supervisor/host/info.py @@ -51,6 +51,20 @@ class InfoCenter(CoreSysAttributes): """Return local CPE.""" return self.sys_dbus.hostname.cpe + @property + def total_space(self) -> float: + """Return total space (GiB) on disk for supervisor data directory.""" + return self.coresys.hardware.get_disk_total_space( + self.coresys.config.path_supervisor + ) + + @property + def used_space(self) -> float: + """Return used space (GiB) on disk for supervisor data directory.""" + return self.coresys.hardware.get_disk_used_space( + self.coresys.config.path_supervisor + ) + @property def free_space(self) -> float: """Return available space (GiB) on disk for supervisor data directory.""" diff --git a/supervisor/ingress.py b/supervisor/ingress.py index 3f1220f29..6a64fdac5 100644 --- a/supervisor/ingress.py +++ b/supervisor/ingress.py @@ -28,8 +28,8 @@ class Ingress(JsonConfig, CoreSysAttributes): def get(self, token: str) -> Optional[Addon]: """Return addon they have this ingress token.""" if token not in self.tokens: - self._update_token_list() - return self.sys_addons.get(self.tokens.get(token)) + return None + return self.sys_addons.get(self.tokens[token], local_only=True) @property def sessions(self) -> Dict[str, float]: @@ -61,6 +61,7 @@ class Ingress(JsonConfig, CoreSysAttributes): async def reload(self) -> None: """Reload/Validate sessions.""" self._cleanup_sessions() + self._update_token_list() async def unload(self) -> None: """Shutdown sessions.""" diff --git a/supervisor/misc/filter.py b/supervisor/misc/filter.py new file mode 100644 index 000000000..6b051d448 --- /dev/null +++ b/supervisor/misc/filter.py @@ -0,0 +1,89 @@ +"""Filter tools.""" +import os +import re + +from aiohttp import hdrs + +from ..const import ENV_SUPERVISOR_DEV, HEADER_TOKEN_OLD, CoreStates +from ..coresys import CoreSys +from ..exceptions import AddonConfigurationError + +RE_URL: re.Pattern = re.compile(r"(\w+:\/\/)(.*\.\w+)(.*)") + + +def sanitize_url(url: str) -> str: + """Return a sanitized url.""" + if not re.match(RE_URL, url): + # Not a URL, just return it back + return url + + return re.sub(RE_URL, r"\1example.com\3", url) + + +def filter_data(coresys: CoreSys, event: dict, hint: dict) -> dict: + """Filter event data before sending to sentry.""" + dev_env: bool = bool(os.environ.get(ENV_SUPERVISOR_DEV, 0)) + + # Ignore some exceptions + if "exc_info" in hint: + _, exc_value, _ = hint["exc_info"] + if isinstance(exc_value, (AddonConfigurationError)): + return None + + # Ignore issue if system is not supported or diagnostics is disabled + if not coresys.config.diagnostics or not coresys.supported or dev_env: + return None + + # Not full startup - missing information + if coresys.core.state in (CoreStates.INITIALIZE, CoreStates.SETUP): + return event + + # Update information + event.setdefault("extra", {}).update( + { + "supervisor": { + "machine": coresys.machine, + "arch": coresys.arch.default, + "docker": coresys.docker.info.version, + "channel": coresys.updater.channel, + "supervisor": coresys.supervisor.version, + "os": coresys.hassos.version, + "host": coresys.host.info.operating_system, + "kernel": coresys.host.info.kernel, + "core": coresys.homeassistant.version, + "audio": coresys.plugins.audio.version, + "dns": coresys.plugins.dns.version, + "multicast": coresys.plugins.multicast.version, + "cli": coresys.plugins.cli.version, + "disk_free_space": coresys.host.info.free_space, + } + } + ) + event.setdefault("tags", []).extend( + [ + ["installation_type", "os" if coresys.hassos.available else "supervised"], + ["machine", coresys.machine], + ], + ) + + # Sanitize event + for i, tag in enumerate(event.get("tags", [])): + key, value = tag + if key == "url": + event["tags"][i] = [key, sanitize_url(value)] + + if event.get("request"): + if event["request"].get("url"): + event["request"]["url"] = sanitize_url(event["request"]["url"]) + + for i, header in enumerate(event["request"].get("headers", [])): + key, value = header + if key == hdrs.REFERER: + event["request"]["headers"][i] = [key, sanitize_url(value)] + + if key == HEADER_TOKEN_OLD: + event["request"]["headers"][i] = [key, "XXXXXXXXXXXXXXXXXXX"] + + if key in [hdrs.HOST, hdrs.X_FORWARDED_HOST]: + event["request"]["headers"][i] = [key, "example.com"] + return event diff --git a/supervisor/misc/hardware.py b/supervisor/misc/hardware.py index addd90065..df3f95b29 100644 --- a/supervisor/misc/hardware.py +++ b/supervisor/misc/hardware.py @@ -196,6 +196,16 @@ class Hardware: return datetime.utcfromtimestamp(int(found.group(1))) + def get_disk_total_space(self, path: Union[str, Path]) -> float: + """Return total space (GiB) on disk for path.""" + total, _, _ = shutil.disk_usage(path) + return round(total / (1024.0 ** 3), 1) + + def get_disk_used_space(self, path: Union[str, Path]) -> float: + """Return used space (GiB) on disk for path.""" + _, used, _ = shutil.disk_usage(path) + return round(used / (1024.0 ** 3), 1) + def get_disk_free_space(self, path: Union[str, Path]) -> float: """Return free space (GiB) on disk for path.""" _, _, free = shutil.disk_usage(path) diff --git a/supervisor/misc/hwmon.py b/supervisor/misc/hwmon.py index 2d6ebf469..b36375f30 100644 --- a/supervisor/misc/hwmon.py +++ b/supervisor/misc/hwmon.py @@ -28,6 +28,7 @@ class HwMonitor(CoreSysAttributes): self.monitor = pyudev.Monitor.from_netlink(self.context) self.observer = pyudev.MonitorObserver(self.monitor, self._udev_events) except OSError: + self.sys_core.healthy = False _LOGGER.critical("Not privileged to run udev monitor!") else: self.observer.start() diff --git a/supervisor/utils/__init__.py b/supervisor/utils/__init__.py index bb4a43499..41df7065a 100644 --- a/supervisor/utils/__init__.py +++ b/supervisor/utils/__init__.py @@ -8,7 +8,8 @@ import socket from typing import Optional _LOGGER: logging.Logger = logging.getLogger(__name__) -RE_STRING = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") + +RE_STRING: re.Pattern = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") def convert_to_ascii(raw: bytes) -> str: diff --git a/tests/conftest.py b/tests/conftest.py index f7896c7b4..a55d60684 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -5,7 +5,7 @@ import pytest from supervisor.bootstrap import initialize_coresys -# pylint: disable=redefined-outer-name +# pylint: disable=redefined-outer-name, protected-access @pytest.fixture @@ -26,6 +26,7 @@ async def coresys(loop, docker): coresys_obj = await initialize_coresys() coresys_obj.ingress.save_data = MagicMock() + coresys_obj.arch._default_arch = "amd64" yield coresys_obj diff --git a/tests/misc/test_filter_data.py b/tests/misc/test_filter_data.py new file mode 100644 index 000000000..795bd8683 --- /dev/null +++ b/tests/misc/test_filter_data.py @@ -0,0 +1,94 @@ +"""Test sentry data filter.""" +from unittest.mock import patch + +from supervisor.const import CoreStates +from supervisor.exceptions import AddonConfigurationError +from supervisor.misc.filter import filter_data + +SAMPLE_EVENT = {"sample": "event"} + + +def test_ignored_exception(coresys): + """Test ignored exceptions.""" + hint = {"exc_info": (None, AddonConfigurationError(), None)} + assert filter_data(coresys, SAMPLE_EVENT, hint) is None + + +def test_diagnostics_disabled(coresys): + """Test if diagnostics is disabled.""" + coresys.config.diagnostics = False + coresys.supported = True + assert filter_data(coresys, SAMPLE_EVENT, {}) is None + + +def test_not_supported(coresys): + """Test if not supported.""" + coresys.config.diagnostics = True + coresys.supported = False + assert filter_data(coresys, SAMPLE_EVENT, {}) is None + + +def test_is_dev(coresys): + """Test if dev.""" + coresys.config.diagnostics = True + coresys.supported = True + with patch("os.environ", return_value=[("ENV_SUPERVISOR_DEV", "1")]): + assert filter_data(coresys, SAMPLE_EVENT, {}) is None + + +def test_not_started(coresys): + """Test if supervisor not fully started.""" + coresys.config.diagnostics = True + coresys.supported = True + + coresys.core.state = CoreStates.INITIALIZE + assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT + + coresys.core.state = CoreStates.SETUP + assert filter_data(coresys, SAMPLE_EVENT, {}) == SAMPLE_EVENT + + +def test_defaults(coresys): + """Test event defaults.""" + coresys.config.diagnostics = True + coresys.supported = True + + coresys.core.state = CoreStates.RUNNING + with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0 ** 3))): + filtered = filter_data(coresys, SAMPLE_EVENT, {}) + + assert ["installation_type", "supervised"] in filtered["tags"] + assert filtered["extra"]["supervisor"]["arch"] == "amd64" + + +def test_sanitize(coresys): + """Test event sanitation.""" + event = { + "tags": [["url", "https://mydomain.com"]], + "request": { + "url": "https://mydomain.com", + "headers": [ + ["Host", "mydomain.com"], + ["Referer", "https://mydomain.com/api/hassio_ingress/xxx-xxx/"], + ["X-Forwarded-Host", "mydomain.com"], + ["X-Hassio-Key", "xxx"], + ], + }, + } + coresys.config.diagnostics = True + coresys.supported = True + + coresys.core.state = CoreStates.RUNNING + with patch("shutil.disk_usage", return_value=(42, 42, 2 * (1024.0 ** 3))): + filtered = filter_data(coresys, event, {}) + + assert ["url", "https://example.com"] in filtered["tags"] + + assert filtered["request"]["url"] == "https://example.com" + + assert ["Host", "example.com"] in filtered["request"]["headers"] + assert ["Referer", "https://example.com/api/hassio_ingress/xxx-xxx/"] in filtered[ + "request" + ]["headers"] + assert ["X-Forwarded-Host", "example.com"] in filtered["request"]["headers"] + assert ["X-Hassio-Key", "XXXXXXXXXXXXXXXXXXX"] in filtered["request"]["headers"] diff --git a/tests/misc/test_hardware.py b/tests/misc/test_hardware.py index cbe2eda30..6c0f63d6f 100644 --- a/tests/misc/test_hardware.py +++ b/tests/misc/test_hardware.py @@ -41,3 +41,21 @@ def test_free_space(): free = system.get_disk_free_space("/data") assert free == 2.0 + + +def test_total_space(): + """Test total space helper.""" + system = Hardware() + with patch("shutil.disk_usage", return_value=(10 * (1024.0 ** 3), 42, 42)): + total = system.get_disk_total_space("/data") + + assert total == 10.0 + + +def test_used_space(): + """Test used space helper.""" + system = Hardware() + with patch("shutil.disk_usage", return_value=(42, 8 * (1024.0 ** 3), 42)): + used = system.get_disk_used_space("/data") + + assert used == 8.0 diff --git a/tests/misc/test_sanitise_url.py b/tests/misc/test_sanitise_url.py new file mode 100644 index 000000000..2041855fc --- /dev/null +++ b/tests/misc/test_sanitise_url.py @@ -0,0 +1,9 @@ +"""Test supervisor.utils.sanitize_url.""" +from supervisor.misc.filter import sanitize_url + + +def test_sanitize_url(): + """Test supervisor.utils.sanitize_url.""" + assert sanitize_url("test") == "test" + assert sanitize_url("http://my.duckdns.org") == "http://example.com" + assert sanitize_url("http://my.duckdns.org/test") == "http://example.com/test"