Add secrets support for options (#1283)

* Add secrets API

* Don't expose secrets
This commit is contained in:
Pascal Vizeli 2019-09-11 16:29:34 +02:00 committed by GitHub
parent a308ea6927
commit 02d4045ec3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 118 additions and 17 deletions

View File

@ -35,7 +35,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Install Python dependencies from requirements.txt if it exists # Install Python dependencies from requirements.txt if it exists
COPY requirements.txt requirements_tests.txt ./ 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 \ && pip3 install black tox \
&& rm -f requirements.txt requirements_tests.txt && rm -f requirements.txt requirements_tests.txt

View File

@ -438,7 +438,9 @@ class Addon(AddonModel):
options = {**self.persist[ATTR_OPTIONS], **default_options} options = {**self.persist[ATTR_OPTIONS], **default_options}
# create voluptuous # 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 # validate
try: try:

View File

@ -461,7 +461,7 @@ class AddonModel(CoreSysAttributes):
if isinstance(raw_schema, bool): if isinstance(raw_schema, bool):
return vol.Schema(dict) 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): def __eq__(self, other):
"""Compaired add-on objects.""" """Compaired add-on objects."""

View File

@ -109,16 +109,21 @@ V_EMAIL = "email"
V_URL = "url" V_URL = "url"
V_PORT = "port" V_PORT = "port"
V_MATCH = "match" V_MATCH = "match"
V_LIST = "list"
RE_SCHEMA_ELEMENT = re.compile( RE_SCHEMA_ELEMENT = re.compile(
r"^(?:" r"^(?:"
r"|str|bool|email|url|port" r"|bool|email|url|port"
r"|str(?:\((?P<s_min>\d+)?,(?P<s_max>\d+)?\))?"
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?" r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?" r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
r"|match\((?P<match>.*)\)" r"|match\((?P<match>.*)\)"
r"|list\((?P<list>.+)\)"
r")\??$" 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 = re.compile(r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)$")
RE_DOCKER_IMAGE_BUILD = re.compile( RE_DOCKER_IMAGE_BUILD = re.compile(
r"^([a-zA-Z\-\.:\d{}]+/)*?([\-\w{}]+)/([\-\w{}]+)(:[\.\-\w{}]+)?$" 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.""" """Validate schema."""
def validate(struct): def validate(struct):
@ -323,13 +328,13 @@ def validate_options(raw_schema):
try: try:
if isinstance(typ, list): if isinstance(typ, list):
# nested value 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): elif isinstance(typ, dict):
# nested value dict # nested value dict
options[key] = _nested_validate_dict(typ, value, key) options[key] = _nested_validate_dict(coresys, typ, value, key)
else: else:
# normal value # normal value
options[key] = _single_validate(typ, value, key) options[key] = _single_validate(coresys, typ, value, key)
except (IndexError, KeyError): except (IndexError, KeyError):
raise vol.Invalid(f"Type error for {key}") from None 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=no-value-for-parameter
# pylint: disable=inconsistent-return-statements # pylint: disable=inconsistent-return-statements
def _single_validate(typ, value, key): def _single_validate(coresys, typ, value, key):
"""Validate a single element.""" """Validate a single element."""
# if required argument # if required argument
if value is None: if value is None:
raise vol.Invalid(f"Missing required option '{key}'") 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 # parse extend data from type
match = RE_SCHEMA_ELEMENT.match(typ) match = RE_SCHEMA_ELEMENT.match(typ)
# prepare range # prepare range
range_args = {} 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) group_value = match.group(group_name)
if group_value: if group_value:
range_args[group_name[2:]] = float(group_value) range_args[group_name[2:]] = float(group_value)
if typ.startswith(V_STR): if typ.startswith(V_STR):
return str(value) return vol.All(str(value), vol.Range(**range_args))(value)
elif typ.startswith(V_INT): elif typ.startswith(V_INT):
return vol.All(vol.Coerce(int), vol.Range(**range_args))(value) return vol.All(vol.Coerce(int), vol.Range(**range_args))(value)
elif typ.startswith(V_FLOAT): elif typ.startswith(V_FLOAT):
@ -373,26 +383,28 @@ def _single_validate(typ, value, key):
return NETWORK_PORT(value) return NETWORK_PORT(value)
elif typ.startswith(V_MATCH): elif typ.startswith(V_MATCH):
return vol.Match(match.group("match"))(str(value)) 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}") 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.""" """Validate nested items."""
options = [] options = []
for element in data_list: for element in data_list:
# Nested? # Nested?
if isinstance(typ, dict): 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) options.append(c_options)
else: else:
options.append(_single_validate(typ, element, key)) options.append(_single_validate(coresys, typ, element, key))
return options return options
def _nested_validate_dict(typ, data_dict, key): def _nested_validate_dict(coresys, typ, data_dict, key):
"""Validate nested items.""" """Validate nested items."""
options = {} options = {}
@ -404,9 +416,11 @@ def _nested_validate_dict(typ, data_dict, key):
# Nested? # Nested?
if isinstance(typ[c_key], list): 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: 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) _check_missing_options(typ, options, key)
return options return options

View File

@ -40,6 +40,7 @@ NO_SECURITY_CHECK = re.compile(
ADDONS_API_BYPASS = re.compile( ADDONS_API_BYPASS = re.compile(
r"^(?:" r"^(?:"
r"|/addons/self/(?!security|update)[^/]+" r"|/addons/self/(?!security|update)[^/]+"
r"|/secrets/.+"
r"|/info" r"|/info"
r"|/hardware/trigger" r"|/hardware/trigger"
r"|/services.*" r"|/services.*"

View File

@ -27,6 +27,7 @@ from .store import StoreManager
from .supervisor import Supervisor from .supervisor import Supervisor
from .tasks import Tasks from .tasks import Tasks
from .updater import Updater from .updater import Updater
from .secrets import SecretsManager
from .utils.dt import fetch_timezone from .utils.dt import fetch_timezone
_LOGGER: logging.Logger = logging.getLogger(__name__) _LOGGER: logging.Logger = logging.getLogger(__name__)
@ -61,6 +62,7 @@ async def initialize_coresys():
coresys.discovery = Discovery(coresys) coresys.discovery = Discovery(coresys)
coresys.dbus = DBusManager(coresys) coresys.dbus = DBusManager(coresys)
coresys.hassos = HassOS(coresys) coresys.hassos = HassOS(coresys)
coresys.secrets = SecretsManager(coresys)
# bootstrap config # bootstrap config
initialize_system_data(coresys) initialize_system_data(coresys)

View File

@ -220,6 +220,7 @@ ATTR_DNS = "dns"
ATTR_SERVERS = "servers" ATTR_SERVERS = "servers"
ATTR_LOCALS = "locals" ATTR_LOCALS = "locals"
ATTR_UDEV = "udev" ATTR_UDEV = "udev"
ATTR_VALUE = "value"
PROVIDE_SERVICE = "provide" PROVIDE_SERVICE = "provide"
NEED_SERVICE = "need" NEED_SERVICE = "need"

View File

@ -72,6 +72,9 @@ class HassIO(CoreSysAttributes):
# Load ingress # Load ingress
await self.sys_ingress.load() await self.sys_ingress.load()
# Load secrets
await self.sys_secrets.load()
async def start(self): async def start(self):
"""Start Hass.io orchestration.""" """Start Hass.io orchestration."""
await self.sys_api.start() await self.sys_api.start()

View File

@ -24,6 +24,7 @@ if TYPE_CHECKING:
from .homeassistant import HomeAssistant from .homeassistant import HomeAssistant
from .host import HostManager from .host import HostManager
from .ingress import Ingress from .ingress import Ingress
from .secrets import SecretsManager
from .services import ServiceManager from .services import ServiceManager
from .snapshots import SnapshotManager from .snapshots import SnapshotManager
from .supervisor import Supervisor from .supervisor import Supervisor
@ -70,6 +71,7 @@ class CoreSys:
self._dbus: Optional[DBusManager] = None self._dbus: Optional[DBusManager] = None
self._hassos: Optional[HassOS] = None self._hassos: Optional[HassOS] = None
self._services: Optional[ServiceManager] = None self._services: Optional[ServiceManager] = None
self._secrets: Optional[SecretsManager] = None
self._store: Optional[StoreManager] = None self._store: Optional[StoreManager] = None
self._discovery: Optional[Discovery] = None self._discovery: Optional[Discovery] = None
@ -209,6 +211,18 @@ class CoreSys:
raise RuntimeError("Updater already set!") raise RuntimeError("Updater already set!")
self._updater = value 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 @property
def addons(self) -> AddonManager: def addons(self) -> AddonManager:
"""Return AddonManager object.""" """Return AddonManager object."""
@ -437,6 +451,11 @@ class CoreSysAttributes:
"""Return Updater object.""" """Return Updater object."""
return self.coresys.updater return self.coresys.updater
@property
def sys_secrets(self) -> SecretsManager:
"""Return SecretsManager object."""
return self.coresys.secrets
@property @property
def sys_addons(self) -> AddonManager: def sys_addons(self) -> AddonManager:
"""Return AddonManager object.""" """Return AddonManager object."""

52
hassio/secrets.py Normal file
View File

@ -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))

View File

@ -19,6 +19,7 @@ RUN_RELOAD_SNAPSHOTS = 72000
RUN_RELOAD_HOST = 72000 RUN_RELOAD_HOST = 72000
RUN_RELOAD_UPDATER = 7200 RUN_RELOAD_UPDATER = 7200
RUN_RELOAD_INGRESS = 930 RUN_RELOAD_INGRESS = 930
RUN_RELOAD_SECRETS = 900
RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15 RUN_WATCHDOG_HOMEASSISTANT_DOCKER = 15
RUN_WATCHDOG_HOMEASSISTANT_API = 300 RUN_WATCHDOG_HOMEASSISTANT_API = 300
@ -77,6 +78,11 @@ class Tasks(CoreSysAttributes):
self.sys_ingress.reload, RUN_RELOAD_INGRESS self.sys_ingress.reload, RUN_RELOAD_INGRESS
) )
) )
self.jobs.add(
self.sys_scheduler.register_task(
self.sys_secrets.reload, RUN_RELOAD_SECRETS
)
)
# Watchdog # Watchdog
self.jobs.add( self.jobs.add(

View File

@ -10,6 +10,7 @@ gitpython==3.0.2
packaging==19.1 packaging==19.1
pytz==2019.2 pytz==2019.2
pyudev==0.21.0 pyudev==0.21.0
ruamel.yaml==0.15.100
uvloop==0.12.2 uvloop==0.12.2
voluptuous==0.11.7 voluptuous==0.11.7
ptvsd==4.3.2 ptvsd==4.3.2