From 02d4045ec3c1e80a9f7bbf44a3c75e9afdff8925 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 11 Sep 2019 16:29:34 +0200 Subject: [PATCH] Add secrets support for options (#1283) * Add secrets API * Don't expose secrets --- .devcontainer/Dockerfile | 2 +- hassio/addons/addon.py | 4 ++- hassio/addons/model.py | 2 +- hassio/addons/validate.py | 42 ++++++++++++++++++++----------- hassio/api/security.py | 1 + hassio/bootstrap.py | 2 ++ hassio/const.py | 1 + hassio/core.py | 3 +++ hassio/coresys.py | 19 ++++++++++++++ hassio/secrets.py | 52 +++++++++++++++++++++++++++++++++++++++ hassio/tasks.py | 6 +++++ requirements.txt | 1 + 12 files changed, 118 insertions(+), 17 deletions(-) create mode 100644 hassio/secrets.py diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index e7404513d..18b012145 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -35,7 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Install Python dependencies from requirements.txt if it exists COPY requirements.txt requirements_tests.txt ./ -RUN pip3 install -r requirements_tests.txt -r requirements_tests.txt \ +RUN pip3 install -r requirements.txt -r requirements_tests.txt \ && pip3 install black tox \ && rm -f requirements.txt requirements_tests.txt diff --git a/hassio/addons/addon.py b/hassio/addons/addon.py index be4fc2238..582c12b9b 100644 --- a/hassio/addons/addon.py +++ b/hassio/addons/addon.py @@ -438,7 +438,9 @@ class Addon(AddonModel): options = {**self.persist[ATTR_OPTIONS], **default_options} # create voluptuous - new_schema = vol.Schema(vol.All(dict, validate_options(new_raw_schema))) + new_schema = vol.Schema( + vol.All(dict, validate_options(self.coresys, new_raw_schema)) + ) # validate try: diff --git a/hassio/addons/model.py b/hassio/addons/model.py index 1a895de61..ffdada1c9 100644 --- a/hassio/addons/model.py +++ b/hassio/addons/model.py @@ -461,7 +461,7 @@ class AddonModel(CoreSysAttributes): if isinstance(raw_schema, bool): return vol.Schema(dict) - return vol.Schema(vol.All(dict, validate_options(raw_schema))) + return vol.Schema(vol.All(dict, validate_options(self.coresys, raw_schema))) def __eq__(self, other): """Compaired add-on objects.""" diff --git a/hassio/addons/validate.py b/hassio/addons/validate.py index 1c65b92bb..bbc46bcc8 100644 --- a/hassio/addons/validate.py +++ b/hassio/addons/validate.py @@ -109,16 +109,21 @@ V_EMAIL = "email" V_URL = "url" V_PORT = "port" V_MATCH = "match" +V_LIST = "list" RE_SCHEMA_ELEMENT = re.compile( r"^(?:" - r"|str|bool|email|url|port" + r"|bool|email|url|port" + r"|str(?:\((?P\d+)?,(?P\d+)?\))?" r"|int(?:\((?P\d+)?,(?P\d+)?\))?" r"|float(?:\((?P[\d\.]+)?,(?P[\d\.]+)?\))?" r"|match\((?P.*)\)" + r"|list\((?P.+)\)" r")\??$" ) +_SCHEMA_LENGTH_PARTS = ("i_min", "i_max", "f_min", "f_max", "s_min", "s_max") + RE_DOCKER_IMAGE = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$") RE_DOCKER_IMAGE_BUILD = re.compile( r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$" @@ -305,7 +310,7 @@ SCHEMA_ADDON_SNAPSHOT = vol.Schema( ) -def validate_options(raw_schema): +def validate_options(coresys, raw_schema): """Validate schema.""" def validate(struct): @@ -323,13 +328,13 @@ def validate_options(raw_schema): try: if isinstance(typ, list): # nested value list - options[key] = _nested_validate_list(typ[0], value, key) + options[key] = _nested_validate_list(coresys, typ[0], value, key) elif isinstance(typ, dict): # nested value dict - options[key] = _nested_validate_dict(typ, value, key) + options[key] = _nested_validate_dict(coresys, typ, value, key) else: # normal value - options[key] = _single_validate(typ, value, key) + options[key] = _single_validate(coresys, typ, value, key) except (IndexError, KeyError): raise vol.Invalid(f"Type error for {key}") from None @@ -341,24 +346,29 @@ def validate_options(raw_schema): # pylint: disable=no-value-for-parameter # pylint: disable=inconsistent-return-statements -def _single_validate(typ, value, key): +def _single_validate(coresys, typ, value, key): """Validate a single element.""" # if required argument if value is None: raise vol.Invalid(f"Missing required option '{key}'") + # Lookup secret + if str(value).startswith("!secret "): + secret: str = value.partition(" ")[2] + value = coresys.secrets.get(secret) + # parse extend data from type match = RE_SCHEMA_ELEMENT.match(typ) # prepare range range_args = {} - for group_name in ("i_min", "i_max", "f_min", "f_max"): + for group_name in _SCHEMA_LENGTH_PARTS: group_value = match.group(group_name) if group_value: range_args[group_name[2:]] = float(group_value) if typ.startswith(V_STR): - return str(value) + return vol.All(str(value), vol.Range(**range_args))(value) elif typ.startswith(V_INT): return vol.All(vol.Coerce(int), vol.Range(**range_args))(value) elif typ.startswith(V_FLOAT): @@ -373,26 +383,28 @@ def _single_validate(typ, value, key): return NETWORK_PORT(value) elif typ.startswith(V_MATCH): return vol.Match(match.group("match"))(str(value)) + elif typ.strartswith(V_LIST): + return vol.In(match.group("list").split("|"))(str(value)) raise vol.Invalid(f"Fatal error for {key} type {typ}") -def _nested_validate_list(typ, data_list, key): +def _nested_validate_list(coresys, typ, data_list, key): """Validate nested items.""" options = [] for element in data_list: # Nested? if isinstance(typ, dict): - c_options = _nested_validate_dict(typ, element, key) + c_options = _nested_validate_dict(coresys, typ, element, key) options.append(c_options) else: - options.append(_single_validate(typ, element, key)) + options.append(_single_validate(coresys, typ, element, key)) return options -def _nested_validate_dict(typ, data_dict, key): +def _nested_validate_dict(coresys, typ, data_dict, key): """Validate nested items.""" options = {} @@ -404,9 +416,11 @@ def _nested_validate_dict(typ, data_dict, key): # Nested? if isinstance(typ[c_key], list): - options[c_key] = _nested_validate_list(typ[c_key][0], c_value, c_key) + options[c_key] = _nested_validate_list( + coresys, typ[c_key][0], c_value, c_key + ) else: - options[c_key] = _single_validate(typ[c_key], c_value, c_key) + options[c_key] = _single_validate(coresys, typ[c_key], c_value, c_key) _check_missing_options(typ, options, key) return options diff --git a/hassio/api/security.py b/hassio/api/security.py index 59fd70996..63d930197 100644 --- a/hassio/api/security.py +++ b/hassio/api/security.py @@ -40,6 +40,7 @@ NO_SECURITY_CHECK = re.compile( ADDONS_API_BYPASS = re.compile( r"^(?:" r"|/addons/self/(?!security|update)[^/]+" + r"|/secrets/.+" r"|/info" r"|/hardware/trigger" r"|/services.*" diff --git a/hassio/bootstrap.py b/hassio/bootstrap.py index 348fdcf18..a5587f5d0 100644 --- a/hassio/bootstrap.py +++ b/hassio/bootstrap.py @@ -27,6 +27,7 @@ from .store import StoreManager from .supervisor import Supervisor from .tasks import Tasks from .updater import Updater +from .secrets import SecretsManager from .utils.dt import fetch_timezone _LOGGER: logging.Logger = logging.getLogger(__name__) @@ -61,6 +62,7 @@ async def initialize_coresys(): coresys.discovery = Discovery(coresys) coresys.dbus = DBusManager(coresys) coresys.hassos = HassOS(coresys) + coresys.secrets = SecretsManager(coresys) # bootstrap config initialize_system_data(coresys) diff --git a/hassio/const.py b/hassio/const.py index e1bb8e9fd..ad608f0da 100644 --- a/hassio/const.py +++ b/hassio/const.py @@ -220,6 +220,7 @@ ATTR_DNS = "dns" ATTR_SERVERS = "servers" ATTR_LOCALS = "locals" ATTR_UDEV = "udev" +ATTR_VALUE = "value" PROVIDE_SERVICE = "provide" NEED_SERVICE = "need" diff --git a/hassio/core.py b/hassio/core.py index f3264cf69..572d21d4e 100644 --- a/hassio/core.py +++ b/hassio/core.py @@ -72,6 +72,9 @@ class HassIO(CoreSysAttributes): # Load ingress await self.sys_ingress.load() + # Load secrets + await self.sys_secrets.load() + async def start(self): """Start Hass.io orchestration.""" await self.sys_api.start() diff --git a/hassio/coresys.py b/hassio/coresys.py index 5d9892b34..70d47f707 100644 --- a/hassio/coresys.py +++ b/hassio/coresys.py @@ -24,6 +24,7 @@ if TYPE_CHECKING: from .homeassistant import HomeAssistant from .host import HostManager from .ingress import Ingress + from .secrets import SecretsManager from .services import ServiceManager from .snapshots import SnapshotManager from .supervisor import Supervisor @@ -70,6 +71,7 @@ class CoreSys: self._dbus: Optional[DBusManager] = None self._hassos: Optional[HassOS] = None self._services: Optional[ServiceManager] = None + self._secrets: Optional[SecretsManager] = None self._store: Optional[StoreManager] = None self._discovery: Optional[Discovery] = None @@ -209,6 +211,18 @@ class CoreSys: raise RuntimeError("Updater already set!") self._updater = value + @property + def secrets(self) -> SecretsManager: + """Return SecretsManager object.""" + return self._secrets + + @secrets.setter + def secrets(self, value: SecretsManager): + """Set a Updater object.""" + if self._secrets: + raise RuntimeError("SecretsManager already set!") + self._secrets = value + @property def addons(self) -> AddonManager: """Return AddonManager object.""" @@ -437,6 +451,11 @@ class CoreSysAttributes: """Return Updater object.""" return self.coresys.updater + @property + def sys_secrets(self) -> SecretsManager: + """Return SecretsManager object.""" + return self.coresys.secrets + @property def sys_addons(self) -> AddonManager: """Return AddonManager object.""" diff --git a/hassio/secrets.py b/hassio/secrets.py new file mode 100644 index 000000000..486c388b6 --- /dev/null +++ b/hassio/secrets.py @@ -0,0 +1,52 @@ +"""Handle Home Assistant secrets to add-ons.""" +from typing import Dict +from pathlib import Path +import logging + +from ruamel.yaml import YAML, YAMLError + +from .coresys import CoreSys, CoreSysAttributes + +_LOGGER: logging.Logger = logging.getLogger(__name__) + + +class SecretsManager(CoreSysAttributes): + """Manage Home Assistant secrets.""" + + def __init__(self, coresys: CoreSys): + """Initialize secret manager.""" + self.coresys: CoreSys = coresys + self.secrets: Dict[str, str] = {} + + @property + def path_secrets(self) -> Path: + """Return path to secret file.""" + return Path(self.sys_config.path_homeassistant, "secrets.yaml") + + def get(self, secret: str) -> str: + """Get secret from store.""" + _LOGGER.info("Request secret %s", secret) + return self.secrets.get(secret) + + async def load(self) -> None: + """Load secrets on start.""" + await self._read_secrets() + + async def reload(self) -> None: + """Reload secrets.""" + await self._read_secrets() + + async def _read_secrets(self): + """Read secrets.yaml into memory.""" + if not self.path_secrets.exists(): + _LOGGER.debug("Home Assistant secrets not exists") + return + + # Read secrets + try: + yaml = YAML() + self.secrets = await self.sys_run_in_executor(yaml.load, self.path_secrets) + except YAMLError as err: + _LOGGER.error("Can't process Home Assistant secrets: %s", err) + else: + _LOGGER.info("Load Home Assistant secrets: %s", len(self.secrets)) diff --git a/hassio/tasks.py b/hassio/tasks.py index fc1dff749..36bc8e3ca 100644 --- a/hassio/tasks.py +++ b/hassio/tasks.py @@ -19,6 +19,7 @@ RUN_RELOAD_SNAPSHOTS = 72000 RUN_RELOAD_HOST = 72000 RUN_RELOAD_UPDATER = 7200 RUN_RELOAD_INGRESS = 930 +RUN_RELOAD_SECRETS = 900 RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15 RUN_WATCHDOG_HOMEASSISTANT_API = 300 @@ -77,6 +78,11 @@ class Tasks(CoreSysAttributes): self.sys_ingress.reload, RUN_RELOAD_INGRESS ) ) + self.jobs.add( + self.sys_scheduler.register_task( + self.sys_secrets.reload, RUN_RELOAD_SECRETS + ) + ) # Watchdog self.jobs.add( diff --git a/requirements.txt b/requirements.txt index ec9f64288..49123e988 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,6 +10,7 @@ gitpython==3.0.2 packaging==19.1 pytz==2019.2 pyudev==0.21.0 +ruamel.yaml==0.15.100 uvloop==0.12.2 voluptuous==0.11.7 ptvsd==4.3.2