mirror of
https://github.com/home-assistant/supervisor.git
synced 2025-07-07 17:26:32 +00:00
Add secrets support for options (#1283)
* Add secrets API * Don't expose secrets
This commit is contained in:
parent
a308ea6927
commit
02d4045ec3
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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."""
|
||||
|
@ -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<s_min>\d+)?,(?P<s_max>\d+)?\))?"
|
||||
r"|int(?:\((?P<i_min>\d+)?,(?P<i_max>\d+)?\))?"
|
||||
r"|float(?:\((?P<f_min>[\d\.]+)?,(?P<f_max>[\d\.]+)?\))?"
|
||||
r"|match\((?P<match>.*)\)"
|
||||
r"|list\((?P<list>.+)\)"
|
||||
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
|
||||
|
@ -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.*"
|
||||
|
@ -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)
|
||||
|
@ -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"
|
||||
|
@ -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()
|
||||
|
@ -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."""
|
||||
|
52
hassio/secrets.py
Normal file
52
hassio/secrets.py
Normal 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))
|
@ -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(
|
||||
|
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user